From e2669eb370ade571a50a7a1453dfd5e7b9e138c7 Mon Sep 17 00:00:00 2001 From: Yunus M Date: Mon, 19 Feb 2024 09:19:06 +0530 Subject: [PATCH] API ingestion keys - CRUD (#4524) * feat: api keys crud - integration v0.1 * feat: add test cases * fix: add review comments * feat: api integration and ui updates * feat: update test cases * feat: update expiriesAt request payload * feat: ui feedback updates * feat: api keys crud - integration v0.1 * feat: add test cases * fix: add review comments * feat: api integration and ui updates * feat: update test cases * feat: update expiriesAt request payload * feat: ui feedback updates * feat: handle light mode styles * feat: hide pagination on single page * feat: do not show last used if not present or 0 * feat: show tooltip on role --------- Co-authored-by: Rajat Dabade --- frontend/jest.config.ts | 2 + frontend/jest.setup.ts | 1 + frontend/package.json | 8 +- frontend/public/locales/en-GB/routes.json | 1 + frontend/public/locales/en-GB/titles.json | 1 + frontend/public/locales/en/apiKeys.json | 3 + frontend/public/locales/en/routes.json | 1 + frontend/public/locales/en/titles.json | 1 + frontend/src/AppRoutes/Private.tsx | 1 + frontend/src/AppRoutes/pageComponents.ts | 4 + frontend/src/AppRoutes/routes.ts | 8 + frontend/src/api/APIKeys/createAPIKey.ts | 26 + frontend/src/api/APIKeys/deleteAPIKey.ts | 24 + frontend/src/api/APIKeys/getAPIKey.ts | 24 + frontend/src/api/APIKeys/getAllAPIKeys.ts | 6 + frontend/src/api/APIKeys/updateAPIKey.ts | 26 + frontend/src/constants/routes.ts | 1 + .../src/container/APIKeys/APIKeys.styles.scss | 685 ++++++++++++ .../src/container/APIKeys/APIKeys.test.tsx | 99 ++ frontend/src/container/APIKeys/APIKeys.tsx | 844 +++++++++++++++ .../TopNav/DateTimeSelectionV2/config.ts | 1 + .../src/hooks/APIKeys/useGetAllAPIKeys.ts | 13 + .../src/mocks-server/__mockdata__/apiKeys.ts | 541 ++++++++++ .../pages/Settings/{config.ts => config.tsx} | 45 +- frontend/src/pages/Settings/index.tsx | 3 +- frontend/src/pages/Settings/utils.ts | 11 +- frontend/src/periscope.scss | 12 + frontend/src/types/api/pat/types.ts | 53 + frontend/src/utils/permission/index.ts | 1 + frontend/tsconfig.json | 5 + frontend/yarn.lock | 972 +++++++++++++++++- 31 files changed, 3382 insertions(+), 41 deletions(-) create mode 100644 frontend/public/locales/en/apiKeys.json create mode 100644 frontend/src/api/APIKeys/createAPIKey.ts create mode 100644 frontend/src/api/APIKeys/deleteAPIKey.ts create mode 100644 frontend/src/api/APIKeys/getAPIKey.ts create mode 100644 frontend/src/api/APIKeys/getAllAPIKeys.ts create mode 100644 frontend/src/api/APIKeys/updateAPIKey.ts create mode 100644 frontend/src/container/APIKeys/APIKeys.styles.scss create mode 100644 frontend/src/container/APIKeys/APIKeys.test.tsx create mode 100644 frontend/src/container/APIKeys/APIKeys.tsx create mode 100644 frontend/src/hooks/APIKeys/useGetAllAPIKeys.ts create mode 100644 frontend/src/mocks-server/__mockdata__/apiKeys.ts rename frontend/src/pages/Settings/{config.ts => config.tsx} (56%) create mode 100644 frontend/src/types/api/pat/types.ts diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index 20bf44bf96..0493353115 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -20,6 +20,8 @@ const config: Config.InitialOptions = { transform: { '^.+\\.(ts|tsx)?$': 'ts-jest', '^.+\\.(js|jsx)$': 'babel-jest', + '^.+\\.(css|scss|sass|less)$': 'jest-preview/transforms/css', + '^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)': 'jest-preview/transforms/file', }, transformIgnorePatterns: [ 'node_modules/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@signozhq/design-tokens)/)', diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts index debe6128e2..4c3aad294f 100644 --- a/frontend/jest.setup.ts +++ b/frontend/jest.setup.ts @@ -7,6 +7,7 @@ */ import '@testing-library/jest-dom'; import 'jest-styled-components'; +import './src/styles.scss'; import { server } from './src/mocks-server/server'; // Establish API mocking before all tests. diff --git a/frontend/package.json b/frontend/package.json index 1a7acae5ad..205a9e0ed2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,8 @@ "jest": "jest", "jest:coverage": "jest --coverage", "jest:watch": "jest --watch", + "jest-preview": "jest-preview", + "test:debug": "npm-run-all -p test jest-preview", "postinstall": "is-ci || yarn husky:configure", "playwright": "npm run i18n:generate-hash && NODE_ENV=testing playwright test --config=./playwright.config.ts", "playwright:local:debug": "PWDEBUG=console yarn playwright --headed --browser=chromium", @@ -77,7 +79,7 @@ "less": "^4.1.2", "less-loader": "^10.2.0", "lodash-es": "^4.17.21", - "lucide-react": "0.288.0", + "lucide-react": "0.321.0", "mini-css-extract-plugin": "2.4.5", "papaparse": "5.4.1", "react": "18.2.0", @@ -192,6 +194,7 @@ "husky": "^7.0.4", "is-ci": "^3.0.1", "jest-playwright-preset": "^1.7.2", + "jest-preview": "0.3.1", "jest-styled-components": "^7.0.8", "lint-staged": "^12.5.0", "msw": "1.3.2", @@ -208,7 +211,8 @@ "ts-node": "^10.2.1", "typescript-plugin-css-modules": "5.0.1", "webpack-bundle-analyzer": "^4.5.0", - "webpack-cli": "^4.9.2" + "webpack-cli": "^4.9.2", + "npm-run-all": "latest" }, "lint-staged": { "*.(js|jsx|ts|tsx)": [ diff --git a/frontend/public/locales/en-GB/routes.json b/frontend/public/locales/en-GB/routes.json index a3357435dd..c88baa096a 100644 --- a/frontend/public/locales/en-GB/routes.json +++ b/frontend/public/locales/en-GB/routes.json @@ -3,6 +3,7 @@ "alert_channels": "Alert Channels", "organization_settings": "Organization Settings", "ingestion_settings": "Ingestion Settings", + "api_keys": "API Keys", "my_settings": "My Settings", "overview_metrics": "Overview Metrics", "dbcall_metrics": "Database Calls", diff --git a/frontend/public/locales/en-GB/titles.json b/frontend/public/locales/en-GB/titles.json index a457360245..d8ed6ff0ef 100644 --- a/frontend/public/locales/en-GB/titles.json +++ b/frontend/public/locales/en-GB/titles.json @@ -26,6 +26,7 @@ "MY_SETTINGS": "SigNoz | My Settings", "ORG_SETTINGS": "SigNoz | Organization Settings", "INGESTION_SETTINGS": "SigNoz | Ingestion Settings", + "API_KEYS": "SigNoz | API Keys", "SOMETHING_WENT_WRONG": "SigNoz | Something Went Wrong", "UN_AUTHORIZED": "SigNoz | Unauthorized", "NOT_FOUND": "SigNoz | Page Not Found", diff --git a/frontend/public/locales/en/apiKeys.json b/frontend/public/locales/en/apiKeys.json new file mode 100644 index 0000000000..5cc51fa92e --- /dev/null +++ b/frontend/public/locales/en/apiKeys.json @@ -0,0 +1,3 @@ +{ + "delete_confirm_message": "Are you sure you want to delete {{keyName}} key? Deleting a key is irreversible and cannot be undone." +} diff --git a/frontend/public/locales/en/routes.json b/frontend/public/locales/en/routes.json index a3357435dd..c88baa096a 100644 --- a/frontend/public/locales/en/routes.json +++ b/frontend/public/locales/en/routes.json @@ -3,6 +3,7 @@ "alert_channels": "Alert Channels", "organization_settings": "Organization Settings", "ingestion_settings": "Ingestion Settings", + "api_keys": "API Keys", "my_settings": "My Settings", "overview_metrics": "Overview Metrics", "dbcall_metrics": "Database Calls", diff --git a/frontend/public/locales/en/titles.json b/frontend/public/locales/en/titles.json index 82fad7f472..cebb3151d9 100644 --- a/frontend/public/locales/en/titles.json +++ b/frontend/public/locales/en/titles.json @@ -26,6 +26,7 @@ "MY_SETTINGS": "SigNoz | My Settings", "ORG_SETTINGS": "SigNoz | Organization Settings", "INGESTION_SETTINGS": "SigNoz | Ingestion Settings", + "API_KEYS": "SigNoz | API Keys", "SOMETHING_WENT_WRONG": "SigNoz | Something Went Wrong", "UN_AUTHORIZED": "SigNoz | Unauthorized", "NOT_FOUND": "SigNoz | Page Not Found", diff --git a/frontend/src/AppRoutes/Private.tsx b/frontend/src/AppRoutes/Private.tsx index e409e3e3e6..f0bfa62d6e 100644 --- a/frontend/src/AppRoutes/Private.tsx +++ b/frontend/src/AppRoutes/Private.tsx @@ -98,6 +98,7 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element { if ( userResponse && + route && route.find((e) => e === userResponse.payload.role) === undefined ) { history.push(ROUTES.UN_AUTHORIZED); diff --git a/frontend/src/AppRoutes/pageComponents.ts b/frontend/src/AppRoutes/pageComponents.ts index 0a2e0e59f2..6d26f9b55a 100644 --- a/frontend/src/AppRoutes/pageComponents.ts +++ b/frontend/src/AppRoutes/pageComponents.ts @@ -118,6 +118,10 @@ export const IngestionSettings = Loadable( () => import(/* webpackChunkName: "Ingestion Settings" */ 'pages/Settings'), ); +export const APIKeys = Loadable( + () => import(/* webpackChunkName: "All Settings" */ 'pages/Settings'), +); + export const MySettings = Loadable( () => import(/* webpackChunkName: "All MySettings" */ 'pages/MySettings'), ); diff --git a/frontend/src/AppRoutes/routes.ts b/frontend/src/AppRoutes/routes.ts index e9d202c420..a6543ad01d 100644 --- a/frontend/src/AppRoutes/routes.ts +++ b/frontend/src/AppRoutes/routes.ts @@ -6,6 +6,7 @@ import { RouteProps } from 'react-router-dom'; import { AllAlertChannels, AllErrors, + APIKeys, BillingPage, CreateAlertChannelAlerts, CreateNewAlerts, @@ -236,6 +237,13 @@ const routes: AppRoutes[] = [ isPrivate: true, key: 'INGESTION_SETTINGS', }, + { + path: ROUTES.API_KEYS, + exact: true, + component: APIKeys, + isPrivate: true, + key: 'API_KEYS', + }, { path: ROUTES.MY_SETTINGS, exact: true, diff --git a/frontend/src/api/APIKeys/createAPIKey.ts b/frontend/src/api/APIKeys/createAPIKey.ts new file mode 100644 index 0000000000..2b219a0166 --- /dev/null +++ b/frontend/src/api/APIKeys/createAPIKey.ts @@ -0,0 +1,26 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { APIKeyProps, CreateAPIKeyProps } from 'types/api/pat/types'; + +const createAPIKey = async ( + props: CreateAPIKeyProps, +): Promise | ErrorResponse> => { + try { + const response = await axios.post('/pats', { + ...props, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default createAPIKey; diff --git a/frontend/src/api/APIKeys/deleteAPIKey.ts b/frontend/src/api/APIKeys/deleteAPIKey.ts new file mode 100644 index 0000000000..03b8d595da --- /dev/null +++ b/frontend/src/api/APIKeys/deleteAPIKey.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { AllAPIKeyProps } from 'types/api/pat/types'; + +const deleteAPIKey = async ( + id: string, +): Promise | ErrorResponse> => { + try { + const response = await axios.delete(`/pats/${id}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default deleteAPIKey; diff --git a/frontend/src/api/APIKeys/getAPIKey.ts b/frontend/src/api/APIKeys/getAPIKey.ts new file mode 100644 index 0000000000..c0410d873f --- /dev/null +++ b/frontend/src/api/APIKeys/getAPIKey.ts @@ -0,0 +1,24 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, Props } from 'types/api/alerts/get'; + +const get = async ( + props: Props, +): Promise | ErrorResponse> => { + try { + const response = await axios.get(`/pats/${props.id}`); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default get; diff --git a/frontend/src/api/APIKeys/getAllAPIKeys.ts b/frontend/src/api/APIKeys/getAllAPIKeys.ts new file mode 100644 index 0000000000..488d9dc5cf --- /dev/null +++ b/frontend/src/api/APIKeys/getAllAPIKeys.ts @@ -0,0 +1,6 @@ +import axios from 'api'; +import { AxiosResponse } from 'axios'; +import { AllAPIKeyProps } from 'types/api/pat/types'; + +export const getAllAPIKeys = (): Promise> => + axios.get(`/pats`); diff --git a/frontend/src/api/APIKeys/updateAPIKey.ts b/frontend/src/api/APIKeys/updateAPIKey.ts new file mode 100644 index 0000000000..38d20227a3 --- /dev/null +++ b/frontend/src/api/APIKeys/updateAPIKey.ts @@ -0,0 +1,26 @@ +import axios from 'api'; +import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; +import { AxiosError } from 'axios'; +import { ErrorResponse, SuccessResponse } from 'types/api'; +import { PayloadProps, UpdateAPIKeyProps } from 'types/api/pat/types'; + +const updateAPIKey = async ( + props: UpdateAPIKeyProps, +): Promise | ErrorResponse> => { + try { + const response = await axios.put(`/pats/${props.id}`, { + ...props.data, + }); + + return { + statusCode: 200, + error: null, + message: response.data.status, + payload: response.data.data, + }; + } catch (error) { + return ErrorResponseHandler(error as AxiosError); + } +}; + +export default updateAPIKey; diff --git a/frontend/src/constants/routes.ts b/frontend/src/constants/routes.ts index 0dae80950d..0715ebf787 100644 --- a/frontend/src/constants/routes.ts +++ b/frontend/src/constants/routes.ts @@ -24,6 +24,7 @@ const ROUTES = { MY_SETTINGS: '/my-settings', SETTINGS: '/settings', ORG_SETTINGS: '/settings/org-settings', + API_KEYS: '/settings/api-keys', INGESTION_SETTINGS: '/settings/ingestion-settings', SOMETHING_WENT_WRONG: '/something-went-wrong', UN_AUTHORIZED: '/un-authorized', diff --git a/frontend/src/container/APIKeys/APIKeys.styles.scss b/frontend/src/container/APIKeys/APIKeys.styles.scss new file mode 100644 index 0000000000..c0aeec0cac --- /dev/null +++ b/frontend/src/container/APIKeys/APIKeys.styles.scss @@ -0,0 +1,685 @@ +.api-key-container { + margin-top: 24px; + display: flex; + justify-content: center; + width: 100%; + + .api-key-content { + width: calc(100% - 30px); + max-width: 736px; + + .title { + color: var(--bg-vanilla-100); + font-size: var(--font-size-lg); + font-style: normal; + font-weight: var(--font-weight-normal); + line-height: 28px; /* 155.556% */ + letter-spacing: -0.09px; + } + + .subtitle { + color: var(--bg-vanilla-400); + font-size: var(--font-size-sm); + font-style: normal; + font-weight: var(--font-weight-normal); + line-height: 20px; /* 142.857% */ + letter-spacing: -0.07px; + } + + .api-keys-search-add-new { + display: flex; + align-items: center; + gap: 12px; + + padding: 16px 0; + + .add-new-api-key-btn { + display: flex; + align-items: center; + gap: 8px; + } + } + + .ant-table-row { + .ant-table-cell { + padding: 0; + border: none; + background: var(--bg-ink-500); + } + .column-render { + margin: 8px 0 !important; + border-radius: 6px; + border: 1px solid var(--bg-slate-500); + background: var(--bg-ink-400); + + .title-with-action { + display: flex; + justify-content: space-between; + + align-items: center; + padding: 8px; + + .api-key-data { + display: flex; + gap: 8px; + align-items: center; + + .api-key-title { + display: flex; + align-items: center; + gap: 6px; + + .ant-typography { + color: var(--bg-vanilla-400); + font-size: var(--font-size-sm); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: 20px; + letter-spacing: -0.07px; + } + } + + .api-key-value { + display: flex; + align-items: center; + gap: 12px; + + border-radius: 20px; + padding: 0px 12px; + + background: var(--bg-ink-200); + + .ant-typography { + color: var(--bg-vanilla-400); + font-size: var(--font-size-xs); + font-family: 'Space Mono', monospace; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: 20px; + letter-spacing: -0.07px; + } + + .copy-key-btn { + cursor: pointer; + } + } + } + + .action-btn { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + } + + .visibility-btn { + border: 1px solid rgba(113, 144, 249, 0.2); + background: rgba(113, 144, 249, 0.1); + } + } + + .ant-collapse { + border: none; + + .ant-collapse-header { + padding: 0px 8px; + + display: flex; + align-items: center; + background-color: #121317; + } + + .ant-collapse-content { + border-top: 1px solid var(--bg-slate-500); + } + + .ant-collapse-item { + border-bottom: none; + } + + .ant-collapse-expand-icon { + padding-inline-end: 0px; + } + } + + .api-key-details { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border-top: 1px solid var(--bg-slate-500); + padding: 8px; + + .api-key-tag { + width: 14px; + height: 14px; + border-radius: 50px; + background: var(--bg-slate-300); + display: flex; + justify-content: center; + align-items: center; + + .tag-text { + color: var(--bg-vanilla-400); + leading-trim: both; + text-edge: cap; + font-size: 10px; + font-style: normal; + font-weight: var(--font-weight-normal); + line-height: normal; + letter-spacing: -0.05px; + } + } + + .api-key-created-by { + margin-left: 8px; + } + + .api-key-last-used-at { + display: flex; + align-items: center; + gap: 8px; + + .ant-typography { + color: var(--bg-vanilla-400); + font-size: var(--font-size-sm); + font-style: normal; + font-weight: var(--font-weight-normal); + line-height: 18px; /* 128.571% */ + letter-spacing: -0.07px; + font-variant-numeric: lining-nums tabular-nums stacked-fractions + slashed-zero; + font-feature-settings: 'dlig' on, 'salt' on, 'cpsp' on, 'case' on; + } + } + + .api-key-expires-in { + font-style: normal; + font-weight: 400; + line-height: 18px; + + display: flex; + align-items: center; + gap: 8px; + + .dot { + height: 6px; + width: 6px; + border-radius: 50%; + } + + &.warning { + color: var(--bg-amber-400); + + .dot { + background: var(--bg-amber-400); + box-shadow: 0px 0px 6px 0px var(--bg-amber-400); + } + } + + &.danger { + color: var(--bg-cherry-400); + + .dot { + background: var(--bg-cherry-400); + box-shadow: 0px 0px 6px 0px var(--bg-cherry-400); + } + } + } + } + } + } + + .ant-pagination-item { + display: flex; + justify-content: center; + align-items: center; + + > a { + color: var(--bg-vanilla-400); + font-variant-numeric: lining-nums tabular-nums slashed-zero; + font-feature-settings: 'dlig' on, 'salt' on, 'case' on, 'cpsp' on; + font-size: var(--font-size-sm); + font-style: normal; + font-weight: var(--font-weight-normal); + line-height: 20px; /* 142.857% */ + } + } + + .ant-pagination-item-active { + background-color: var(--bg-robin-500); + > a { + color: var(--bg-ink-500) !important; + font-size: var(--font-size-sm); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: 20px; + } + } + } +} + +.api-key-info-container { + display: flex; + gap: 12px; + flex-direction: column; + + .user-info { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + + .user-avatar { + background-color: lightslategray; + vertical-align: middle; + } + } + + .user-email { + display: inline-flex; + align-items: center; + gap: 12px; + border-radius: 20px; + padding: 0px 12px; + background: var(--bg-ink-200); + + font-family: 'Space Mono', monospace; + } + + .role { + display: flex; + align-items: center; + gap: 12px; + } +} + +.api-key-modal { + .ant-modal-content { + border-radius: 4px; + border: 1px solid var(--bg-slate-500); + background: var(--bg-ink-400); + box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2); + padding: 0; + + .ant-modal-header { + background: none; + border-bottom: 1px solid var(--bg-slate-500); + padding: 16px; + } + + .ant-modal-close-x { + font-size: 12px; + } + + .ant-modal-body { + padding: 12px 16px; + } + + .ant-modal-footer { + padding: 16px; + margin-top: 0; + + display: flex; + justify-content: flex-end; + } + } +} + +.api-key-access-role { + display: flex; + + .ant-radio-button-wrapper { + font-size: 12px; + text-transform: capitalize; + + &.ant-radio-button-wrapper-checked { + color: #fff; + background: var(--bg-slate-400, #1d212d); + border-color: var(--bg-slate-400, #1d212d); + + &:hover { + color: #fff; + background: var(--bg-slate-400, #1d212d); + border-color: var(--bg-slate-400, #1d212d); + + &::before { + background-color: var(--bg-slate-400, #1d212d); + } + } + + &:focus { + color: #fff; + background: var(--bg-slate-400, #1d212d); + border-color: var(--bg-slate-400, #1d212d); + } + } + } + + .tab { + border: 1px solid var(--bg-slate-400); + + flex: 1; + + display: flex; + justify-content: center; + + &::before { + background: var(--bg-slate-400); + } + + &.selected { + background: var(--bg-slate-400, #1d212d); + } + } + + .role { + display: flex; + align-items: center; + gap: 8px; + } +} + +.delete-api-key-modal { + width: calc(100% - 30px) !important; /* Adjust the 20px as needed */ + max-width: 384px; + .ant-modal-content { + padding: 0; + border-radius: 4px; + border: 1px solid var(--bg-slate-500); + background: var(--bg-ink-400); + box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2); + + .ant-modal-header { + padding: 16px; + background: var(--bg-ink-400); + } + + .ant-modal-body { + padding: 0px 16px 28px 16px; + + .ant-typography { + color: var(--bg-vanilla-400); + font-size: var(--font-size-sm); + font-style: normal; + font-weight: var(--font-weight-normal); + line-height: 20px; + letter-spacing: -0.07px; + } + + .api-key-input { + margin-top: 8px; + display: flex; + gap: 8px; + } + + .ant-color-picker-trigger { + padding: 6px; + border-radius: 2px; + border: 1px solid var(--bg-slate-400); + background: var(--bg-ink-300); + width: 32px; + height: 32px; + + .ant-color-picker-color-block { + border-radius: 50px; + width: 16px; + height: 16px; + flex-shrink: 0; + + .ant-color-picker-color-block-inner { + display: flex; + justify-content: center; + align-items: center; + } + } + } + } + + .ant-modal-footer { + display: flex; + justify-content: flex-end; + padding: 16px 16px; + margin: 0; + + .cancel-btn { + display: flex; + align-items: center; + border: none; + border-radius: 2px; + background: var(--bg-slate-500); + } + + .delete-btn { + display: flex; + align-items: center; + border: none; + border-radius: 2px; + background: var(--bg-cherry-500); + margin-left: 12px; + } + + .delete-btn:hover { + color: var(--bg-vanilla-100); + background: var(--bg-cherry-600); + } + } + } + + .title { + color: var(--bg-vanilla-100); + font-size: var(--font-size-sm); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: 20px; /* 142.857% */ + } +} + +.expiration-selector { + .ant-select-selector { + border: 1px solid var(--bg-slate-400) !important; + } +} + +.newAPIKeyDetails { + display: flex; + flex-direction: column; + gap: 8px; +} + +.copyable-text { + display: inline-flex; + align-items: center; + gap: 12px; + border-radius: 20px; + padding: 0px 12px; + background: var(--bg-ink-200, #23262e); + + .copy-key-btn { + cursor: pointer; + } +} + +.lightMode { + .api-key-container { + .api-key-content { + .title { + color: var(--bg-ink-500); + } + + .ant-table-row { + .ant-table-cell { + background: var(--bg-vanilla-200); + } + + &:hover { + .ant-table-cell { + background: var(--bg-vanilla-200) !important; + } + } + + .column-render { + border: 1px solid var(--bg-vanilla-200); + background: var(--bg-vanilla-100); + + .ant-collapse { + border: none; + + .ant-collapse-header { + background: var(--bg-vanilla-100); + } + + .ant-collapse-content { + border-top: 1px solid var(--bg-vanilla-300); + } + } + + .title-with-action { + .api-key-title { + .ant-typography { + color: var(--bg-ink-500); + } + } + + .api-key-value { + background: var(--bg-vanilla-200); + + .ant-typography { + color: var(--bg-slate-400); + } + + .copy-key-btn { + cursor: pointer; + } + } + + .action-btn { + .ant-typography { + color: var(--bg-ink-500); + } + } + } + + .api-key-details { + border-top: 1px solid var(--bg-vanilla-200); + .api-key-tag { + background: var(--bg-vanilla-200); + .tag-text { + color: var(--bg-ink-500); + } + } + + .api-key-created-by { + color: var(--bg-ink-500); + } + + .api-key-last-used-at { + .ant-typography { + color: var(--bg-ink-500); + } + } + } + } + } + } + } + + .delete-api-key-modal { + .ant-modal-content { + border: 1px solid var(--bg-vanilla-200); + background: var(--bg-vanilla-100); + + .ant-modal-header { + background: var(--bg-vanilla-100); + + .title { + color: var(--bg-ink-500); + } + } + + .ant-modal-body { + .ant-typography { + color: var(--bg-ink-500); + } + + .api-key-input { + .ant-input { + background: var(--bg-vanilla-200); + color: var(--bg-ink-500); + } + } + } + + .ant-modal-footer { + .cancel-btn { + background: var(--bg-vanilla-300); + color: var(--bg-ink-400); + } + } + } + } + + .api-key-info-container { + .user-email { + background: var(--bg-vanilla-200); + } + } + + .api-key-modal { + .ant-modal-content { + border-radius: 4px; + border: 1px solid var(--bg-vanilla-200); + background: var(--bg-vanilla-100); + box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2); + padding: 0; + + .ant-modal-header { + background: none; + border-bottom: 1px solid var(--bg-vanilla-200); + padding: 16px; + } + } + } + + .api-key-access-role { + .ant-radio-button-wrapper { + &.ant-radio-button-wrapper-checked { + color: var(--bg-ink-400); + background: var(--bg-vanilla-300); + border-color: var(--bg-vanilla-300); + + &:hover { + color: var(--bg-ink-400); + background: var(--bg-vanilla-300); + border-color: var(--bg-vanilla-300); + + &::before { + background-color: var(--bg-vanilla-300); + } + } + + &:focus { + color: var(--bg-ink-400); + background: var(--bg-vanilla-300); + border-color: var(--bg-vanilla-300); + } + } + } + + .tab { + border: 1px solid var(--bg-vanilla-300); + + &::before { + background: var(--bg-vanilla-300); + } + + &.selected { + background: var(--bg-vanilla-300); + } + } + } + + .copyable-text { + background: var(--bg-vanilla-200); + } +} diff --git a/frontend/src/container/APIKeys/APIKeys.test.tsx b/frontend/src/container/APIKeys/APIKeys.test.tsx new file mode 100644 index 0000000000..cfc2239236 --- /dev/null +++ b/frontend/src/container/APIKeys/APIKeys.test.tsx @@ -0,0 +1,99 @@ +import { + createAPIKeyResponse, + getAPIKeysResponse, +} from 'mocks-server/__mockdata__/apiKeys'; +import { server } from 'mocks-server/server'; +import { rest } from 'msw'; +import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils'; + +import APIKeys from './APIKeys'; + +const apiKeysURL = 'http://localhost/api/v1/pats'; + +describe('APIKeys component', () => { + beforeEach(() => { + server.use( + rest.get(apiKeysURL, (req, res, ctx) => + res(ctx.status(200), ctx.json(getAPIKeysResponse)), + ), + ); + + render(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders APIKeys component without crashing', () => { + expect(screen.getByText('API Keys')).toBeInTheDocument(); + expect( + screen.getByText('Create and manage access keys for the SigNoz API'), + ).toBeInTheDocument(); + }); + + it('render list of API Keys', async () => { + server.use( + rest.get(apiKeysURL, (req, res, ctx) => + res(ctx.status(200), ctx.json(getAPIKeysResponse)), + ), + ); + + await waitFor(() => { + expect(screen.getByText('No Expiry Token')).toBeInTheDocument(); + expect(screen.getByText('1-5 of 18 API Keys')).toBeInTheDocument(); + }); + }); + + it('opens add new key modal on button click', async () => { + fireEvent.click(screen.getByText('New Key')); + await waitFor(() => { + const createNewKeyBtn = screen.getByRole('button', { + name: /Create new key/i, + }); + + expect(createNewKeyBtn).toBeInTheDocument(); + }); + }); + + it('closes add new key modal on cancel button click', async () => { + fireEvent.click(screen.getByText('New Key')); + + const createNewKeyBtn = screen.getByRole('button', { + name: /Create new key/i, + }); + + await waitFor(() => { + expect(createNewKeyBtn).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText('Cancel')); + await waitFor(() => { + expect(createNewKeyBtn).not.toBeInTheDocument(); + }); + }); + + it('creates a new key on form submission', async () => { + server.use( + rest.post(apiKeysURL, (req, res, ctx) => + res(ctx.status(200), ctx.json(createAPIKeyResponse)), + ), + ); + + fireEvent.click(screen.getByText('New Key')); + + const createNewKeyBtn = screen.getByRole('button', { + name: /Create new key/i, + }); + + await waitFor(() => { + expect(createNewKeyBtn).toBeInTheDocument(); + }); + + act(() => { + const inputElement = screen.getByPlaceholderText('Enter Key Name'); + fireEvent.change(inputElement, { target: { value: 'Top Secret' } }); + fireEvent.click(screen.getByTestId('create-form-admin-role-btn')); + fireEvent.click(createNewKeyBtn); + }); + }); +}); diff --git a/frontend/src/container/APIKeys/APIKeys.tsx b/frontend/src/container/APIKeys/APIKeys.tsx new file mode 100644 index 0000000000..0540e86954 --- /dev/null +++ b/frontend/src/container/APIKeys/APIKeys.tsx @@ -0,0 +1,844 @@ +import './APIKeys.styles.scss'; + +import { Color } from '@signozhq/design-tokens'; +import { + Avatar, + Button, + Col, + Collapse, + Flex, + Form, + Input, + Modal, + Radio, + Row, + Select, + Table, + TableProps, + Tooltip, + Typography, +} from 'antd'; +import { NotificationInstance } from 'antd/es/notification/interface'; +import { CollapseProps } from 'antd/lib'; +import createAPIKeyApi from 'api/APIKeys/createAPIKey'; +import deleteAPIKeyApi from 'api/APIKeys/deleteAPIKey'; +import updateAPIKeyApi from 'api/APIKeys/updateAPIKey'; +import axios, { AxiosError } from 'axios'; +import cx from 'classnames'; +import { SOMETHING_WENT_WRONG } from 'constants/api'; +import { useGetAllAPIKeys } from 'hooks/APIKeys/useGetAllAPIKeys'; +import { useNotifications } from 'hooks/useNotifications'; +import { + CalendarClock, + Check, + ClipboardEdit, + Contact2, + Copy, + Eye, + Minus, + PenLine, + Plus, + Search, + Trash2, + View, + X, +} from 'lucide-react'; +import { ChangeEvent, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from 'react-query'; +import { useSelector } from 'react-redux'; +import { useCopyToClipboard } from 'react-use'; +import { AppState } from 'store/reducers'; +import { APIKeyProps } from 'types/api/pat/types'; +import AppReducer from 'types/reducer/app'; +import { USER_ROLES } from 'types/roles'; + +export const showErrorNotification = ( + notifications: NotificationInstance, + err: Error, +): void => { + notifications.error({ + message: axios.isAxiosError(err) ? err.message : SOMETHING_WENT_WRONG, + }); +}; + +type ExpiryOption = { + value: string; + label: string; +}; + +const EXPIRATION_WITHIN_SEVEN_DAYS = 7; + +const API_KEY_EXPIRY_OPTIONS: ExpiryOption[] = [ + { value: '1', label: '1 day' }, + { value: '7', label: '1 week' }, + { value: '30', label: '1 month' }, + { value: '90', label: '3 months' }, + { value: '365', label: '1 year' }, + { value: '0', label: 'No Expiry' }, +]; + +function APIKeys(): JSX.Element { + const { user } = useSelector((state) => state.app); + const { notifications } = useNotifications(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [showNewAPIKeyDetails, setShowNewAPIKeyDetails] = useState(false); + const [, handleCopyToClipboard] = useCopyToClipboard(); + + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [activeAPIKey, setActiveAPIKey] = useState(); + + const [searchValue, setSearchValue] = useState(''); + const [dataSource, setDataSource] = useState([]); + const { t } = useTranslation(['apiKeys']); + + const [editForm] = Form.useForm(); + const [createForm] = Form.useForm(); + + const handleFormReset = (): void => { + editForm.resetFields(); + createForm.resetFields(); + }; + + const hideDeleteViewModal = (): void => { + handleFormReset(); + setActiveAPIKey(null); + setIsDeleteModalOpen(false); + }; + + const showDeleteModal = (apiKey: APIKeyProps): void => { + setActiveAPIKey(apiKey); + setIsDeleteModalOpen(true); + }; + + const hideEditViewModal = (): void => { + handleFormReset(); + setActiveAPIKey(null); + setIsEditModalOpen(false); + }; + + const hideAddViewModal = (): void => { + handleFormReset(); + setShowNewAPIKeyDetails(false); + setActiveAPIKey(null); + setIsAddModalOpen(false); + }; + + const showEditModal = (apiKey: APIKeyProps): void => { + handleFormReset(); + setActiveAPIKey(apiKey); + + editForm.setFieldsValue({ + name: apiKey.name, + role: apiKey.role || USER_ROLES.VIEWER, + }); + + setIsEditModalOpen(true); + }; + + const showAddModal = (): void => { + setActiveAPIKey(null); + setIsAddModalOpen(true); + }; + + const handleModalClose = (): void => { + setActiveAPIKey(null); + }; + + const { + data: APIKeys, + isLoading, + isRefetching, + refetch: refetchAPIKeys, + error, + isError, + } = useGetAllAPIKeys(); + + useEffect(() => { + setActiveAPIKey(APIKeys?.data.data[0]); + }, [APIKeys]); + + useEffect(() => { + setDataSource(APIKeys?.data.data || []); + }, [APIKeys?.data.data]); + + useEffect(() => { + if (isError) { + showErrorNotification(notifications, error as AxiosError); + } + }, [error, isError, notifications]); + + const handleSearch = (e: ChangeEvent): void => { + setSearchValue(e.target.value); + const filteredData = APIKeys?.data?.data?.filter( + (key: APIKeyProps) => + key && + key.name && + key.name.toLowerCase().includes(e.target.value.toLowerCase()), + ); + setDataSource(filteredData || []); + }; + + const clearSearch = (): void => { + setSearchValue(''); + }; + + const { mutate: createAPIKey, isLoading: isLoadingCreateAPIKey } = useMutation( + createAPIKeyApi, + { + onSuccess: (data) => { + setShowNewAPIKeyDetails(true); + setActiveAPIKey(data.payload); + + refetchAPIKeys(); + }, + onError: (error) => { + showErrorNotification(notifications, error as AxiosError); + }, + }, + ); + + const { mutate: updateAPIKey, isLoading: isLoadingUpdateAPIKey } = useMutation( + updateAPIKeyApi, + { + onSuccess: () => { + refetchAPIKeys(); + setIsEditModalOpen(false); + }, + onError: (error) => { + showErrorNotification(notifications, error as AxiosError); + }, + }, + ); + + const { mutate: deleteAPIKey, isLoading: isDeleteingAPIKey } = useMutation( + deleteAPIKeyApi, + { + onSuccess: () => { + refetchAPIKeys(); + setIsDeleteModalOpen(false); + }, + onError: (error) => { + showErrorNotification(notifications, error as AxiosError); + }, + }, + ); + + const onDeleteHandler = (): void => { + clearSearch(); + + if (activeAPIKey) { + deleteAPIKey(activeAPIKey.id); + } + }; + + const onUpdateApiKey = (): void => { + editForm + .validateFields() + .then((values) => { + if (activeAPIKey) { + updateAPIKey({ + id: activeAPIKey.id, + data: { + name: values.name, + role: values.role, + }, + }); + } + }) + .catch((errorInfo) => { + console.error('error info', errorInfo); + }); + }; + + const onCreateAPIKey = (): void => { + createForm + .validateFields() + .then((values) => { + if (user) { + createAPIKey({ + name: values.name, + expiresInDays: parseInt(values.expiration, 10), + role: values.role, + }); + } + }) + .catch((errorInfo) => { + console.error('error info', errorInfo); + }); + }; + + const handleCopyKey = (text: string): void => { + handleCopyToClipboard(text); + notifications.success({ + message: 'Copied to clipboard', + }); + }; + + const getFormattedTime = (epochTime: number): string => { + const timeOptions: Intl.DateTimeFormatOptions = { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }; + const formattedTime = new Date(epochTime * 1000).toLocaleTimeString( + 'en-US', + timeOptions, + ); + + const dateOptions: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + }; + + const formattedDate = new Date(epochTime * 1000).toLocaleDateString( + 'en-US', + dateOptions, + ); + + return `${formattedDate} ${formattedTime}`; + }; + + const handleCopyClose = (): void => { + if (activeAPIKey) { + handleCopyKey(activeAPIKey?.token); + } + + hideAddViewModal(); + }; + + const getDateDifference = ( + createdTimestamp: number, + expiryTimestamp: number, + ): number => { + const differenceInSeconds = Math.abs(expiryTimestamp - createdTimestamp); + + // Convert seconds to days + return differenceInSeconds / (60 * 60 * 24); + }; + + const columns: TableProps['columns'] = [ + { + title: 'API Key', + key: 'api-key', + render: (APIKey: APIKeyProps): JSX.Element => { + const formattedDateAndTime = + APIKey && APIKey?.lastUsed && APIKey?.lastUsed !== 0 + ? getFormattedTime(APIKey?.lastUsed) + : 'Never'; + + const createdOn = getFormattedTime(APIKey.createdAt); + + const expiresIn = + APIKey.expiresAt === 0 + ? Number.POSITIVE_INFINITY + : getDateDifference(APIKey?.createdAt, APIKey?.expiresAt); + + const expiresOn = + !APIKey.expiresAt || APIKey.expiresAt === 0 + ? 'No Expiry' + : getFormattedTime(APIKey.expiresAt); + + const updatedOn = + !APIKey.updatedAt || APIKey.updatedAt === 0 + ? null + : getFormattedTime(APIKey?.updatedAt); + + const items: CollapseProps['items'] = [ + { + key: '1', + label: ( +
+
+
+ {APIKey?.name} +
+ +
+ + {APIKey?.token.substring(0, 2)}******** + {APIKey?.token.substring(APIKey.token.length - 2).trim()} + + + { + e.stopPropagation(); + e.preventDefault(); + handleCopyKey(APIKey.token); + }} + /> +
+ + {APIKey.role === USER_ROLES.ADMIN && ( + + + + )} + + {APIKey.role === USER_ROLES.EDITOR && ( + + + + )} + + {APIKey.role === USER_ROLES.VIEWER && ( + + + + )} + + {!APIKey.role && ( + + + + )} +
+
+
+
+ ), + children: ( +
+ {APIKey?.createdByUser && ( + + Creator + + + {APIKey?.createdByUser?.name?.substring(0, 1)} + + + {APIKey.createdByUser?.name} + +
{APIKey.createdByUser?.email}
+ +
+ )} + + Created on + + {createdOn} + + + {updatedOn && ( + + Updated on + + {updatedOn} + + + )} + + + Expires on + + {expiresOn} + + +
+ ), + }, + ]; + + return ( +
+ + +
+
+ + Last used + {formattedDateAndTime} +
+ {expiresIn <= EXPIRATION_WITHIN_SEVEN_DAYS && ( +
+ Expires in {expiresIn} Days +
+ )} +
+
+ ); + }, + }, + ]; + + return ( +
+
+
+ API Keys + + Create and manage access keys for the SigNoz API + +
+ +
+ } + value={searchValue} + onChange={handleSearch} + /> + + +
+ + + `${range[0]}-${range[1]} of ${total} API Keys`, + }} + /> + + + {/* Delete Key Modal */} + Delete key} + open={isDeleteModalOpen} + closable + afterClose={handleModalClose} + onCancel={hideDeleteViewModal} + destroyOnClose + footer={[ + , + , + ]} + > + + {t('delete_confirm_message', { + keyName: activeAPIKey?.name, + })} + + + + {/* Edit Key Modal */} + } + > + Cancel + , + , + ]} + > +
+ + + + + + + + +
+ Admin +
+
+ +
+ Editor +
+
+ +
+ Viewer +
+
+
+
+
+ +
+ + {/* Create New Key Modal */} + } + > + Copy key and close + , + ] + : [ + , + , + ] + } + > + {!showNewAPIKeyDetails && ( +
+ + + + + + + + +
+ Admin +
+
+ +
+ Editor +
+
+ +
+ Viewer +
+
+
+
+
+ +