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.
+ 
+ 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
+
+
+ This is an example custom page.
+ Feel free to update this to your needs.
+
+
+ Section 2
+
+ Any HTML content can go here.
+
+ Here is an image:
+
+
+
+
+
+ Click me!
+
+
+
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:
+
+
+
+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 && (
setShowSubscribeModal(true)}>
SUBSCRIBE
- )} */}
+ )}
)}
- {/*
- // Note: Removing sections for GGv2 demo.
setShowSubscribeModal(false)}
apiProduct={apiProduct}
- /> */}
+ />
) : undefined
}
diff --git a/projects/ui/src/Components/ApiDetails/gloo-gateway-components/DocsTab/DocsTabContent.tsx b/projects/ui/src/Components/ApiDetails/gloo-gateway-components/DocsTab/DocsTabContent.tsx
index dd968a04..a1d83b3d 100644
--- a/projects/ui/src/Components/ApiDetails/gloo-gateway-components/DocsTab/DocsTabContent.tsx
+++ b/projects/ui/src/Components/ApiDetails/gloo-gateway-components/DocsTab/DocsTabContent.tsx
@@ -1,25 +1,7 @@
-import { css } from "@emotion/react";
-import styled from "@emotion/styled";
import { Box } from "@mantine/core";
-import Markdown from "react-markdown";
-import remarkGfm from "remark-gfm";
import { ApiVersion } from "../../../../Apis/api-types";
import { CardStyles } from "../../../../Styles/shared/Card.style";
-
-const MarkdownOuterContainer = styled.div(
- ({ theme }) => css`
- padding: 30px;
- * {
- margin: revert;
- padding: revert;
- font-family: revert;
- font-weight: revert;
- }
- blockquote p {
- color: ${theme.augustGrey};
- }
- `
-);
+import MarkdownRenderer from "../../../Common/MarkdownRenderer";
const DocsTabContent = ({
selectedApiVersion,
@@ -29,11 +11,7 @@ const DocsTabContent = ({
return (
-
-
- {selectedApiVersion.documentation}
-
-
+
);
diff --git a/projects/ui/src/Components/ApiDetails/gloo-gateway-components/SchemaTab/SchemaTabContent.tsx b/projects/ui/src/Components/ApiDetails/gloo-gateway-components/SchemaTab/SchemaTabContent.tsx
index 15bc5ae3..198596a6 100644
--- a/projects/ui/src/Components/ApiDetails/gloo-gateway-components/SchemaTab/SchemaTabContent.tsx
+++ b/projects/ui/src/Components/ApiDetails/gloo-gateway-components/SchemaTab/SchemaTabContent.tsx
@@ -1,20 +1,14 @@
-import { Box } from "@mantine/core";
-import {
- ApiProductSummary,
- ApiVersion,
- ApiVersionSchema,
-} from "../../../../Apis/api-types";
+import { Box, Code } from "@mantine/core";
+import { ApiVersion, ApiVersionSchema } from "../../../../Apis/api-types";
import { EmptyData } from "../../../Common/EmptyData";
import { ErrorBoundary } from "../../../Common/ErrorBoundary";
import { ApiSchemaDisplay } from "./ApiSchemaDisplay";
const SchemaTabContent = ({
- apiProduct,
selectedApiVersion,
apiProductVersions,
apiVersionSpec,
}: {
- apiProduct: ApiProductSummary;
selectedApiVersion: ApiVersion;
apiProductVersions: ApiVersion[];
apiVersionSpec: ApiVersionSchema | undefined;
@@ -22,9 +16,10 @@ const SchemaTabContent = ({
if (!apiProductVersions.length) {
return (
-
+
+ Add a version to the spec.versions
field of this{" "}
+ ApiProduct
for data to appear.
+
);
}
@@ -36,9 +31,13 @@ const SchemaTabContent = ({
// There is a selected API version, but no schema.
return (
-
+
+ The schema was not returned for this ApiProduct
version.
+
+ Verify that your OpenApi spec was generated correctly in the
+ corresponding ApiDoc
resource for this{" "}
+ Service
.
+
);
}
diff --git a/projects/ui/src/Components/ApiDetails/shared/ApiSchema/swagger/SwaggerDisplay.tsx b/projects/ui/src/Components/ApiDetails/shared/ApiSchema/swagger/SwaggerDisplay.tsx
index 653bd9a0..374e6af7 100644
--- a/projects/ui/src/Components/ApiDetails/shared/ApiSchema/swagger/SwaggerDisplay.tsx
+++ b/projects/ui/src/Components/ApiDetails/shared/ApiSchema/swagger/SwaggerDisplay.tsx
@@ -1,8 +1,14 @@
-import { useEffect, useState } from "react";
+import { useContext, useEffect, useState } from "react";
import SwaggerUIConstructor from "swagger-ui";
import "swagger-ui/dist/swagger-ui.css";
import { ApiVersionSchema } from "../../../../../Apis/api-types";
-import { swaggerConfigURL } from "../../../../../user_variables.tmplr";
+import { AuthContext } from "../../../../../Context/AuthContext";
+import {
+ swaggerConfigURL,
+ swaggerPrefillApiKey,
+ swaggerPrefillBasic,
+ swaggerPrefillOauth,
+} from "../../../../../user_variables.tmplr";
import { SwaggerDisplayContainer } from "./SwaggerDisplay.style";
const sanitize = (id: string) => id.replaceAll(".", "-");
@@ -14,6 +20,8 @@ export function SwaggerDisplay({
apiVersionSpec: ApiVersionSchema | undefined;
apiVersionId: string;
}) {
+ const { tokensResponse } = useContext(AuthContext);
+
// The sanitized dom_id, where all periods are replaced with dashes. This fixes issues where Swagger tries
// doing a `querySelector` which fails, due to it treating the period as a class selector, and not part of the ID itself.
const [sanitizedDomId, setSanitizedDomId] = useState(
@@ -27,13 +35,47 @@ export function SwaggerDisplay({
}, [apiVersionId, sanitizedDomId]);
useEffect(() => {
- SwaggerUIConstructor({
+ const swaggerInstance = SwaggerUIConstructor({
spec: apiVersionSpec,
dom_id: `#display-swagger-${sanitizedDomId}`,
withCredentials: true,
deepLinking: true,
configUrl: swaggerConfigURL !== "" ? swaggerConfigURL : undefined,
});
+
+ // Here we pass through user supplied configuration for each of these Swagger UI instance methods:
+ // https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/configuration.md#instance-methods
+
+ // API KEY AUTH
+ if (swaggerPrefillApiKey !== undefined) {
+ let apiKeyValue = swaggerPrefillApiKey.apiKeyValue;
+ if (!!tokensResponse?.access_token) {
+ // Try to find & replace the "{{USER_TOKEN}}" string with this user's access token.
+ // This is documented in the README.md.
+ apiKeyValue = apiKeyValue.replace(
+ "{{USER_TOKEN}}",
+ tokensResponse.access_token
+ );
+ }
+ swaggerInstance.preauthorizeApiKey(
+ swaggerPrefillApiKey.authDefinitionKey,
+ apiKeyValue
+ );
+ }
+
+ // OAUTH
+ if (swaggerPrefillOauth !== undefined) {
+ swaggerInstance.initOAuth(swaggerPrefillOauth);
+ }
+
+ // BASIC AUTH
+ if (swaggerPrefillBasic !== undefined) {
+ swaggerInstance.preauthorizeBasic(
+ swaggerPrefillBasic.authDefinitionKey,
+ swaggerPrefillBasic.username,
+ swaggerPrefillBasic.password
+ );
+ }
}, [sanitizedDomId, apiVersionSpec]);
return (
diff --git a/projects/ui/src/Components/Apis/ApisPage.tsx b/projects/ui/src/Components/Apis/ApisPage.tsx
index 0c514236..d4591171 100644
--- a/projects/ui/src/Components/Apis/ApisPage.tsx
+++ b/projects/ui/src/Components/Apis/ApisPage.tsx
@@ -1,5 +1,6 @@
import { useContext } from "react";
import { AppContext } from "../../Context/AppContext";
+import { EmptyApisPage } from "./EmptyApisPage";
import { GG_ApisPage } from "./gloo-gateway-components/GG_ApisPage";
import { GMG_ApisPage } from "./gloo-mesh-gateway-components/GMG_ApisPage";
@@ -12,5 +13,5 @@ export function ApisPage() {
if (portalServerType === "gloo-mesh-gateway") {
return ;
}
- return null;
+ return ;
}
diff --git a/projects/ui/src/Components/Apis/EmptyApisPage.tsx b/projects/ui/src/Components/Apis/EmptyApisPage.tsx
new file mode 100644
index 00000000..6feb91f0
--- /dev/null
+++ b/projects/ui/src/Components/Apis/EmptyApisPage.tsx
@@ -0,0 +1,42 @@
+import { Code } from "@mantine/core";
+import { Icon } from "../../Assets/Icons";
+import { useIsLoggedIn } from "../../Context/AuthContext";
+import { BannerHeading } from "../Common/Banner/BannerHeading";
+import { BannerHeadingTitle } from "../Common/Banner/BannerHeadingTitle";
+import { EmptyData } from "../Common/EmptyData";
+import { PageContainer } from "../Common/PageContainer";
+import { StyledApisListMain } from "./gloo-mesh-gateway-components/ApisPage.style";
+
+export function EmptyApisPage() {
+ return (
+
+ } />}
+ description={
+ "Browse the list of APIs and documentation in this portal. From here you can get the information you need to make API calls."
+ }
+ breadcrumbItems={[{ label: "Home", link: "/" }, { label: "APIs" }]}
+ />
+
+
+
+
+ );
+}
+
+export const EmptyApisPageContent = () => {
+ const isLoggedIn = useIsLoggedIn();
+
+ if (!!isLoggedIn)
+ return No API Products have been created. ;
+ return (
+
+
+ To view API Products in private Portals, please log in.
+
+ To view API Products in public Portals, the Portal resource must have{" "}
+ spec.visibility.public = true
.
+
+
+ );
+};
diff --git a/projects/ui/src/Components/Apis/gloo-gateway-components/ApisTab/ApisFilter.tsx b/projects/ui/src/Components/Apis/gloo-gateway-components/ApisTab/ApisFilter.tsx
index 41fa3ebb..f977270c 100644
--- a/projects/ui/src/Components/Apis/gloo-gateway-components/ApisTab/ApisFilter.tsx
+++ b/projects/ui/src/Components/Apis/gloo-gateway-components/ApisTab/ApisFilter.tsx
@@ -14,7 +14,9 @@ import GridListToggle from "../../../Common/GridListToggle";
export function ApisFilter({ filters }: { filters: FiltrationProp }) {
const { preferGridView, setPreferGridView } = useContext(AppContext);
- const [pairFilter, setPairFilter] = useState({
+ const [pairFilter, setPairFilter] = useState<
+ KeyValuePair & { value: string }
+ >({
pairKey: "",
value: "",
});
diff --git a/projects/ui/src/Components/Apis/gloo-gateway-components/ApisTab/ApisList.tsx b/projects/ui/src/Components/Apis/gloo-gateway-components/ApisTab/ApisList.tsx
index 7e8d8f4d..51b8459f 100644
--- a/projects/ui/src/Components/Apis/gloo-gateway-components/ApisTab/ApisList.tsx
+++ b/projects/ui/src/Components/Apis/gloo-gateway-components/ApisTab/ApisList.tsx
@@ -78,8 +78,13 @@ export function ApisList({
if (apiProductsList === undefined) {
return ;
}
+ if (!apiProductsList.length) {
+ return ;
+ }
if (!filteredApiProductsList.length) {
- return ;
+ return (
+
+ );
}
if (preferGridView) {
return (
diff --git a/projects/ui/src/Components/Apis/gloo-gateway-components/GG_ApisPage.tsx b/projects/ui/src/Components/Apis/gloo-gateway-components/GG_ApisPage.tsx
index 3a453cde..e0925317 100644
--- a/projects/ui/src/Components/Apis/gloo-gateway-components/GG_ApisPage.tsx
+++ b/projects/ui/src/Components/Apis/gloo-gateway-components/GG_ApisPage.tsx
@@ -1,120 +1,137 @@
-import { Box } from "@mantine/core";
+import { Box, Flex, Loader, Tabs } from "@mantine/core";
+import { useEffect, useState } from "react";
+import { di } from "react-magnetic-di";
+import { useLocation, useNavigate } from "react-router-dom";
+import {
+ isSubscriptionsListError,
+ SubscriptionStatus,
+} from "../../../Apis/api-types";
+import {
+ useListApiProducts,
+ useListSubscriptionsForStatus,
+} from "../../../Apis/gg_hooks";
import { Icon } from "../../../Assets/Icons";
-import { apisImageURL } from "../../../user_variables.tmplr";
+import { colors } from "../../../Styles";
import { BannerHeading } from "../../Common/Banner/BannerHeading";
import { BannerHeadingTitle } from "../../Common/Banner/BannerHeadingTitle";
+import { Loading } from "../../Common/Loading";
import { PageContainer } from "../../Common/PageContainer";
+import { ApisPageStyles } from "../ApisPage.style";
+import { EmptyApisPageContent } from "../EmptyApisPage";
import { ApisTabContent } from "./ApisTab/ApisTabContent";
+import PendingSubscriptionsTabContent from "./PendingSubscriptionsTab/PendingSubscriptionsTabContent";
-// const URL_SEARCH_PARAM_TAB_KEY = "tab";
-// const tabValues = {
-// APIS: "apis",
-// SUBS: "subs",
-// };
-// const defaultTabValue = tabValues.APIS;
+const URL_SEARCH_PARAM_TAB_KEY = "tab";
+const tabValues = {
+ APIS: "apis",
+ SUBS: "subs",
+};
+const defaultTabValue = tabValues.APIS;
export function GG_ApisPage() {
- // di(useListApiProducts, useListSubscriptionsForStatus);
- // const { isLoading: isLoadingApiProducts } = useListApiProducts();
-
- // Note: Removing sections for GGv2 demo.
-
- // const {
- // isLoading: isLoadingSubscriptions,
- // data: subscriptions,
- // error: subscriptionsErr,
- // } = useListSubscriptionsForStatus(SubscriptionStatus.PENDING);
- // const subscriptionsError =
- // !!subscriptionsErr ||
- // isSubscriptionsListError(subscriptions) ||
- // !Array.isArray(subscriptions);
- // const isLoading = isLoadingApiProducts || isLoadingSubscriptions;
- // const isLoading = isLoadingApiProducts;
-
- //
- // Tab navigation
- //
- // const navigate = useNavigate();
- // const location = useLocation();
- // const [tab, setTab] = useState(
- // new URLSearchParams(location.search).get(URL_SEARCH_PARAM_TAB_KEY) ??
- // defaultTabValue
- // );
- // // Update the URL when the selected tab changes.
- // useEffect(() => {
- // const newSearchParams = new URLSearchParams(location.search);
- // if (!!tab) {
- // newSearchParams.set(URL_SEARCH_PARAM_TAB_KEY, tab);
- // }
- // navigate(location.pathname + `?${newSearchParams.toString()}`, {
- // replace: true,
- // });
- // }, [tab, location.search]);
-
//
// Render
//
return (
} />}
description={
"Browse the list of APIs and documentation in this portal. From here you can get the information you need to make API calls."
}
breadcrumbItems={[{ label: "Home", link: "/" }, { label: "APIs" }]}
/>
-
- {/* {isLoading ? (
- // Make sure the APIs are finished loading since they are a dependency of both tabs.
-
- ) : (
- ) : subscriptionsError ? (
- // // If there was a subscriptions error message, don't show the subscriptions.
-*/}
-
- {/* setTab(t ?? defaultTabValue)}>
- {/*
-
- Tab Titles
- * /}
-
- APIs
-
-
- Pending API Subscriptions
- {isLoadingSubscriptions || !subscriptions ? (
-
-
-
- ) : (
- subscriptions.length > 0 && (
-
- {subscriptions.length}
-
- )
- )}
-
-
-
- {/*
-
- Tab Content
- * /}
-
-
-
- {/*
-
-
- * /}
-
- )}*/}
+
);
}
+
+function GG_ApisPageContent() {
+ di(useListApiProducts, useListSubscriptionsForStatus);
+ const { data: apiProducts, error: apiProductsError } = useListApiProducts();
+
+ const { data: subscriptions, error: subscriptionsErr } =
+ useListSubscriptionsForStatus(SubscriptionStatus.PENDING);
+ const subscriptionsError =
+ !!subscriptionsErr ||
+ isSubscriptionsListError(subscriptions) ||
+ !Array.isArray(subscriptions);
+ const isLoadingSubscriptions =
+ subscriptions === undefined && !subscriptionsErr;
+
+ const isLoading = apiProducts === undefined || subscriptions === undefined;
+
+ //
+ // Tab navigation
+ //
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [tab, setTab] = useState(
+ new URLSearchParams(location.search).get(URL_SEARCH_PARAM_TAB_KEY) ??
+ defaultTabValue
+ );
+ // Update the URL when the selected tab changes.
+ useEffect(() => {
+ const newSearchParams = new URLSearchParams(location.search);
+ if (!!tab) {
+ newSearchParams.set(URL_SEARCH_PARAM_TAB_KEY, tab);
+ }
+ navigate(location.pathname + `?${newSearchParams.toString()}`, {
+ replace: true,
+ });
+ }, [tab, location.search]);
+
+ if (!!apiProductsError) {
+ return ;
+ }
+ if (subscriptionsError) {
+ // If there was a subscriptions error message, don't show the subscriptions.
+ return ;
+ }
+ if (isLoading) {
+ // Make sure the APIs are finished loading since they are a dependency of both tabs.
+ return ;
+ }
+ return (
+ setTab(t ?? defaultTabValue)}>
+ {/*
+
+ Tab Titles
+ */}
+
+ APIs
+
+
+ Pending API Subscriptions
+ {!subscriptions ? (
+
+
+
+ ) : (
+ subscriptions.length > 0 && (
+
+ {subscriptions.length}
+
+ )
+ )}
+
+
+
+ {/*
+
+ Tab Content
+ */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/ApisFilter.tsx b/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/ApisFilter.tsx
index 9a31774a..826da151 100644
--- a/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/ApisFilter.tsx
+++ b/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/ApisFilter.tsx
@@ -122,7 +122,9 @@ type ApisFiltrationProp = {
};
export function ApisFilter({ filters }: { filters: ApisFiltrationProp }) {
- const [pairFilter, setPairFilter] = useState({
+ const [pairFilter, setPairFilter] = useState<
+ KeyValuePair & { value: string }
+ >({
pairKey: "",
value: "",
});
diff --git a/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/ApisList.tsx b/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/ApisList.tsx
index 46d5f7c0..bd193c01 100644
--- a/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/ApisList.tsx
+++ b/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/ApisList.tsx
@@ -72,7 +72,7 @@ export function ApisList({
}
if (!displayedApisList.length) {
- return ;
+ return ;
}
return (
<>
diff --git a/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/ApisPage.style.tsx b/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/ApisPage.style.tsx
index 0ad543cb..4dfe0f3c 100644
--- a/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/ApisPage.style.tsx
+++ b/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/ApisPage.style.tsx
@@ -455,7 +455,9 @@ export const StyledApisListMain = styled.main(
// Shared styles
.apiListCard,
.apiGridList a.apiGridCard {
- transition: 0.1s box-shadow, 0.1s outline-color;
+ transition:
+ 0.1s box-shadow,
+ 0.1s outline-color;
outline-offset: 2px;
outline: 2px solid transparent;
&:hover {
diff --git a/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/GMG_ApisPage.tsx b/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/GMG_ApisPage.tsx
index 1b3f1c51..2a316934 100644
--- a/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/GMG_ApisPage.tsx
+++ b/projects/ui/src/Components/Apis/gloo-mesh-gateway-components/GMG_ApisPage.tsx
@@ -1,6 +1,5 @@
import { useState } from "react";
import { Icon } from "../../../Assets/Icons";
-import { apisImageURL } from "../../../user_variables.tmplr";
import { BannerHeading } from "../../Common/Banner/BannerHeading";
import { BannerHeadingTitle } from "../../Common/Banner/BannerHeadingTitle";
import { ErrorBoundary } from "../../Common/ErrorBoundary";
@@ -30,7 +29,6 @@ export function GMG_ApisPage() {
return (
} />}
description={
"Browse the list of APIs and documentation in this portal. From here you can get the information you need to make API calls."
diff --git a/projects/ui/src/Components/App.tsx b/projects/ui/src/Components/App.tsx
index 9dc89cb4..4555b882 100644
--- a/projects/ui/src/Components/App.tsx
+++ b/projects/ui/src/Components/App.tsx
@@ -1,9 +1,11 @@
import { Global, ThemeProvider } from "@emotion/react";
import { MantineProvider } from "@mantine/core";
import { AppContextProvider } from "../Context/AppContext";
+import { AppUtilsContextProvider } from "../Context/AppUtilsContext";
import { defaultTheme, globalStyles } from "../Styles";
import { mantineThemeOverride } from "../Styles/global-styles/mantine-theme";
import PortalServerTypeChecker from "../Utility/PortalServerTypeChecker";
+import UserChecker from "../Utility/UserChecker";
import AppContent from "./AppContent";
/**
@@ -15,17 +17,20 @@ export function App() {
return (
-
-
+
+
+
+
-
-
-
-
+
+
+
+
+
);
}
diff --git a/projects/ui/src/Components/AppContent.tsx b/projects/ui/src/Components/AppContent.tsx
index 353d8ab8..e167e06e 100644
--- a/projects/ui/src/Components/AppContent.tsx
+++ b/projects/ui/src/Components/AppContent.tsx
@@ -2,10 +2,8 @@ import { css } from "@emotion/react";
import styled from "@emotion/styled";
import { useContext } from "react";
import { AppContext } from "../Context/AppContext";
-import { appliedOidcAuthCodeConfig } from "../user_variables.tmplr";
import AppContentRoutes from "./AppContentRoutes";
-import { Header } from "./Structure/Header";
-import OidcAuthCodeHeaderVariant from "./Structure/OidcAuthCodeHeaderVariant/OidcAuthCodeHeaderVariant";
+import Header from "./Structure/Header";
export const StyledAppContainer = styled.div(
({ theme }) => css`
@@ -32,7 +30,7 @@ function AppContent() {
*/
return (
- {!!appliedOidcAuthCodeConfig ? : }
+
);
diff --git a/projects/ui/src/Components/AppContentRoutes.tsx b/projects/ui/src/Components/AppContentRoutes.tsx
index 9aa7d2e3..84b1dfc4 100644
--- a/projects/ui/src/Components/AppContentRoutes.tsx
+++ b/projects/ui/src/Components/AppContentRoutes.tsx
@@ -2,23 +2,28 @@ import styled from "@emotion/styled";
import { useContext } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { AppContext } from "../Context/AppContext";
+import { useIsLoggedIn } from "../Context/AuthContext";
import {
+ customPages,
oidcAuthCodeConfigCallbackPath,
oidcAuthCodeConfigLogoutPath,
} from "../user_variables.tmplr";
+import { getCustomPagePath } from "../Utility/utility";
+import AdminAppsPage from "./AdminApps/AdminAppsPage";
+import AdminSubscriptionsPage from "./AdminSubscriptions/AdminSubscriptionsPage";
+import AdminTeamsPage from "./AdminTeams/AdminTeamsPage";
import { ApiDetailsPage } from "./ApiDetails/ApiDetailsPage";
import { ApisPage } from "./Apis/ApisPage";
+import { AppsPage } from "./Apps/AppsPage";
+import AppDetailsPage from "./Apps/Details/AppDetailsPage";
import { ErrorBoundary } from "./Common/ErrorBoundary";
import LoggedOut from "./Common/LoggedOut";
+import CustomPageLanding from "./CustomPage/CustomPageLanding";
import { HomePage } from "./Home/HomePage";
import { Footer } from "./Structure/Footer";
+import TeamDetailsPage from "./Teams/Details/TeamDetailsPage";
+import { TeamsPage } from "./Teams/TeamsPage";
import { UsagePlansPage } from "./UsagePlans/UsagePlansPage";
-// import AdminSubscriptionsPage from "./AdminSubscriptions/AdminSubscriptionsPage";
-// import AdminTeamsPage from "./AdminTeams/AdminTeamsPage";
-// import { AppsPage } from "./Apps/AppsPage";
-// import AppDetailsPage from "./Apps/Details/AppDetailsPage";
-// import TeamDetailsPage from "./Teams/Details/TeamDetailsPage";
-// import { TeamsPage } from "./Teams/TeamsPage";
const MainContentContainer = styled.div`
grid-area: contentcontainer;
@@ -42,10 +47,14 @@ const MainContentContainer = styled.div`
* know the area that failed.
**/
function AppContentRoutes() {
+ const isLoggedIn = useIsLoggedIn();
const { portalServerType } = useContext(AppContext);
return (
+ {/*
+ // region Shared
+ */}
}
/>
- {/* // Note: Removing sections for GGv2 demo.
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
-
-
-
- }
- />
- {/*
-
- // Admin Routes
- * /}
-
-
-
- }
- />
-
-
-
- }
- />*/}
{/*
-
- Gloo Mesh Gateway Routes
+ // region GG
*/}
- {portalServerType === "gloo-mesh-gateway" && (
+ {portalServerType === "gloo-gateway" && isLoggedIn && (
<>
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ {/*
+
+ Admin Routes
+ */}
+
+
+
+ }
+ />
+
-
+
+
}
/>
-
+
+
}
/>
>
)}
+ {/*
+
+ // region GMG
+ */}
+ {portalServerType === "gloo-mesh-gateway" && (
+ <>
+ {isLoggedIn && (
+ <>
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ >
+ )}
+ >
+ )}
+ {customPages.map((page) => (
+
+
+
+ }
+ />
+ ))}
diff --git a/projects/ui/src/Components/Apps/AppsPage.tsx b/projects/ui/src/Components/Apps/AppsPage.tsx
index 7375abc1..b654cf99 100644
--- a/projects/ui/src/Components/Apps/AppsPage.tsx
+++ b/projects/ui/src/Components/Apps/AppsPage.tsx
@@ -1,6 +1,7 @@
import { Box } from "@mantine/core";
import { useState } from "react";
import { Icon } from "../../Assets/Icons";
+import { useIsAdmin } from "../../Context/AuthContext";
import { BannerHeading } from "../Common/Banner/BannerHeading";
import { BannerHeadingTitle } from "../Common/Banner/BannerHeadingTitle";
import { Button } from "../Common/Button";
@@ -10,6 +11,7 @@ import { AppsPageContent } from "./PageContent/AppsPageContent";
export function AppsPage() {
const [modalOpen, setModalOpen] = useState(false);
+ const isAdmin = useIsAdmin();
return (
@@ -18,9 +20,13 @@ export function AppsPage() {
description={
<>
Browse the list of Apps in this portal.
-
- setModalOpen(true)}>CREATE NEW APP
-
+ {!isAdmin && (
+
+ setModalOpen(true)}>
+ CREATE NEW APP
+
+
+ )}
>
}
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)}
+ />
+
+ ADD API Key
+
+
+
+
+ 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))}
+
+
+ setConfirmDeleteApiKey(apiKey)}
+ >
+ Delete
+
+
+
+
+ ) ?? []
+ );
+ });
+ }, [paginatedData]);
+
+ if (apiKeys === undefined) {
+ return ;
+ }
+ return (
+
+
+ API Keys
+ {!isAdmin && (
+
+ setShowAddApiKeySubSection(!showAddApiKeySubSection)
+ }
+ />
+ )}
+
+ setShowAddApiKeySubSection(false)}
+ />
+ {!apiKeys?.length ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+ Name
+ Created
+
+
+ Delete
+
+
+
+
+ {rows}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+ 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 ? (
+
+ setShowConfirmCreateCredentials(true)}>
+ Create OAuth Client
+
+
+ ) : credentialsToShow === undefined ? (
+
+
+
+
+
+ ) : (
+ <>
+
+ Client ID:
+
+ {credentialsToShow.idpClientId}
+
+
+
+
+ setCredentialIdToDelete(credentialsToShow.id)}
+ >
+ Delete OAuth Client
+
+
+ >
+ )}
+ {/*
+
+ // 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?
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ Create OAuth Client
+
+
+
+
+ );
+};
+
+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?
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ Delete 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?
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ Delete OAuth Client
+
+
+
+
+ );
+};
+
+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}
+
+ {
+ copyToClipboard(itemToCopyValue).then(() => {
+ toast.success(`Copied ${itemToCopyName} to clipboard`);
+ setHasCopiedItem(true);
+ });
+ }}
+ >
+
+ {itemToCopyValue}
+ {hasCopiedItem ? : }
+
+
+
+ }
+ 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}
+
+
+
+ {hasCopiedItem ? "Close" : `Copy the ${itemToCopyName} above`}
+
+
+
+
+ );
+};
+
+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 (
-
-
-
-
-
- {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({
-
+
>
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 = ({
setShowApproveSubModal(true)}
>
Approve
@@ -43,7 +39,7 @@ const SubscriptionInfoCardAdminFooter = ({
setShowRejectSubModal(true)}
>
Reject
@@ -52,7 +48,6 @@ const SubscriptionInfoCardAdminFooter = ({
setShowDeleteSubModal(true)}
>
Delete
diff --git a/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCardFooter.tsx b/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCardFooter.tsx
index 02dfaa1c..a0c4faa7 100644
--- a/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCardFooter.tsx
+++ b/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCardFooter.tsx
@@ -22,9 +22,12 @@ const SubscriptionInfoCardFooter = ({
subscriptionState: SubscriptionState;
}) => {
const [showDeleteSubModal, setShowDeleteSubModal] = useState(false);
- const canDeleteSubscription =
- subscriptionState === SubscriptionState.PENDING ||
- subscriptionState === SubscriptionState.ACCEPTED;
+ const canDeleteSubscription = true;
+ // We could limit what's able to be deleted like this.
+ // But it's possible that an admin accidentally rejects a subscription and a user wants to recreate it.
+ // const canDeleteSubscription =
+ // subscriptionState === SubscriptionState.PENDING ||
+ // subscriptionState === SubscriptionState.APPROVED;
//
// Render
diff --git a/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCardLink.tsx b/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCardLink.tsx
index bc08ac79..2e5ede95 100644
--- a/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCardLink.tsx
+++ b/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCardLink.tsx
@@ -1,7 +1,6 @@
import { Box, Tooltip } from "@mantine/core";
-import { useContext } from "react";
import { NavLink, useLocation } from "react-router-dom";
-import { AuthContext } from "../../../../Context/AuthContext";
+import { useIsAdmin } from "../../../../Context/AuthContext";
import { CardStyles } from "../../../../Styles/shared/Card.style";
import { UtilityStyles } from "../../../../Styles/shared/Utility.style";
@@ -16,7 +15,7 @@ export const SubscriptionInfoCardLink = ({
ItemIcon: React.FunctionComponent>;
item: undefined | null | T;
}) => {
- const { isAdmin } = useContext(AuthContext);
+ const isAdmin = useIsAdmin();
const location = useLocation();
const onLinkedPage = location.pathname === link;
diff --git a/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionsList.tsx b/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionsList.tsx
index 14328fb1..b401c7eb 100644
--- a/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionsList.tsx
+++ b/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionsList.tsx
@@ -35,7 +35,7 @@ const SubscriptionsList = ({
return null;
}
if (subscriptions.length === 0) {
- return ;
+ return ;
}
return (
diff --git a/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionsUtility.ts b/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionsUtility.ts
index b4f1838d..a1bf6c7c 100644
--- a/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionsUtility.ts
+++ b/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionsUtility.ts
@@ -3,9 +3,8 @@ import { colors } from "../../../Styles";
export enum SubscriptionState {
PENDING,
- ACCEPTED,
+ APPROVED,
REJECTED,
- DELETED,
}
export const subscriptionStateMap = {
[SubscriptionState.PENDING]: {
@@ -14,9 +13,9 @@ export const subscriptionStateMap = {
accentColor: colors.seaBlue,
borderColor: colors.splashBlue,
},
- [SubscriptionState.ACCEPTED]: {
- subscriptionState: SubscriptionState.ACCEPTED,
- label: "ACCEPTED",
+ [SubscriptionState.APPROVED]: {
+ subscriptionState: SubscriptionState.APPROVED,
+ label: "APPROVED",
accentColor: colors.midGreen,
borderColor: colors.splashBlue,
},
@@ -26,27 +25,15 @@ export const subscriptionStateMap = {
accentColor: colors.darkRed,
borderColor: colors.lightMidRed,
},
- [SubscriptionState.DELETED]: {
- subscriptionState: SubscriptionState.DELETED,
- label: "DELETED",
- accentColor: colors.aprilGrey,
- borderColor: colors.aprilGrey,
- },
-};
-
-const dateHasValue = (dateString: string | undefined) => {
- return !!dateString && new Date(dateString).getFullYear() !== 0;
};
export const GetSubscriptionState = (subscription: Subscription) => {
if (!!subscription.approved) {
- return SubscriptionState.ACCEPTED;
- }
- if (dateHasValue(subscription.deletedAt)) {
- return SubscriptionState.DELETED;
+ return SubscriptionState.APPROVED;
}
if (!!subscription.rejected) {
return SubscriptionState.REJECTED;
}
+ // Deleted subscriptions aren't returned from the API.
return SubscriptionState.PENDING;
};
diff --git a/projects/ui/src/Components/Common/ToggleAddButton.tsx b/projects/ui/src/Components/Common/ToggleAddButton.tsx
index ec328b66..75796c2d 100644
--- a/projects/ui/src/Components/Common/ToggleAddButton.tsx
+++ b/projects/ui/src/Components/Common/ToggleAddButton.tsx
@@ -1,6 +1,6 @@
import { Icon } from "../../Assets/Icons";
import { UtilityStyles } from "../../Styles/shared/Utility.style";
-import { Button } from "./Button";
+import { Button, ButtonVariant } from "./Button";
/**
* Shared between details pages.
@@ -9,13 +9,15 @@ const ToggleAddButton = ({
topicUpperCase,
isAdding,
toggleAdding,
+ variant = "subtle",
}: {
+ variant?: ButtonVariant;
topicUpperCase: string;
isAdding: boolean;
toggleAdding: () => void;
}) => {
return (
-
+
{isAdding ? (
<>
diff --git a/projects/ui/src/Components/Common/WarningAlert.tsx b/projects/ui/src/Components/Common/WarningAlert.tsx
new file mode 100644
index 00000000..d7b8adfd
--- /dev/null
+++ b/projects/ui/src/Components/Common/WarningAlert.tsx
@@ -0,0 +1,38 @@
+import { css } from "@emotion/react";
+import styled from "@emotion/styled";
+import { WarningExclamation } from "../../Assets/Icons/Icons";
+import { borderRadiusConstants } from "../../Styles/constants";
+import { Color, svgColorReplace } from "../../Styles/utils";
+
+const AlertContainer = styled.div(
+ ({ theme }) => css`
+ padding: 12px;
+ background-color: ${theme.lightYellow};
+ border-radius: ${borderRadiusConstants.small};
+
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+
+ ${svgColorReplace(theme.darkYellowDark20 as Color)};
+ > svg {
+ margin-right: 10px;
+ min-width: 24px;
+ }
+
+ * {
+ line-height: 1.3rem;
+ font-size: 0.95rem;
+ color: ${theme.darkYellowDark20};
+ }
+ `
+);
+
+export function WarningAlert(props: { children?: React.ReactNode }) {
+ return (
+
+
+ {props.children}
+
+ );
+}
diff --git a/projects/ui/src/Components/CustomPage/Content/CustomHtmlPage.tsx b/projects/ui/src/Components/CustomPage/Content/CustomHtmlPage.tsx
new file mode 100644
index 00000000..09ce177d
--- /dev/null
+++ b/projects/ui/src/Components/CustomPage/Content/CustomHtmlPage.tsx
@@ -0,0 +1,29 @@
+import { useContext } from "react";
+import { AppUtilsContext } from "../../../Context/AppUtilsContext";
+import { CustomPage } from "../../../user_variables.tmplr";
+import { footerHeightPx } from "../../Structure/Footer";
+import { headerHeightPx } from "../../Structure/Header.style";
+
+const CustomHtmlPage = ({
+ customPage,
+ customPageUrl,
+}: {
+ customPage: CustomPage;
+ customPageUrl: string;
+}) => {
+ const { windowInnerWidth, windowInnerHeight } = useContext(AppUtilsContext);
+
+ return (
+
+ );
+};
+
+export default CustomHtmlPage;
diff --git a/projects/ui/src/Components/CustomPage/Content/CustomMarkdownPage.tsx b/projects/ui/src/Components/CustomPage/Content/CustomMarkdownPage.tsx
new file mode 100644
index 00000000..ba548597
--- /dev/null
+++ b/projects/ui/src/Components/CustomPage/Content/CustomMarkdownPage.tsx
@@ -0,0 +1,31 @@
+import { Box } from "@mantine/core";
+import { GridCardStyles } from "../../../Styles/shared/GridCard.style";
+import { CustomPage } from "../../../user_variables.tmplr";
+import Breadcrumbs from "../../Common/Breadcrumbs";
+import MarkdownRenderer from "../../Common/MarkdownRenderer";
+import { PageContainer } from "../../Common/PageContainer";
+
+const CustomMarkdownPage = ({
+ customPage,
+ customPageContent,
+}: {
+ customPage: CustomPage;
+ customPageContent: string;
+}) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CustomMarkdownPage;
diff --git a/projects/ui/src/Components/CustomPage/CustomPageLanding.tsx b/projects/ui/src/Components/CustomPage/CustomPageLanding.tsx
new file mode 100644
index 00000000..d02d694a
--- /dev/null
+++ b/projects/ui/src/Components/CustomPage/CustomPageLanding.tsx
@@ -0,0 +1,111 @@
+import { Code } from "@mantine/core";
+import { useEffect, useMemo, useState } from "react";
+import { useLocation } from "react-router-dom";
+import { customPages } from "../../user_variables.tmplr";
+import { getCustomPagePath } from "../../Utility/utility";
+import { EmptyData } from "../Common/EmptyData";
+import { Loading } from "../Common/Loading";
+
+import styled from "@emotion/styled";
+import CustomHtmlPage from "./Content/CustomHtmlPage";
+import CustomMarkdownPage from "./Content/CustomMarkdownPage";
+
+type CustomPageType = "md" | "html" | "loading" | "unsupported";
+
+const StyledCustomPageContainer = styled.div`
+ margin: 60px;
+`;
+
+//
+// region Component
+//
+const CustomPageLanding = () => {
+ const location = useLocation();
+
+ // Find our custom page object.
+ const customPage = useMemo(() => {
+ return customPages.find(
+ (page) => getCustomPagePath(page) === location.pathname
+ );
+ }, [location.pathname]);
+
+ // Get the page type.
+ const customPageType = useMemo(() => {
+ if (!customPage) {
+ return "loading";
+ }
+ const lowercasePath = customPage.path.toLowerCase();
+ if (lowercasePath.endsWith(".md")) {
+ return "md";
+ }
+ if (lowercasePath.endsWith(".html")) {
+ return "html";
+ }
+ return "unsupported";
+ }, [customPage?.path]);
+
+ // Fetch custom page content
+ const [customPageContent, setCustomPageContent] = useState();
+ useEffect(() => {
+ if (!customPage) {
+ return;
+ }
+ (async () => {
+ const newPageContent = await (await fetch(customPage.path)).text();
+ setCustomPageContent(newPageContent);
+ })();
+ }, [customPage, setCustomPageContent]);
+
+ //
+ // region Render
+ //
+ if (!customPage) {
+ return (
+
+ ;
+
+ );
+ }
+ if (customPageType === "loading") {
+ return (
+
+
+
+ );
+ }
+ if (customPageType === "unsupported") {
+ return (
+
+
+ Markdown and HTML custom pages are supported.
+
+ To view this page, update the path
for this custom page
+ so that it ends in .md
or .html
.
+
+
+ );
+ }
+ if (!customPageContent) {
+ return (
+
+
+ To view this page, make sure that your custom page is publicly
+ accessible at {customPage.path}
.
+
+
+ );
+ }
+ if (customPageType === "md") {
+ return (
+
+ );
+ }
+ return (
+
+ );
+};
+
+export default CustomPageLanding;
diff --git a/projects/ui/src/Components/Home/HomePage.tsx b/projects/ui/src/Components/Home/HomePage.tsx
index c74099e1..baefacfe 100644
--- a/projects/ui/src/Components/Home/HomePage.tsx
+++ b/projects/ui/src/Components/Home/HomePage.tsx
@@ -1,12 +1,12 @@
import { Box } from "@mantine/core";
-import { useContext } from "react";
import { NavLink } from "react-router-dom";
-import { AuthContext } from "../../Context/AuthContext";
+import { useIsAdmin } from "../../Context/AuthContext";
import { companyName, homeImageURL } from "../../user_variables.tmplr";
import { BannerHeading } from "../Common/Banner/BannerHeading";
import { BannerHeadingTitle } from "../Common/Banner/BannerHeadingTitle";
import { Button } from "../Common/Button";
import { PageContainer } from "../Common/PageContainer";
+import { useOnApisPageClick } from "../Structure/Header";
import { HomePageStyles } from "./HomePage.style";
import { HomePageCategoryCard } from "./HomePageCategoryCard";
@@ -15,10 +15,11 @@ import { HomePageCategoryCard } from "./HomePageCategoryCard";
import CardImage1 from "../../Assets/card-option-1@2x.webp";
import CardImage2 from "../../Assets/card-option-2@2x.webp";
import CardImage3 from "../../Assets/card-option-3@2x.webp";
-import { onApisPageClick } from "../Structure/Header";
export function HomePage() {
- const { isAdmin } = useContext(AuthContext);
+ const isAdmin = useIsAdmin();
+ const { onApisPageClick } = useOnApisPageClick();
+
return (
diff --git a/projects/ui/src/Components/Structure/BasicAuth/BasicAuthHeaderSection.tsx b/projects/ui/src/Components/Structure/BasicAuth/BasicAuthHeaderSection.tsx
new file mode 100644
index 00000000..97522724
--- /dev/null
+++ b/projects/ui/src/Components/Structure/BasicAuth/BasicAuthHeaderSection.tsx
@@ -0,0 +1,23 @@
+import { useIsLoggedIn } from "../../../Context/AuthContext";
+import HeaderSectionLoggedIn from "./HeaderSectionLoggedIn";
+import HeaderSectionLoggedOut from "./HeaderSectionLoggedOut";
+
+if (!window.isSecureContext) {
+ // eslint-disable-next-line no-console
+ console.error(
+ "This page is not being delivered in a secure context (see https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), " +
+ "so login will not work."
+ );
+}
+
+/**
+ * MAIN COMPONENT
+ **/
+export function BasicAuthHeaderSection() {
+ const isLoggedIn = useIsLoggedIn();
+
+ if (isLoggedIn) {
+ return
;
+ }
+ return
;
+}
diff --git a/projects/ui/src/Components/Structure/HeaderSectionLoggedIn.tsx b/projects/ui/src/Components/Structure/BasicAuth/HeaderSectionLoggedIn.tsx
similarity index 85%
rename from projects/ui/src/Components/Structure/HeaderSectionLoggedIn.tsx
rename to projects/ui/src/Components/Structure/BasicAuth/HeaderSectionLoggedIn.tsx
index 011a0c5f..dca43717 100644
--- a/projects/ui/src/Components/Structure/HeaderSectionLoggedIn.tsx
+++ b/projects/ui/src/Components/Structure/BasicAuth/HeaderSectionLoggedIn.tsx
@@ -4,11 +4,11 @@ import { Popover } from "@mantine/core";
import { useContext, useEffect, useMemo, useState } from "react";
import { di } from "react-magnetic-di";
import { NavLink, useLocation, useSearchParams } from "react-router-dom";
-import { useGetCurrentUser } from "../../Apis/gg_hooks";
-import { Icon } from "../../Assets/Icons";
-import { AppContext } from "../../Context/AppContext";
-import { AuthContext } from "../../Context/AuthContext";
-import { logoutEndpoint } from "../../user_variables.tmplr";
+import { useGetCurrentUser } from "../../../Apis/gg_hooks";
+import { Icon } from "../../../Assets/Icons";
+import { AppContext } from "../../../Context/AppContext";
+import { AuthContext } from "../../../Context/AuthContext";
+import { logoutEndpoint } from "../../../user_variables.tmplr";
export const StyledUserDropdown = styled(Popover.Dropdown)(
({ theme }) => css`
@@ -78,9 +78,11 @@ const HeaderSectionLoggedIn = () => {
className="userLoginArea loggedIn"
onClick={() => setOpened(!opened)}
>
-
-
{" "}
- {!!user ? user.username : " "}
+ {/*
+ * Note: The "userHolder" class is kept here for testing purposes.
+ */}
+
+
{user?.username ?? ""}
diff --git a/projects/ui/src/Components/Structure/HeaderSectionLoggedOut.tsx b/projects/ui/src/Components/Structure/BasicAuth/HeaderSectionLoggedOut.tsx
similarity index 90%
rename from projects/ui/src/Components/Structure/HeaderSectionLoggedOut.tsx
rename to projects/ui/src/Components/Structure/BasicAuth/HeaderSectionLoggedOut.tsx
index e1a5ebad..692e76e8 100644
--- a/projects/ui/src/Components/Structure/HeaderSectionLoggedOut.tsx
+++ b/projects/ui/src/Components/Structure/BasicAuth/HeaderSectionLoggedOut.tsx
@@ -4,10 +4,15 @@ import {
AuthContext,
LOCAL_STORAGE_AUTH_STATE,
LOCAL_STORAGE_AUTH_VERIFIER,
-} from "../../Context/AuthContext";
-import { doAccessTokenRequest } from "../../Utility/accessTokenRequest";
-import { audience, authEndpoint, clientId } from "../../user_variables.tmplr";
-import { Button } from "../Common/Button";
+ useIsLoggedIn,
+} from "../../../Context/AuthContext";
+import { doAccessTokenRequest } from "../../../Utility/accessTokenRequest";
+import {
+ audience,
+ authEndpoint,
+ clientId,
+} from "../../../user_variables.tmplr";
+import { Button } from "../../Common/Button";
//
// From https://stackoverflow.com/a/63336562
@@ -71,9 +76,8 @@ const AuthFlowStarter = () => {
const [codeChallenge, setCodeChallenge] = useState
();
useEffect(() => {
(async () => {
- const newCodeChallenge = await generateCodeChallengeFromVerifier(
- verifier
- );
+ const newCodeChallenge =
+ await generateCodeChallengeFromVerifier(verifier);
setCodeChallenge(newCodeChallenge);
})();
}, [setCodeChallenge]);
@@ -100,7 +104,8 @@ const AuthFlowStarter = () => {
};
const HeaderSectionLoggedOut = () => {
- const { onLogin, isLoggedIn } = useContext(AuthContext);
+ const { onLogin } = useContext(AuthContext);
+ const isLoggedIn = useIsLoggedIn();
const [searchParams] = useSearchParams();
//
diff --git a/projects/ui/src/Components/Structure/CustomPagesNavSection.tsx b/projects/ui/src/Components/Structure/CustomPagesNavSection.tsx
new file mode 100644
index 00000000..510606f0
--- /dev/null
+++ b/projects/ui/src/Components/Structure/CustomPagesNavSection.tsx
@@ -0,0 +1,83 @@
+import { Box, Popover } from "@mantine/core";
+import { useState } from "react";
+import { NavLink } from "react-router-dom";
+import { Icon } from "../../Assets/Icons";
+import { colors } from "../../Styles";
+import { CustomPage, customPages } from "../../user_variables.tmplr";
+import { getCustomPagePath, useInArea } from "../../Utility/utility";
+import { StyledUserDropdown } from "./BasicAuth/HeaderSectionLoggedIn";
+
+const CustomPageNavLink = ({
+ page,
+ onClick,
+}: {
+ page: CustomPage;
+ onClick: () => void;
+}) => {
+ const onThisPage = useInArea([page.path]);
+ return (
+
+ {page.title}
+
+ );
+};
+
+const CustomPagesNavSection = () => {
+ const [opened, setOpened] = useState(false);
+ const inAnyCustomPageArea = useInArea(
+ customPages?.map((page) => page.path) ?? []
+ );
+
+ if (!customPages.length) {
+ return null;
+ }
+ return (
+
+
+
+ setOpened(!opened)}
+ >
+
+ Navigation
+
+
+
+
+
+ {customPages.map((page) => (
+ setOpened(false)}
+ />
+ ))}
+
+
+
+ );
+};
+
+export default CustomPagesNavSection;
diff --git a/projects/ui/src/Components/Structure/Footer.tsx b/projects/ui/src/Components/Structure/Footer.tsx
index 5870c414..d17d966a 100644
--- a/projects/ui/src/Components/Structure/Footer.tsx
+++ b/projects/ui/src/Components/Structure/Footer.tsx
@@ -4,12 +4,14 @@ import { useContext } from "react";
import { AppContext } from "../../Context/AppContext";
import { ContentWidthDiv } from "../../Styles/ContentWidthHelpers";
+export const footerHeightPx = 40;
+
const FooterContainer = styled.footer(
({ theme }) => css`
margin-bottom: 40px;
grid-area: footer;
width: 100%;
- height: 40px;
+ height: ${footerHeightPx}px;
background: ${theme.marchGrey};
color: ${theme.augustGrey};
display: block;
diff --git a/projects/ui/src/Components/Structure/Header.style.tsx b/projects/ui/src/Components/Structure/Header.style.tsx
index 450566bb..9b75a4e2 100644
--- a/projects/ui/src/Components/Structure/Header.style.tsx
+++ b/projects/ui/src/Components/Structure/Header.style.tsx
@@ -2,9 +2,11 @@ import { css } from "@emotion/react";
import styled from "@emotion/styled";
import { ContentWidthNav } from "../../Styles/ContentWidthHelpers";
+export const headerHeightPx = 90;
+
export namespace HeaderStyles {
export const StyledLogoImg = styled.img`
- height: 90px;
+ height: ${headerHeightPx}px;
padding: 5px 0px;
`;
@@ -12,8 +14,9 @@ export namespace HeaderStyles {
({ theme }) => css`
grid-area: header;
width: 100%;
- height: 90px;
+ height: ${headerHeightPx}px;
background: white;
+ border-bottom: 1px solid ${theme.marchGrey};
box-shadow: #253e580b 0px 2px 8px;
// These are the hover/active styles for the links in the main top bar,
@@ -21,7 +24,7 @@ export namespace HeaderStyles {
.userLoginArea.loggedIn,
.logoContainer,
.siteNavigating a.navLink,
- .userLoginArea .userHolder,
+ .userLoginArea .dropdownContainer,
a,
button.logout {
cursor: pointer;
@@ -115,9 +118,10 @@ export namespace HeaderStyles {
padding: 0 12px;
margin-right: -12px;
- .userHolder {
+ .dropdownContainer {
display: flex;
align-items: center;
+ margin-top: -4px;
svg.userCircle {
width: 40px;
diff --git a/projects/ui/src/Components/Structure/Header.tsx b/projects/ui/src/Components/Structure/Header.tsx
index 51d5e073..5c876415 100644
--- a/projects/ui/src/Components/Structure/Header.tsx
+++ b/projects/ui/src/Components/Structure/Header.tsx
@@ -1,74 +1,69 @@
-import { MouseEventHandler, useContext, useMemo } from "react";
-import { Link, NavLink, useLocation } from "react-router-dom";
+import { MouseEventHandler, useContext } from "react";
+import { Link, NavLink } from "react-router-dom";
import { ReactComponent as Logo } from "../../Assets/logo.svg";
import { AppContext } from "../../Context/AppContext";
-import { AuthContext } from "../../Context/AuthContext";
-import { apiPageReload, logoImageURL } from "../../user_variables.tmplr";
+import { useIsAdmin, useIsLoggedIn } from "../../Context/AuthContext";
+import {
+ apiPageReload,
+ appliedOidcAuthCodeConfig,
+ logoImageURL,
+} from "../../user_variables.tmplr";
+import { useInArea } from "../../Utility/utility";
import { ErrorBoundary } from "../Common/ErrorBoundary";
+import { BasicAuthHeaderSection } from "./BasicAuth/BasicAuthHeaderSection";
+import CustomPagesNavSection from "./CustomPagesNavSection";
import { HeaderStyles } from "./Header.style";
-import HeaderSectionLoggedIn from "./HeaderSectionLoggedIn";
-import HeaderSectionLoggedOut from "./HeaderSectionLoggedOut";
-
-if (!window.isSecureContext) {
- // eslint-disable-next-line no-console
- console.error(
- "This page is not being delivered in a secure context (see https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), " +
- "so login will not work."
- );
-}
-
-// If apiPageReload is true use the onclick to reload the page
-export const onApisPageClick: MouseEventHandler | undefined =
- apiPageReload === "true"
- ? (e) => {
- // If we are using `apiPageReload=true`, we want to override the react router
- // behavior here, otherwise we get 2 `/apis` page history entries.
- e.preventDefault();
- window.location.href = "/apis";
- }
- : undefined;
-
-/**
- * MAIN COMPONENT
- **/
-export function Header() {
- const routerLocation = useLocation();
- const { isLoggedIn } = useContext(AuthContext);
-
- const inArea = (paths: string[]) => {
- return paths.some((s) => routerLocation.pathname.includes(s));
+import { OidcAuthCodeHeaderSection } from "./OidcAuthCodeHeader/OidcAuthCodeHeaderSection";
+
+// This checks if we need to do a full page load when going to the /apis page,
+// which is sometimes necessary for getting external auth routing to work.
+export const useOnApisPageClick = () => {
+ const { portalServerType } = useContext(AppContext);
+ const isLoggedIn = useIsLoggedIn();
+
+ // In these cases, we can return `undefined` so that the app does normal react-router routing.
+ if (
+ // If using gloo-gateway and logged in, we should be able to use normal react-router routing.
+ // This is because the auth is already done.
+ (portalServerType === "gloo-gateway" && isLoggedIn) ||
+ apiPageReload !== "true"
+ ) {
+ return { onApisPageClick: undefined };
+ }
+
+ // Otherwise, we want to override the react-router behavior here with `e.preventDefault`.
+ // If we don't we get 2 `/apis` page history entries when doing a full page load.
+ const onClick: MouseEventHandler = (e) => {
+ e.preventDefault();
+ window.location.href = "/apis";
};
+ return { onApisPageClick: onClick };
+};
- // Note: Removing sections for GGv2 demo.
-
- // const inAdminTeamsArea = useMemo(
- // () => inArea(["/admin/teams"]),
- // [routerLocation.pathname]
- // );
-
- // const inAdminSubscriptionsArea = useMemo(
- // () => inArea(["/admin/subscriptions"]),
- // [routerLocation.pathname]
- // );
-
- const inAPIsArea = useMemo(
- () => inArea(["/apis", "/api-details/"]),
- [routerLocation.pathname]
+const ApisPageNavLink = () => {
+ const { onApisPageClick } = useOnApisPageClick();
+ const inAPIsArea = useInArea(["/apis", "/api-details/"]);
+ return (
+
+ APIs
+
);
+};
- // Note: Removing sections for GGv2 demo.
-
- // const inAppsArea = useMemo(
- // () => inArea(["/apps", "/app-details/"]),
- // [routerLocation.pathname]
- // );
+const Header = () => {
+ const { pageContentIsWide, portalServerType } = useContext(AppContext);
+ const isAdmin = useIsAdmin();
+ const isLoggedIn = useIsLoggedIn();
- // const inTeamsArea = useMemo(
- // () => inArea(["/teams", "/team-details/"]),
- // [routerLocation.pathname]
- // );
-
- const { pageContentIsWide } = useContext(AppContext);
+ const inAdminTeamsArea = useInArea(["/admin/teams"]);
+ const inAdminAppsArea = useInArea(["/admin/apps"]);
+ const inAdminSubscriptionsArea = useInArea(["/admin/subscriptions"]);
+ const inAppsArea = useInArea(["/apps", "/app-details/"]);
+ const inTeamsArea = useInArea(["/teams", "/team-details/"]);
return (
@@ -87,82 +82,97 @@ export function Header() {
Home
- {/*
- // Note: Removing sections for GGv2 demo.
-
- {!isAdmin && (
- // If we allow admins to access the APIs page, things get a bit
- // more confusing, since we will have to consider the behavior
- // of the API details pages and pending subscriptions tabs.
- // For example, a user can create an App from the API details page,
- // so it would be strange for the admin not to have access to
- // the Apps page in that case.
- */}
-
- APIs
-
- {/* )} */}
-
- {/*
-
- // Note: Removing sections for GGv2 demo.
-
- {isLoggedIn &&
- (isAdmin ? (
- //
- // Logged-in, admin view
- //
- <>
-
- Teams
-
-
- Subscriptions
-
- >
- ) : (
- //
- // Logged-in, non-admin view
- //
+ {portalServerType === "gloo-mesh-gateway" ? (
+ //
+ // region [GMG]
+ // This is the same view if logged in or logged out.
+ //
+
+ ) : (
+ (portalServerType === "gloo-gateway" ||
+ portalServerType === "unknown") && (
<>
-
- Teams
-
-
- Apps
-
+ {!isLoggedIn ? (
+ //
+ // region [GG] Logged-out
+ //
+
+ ) : isAdmin ? (
+ //
+ // region [GG] Logged-in, admin
+ //
+ <>
+
+ Teams
+
+
+ Apps
+
+
+ Subscriptions
+
+ >
+ ) : (
+ //
+ // region [GG] Logged-in, non-admin
+ //
+ <>
+ {/*
+ // If we allow admins to access the APIs page, things get a bit
+ // more confusing, since we will have to consider the behavior
+ // of the API details pages and pending subscriptions tabs.
+ // For example, a user can create an App from the API details page,
+ // so it would be strange for the admin not to have access to
+ // the Apps page in that case.
+ */}
+
+
+ Teams
+
+
+ Apps
+
+ >
+ )}
+ {/*
+ // region [GG] Custom Pages
+ */}
+
>
- ))}
- */}
+ )
+ )}
+
+ {/*
+ // region Auth Dropdown
+ */}
- {isLoggedIn ? (
-
+ {!!appliedOidcAuthCodeConfig ? (
+
) : (
-
+
)}
);
-}
+};
+
+export default Header;
diff --git a/projects/ui/src/Components/Structure/OidcAuthCodeHeaderVariant/OidcAuthCodeHeaderDropdown.tsx b/projects/ui/src/Components/Structure/OidcAuthCodeHeader/OidcAuthCodeHeaderSection.tsx
similarity index 74%
rename from projects/ui/src/Components/Structure/OidcAuthCodeHeaderVariant/OidcAuthCodeHeaderDropdown.tsx
rename to projects/ui/src/Components/Structure/OidcAuthCodeHeader/OidcAuthCodeHeaderSection.tsx
index fae765b2..722a393b 100644
--- a/projects/ui/src/Components/Structure/OidcAuthCodeHeaderVariant/OidcAuthCodeHeaderDropdown.tsx
+++ b/projects/ui/src/Components/Structure/OidcAuthCodeHeader/OidcAuthCodeHeaderSection.tsx
@@ -1,36 +1,32 @@
import { Popover } from "@mantine/core";
-import { useContext, useMemo, useState } from "react";
-import { di } from "react-magnetic-di";
-import { NavLink, useLocation } from "react-router-dom";
+import { useContext, useState } from "react";
+import { NavLink } from "react-router-dom";
import { useGetCurrentUser } from "../../../Apis/gg_hooks";
import { Icon } from "../../../Assets/Icons";
import { AppContext } from "../../../Context/AppContext";
+import { useIsLoggedIn } from "../../../Context/AuthContext";
import {
oidcAuthCodeConfigCallbackPath,
oidcAuthCodeConfigLogoutPath,
} from "../../../user_variables.tmplr";
-import { StyledUserDropdown } from "../HeaderSectionLoggedIn";
+import { useInArea } from "../../../Utility/utility";
+import { StyledUserDropdown } from "../BasicAuth/HeaderSectionLoggedIn";
/**
* MAIN COMPONENT
**/
-export function OidcAuthCodeHeaderDropdown() {
- di(useGetCurrentUser);
- const { data: user } = useGetCurrentUser();
+export function OidcAuthCodeHeaderSection() {
const { portalServerType } = useContext(AppContext);
-
- const routerLocation = useLocation();
- const inUsagePlansArea = useMemo(
- () => routerLocation.pathname.includes("/usage-plans"),
- [routerLocation.pathname]
- );
+ const isLoggedIn = useIsLoggedIn();
+ const { data: user } = useGetCurrentUser();
const [opened, setOpened] = useState(false);
+ const inUsagePlansArea = useInArea(["/usage-plans"]);
+
// eslint-disable-next-line no-console
// console.log(user);
- const isLoggedIn = !!user?.email || !!user?.username || !!user?.name;
return !isLoggedIn ? (
@@ -44,8 +40,11 @@ export function OidcAuthCodeHeaderDropdown() {
className="userLoginArea loggedIn"
onClick={() => setOpened(!opened)}
>
-
-
{user?.username}
+ {/*
+ * Note: The "userHolder" class is kept here for testing purposes.
+ */}
+
+
{user?.username ?? ""}
diff --git a/projects/ui/src/Components/Structure/OidcAuthCodeHeaderVariant/OidcAuthCodeHeaderVariant.tsx b/projects/ui/src/Components/Structure/OidcAuthCodeHeaderVariant/OidcAuthCodeHeaderVariant.tsx
deleted file mode 100644
index b12d7d7a..00000000
--- a/projects/ui/src/Components/Structure/OidcAuthCodeHeaderVariant/OidcAuthCodeHeaderVariant.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { useContext, useMemo } from "react";
-import { Link, NavLink, useLocation } from "react-router-dom";
-import { ReactComponent as Logo } from "../../../Assets/logo.svg";
-import { AppContext } from "../../../Context/AppContext";
-import { ErrorBoundary } from "../../Common/ErrorBoundary";
-import { onApisPageClick } from "../Header";
-import { HeaderStyles } from "../Header.style";
-import { OidcAuthCodeHeaderDropdown } from "./OidcAuthCodeHeaderDropdown";
-
-/**
- * This is for when there is an
- * oidcAuthorizationCode config in
- * the user's ExtAuthPolicy
- */
-const OidcAuthCodeHeaderVariant = () => {
- const routerLocation = useLocation();
-
- const inAPIsArea = useMemo(() => {
- return ["/apis", "/api-details/"].some((s) =>
- routerLocation.pathname.includes(s)
- );
- }, [routerLocation.pathname]);
-
- const { pageContentIsWide } = useContext(AppContext);
-
- return (
-
-
-
-
-
-
-
-
-
- Home
-
-
- APIs
-
-
-
-
-
-
-
-
- );
-};
-
-export default OidcAuthCodeHeaderVariant;
diff --git a/projects/ui/src/Components/Teams/Details/AppsSection/TeamAppsSection.tsx b/projects/ui/src/Components/Teams/Details/AppsSection/TeamAppsSection.tsx
index 39515fd9..a8b8c990 100644
--- a/projects/ui/src/Components/Teams/Details/AppsSection/TeamAppsSection.tsx
+++ b/projects/ui/src/Components/Teams/Details/AppsSection/TeamAppsSection.tsx
@@ -1,10 +1,10 @@
import { Box, Flex } from "@mantine/core";
-import { useContext, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
import { di } from "react-magnetic-di";
import { NavLink } from "react-router-dom";
import { Team } from "../../../../Apis/api-types";
import { useListAppsForTeam } from "../../../../Apis/gg_hooks";
-import { AuthContext } from "../../../../Context/AuthContext";
+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";
@@ -22,7 +22,7 @@ import AddTeamAppSubSection from "./AddTeamAppSubSection";
const TeamAppsSection = ({ team }: { team: Team }) => {
di(useListAppsForTeam);
- const { isAdmin } = useContext(AuthContext);
+ const isAdmin = useIsAdmin();
const { isLoading, data: apps } = useListAppsForTeam(team);
const [showAddTeamAppSubSection, setShowAddTeamAppSubSection] =
useState(false);
@@ -86,7 +86,7 @@ const TeamAppsSection = ({ team }: { team: Team }) => {
)}
{!apps?.length ? (
-
+
) : (
diff --git a/projects/ui/src/Components/Teams/Details/TeamDetailsPageContent.tsx b/projects/ui/src/Components/Teams/Details/TeamDetailsPageContent.tsx
index e1494227..e96b9ac1 100644
--- a/projects/ui/src/Components/Teams/Details/TeamDetailsPageContent.tsx
+++ b/projects/ui/src/Components/Teams/Details/TeamDetailsPageContent.tsx
@@ -1,7 +1,6 @@
import { Box, Flex } from "@mantine/core";
-import { useContext } from "react";
import { Team } from "../../../Apis/api-types";
-import { AuthContext } from "../../../Context/AuthContext";
+import { Icon } from "../../../Assets/Icons";
import { BannerHeading } from "../../Common/Banner/BannerHeading";
import { BannerHeadingTitle } from "../../Common/Banner/BannerHeadingTitle";
import { PageContainer } from "../../Common/PageContainer";
@@ -10,13 +9,13 @@ import EditTeamButtonWithModal from "./EditTeamButtonWithModal";
import TeamUsersSection from "./UsersSection/TeamUsersSection";
const TeamDetailsPageContent = ({ team }: { team: Team }) => {
- const { isAdmin } = useContext(AuthContext);
return (
}
stylingTweaks={{
fontSize: "32px",
lineHeight: "36px",
@@ -34,7 +33,7 @@ const TeamDetailsPageContent = ({ team }: { team: Team }) => {
- {!isAdmin && }
+
diff --git a/projects/ui/src/Components/Teams/Details/UsersSection/TeamUsersSection.tsx b/projects/ui/src/Components/Teams/Details/UsersSection/TeamUsersSection.tsx
index 12bf55a9..af0a30bd 100644
--- a/projects/ui/src/Components/Teams/Details/UsersSection/TeamUsersSection.tsx
+++ b/projects/ui/src/Components/Teams/Details/UsersSection/TeamUsersSection.tsx
@@ -40,41 +40,36 @@ const TeamUsersSection = ({ team }: { team: Team }) => {
useState();
const rows = useMemo(() => {
- return paginatedData?.map(
- (member) =>
- (
-
- {member.email}
- {member.username}
- {member.name}
- {formatDateToMMDDYYYY(new Date(member.createdAt))}
- {formatDateToMMDDYYYY(new Date(member.updatedAt))}
-
-
-
- {member.synced ? (
-
- ) : (
-
- )}
-
-
-
-
-
- setConfirmRemoveTeamMember(member)}
- >
- REMOVE
-
-
-
-
- ) ?? []
+ return (
+ paginatedData?.map((member) => (
+
+ {member.email}
+ {member.username}
+ {member.name}
+ {formatDateToMMDDYYYY(new Date(member.createdAt))}
+ {formatDateToMMDDYYYY(new Date(member.updatedAt))}
+
+
+
+ {member.synced ? : }
+
+
+
+
+
+ setConfirmRemoveTeamMember(member)}
+ >
+ REMOVE
+
+
+
+
+ )) ?? []
);
}, [paginatedData]);
@@ -100,7 +95,8 @@ const TeamUsersSection = ({ team }: { team: Team }) => {
/>
{!members?.length ? (
-
+ {/* We never should get here, since the user must be a member. */}
+
) : (
diff --git a/projects/ui/src/Components/Teams/TeamsList/TeamSummaryCards/TeamSummaryGridCard.tsx b/projects/ui/src/Components/Teams/TeamsList/TeamSummaryCards/TeamSummaryGridCard.tsx
index 743fbe9d..855f3299 100644
--- a/projects/ui/src/Components/Teams/TeamsList/TeamSummaryCards/TeamSummaryGridCard.tsx
+++ b/projects/ui/src/Components/Teams/TeamsList/TeamSummaryCards/TeamSummaryGridCard.tsx
@@ -57,7 +57,7 @@ export function TeamSummaryGridCard({ team }: { team: Team }) {
- MANAGE
+ DETAILS
diff --git a/projects/ui/src/Components/Teams/TeamsList/TeamsList.tsx b/projects/ui/src/Components/Teams/TeamsList/TeamsList.tsx
index 49e2d3c4..0b711ce4 100644
--- a/projects/ui/src/Components/Teams/TeamsList/TeamsList.tsx
+++ b/projects/ui/src/Components/Teams/TeamsList/TeamsList.tsx
@@ -12,11 +12,11 @@ export function TeamsList() {
//
// Render
//
- if (isLoading) {
+ if (teamsList === undefined || isLoading) {
return
;
}
if (!teamsList?.length) {
- return
;
+ return
;
}
return (
diff --git a/projects/ui/src/Components/Teams/TeamsPage.tsx b/projects/ui/src/Components/Teams/TeamsPage.tsx
index 63013b91..830b6778 100644
--- a/projects/ui/src/Components/Teams/TeamsPage.tsx
+++ b/projects/ui/src/Components/Teams/TeamsPage.tsx
@@ -1,7 +1,7 @@
import { Box } from "@mantine/core";
-import { useContext, useState } from "react";
+import { useState } from "react";
import { Icon } from "../../Assets/Icons";
-import { AuthContext } from "../../Context/AuthContext";
+import { useIsAdmin } from "../../Context/AuthContext";
import { BannerHeading } from "../Common/Banner/BannerHeading";
import { BannerHeadingTitle } from "../Common/Banner/BannerHeadingTitle";
import { Button } from "../Common/Button";
@@ -10,7 +10,7 @@ import CreateNewTeamModal from "./Modals/CreateNewTeamModal";
import { TeamsList } from "./TeamsList/TeamsList";
export function TeamsPage() {
- const { isAdmin } = useContext(AuthContext);
+ const isAdmin = useIsAdmin();
const [modalOpen, setModalOpen] = useState(false);
return (
diff --git a/projects/ui/src/Components/UsagePlans/UsagePlanList/APIUsagePlansList.tsx b/projects/ui/src/Components/UsagePlans/UsagePlanList/APIUsagePlansList.tsx
index 0cd03c09..0808d446 100644
--- a/projects/ui/src/Components/UsagePlans/UsagePlanList/APIUsagePlansList.tsx
+++ b/projects/ui/src/Components/UsagePlans/UsagePlanList/APIUsagePlansList.tsx
@@ -46,7 +46,7 @@ export function APIUsagePlansList() {
))
) : (
-
+
)}
>
);
diff --git a/projects/ui/src/Context/AppUtilsContext.tsx b/projects/ui/src/Context/AppUtilsContext.tsx
new file mode 100644
index 00000000..5bf5786b
--- /dev/null
+++ b/projects/ui/src/Context/AppUtilsContext.tsx
@@ -0,0 +1,38 @@
+import { ReactNode, createContext, useState } from "react";
+import { useEventListener } from "../Utility/utility";
+
+//
+// Types
+//
+interface AppUtilsProviderProps {
+ children?: ReactNode;
+}
+interface IAppUtilsContext extends AppUtilsProviderProps {
+ windowInnerWidth: number;
+ windowInnerHeight: number;
+}
+
+//
+// Context
+//
+export const AppUtilsContext = createContext({} as IAppUtilsContext);
+
+//
+// Provider
+//
+export const AppUtilsContextProvider = (props: AppUtilsProviderProps) => {
+ const [windowInnerWidth, setWindowInnerWidth] = useState(window.innerWidth);
+ const [windowInnerHeight, setWindowInnerHeight] = useState(
+ window.innerHeight
+ );
+ useEventListener(window, "resize", () => {
+ setWindowInnerWidth(window.innerWidth);
+ setWindowInnerHeight(window.innerHeight);
+ });
+
+ return (
+
+ {props.children}
+
+ );
+};
diff --git a/projects/ui/src/Context/AuthContext.tsx b/projects/ui/src/Context/AuthContext.tsx
index ce4b7785..8ef6d502 100644
--- a/projects/ui/src/Context/AuthContext.tsx
+++ b/projects/ui/src/Context/AuthContext.tsx
@@ -1,8 +1,10 @@
-import { createContext, useEffect, useMemo, useState } from "react";
+import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { toast } from "react-hot-toast";
+import { di } from "react-magnetic-di";
import { useNavigate } from "react-router-dom";
import { mutate } from "swr";
import { AccessTokensResponse } from "../Apis/api-types";
+import { useGetCurrentUser } from "../Apis/gg_hooks";
import { doAccessTokenRequest } from "../Utility/accessTokenRequest";
import { jwtDecode, parseJwt } from "../Utility/utility";
@@ -13,12 +15,10 @@ interface AuthProviderProps {
children?: any;
}
interface IAuthContext extends AuthProviderProps {
- isAdmin: boolean;
// The id_token is used for identifying the user in the logout request.
idToken: string | undefined;
// The access_token is used for user claims (like "email").
latestAccessToken: string | undefined;
- isLoggedIn: boolean;
tokensResponse: AccessTokensResponse | undefined;
onLogin: (newTokensResponse: AccessTokensResponse) => void;
onLogout: () => void;
@@ -185,7 +185,45 @@ export const AuthContextProvider = (props: AuthProviderProps) => {
toast.success("Logged out!");
};
- const isAdmin = useMemo(() => {
+ return (
+
+ {props.children}
+
+ );
+};
+
+function useIsOidcAuthLoggedIn() {
+ di(useGetCurrentUser);
+ const { data: user } = useGetCurrentUser();
+ const isOidcAuthLoggedIn = !!user?.email || !!user?.username || !!user?.name;
+ return isOidcAuthLoggedIn;
+}
+
+/**
+ * Since we support different authorization types, this is the way to tell if someone is logged in.
+ */
+export function useIsLoggedIn() {
+ const { tokensResponse } = useContext(AuthContext);
+ const isAccessTokenAuthLoggedIn = !!tokensResponse?.access_token;
+ const isOidcAuthLoggedIn = useIsOidcAuthLoggedIn();
+ return isAccessTokenAuthLoggedIn || isOidcAuthLoggedIn;
+}
+
+export function useIsAdmin() {
+ di(useGetCurrentUser);
+ const { tokensResponse } = useContext(AuthContext);
+ const { data: user } = useGetCurrentUser();
+
+ // Check if the isAdmin property is in the token.
+ const isAdminTokensResponse = useMemo(() => {
if (!tokensResponse?.access_token) {
return false;
}
@@ -203,19 +241,15 @@ export const AuthContextProvider = (props: AuthProviderProps) => {
);
}, [tokensResponse]);
- return (
-
- {props.children}
-
- );
-};
+ // If there was no user, they can't be an admin.
+ if (user === undefined) {
+ return false;
+ }
+ // Use the portal server property if possible.
+ if (user?.isAdmin !== undefined) {
+ return user.isAdmin;
+ }
+ // Otherwise fall back to what is in the token.
+ // This is used for older portal server versions (before the isAdmin property was added to /me).
+ return !!isAdminTokensResponse;
+}
diff --git a/projects/ui/src/Styles/colors.ts b/projects/ui/src/Styles/colors.ts
index 06513481..b24d72fa 100644
--- a/projects/ui/src/Styles/colors.ts
+++ b/projects/ui/src/Styles/colors.ts
@@ -43,6 +43,8 @@ const baseColors = {
const colorMap = {
...baseColors,
+ januaryGreyDark1: Color(baseColors.januaryGrey).darken(0.01).hex(),
+
marchGreyDark3: Color(baseColors.marchGrey).darken(0.03).hex(),
marchGreyDark5: Color(baseColors.marchGrey).darken(0.05).hex(),
marchGreyDark10: Color(baseColors.marchGrey).darken(0.1).hex(),
@@ -78,7 +80,10 @@ const colorMap = {
pumpkinOrangeLight10: Color(baseColors.pumpkinOrange).lighten(0.1).hex(),
pumpkinOrangeLight20: Color(baseColors.pumpkinOrange).lighten(0.2).hex(),
+ lightYellowLight1: Color(baseColors.lightYellow).lighten(0.02).hex(),
midYellowDark20: Color(baseColors.midYellow).darken(0.2).hex(),
+ darkYellowDark10: Color(baseColors.darkYellow).darken(0.1).hex(),
+ darkYellowDark20: Color(baseColors.darkYellow).darken(0.2).hex(),
} as const;
const semanticColorMap = {
diff --git a/projects/ui/src/Styles/global-styles/highlight.js.min.css b/projects/ui/src/Styles/global-styles/highlight.js.min.css
new file mode 100644
index 00000000..0eabaf28
--- /dev/null
+++ b/projects/ui/src/Styles/global-styles/highlight.js.min.css
@@ -0,0 +1,126 @@
+/*!
+ Theme: StackOverflow Dark
+ Description: Dark theme as used on stackoverflow.com
+ Author: stackoverflow.com
+ Maintainer: @Hirse
+ Website: https://github.com/StackExchange/Stacks
+ License: MIT
+ Updated: 2021-05-15
+
+ Updated for @stackoverflow/stacks v0.64.0
+ Code Blocks: /blob/v0.64.0/lib/css/components/_stacks-code-blocks.less
+ Colors: /blob/v0.64.0/lib/css/exports/_stacks-constants-colors.less
+*/
+
+.hljs {
+ /* var(--highlight-color) */
+ color: #ffffff;
+ /* var(--highlight-bg) */
+ background: #1c1b1b;
+}
+
+.hljs-subst {
+ /* var(--highlight-color) */
+ color: #ffffff;
+}
+
+.hljs-comment {
+ /* var(--highlight-comment) */
+ color: #999999;
+}
+
+.hljs-keyword,
+.hljs-selector-tag,
+.hljs-meta .hljs-keyword,
+.hljs-doctag,
+.hljs-section {
+ /* var(--highlight-keyword) */
+ color: #88aece;
+}
+
+.hljs-attr {
+ /* var(--highlight-attribute); */
+ color: #88aece;
+}
+
+.hljs-attribute {
+ /* var(--highlight-symbol) */
+ color: #c59bc1;
+}
+
+.hljs-name,
+.hljs-type,
+.hljs-number,
+.hljs-selector-id,
+.hljs-quote,
+.hljs-template-tag {
+ /* var(--highlight-namespace) */
+ color: #f08d49;
+}
+
+.hljs-selector-class {
+ /* var(--highlight-keyword) */
+ color: #88aece;
+}
+
+.hljs-string,
+.hljs-regexp,
+.hljs-symbol,
+.hljs-variable,
+.hljs-template-variable,
+.hljs-link,
+.hljs-selector-attr {
+ /* var(--highlight-variable) */
+ color: #b5bd68;
+}
+
+.hljs-meta,
+.hljs-selector-pseudo {
+ /* var(--highlight-keyword) */
+ color: #88aece;
+}
+
+.hljs-built_in,
+.hljs-title,
+.hljs-literal {
+ /* var(--highlight-literal) */
+ color: #f08d49;
+}
+
+.hljs-bullet,
+.hljs-code {
+ /* var(--highlight-punctuation) */
+ color: #cccccc;
+}
+
+.hljs-meta .hljs-string {
+ /* var(--highlight-variable) */
+ color: #b5bd68;
+}
+
+.hljs-deletion {
+ /* var(--highlight-deletion) */
+ color: #de7176;
+}
+
+.hljs-addition {
+ /* var(--highlight-addition) */
+ color: #76c490;
+}
+
+.hljs-emphasis {
+ font-style: italic;
+}
+
+.hljs-strong {
+ font-weight: bold;
+}
+
+.hljs-formula,
+.hljs-operator,
+.hljs-params,
+.hljs-property,
+.hljs-punctuation,
+.hljs-tag {
+ /* purposely ignored */
+}
diff --git a/projects/ui/src/Styles/global-styles/index.ts b/projects/ui/src/Styles/global-styles/index.ts
index f1aab047..2a390f25 100644
--- a/projects/ui/src/Styles/global-styles/index.ts
+++ b/projects/ui/src/Styles/global-styles/index.ts
@@ -7,7 +7,9 @@ import "./style-reset.css";
// prettier-ignore
import "./fontFace.css";
// prettier-ignore
-import "./graphiql.min.css";
+// import "./graphiql.min.css";
+// prettier-ignore
+import "./highlight.js.min.css";
export const globalStyles = css`
${mantineGlobalStyles}
diff --git a/projects/ui/src/Styles/global-styles/mantine-overrides.style.ts b/projects/ui/src/Styles/global-styles/mantine-overrides.style.ts
index e020250e..2872cc26 100644
--- a/projects/ui/src/Styles/global-styles/mantine-overrides.style.ts
+++ b/projects/ui/src/Styles/global-styles/mantine-overrides.style.ts
@@ -63,4 +63,14 @@ export const mantineGlobalStyles = css`
}
}
}
+
+ code.mantine-Code-root {
+ background-color: ${colors.januaryGreyDark1};
+ white-space: nowrap;
+ }
+
+ input:disabled {
+ background-color: ${colors.januaryGrey} !important;
+ color: ${colors.defaultText} !important;
+ }
`;
diff --git a/projects/ui/src/Styles/global-styles/site.style.ts b/projects/ui/src/Styles/global-styles/site.style.ts
index 4f5c8cd7..cba6fbfd 100644
--- a/projects/ui/src/Styles/global-styles/site.style.ts
+++ b/projects/ui/src/Styles/global-styles/site.style.ts
@@ -2,7 +2,7 @@ import { css } from "@emotion/react";
import { colors } from "../colors";
export const siteGlobalStyles = css`
- * {
+ *:not(code) {
color: ${colors.neptuneBlue};
}
//
diff --git a/projects/ui/src/Styles/global-styles/style-reset.css b/projects/ui/src/Styles/global-styles/style-reset.css
index 2f7a0049..28dd33e3 100644
--- a/projects/ui/src/Styles/global-styles/style-reset.css
+++ b/projects/ui/src/Styles/global-styles/style-reset.css
@@ -5,7 +5,7 @@
*
*******************************
******************************/
-* {
+*:not(code) {
font-family: "Proxima Nova", "Open Sans", "Helvetica", "Arial", "sans-serif" !important;
box-sizing: border-box;
diff --git a/projects/ui/src/Styles/shared/FormModalStyles.tsx b/projects/ui/src/Styles/shared/FormModalStyles.tsx
index 7734e143..d35dd5a3 100644
--- a/projects/ui/src/Styles/shared/FormModalStyles.tsx
+++ b/projects/ui/src/Styles/shared/FormModalStyles.tsx
@@ -38,13 +38,21 @@ export namespace FormModalStyles {
}
`;
- export const BodyContainerForm = styled.form`
- padding: ${modalDefaultPadding};
- padding-top: 0px;
- flex-direction: column;
- display: flex;
- gap: 20px;
- `;
+ export const BodyContainerForm = styled.form<{ padding?: string }>(
+ ({ padding }) => css`
+ ${!!padding
+ ? css`
+ padding: ${padding};
+ `
+ : css`
+ padding: ${modalDefaultPadding};
+ padding-top: 0px;
+ `}
+ flex-direction: column;
+ display: flex;
+ gap: 20px;
+ `
+ );
export const Title = styled.div`
font-size: 1.7rem;
diff --git a/projects/ui/src/Styles/shared/Utility.style.tsx b/projects/ui/src/Styles/shared/Utility.style.tsx
index 16420c16..d3bd3731 100644
--- a/projects/ui/src/Styles/shared/Utility.style.tsx
+++ b/projects/ui/src/Styles/shared/Utility.style.tsx
@@ -20,13 +20,22 @@ export namespace UtilityStyles {
justify-content: center;
`;
- export const NavLinkContainer = styled.div(
- ({ theme }) => css`
+ export const NavLinkContainer = styled.div<{
+ withArrow?: boolean;
+ flexCenter?: boolean;
+ }>(
+ ({ theme, withArrow = true, flexCenter = false }) => css`
font-size: 0.95rem;
a {
position: relative;
font-weight: 500;
padding-right: 5px;
+ ${flexCenter &&
+ css`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ `}
:hover {
color: ${theme.seaBlue};
text-decoration: underline;
@@ -34,16 +43,19 @@ export namespace UtilityStyles {
:active {
color: ${theme.oceanBlue};
}
- :after {
- content: "";
- border-top: 2px solid;
- border-right: 2px solid;
- border-color: currentColor;
- width: 10px;
- height: 10px;
- display: inline-block;
- transform: translateX(2px) rotate(45deg);
- }
+ ${!!withArrow &&
+ css`
+ :after {
+ content: "";
+ border-top: 2px solid;
+ border-right: 2px solid;
+ border-color: currentColor;
+ width: 10px;
+ height: 10px;
+ display: inline-block;
+ transform: translateX(2px) rotate(45deg);
+ }
+ `}
}
`
);
diff --git a/projects/ui/src/Utility/AdminUtility/CustomMetadataEditor.tsx b/projects/ui/src/Utility/AdminUtility/CustomMetadataEditor.tsx
new file mode 100644
index 00000000..dceb9d03
--- /dev/null
+++ b/projects/ui/src/Utility/AdminUtility/CustomMetadataEditor.tsx
@@ -0,0 +1,279 @@
+import { Box, Flex, Input, Text } from "@mantine/core";
+import { FormEvent, useMemo, useRef, useState } from "react";
+import toast from "react-hot-toast";
+import {
+ UpsertAppMetadataParams,
+ UpsertSubscriptionMetadataParams,
+ useUpsertAppMetadataMutation,
+ useUpsertSubscriptionMetadataMutation,
+} from "../../Apis/gg_hooks";
+import { Icon } from "../../Assets/Icons";
+import { Button } from "../../Components/Common/Button";
+import { DataPairPill } from "../../Components/Common/DataPairPill";
+import { useIsAdmin } from "../../Context/AuthContext";
+import { colors } from "../../Styles";
+import { FilterStyles } from "../../Styles/shared/Filters.style";
+import { shallowEquals } from "../utility";
+import { SharedMetadataProps } from "./MetadataDisplay";
+
+export const CustomMetadataEditor = ({
+ item,
+ isEditingCustomMetadata,
+ customMetadata,
+ rateLimitInfo,
+ onIsEditingCustomMetadataChange,
+}: SharedMetadataProps & {
+ isEditingCustomMetadata: boolean;
+ onIsEditingCustomMetadataChange: (newIsEditingMetadata: boolean) => void;
+}) => {
+ //
+ // region State
+ //
+ const formRef = useRef(null);
+ const [metaKey, setMetaKey] = useState("");
+ const [metaValue, setMetaValue] = useState("");
+ const metaKeyInputRef = useRef(null);
+ const isAdmin = useIsAdmin();
+
+ const [editedCustomMetadata, setEditedCustomMetadata] = useState<
+ Record
+ >(customMetadata ?? {});
+
+ const editedCustomMetadataKeys = useMemo(
+ () => Object.keys(editedCustomMetadata ?? {}),
+ [editedCustomMetadata]
+ );
+
+ //
+ // region Saving Data
+ //
+ const { trigger: upsertAppMetadata } = useUpsertAppMetadataMutation();
+ const { trigger: upsertSubscriptionMetadata } =
+ useUpsertSubscriptionMetadataMutation();
+ const onSave = async () => {
+ // Stop here if the object wasn't edited.
+ if (shallowEquals(editedCustomMetadata, customMetadata)) {
+ return;
+ }
+ (async () => {
+ if ("applicationId" in item) {
+ // This is a Subscription
+ const payload: UpsertSubscriptionMetadataParams["arg"] = {
+ customMetadata: editedCustomMetadata,
+ rateLimit: rateLimitInfo,
+ subscription: item,
+ };
+ // using the same upsert operation but in different contexts to better display the warning messages based on the context of the call
+ if (!!item.metadata) {
+ // Updating existing metadata
+ await toast.promise(upsertSubscriptionMetadata(payload), {
+ error: "There was an error updating the subscription metadata.",
+ loading: "Updating the subscription metadata.",
+ success: "Updated the subscription metadata!",
+ });
+ } else {
+ // Creating metadata
+ await toast.promise(upsertSubscriptionMetadata(payload), {
+ error: "There was an error creating the subscription metadata.",
+ loading: "Creating the subscription metadata.",
+ success: "Created the subscription metadata!",
+ });
+ }
+ } else {
+ // This is an App
+ const payload: UpsertAppMetadataParams["arg"] = {
+ customMetadata: editedCustomMetadata,
+ rateLimit: rateLimitInfo,
+ appId: item.id,
+ };
+ if (!!item.metadata) {
+ // Updating existing metadata
+ await toast.promise(upsertAppMetadata(payload), {
+ error: "There was an error updating the app metadata.",
+ loading: "Updating the app metadata.",
+ success: "Updated the app metadata!",
+ });
+ } else {
+ // Creating metadata
+ await toast.promise(upsertAppMetadata(payload), {
+ error: "There was an error updating the app metadata.",
+ loading: "Creating the app metadata.",
+ success: "Created the app metadata!",
+ });
+ }
+ }
+ onIsEditingCustomMetadataChange(false);
+ })();
+ };
+
+ //
+ // region Helpers
+ //
+
+ const addEditedMetadata = async (e?: FormEvent) => {
+ e?.preventDefault();
+ const isValid = formRef.current?.reportValidity();
+ if (!isValid || !metaKey || !metaValue) {
+ return;
+ }
+ setEditedCustomMetadata({
+ ...editedCustomMetadata,
+ [metaKey]: metaValue,
+ });
+ setMetaKey("");
+ setMetaValue("");
+ metaKeyInputRef.current?.focus();
+ };
+
+ const cancelEditingMetadata = () => {
+ setMetaKey("");
+ setMetaValue("");
+ setEditedCustomMetadata(customMetadata ?? {});
+ onIsEditingCustomMetadataChange(false);
+ };
+
+ const beginEditingMetadata = () => onIsEditingCustomMetadataChange(true);
+
+ //
+ // region Render
+ //
+ return (
+
+ );
+};
diff --git a/projects/ui/src/Utility/AdminUtility/MetadataDisplay.tsx b/projects/ui/src/Utility/AdminUtility/MetadataDisplay.tsx
new file mode 100644
index 00000000..6570b44e
--- /dev/null
+++ b/projects/ui/src/Utility/AdminUtility/MetadataDisplay.tsx
@@ -0,0 +1,72 @@
+import { Box } from "@mantine/core";
+import { useEffect, useState } from "react";
+import { App, RateLimit, Subscription } from "../../Apis/api-types";
+import { useIsAdmin } from "../../Context/AuthContext";
+import { useInArea } from "../utility";
+import { CustomMetadataEditor } from "./CustomMetadataEditor";
+import { RateLimitSection } from "./RateLimitSection";
+
+export interface SharedMetadataProps {
+ item: App | Subscription;
+ customMetadata: Record | undefined;
+ rateLimitInfo: RateLimit | undefined;
+}
+
+/**
+ * The `MetadataDisplay` handles the viewing and editing of custom metadata and rate limit information.
+ */
+export const MetadataDisplay = ({
+ customMetadata,
+ rateLimitInfo,
+ onIsWideChange,
+ ...props
+}: SharedMetadataProps & {
+ onIsWideChange?: (newIsWide: boolean) => void;
+}) => {
+ const isAdmin = useIsAdmin();
+ const [isEditingCustomMetadata, setIsEditingCustomMetadata] = useState(false);
+ const [isEditingRateLimit, setIsEditingRateLimit] = useState(false);
+
+ useEffect(() => {
+ onIsWideChange?.(isEditingCustomMetadata || isEditingCustomMetadata);
+ }, [isEditingCustomMetadata, isEditingRateLimit]);
+
+ const inAppDetailsPage = useInArea([`apps/${props.item.id}`]);
+ const isSubscription = "apiProductId" in props.item;
+
+ // This component is reused for apps and subscription rate limit & metadata.
+ // Here we show the rate limit when:
+ // - This is an admin
+ // - We are on the app details page
+ // - This is a subscription
+ const showingRateLimit = !!isAdmin || inAppDetailsPage || isSubscription;
+
+ return (
+
+
+ setIsEditingCustomMetadata(value)
+ }
+ customMetadata={customMetadata}
+ rateLimitInfo={rateLimitInfo}
+ {...props}
+ />
+ {showingRateLimit && (
+
+
+ setIsEditingRateLimit(newIsEditingRateLimit)
+ }
+ customMetadata={customMetadata}
+ rateLimitInfo={rateLimitInfo}
+ {...props}
+ />
+
+ )}
+
+ );
+};
diff --git a/projects/ui/src/Utility/AdminUtility/RateLimitEditor.tsx b/projects/ui/src/Utility/AdminUtility/RateLimitEditor.tsx
new file mode 100644
index 00000000..8068d15e
--- /dev/null
+++ b/projects/ui/src/Utility/AdminUtility/RateLimitEditor.tsx
@@ -0,0 +1,223 @@
+import { Box, Flex, NumberInput, Select, Text } from "@mantine/core";
+import { FormEvent, useRef, useState } from "react";
+import toast from "react-hot-toast";
+import { RateLimitUnit, rateLimitUnitOptions } from "../../Apis/api-types";
+import {
+ UpsertAppMetadataParams,
+ UpsertSubscriptionMetadataParams,
+ useUpsertAppMetadataMutation,
+ useUpsertSubscriptionMetadataMutation,
+} from "../../Apis/gg_hooks";
+import { Button } from "../../Components/Common/Button";
+import { useIsAdmin } from "../../Context/AuthContext";
+import { colors } from "../../Styles";
+import { shallowEquals } from "../utility";
+import { SharedMetadataProps } from "./MetadataDisplay";
+
+export type RateLimitEditorProps = SharedMetadataProps & {
+ inAppDetailsPage: boolean;
+ isSubscription: boolean;
+ isEditingRateLimit: boolean;
+ onIsEditingRateLimitChange: (newIsEditinRateLimit: boolean) => void;
+};
+
+export const RateLimitEditor = ({
+ item,
+ isEditingRateLimit,
+ customMetadata,
+ rateLimitInfo,
+ onIsEditingRateLimitChange,
+}: RateLimitEditorProps) => {
+ //
+ // region State
+ //
+ const initialRateLimitInfo = rateLimitInfo ?? {
+ // This is the default if no rate limit is specified.
+ requestsPerUnit: "0",
+ unit: RateLimitUnit[RateLimitUnit.UNKNOWN],
+ };
+ const rateLimitExists = !!rateLimitInfo;
+ let initialRPU = 0;
+ try {
+ initialRPU = Number.parseInt(initialRateLimitInfo.requestsPerUnit ?? "0");
+ } catch {}
+ const formRef = useRef(null);
+ const [requestsPerUnit, setRequestsPerUnit] = useState(initialRPU);
+ const [unit, setUnit] = useState(initialRateLimitInfo.unit);
+ const requestsPerUnitRef = useRef(null);
+ const isAdmin = useIsAdmin();
+
+ //
+ // region Saving Data
+ //
+ const { trigger: upsertAppMetadata } = useUpsertAppMetadataMutation();
+ const { trigger: upsertSubscriptionMetadata } =
+ useUpsertSubscriptionMetadataMutation();
+ const onSave = async (e: FormEvent) => {
+ e.preventDefault();
+ const newRateLimitInfo = {
+ requestsPerUnit: requestsPerUnit.toString(),
+ unit,
+ };
+ const newCustomMetadata = customMetadata ?? {};
+ // Stop here if the object wasn't edited.
+ if (shallowEquals(newRateLimitInfo, initialRateLimitInfo)) {
+ return;
+ }
+ (async () => {
+ if ("applicationId" in item) {
+ // This is a Subscription
+ const payload: UpsertSubscriptionMetadataParams["arg"] = {
+ customMetadata: newCustomMetadata,
+ rateLimit: newRateLimitInfo,
+ subscription: item,
+ };
+ // using the same upsert operation but in different contexts to better display the warning messages based on the context of the call
+ if (!!item.metadata) {
+ // Updating existing metadata
+ await toast.promise(upsertSubscriptionMetadata(payload), {
+ error: "There was an error updating the subscription metadata.",
+ loading: "Updating the subscription metadata.",
+ success: "Updated the subscription metadata!",
+ });
+ } else {
+ // Creating metadata
+ await toast.promise(upsertSubscriptionMetadata(payload), {
+ error: "There was an error creating the subscription metadata.",
+ loading: "Creating the subscription metadata.",
+ success: "Created the subscription metadata!",
+ });
+ }
+ } else {
+ // This is an App
+ const payload: UpsertAppMetadataParams["arg"] = {
+ customMetadata: newCustomMetadata,
+ rateLimit: newRateLimitInfo,
+ appId: item.id,
+ };
+ if (!!item.metadata) {
+ // Updating existing metadata
+ await toast.promise(upsertAppMetadata(payload), {
+ error: "There was an error updating the app metadata.",
+ loading: "Updating the app metadata.",
+ success: "Updated the app metadata!",
+ });
+ } else {
+ // Creating metadata
+ await toast.promise(upsertAppMetadata(payload), {
+ error: "There was an error updating the app metadata.",
+ loading: "Creating the app metadata.",
+ success: "Created the app metadata!",
+ });
+ }
+ }
+ onIsEditingRateLimitChange(false);
+ })();
+ };
+
+ //
+ // region Helpers
+ //
+
+ const cancelEditingRateLimit = () => {
+ setRequestsPerUnit(initialRPU);
+ setUnit(initialRateLimitInfo.unit);
+ onIsEditingRateLimitChange(false);
+ };
+
+ const beginEditingRateLimit = () => onIsEditingRateLimitChange(true);
+
+ //
+ // region Render
+ //
+ return (
+
+ );
+};
diff --git a/projects/ui/src/Utility/AdminUtility/RateLimitSection.tsx b/projects/ui/src/Utility/AdminUtility/RateLimitSection.tsx
new file mode 100644
index 00000000..4403e497
--- /dev/null
+++ b/projects/ui/src/Utility/AdminUtility/RateLimitSection.tsx
@@ -0,0 +1,64 @@
+import { Box, Text } from "@mantine/core";
+import { useState } from "react";
+import { useListSubscriptionsForApp } from "../../Apis/gg_hooks";
+import { Button } from "../../Components/Common/Button";
+import { WarningAlert } from "../../Components/Common/WarningAlert";
+import { useIsAdmin } from "../../Context/AuthContext";
+import { RateLimitEditor, RateLimitEditorProps } from "./RateLimitEditor";
+
+export const RateLimitSection = ({ ...props }: RateLimitEditorProps) => {
+ //
+ // region State
+ //
+ const isAdmin = useIsAdmin();
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const { data: appSubscriptions } = useListSubscriptionsForApp(
+ isExpanded && !props.isSubscription && isAdmin ? props.item.id : null
+ );
+
+ //
+ // region Render
+ //
+ return (
+ <>
+
+ Rate Limit
+
+ {isAdmin && !isExpanded && !props.isSubscription ? (
+ setIsExpanded(true)}>
+ View Rate Limit Information
+
+ ) : (
+
+ )}
+
+ {/* Admins will see this message when editing the App Rate Limits. */}
+ {!props.isSubscription && !!appSubscriptions && (
+
+
+ Please note that Subscriptions are able to override this Apps Rate
+ Limit value.
+
+ {Array.isArray(appSubscriptions) && (
+ <>
+ This App has {appSubscriptions.length} Subscription
+ {appSubscriptions.length === 1 ? "" : "s"}.
+ >
+ )}
+
+
+ )}
+
+ {/* Non-admins will see this message on their App details pages. */}
+ {!props.isSubscription && !!props.inAppDetailsPage && !isAdmin && (
+
+
+ Please note that Subscriptions are able to override this Apps Rate
+ Limit value.
+
+
+ )}
+ >
+ );
+};
diff --git a/projects/ui/src/Utility/UserChecker.tsx b/projects/ui/src/Utility/UserChecker.tsx
new file mode 100644
index 00000000..3dead1b1
--- /dev/null
+++ b/projects/ui/src/Utility/UserChecker.tsx
@@ -0,0 +1,33 @@
+import { useEffect, useRef } from "react";
+import { useCreateUserMutation, useGetCurrentUser } from "../Apis/gg_hooks";
+
+const UserChecker = () => {
+ // For Gloo Gateway:
+ // If the user is logged into the IDP, but the portal server doesn't return data for it,
+ // then the user will need to be created.
+ const { error, mutate } = useGetCurrentUser();
+ const { trigger: createUser } = useCreateUserMutation();
+ const triedToCreateUser = useRef(false);
+
+ useEffect(() => {
+ if (
+ // This error occurs when the user has logged in through their IDP,
+ // but does not exist in the portal server DB.
+ error?.message ===
+ "logged in user is not found, this should not happen" &&
+ !triedToCreateUser.current
+ ) {
+ (async () => {
+ // We can then create the current user.
+ await createUser();
+ // and then refetch their user information.
+ mutate();
+ })();
+ triedToCreateUser.current = true;
+ }
+ }, [error, createUser, mutate]);
+
+ return null;
+};
+
+export default UserChecker;
diff --git a/projects/ui/src/Utility/utility.ts b/projects/ui/src/Utility/utility.ts
index 64bf0440..1ec05960 100644
--- a/projects/ui/src/Utility/utility.ts
+++ b/projects/ui/src/Utility/utility.ts
@@ -2,10 +2,13 @@
// From https://stackoverflow.com/a/65996386
// navigator.clipboard.writeText doesn't always work.
+import { DependencyList, useEffect, useMemo } from "react";
+import { useLocation } from "react-router-dom";
import {
ErrorMessageResponse,
isErrorMessageResponse,
} from "../Apis/api-types";
+import { CustomPage } from "../user_variables.tmplr";
//
export async function copyToClipboard(textToCopy: string) {
@@ -121,5 +124,61 @@ export const customLog = (...args: Parameters) => {
export const filterMetadataToDisplay = ([pairKey]: [
key: string,
- value: string
+ value: string,
]) => pairKey !== "imageURL";
+
+const customPagePrefix = "/pages/";
+
+export const getCustomPagePath = (page: CustomPage | string) => {
+ const pagePath = typeof page === "string" ? page : page.path;
+ return (
+ customPagePrefix +
+ encodeURIComponent(pagePath.replace(/^\//g, "").replaceAll(/\./g, "_"))
+ );
+};
+
+// Actual hook for above function definitions
+export function useEventListener(
+ element: Elem,
+ eventName: string,
+ listener: EventListenerOrEventListenerObject,
+ dependencies: DependencyList = [],
+ skip = false
+) {
+ useEffect(() => {
+ // If element doesn't currently exist or hook isn't active then don't add a listener
+ if (!element || !element.addEventListener || skip) return;
+
+ element.addEventListener(eventName, listener);
+ return () => {
+ element.removeEventListener(eventName, listener);
+ };
+ }, [element, skip, ...dependencies]);
+}
+
+export const useInArea = (paths: string[]) => {
+ const routerLocation = useLocation();
+ return useMemo(() => {
+ return paths.some((s) => {
+ return (
+ routerLocation.pathname.includes(s) ||
+ routerLocation.pathname.includes(getCustomPagePath(s))
+ );
+ });
+ }, [routerLocation.pathname, paths]);
+};
+
+export const shallowEquals = (
+ a: Record | undefined,
+ b: Record | undefined
+) => {
+ if (!a && !b) {
+ return true;
+ }
+ if (!a || !b || Object.keys(a).length !== Object.keys(b).length) {
+ return false;
+ }
+ // The number of entries should be the same between a and b here,
+ // so we can do the check once.
+ return Object.entries(a).every(([k, v]) => b[k] === v);
+};
diff --git a/projects/ui/src/user_variables.tmplr.ts b/projects/ui/src/user_variables.tmplr.ts
index 9d083fa1..bac47ddc 100644
--- a/projects/ui/src/user_variables.tmplr.ts
+++ b/projects/ui/src/user_variables.tmplr.ts
@@ -152,10 +152,10 @@ export const homeImageURL = templateString(
/**
* This is optional. Used on the API's ("/apis") page.
*/
-export const apisImageURL = templateString(
- "{{ tmplr.apisImageURL }}",
- insertedEnvironmentVariables?.VITE_APIS_IMAGE_URL,
- import.meta.env.VITE_APIS_IMAGE_URL,
+export const bannerImageURL = templateString(
+ "{{ tmplr.bannerImageURL }}",
+ insertedEnvironmentVariables?.VITE_BANNER_IMAGE_URL,
+ import.meta.env.VITE_BANNER_IMAGE_URL,
""
);
@@ -169,10 +169,111 @@ export const logoImageURL = templateString(
""
);
+export type CustomPage = {
+ title: string;
+ path: string;
+};
+/**
+ * This is an optional, JSON serialized array of objects.
+ * Each object has a "title" and "path" that corresponds to a ".html" or ".md" file in the `projects/ui/src/public` folder.
+ * The name is the text that is displayed in the navbar header link.
+ * For example:
+ * '[{"title":"Custom Page","path":"/custom-page.md"}, {"title":"Another Page","path":"/some-path/another-page.html"}]'
+ */
+export const customPages = JSON.parse(
+ templateString(
+ "{{ tmplr.customPages }}",
+ insertedEnvironmentVariables?.VITE_CUSTOM_PAGES,
+ import.meta.env.VITE_CUSTOM_PAGES,
+ "[]"
+ )
+) as Array;
+// TODO: Check the paths and if any overlap with the dev-portal-starter.
+// console.log("Loaded custom pages", customPages);
+
+/**
+ * This is optional. Check the README for usage.
+ */
+export const swaggerPrefillApiKey = (() => {
+ const parsed = JSON.parse(
+ templateString(
+ "{{ tmplr.swaggerPrefillApiKey }}",
+ insertedEnvironmentVariables?.VITE_SWAGGER_PREFILL_API_KEY,
+ import.meta.env.VITE_SWAGGER_PREFILL_API_KEY,
+ "[]"
+ )
+ ) as [string, string] | [];
+ return parsed.length === 2
+ ? {
+ authDefinitionKey: parsed[0],
+ apiKeyValue: parsed[1],
+ }
+ : undefined;
+})();
+
+/**
+ * This is optional. Check the README for usage.
+ */
+export const swaggerPrefillOauth = (() => {
+ const parsed = JSON.parse(
+ templateString(
+ "{{ tmplr.swaggerPrefillOauth }}",
+ insertedEnvironmentVariables?.VITE_SWAGGER_PREFILL_OAUTH,
+ import.meta.env.VITE_SWAGGER_PREFILL_OAUTH,
+ "{}"
+ )
+ );
+ return Object.keys(parsed).length > 0 ? parsed : undefined;
+})();
+
+/**
+ * This is optional. Check the README for usage.
+ */
+export const swaggerPrefillBasic = (() => {
+ const parsed = JSON.parse(
+ templateString(
+ "{{ tmplr.swaggerPrefillBasic }}",
+ insertedEnvironmentVariables?.VITE_SWAGGER_PREFILL_BASIC,
+ import.meta.env.VITE_SWAGGER_PREFILL_BASIC,
+ "[]"
+ )
+ ) as [string, string, string] | [];
+ return parsed.length === 3
+ ? {
+ authDefinitionKey: parsed[0],
+ username: parsed[1],
+ password: parsed[2],
+ }
+ : undefined;
+})();
+
+/**
+ * This is optional.
+ */
+export enum AppAuthMethod {
+ ALL,
+ OAUTH,
+ API_KEY,
+}
+export const defaultAppAuthMethod = templateString(
+ "{{ tmplr.defaultAppAuthMethod }}",
+ insertedEnvironmentVariables?.VITE_DEFAULT_APP_AUTH,
+ import.meta.env.VITE_DEFAULT_APP_AUTH,
+ "ALL"
+).toUpperCase() as keyof typeof AppAuthMethod;
+if (AppAuthMethod[defaultAppAuthMethod] === undefined) {
+ // eslint-disable-next-line no-console
+ console.error(
+ 'The value for `VITE_DEFAULT_APP_AUTH` must be: "OAUTH", "ALL", or "API_KEY".'
+ );
+}
+
+/**
+ * This is optional.
+ */
export const apiPageReload = templateString(
- "{{ tmplr.apiPageReload }}",
- insertedEnvironmentVariables?.VITE_API_PAGE_RELOAD,
- import.meta.env.VITE_API_PAGE_RELOAD,
- "false"
+ "{{ tmplr.apiPageReload }}",
+ insertedEnvironmentVariables?.VITE_API_PAGE_RELOAD,
+ import.meta.env.VITE_API_PAGE_RELOAD,
+ "false"
);
-
diff --git a/projects/ui/yarn.lock b/projects/ui/yarn.lock
index 0b101183..d4a32f79 100644
--- a/projects/ui/yarn.lock
+++ b/projects/ui/yarn.lock
@@ -5951,6 +5951,11 @@ highlight.js@^10.4.1, highlight.js@~10.7.0:
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531"
integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==
+highlight.js@^11.10.0:
+ version "11.10.0"
+ resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.10.0.tgz#6e3600dc4b33d6dc23d5bd94fbf72405f5892b92"
+ integrity sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==
+
hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.3.1:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
diff --git a/readme_assets/custom-pages-navbar.png b/readme_assets/custom-pages-navbar.png
new file mode 100644
index 00000000..ac2108f6
Binary files /dev/null and b/readme_assets/custom-pages-navbar.png differ
diff --git a/scripts/startup.sh b/scripts/startup.sh
index 4c862a0d..15289af0 100644
--- a/scripts/startup.sh
+++ b/scripts/startup.sh
@@ -9,11 +9,11 @@
#############################
# Install dependencies.
-yarn --cwd ./projects/ui
-yarn --cwd ./projects/server
+yarn --cwd ./projects/ui || exit 1
+yarn --cwd ./projects/server || exit 1
# Build the UI.
-yarn --cwd ./projects/ui build
+yarn --cwd ./projects/ui build || exit 1
# Cleanup old server files if they exist.
rm -rf ./projects/server/public/dist || true