├── .github ├── dependabot.yml └── workflows │ ├── dependabot.yaml │ ├── main.yaml │ └── run-release.yaml ├── .gitignore ├── .husky └── pre-commit ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── CODEOWNERS ├── LICENSE ├── README.md ├── SECURITY.md ├── docs └── screenshot.png ├── examples └── nextjs │ ├── .env.example │ ├── .env.local │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── public │ └── spinner.svg │ ├── src │ ├── components │ │ ├── Authenticating.tsx │ │ ├── Header.tsx │ │ ├── KeyManager.tsx │ │ ├── Layout.tsx │ │ ├── Spinner.tsx │ │ ├── ThemePicker.tsx │ │ └── Toggle.tsx │ ├── contexts │ │ └── ThemeContext.tsx │ ├── env.ts │ ├── pages │ │ ├── _app.tsx │ │ ├── index.tsx │ │ └── keys.tsx │ └── styles │ │ └── globals.css │ ├── tailwind.config.js │ └── tsconfig.json ├── package-lock.json ├── package.json ├── packages └── react │ ├── .eslintignore │ ├── .eslintrc.cjs │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── src │ ├── components │ │ ├── ApiKeyManager.module.css │ │ ├── ApiKeyManager.tsx │ │ ├── ConsumerControl.module.css │ │ ├── ConsumerControl.tsx │ │ ├── ConsumerLoading.module.css │ │ ├── ConsumerLoading.tsx │ │ ├── CreateConsumer.module.css │ │ ├── CreateConsumer.tsx │ │ ├── KeyControl.module.css │ │ ├── KeyControl.tsx │ │ ├── SimpleMenu.module.css │ │ ├── SimpleMenu.tsx │ │ ├── context.ts │ │ ├── icons.module.css │ │ └── icons.tsx │ ├── default-provider.ts │ ├── index.tsx │ ├── interfaces.ts │ ├── refresh-provider.ts │ └── types.d.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── tsup.config.js └── tsconfig.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: npm 8 | directory: / 9 | schedule: 10 | interval: weekly 11 | open-pull-requests-limit: 0 12 | allow: 13 | - dependency-type: "production" 14 | groups: 15 | eslint: 16 | patterns: 17 | - "*eslint*" 18 | prettier: 19 | patterns: 20 | - "*prettier*" 21 | - package-ecosystem: docker 22 | directory: / 23 | schedule: 24 | interval: weekly 25 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yaml: -------------------------------------------------------------------------------- 1 | name: Dependabot 2 | on: workflow_call 3 | permissions: 4 | pull-requests: write 5 | jobs: 6 | dependabot: 7 | runs-on: ubuntu-latest 8 | if: ${{ github.actor == 'dependabot[bot]' }} 9 | steps: 10 | - name: Dependabot metadata 11 | id: metadata 12 | uses: dependabot/fetch-metadata@v2 13 | with: 14 | github-token: ${{ secrets.GITHUB_TOKEN }} 15 | - name: Approve a PR 16 | run: gh pr review --approve "$PR_URL" 17 | env: 18 | PR_URL: ${{github.event.pull_request.html_url}} 19 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 20 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Main 2 | on: 3 | push: 4 | 5 | jobs: 6 | build: 7 | name: Build & Test 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: read 11 | packages: write 12 | steps: 13 | - uses: actions/checkout@v4 14 | - run: npm ci 15 | - run: npm run build 16 | 17 | publish: 18 | name: Publish 19 | if: startsWith(github.ref, 'refs/tags/v') 20 | needs: build 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: write 24 | packages: write 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version-file: ".nvmrc" 30 | cache: "npm" 31 | registry-url: "https://npm.pkg.github.com" 32 | 33 | - name: Archive Example 34 | run: zip -r nextjs-example.zip . 35 | working-directory: ./examples/nextjs 36 | 37 | - run: npm ci 38 | - run: npm run build 39 | working-directory: ./packages/react 40 | 41 | - run: npm publish 42 | working-directory: ./packages/react 43 | env: 44 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | # To the public NPM Registry 47 | - uses: actions/setup-node@v4 48 | with: 49 | node-version-file: ".nvmrc" 50 | cache: "npm" 51 | registry-url: "https://registry.npmjs.com" 52 | scope: "@zuplo" 53 | 54 | - run: npm publish --access public 55 | working-directory: ./packages/react 56 | env: 57 | NODE_AUTH_TOKEN: ${{ secrets.PUBLIC_NPM_REGISTRY_TOKEN }} 58 | 59 | - uses: ncipollo/release-action@v1 60 | with: 61 | token: ${{ secrets.GITHUB_TOKEN }} 62 | artifacts: examples/nextjs/nextjs-example.zip 63 | -------------------------------------------------------------------------------- /.github/workflows/run-release.yaml: -------------------------------------------------------------------------------- 1 | ## Bumps the (minor) version number of the package 2 | ## Commits and pushes the changes 3 | ## Creates a tag/release 4 | 5 | name: Run Release 6 | on: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | release: 11 | name: Run Release 12 | runs-on: ubuntu-latest 13 | if: github.ref == 'refs/heads/main' # This can only be triggered from main 14 | 15 | permissions: 16 | contents: write 17 | 18 | env: 19 | CHECKOUT_TOKEN: 20 | ${{ secrets.GH_TOKEN_COMMIT_AND_BYPASS_BRANCH_PROTECTION }} 21 | GITHUB_TOKEN: ${{ secrets.GH_CUSTOM_TOKEN }} 22 | GITHUB_NPM_TOKEN: ${{ secrets.GH_CUSTOM_TOKEN }} 23 | NODE_AUTH_TOKEN: ${{ secrets.GH_CUSTOM_TOKEN }} 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | # Override the default token because the built-in 29 | # token cannot trigger other workflows 30 | # https://github.community/t/github-actions-workflow-not-triggering-with-tag-push/17053/2 31 | token: ${{ secrets.GH_TOKEN_COMMIT_AND_BYPASS_BRANCH_PROTECTION }} 32 | - uses: actions/setup-node@v4 33 | with: 34 | node-version: "18.x" 35 | registry-url: "https://npm.pkg.github.com" 36 | 37 | - run: git config --global user.email "integrations@zuplo.com" 38 | - run: git config --global user.name "Integration Service" 39 | 40 | - run: npm ci 41 | 42 | - run: npm run release 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-tailwind 13 | dist-ssr 14 | .eslintcache 15 | 16 | # local env files 17 | .env 18 | .env*.local 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | .DS_Store 25 | *.suo 26 | *.ntvs* 27 | *.njsproj 28 | *.sln 29 | *.sw? -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | dist/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "proseWrap": "always" 3 | } 4 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. Unless a later match takes precedence. 3 | * @zuplo/codeowners -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Zuplo, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | packages/react/README.md -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security 2 | 3 | Zuplo takes the security of our software products and services seriously, 4 | including all of the open source code repositories managed through our Zuplo 5 | organizations, such as [Zuplo](https://github.com/zuplo). 6 | 7 | ## Reporting Security Issues 8 | 9 | If you believe you have found a security vulnerability in any Zuplo-owned 10 | repository, please report it to us through coordinated disclosure. 11 | 12 | **Please do not report security vulnerabilities through public GitHub issues, 13 | discussions, or pull requests.** 14 | 15 | Instead, please send an email to security[@]zuplo.com. 16 | 17 | Please include as much of the information listed below as you can to help us 18 | better understand and resolve the issue: 19 | 20 | - The type of issue (e.g., buffer overflow, SQL injection, or cross-site 21 | scripting) 22 | - Full paths of source file(s) related to the manifestation of the issue 23 | - The location of the affected source code (tag/branch/commit or direct URL) 24 | - Any special configuration required to reproduce the issue 25 | - Step-by-step instructions to reproduce the issue 26 | - Proof-of-concept or exploit code (if possible) 27 | - Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | ## Policy 32 | 33 | See the full Zuplo security policy at 34 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zuplo/api-key-manager/b320bb2b484ac53b8da8753db07d0c3e1ef2d5aa/docs/screenshot.png -------------------------------------------------------------------------------- /examples/nextjs/.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL= 2 | NEXT_PUBLIC_AUTH0_DOMAIN=zuplo-samples.us.auth0.com 3 | NEXT_PUBLIC_AUTH0_CLIENT_ID=OFNbP5hhtsCHkBsXHEtWO72kKQvJtgI3 4 | NEXT_PUBLIC_AUTH0_AUDIENCE=https://api.example.com/ 5 | -------------------------------------------------------------------------------- /examples/nextjs/.env.local: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL=https://sample-auth-translation-api-main-d1b33d3.d2.zuplo.dev 2 | NEXT_PUBLIC_AUTH0_DOMAIN=zuplo-samples.us.auth0.com 3 | NEXT_PUBLIC_AUTH0_CLIENT_ID=OFNbP5hhtsCHkBsXHEtWO72kKQvJtgI3 4 | NEXT_PUBLIC_AUTH0_AUDIENCE=https://api.example.com/ 5 | -------------------------------------------------------------------------------- /examples/nextjs/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /**@type {import('eslint').Linter.Config} */ 2 | // eslint-disable-next-line no-undef 3 | module.exports = { 4 | root: true, 5 | extends: "next", 6 | }; 7 | -------------------------------------------------------------------------------- /examples/nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | .env 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | .env.local 39 | -------------------------------------------------------------------------------- /examples/nextjs/.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /examples/nextjs/README.md: -------------------------------------------------------------------------------- 1 | # API Key Console Sample 2 | 3 | This sample shows how to create your own Developer Console that uses the Zuplo API Key Service (https://dev.zuplo.com/docs) to help your developers manage their own API keys. 4 | 5 | ## Features 6 | 7 | - Auth0 Login 8 | - Zuplo API Key Manager 9 | - Light and dark mode 10 | 11 | ## Run locally 12 | 13 | 1. Clone the repo 14 | 15 | ``` 16 | npx create-next-app --example \ 17 | https://github.com/zuplo/api-key-manager/tree/main/examples/nextjs 18 | ``` 19 | 20 | 2. Add `NEXT_PUBLIC_API_URL` value to `.env.local` file 21 | 22 | If you don't have an API Key service, you can use this one: `https://api-key-live-sample-main-21ced70.d2.zuplo.dev`. 23 | 24 | If you want to create your own, you can follow the tutorial from our blogpost [here](https://zuplo.com/blog/2023/08/08/open-source-release). 25 | 26 | 3. Run the sample 27 | 28 | ``` 29 | npm run dev 30 | ``` 31 | 32 | ### Future iteration TODOs 33 | 34 | - Add delete confirm dialog 35 | - Add roll key dialog with option to choose 36 | -------------------------------------------------------------------------------- /examples/nextjs/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | module.exports = nextConfig; 7 | -------------------------------------------------------------------------------- /examples/nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dev-console-key-api", 3 | "version": "2.5.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@headlessui/react": "^1.7.17", 13 | "@heroicons/react": "^2.0.18", 14 | "@auth0/auth0-react": "2.2.0", 15 | "@types/node": "20.4.2", 16 | "@types/react": "18.2.14", 17 | "@types/react-dom": "18.2.7", 18 | "@zuplo/react-api-key-manager": "^2", 19 | "autoprefixer": "10.4.14", 20 | "eslint": "8.49.0", 21 | "eslint-config-next": "13.4.19", 22 | "next": "14.1.1", 23 | "postcss": "8.4.35", 24 | "react": "18.2.0", 25 | "react-dom": "18.2.0", 26 | "tailwindcss": "3.3.2", 27 | "typescript": "5.1.6" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/nextjs/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /examples/nextjs/public/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | adrian-spinner 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/nextjs/src/components/Authenticating.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from "./Spinner"; 2 | 3 | export default function Authenticating() { 4 | return ( 5 |
6 |
7 | 8 |
{" "} 9 | Authenticating... 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /examples/nextjs/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import { useAuth0 } from "@auth0/auth0-react"; 3 | import ThemePicker from "./ThemePicker"; 4 | 5 | function Header() { 6 | const { isAuthenticated, loginWithRedirect, logout } = useAuth0(); 7 | 8 | const onClick = () => { 9 | if (isAuthenticated) { 10 | logout(); 11 | } else { 12 | loginWithRedirect(); 13 | } 14 | }; 15 | 16 | return ( 17 |
18 | 37 |
38 | ); 39 | } 40 | 41 | export default Header; 42 | -------------------------------------------------------------------------------- /examples/nextjs/src/components/KeyManager.tsx: -------------------------------------------------------------------------------- 1 | import ApiKeyManager, { 2 | DefaultApiKeyManagerProvider, 3 | RefreshProvider, 4 | } from "@zuplo/react-api-key-manager"; 5 | import { useContext, useMemo } from "react"; 6 | import { ThemeContext } from "@/contexts/ThemeContext"; 7 | 8 | interface Props { 9 | apiUrl: string; 10 | accessToken: string; 11 | } 12 | 13 | export default function KeyManager({ apiUrl, accessToken }: Props) { 14 | const [theme] = useContext(ThemeContext); 15 | 16 | const provider = new DefaultApiKeyManagerProvider(apiUrl, accessToken); 17 | 18 | return ( 19 | <> 20 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /examples/nextjs/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from "@auth0/auth0-react"; 2 | import { PropsWithChildren } from "react"; 3 | import Authenticating from "./Authenticating"; 4 | import Header from "./Header"; 5 | import ThemeProvider from "@/contexts/ThemeContext"; 6 | function Layout({ children }: PropsWithChildren) { 7 | const { isLoading } = useAuth0(); 8 | 9 | return ( 10 | 11 |
12 |
13 |
14 |
{isLoading ? : children}
15 |
16 |
17 |
18 | ); 19 | } 20 | 21 | export default Layout; 22 | -------------------------------------------------------------------------------- /examples/nextjs/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | export default function Spinner() { 3 | return spinner; 4 | } 5 | -------------------------------------------------------------------------------- /examples/nextjs/src/components/ThemePicker.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useContext } from "react"; 3 | import Toggle from "./Toggle"; 4 | import { ThemeContext } from "@/contexts/ThemeContext"; 5 | import { MoonIcon, SunIcon } from "@heroicons/react/24/outline"; 6 | 7 | export default function ThemePicker() { 8 | const [theme, setTheme] = useContext(ThemeContext); 9 | const handleThemeChange = (useDarkTheme: boolean) => { 10 | setTheme(useDarkTheme ? "dark" : "light"); 11 | }; 12 | 13 | return ( 14 | } 20 | disabledIcon={} 21 | /> 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /examples/nextjs/src/components/Toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Switch } from "@headlessui/react"; 3 | 4 | type ToggleProps = { 5 | enabledIcon: JSX.Element; 6 | disabledIcon: JSX.Element; 7 | enabledBackgroundStyle: string; 8 | disabledBackgroundStyle: string; 9 | isEnabled: boolean; 10 | onChange: (isEnabled: boolean) => void; 11 | }; 12 | 13 | export default function Toggle({ 14 | enabledIcon, 15 | disabledIcon, 16 | enabledBackgroundStyle, 17 | disabledBackgroundStyle, 18 | isEnabled, 19 | onChange, 20 | }: ToggleProps) { 21 | return ( 22 | 29 | Use setting 30 | 35 | 45 | 55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /examples/nextjs/src/contexts/ThemeContext.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { PropsWithChildren, useEffect, useState } from "react"; 3 | 4 | const getSystemDefaultThemePreference = (): "dark" | "light" => { 5 | if ( 6 | typeof window !== "undefined" && 7 | window.matchMedia("(prefers-color-scheme: dark)").matches 8 | ) { 9 | return "dark"; 10 | } 11 | return "light"; 12 | }; 13 | 14 | export const ThemeContext = React.createContext< 15 | ["dark" | "light", (theme: "dark" | "light") => void] 16 | >([ 17 | getSystemDefaultThemePreference(), 18 | () => { 19 | return; 20 | }, 21 | ]); 22 | 23 | const ThemeProvider = ({ children }: PropsWithChildren) => { 24 | const [theme, setTheme] = useState<"dark" | "light">("light"); 25 | useEffect(() => { 26 | setTheme(getSystemDefaultThemePreference()); 27 | }, []); 28 | 29 | return ( 30 | 31 |
{children}
32 |
33 | ); 34 | }; 35 | 36 | export default ThemeProvider; 37 | -------------------------------------------------------------------------------- /examples/nextjs/src/env.ts: -------------------------------------------------------------------------------- 1 | export const API_URL = process.env.NEXT_PUBLIC_API_URL; 2 | 3 | // Sets the sample fallbacks are for demo purposes only, 4 | // they should be removed in your deployment 5 | const vars: Record = { 6 | NEXT_PUBLIC_AUTH0_DOMAIN: 7 | process.env.NEXT_PUBLIC_AUTH0_DOMAIN ?? "zuplo-samples.us.auth0.com", 8 | NEXT_PUBLIC_AUTH0_CLIENT_ID: 9 | process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID ?? 10 | "OFNbP5hhtsCHkBsXHEtWO72kKQvJtgI3", 11 | NEXT_PUBLIC_AUTH0_AUDIENCE: 12 | process.env.NEXT_PUBLIC_AUTH0_AUDIENCE ?? "https://api.example.com/", 13 | 14 | NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL!, 15 | // TODO: Deploy the final sample version in order to have a default 16 | // ??"https://sample-api-key-auth-translation-main-1e90fe1.d2.zuplo.dev", 17 | }; 18 | 19 | export function getRequiredEnvVar(name: string): string { 20 | const val = vars[name]; 21 | if (!val) { 22 | throw new Error(`The environment variable '${name}' must be set.`); 23 | } 24 | return val; 25 | } 26 | -------------------------------------------------------------------------------- /examples/nextjs/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import { Auth0Provider } from "@auth0/auth0-react"; 3 | import type { AppProps } from "next/app"; 4 | import { getRequiredEnvVar } from "../env"; 5 | 6 | export default function App({ Component, pageProps }: AppProps) { 7 | let redirectUri; 8 | if (typeof window !== "undefined") { 9 | redirectUri = window.location.origin; 10 | } 11 | 12 | const domain = getRequiredEnvVar("NEXT_PUBLIC_AUTH0_DOMAIN"); 13 | const clientId = getRequiredEnvVar("NEXT_PUBLIC_AUTH0_CLIENT_ID"); 14 | const audience = getRequiredEnvVar("NEXT_PUBLIC_AUTH0_AUDIENCE"); 15 | 16 | return ( 17 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /examples/nextjs/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useAuth0 } from "@auth0/auth0-react"; 2 | import { useRouter } from "next/router"; 3 | import { useEffect } from "react"; 4 | import Layout from "../components/Layout"; 5 | 6 | export default function Home() { 7 | const { isAuthenticated } = useAuth0(); 8 | const router = useRouter(); 9 | 10 | useEffect(() => { 11 | if (isAuthenticated) { 12 | router.push("/keys"); 13 | } 14 | }, [router, isAuthenticated]); 15 | 16 | return ( 17 | 18 |
19 | Login to continue ↗️ 20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /examples/nextjs/src/pages/keys.tsx: -------------------------------------------------------------------------------- 1 | import KeyManager from "@/components/KeyManager"; 2 | import { useAuth0 } from "@auth0/auth0-react"; 3 | import { useRouter } from "next/router"; 4 | import { useEffect, useState } from "react"; 5 | import Layout from "../components/Layout"; 6 | import { getRequiredEnvVar } from "../env"; 7 | 8 | function Keys() { 9 | const router = useRouter(); 10 | const { isAuthenticated, isLoading, getAccessTokenSilently } = useAuth0(); 11 | const [accessToken, setAccessToken] = useState(); 12 | const apiUrl = getRequiredEnvVar("NEXT_PUBLIC_API_URL"); 13 | 14 | useEffect(() => { 15 | if (isAuthenticated) { 16 | const audience = getRequiredEnvVar("NEXT_PUBLIC_AUTH0_AUDIENCE"); 17 | const getToken = async () => { 18 | const token = await getAccessTokenSilently({ 19 | authorizationParams: { 20 | audience, 21 | }, 22 | }); 23 | setAccessToken(token); 24 | }; 25 | 26 | getToken(); 27 | } 28 | }, [getAccessTokenSilently, isAuthenticated]); 29 | 30 | // If the user is not authenticated, redirect to the index page 31 | if (!isLoading && !isAuthenticated) { 32 | console.warn("Keys not authenticated"); 33 | router.push("/"); 34 | } 35 | 36 | return ( 37 | 38 | {accessToken ? ( 39 | 40 | ) : ( 41 |
Authenticating...
42 | )} 43 |
44 | ); 45 | } 46 | 47 | export default Keys; 48 | -------------------------------------------------------------------------------- /examples/nextjs/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @import "@zuplo/react-api-key-manager/tailwind.css"; 5 | -------------------------------------------------------------------------------- /examples/nextjs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 5 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 7 | "../../packages/react/src/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": 14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | darkMode: "class", 20 | }; 21 | -------------------------------------------------------------------------------- /examples/nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"], 28 | "references": [ 29 | { 30 | "path": "../../packages/react" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zuplo/api-key-manager", 3 | "version": "2.5.0", 4 | "description": "A React component to manage API keys", 5 | "license": "MIT", 6 | "author": "Zuplo", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/zuplo/api-key-manager" 10 | }, 11 | "scripts": { 12 | "build": "npm run build --workspace packages/react", 13 | "dev": "concurrently \"npm run dev --workspace packages/react\" \"npm run dev --workspace examples/nextjs\"", 14 | "format": "prettier --write .", 15 | "lint": "npm run lint --workspaces", 16 | "release": "npm version minor", 17 | "version": "npm version $npm_package_version --workspaces && git add **/package.json", 18 | "postversion": "git push --tags && git push", 19 | "postinstall": "npx husky install" 20 | }, 21 | "workspaces": [ 22 | "examples/*", 23 | "packages/*" 24 | ], 25 | "devDependencies": { 26 | "concurrently": "^8.2.0", 27 | "eslint": "^8.49.0", 28 | "eslint-config-prettier": "^9.0.0", 29 | "eslint-plugin-no-only-tests": "^3.1.0", 30 | "husky": "^8.0.3", 31 | "lint-staged": "^13.2.3", 32 | "prettier": "^3.0.3", 33 | "typescript": "^5.0.2" 34 | }, 35 | "lint-staged": { 36 | "**/*.{ts,json,md,yml,js,css,html}": [ 37 | "prettier --write" 38 | ] 39 | }, 40 | "dependencies": { 41 | "eslint-plugin-import": "^2.28.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/react/.eslintignore: -------------------------------------------------------------------------------- 1 | postcss.config.js -------------------------------------------------------------------------------- /packages/react/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: ["dist", ".eslintrc.cjs"], 10 | parser: "@typescript-eslint/parser", 11 | plugins: ["react-refresh"], 12 | rules: { 13 | "react-refresh/only-export-components": [ 14 | "warn", 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /packages/react/.npmignore: -------------------------------------------------------------------------------- 1 | .eslintrc.cjs 2 | postcss.config.js 3 | tailwind.config.js 4 | tsconfig.json 5 | tsup.config.js 6 | src/ -------------------------------------------------------------------------------- /packages/react/README.md: -------------------------------------------------------------------------------- 1 |

React API Key Manager

2 | 3 |

4 | 5 | 6 | NPM Badge 7 | License Badge 8 | Discrod Badge 9 |

10 | 11 | ## Overview 12 | 13 | A react component for managing API keys that is compatible with any API Key 14 | management API. 15 | 16 | ![Component Screenshot](https://cdn.zuplo.com/assets/cedd8ad0-9433-4433-80f6-86545ba0d41a.png) 17 | 18 | To see a demo of the component visit https://api-key-manager.com. 19 | 20 | Try it out by following our detailed [walkthrough tutorial](https://zuplo.com/blog/2023/08/08/open-source-release). 21 | 22 | ## Getting Started 23 | 24 | This component can be used with any React framework. It is compatible with 25 | TailwindCSS, but Tailwind is not required. 26 | 27 | ### Install 28 | 29 | Install the component in your React project 30 | 31 | ```bash 32 | npm install @zuplo/react-api-key-manager 33 | ``` 34 | 35 | ### With Tailwind 36 | 37 | Import the component's stylesheet into your `global.css` or equivalent file. The 38 | styles will use your project's tailwind configuration to provide a consistent 39 | theme. 40 | 41 | ```css 42 | @tailwind base; 43 | @tailwind components; 44 | @tailwind utilities; 45 | @import "@zuplo/react-api-key-manager/tailwind.css"; 46 | ``` 47 | 48 | ### Without Tailwind 49 | 50 | Import the component's stylesheet into your root component (i.e. `App.jsx`), 51 | typically below your other stylesheets. 52 | 53 | ```jsx 54 | import "./styles/globals.css"; 55 | import "@zuplo/react-api-key-manager/index.css"; 56 | ``` 57 | 58 | ### Custom Styles 59 | 60 | The the component's css can be completely customized by copying either the 61 | `tailwind.css` or `index.css` files from 62 | `node_modules/@zuplo/react-api-key-manager/dist/` and modifying the styles to 63 | suite your needs. 64 | 65 | ## Usage 66 | 67 | You can import the `ReactAPIKeyManager` into your React project directly. 68 | 69 | ```ts 70 | import { 71 | ApiKeyManager, 72 | DefaultApiKeyManagerProvider, 73 | } from "@zuplo/react-api-key-manager"; 74 | 75 | const MyComponent = () => { 76 | const defaultProvider = new DefaultApiKeyManagerProvider( 77 | "", 78 | "", 79 | ); 80 | 81 | return ; 82 | }; 83 | ``` 84 | 85 | ## Backend API 86 | 87 | The API Key Manager component interacts with an API that allows authorized users 88 | to manage their own keys. The easiest way to get started is to use the 89 | [Auth Translation API](https://github.com/zuplo/sample-auth-translation-api) 90 | sample and deploy it to [Zuplo](https://zuplo.com). By default this sample 91 | connects the 92 | [Zuplo API Key Management Service](https://zuplo.com/docs/articles/api-key-management), 93 | but you could adapt the sample to use other services or data storage systems. 94 | 95 | ## Custom Provider 96 | 97 | If you don't want to build an API that conforms to the built-in provider, you 98 | can implement a custom `ApiKeyManagerProvider` in your client to use an existing 99 | or custom API. The Interface for the provider is shown below. Additionally, you 100 | can see the default implementation in 101 | [`packages/react/src/default-provider.ts`](https://github.com/zuplo/api-key-manager/blob/main/packages/react/src/default-provider.ts) 102 | 103 | ## Community and Contribution 104 | 105 | We welcome community contributions and ideas. Please feel free to open an issue 106 | or propose a pull request. [Join us on Discord](https://discord.gg/Y87N4SxjvJ) 107 | if you have questions, feedback, or just want to hang out. 108 | 109 | ## License 110 | 111 | MIT License 112 | 113 | Copyright © 2023 Zuplo, Inc. All rights reserved. 114 | -------------------------------------------------------------------------------- /packages/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zuplo/react-api-key-manager", 3 | "version": "2.5.0", 4 | "description": "A React component to manage API keys", 5 | "keywords": [ 6 | "react", 7 | "api-keys", 8 | "api-key", 9 | "zuplo" 10 | ], 11 | "repository": { 12 | "url": "github:zuplo/api-key-manager", 13 | "directory": "packages/react" 14 | }, 15 | "license": "MIT", 16 | "type": "module", 17 | "exports": { 18 | "./package.json": "./package.json", 19 | "./index.css": "./dist/index.css", 20 | "./tailwind.css": "./dist/tailwind.css", 21 | ".": { 22 | "browser": "./dist/index.js", 23 | "import": "./dist/index.js", 24 | "require": "./dist/index.cjs" 25 | }, 26 | "./react": { 27 | "browser": "./dist/react/index.js", 28 | "import": "./dist/react/index.js", 29 | "require": "./dist/react/index.cjs" 30 | } 31 | }, 32 | "main": "dist/index.js", 33 | "types": "dist/index.d.ts", 34 | "typesVersions": { 35 | "*": { 36 | "*": [ 37 | "dist/index.d.ts" 38 | ], 39 | "react": [ 40 | "dist/react/index.d.ts" 41 | ] 42 | } 43 | }, 44 | "scripts": { 45 | "build": "tsup", 46 | "dev": "tsup --watch . --ignore-watch dist --ignore-watch dist-tailwind", 47 | "lint": "eslint .", 48 | "lint-fix": "eslint . --fix", 49 | "test": "jest", 50 | "type-check": "tsc --noEmit" 51 | }, 52 | "peerDependencies": { 53 | "react": "^16.8.0 || 18.x", 54 | "react-dom": "^16.8.0 || 18.x" 55 | }, 56 | "devDependencies": { 57 | "@types/node": "^20.3.1", 58 | "@types/react": "^18.2.14", 59 | "@typescript-eslint/eslint-plugin": "^6.7.0", 60 | "@typescript-eslint/parser": "^6.7.0", 61 | "autoprefixer": "^10.4.14", 62 | "dayjs": "^1.11.9", 63 | "eslint": "^8.49.0", 64 | "eslint-plugin-react": "^7.33.2", 65 | "eslint-plugin-react-hooks": "^4.6.0", 66 | "eslint-plugin-react-refresh": "^0.4.3", 67 | "postcss": "^8.4.32", 68 | "postcss-css-variables": "^0.19.0", 69 | "postcss-modules": "^6.0.0", 70 | "react": "^18.2.0", 71 | "react-dom": "^18.2.0", 72 | "tailwindcss": "^3.3.3", 73 | "tsup": "7.1.0" 74 | }, 75 | "lint-staged": { 76 | "**/*.{ts,js}": [ 77 | "eslint --cache --fix" 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/react/src/components/ApiKeyManager.module.css: -------------------------------------------------------------------------------- 1 | .query-error-border { 2 | @apply flex flex-col w-full text-red-600 bg-red-50 text-sm border border-red-600 rounded-lg p-4; 3 | } 4 | 5 | .query-error-message { 6 | @apply text-red-500 m-4; 7 | } 8 | 9 | .query-error-body { 10 | @apply flex flex-row justify-between items-center bg-red-50 p-4 border-b rounded-t-lg border-red-600; 11 | } 12 | 13 | .query-error-heading { 14 | @apply flex flex-row items-center; 15 | } 16 | 17 | .query-error-icon { 18 | @apply h-4 w-4 mr-1; 19 | } 20 | 21 | .query-error-heading-text { 22 | @apply font-bold; 23 | } 24 | 25 | .no-keys-message { 26 | @apply py-4 dark:text-white; 27 | } 28 | -------------------------------------------------------------------------------- /packages/react/src/components/ApiKeyManager.tsx: -------------------------------------------------------------------------------- 1 | import { ApiKeyManagerProvider, DataModel, MenuItem } from "../interfaces"; 2 | import ConsumerControl from "./ConsumerControl"; 3 | import ConsumerLoading from "./ConsumerLoading"; 4 | import { XCircleIcon } from "./icons"; 5 | import styles from "./ApiKeyManager.module.css"; 6 | import { useEffect, useState } from "react"; 7 | import { DataContext, ProviderContext } from "./context"; 8 | import CreateConsumer from "./CreateConsumer"; 9 | import { RefreshProvider, refreshEventName } from "../refresh-provider"; 10 | 11 | type ThemeOptions = "light" | "dark" | "system"; 12 | type Theme = "light" | "dark"; 13 | interface Props { 14 | menuItems?: MenuItem[]; 15 | /** 16 | * @default "light" 17 | */ 18 | theme?: ThemeOptions; 19 | provider: ApiKeyManagerProvider; 20 | enableCreateConsumer?: boolean; 21 | enableDeleteConsumer?: boolean; 22 | refreshProvider?: RefreshProvider; 23 | } 24 | 25 | const getSystemDefaultThemePreference = (): Theme => { 26 | if ( 27 | typeof window !== "undefined" && 28 | window.matchMedia("(prefers-color-scheme: dark)").matches 29 | ) { 30 | return "dark"; 31 | } 32 | return "light"; 33 | }; 34 | 35 | const DEFAULT_DATA_MODEL: DataModel = { 36 | isFetching: false, 37 | consumers: undefined, 38 | }; 39 | 40 | const getTheme = (theme: ThemeOptions): Theme => { 41 | if (theme === "system") { 42 | return getSystemDefaultThemePreference(); 43 | } 44 | return theme; 45 | }; 46 | 47 | function ApiKeyManager({ 48 | provider, 49 | menuItems, 50 | theme = "light", 51 | enableCreateConsumer, 52 | enableDeleteConsumer, 53 | refreshProvider, 54 | }: Props) { 55 | const themeStyle = `zp-key-manager--${getTheme(theme)}`; 56 | const [dataModel, setDataModel] = useState(DEFAULT_DATA_MODEL); 57 | const [error, setError] = useState(undefined); 58 | 59 | const loadData = async (prov: ApiKeyManagerProvider) => { 60 | try { 61 | setDataModel({ ...dataModel, isFetching: true }); 62 | const result = await prov.getConsumers(); 63 | setDataModel({ consumers: result.data, isFetching: false }); 64 | } catch (err) { 65 | setError((err as Error).message); 66 | setDataModel({ consumers: undefined, isFetching: false }); 67 | console.error(err); 68 | } 69 | }; 70 | 71 | useEffect(() => { 72 | if (refreshProvider) { 73 | const refreshCallback = () => { 74 | void loadData(provider); 75 | }; 76 | 77 | refreshProvider.addEventListener(refreshEventName, refreshCallback); 78 | 79 | return () => { 80 | refreshProvider.removeEventListener(refreshEventName, refreshCallback); 81 | }; 82 | } 83 | // eslint-disable-next-line react-hooks/exhaustive-deps 84 | }, [refreshProvider, provider]); 85 | 86 | useEffect(() => { 87 | void loadData(provider); 88 | // eslint-disable-next-line react-hooks/exhaustive-deps 89 | }, [provider]); 90 | 91 | if (!dataModel.consumers && dataModel.isFetching) { 92 | return ( 93 |
94 | 95 |
96 | ); 97 | } 98 | 99 | if (error) { 100 | return ( 101 |
102 |
103 |
104 | 105 | Error 106 |
107 |
108 |
"{error}"
109 |
110 | ); 111 | } 112 | 113 | const consumers = dataModel.consumers ?? []; 114 | 115 | return ( 116 | 117 | 118 | <> 119 | {consumers.length === 0 && ( 120 |
121 |
122 | You have no API keys 123 |
124 |
125 | )} 126 |
127 | {consumers.map((c) => { 128 | return ( 129 | 135 | ); 136 | })} 137 |
138 | {enableCreateConsumer && } 139 | 140 |
141 |
142 | ); 143 | } 144 | 145 | export default ApiKeyManager; 146 | -------------------------------------------------------------------------------- /packages/react/src/components/ConsumerControl.module.css: -------------------------------------------------------------------------------- 1 | /* ConsumerControl.module.css */ 2 | .consumer-control-container { 3 | @apply rounded-lg bg-slate-50 dark:bg-[#4f566b] border-zinc-200 dark:border-none border mb-5 selection:dark:text-zinc-900 selection:dark:bg-white; 4 | } 5 | 6 | .consumer-control-header { 7 | @apply flex flex-row justify-between border-b border-zinc-200 dark:border-none items-center; 8 | } 9 | 10 | .consumer-control-input-container { 11 | @apply flex flex-row w-full; 12 | } 13 | 14 | .consumer-control-input { 15 | @apply flex-1 disabled:opacity-50 rounded border border-slate-400 m-1 ml-2 my-2 px-2 bg-white dark:bg-slate-600 dark:text-white ring-0 w-full py-1; 16 | } 17 | 18 | .consumer-control-button { 19 | @apply hover:bg-slate-300 hover:dark:bg-slate-400 text-zinc-700 dark:text-white px-2 bg-slate-200 dark:bg-slate-500 rounded flex flex-row items-center text-sm m-1 my-2 h-8; 20 | } 21 | 22 | .consumer-control-menu-spinner-container { 23 | @apply h-full pb-[10px] pt-1 mr-1; 24 | } 25 | 26 | .consumer-control-menu-spinner { 27 | @apply h-5 w-5 animate-spin dark:text-white; 28 | } 29 | 30 | .consumer-control-spinner-container { 31 | @apply mr-1; 32 | } 33 | 34 | .consumer-control-spinner { 35 | @apply h-4 w-4 animate-spin dark:text-white; 36 | } 37 | 38 | .consumer-control-save-icon { 39 | @apply h-4 w-auto mr-1; 40 | } 41 | 42 | .consumer-control-cancel-icon { 43 | @apply h-4 w-auto mr-1; 44 | } 45 | 46 | .consumer-control-description { 47 | @apply ml-4 text-zinc-900 dark:text-white; 48 | } 49 | 50 | .consumer-menu-button-wrapper { 51 | @apply m-2 mt-4 mr-3; 52 | } 53 | 54 | .consumer-control-menu-button { 55 | @apply hover:bg-slate-200 rounded p-1; 56 | } 57 | 58 | .dark .consumer-control-menu-button:hover { 59 | background-color: #697386; 60 | } 61 | 62 | .consumer-control-menu-icon { 63 | @apply h-5 w-5 text-zinc-500 dark:text-white; 64 | } 65 | 66 | .consumer-control-error-container { 67 | @apply p-4 text-red-600 bg-red-50 text-sm; 68 | } 69 | 70 | .consumer-control-error-header-wrapper { 71 | @apply flex flex-row justify-between; 72 | } 73 | 74 | .consumer-control-error-header { 75 | @apply flex flex-row items-center; 76 | } 77 | 78 | .consumer-control-error-icon { 79 | @apply h-4 w-4 mr-1; 80 | } 81 | 82 | .consumer-control-error-leading-text { 83 | @apply font-bold; 84 | } 85 | 86 | .consumer-control-error-dismiss { 87 | @apply text-zinc-700 hover:bg-red-100 rounded p-2; 88 | } 89 | 90 | .consumer-control-error-dismiss-icon { 91 | @apply h-4 w-4; 92 | } 93 | 94 | .consumer-control-error-text { 95 | @apply pl-5 text-red-500; 96 | } 97 | 98 | .consumer-control-content { 99 | @apply bg-white rounded-b-lg p-4 dark:bg-[#2a2f45]; 100 | } 101 | 102 | .consumer-control-key-control { 103 | @apply mb-3; 104 | } 105 | -------------------------------------------------------------------------------- /packages/react/src/components/ConsumerControl.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useState } from "react"; 2 | import { Consumer, MenuItem } from "../interfaces"; 3 | import KeyControl from "./KeyControl"; 4 | import { SimpleMenu } from "./SimpleMenu"; 5 | import { 6 | ArrowPathIcon, 7 | EllipsisVerticalIcon, 8 | PencilSquareIcon, 9 | Save, 10 | Spinner, 11 | TrashIcon, 12 | XCircleIcon, 13 | XIcon, 14 | } from "./icons"; 15 | 16 | import styles from "./ConsumerControl.module.css"; 17 | import { useDataContext, useProviderContext } from "./context"; 18 | 19 | interface ConsumerControlProps { 20 | consumer: Consumer; 21 | menuItems?: MenuItem[]; 22 | enableDeleteConsumer?: boolean; 23 | } 24 | 25 | export const ErrorContext = createContext< 26 | [string | undefined, (error: string | undefined) => void] 27 | >([undefined, () => {}]); 28 | 29 | // 7 days 30 | const EXPIRY_PERIOD_MS = 1000 * 60 * 60 * 24 * 7; 31 | 32 | const ConsumerControl = ({ 33 | consumer, 34 | menuItems, 35 | enableDeleteConsumer, 36 | }: ConsumerControlProps) => { 37 | const [edit, setEdit] = useState(false); 38 | const [error, setError] = useState(); 39 | const [description, setDescription] = useState(consumer.description); 40 | const [dataModel, setDataModel] = useDataContext(); 41 | const [descriptionUpdating, setDescriptionUpdating] = useState(false); 42 | const [isLoading, setIsLoading] = useState(false); 43 | const provider = useProviderContext(); 44 | 45 | const handleDescriptionSave = async () => { 46 | try { 47 | setDescriptionUpdating(true); 48 | await provider.updateConsumerDescription(consumer.name, description); 49 | const result = await provider.getConsumers(); 50 | setDataModel({ 51 | isFetching: dataModel?.isFetching, 52 | consumers: result.data, 53 | }); 54 | setEdit(false); 55 | setError(undefined); 56 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 57 | } catch (err: any) { 58 | setError(err.message); 59 | } finally { 60 | setDescriptionUpdating(false); 61 | } 62 | }; 63 | 64 | const handleRollKey = async () => { 65 | try { 66 | setIsLoading(true); 67 | await provider.rollKey( 68 | consumer.name, 69 | new Date(Date.now() + EXPIRY_PERIOD_MS), 70 | ); 71 | const result = await provider.getConsumers(); 72 | setDataModel({ 73 | isFetching: dataModel?.isFetching, 74 | consumers: result.data, 75 | }); 76 | setError(undefined); 77 | } catch (err) { 78 | setError((err as Error).message); 79 | } finally { 80 | setIsLoading(false); 81 | } 82 | }; 83 | 84 | const handleDeleteConsumer = async () => { 85 | try { 86 | if (!provider.deleteConsumer) { 87 | throw new Error( 88 | "Provider does not support deleteConsumer but enableDeleteConsumer is true", 89 | ); 90 | } 91 | setIsLoading(true); 92 | await provider.deleteConsumer(consumer.name); 93 | const result = await provider.getConsumers(); 94 | setDataModel({ 95 | ...dataModel, 96 | consumers: result.data, 97 | }); 98 | setError(undefined); 99 | } catch (err) { 100 | setError((err as Error).message); 101 | } finally { 102 | setIsLoading(false); 103 | } 104 | }; 105 | 106 | const editLabelMenuItem = { 107 | label: "Edit Label", 108 | action: () => { 109 | setEdit(true); 110 | }, 111 | icon: PencilSquareIcon({}), 112 | }; 113 | 114 | // You can't roll keys if there are no keys to roll 115 | const numRollableKeys = consumer.apiKeys.filter((k) => !k.expiresOn).length; 116 | const enableRollKeys = numRollableKeys > 0; 117 | const rollKeysMenuItem: MenuItem = { 118 | label: numRollableKeys > 1 ? "Roll keys" : "Roll key", 119 | action: handleRollKey, 120 | icon: ArrowPathIcon({}), 121 | }; 122 | 123 | const deleteConsumerMenuItem: MenuItem = { 124 | label: "Delete", 125 | action: handleDeleteConsumer, 126 | icon: TrashIcon({}), 127 | }; 128 | 129 | const initialMenuItems: MenuItem[] = [editLabelMenuItem]; 130 | if (enableRollKeys) { 131 | initialMenuItems.push(rollKeysMenuItem); 132 | } 133 | if (enableDeleteConsumer) { 134 | initialMenuItems.push(deleteConsumerMenuItem); 135 | } 136 | 137 | const withCustomMenuItems = [...initialMenuItems, ...(menuItems ?? [])]; 138 | 139 | return ( 140 | 141 |
142 |
143 | {edit ? ( 144 |
145 | event.target.select()} 149 | onKeyUp={(event) => { 150 | event.key === "Enter" && handleDescriptionSave(); 151 | }} 152 | disabled={descriptionUpdating} 153 | type="text" 154 | className={styles["consumer-control-input"]} 155 | onChange={(e) => setDescription(e.target.value)} 156 | defaultValue={consumer.description} 157 | /> 158 | 173 | 182 |
183 | ) : ( 184 |
188 | {consumer.description ?? consumer.name} 189 |
190 | )} 191 |
192 | {dataModel.isFetching || isLoading ? ( 193 |
196 | 197 |
198 | ) : ( 199 | 200 |
201 | 204 |
205 |
206 | )} 207 |
208 |
209 | {Boolean(error) && ( 210 |
211 |
212 |
213 | 216 | 217 | Error 218 | 219 |
220 | 230 |
231 |
232 | "{error}" 233 |
234 |
235 | )} 236 |
237 | {consumer.apiKeys.map((k) => ( 238 | 239 | ))} 240 |
241 |
242 |
243 | ); 244 | }; 245 | 246 | export default ConsumerControl; 247 | -------------------------------------------------------------------------------- /packages/react/src/components/ConsumerLoading.module.css: -------------------------------------------------------------------------------- 1 | .consumer-loading-container { 2 | @apply rounded-lg bg-slate-50 dark:bg-[#4f566b] border-zinc-200 dark:border-none border mb-5; 3 | } 4 | 5 | .consumer-loading-header { 6 | @apply flex flex-row border-b border-zinc-200 dark:border-none justify-between items-center; 7 | } 8 | 9 | .consumer-loading-pulse { 10 | @apply h-4 m-5 mb-[22px] ml-4 w-44 animate-pulse rounded-lg bg-gray-300; 11 | } 12 | 13 | .consumer-loading-ellipsis { 14 | @apply m-3 mt-5 mb-[18px] mr-4 opacity-50; 15 | } 16 | 17 | .consumer-loading-ellipsis-icon { 18 | @apply h-5 w-5 dark:text-white; 19 | } 20 | 21 | .consumer-loading-content { 22 | @apply bg-white dark:bg-[#2a2f45] flex flex-col rounded-b-lg p-4 py-2; 23 | } 24 | 25 | .key-loading-container { 26 | @apply flex w-full justify-between; 27 | } 28 | 29 | .key-loading-pulse { 30 | @apply h-4 mt-6 mb-8 w-[440px] animate-pulse rounded-lg bg-gray-300; 31 | } 32 | -------------------------------------------------------------------------------- /packages/react/src/components/ConsumerLoading.tsx: -------------------------------------------------------------------------------- 1 | import { EllipsisVerticalIcon } from "./icons"; 2 | 3 | import styles from "./ConsumerLoading.module.css"; 4 | 5 | const ConsumerLoading = () => { 6 | return ( 7 |
8 |
9 |
10 |
11 | 14 |
15 |
16 |
17 | 18 |
19 |
20 | ); 21 | }; 22 | 23 | const KeyLoading = () => { 24 | return ( 25 |
26 |
27 |
28 | ); 29 | }; 30 | 31 | export default ConsumerLoading; 32 | -------------------------------------------------------------------------------- /packages/react/src/components/CreateConsumer.module.css: -------------------------------------------------------------------------------- 1 | 2 | .create-consumer-button { 3 | @apply bg-pink-500 rounded hover:bg-pink-700 p-2 text-white flex-none; 4 | } 5 | 6 | .create-consumer-main-button { 7 | @apply text-white rounded bg-pink-500 hover:bg-pink-700 p-2 px-4; 8 | } 9 | 10 | .create-consumer-icon { 11 | @apply w-5 h-auto m-1; 12 | } 13 | 14 | .create-consumer-spinner { 15 | @apply w-5 h-auto m-1 animate-spin; 16 | } 17 | 18 | .create-consumer-label { 19 | @apply rounded shadow-md p-2 flex-grow border border-gray-200 dark:bg-slate-700 dark:border-slate-500 dark:text-white; 20 | } 21 | 22 | .create-consumer-label-invalid { 23 | @apply rounded shadow-md p-2 flex-grow border-2 border-red-500 bg-red-50 dark:border-red-800 dark:bg-red-950 dark:text-white; 24 | } 25 | 26 | .create-consumer-container { 27 | @apply flex flex-row items-center gap-x-2 w-full; 28 | } -------------------------------------------------------------------------------- /packages/react/src/components/CreateConsumer.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from "react"; 2 | import { ErrorContext } from "./ConsumerControl"; 3 | import { useDataContext, useProviderContext } from "./context"; 4 | import { CheckIcon, Spinner } from "./icons"; 5 | import styles from "./CreateConsumer.module.css"; 6 | 7 | export default function CreateConsumer() { 8 | const provider = useProviderContext(); 9 | const [, setError] = useContext(ErrorContext); 10 | const [dataModel, setDataModel] = useDataContext(); 11 | const [editMode, setEditMode] = useState(false); 12 | const [label, setLabel] = useState(""); 13 | const [isCreating, setIsCreating] = useState(false); 14 | const [isInvalid, setIsInvalid] = useState(false); 15 | 16 | // TODO - handle errors 17 | 18 | async function handleCreateConsumer() { 19 | try { 20 | if (label.trim().length === 0) { 21 | setIsInvalid(true); 22 | return; 23 | } 24 | setIsInvalid(false); 25 | 26 | if (!provider.createConsumer) { 27 | throw new Error( 28 | "Provider does not implement createConsumer but enableCreateConsumer is true", 29 | ); 30 | } 31 | 32 | setIsCreating(true); 33 | await provider.createConsumer(label); 34 | const result = await provider.getConsumers(); 35 | setDataModel({ ...dataModel, consumers: result.data }); 36 | setEditMode(false); 37 | setError(undefined); 38 | } catch (err) { 39 | setError((err as Error).message); 40 | } finally { 41 | setIsCreating(false); 42 | } 43 | } 44 | 45 | function handleLabelChange(e: React.ChangeEvent) { 46 | if (e.target.value.trim().length > 0) { 47 | setIsInvalid(false); 48 | } 49 | setLabel(e.target.value); 50 | } 51 | 52 | if (!editMode) { 53 | return ( 54 | 60 | ); 61 | } 62 | 63 | return ( 64 |
65 | 78 | 88 |
89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /packages/react/src/components/KeyControl.module.css: -------------------------------------------------------------------------------- 1 | /* KeyControl.module.css */ 2 | .key-control-container { 3 | @apply flex flex-row justify-between items-center; 4 | } 5 | 6 | .key-control-key { 7 | @apply font-mono text-ellipsis overflow-hidden py-2 mr-2 text-zinc-800 dark:text-zinc-200; 8 | } 9 | 10 | .key-control-buttons { 11 | @apply flex gap-x-1 justify-end text-zinc-500 dark:text-zinc-200; 12 | } 13 | 14 | .key-control-button { 15 | @apply rounded p-1; 16 | } 17 | 18 | .key-control-button:hover { 19 | @apply bg-slate-200; 20 | } 21 | 22 | .dark .key-control-button:hover { 23 | background-color: #3c4257; 24 | } 25 | 26 | .key-control-button-active { 27 | @apply h-5 w-5 text-green-400; 28 | } 29 | 30 | .key-control-spinner-icon { 31 | @apply h-5 w-5 animate-spin; 32 | } 33 | 34 | .key-control-button-duplicate { 35 | @apply h-5 w-5; 36 | } 37 | 38 | .key-control-button-eye { 39 | @apply h-5 w-5; 40 | } 41 | 42 | .key-control-button-slash { 43 | @apply h-5 w-5; 44 | } 45 | 46 | .key-control-spinner { 47 | @apply p-1; 48 | } 49 | 50 | .key-control-created { 51 | @apply text-xs flex flex-row items-center gap-x-1 -mt-2 mb-2; 52 | } 53 | 54 | .key-control-created-text { 55 | @apply text-zinc-400; 56 | } 57 | 58 | .key-control-expires { 59 | @apply text-red-500; 60 | } 61 | -------------------------------------------------------------------------------- /packages/react/src/components/KeyControl.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import localizedFormat from "dayjs/plugin/localizedFormat"; 3 | import relativeTime from "dayjs/plugin/relativeTime"; 4 | import { useContext, useState } from "react"; 5 | import { ApiKey } from "../interfaces"; 6 | 7 | import { 8 | CheckIcon, 9 | DocumentDuplicateIcon, 10 | EyeIcon, 11 | EyeSlashIcon, 12 | Spinner, 13 | TrashIcon, 14 | } from "./icons"; 15 | 16 | import styles from "./KeyControl.module.css"; 17 | import { useDataContext, useProviderContext } from "./context"; 18 | import { ErrorContext } from "./ConsumerControl"; 19 | 20 | interface KeyControlProps { 21 | consumerName: string; 22 | apiKey: ApiKey; 23 | } 24 | 25 | dayjs.extend(relativeTime); 26 | dayjs.extend(localizedFormat); 27 | 28 | function mask(value: string, mask: boolean) { 29 | if (!mask || value.length <= 8) { 30 | return value; 31 | } 32 | 33 | const maskedPart = "*".repeat(value.length - 8); 34 | const lastEightChars = value.slice(-8); 35 | 36 | return maskedPart + lastEightChars; 37 | } 38 | 39 | const KeyControl = ({ apiKey, consumerName }: KeyControlProps) => { 40 | const [masked, setMasked] = useState(true); 41 | const [copied, setCopied] = useState(false); 42 | 43 | const [dataModel, setDataModel] = useDataContext(); 44 | const [keyDeleting, setKeyDeleting] = useState(false); 45 | const [, setError] = useContext(ErrorContext); 46 | const provider = useProviderContext(); 47 | 48 | function copy(value: string) { 49 | navigator.clipboard.writeText(value); 50 | setCopied(true); 51 | setTimeout(() => { 52 | setCopied(false); 53 | }, 2000); 54 | } 55 | 56 | async function handleDeleteKey() { 57 | try { 58 | setKeyDeleting(true); 59 | await provider.deleteKey(consumerName, apiKey.id); 60 | const result = await provider.getConsumers(); 61 | setDataModel({ 62 | isFetching: dataModel.isFetching, 63 | consumers: result.data, 64 | }); 65 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 66 | } catch (err: any) { 67 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 68 | setError(err.message); 69 | } finally { 70 | setKeyDeleting(false); 71 | } 72 | } 73 | 74 | return ( 75 |
76 |
77 | 82 | {mask(apiKey.key, masked)} 83 | 84 |
85 | 99 | 113 | {apiKey.expiresOn ? ( 114 | keyDeleting ? ( 115 |
116 | 117 |
118 | ) : ( 119 | 127 | ) 128 | ) : null} 129 |
130 |
131 |
132 |
133 | created {dayjs(apiKey.createdOn).fromNow()} 134 | {apiKey.expiresOn && ","} 135 |
136 |
137 | {apiKey.expiresOn && `expires ${dayjs(apiKey.expiresOn).fromNow()}`} 138 |
139 |
140 |
141 | ); 142 | }; 143 | 144 | export default KeyControl; 145 | -------------------------------------------------------------------------------- /packages/react/src/components/SimpleMenu.module.css: -------------------------------------------------------------------------------- 1 | /* SimpleMenu.module.css */ 2 | 3 | .simple-menu-button { 4 | @apply focus:outline-none; 5 | } 6 | 7 | .simple-menu-wrapper { 8 | @apply relative; 9 | } 10 | 11 | .simple-menu-dialog { 12 | @apply absolute top-0 right-0 z-50 shadow-md rounded bg-white dark:bg-[#1a1f36]; 13 | } 14 | 15 | .simple-menu-item-button { 16 | @apply whitespace-nowrap rounded w-full hover:bg-slate-50 hover:dark:text-white px-3 py-1 text-right flex flex-row items-center; 17 | } 18 | 19 | .dark .simple-menu-item-button:hover { 20 | background-color: #2a2f45; 21 | } 22 | 23 | .simple-menu-dropdown { 24 | @apply text-zinc-800 dark:text-zinc-200 p-1; 25 | } 26 | 27 | .simple-menu-item-icon { 28 | @apply h-4 w-auto mr-2; 29 | } -------------------------------------------------------------------------------- /packages/react/src/components/SimpleMenu.tsx: -------------------------------------------------------------------------------- 1 | import { cloneElement, useEffect, useRef, useState } from "react"; 2 | 3 | import styles from "./SimpleMenu.module.css"; 4 | import { Consumer, MenuItem } from ".."; 5 | 6 | interface Props { 7 | consumer: Consumer; 8 | disabled?: boolean; 9 | items: MenuItem[]; 10 | children: JSX.Element; 11 | } 12 | 13 | export function SimpleMenu({ consumer, disabled, items, children }: Props) { 14 | const [isOpen, setIsOpen] = useState(false); 15 | const buttonRef = useRef(null); 16 | 17 | function click(action: (consumer: Consumer) => void) { 18 | setIsOpen(false); 19 | action(consumer); 20 | } 21 | 22 | useEffect(() => { 23 | window.addEventListener("click", (e) => { 24 | // If the click is inside the button, don't close the menu. 25 | if (buttonRef.current?.contains(e.target as Node)) { 26 | e.stopPropagation(); 27 | return; 28 | } 29 | setIsOpen(false); 30 | }); 31 | }, []); 32 | 33 | function toggleOpen() { 34 | setIsOpen(!isOpen); 35 | } 36 | 37 | return ( 38 |
39 | 49 |
50 | {isOpen && ( 51 |
52 |
53 | {items.map((item) => ( 54 | 72 | ))} 73 |
74 |
75 | )} 76 |
77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /packages/react/src/components/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | import { ApiKeyManagerProvider, DataModel } from "../interfaces"; 3 | 4 | export const DataContext = createContext< 5 | [DataModel | undefined, (dataModel: DataModel) => void] 6 | >([undefined, () => {}]); 7 | 8 | export function useDataContext() { 9 | const ctx = useContext(DataContext); 10 | const [dataModel, setDataModel] = ctx; 11 | if (!dataModel || !setDataModel) { 12 | throw new Error( 13 | `Invalid state, no 'dataModel' or 'setDataModel' available`, 14 | ); 15 | } 16 | return [dataModel, setDataModel] as [ 17 | DataModel, 18 | (dataModel: DataModel) => void, 19 | ]; 20 | } 21 | 22 | export const ProviderContext = createContext( 23 | undefined, 24 | ); 25 | 26 | export function useProviderContext() { 27 | const pc = useContext(ProviderContext); 28 | if (!pc) { 29 | throw new Error(`Invalid state, no ProviderContext available`); 30 | } 31 | return pc; 32 | } 33 | -------------------------------------------------------------------------------- /packages/react/src/components/icons.module.css: -------------------------------------------------------------------------------- 1 | .icon { 2 | @apply w-6 h-6; 3 | } 4 | -------------------------------------------------------------------------------- /packages/react/src/components/icons.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./icons.module.css"; 2 | 3 | export const XCircleIcon = (props: { className?: string }) => ( 4 | 13 | 18 | 19 | ); 20 | 21 | export const EllipsisHorizontalIcon = (props: { className?: string }) => ( 22 | 31 | 36 | 37 | ); 38 | 39 | export const CheckIcon = (props: { className?: string }) => ( 40 | 49 | 54 | 55 | ); 56 | 57 | export const DocumentDuplicateIcon = (props: { className?: string }) => ( 58 | 67 | 72 | 73 | ); 74 | export const EyeIcon = (props: { className?: string }) => ( 75 | 84 | 89 | 94 | 95 | ); 96 | export const EyeSlashIcon = (props: { className?: string }) => ( 97 | 106 | 111 | 112 | ); 113 | 114 | export const EllipsisVerticalIcon = (props: { className?: string }) => ( 115 | 124 | 129 | 130 | ); 131 | 132 | export const TrashIcon = (props: { className?: string }) => ( 133 | 142 | 147 | 148 | ); 149 | 150 | export const Spinner = (props: { className?: string }) => ( 151 | 157 | adrian-spinner 158 | 159 | 160 | 165 | 169 | 170 | 171 | 172 | ); 173 | 174 | export const Save = (props: { className?: string }) => ( 175 | 187 | 188 | 189 | 190 | 191 | ); 192 | 193 | export const XIcon = (props: { className?: string }) => ( 194 | 203 | 208 | 209 | ); 210 | 211 | export const PencilSquareIcon = (props: { className?: string }) => ( 212 | 221 | 226 | 227 | ); 228 | 229 | export const ArrowPathIcon = (props: { className?: string }) => ( 230 | 239 | 244 | 245 | ); 246 | -------------------------------------------------------------------------------- /packages/react/src/default-provider.ts: -------------------------------------------------------------------------------- 1 | import { ApiKeyManagerProvider } from "./interfaces"; 2 | 3 | export class DefaultApiKeyManagerProvider implements ApiKeyManagerProvider { 4 | constructor(baseUrl: string, token: string) { 5 | this.baseUrl = baseUrl; 6 | this.token = token; 7 | } 8 | 9 | private readonly baseUrl: string; 10 | private readonly token: string; 11 | 12 | private innerFetch = async ( 13 | url: string, 14 | okStatus: number, 15 | method: string = "GET", 16 | body?: object, 17 | ) => { 18 | const headers: Record = { 19 | authorization: `Bearer ${this.token}`, 20 | }; 21 | 22 | if (body) { 23 | headers["content-type"] = "application/json"; 24 | } 25 | 26 | const response = await fetch(`${this.baseUrl}${url}`, { 27 | method, 28 | headers, 29 | body: JSON.stringify(body), 30 | }); 31 | 32 | const isExpectedResponse = response.status === okStatus; 33 | const contentType = response.headers.get("content-type"); 34 | if ( 35 | contentType?.includes("application/json") || 36 | contentType?.includes("application/problem+json") 37 | ) { 38 | let responseError: Error; 39 | try { 40 | const jsonData = await response.json(); 41 | if (isExpectedResponse) { 42 | return jsonData; 43 | } 44 | if ("title" in jsonData || "detail" in jsonData) { 45 | const { title, detail } = jsonData; 46 | 47 | responseError = new Error( 48 | `Failed '${method}' operation calling '${url}' 49 | -> ${title} ${detail ? `: ${detail}` : ""}`, 50 | ); 51 | } else { 52 | responseError = new Error( 53 | `Failed '${method}' operation calling '${url}' 54 | -> ${JSON.stringify(jsonData)}`, 55 | ); 56 | } 57 | } catch (e) { 58 | responseError = new Error( 59 | `Failed to parse JSON response from '${url}' - ${ 60 | (e as Error).message 61 | }`, 62 | ); 63 | } 64 | 65 | throw responseError; 66 | } 67 | 68 | const text = await response.text(); 69 | if (!isExpectedResponse) { 70 | throw new Error( 71 | `Failed '${method}' operation calling '${url}' 72 | -> ${response.status}: ${text.substring(0, 100)}`, 73 | ); 74 | } 75 | // otherwise just return 76 | return; 77 | }; 78 | 79 | getConsumers = async () => { 80 | const data = await this.innerFetch(`/consumers/my`, 200); 81 | return data; 82 | }; 83 | 84 | rollKey = async (consumerName: string, expiresOn: Date) => { 85 | await this.innerFetch(`/consumers/${consumerName}/roll`, 204, "POST", { 86 | expiresOn, 87 | }); 88 | }; 89 | 90 | deleteKey = async (consumerName: string, keyId: string) => { 91 | await this.innerFetch( 92 | `/consumers/${consumerName}/keys/${keyId}`, 93 | 204, 94 | "DELETE", 95 | ); 96 | }; 97 | 98 | createConsumer = async (description: string) => { 99 | await this.innerFetch(`/consumers/my`, 200, "POST", { 100 | description, 101 | }); 102 | }; 103 | 104 | deleteConsumer = async (consumerName: string) => { 105 | await this.innerFetch(`/consumers/${consumerName}`, 204, "DELETE"); 106 | }; 107 | 108 | updateConsumerDescription = async ( 109 | consumerName: string, 110 | description: string, 111 | ) => { 112 | const data = await this.innerFetch( 113 | `/consumers/${consumerName}`, 114 | 200, 115 | "PATCH", 116 | { 117 | description, 118 | }, 119 | ); 120 | return data; 121 | }; 122 | } 123 | -------------------------------------------------------------------------------- /packages/react/src/index.tsx: -------------------------------------------------------------------------------- 1 | import ApiKeyManager from "./components/ApiKeyManager"; 2 | 3 | export default ApiKeyManager; 4 | export { DefaultApiKeyManagerProvider } from "./default-provider"; 5 | export { RefreshProvider } from "./refresh-provider"; 6 | export type { 7 | ApiKey, 8 | ApiKeyManagerProvider, 9 | Consumer, 10 | ConsumerData, 11 | MenuItem, 12 | } from "./interfaces"; 13 | export { ApiKeyManager }; 14 | -------------------------------------------------------------------------------- /packages/react/src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface ApiKeyManagerProvider { 2 | getConsumers: () => Promise; 3 | rollKey: (consumerName: string, expiresOn: Date) => Promise; 4 | deleteKey: (consumerName: string, keyId: string) => Promise; 5 | updateConsumerDescription: ( 6 | consumerName: string, 7 | description: string, 8 | ) => Promise; 9 | createConsumer?: (description: string) => Promise; 10 | deleteConsumer?: (consumerName: string) => Promise; 11 | } 12 | 13 | export interface MenuItem { 14 | label: string; 15 | action: (consumer: Consumer) => void; 16 | icon?: JSX.Element; 17 | } 18 | 19 | export interface ConsumerData { 20 | data: Consumer[]; 21 | } 22 | 23 | export interface Consumer { 24 | name: string; 25 | createdOn: string; 26 | description: string; 27 | apiKeys: ApiKey[]; 28 | } 29 | 30 | export interface ApiKey { 31 | id: string; 32 | description: string; 33 | createdOn: string; 34 | updatedOn: string; 35 | expiresOn: string | null; 36 | key: string; 37 | } 38 | 39 | export interface DataModel { 40 | consumers?: Consumer[]; 41 | isFetching: boolean; 42 | } 43 | -------------------------------------------------------------------------------- /packages/react/src/refresh-provider.ts: -------------------------------------------------------------------------------- 1 | // This component is designed to allow consumers to programmatically refresh the data model. 2 | 3 | export const refreshEventName = "refresh"; 4 | 5 | export class RefreshProvider extends EventTarget { 6 | constructor() { 7 | super(); 8 | } 9 | 10 | refresh() { 11 | const event = new CustomEvent(refreshEventName); 12 | this.dispatchEvent(event); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/react/src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.module.css" { 2 | const classes: { [key: string]: string }; 3 | export default classes; 4 | } 5 | -------------------------------------------------------------------------------- /packages/react/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: { 6 | colors: { 7 | // Do NOT use this for colors, as a bug in the tailwind css export will 8 | // prevent the tailwind.css from picking these up 9 | // Instead use selectors and values directly 10 | // Ex. for dark mode use 11 | /** 12 | * .dark .simple-menu-dialog:hover { 13 | * background-color: #1a1f36; 14 | * } 15 | */ 16 | }, 17 | }, 18 | }, 19 | plugins: [], 20 | darkMode: "class", 21 | }; 22 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true 23 | }, 24 | "include": ["src"], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } 27 | -------------------------------------------------------------------------------- /packages/react/tsup.config.js: -------------------------------------------------------------------------------- 1 | import autoprefixer from "autoprefixer"; 2 | import fs from "fs/promises"; 3 | import path from "path"; 4 | import postcss from "postcss"; 5 | import postcssVariables from "postcss-css-variables"; 6 | import postcssModules from "postcss-modules"; 7 | import tailwindcss from "tailwindcss"; 8 | import { defineConfig } from "tsup"; 9 | import config from "./tailwind.config.js"; 10 | 11 | /** 12 | * @param {{ postcssPlugins?: postcss.AcceptedPlugin[] }} options 13 | * @returns {import('tsup').Options} 14 | * */ 15 | function getConfig({ postcssPlugins } = {}) { 16 | return { 17 | entry: { 18 | index: "src/index.tsx", 19 | }, 20 | external: ["react"], 21 | bundle: true, 22 | splitting: false, 23 | sourcemap: true, 24 | treeshake: true, 25 | // eslint-disable-next-line no-undef 26 | clean: process.env.NODE_ENV === "PRODUCTION", 27 | dts: true, 28 | format: ["esm", "cjs"], 29 | esbuildOptions: (options) => { 30 | // Append "use client" to the top of the react entry point 31 | options.banner = { 32 | js: '"use client";', 33 | }; 34 | }, 35 | esbuildPlugins: [ 36 | { 37 | name: "css-module", 38 | setup(build) { 39 | build.onResolve( 40 | { filter: /\.module\.css$/, namespace: "file" }, 41 | (args) => ({ 42 | path: `${args.path}#css-module`, 43 | namespace: "css-module", 44 | pluginData: { 45 | pathDir: path.join(args.resolveDir, args.path), 46 | }, 47 | }) 48 | ); 49 | build.onLoad( 50 | { filter: /#css-module$/, namespace: "css-module" }, 51 | async (args) => { 52 | const { pluginData } = args; 53 | 54 | const source = await fs.readFile(pluginData.pathDir, "utf8"); 55 | 56 | let cssModule = {}; 57 | const result = await postcss([ 58 | postcssModules({ 59 | generateScopedName: "zp-key-manager--[local]", 60 | getJSON(_, json) { 61 | cssModule = json; 62 | }, 63 | }), 64 | ...(postcssPlugins ?? []), 65 | ]).process(source, { from: pluginData.pathDir }); 66 | 67 | return { 68 | pluginData: { css: result.css }, 69 | contents: `import "${ 70 | pluginData.pathDir 71 | }"; export default ${JSON.stringify(cssModule)}`, 72 | }; 73 | } 74 | ); 75 | build.onResolve( 76 | { filter: /\.module\.css$/, namespace: "css-module" }, 77 | (args) => ({ 78 | path: path.join(args.resolveDir, args.path, "#css-module-data"), 79 | namespace: "css-module", 80 | pluginData: args.pluginData, 81 | }) 82 | ); 83 | build.onLoad( 84 | { filter: /#css-module-data$/, namespace: "css-module" }, 85 | (args) => ({ 86 | contents: args.pluginData.css, 87 | loader: "css", 88 | }) 89 | ); 90 | }, 91 | }, 92 | ], 93 | }; 94 | } 95 | 96 | export default defineConfig([ 97 | { 98 | ...getConfig({ 99 | postcssPlugins: [tailwindcss(config), postcssVariables(), autoprefixer()], 100 | }), 101 | outDir: "dist", 102 | }, 103 | { 104 | ...getConfig(), 105 | outDir: "dist-tailwind", 106 | onSuccess: async () => { 107 | const source = path.join("dist-tailwind/index.css"); 108 | const dest = path.join("dist/tailwind.css"); 109 | await fs.copyFile(source, dest); 110 | }, 111 | }, 112 | ]); 113 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./packages/react" }] 4 | } 5 | --------------------------------------------------------------------------------