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 <rajat@signoz.io>
This commit is contained in:
Yunus M 2024-02-19 09:19:06 +05:30 committed by GitHub
parent c4bbbf372c
commit e2669eb370
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 3382 additions and 41 deletions

View File

@ -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)/)',

View File

@ -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.

View File

@ -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)": [

View File

@ -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",

View File

@ -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",

View File

@ -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."
}

View File

@ -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",

View File

@ -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",

View File

@ -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);

View File

@ -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'),
);

View File

@ -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,

View File

@ -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<SuccessResponse<APIKeyProps> | 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;

View File

@ -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<SuccessResponse<AllAPIKeyProps> | 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;

View File

@ -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<SuccessResponse<PayloadProps> | 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;

View File

@ -0,0 +1,6 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import { AllAPIKeyProps } from 'types/api/pat/types';
export const getAllAPIKeys = (): Promise<AxiosResponse<AllAPIKeyProps>> =>
axios.get(`/pats`);

View File

@ -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<SuccessResponse<PayloadProps> | 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;

View File

@ -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',

View File

@ -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);
}
}

View File

@ -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(<APIKeys />);
});
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);
});
});
});

View File

@ -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<AppState, AppReducer>((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<APIKeyProps | null>();
const [searchValue, setSearchValue] = useState<string>('');
const [dataSource, setDataSource] = useState<APIKeyProps[]>([]);
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<HTMLInputElement>): 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<APIKeyProps>['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: (
<div className="title-with-action">
<div className="api-key-data">
<div className="api-key-title">
<Typography.Text>{APIKey?.name}</Typography.Text>
</div>
<div className="api-key-value">
<Typography.Text>
{APIKey?.token.substring(0, 2)}********
{APIKey?.token.substring(APIKey.token.length - 2).trim()}
</Typography.Text>
<Copy
className="copy-key-btn"
size={12}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
handleCopyKey(APIKey.token);
}}
/>
</div>
{APIKey.role === USER_ROLES.ADMIN && (
<Tooltip title={USER_ROLES.ADMIN}>
<Contact2 size={14} color={Color.BG_ROBIN_400} />
</Tooltip>
)}
{APIKey.role === USER_ROLES.EDITOR && (
<Tooltip title={USER_ROLES.EDITOR}>
<ClipboardEdit size={14} color={Color.BG_ROBIN_400} />
</Tooltip>
)}
{APIKey.role === USER_ROLES.VIEWER && (
<Tooltip title={USER_ROLES.VIEWER}>
<View size={14} color={Color.BG_ROBIN_400} />
</Tooltip>
)}
{!APIKey.role && (
<Tooltip title={USER_ROLES.ADMIN}>
<Contact2 size={14} color={Color.BG_ROBIN_400} />
</Tooltip>
)}
</div>
<div className="action-btn">
<Button
className="periscope-btn ghost"
icon={<PenLine size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
showEditModal(APIKey);
}}
/>
<Button
className="periscope-btn ghost"
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
showDeleteModal(APIKey);
}}
/>
</div>
</div>
),
children: (
<div className="api-key-info-container">
{APIKey?.createdByUser && (
<Row>
<Col span={6}> Creator </Col>
<Col span={12} className="user-info">
<Avatar className="user-avatar" size="small">
{APIKey?.createdByUser?.name?.substring(0, 1)}
</Avatar>
<Typography.Text>{APIKey.createdByUser?.name}</Typography.Text>
<div className="user-email">{APIKey.createdByUser?.email}</div>
</Col>
</Row>
)}
<Row>
<Col span={6}> Created on </Col>
<Col span={12}>
<Typography.Text>{createdOn}</Typography.Text>
</Col>
</Row>
{updatedOn && (
<Row>
<Col span={6}> Updated on </Col>
<Col span={12}>
<Typography.Text>{updatedOn}</Typography.Text>
</Col>
</Row>
)}
<Row>
<Col span={6}> Expires on </Col>
<Col span={12}>
<Typography.Text>{expiresOn}</Typography.Text>
</Col>
</Row>
</div>
),
},
];
return (
<div className="column-render">
<Collapse items={items} />
<div className="api-key-details">
<div className="api-key-last-used-at">
<CalendarClock size={14} />
Last used <Minus size={12} />
<Typography.Text>{formattedDateAndTime}</Typography.Text>
</div>
{expiresIn <= EXPIRATION_WITHIN_SEVEN_DAYS && (
<div
className={cx(
'api-key-expires-in',
expiresIn <= 3 ? 'danger' : 'warning',
)}
>
<span className="dot" /> Expires in {expiresIn} Days
</div>
)}
</div>
</div>
);
},
},
];
return (
<div className="api-key-container">
<div className="api-key-content">
<header>
<Typography.Title className="title">API Keys</Typography.Title>
<Typography.Text className="subtitle">
Create and manage access keys for the SigNoz API
</Typography.Text>
</header>
<div className="api-keys-search-add-new">
<Input
placeholder="Search for keys..."
prefix={<Search size={12} color={Color.BG_VANILLA_400} />}
value={searchValue}
onChange={handleSearch}
/>
<Button
className="add-new-api-key-btn"
type="primary"
onClick={showAddModal}
>
<Plus size={14} /> New Key
</Button>
</div>
<Table
columns={columns}
dataSource={dataSource}
loading={isLoading || isRefetching}
showHeader={false}
pagination={{
pageSize: 5,
hideOnSinglePage: true,
showTotal: (total: number, range: number[]): string =>
`${range[0]}-${range[1]} of ${total} API Keys`,
}}
/>
</div>
{/* Delete Key Modal */}
<Modal
className="delete-api-key-modal"
title={<span className="title">Delete key</span>}
open={isDeleteModalOpen}
closable
afterClose={handleModalClose}
onCancel={hideDeleteViewModal}
destroyOnClose
footer={[
<Button
key="cancel"
onClick={hideDeleteViewModal}
className="cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<Button
key="submit"
icon={<Trash2 size={16} />}
loading={isDeleteingAPIKey}
onClick={onDeleteHandler}
className="delete-btn"
>
Delete key
</Button>,
]}
>
<Typography.Text className="delete-text">
{t('delete_confirm_message', {
keyName: activeAPIKey?.name,
})}
</Typography.Text>
</Modal>
{/* Edit Key Modal */}
<Modal
className="api-key-modal"
title="Edit key"
open={isEditModalOpen}
key="edit-api-key-modal"
afterClose={handleModalClose}
// closable
onCancel={hideEditViewModal}
destroyOnClose
footer={[
<Button
key="cancel"
onClick={hideEditViewModal}
className="periscope-btn cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<Button
className="periscope-btn primary"
key="submit"
type="primary"
loading={isLoadingUpdateAPIKey}
icon={<Check size={14} />}
onClick={onUpdateApiKey}
>
Update key
</Button>,
]}
>
<Form
name="edit-api-key-form"
key={activeAPIKey?.id}
form={editForm}
layout="vertical"
autoComplete="off"
initialValues={{
name: activeAPIKey?.name,
role: activeAPIKey?.role,
}}
>
<Form.Item
name="name"
label="Name"
rules={[{ required: true }, { type: 'string', min: 6 }]}
>
<Input placeholder="Enter Key Name" autoFocus />
</Form.Item>
<Form.Item name="role" label="Role">
<Flex vertical gap="middle">
<Radio.Group
buttonStyle="solid"
className="api-key-access-role"
defaultValue={activeAPIKey?.role}
>
<Radio.Button value={USER_ROLES.ADMIN} className={cx('tab')}>
<div className="role">
<Contact2 size={14} /> Admin
</div>
</Radio.Button>
<Radio.Button value={USER_ROLES.EDITOR} className={cx('tab')}>
<div className="role">
<ClipboardEdit size={14} /> Editor
</div>
</Radio.Button>
<Radio.Button value={USER_ROLES.VIEWER} className={cx('tab')}>
<div className="role">
<Eye size={14} /> Viewer
</div>
</Radio.Button>
</Radio.Group>
</Flex>
</Form.Item>
</Form>
</Modal>
{/* Create New Key Modal */}
<Modal
className="api-key-modal"
title="Create new key"
open={isAddModalOpen}
key="create-api-key-modal"
closable
onCancel={hideAddViewModal}
destroyOnClose
footer={
showNewAPIKeyDetails
? [
<Button
key="copy-key-close"
className="periscope-btn primary"
data-testid="copy-key-close-btn"
type="primary"
onClick={handleCopyClose}
icon={<Check size={12} />}
>
Copy key and close
</Button>,
]
: [
<Button
key="cancel"
onClick={hideAddViewModal}
className="periscope-btn cancel-btn"
icon={<X size={16} />}
>
Cancel
</Button>,
<Button
className="periscope-btn primary"
test-id="create-new-key"
key="submit"
type="primary"
icon={<Check size={14} />}
loading={isLoadingCreateAPIKey}
onClick={onCreateAPIKey}
>
Create new key
</Button>,
]
}
>
{!showNewAPIKeyDetails && (
<Form
key="createForm"
name="create-api-key-form"
form={createForm}
initialValues={{
role: USER_ROLES.ADMIN,
expiration: '1',
name: '',
}}
layout="vertical"
autoComplete="off"
>
<Form.Item
name="name"
label="Name"
rules={[{ required: true }, { type: 'string', min: 6 }]}
validateTrigger="onFinish"
>
<Input placeholder="Enter Key Name" autoFocus />
</Form.Item>
<Form.Item name="role" label="Role">
<Flex vertical gap="middle">
<Radio.Group
buttonStyle="solid"
className="api-key-access-role"
defaultValue={USER_ROLES.ADMIN}
>
<Radio.Button value={USER_ROLES.ADMIN} className={cx('tab')}>
<div className="role" data-testid="create-form-admin-role-btn">
<Contact2 size={14} /> Admin
</div>
</Radio.Button>
<Radio.Button value={USER_ROLES.EDITOR} className="tab">
<div className="role" data-testid="create-form-editor-role-btn">
<ClipboardEdit size={14} /> Editor
</div>
</Radio.Button>
<Radio.Button value={USER_ROLES.VIEWER} className="tab">
<div className="role" data-testid="create-form-viewer-role-btn">
<Eye size={14} /> Viewer
</div>
</Radio.Button>
</Radio.Group>
</Flex>
</Form.Item>
<Form.Item name="expiration" label="Expiration">
<Select
className="expiration-selector"
placeholder="Expiration"
options={API_KEY_EXPIRY_OPTIONS}
/>
</Form.Item>
</Form>
)}
{showNewAPIKeyDetails && (
<div className="api-key-info-container">
<Row>
<Col span={8}>Key</Col>
<Col span={16}>
<span className="copyable-text">
<Typography.Text>
{activeAPIKey?.token.substring(0, 2)}****************
{activeAPIKey?.token.substring(activeAPIKey.token.length - 2).trim()}
</Typography.Text>
<Copy
className="copy-key-btn"
size={12}
onClick={(): void => {
if (activeAPIKey) {
handleCopyKey(activeAPIKey.token);
}
}}
/>
</span>
</Col>
</Row>
<Row>
<Col span={8}>Name</Col>
<Col span={16}>{activeAPIKey?.name}</Col>
</Row>
<Row>
<Col span={8}>Role</Col>
<Col span={16}>
{activeAPIKey?.role === USER_ROLES.ADMIN && (
<div className="role">
<Contact2 size={14} /> Admin
</div>
)}
{activeAPIKey?.role === USER_ROLES.EDITOR && (
<div className="role">
{' '}
<ClipboardEdit size={14} /> Editor
</div>
)}
{activeAPIKey?.role === USER_ROLES.VIEWER && (
<div className="role">
{' '}
<View size={14} /> Viewer
</div>
)}
</Col>
</Row>
<Row>
<Col span={8}>Creator</Col>
<Col span={16} className="user-info">
<Avatar className="user-avatar" size="small">
{activeAPIKey?.createdByUser?.name?.substring(0, 1)}
</Avatar>
<Typography.Text>{activeAPIKey?.createdByUser?.name}</Typography.Text>
<div className="user-email">{activeAPIKey?.createdByUser?.email}</div>
</Col>
</Row>
{activeAPIKey?.createdAt && (
<Row>
<Col span={8}>Created on</Col>
<Col span={16}>{getFormattedTime(activeAPIKey?.createdAt)}</Col>
</Row>
)}
{activeAPIKey?.expiresAt !== 0 && activeAPIKey?.expiresAt && (
<Row>
<Col span={8}>Expires on</Col>
<Col span={16}>{getFormattedTime(activeAPIKey?.expiresAt)}</Col>
</Row>
)}
{activeAPIKey?.expiresAt === 0 && (
<Row>
<Col span={8}>Expires on</Col>
<Col span={16}> No Expiry </Col>
</Row>
)}
</div>
)}
</Modal>
</div>
);
}
export default APIKeys;

View File

@ -121,6 +121,7 @@ export const routesToSkip = [
ROUTES.ALL_DASHBOARD,
ROUTES.ORG_SETTINGS,
ROUTES.INGESTION_SETTINGS,
ROUTES.API_KEYS,
ROUTES.ERROR_DETAIL,
ROUTES.LOGS_PIPELINES,
ROUTES.BILLING,

View File

@ -0,0 +1,13 @@
import { getAllAPIKeys } from 'api/APIKeys/getAllAPIKeys';
import { AxiosError, AxiosResponse } from 'axios';
import { useQuery, UseQueryResult } from 'react-query';
import { AllAPIKeyProps } from 'types/api/pat/types';
export const useGetAllAPIKeys = (): UseQueryResult<
AxiosResponse<AllAPIKeyProps>,
AxiosError
> =>
useQuery<AxiosResponse<AllAPIKeyProps>, AxiosError>({
queryKey: ['APIKeys'],
queryFn: () => getAllAPIKeys(),
});

View File

@ -0,0 +1,541 @@
const createdByEmail = 'mando@signoz.io';
export const getAPIKeysResponse = {
status: 'success',
data: [
{
id: '26',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '',
name: '',
email: '',
createdAt: 0,
profilePictureURL: '',
notFound: true,
},
token: 'T2DuASwpuUx3wlYraFl5r7N9G1ikBhzGuy2ihcIKDMs=',
role: 'ADMIN',
name: '1 Day Old',
createdAt: 1708010258,
expiresAt: 1708096658,
updatedAt: 1708010258,
lastUsed: 0,
revoked: false,
updatedByUserId: '',
},
{
id: '24',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
token: 'EteVs77BA4FFLJD/TsFE9c+CLX4kXVmlx+0GGK7dpXY=',
role: 'ADMIN',
name: '1 year expiry - updated',
createdAt: 1708008146,
expiresAt: 1739544146,
updatedAt: 1708008239,
lastUsed: 0,
revoked: false,
updatedByUserId: 'mandalorian',
},
{
id: '25',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
token: '1udrUFmRI6gdb8r/hLabS7zRlgfMQlUw/tz9sac82pE=',
role: 'ADMIN',
name: 'No Expiry Token',
createdAt: 1708008178,
expiresAt: 0,
updatedAt: 1708008190,
lastUsed: 0,
revoked: false,
updatedByUserId: 'mandalorian',
},
{
id: '22',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
token: 'gtqKF7g7avoe+Yu2+WhyDDLQSr6IsVaR5xpby2XhLAY=',
role: 'VIEWER',
name: 'No Expiry',
createdAt: 1708007395,
expiresAt: 0,
updatedAt: 1708007936,
lastUsed: 0,
revoked: false,
updatedByUserId: 'mandalorian',
},
{
id: '23',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
token: 'GM/TqEID8N4ynlvQHK38ITEvRAcn5XkJZpmd11xT3OQ=',
role: 'VIEWER',
name: 'No Expiry - 2',
createdAt: 1708007685,
expiresAt: 0,
updatedAt: 1708007786,
lastUsed: 0,
revoked: false,
updatedByUserId: 'mandalorian',
},
{
id: '19',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
token: 'Oj75e6Zr7JmjFcWIo0UK/Nl06RdC2BKOr/QVHoBA0gM=',
role: 'ADMIN',
name: '7 Days',
createdAt: 1708003326,
expiresAt: 1708608126,
updatedAt: 1708007380,
lastUsed: 0,
revoked: false,
updatedByUserId: 'mandalorian',
},
{
id: '20',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
token: 'T+sNdYe6I74ya/9mEKqB3UTrFm8+jwI0DiirqEx3bsM=',
role: 'EDITOR',
name: '1 month',
createdAt: 1708004012,
expiresAt: 1710596012,
updatedAt: 1708005206,
lastUsed: 0,
revoked: false,
updatedByUserId: 'mandalorian',
},
{
id: '21',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
token: 'JWw26FuymeHq+fsfFcb+2+Ls/MdokmeXxXdZisuaVeI=',
role: 'ADMIN',
name: '3 Months',
createdAt: 1708004755,
expiresAt: 1715780755,
updatedAt: 1708005197,
lastUsed: 0,
revoked: false,
updatedByUserId: 'mandalorian',
},
{
id: '17',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '',
name: '',
email: '',
createdAt: 0,
profilePictureURL: '',
notFound: true,
},
token: '2zDrYNr+IWXUyA14+afVvO6GI9dcHfEsOYxjA9mrprg=',
role: 'ADMIN',
name: 'New No Expiry',
createdAt: 1708000444,
expiresAt: 0,
updatedAt: 1708000444,
lastUsed: 0,
revoked: false,
updatedByUserId: '',
},
{
id: '14',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '',
name: '',
email: '',
createdAt: 0,
profilePictureURL: '',
notFound: true,
},
token: 'Q+/+UB2OrDPcS9b0+5A1dDXYmWHz0abbVVidF48QCso=',
role: 'EDITOR',
name: 'Editor Token for user 1',
createdAt: 1707997720,
expiresAt: 1708170520,
updatedAt: 1707997720,
lastUsed: 0,
revoked: false,
updatedByUserId: '',
},
{
id: '13',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '',
name: '',
email: '',
createdAt: 0,
profilePictureURL: '',
notFound: true,
},
token: '/X3OEaSOLrrJImvzIB3g5WGg+5831X89fZZQT1JaxvQ=',
role: 'EDITOR',
name: 'Editor Token for user 2',
createdAt: 1707997603,
expiresAt: 1708170403,
updatedAt: 1707997603,
lastUsed: 0,
revoked: false,
updatedByUserId: '',
},
{
id: '12',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '',
name: '',
email: '',
createdAt: 0,
profilePictureURL: '',
notFound: true,
},
token: 'bTs+Q6waIiP4KJ8L5N58EQonuapWMXsfEra/cmMwmbE=',
role: 'EDITOR',
name: 'Editor Token for user 3',
createdAt: 1707997539,
expiresAt: 1708170339,
updatedAt: 1707997539,
lastUsed: 0,
revoked: false,
updatedByUserId: '',
},
{
id: '11',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '',
name: '',
email: '',
createdAt: 0,
profilePictureURL: '',
notFound: true,
},
token: 'YaEqQHrH8KOnYFllor/8Tq653TgxPU1Z7ZDzY3+ETmI=',
role: 'EDITOR',
name: 'Editor Token for user',
createdAt: 1707997537,
expiresAt: 1708170337,
updatedAt: 1707997537,
lastUsed: 0,
revoked: false,
updatedByUserId: '',
},
{
id: '10',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '',
name: '',
email: '',
createdAt: 0,
profilePictureURL: '',
notFound: true,
},
token: 'Hg/QpMU9VQyqIuzSh9ND2454IN5uOHzVkv7owEtBcPo=',
role: 'EDITOR',
name: 'test123',
createdAt: 1707997288,
expiresAt: 1708083688,
updatedAt: 1707997288,
lastUsed: 0,
revoked: false,
updatedByUserId: '',
},
{
id: '9',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '',
name: '',
email: '',
createdAt: 0,
profilePictureURL: '',
notFound: true,
},
token: 'M5gMsccDthPTibquB7kR7ZSEI76y4endOxZPESZ9/po=',
role: 'VIEWER',
name: 'Viewer Token for user',
createdAt: 1707996747,
expiresAt: 1708255947,
updatedAt: 1707996747,
lastUsed: 0,
revoked: false,
updatedByUserId: '',
},
{
id: '8',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '',
name: '',
email: '',
createdAt: 0,
profilePictureURL: '',
notFound: true,
},
token: 'H8NVlOD09IcMgQ/rzfVucb+4+jEcqZ4ZRx6n7QztMSc=',
role: 'EDITOR',
name: 'Editor Token for user',
createdAt: 1707996736,
expiresAt: 1708169536,
updatedAt: 1707996736,
lastUsed: 0,
revoked: false,
updatedByUserId: '',
},
{
id: '7',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '',
name: '',
email: '',
createdAt: 0,
profilePictureURL: '',
notFound: true,
},
token: 'z24SswLmNlPVUgb1j6rfc2u4Kb4xSUolwb11cI8kbrs=',
role: 'ADMIN',
name: 'Admin Token for user',
createdAt: 1707996719,
expiresAt: 0,
updatedAt: 1707996719,
lastUsed: 0,
revoked: false,
updatedByUserId: '',
},
{
id: '5',
userId: 'mandalorian',
createdByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
updatedByUser: {
id: '001',
name: 'Mando',
email: createdByEmail,
createdAt: 1707974098,
profilePictureURL: '',
notFound: false,
},
token: 'SWuNSF08EB6+VN05312QaAsPum2wkqIm+ujiWZKnm2Q=',
role: 'EDITOR',
name: 'Editor Token',
createdAt: 1707992270,
expiresAt: 1708165070,
updatedAt: 1707995424,
lastUsed: 1707992517,
revoked: false,
updatedByUserId: 'mandalorian',
},
],
};
export const createAPIKeyResponse = {
status: 'success',
data: {
id: '57',
userId: 'mandalorian',
token: 'pQ5kiHjcbQ2FbKlS14LQjA2RzXEBi/KvBfM7BRSwltI=',
name: 'test1233',
createdAt: 1707818550,
expiresAt: 0,
},
};

View File

@ -1,16 +1,22 @@
import { RouteTabProps } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
import AlertChannels from 'container/AllAlertChannels';
import APIKeys from 'container/APIKeys/APIKeys';
import GeneralSettings from 'container/GeneralSettings';
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
import IngestionSettings from 'container/IngestionSettings/IngestionSettings';
import OrganizationSettings from 'container/OrganizationSettings';
import { TFunction } from 'i18next';
import { Backpack, BellDot, Building, Cpu, KeySquare } from 'lucide-react';
export const organizationSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: OrganizationSettings,
name: t('routes:organization_settings').toString(),
name: (
<div className="periscope-tab">
<Building size={16} /> {t('routes:organization_settings').toString()}
</div>
),
route: ROUTES.ORG_SETTINGS,
key: ROUTES.ORG_SETTINGS,
},
@ -19,7 +25,11 @@ export const organizationSettings = (t: TFunction): RouteTabProps['routes'] => [
export const alertChannels = (t: TFunction): RouteTabProps['routes'] => [
{
Component: AlertChannels,
name: t('routes:alert_channels').toString(),
name: (
<div className="periscope-tab">
<BellDot size={16} /> {t('routes:alert_channels').toString()}
</div>
),
route: ROUTES.ALL_CHANNELS,
key: ROUTES.ALL_CHANNELS,
},
@ -28,7 +38,11 @@ export const alertChannels = (t: TFunction): RouteTabProps['routes'] => [
export const ingestionSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: IngestionSettings,
name: t('routes:ingestion_settings').toString(),
name: (
<div className="periscope-tab">
<Cpu size={16} /> {t('routes:ingestion_settings').toString()}
</div>
),
route: ROUTES.INGESTION_SETTINGS,
key: ROUTES.INGESTION_SETTINGS,
},
@ -37,7 +51,11 @@ export const ingestionSettings = (t: TFunction): RouteTabProps['routes'] => [
export const generalSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: GeneralSettings,
name: t('routes:general').toString(),
name: (
<div className="periscope-tab">
<Backpack size={16} /> {t('routes:general').toString()}
</div>
),
route: ROUTES.SETTINGS,
key: ROUTES.SETTINGS,
},
@ -46,8 +64,25 @@ export const generalSettings = (t: TFunction): RouteTabProps['routes'] => [
export const generalSettingsCloud = (t: TFunction): RouteTabProps['routes'] => [
{
Component: GeneralSettingsCloud,
name: t('routes:general').toString(),
name: (
<div className="periscope-tab">
<Backpack size={16} /> {t('routes:general').toString()}
</div>
),
route: ROUTES.SETTINGS,
key: ROUTES.SETTINGS,
},
];
export const apiKeys = (t: TFunction): RouteTabProps['routes'] => [
{
Component: APIKeys,
name: (
<div className="periscope-tab">
<KeySquare size={16} /> {t('routes:api_keys').toString()}
</div>
),
route: ROUTES.API_KEYS,
key: ROUTES.API_KEYS,
},
];

View File

@ -19,7 +19,8 @@ function SettingsPage(): JSX.Element {
);
const { t } = useTranslation(['routes']);
const routes = useMemo(() => getRoutes(isCurrentOrgSettings, t), [
const routes = useMemo(() => getRoutes(role, isCurrentOrgSettings, t), [
role,
isCurrentOrgSettings,
t,
]);

View File

@ -1,20 +1,25 @@
import { RouteTabProps } from 'components/RouteTab/types';
import { TFunction } from 'i18next';
import { isCloudUser } from 'utils/app';
import { ROLES, USER_ROLES } from 'types/roles';
import { isCloudUser, isEECloudUser } from 'utils/app';
import {
alertChannels,
apiKeys,
generalSettings,
ingestionSettings,
organizationSettings,
} from './config';
export const getRoutes = (
userRole: ROLES | null,
isCurrentOrgSettings: boolean,
t: TFunction,
): RouteTabProps['routes'] => {
const settings = [];
settings.push(...generalSettings(t));
if (isCurrentOrgSettings) {
settings.push(...organizationSettings(t));
}
@ -26,7 +31,9 @@ export const getRoutes = (
settings.push(...alertChannels(t));
}
settings.push(...generalSettings(t));
if ((isCloudUser() || isEECloudUser()) && userRole === USER_ROLES.ADMIN) {
settings.push(...apiKeys(t));
}
return settings;
};

View File

@ -24,6 +24,18 @@
}
cursor: pointer;
&.primary {
color: #fff;
background-color: #4566d6;
box-shadow: 0 2px 0 rgba(62, 86, 245, 0.09);
}
}
.periscope-tab {
display: flex;
align-items: center;
gap: 8px;
}
.lightMode {

View File

@ -0,0 +1,53 @@
export interface User {
createdAt?: number;
email?: string;
id: string;
name?: string;
notFound?: boolean;
profilePictureURL?: string;
}
export interface APIKeyProps {
name: string;
expiresAt: number;
role: string;
token: string;
id: string;
createdAt: number;
createdByUser?: User;
updatedAt?: number;
updatedByUser?: User;
lastUsed?: number;
}
export interface CreateAPIKeyProps {
name: string;
expiresInDays: number;
role: string;
}
export interface AllAPIKeyProps {
status: string;
data: APIKeyProps[];
}
export interface CreateAPIKeyProp {
data: APIKeyProps;
}
export interface DeleteAPIKeyPayloadProps {
status: string;
}
export interface UpdateAPIKeyProps {
id: string;
data: {
name: string;
role: string;
};
}
export type PayloadProps = {
status: string;
data: string;
};

View File

@ -88,6 +88,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACES_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
API_KEYS: ['ADMIN'],
LOGS_BASE: [],
OLD_LOGS_EXPLORER: [],
SHORTCUTS: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@ -23,6 +23,11 @@
"isolatedModules": true,
"noEmit": true,
"baseUrl": "./src",
"paths": {
"@constants/*": [
"/container/OnboardingContainer/constants/*"
]
},
"downlevelIteration": true,
"plugins": [
{

File diff suppressed because it is too large Load Diff