mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-19 11:12:18 +00:00
Compare commits
21 Commits
service-ac
...
update-mem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9bcd2d4e1 | ||
|
|
34dcd79243 | ||
|
|
145d6327a7 | ||
|
|
61cfd33fc6 | ||
|
|
b299d63263 | ||
|
|
94f3e6d6d7 | ||
|
|
7ae0a23103 | ||
|
|
2d91b5fd0b | ||
|
|
ab1428d413 | ||
|
|
9c859e4d07 | ||
|
|
d6de4d58f7 | ||
|
|
e52c5683dd | ||
|
|
90e3cb6775 | ||
|
|
155f287462 | ||
|
|
c8fcc48022 | ||
|
|
44b6885639 | ||
|
|
0e5a128325 | ||
|
|
fd19ff8e5e | ||
|
|
7b9e93162f | ||
|
|
f106f57097 | ||
|
|
5bafdeb373 |
@@ -308,6 +308,9 @@ user:
|
||||
allow_self: true
|
||||
# The duration within which a user can reset their password.
|
||||
max_token_lifetime: 6h
|
||||
invite:
|
||||
# The duration within which a user can accept their invite.
|
||||
max_token_lifetime: 48h
|
||||
root:
|
||||
# Whether to enable the root user. When enabled, a root user is provisioned
|
||||
# on startup using the email and password below. The root user cannot be
|
||||
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.115.0
|
||||
image: signoz/signoz:v0.116.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.115.0
|
||||
image: signoz/signoz:v0.116.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.115.0}
|
||||
image: signoz/signoz:${VERSION:-v0.116.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.115.0}
|
||||
image: signoz/signoz:${VERSION:-v0.116.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -2101,17 +2101,6 @@ components:
|
||||
role:
|
||||
type: string
|
||||
type: object
|
||||
TypesPostableAcceptInvite:
|
||||
properties:
|
||||
displayName:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
sourceUrl:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
TypesPostableBulkInviteRequest:
|
||||
properties:
|
||||
invites:
|
||||
@@ -3290,53 +3279,6 @@ paths:
|
||||
tags:
|
||||
- global
|
||||
/api/v1/invite:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists all invites
|
||||
operationId: ListInvite
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/TypesInvite'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: List invites
|
||||
tags:
|
||||
- users
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates an invite for a user
|
||||
@@ -3399,151 +3341,6 @@ paths:
|
||||
summary: Create invite
|
||||
tags:
|
||||
- users
|
||||
/api/v1/invite/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint deletes an invite by id
|
||||
operationId: DeleteInvite
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Delete invite
|
||||
tags:
|
||||
- users
|
||||
/api/v1/invite/{token}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets an invite by token
|
||||
operationId: GetInvite
|
||||
parameters:
|
||||
- in: path
|
||||
name: token
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesInvite'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
summary: Get invite
|
||||
tags:
|
||||
- users
|
||||
/api/v1/invite/accept:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint accepts an invite by token
|
||||
operationId: AcceptInvite
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TypesPostableAcceptInvite'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesUser'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
summary: Accept invite
|
||||
tags:
|
||||
- users
|
||||
/api/v1/invite/bulk:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -24,7 +24,8 @@ const config: Config.InitialOptions = {
|
||||
'<rootDir>/node_modules/@signozhq/icons/dist/index.esm.js',
|
||||
'^react-syntax-highlighter/dist/esm/(.*)$':
|
||||
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',
|
||||
'^@signozhq/([^/]+)$': '<rootDir>/node_modules/@signozhq/$1/dist/$1.js',
|
||||
'^@signozhq/(?!ui$)([^/]+)$':
|
||||
'<rootDir>/node_modules/@signozhq/$1/dist/$1.js',
|
||||
},
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
testMatch: ['<rootDir>/src/**/*?(*.)(test).(ts|js)?(x)'],
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"@signozhq/table": "0.3.7",
|
||||
"@signozhq/toggle-group": "0.0.1",
|
||||
"@signozhq/tooltip": "0.0.2",
|
||||
"@signozhq/ui": "0.0.5",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"@tanstack/react-virtual": "3.11.2",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
@@ -136,7 +137,6 @@
|
||||
"react-full-screen": "1.1.1",
|
||||
"react-grid-layout": "^1.3.4",
|
||||
"react-helmet-async": "1.3.0",
|
||||
"react-hook-form": "7.71.2",
|
||||
"react-i18next": "^11.16.1",
|
||||
"react-lottie": "1.2.10",
|
||||
"react-markdown": "8.0.7",
|
||||
|
||||
@@ -15,6 +15,5 @@
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"members": "Members",
|
||||
"service_accounts": "Service Accounts"
|
||||
"members": "Members"
|
||||
}
|
||||
|
||||
@@ -50,8 +50,5 @@
|
||||
"INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring",
|
||||
"METER_EXPLORER": "SigNoz | Meter Explorer",
|
||||
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
|
||||
"METER": "SigNoz | Meter",
|
||||
"ROLES_SETTINGS": "SigNoz | Roles",
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members",
|
||||
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
|
||||
"METER": "SigNoz | Meter"
|
||||
}
|
||||
|
||||
@@ -15,6 +15,5 @@
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details",
|
||||
"members": "Members",
|
||||
"service_accounts": "Service Accounts"
|
||||
"members": "Members"
|
||||
}
|
||||
|
||||
@@ -75,6 +75,5 @@
|
||||
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
|
||||
"METER": "SigNoz | Meter",
|
||||
"ROLES_SETTINGS": "SigNoz | Roles",
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members",
|
||||
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts"
|
||||
"MEMBERS_SETTINGS": "SigNoz | Members"
|
||||
}
|
||||
|
||||
@@ -2511,25 +2511,6 @@ export interface TypesPostableAPIKeyDTO {
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableAcceptInviteDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
sourceUrl?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface TypesPostableBulkInviteRequestDTO {
|
||||
/**
|
||||
* @type array
|
||||
@@ -3033,17 +3014,6 @@ export type GetGlobalConfig200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListInvite200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: TypesInviteDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateInvite201 = {
|
||||
data: TypesInviteDTO;
|
||||
/**
|
||||
@@ -3052,28 +3022,6 @@ export type CreateInvite201 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteInvitePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetInvitePathParameters = {
|
||||
token: string;
|
||||
};
|
||||
export type GetInvite200 = {
|
||||
data: TypesInviteDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type AcceptInvite201 = {
|
||||
data: TypesUserDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListPromotedAndIndexedPaths200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -20,26 +20,20 @@ import { useMutation, useQuery } from 'react-query';
|
||||
import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type {
|
||||
AcceptInvite201,
|
||||
ChangePasswordPathParameters,
|
||||
CreateAPIKey201,
|
||||
CreateInvite201,
|
||||
DeleteInvitePathParameters,
|
||||
DeleteUserPathParameters,
|
||||
GetInvite200,
|
||||
GetInvitePathParameters,
|
||||
GetMyUser200,
|
||||
GetResetPasswordToken200,
|
||||
GetResetPasswordTokenPathParameters,
|
||||
GetUser200,
|
||||
GetUserPathParameters,
|
||||
ListAPIKeys200,
|
||||
ListInvite200,
|
||||
ListUsers200,
|
||||
RenderErrorResponseDTO,
|
||||
RevokeAPIKeyPathParameters,
|
||||
TypesChangePasswordRequestDTO,
|
||||
TypesPostableAcceptInviteDTO,
|
||||
TypesPostableAPIKeyDTO,
|
||||
TypesPostableBulkInviteRequestDTO,
|
||||
TypesPostableForgotPasswordDTO,
|
||||
@@ -255,84 +249,6 @@ export const invalidateGetResetPasswordToken = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists all invites
|
||||
* @summary List invites
|
||||
*/
|
||||
export const listInvite = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListInvite200>({
|
||||
url: `/api/v1/invite`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListInviteQueryKey = () => {
|
||||
return [`/api/v1/invite`] as const;
|
||||
};
|
||||
|
||||
export const getListInviteQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listInvite>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof listInvite>>, TError, TData>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListInviteQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listInvite>>> = ({
|
||||
signal,
|
||||
}) => listInvite(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listInvite>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListInviteQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listInvite>>
|
||||
>;
|
||||
export type ListInviteQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List invites
|
||||
*/
|
||||
|
||||
export function useListInvite<
|
||||
TData = Awaited<ReturnType<typeof listInvite>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof listInvite>>, TError, TData>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListInviteQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List invites
|
||||
*/
|
||||
export const invalidateListInvite = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListInviteQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates an invite for a user
|
||||
* @summary Create invite
|
||||
@@ -416,257 +332,6 @@ export const useCreateInvite = <
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint deletes an invite by id
|
||||
* @summary Delete invite
|
||||
*/
|
||||
export const deleteInvite = ({ id }: DeleteInvitePathParameters) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/invite/${id}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteInviteMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteInvite>>,
|
||||
TError,
|
||||
{ pathParams: DeleteInvitePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteInvite>>,
|
||||
TError,
|
||||
{ pathParams: DeleteInvitePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteInvite'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteInvite>>,
|
||||
{ pathParams: DeleteInvitePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteInvite(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteInviteMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteInvite>>
|
||||
>;
|
||||
|
||||
export type DeleteInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete invite
|
||||
*/
|
||||
export const useDeleteInvite = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteInvite>>,
|
||||
TError,
|
||||
{ pathParams: DeleteInvitePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteInvite>>,
|
||||
TError,
|
||||
{ pathParams: DeleteInvitePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getDeleteInviteMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint gets an invite by token
|
||||
* @summary Get invite
|
||||
*/
|
||||
export const getInvite = (
|
||||
{ token }: GetInvitePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetInvite200>({
|
||||
url: `/api/v1/invite/${token}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetInviteQueryKey = ({ token }: GetInvitePathParameters) => {
|
||||
return [`/api/v1/invite/${token}`] as const;
|
||||
};
|
||||
|
||||
export const getGetInviteQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getInvite>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ token }: GetInvitePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getInvite>>, TError, TData>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetInviteQueryKey({ token });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getInvite>>> = ({
|
||||
signal,
|
||||
}) => getInvite({ token }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!token,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<Awaited<ReturnType<typeof getInvite>>, TError, TData> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetInviteQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getInvite>>
|
||||
>;
|
||||
export type GetInviteQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get invite
|
||||
*/
|
||||
|
||||
export function useGetInvite<
|
||||
TData = Awaited<ReturnType<typeof getInvite>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>
|
||||
>(
|
||||
{ token }: GetInvitePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof getInvite>>, TError, TData>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetInviteQueryOptions({ token }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get invite
|
||||
*/
|
||||
export const invalidateGetInvite = async (
|
||||
queryClient: QueryClient,
|
||||
{ token }: GetInvitePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetInviteQueryKey({ token }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint accepts an invite by token
|
||||
* @summary Accept invite
|
||||
*/
|
||||
export const acceptInvite = (
|
||||
typesPostableAcceptInviteDTO: BodyType<TypesPostableAcceptInviteDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<AcceptInvite201>({
|
||||
url: `/api/v1/invite/accept`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: typesPostableAcceptInviteDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getAcceptInviteMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['acceptInvite'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return acceptInvite(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type AcceptInviteMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof acceptInvite>>
|
||||
>;
|
||||
export type AcceptInviteMutationBody = BodyType<TypesPostableAcceptInviteDTO>;
|
||||
export type AcceptInviteMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Accept invite
|
||||
*/
|
||||
export const useAcceptInvite = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof acceptInvite>>,
|
||||
TError,
|
||||
{ data: BodyType<TypesPostableAcceptInviteDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationOptions = getAcceptInviteMutationOptions(options);
|
||||
|
||||
return useMutation(mutationOptions);
|
||||
};
|
||||
/**
|
||||
* This endpoint creates a bulk invite for a user
|
||||
* @summary Create bulk invite
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, PendingInvite } from 'types/api/user/getPendingInvites';
|
||||
|
||||
const get = async (): Promise<SuccessResponseV2<PendingInvite[]>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/invite`);
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default get;
|
||||
@@ -1,22 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/user/accept';
|
||||
import { UserResponse } from 'types/api/user/getUser';
|
||||
|
||||
const accept = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<UserResponse>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>(`/invite/accept`, props);
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default accept;
|
||||
@@ -1,20 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { Props } from 'types/api/user/deleteInvite';
|
||||
|
||||
const del = async (props: Props): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.delete(`/invite/${props.id}`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: null,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default del;
|
||||
@@ -1,28 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
InviteDetails,
|
||||
PayloadProps,
|
||||
Props,
|
||||
} from 'types/api/user/getInviteDetails';
|
||||
|
||||
const getInviteDetails = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<InviteDetails>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(
|
||||
`/invite/${props.inviteId}?ref=${window.location.href}`,
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getInviteDetails;
|
||||
1
frontend/src/auto-import-registry.d.ts
vendored
1
frontend/src/auto-import-registry.d.ts
vendored
@@ -30,3 +30,4 @@ import '@signozhq/switch';
|
||||
import '@signozhq/table';
|
||||
import '@signozhq/toggle-group';
|
||||
import '@signozhq/tooltip';
|
||||
import '@signozhq/ui';
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
.announcement-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
height: 40px;
|
||||
font-family: var(--font-sans), sans-serif;
|
||||
font-size: var(--label-base-500-font-size);
|
||||
line-height: var(--label-base-500-line-height);
|
||||
font-weight: var(--label-base-500-font-weight);
|
||||
letter-spacing: -0.065px;
|
||||
|
||||
&--warning {
|
||||
background-color: var(--callout-warning-background);
|
||||
color: var(--callout-warning-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-warning-border);
|
||||
}
|
||||
}
|
||||
|
||||
&--info {
|
||||
background-color: var(--callout-primary-background);
|
||||
color: var(--callout-primary-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-primary-border);
|
||||
}
|
||||
}
|
||||
|
||||
&--error {
|
||||
background-color: var(--callout-error-background);
|
||||
color: var(--callout-error-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-error-border);
|
||||
}
|
||||
}
|
||||
|
||||
&--success {
|
||||
background-color: var(--callout-success-background);
|
||||
color: var(--callout-success-description);
|
||||
.announcement-banner__action,
|
||||
.announcement-banner__dismiss {
|
||||
background: var(--callout-success-border);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: var(--line-height-normal);
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
}
|
||||
|
||||
&__action {
|
||||
height: 24px;
|
||||
font-size: var(--label-small-500-font-size);
|
||||
color: currentColor;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&__dismiss {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
color: currentColor;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
AnnouncementBanner,
|
||||
AnnouncementBannerProps,
|
||||
PersistedAnnouncementBanner,
|
||||
} from './index';
|
||||
|
||||
const STORAGE_KEY = 'test-banner-dismissed';
|
||||
|
||||
function renderBanner(props: Partial<AnnouncementBannerProps> = {}): void {
|
||||
render(<AnnouncementBanner message="Test message" {...props} />);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('AnnouncementBanner', () => {
|
||||
it('renders message and default warning variant', () => {
|
||||
renderBanner({ message: <strong>Heads up</strong> });
|
||||
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveClass('announcement-banner--warning');
|
||||
expect(alert).toHaveTextContent('Heads up');
|
||||
});
|
||||
|
||||
it.each(['warning', 'info', 'success', 'error'] as const)(
|
||||
'renders %s variant correctly',
|
||||
(type) => {
|
||||
renderBanner({ type, message: 'Test message' });
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveClass(`announcement-banner--${type}`);
|
||||
},
|
||||
);
|
||||
|
||||
it('calls action onClick when action button is clicked', async () => {
|
||||
const onClick = jest.fn() as jest.MockedFunction<() => void>;
|
||||
renderBanner({ action: { label: 'Go to Settings', onClick } });
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByRole('button', { name: /go to settings/i }));
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides dismiss button when onClose is not provided and hides icon when icon is null', () => {
|
||||
renderBanner({ onClose: undefined, icon: null });
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /dismiss/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('alert')?.querySelector('.announcement-banner__icon'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersistedAnnouncementBanner', () => {
|
||||
it('dismisses on click, calls onDismiss, and persists to localStorage', async () => {
|
||||
const onDismiss = jest.fn() as jest.MockedFunction<() => void>;
|
||||
render(
|
||||
<PersistedAnnouncementBanner
|
||||
message="Test message"
|
||||
storageKey={STORAGE_KEY}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByRole('button', { name: /dismiss/i }));
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('true');
|
||||
});
|
||||
|
||||
it('does not render when storageKey is already set in localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
render(
|
||||
<PersistedAnnouncementBanner
|
||||
message="Test message"
|
||||
storageKey={STORAGE_KEY}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import {
|
||||
CircleAlert,
|
||||
CircleCheckBig,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import './AnnouncementBanner.styles.scss';
|
||||
|
||||
export type AnnouncementBannerType = 'warning' | 'info' | 'error' | 'success';
|
||||
|
||||
export interface AnnouncementBannerAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface AnnouncementBannerProps {
|
||||
message: ReactNode;
|
||||
type?: AnnouncementBannerType;
|
||||
icon?: ReactNode | null;
|
||||
action?: AnnouncementBannerAction;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ICONS: Record<AnnouncementBannerType, ReactNode> = {
|
||||
warning: <TriangleAlert size={14} />,
|
||||
info: <Info size={14} />,
|
||||
error: <CircleAlert size={14} />,
|
||||
success: <CircleCheckBig size={14} />,
|
||||
};
|
||||
|
||||
export default function AnnouncementBanner({
|
||||
message,
|
||||
type = 'warning',
|
||||
icon,
|
||||
action,
|
||||
onClose,
|
||||
className,
|
||||
}: AnnouncementBannerProps): JSX.Element {
|
||||
const resolvedIcon = icon === null ? null : icon ?? DEFAULT_ICONS[type];
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cx(
|
||||
'announcement-banner',
|
||||
`announcement-banner--${type}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="announcement-banner__body">
|
||||
{resolvedIcon && (
|
||||
<span className="announcement-banner__icon">{resolvedIcon}</span>
|
||||
)}
|
||||
<span className="announcement-banner__message">{message}</span>
|
||||
{action && (
|
||||
<Button
|
||||
type="button"
|
||||
className="announcement-banner__action"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onClose && (
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="Dismiss"
|
||||
className="announcement-banner__dismiss"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import AnnouncementBanner, {
|
||||
AnnouncementBannerProps,
|
||||
} from './AnnouncementBanner';
|
||||
|
||||
interface PersistedAnnouncementBannerProps extends AnnouncementBannerProps {
|
||||
storageKey: string;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
function isDismissed(storageKey: string): boolean {
|
||||
return localStorage.getItem(storageKey) === 'true';
|
||||
}
|
||||
|
||||
export default function PersistedAnnouncementBanner({
|
||||
storageKey,
|
||||
onDismiss,
|
||||
...props
|
||||
}: PersistedAnnouncementBannerProps): JSX.Element | null {
|
||||
const [visible, setVisible] = useState(() => !isDismissed(storageKey));
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClose = (): void => {
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
setVisible(false);
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
return <AnnouncementBanner {...props} onClose={handleClose} />;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import AnnouncementBanner from './AnnouncementBanner';
|
||||
import PersistedAnnouncementBanner from './PersistedAnnouncementBanner';
|
||||
|
||||
export type {
|
||||
AnnouncementBannerAction,
|
||||
AnnouncementBannerProps,
|
||||
AnnouncementBannerType,
|
||||
} from './AnnouncementBanner';
|
||||
|
||||
export { AnnouncementBanner, PersistedAnnouncementBanner };
|
||||
|
||||
export default AnnouncementBanner;
|
||||
@@ -1,106 +0,0 @@
|
||||
.create-sa-modal {
|
||||
max-width: 530px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
|
||||
|
||||
[data-slot='dialog-header'] {
|
||||
padding: var(--padding-4);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
font-size: var(--label-base-400-font-size);
|
||||
font-weight: var(--label-base-400-font-weight);
|
||||
line-height: var(--label-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--bg-base-white);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
|
||||
.create-sa-modal__content {
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.create-sa-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
> label {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
font-size: var(--paragraph-small-400-font-size);
|
||||
color: var(--destructive);
|
||||
line-height: var(--paragraph-small-400-line-height);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__helper {
|
||||
font-size: var(--paragraph-small-400-font-size);
|
||||
color: var(--l3-foreground);
|
||||
margin: calc(var(--spacing-2) * -1) 0 var(--spacing-4) 0;
|
||||
line-height: var(--paragraph-small-400-line-height);
|
||||
}
|
||||
}
|
||||
|
||||
.create-sa-modal__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
border-top: 1px solid var(--secondary);
|
||||
gap: var(--spacing-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.create-sa-modal {
|
||||
[data-slot='dialog-title'] {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
invalidateListServiceAccounts,
|
||||
useCreateServiceAccount,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import RolesSelect, { useRoles } from 'components/RolesSelect';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
|
||||
import './CreateServiceAccountModal.styles.scss';
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
function CreateServiceAccountModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const [isOpen, setIsOpen] = useQueryState(
|
||||
'create-sa',
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isValid, errors },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: createServiceAccount,
|
||||
isLoading: isSubmitting,
|
||||
} = useCreateServiceAccount({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast.success('Service account created successfully', {
|
||||
richColors: true,
|
||||
});
|
||||
reset();
|
||||
void setIsOpen(null);
|
||||
void invalidateListServiceAccounts(queryClient);
|
||||
},
|
||||
onError: (err) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
err as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'An error occurred';
|
||||
toast.error(`Failed to create service account: ${errMessage}`, {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
const {
|
||||
roles,
|
||||
isLoading: rolesLoading,
|
||||
isError: rolesError,
|
||||
error: rolesErrorObj,
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
function handleClose(): void {
|
||||
reset();
|
||||
void setIsOpen(null);
|
||||
}
|
||||
|
||||
function handleCreate(values: FormValues): void {
|
||||
createServiceAccount({
|
||||
data: {
|
||||
name: values.name.trim(),
|
||||
email: values.email.trim(),
|
||||
roles: values.roles,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="New Service Account"
|
||||
open={isOpen}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
showCloseButton
|
||||
width="narrow"
|
||||
className="create-sa-modal"
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<div className="create-sa-modal__content">
|
||||
<form className="create-sa-form">
|
||||
<div className="create-sa-form__item">
|
||||
<label htmlFor="sa-name">Name</label>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: 'Name is required' }}
|
||||
render={({ field }): JSX.Element => (
|
||||
<Input
|
||||
id="sa-name"
|
||||
placeholder="Enter a name"
|
||||
className="create-sa-form__input"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="create-sa-form__error">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="create-sa-form__item">
|
||||
<label htmlFor="sa-email">Email Address</label>
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
rules={{
|
||||
required: 'Email Address is required',
|
||||
pattern: {
|
||||
value: EMAIL_REGEX,
|
||||
message: 'Please enter a valid email address',
|
||||
},
|
||||
}}
|
||||
render={({ field }): JSX.Element => (
|
||||
<Input
|
||||
id="sa-email"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
className="create-sa-form__input"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="create-sa-form__error">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="create-sa-form__helper">
|
||||
Used only for notifications about this service account. It is not used for
|
||||
authentication.
|
||||
</p>
|
||||
|
||||
<div className="create-sa-form__item">
|
||||
<label htmlFor="sa-roles">Roles</label>
|
||||
<Controller
|
||||
name="roles"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): string | true =>
|
||||
value.length > 0 || 'At least one role is required',
|
||||
}}
|
||||
render={({ field }): JSX.Element => (
|
||||
<RolesSelect
|
||||
id="sa-roles"
|
||||
mode="multiple"
|
||||
roles={roles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={refetchRoles}
|
||||
placeholder="Select roles"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
getPopupContainer={(triggerNode): HTMLElement =>
|
||||
(triggerNode?.closest('.create-sa-modal') as HTMLElement) ||
|
||||
document.body
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.roles && (
|
||||
<p className="create-sa-form__error">{errors.roles.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="create-sa-modal__footer">
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={handleSubmit(handleCreate)}
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Create Service Account
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateServiceAccountModal;
|
||||
@@ -1,179 +0,0 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CreateServiceAccountModal from '../CreateServiceAccountModal';
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const SERVICE_ACCOUNTS_ENDPOINT = '*/api/v1/service_accounts';
|
||||
|
||||
function renderModal(): ReturnType<typeof render> {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={{ 'create-sa': 'true' }} hasMemory>
|
||||
<CreateServiceAccountModal />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('CreateServiceAccountModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('submit button is disabled when form is empty', () => {
|
||||
renderModal();
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Service Account/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('submit button remains disabled when email is invalid', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter a name'), 'My Bot');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'not-an-email',
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Select roles'));
|
||||
await user.click(await screen.findByTitle('signoz-admin'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Service Account/i }),
|
||||
).toBeDisabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('successful submit shows toast.success and closes modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter a name'), 'Deploy Bot');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'deploy@acme.io',
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Select roles'));
|
||||
await user.click(await screen.findByTitle('signoz-admin'));
|
||||
|
||||
const submitBtn = screen.getByRole('button', {
|
||||
name: /Create Service Account/i,
|
||||
});
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Service account created successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /New Service Account/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows toast.error on API error and keeps modal open', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(500),
|
||||
ctx.json({ status: 'error', error: 'Internal Server Error' }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter a name'), 'Dupe Bot');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'dupe@acme.io',
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Select roles'));
|
||||
await user.click(await screen.findByTitle('signoz-admin'));
|
||||
|
||||
const submitBtn = screen.getByRole('button', {
|
||||
name: /Create Service Account/i,
|
||||
});
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Failed to create service account/i),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /New Service Account/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Cancel button closes modal without submitting', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await screen.findByRole('dialog', { name: /New Service Account/i });
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /New Service Account/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Name is required" after clearing the name field', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Enter a name');
|
||||
await user.type(nameInput, 'Bot');
|
||||
await user.clear(nameInput);
|
||||
|
||||
await screen.findByText('Name is required');
|
||||
});
|
||||
|
||||
it('shows "Please enter a valid email address" for a malformed email', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('email@example.com'),
|
||||
'not-an-email',
|
||||
);
|
||||
|
||||
await screen.findByText('Please enter a valid email address');
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Link,
|
||||
LockKeyhole,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
@@ -17,14 +16,11 @@ import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Select } from 'antd';
|
||||
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import cancelInvite from 'api/v1/invite/id/delete';
|
||||
import deleteUser from 'api/v1/user/id/delete';
|
||||
import update from 'api/v1/user/id/update';
|
||||
import { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { INVITE_PREFIX, MemberStatus } from 'container/MembersSettings/utils';
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ROLES } from 'types/roles';
|
||||
@@ -36,7 +32,6 @@ export interface EditMemberDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onComplete: () => void;
|
||||
onRefetch?: () => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
@@ -45,7 +40,6 @@ function EditMemberDrawer({
|
||||
open,
|
||||
onClose,
|
||||
onComplete,
|
||||
onRefetch,
|
||||
}: EditMemberDrawerProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
@@ -58,11 +52,9 @@ function EditMemberDrawer({
|
||||
const [resetLink, setResetLink] = useState<string | null>(null);
|
||||
const [showResetLinkDialog, setShowResetLinkDialog] = useState(false);
|
||||
const [hasCopiedResetLink, setHasCopiedResetLink] = useState(false);
|
||||
const [linkType, setLinkType] = useState<'invite' | 'reset' | null>(null);
|
||||
|
||||
const isInvited = member?.status === MemberStatus.Invited;
|
||||
// Invited member IDs are prefixed with 'invite-'; strip it to get the real invite ID
|
||||
const inviteId =
|
||||
isInvited && member ? member.id.slice(INVITE_PREFIX.length) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (member) {
|
||||
@@ -73,7 +65,7 @@ function EditMemberDrawer({
|
||||
|
||||
const isDirty =
|
||||
member !== null &&
|
||||
(displayName !== member.name || selectedRole !== member.role);
|
||||
(displayName !== (member.name ?? '') || selectedRole !== member.role);
|
||||
|
||||
const formatTimestamp = useCallback(
|
||||
(ts: string | null | undefined): string => {
|
||||
@@ -89,80 +81,22 @@ function EditMemberDrawer({
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const saveInvitedMember = useCallback(async (): Promise<void> => {
|
||||
if (!member || !inviteId) {
|
||||
return;
|
||||
}
|
||||
await cancelInvite({ id: inviteId });
|
||||
try {
|
||||
await sendInvite({
|
||||
email: member.email,
|
||||
name: displayName,
|
||||
role: selectedRole,
|
||||
frontendBaseUrl: window.location.origin,
|
||||
});
|
||||
toast.success('Invite updated successfully', { richColors: true });
|
||||
onComplete();
|
||||
onClose();
|
||||
} catch {
|
||||
onRefetch?.();
|
||||
onClose();
|
||||
toast.error(
|
||||
'Failed to send the updated invite. Please re-invite this member.',
|
||||
{ richColors: true },
|
||||
);
|
||||
}
|
||||
}, [
|
||||
member,
|
||||
inviteId,
|
||||
displayName,
|
||||
selectedRole,
|
||||
onComplete,
|
||||
onClose,
|
||||
onRefetch,
|
||||
]);
|
||||
|
||||
const saveActiveMember = useCallback(async (): Promise<void> => {
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
await update({
|
||||
userId: member.id,
|
||||
displayName,
|
||||
role: selectedRole,
|
||||
});
|
||||
toast.success('Member details updated successfully', { richColors: true });
|
||||
onComplete();
|
||||
onClose();
|
||||
}, [member, displayName, selectedRole, onComplete, onClose]);
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!member || !isDirty) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (isInvited && inviteId) {
|
||||
await saveInvitedMember();
|
||||
} else {
|
||||
await saveActiveMember();
|
||||
}
|
||||
await update({ userId: member.id, displayName, role: selectedRole });
|
||||
toast.success('Member details updated successfully', { richColors: true });
|
||||
onComplete();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(
|
||||
isInvited ? 'Failed to update invite' : 'Failed to update member details',
|
||||
{ richColors: true },
|
||||
);
|
||||
toast.error('Failed to update member details', { richColors: true });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
member,
|
||||
isDirty,
|
||||
isInvited,
|
||||
inviteId,
|
||||
saveInvitedMember,
|
||||
saveActiveMember,
|
||||
]);
|
||||
}, [member, isDirty, displayName, selectedRole, onComplete, onClose]);
|
||||
|
||||
const handleDelete = useCallback(async (): Promise<void> => {
|
||||
if (!member) {
|
||||
@@ -170,25 +104,23 @@ function EditMemberDrawer({
|
||||
}
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
if (isInvited && inviteId) {
|
||||
await cancelInvite({ id: inviteId });
|
||||
toast.success('Invitation cancelled successfully', { richColors: true });
|
||||
} else {
|
||||
await deleteUser({ userId: member.id });
|
||||
toast.success('Member deleted successfully', { richColors: true });
|
||||
}
|
||||
await deleteUser({ userId: member.id });
|
||||
toast.success(
|
||||
isInvited ? 'Invite revoked successfully' : 'Member deleted successfully',
|
||||
{ richColors: true },
|
||||
);
|
||||
setShowDeleteConfirm(false);
|
||||
onComplete();
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error(
|
||||
isInvited ? 'Failed to cancel invitation' : 'Failed to delete member',
|
||||
isInvited ? 'Failed to revoke invite' : 'Failed to delete member',
|
||||
{ richColors: true },
|
||||
);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [member, isInvited, inviteId, onComplete, onClose]);
|
||||
}, [member, isInvited, onComplete, onClose]);
|
||||
|
||||
const handleGenerateResetLink = useCallback(async (): Promise<void> => {
|
||||
if (!member) {
|
||||
@@ -201,6 +133,7 @@ function EditMemberDrawer({
|
||||
const link = `${window.location.origin}/password-reset?token=${response.data.token}`;
|
||||
setResetLink(link);
|
||||
setHasCopiedResetLink(false);
|
||||
setLinkType(isInvited ? 'invite' : 'reset');
|
||||
setShowResetLinkDialog(true);
|
||||
onClose();
|
||||
} else {
|
||||
@@ -217,7 +150,7 @@ function EditMemberDrawer({
|
||||
} finally {
|
||||
setIsGeneratingLink(false);
|
||||
}
|
||||
}, [member, onClose]);
|
||||
}, [member, isInvited, setLinkType, onClose]);
|
||||
|
||||
const handleCopyResetLink = useCallback(async (): Promise<void> => {
|
||||
if (!resetLink) {
|
||||
@@ -227,36 +160,18 @@ function EditMemberDrawer({
|
||||
await navigator.clipboard.writeText(resetLink);
|
||||
setHasCopiedResetLink(true);
|
||||
setTimeout(() => setHasCopiedResetLink(false), 2000);
|
||||
toast.success('Reset link copied to clipboard', { richColors: true });
|
||||
toast.success(
|
||||
linkType === 'invite'
|
||||
? 'Invite link copied to clipboard'
|
||||
: 'Reset link copied to clipboard',
|
||||
{ richColors: true },
|
||||
);
|
||||
} catch {
|
||||
toast.error('Failed to copy link', {
|
||||
richColors: true,
|
||||
});
|
||||
}
|
||||
}, [resetLink]);
|
||||
|
||||
const handleCopyInviteLink = useCallback(async (): Promise<void> => {
|
||||
if (!member?.token) {
|
||||
toast.error('Invite link is not available', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const inviteLink = `${window.location.origin}${ROUTES.SIGN_UP}?token=${member.token}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
toast.success('Invite link copied to clipboard', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
} catch {
|
||||
toast.error('Failed to copy invite link', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
}, [member]);
|
||||
}, [resetLink, linkType]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setShowDeleteConfirm(false);
|
||||
@@ -348,30 +263,22 @@ function EditMemberDrawer({
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isInvited ? 'Cancel Invite' : 'Delete Member'}
|
||||
{isInvited ? 'Revoke Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
|
||||
<div className="edit-member-drawer__footer-divider" />
|
||||
|
||||
{isInvited ? (
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleCopyInviteLink}
|
||||
disabled={!member?.token}
|
||||
>
|
||||
<Link size={12} />
|
||||
Copy Invite Link
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink ? 'Generating...' : 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink
|
||||
? 'Generating...'
|
||||
: isInvited
|
||||
? 'Copy Invite Link'
|
||||
: 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer-right">
|
||||
@@ -394,21 +301,21 @@ function EditMemberDrawer({
|
||||
</div>
|
||||
);
|
||||
|
||||
const deleteDialogTitle = isInvited ? 'Cancel Invitation' : 'Delete Member';
|
||||
const deleteDialogTitle = isInvited ? 'Revoke Invite' : 'Delete Member';
|
||||
const deleteDialogBody = isInvited ? (
|
||||
<>
|
||||
Are you sure you want to cancel the invitation for{' '}
|
||||
Are you sure you want to revoke the invite for{' '}
|
||||
<strong>{member?.email}</strong>? They will no longer be able to join the
|
||||
workspace using this invite.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Are you sure you want to delete{' '}
|
||||
<strong>{member?.name || member?.email}</strong>? This will permanently
|
||||
remove their access to the workspace.
|
||||
<strong>{member?.name || member?.email}</strong>? This will remove their
|
||||
access to the workspace.
|
||||
</>
|
||||
);
|
||||
const deleteConfirmLabel = isInvited ? 'Cancel Invite' : 'Delete Member';
|
||||
const deleteConfirmLabel = isInvited ? 'Revoke Invite' : 'Delete Member';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -434,17 +341,19 @@ function EditMemberDrawer({
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
setShowResetLinkDialog(false);
|
||||
setLinkType(null);
|
||||
}
|
||||
}}
|
||||
title="Password Reset Link"
|
||||
title={linkType === 'invite' ? 'Invite Link' : 'Password Reset Link'}
|
||||
showCloseButton
|
||||
width="base"
|
||||
className="reset-link-dialog"
|
||||
>
|
||||
<div className="reset-link-dialog__content">
|
||||
<p className="reset-link-dialog__description">
|
||||
This creates a one-time link the team member can use to set a new password
|
||||
for their SigNoz account.
|
||||
{linkType === 'invite'
|
||||
? 'Share this one-time link with the team member to complete their account setup.'
|
||||
: 'This creates a one-time link the team member can use to set a new password for their SigNoz account.'}
|
||||
</p>
|
||||
<div className="reset-link-dialog__link-row">
|
||||
<div className="reset-link-dialog__link-text-wrap">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
|
||||
import cancelInvite from 'api/v1/invite/id/delete';
|
||||
import deleteUser from 'api/v1/user/id/delete';
|
||||
import update from 'api/v1/user/id/update';
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
@@ -48,8 +47,6 @@ jest.mock('@signozhq/dialog', () => ({
|
||||
|
||||
jest.mock('api/v1/user/id/update');
|
||||
jest.mock('api/v1/user/id/delete');
|
||||
jest.mock('api/v1/invite/id/delete');
|
||||
jest.mock('api/v1/invite/create');
|
||||
jest.mock('api/v1/factor_password/getResetPasswordToken');
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
@@ -60,7 +57,6 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
|
||||
const mockUpdate = jest.mocked(update);
|
||||
const mockDeleteUser = jest.mocked(deleteUser);
|
||||
const mockCancelInvite = jest.mocked(cancelInvite);
|
||||
const mockGetResetPasswordToken = jest.mocked(getResetPasswordToken);
|
||||
|
||||
const activeMember = {
|
||||
@@ -74,13 +70,12 @@ const activeMember = {
|
||||
};
|
||||
|
||||
const invitedMember = {
|
||||
id: 'invite-abc123',
|
||||
id: 'abc123',
|
||||
name: '',
|
||||
email: 'bob@signoz.io',
|
||||
role: 'VIEWER' as ROLES,
|
||||
status: MemberStatus.Invited,
|
||||
joinedOn: '1700000000000',
|
||||
token: 'tok-xyz',
|
||||
};
|
||||
|
||||
function renderDrawer(
|
||||
@@ -102,7 +97,6 @@ describe('EditMemberDrawer', () => {
|
||||
jest.clearAllMocks();
|
||||
mockUpdate.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
mockDeleteUser.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
mockCancelInvite.mockResolvedValue({ httpStatusCode: 200, data: null });
|
||||
});
|
||||
|
||||
it('renders active member details and disables Save when form is not dirty', () => {
|
||||
@@ -163,36 +157,61 @@ describe('EditMemberDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Cancel Invite and Copy Invite Link for invited members; hides Last Modified', () => {
|
||||
it('shows revoke invite and copy invite link for invited members; hides Last Modified', () => {
|
||||
renderDrawer({ member: invitedMember });
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /cancel invite/i }),
|
||||
screen.getByRole('button', { name: /revoke invite/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /copy invite link/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /generate password reset link/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Invited On')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Last Modified')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls cancelInvite after confirming Cancel Invite for invited members', async () => {
|
||||
it('calls deleteUser after confirming revoke invite for invited members', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer({ member: invitedMember, onComplete });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /cancel invite/i }));
|
||||
await user.click(screen.getByRole('button', { name: /revoke invite/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/are you sure you want to cancel the invitation/i),
|
||||
await screen.findByText(/Are you sure you want to revoke the invite/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /cancel invite/i });
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /revoke invite/i });
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCancelInvite).toHaveBeenCalledWith({ id: 'abc123' });
|
||||
expect(mockDeleteUser).toHaveBeenCalledWith({ userId: 'abc123' });
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls update API when saving changes for an invited member', async () => {
|
||||
const onComplete = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer({ member: { ...invitedMember, name: 'Bob' }, onComplete });
|
||||
|
||||
const nameInput = screen.getByDisplayValue('Bob');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Bob Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'abc123', displayName: 'Bob Updated' }),
|
||||
);
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -260,7 +279,6 @@ describe('EditMemberDrawer', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^copy$/i }));
|
||||
|
||||
// Verify success path: writeText called with the correct link
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Reset link copied to clipboard',
|
||||
|
||||
@@ -254,8 +254,6 @@ function InviteMembersModal({
|
||||
value={row.email}
|
||||
onChange={(e): void => updateEmail(row.id, e.target.value)}
|
||||
className="team-member-email-input"
|
||||
name={`invite-email-${row.id}`}
|
||||
autoComplete="email"
|
||||
/>
|
||||
{emailValidity[row.id] === false && row.email.trim() !== '' && (
|
||||
<span className="email-error-message">Invalid email address</span>
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
line-height: var(--paragraph-base-400-font-height);
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type React from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Pagination, Table, Tooltip } from 'antd';
|
||||
import { Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { MemberStatus } from 'container/MembersSettings/utils';
|
||||
@@ -18,7 +18,6 @@ export interface MemberRow {
|
||||
status: MemberStatus;
|
||||
joinedOn: string | null;
|
||||
updatedAt?: string | null;
|
||||
token?: string | null;
|
||||
}
|
||||
|
||||
interface MembersTableProps {
|
||||
@@ -64,11 +63,23 @@ function StatusBadge({ status }: { status: MemberRow['status'] }): JSX.Element {
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge color="amber" variant="outline">
|
||||
INVITED
|
||||
</Badge>
|
||||
);
|
||||
if (status === MemberStatus.Deleted) {
|
||||
return (
|
||||
<Badge color="cherry" variant="outline">
|
||||
DELETED
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === MemberStatus.Invited) {
|
||||
return (
|
||||
<Badge color="amber" variant="outline">
|
||||
INVITED
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return <Badge color="vanilla">⎯</Badge>;
|
||||
}
|
||||
|
||||
function MembersEmptyState({
|
||||
@@ -199,14 +210,30 @@ function MembersTable({
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
total,
|
||||
showTotal: showPaginationTotal,
|
||||
showSizeChanger: false,
|
||||
onChange: onPageChange,
|
||||
className: 'members-table-pagination',
|
||||
hideOnSinglePage: true,
|
||||
}}
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'members-table-row--tinted' : ''
|
||||
}
|
||||
onRow={(record): React.HTMLAttributes<HTMLElement> => ({
|
||||
onClick: (): void => onRowClick?.(record),
|
||||
style: onRowClick ? { cursor: 'pointer' } : undefined,
|
||||
})}
|
||||
onRow={(record): React.HTMLAttributes<HTMLElement> => {
|
||||
const isClickable = onRowClick && record.status !== MemberStatus.Deleted;
|
||||
return {
|
||||
onClick: (): void => {
|
||||
if (isClickable) {
|
||||
onRowClick(record);
|
||||
}
|
||||
},
|
||||
style: isClickable ? { cursor: 'pointer' } : undefined,
|
||||
};
|
||||
}}
|
||||
onChange={(_, __, sorter): void => {
|
||||
if (onSortChange) {
|
||||
onSortChange(
|
||||
@@ -220,17 +247,6 @@ function MembersTable({
|
||||
}}
|
||||
className="members-table"
|
||||
/>
|
||||
{total > pageSize && (
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showTotal={showPaginationTotal}
|
||||
showSizeChanger={false}
|
||||
onChange={onPageChange}
|
||||
className="members-table-pagination"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,13 +24,12 @@ const mockActiveMembers: MemberRow[] = [
|
||||
];
|
||||
|
||||
const mockInvitedMember: MemberRow = {
|
||||
id: 'invite-abc',
|
||||
id: 'inv-abc',
|
||||
name: '',
|
||||
email: 'charlie@signoz.io',
|
||||
role: 'EDITOR' as ROLES,
|
||||
status: MemberStatus.Invited,
|
||||
joinedOn: null,
|
||||
token: 'tok-123',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
@@ -93,6 +92,34 @@ describe('MembersTable', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders DELETED badge and does not call onRowClick when a deleted member row is clicked', async () => {
|
||||
const onRowClick = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const deletedMember: MemberRow = {
|
||||
id: 'user-del',
|
||||
name: 'Dave Deleted',
|
||||
email: 'dave@signoz.io',
|
||||
role: 'VIEWER' as ROLES,
|
||||
status: MemberStatus.Deleted,
|
||||
joinedOn: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<MembersTable
|
||||
{...defaultProps}
|
||||
data={[...mockActiveMembers, deletedMember]}
|
||||
total={3}
|
||||
onRowClick={onRowClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('DELETED')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Dave Deleted'));
|
||||
expect(onRowClick).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'user-del' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "No members found" empty state when no data and no search query', () => {
|
||||
render(<MembersTable {...defaultProps} data={[]} total={0} searchQuery="" />);
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
.roles-select {
|
||||
width: 100%;
|
||||
|
||||
// todo: styles should easeup once upgrade to select from periscope
|
||||
.ant-select-selector {
|
||||
min-height: 32px;
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 2px;
|
||||
padding: 2px var(--padding-2) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ant-select-selection-overflow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.ant-select-selection-overflow-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
padding: 0 var(--padding-1) 0 6px;
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-select-selection-item-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--foreground);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector,
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.roles-select-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
color: var(--destructive);
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
&__msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
&__retry-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import { CircleAlert, RefreshCw } from '@signozhq/icons';
|
||||
import { Checkbox, Select } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './RolesSelect.styles.scss';
|
||||
|
||||
export interface RoleOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function useRoles(): {
|
||||
roles: AuthtypesRoleDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | undefined;
|
||||
refetch: () => void;
|
||||
} {
|
||||
const { data, isLoading, isError, error, refetch } = useListRoles();
|
||||
return {
|
||||
roles: data?.data ?? [],
|
||||
isLoading,
|
||||
isError,
|
||||
error: convertToApiError(error),
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoleOptions(roles: AuthtypesRoleDTO[]): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
label: role.name ?? '',
|
||||
value: role.name ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
function ErrorContent({
|
||||
error,
|
||||
onRefetch,
|
||||
}: {
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
}): JSX.Element {
|
||||
const errorMessage = error?.message || 'Failed to load roles';
|
||||
|
||||
return (
|
||||
<div className="roles-select-error">
|
||||
<span className="roles-select-error__msg">
|
||||
<CircleAlert size={12} />
|
||||
{errorMessage}
|
||||
</span>
|
||||
{onRefetch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onRefetch();
|
||||
}}
|
||||
className="roles-select-error__retry-btn"
|
||||
title="Retry"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BaseProps {
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
|
||||
roles?: AuthtypesRoleDTO[];
|
||||
loading?: boolean;
|
||||
isError?: boolean;
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
}
|
||||
|
||||
interface SingleProps extends BaseProps {
|
||||
mode?: 'single';
|
||||
value?: string;
|
||||
onChange?: (role: string) => void;
|
||||
}
|
||||
|
||||
interface MultipleProps extends BaseProps {
|
||||
mode: 'multiple';
|
||||
value?: string[];
|
||||
onChange?: (roles: string[]) => void;
|
||||
}
|
||||
|
||||
export type RolesSelectProps = SingleProps | MultipleProps;
|
||||
|
||||
function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
const externalRoles = props.roles;
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: internalLoading,
|
||||
isError: internalError,
|
||||
error: internalErrorObj,
|
||||
refetch: internalRefetch,
|
||||
} = useListRoles({
|
||||
query: { enabled: externalRoles === undefined },
|
||||
});
|
||||
|
||||
const roles = externalRoles ?? data?.data ?? [];
|
||||
const options = getRoleOptions(roles);
|
||||
|
||||
const {
|
||||
mode,
|
||||
id,
|
||||
placeholder = 'Select role',
|
||||
className,
|
||||
getPopupContainer,
|
||||
loading = internalLoading,
|
||||
isError = internalError,
|
||||
error = convertToApiError(internalErrorObj),
|
||||
onRefetch = externalRoles === undefined ? internalRefetch : undefined,
|
||||
} = props;
|
||||
|
||||
const notFoundContent = isError ? (
|
||||
<ErrorContent error={error} onRefetch={onRefetch} />
|
||||
) : undefined;
|
||||
|
||||
if (mode === 'multiple') {
|
||||
const { value = [], onChange } = props as MultipleProps;
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
mode="multiple"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={cx('roles-select', className)}
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
optionRender={(option): JSX.Element => (
|
||||
<Checkbox
|
||||
checked={value.includes(option.value as string)}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
)}
|
||||
getPopupContainer={getPopupContainer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { value, onChange } = props as SingleProps;
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={cx('roles-select', className)}
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
getPopupContainer={getPopupContainer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RolesSelect;
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { RoleOption, RolesSelectProps } from './RolesSelect';
|
||||
export { default, getRoleOptions, useRoles } from './RolesSelect';
|
||||
@@ -1,179 +0,0 @@
|
||||
.add-key-modal {
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__expiry-toggle {
|
||||
width: 60%;
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
|
||||
[data-slot='toggle-group'] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
font-size: var(--label-small-400-font-size);
|
||||
font-weight: var(--label-small-400-font-weight);
|
||||
line-height: var(--label-small-400-line-height);
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--foreground);
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__datepicker {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
.ant-picker {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
input {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.ant-picker-suffix {
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.add-key-modal-datepicker-popup {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--secondary);
|
||||
background: var(--popover);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__key-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__key-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 var(--padding-2);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l1-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
border-top: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
border-left: 1px solid var(--border);
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
&__expiry-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__expiry-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: var(--padding-4);
|
||||
border-top: 1px solid var(--secondary);
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__learn-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
color: var(--primary);
|
||||
font-size: var(--font-size-sm);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { DialogWrapper } from '@signozhq/dialog';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
invalidateListServiceAccountKeys,
|
||||
useCreateServiceAccountKey,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { disabledDate } from './utils';
|
||||
|
||||
import './AddKeyModal.styles.scss';
|
||||
|
||||
type Phase = 'form' | 'created';
|
||||
type ExpiryMode = 'none' | 'date';
|
||||
|
||||
interface FormValues {
|
||||
keyName: string;
|
||||
expiryMode: ExpiryMode;
|
||||
expiryDate: Dayjs | null;
|
||||
}
|
||||
|
||||
function AddKeyModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const [accountId] = useQueryState('account');
|
||||
const [isAddKeyOpen, setIsAddKeyOpen] = useQueryState(
|
||||
'add-key',
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const open = isAddKeyOpen && !!accountId;
|
||||
|
||||
const [phase, setPhase] = useState<Phase>('form');
|
||||
const [
|
||||
createdKey,
|
||||
setCreatedKey,
|
||||
] = useState<ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO | null>(null);
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { isValid },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues: { keyName: '', expiryMode: 'none', expiryDate: null },
|
||||
});
|
||||
|
||||
const expiryMode = watch('expiryMode');
|
||||
const expiryDate = watch('expiryDate');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPhase('form');
|
||||
setCreatedKey(null);
|
||||
setHasCopied(false);
|
||||
reset();
|
||||
}
|
||||
}, [open, reset]);
|
||||
|
||||
const {
|
||||
mutate: createKey,
|
||||
isLoading: isSubmitting,
|
||||
} = useCreateServiceAccountKey({
|
||||
mutation: {
|
||||
onSuccess: (response) => {
|
||||
const keyData = response?.data;
|
||||
if (keyData) {
|
||||
setCreatedKey(keyData);
|
||||
setPhase('created');
|
||||
if (accountId) {
|
||||
void invalidateListServiceAccountKeys(queryClient, { id: accountId });
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to create key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function handleCreate({
|
||||
keyName,
|
||||
expiryMode: mode,
|
||||
expiryDate: date,
|
||||
}: FormValues): void {
|
||||
if (!accountId) {
|
||||
return;
|
||||
}
|
||||
const expiresAt = mode === 'date' && date ? date.endOf('day').unix() : 0;
|
||||
createKey({
|
||||
pathParams: { id: accountId },
|
||||
data: { name: keyName.trim(), expiresAt },
|
||||
});
|
||||
}
|
||||
|
||||
const handleCopy = useCallback(async (): Promise<void> => {
|
||||
if (!createdKey?.key) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey.key);
|
||||
setHasCopied(true);
|
||||
setTimeout(() => setHasCopied(false), 2000);
|
||||
toast.success('Key copied to clipboard', { richColors: true });
|
||||
} catch {
|
||||
toast.error('Failed to copy key', { richColors: true });
|
||||
}
|
||||
}, [createdKey]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setIsAddKeyOpen(null);
|
||||
}, [setIsAddKeyOpen]);
|
||||
|
||||
const expiryLabel = (): string => {
|
||||
if (expiryMode === 'none' || !expiryDate) {
|
||||
return 'Never';
|
||||
}
|
||||
try {
|
||||
return expiryDate.format('MMM D, YYYY');
|
||||
} catch {
|
||||
return 'Never';
|
||||
}
|
||||
};
|
||||
|
||||
const title = phase === 'form' ? 'Add a New Key' : 'Key Created Successfully';
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
title={title}
|
||||
width="base"
|
||||
className="add-key-modal"
|
||||
showCloseButton
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
{phase === 'form' && (
|
||||
<>
|
||||
<div className="add-key-modal__form">
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label" htmlFor="key-name">
|
||||
Name <span style={{ color: 'var(--destructive)' }}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="key-name"
|
||||
placeholder="Enter key name e.g.: Service Owner"
|
||||
className="add-key-modal__input"
|
||||
{...register('keyName', {
|
||||
required: true,
|
||||
validate: (v) => !!v.trim(),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__field">
|
||||
<span className="add-key-modal__label">Expiration</span>
|
||||
<Controller
|
||||
name="expiryMode"
|
||||
control={control}
|
||||
render={({ field }): JSX.Element => (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={field.value}
|
||||
onValueChange={(val): void => {
|
||||
if (val) {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
className="add-key-modal__expiry-toggle"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="none"
|
||||
className="add-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
No Expiration
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="date"
|
||||
className="add-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
Set Expiration Date
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expiryMode === 'date' && (
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label" htmlFor="expiry-date">
|
||||
Expiration Date
|
||||
</label>
|
||||
<div className="add-key-modal__datepicker">
|
||||
<Controller
|
||||
name="expiryDate"
|
||||
control={control}
|
||||
render={({ field }): JSX.Element => (
|
||||
<DatePicker
|
||||
id="expiry-date"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
popupClassName="add-key-modal-datepicker-popup"
|
||||
getPopupContainer={popupContainer}
|
||||
disabledDate={disabledDate}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__footer">
|
||||
<div className="add-key-modal__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
onClick={handleSubmit(handleCreate)}
|
||||
>
|
||||
Create Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'created' && createdKey && (
|
||||
<>
|
||||
<div className="add-key-modal__form">
|
||||
<div className="add-key-modal__field">
|
||||
<span className="add-key-modal__label">Key</span>
|
||||
<div className="add-key-modal__key-display">
|
||||
<span className="add-key-modal__key-text">{createdKey.key}</span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="add-key-modal__copy-btn"
|
||||
>
|
||||
{hasCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__expiry-meta">
|
||||
<span className="add-key-modal__expiry-label">Expiration</span>
|
||||
<Badge color="vanilla">{expiryLabel()}</Badge>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
message="Store the key securely. This is the only time it will be displayed."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddKeyModal;
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { PowerOff, X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getGetServiceAccountQueryKey,
|
||||
invalidateListServiceAccounts,
|
||||
useUpdateServiceAccountStatus,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
ServiceaccounttypesServiceAccountDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
|
||||
function DisableAccountModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const [accountId, setAccountId] = useQueryState('account');
|
||||
const [isDisableOpen, setIsDisableOpen] = useQueryState(
|
||||
'disable-sa',
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const open = !!isDisableOpen && !!accountId;
|
||||
|
||||
const cachedAccount = accountId
|
||||
? queryClient.getQueryData<{
|
||||
data: ServiceaccounttypesServiceAccountDTO;
|
||||
}>(getGetServiceAccountQueryKey({ id: accountId }))
|
||||
: null;
|
||||
const accountName = cachedAccount?.data?.name;
|
||||
|
||||
const {
|
||||
mutate: updateStatus,
|
||||
isLoading: isDisabling,
|
||||
} = useUpdateServiceAccountStatus({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast.success('Service account disabled', { richColors: true });
|
||||
void setIsDisableOpen(null);
|
||||
void setAccountId(null);
|
||||
void invalidateListServiceAccounts(queryClient);
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to disable service account';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function handleConfirm(): void {
|
||||
if (!accountId) {
|
||||
return;
|
||||
}
|
||||
updateStatus({
|
||||
pathParams: { id: accountId },
|
||||
data: { status: 'DISABLED' },
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
void setIsDisableOpen(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleCancel();
|
||||
}
|
||||
}}
|
||||
title={`Disable service account ${accountName ?? ''}?`}
|
||||
width="narrow"
|
||||
className="alert-dialog sa-disable-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="sa-disable-dialog__body">
|
||||
Disabling this service account will revoke access for all its keys. Any
|
||||
systems using this account will lose access immediately.
|
||||
</p>
|
||||
<DialogFooter className="sa-disable-dialog__footer">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={handleCancel}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
loading={isDisabling}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<PowerOff size={12} />
|
||||
Disable
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default DisableAccountModal;
|
||||
@@ -1,188 +0,0 @@
|
||||
.edit-key-modal {
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__key-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--padding-2);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&__key-text {
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
&__lock-icon {
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__expiry-toggle {
|
||||
width: 60%;
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
|
||||
[data-slot='toggle-group'] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
font-size: var(--label-small-400-font-size);
|
||||
font-weight: var(--label-small-400-font-weight);
|
||||
line-height: var(--label-small-400-line-height);
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__datepicker {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
.ant-picker {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
input {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ant-picker-suffix {
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-key-modal-datepicker-popup {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--secondary);
|
||||
background: var(--popover);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__meta-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--padding-4);
|
||||
border-top: 1px solid var(--secondary);
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__footer-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--destructive);
|
||||
font-size: var(--label-small-400-font-size);
|
||||
font-weight: var(--label-small-400-font-weight);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogWrapper } from '@signozhq/dialog';
|
||||
import { LockKeyhole, Trash2, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
invalidateListServiceAccountKeys,
|
||||
useRevokeServiceAccountKey,
|
||||
useUpdateServiceAccountKey,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
ServiceaccounttypesFactorAPIKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { RevokeKeyContent } from './RevokeKeyModal';
|
||||
import { disabledDate, formatLastObservedAt } from './utils';
|
||||
|
||||
import './EditKeyModal.styles.scss';
|
||||
|
||||
type ExpiryMode = 'none' | 'date';
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
expiryMode: ExpiryMode;
|
||||
expiresAt: Dayjs | null;
|
||||
}
|
||||
|
||||
interface EditKeyModalProps {
|
||||
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null;
|
||||
}
|
||||
|
||||
function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedAccountId] = useQueryState('account');
|
||||
const [editKeyId, setEditKeyId] = useQueryState(
|
||||
'edit-key',
|
||||
parseAsString.withDefault(''),
|
||||
);
|
||||
|
||||
const open = !!editKeyId && !!selectedAccountId;
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
reset,
|
||||
watch,
|
||||
formState: { isDirty },
|
||||
handleSubmit,
|
||||
} = useForm<FormValues>({
|
||||
defaultValues: { name: '', expiryMode: 'none', expiresAt: null },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (keyItem) {
|
||||
reset({
|
||||
name: keyItem.name ?? '',
|
||||
expiryMode: keyItem.expiresAt === 0 ? 'none' : 'date',
|
||||
expiresAt: keyItem.expiresAt === 0 ? null : dayjs.unix(keyItem.expiresAt),
|
||||
});
|
||||
}
|
||||
}, [keyItem?.id, reset]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const expiryMode = watch('expiryMode');
|
||||
|
||||
const { mutate: updateKey, isLoading: isSaving } = useUpdateServiceAccountKey({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast.success('Key updated successfully', { richColors: true });
|
||||
void setEditKeyId(null);
|
||||
if (selectedAccountId) {
|
||||
void invalidateListServiceAccountKeys(queryClient, {
|
||||
id: selectedAccountId,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to update key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: revokeKey,
|
||||
isLoading: isRevoking,
|
||||
} = useRevokeServiceAccountKey({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast.success('Key revoked successfully', { richColors: true });
|
||||
setIsRevokeConfirmOpen(false);
|
||||
void setEditKeyId(null);
|
||||
if (selectedAccountId) {
|
||||
void invalidateListServiceAccountKeys(queryClient, {
|
||||
id: selectedAccountId,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to revoke key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function handleClose(): void {
|
||||
void setEditKeyId(null);
|
||||
setIsRevokeConfirmOpen(false);
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
({ name, expiryMode: mode, expiresAt }): void => {
|
||||
if (!keyItem || !selectedAccountId) {
|
||||
return;
|
||||
}
|
||||
const currentExpiresAt =
|
||||
mode === 'none' || !expiresAt ? 0 : expiresAt.endOf('day').unix();
|
||||
updateKey({
|
||||
pathParams: { id: selectedAccountId, fid: keyItem.id },
|
||||
data: { name, expiresAt: currentExpiresAt },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
function handleRevoke(): void {
|
||||
if (!keyItem || !selectedAccountId) {
|
||||
return;
|
||||
}
|
||||
revokeKey({ pathParams: { id: selectedAccountId, fid: keyItem.id } });
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
if (isRevokeConfirmOpen) {
|
||||
setIsRevokeConfirmOpen(false);
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={
|
||||
isRevokeConfirmOpen
|
||||
? `Revoke ${keyItem?.name ?? 'key'}?`
|
||||
: 'Edit Key Details'
|
||||
}
|
||||
width={isRevokeConfirmOpen ? 'narrow' : 'base'}
|
||||
className={
|
||||
isRevokeConfirmOpen ? 'alert-dialog delete-dialog' : 'edit-key-modal'
|
||||
}
|
||||
showCloseButton={!isRevokeConfirmOpen}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
{isRevokeConfirmOpen ? (
|
||||
<RevokeKeyContent
|
||||
isRevoking={isRevoking}
|
||||
onCancel={(): void => setIsRevokeConfirmOpen(false)}
|
||||
onConfirm={handleRevoke}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="edit-key-modal__form">
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="edit-key-name"
|
||||
className="edit-key-modal__input"
|
||||
placeholder="Enter key name"
|
||||
{...register('name')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-display">
|
||||
Key
|
||||
</label>
|
||||
<div id="edit-key-display" className="edit-key-modal__key-display">
|
||||
<span className="edit-key-modal__key-text">********************</span>
|
||||
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-key-modal__field">
|
||||
<span className="edit-key-modal__label">Expiration</span>
|
||||
<Controller
|
||||
name="expiryMode"
|
||||
control={control}
|
||||
render={({ field }): JSX.Element => (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={field.value}
|
||||
onValueChange={(val): void => {
|
||||
if (val) {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
className="edit-key-modal__expiry-toggle"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="none"
|
||||
className="edit-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
No Expiration
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="date"
|
||||
className="edit-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
Set Expiration Date
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expiryMode === 'date' && (
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-datepicker">
|
||||
Expiration Date
|
||||
</label>
|
||||
<div className="edit-key-modal__datepicker">
|
||||
<Controller
|
||||
name="expiresAt"
|
||||
control={control}
|
||||
render={({ field }): JSX.Element => (
|
||||
<DatePicker
|
||||
value={field.value}
|
||||
id="edit-key-datepicker"
|
||||
onChange={field.onChange}
|
||||
popupClassName="edit-key-modal-datepicker-popup"
|
||||
getPopupContainer={popupContainer}
|
||||
disabledDate={disabledDate}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="edit-key-modal__meta">
|
||||
<span className="edit-key-modal__meta-label">Last Observed At</span>
|
||||
<Badge color="vanilla">
|
||||
{formatLastObservedAt(
|
||||
keyItem?.lastObservedAt ?? null,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="edit-key-modal__footer">
|
||||
<Button
|
||||
type="button"
|
||||
className="edit-key-modal__footer-danger"
|
||||
onClick={(): void => setIsRevokeConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
<div className="edit-key-modal__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditKeyModal;
|
||||
@@ -1,238 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Skeleton, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import EditKeyModal from './EditKeyModal';
|
||||
import RevokeKeyModal from './RevokeKeyModal';
|
||||
import { formatLastObservedAt } from './utils';
|
||||
|
||||
interface KeysTabProps {
|
||||
keys: ServiceaccounttypesFactorAPIKeyDTO[];
|
||||
isLoading: boolean;
|
||||
isDisabled?: boolean;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface BuildColumnsParams {
|
||||
isDisabled: boolean;
|
||||
onRevokeClick: (keyId: string) => void;
|
||||
handleformatLastObservedAt: (
|
||||
lastObservedAt: Date | null | undefined,
|
||||
) => string;
|
||||
}
|
||||
|
||||
function formatExpiry(expiresAt: number): JSX.Element {
|
||||
if (expiresAt === 0) {
|
||||
return <span className="keys-tab__expiry--never">Never</span>;
|
||||
}
|
||||
const expiryDate = dayjs.unix(expiresAt);
|
||||
if (expiryDate.isBefore(dayjs())) {
|
||||
return <span className="keys-tab__expiry--expired">Expired</span>;
|
||||
}
|
||||
return <span>{expiryDate.format('MMM D, YYYY')}</span>;
|
||||
}
|
||||
|
||||
function buildColumns({
|
||||
isDisabled,
|
||||
onRevokeClick,
|
||||
handleformatLastObservedAt,
|
||||
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesFactorAPIKeyDTO> {
|
||||
return [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
className: 'keys-tab__name-column',
|
||||
sorter: (a, b): number => (a.name ?? '').localeCompare(b.name ?? ''),
|
||||
render: (_, record): JSX.Element => (
|
||||
<span className="keys-tab__name-text">{record.name ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Expiry',
|
||||
dataIndex: 'expiresAt',
|
||||
key: 'expiry',
|
||||
width: 160,
|
||||
align: 'right' as const,
|
||||
sorter: (a, b): number => {
|
||||
const aVal = a.expiresAt === 0 ? Infinity : a.expiresAt;
|
||||
const bVal = b.expiresAt === 0 ? Infinity : b.expiresAt;
|
||||
return aVal - bVal;
|
||||
},
|
||||
render: (expiresAt: number): JSX.Element => formatExpiry(expiresAt),
|
||||
},
|
||||
{
|
||||
title: 'Last Observed At',
|
||||
dataIndex: 'lastObservedAt',
|
||||
key: 'lastObservedAt',
|
||||
width: 220,
|
||||
align: 'right' as const,
|
||||
sorter: (a, b): number => {
|
||||
const aVal = a.lastObservedAt
|
||||
? new Date(a.lastObservedAt).getTime()
|
||||
: -Infinity;
|
||||
const bVal = b.lastObservedAt
|
||||
? new Date(b.lastObservedAt).getTime()
|
||||
: -Infinity;
|
||||
return aVal - bVal;
|
||||
},
|
||||
render: (lastObservedAt: Date | null | undefined): string =>
|
||||
handleformatLastObservedAt(lastObservedAt),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
width: 48,
|
||||
align: 'right' as const,
|
||||
render: (_, record): JSX.Element => (
|
||||
<Tooltip title={isDisabled ? 'Service account disabled' : 'Revoke Key'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
color="destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onRevokeClick(record.id);
|
||||
}}
|
||||
className="keys-tab__revoke-btn"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function KeysTab({
|
||||
keys,
|
||||
isLoading,
|
||||
isDisabled = false,
|
||||
currentPage,
|
||||
pageSize,
|
||||
}: KeysTabProps): JSX.Element {
|
||||
const [, setIsAddKeyOpen] = useQueryState(
|
||||
'add-key',
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const [editKeyId, setEditKeyId] = useQueryState(
|
||||
'edit-key',
|
||||
parseAsString.withDefault(''),
|
||||
);
|
||||
const [, setRevokeKeyId] = useQueryState(
|
||||
'revoke-key',
|
||||
parseAsString.withDefault(''),
|
||||
);
|
||||
const editKey = keys.find((k) => k.id === editKeyId) ?? null;
|
||||
|
||||
const handleformatLastObservedAt = useCallback(
|
||||
(lastObservedAt: Date | null | undefined): string =>
|
||||
formatLastObservedAt(lastObservedAt, formatTimezoneAdjustedTimestamp),
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const onRevokeClick = useCallback(
|
||||
(keyId: string): void => {
|
||||
setRevokeKeyId(keyId);
|
||||
},
|
||||
[setRevokeKeyId],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildColumns({ isDisabled, onRevokeClick, handleformatLastObservedAt }),
|
||||
[isDisabled, onRevokeClick, handleformatLastObservedAt],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="keys-tab__loading">
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
return (
|
||||
<div className="keys-tab__empty">
|
||||
<span className="keys-tab__empty-emoji" role="img" aria-label="searching">
|
||||
🧐
|
||||
</span>
|
||||
<p className="keys-tab__empty-text">No keys. Start by creating one.</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="keys-tab__learn-more"
|
||||
onClick={(): void => {
|
||||
void setIsAddKeyOpen(true);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
+ Add your first key
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Todo: use new table component from periscope when ready */}
|
||||
<Table<ServiceaccounttypesFactorAPIKeyDTO>
|
||||
columns={columns}
|
||||
dataSource={keys}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
style: { display: 'none' },
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
}}
|
||||
showSorterTooltip={false}
|
||||
className={`keys-tab__table${
|
||||
isDisabled ? ' keys-tab__table--disabled' : ''
|
||||
}`}
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'keys-tab__table-row--alt' : ''
|
||||
}
|
||||
onRow={(
|
||||
record,
|
||||
): {
|
||||
onClick: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
role: string;
|
||||
tabIndex: number;
|
||||
'aria-label': string;
|
||||
} => ({
|
||||
onClick: (): void => {
|
||||
if (!isDisabled) {
|
||||
void setEditKeyId(record.id);
|
||||
}
|
||||
},
|
||||
onKeyDown: (e: React.KeyboardEvent): void => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && !isDisabled) {
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
}
|
||||
void setEditKeyId(record.id);
|
||||
}
|
||||
},
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
'aria-label': `Edit key ${record.name || 'options'}`,
|
||||
})}
|
||||
/>
|
||||
|
||||
<EditKeyModal keyItem={editKey} />
|
||||
|
||||
<RevokeKeyModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeysTab;
|
||||
@@ -1,153 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { LockKeyhole } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
interface OverviewTabProps {
|
||||
account: ServiceAccountRow;
|
||||
localName: string;
|
||||
onNameChange: (v: string) => void;
|
||||
localRoles: string[];
|
||||
onRolesChange: (v: string[]) => void;
|
||||
isDisabled: boolean;
|
||||
availableRoles: AuthtypesRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
rolesError?: boolean;
|
||||
rolesErrorObj?: APIError | undefined;
|
||||
onRefetchRoles?: () => void;
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
account,
|
||||
localName,
|
||||
onNameChange,
|
||||
localRoles,
|
||||
onRolesChange,
|
||||
isDisabled,
|
||||
availableRoles,
|
||||
rolesLoading,
|
||||
rolesError,
|
||||
rolesErrorObj,
|
||||
onRefetchRoles,
|
||||
}: OverviewTabProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const formatTimestamp = useCallback(
|
||||
(ts: string | null | undefined): string => {
|
||||
if (!ts) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
|
||||
},
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sa-drawer__field">
|
||||
<label className="sa-drawer__label" htmlFor="sa-name">
|
||||
Name
|
||||
</label>
|
||||
{isDisabled ? (
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{localName || '—'}</span>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id="sa-name"
|
||||
value={localName}
|
||||
onChange={(e): void => onNameChange(e.target.value)}
|
||||
className="sa-drawer__input"
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__field">
|
||||
<label className="sa-drawer__label" htmlFor="sa-email">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{account.email || '—'}</span>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__field">
|
||||
<label className="sa-drawer__label" htmlFor="sa-roles">
|
||||
Roles
|
||||
</label>
|
||||
{isDisabled ? (
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<div className="sa-drawer__disabled-roles">
|
||||
{localRoles.length > 0 ? (
|
||||
localRoles.map((r) => (
|
||||
<Badge key={r} color="vanilla">
|
||||
{r}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="sa-drawer__input-text">—</span>
|
||||
)}
|
||||
</div>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
) : (
|
||||
<RolesSelect
|
||||
id="sa-roles"
|
||||
mode="multiple"
|
||||
roles={availableRoles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={onRefetchRoles}
|
||||
value={localRoles}
|
||||
onChange={onRolesChange}
|
||||
placeholder="Select roles"
|
||||
getPopupContainer={(triggerNode): HTMLElement =>
|
||||
(triggerNode?.closest('.sa-drawer') as HTMLElement) || document.body
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__meta">
|
||||
<div className="sa-drawer__meta-item">
|
||||
<span className="sa-drawer__meta-label">Status</span>
|
||||
{account.status?.toUpperCase() === 'ACTIVE' ? (
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
DISABLED
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__meta-item">
|
||||
<span className="sa-drawer__meta-label">Created At</span>
|
||||
<Badge color="vanilla">{formatTimestamp(account.createdAt)}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__meta-item">
|
||||
<span className="sa-drawer__meta-label">Updated At</span>
|
||||
<Badge color="vanilla">{formatTimestamp(account.updatedAt)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverviewTab;
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getListServiceAccountKeysQueryKey,
|
||||
invalidateListServiceAccountKeys,
|
||||
useRevokeServiceAccountKey,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
RenderErrorResponseDTO,
|
||||
ServiceaccounttypesFactorAPIKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
export interface RevokeKeyContentProps {
|
||||
isRevoking: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function RevokeKeyContent({
|
||||
isRevoking,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RevokeKeyContentProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<p className="delete-dialog__body">
|
||||
Revoking this key will permanently invalidate it. Any systems using this key
|
||||
will lose access immediately.
|
||||
</p>
|
||||
<DialogFooter className="delete-dialog__footer">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={onCancel}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
loading={isRevoking}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RevokeKeyModal(): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const [accountId] = useQueryState('account');
|
||||
const [revokeKeyId, setRevokeKeyId] = useQueryState(
|
||||
'revoke-key',
|
||||
parseAsString.withDefault(''),
|
||||
);
|
||||
const open = !!revokeKeyId && !!accountId;
|
||||
|
||||
const cachedKeys = accountId
|
||||
? queryClient.getQueryData<{ data: ServiceaccounttypesFactorAPIKeyDTO[] }>(
|
||||
getListServiceAccountKeysQueryKey({ id: accountId }),
|
||||
)
|
||||
: null;
|
||||
const keyName = cachedKeys?.data?.find((k) => k.id === revokeKeyId)?.name;
|
||||
|
||||
const {
|
||||
mutate: revokeKey,
|
||||
isLoading: isRevoking,
|
||||
} = useRevokeServiceAccountKey({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast.success('Key revoked successfully', { richColors: true });
|
||||
void setRevokeKeyId(null);
|
||||
if (accountId) {
|
||||
void invalidateListServiceAccountKeys(queryClient, { id: accountId });
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to revoke key';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function handleConfirm(): void {
|
||||
if (!revokeKeyId || !accountId) {
|
||||
return;
|
||||
}
|
||||
revokeKey({ pathParams: { id: accountId, fid: revokeKeyId } });
|
||||
}
|
||||
|
||||
function handleCancel(): void {
|
||||
void setRevokeKeyId(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleCancel();
|
||||
}
|
||||
}}
|
||||
title={`Revoke ${keyName ?? 'key'}?`}
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<RevokeKeyContent
|
||||
isRevoking={isRevoking}
|
||||
onCancel={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default RevokeKeyModal;
|
||||
@@ -1,460 +0,0 @@
|
||||
.sa-drawer {
|
||||
[data-slot='drawer-close'] + div {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
padding-left: var(--padding-4);
|
||||
margin-left: var(--margin-2);
|
||||
}
|
||||
|
||||
&__layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--padding-3) var(--padding-4) var(--padding-2) var(--padding-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__tab-group {
|
||||
[data-slot='toggle-group'] {
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
[data-slot='toggle-group-item'] {
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
font-family: Inter, sans-serif;
|
||||
padding: 0 var(--padding-7);
|
||||
gap: var(--spacing-3);
|
||||
box-shadow: none;
|
||||
|
||||
&:first-child {
|
||||
border-left: none;
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
background: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
&__tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--secondary);
|
||||
font-size: var(--code-small-400-font-size);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--padding-5) var(--padding-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
&__footer {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
border-top: 1px solid var(--secondary);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
&__keys-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
padding: var(--padding-2) 0;
|
||||
|
||||
.ant-pagination-total-text {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__pagination-range {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
&__pagination-total {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__footer-btn {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--padding-2);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
&--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&__input-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__lock-icon {
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__disabled-roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--margin-1);
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
[data-slot='badge'] {
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
align-items: center;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.keys-tab {
|
||||
&__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--padding-8) var(--padding-4);
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--padding-8) var(--padding-4);
|
||||
gap: var(--spacing-4);
|
||||
text-align: center;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
&__empty-emoji {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
&__empty-text {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__learn-more {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// todo: styles should easeup by upgrading the component with table component from persiscope
|
||||
&__table {
|
||||
.ant-table {
|
||||
background: transparent;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-thead > tr > td {
|
||||
height: 38px;
|
||||
padding: 0 var(--padding-4);
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid var(--l1-border) !important;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
color: var(--l2-foreground);
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ant-table-column-sorter {
|
||||
color: var(--l2-foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.ant-table-column-sort {
|
||||
background: transparent !important;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.ant-table-column-sorter {
|
||||
color: var(--l1-foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: transparent !important;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.ant-table-column-sorter {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
height: 38px;
|
||||
padding: 0 var(--padding-4);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--l2-foreground);
|
||||
letter-spacing: -0.07px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
transition: none;
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions ordinal
|
||||
slashed-zero;
|
||||
|
||||
&.ant-table-column-sort {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:last-child > td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr {
|
||||
background: transparent;
|
||||
|
||||
&:hover > td {
|
||||
background: rgba(171, 189, 255, 0.06) !important;
|
||||
}
|
||||
|
||||
&.keys-tab__table-row--alt > td {
|
||||
background: rgba(171, 189, 255, 0.02);
|
||||
|
||||
&:hover {
|
||||
background: rgba(171, 189, 255, 0.06) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
.ant-table-tbody > tr {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover > td {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-cell-row-hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__name-column {
|
||||
.ant-table-column-sorters {
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.ant-table-column-title {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__name-text {
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--l2-foreground);
|
||||
letter-spacing: -0.07px;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__expiry--never {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&__expiry--expired {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__revoke-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-disable-dialog {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__body {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l2-foreground);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
margin: 0;
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--margin-6);
|
||||
}
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
import { Key, LayoutGrid, Plus, PowerOff, X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { Pagination, Skeleton } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
useGetServiceAccount,
|
||||
useListServiceAccountKeys,
|
||||
useUpdateServiceAccount,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import {
|
||||
ServiceAccountRow,
|
||||
toServiceAccountRow,
|
||||
} from 'container/ServiceAccountsSettings/utils';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
parseAsStringEnum,
|
||||
useQueryState,
|
||||
} from 'nuqs';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import DisableAccountModal from './DisableAccountModal';
|
||||
import KeysTab from './KeysTab';
|
||||
import OverviewTab from './OverviewTab';
|
||||
import { ServiceAccountDrawerTab } from './utils';
|
||||
|
||||
import './ServiceAccountDrawer.styles.scss';
|
||||
|
||||
export interface ServiceAccountDrawerProps {
|
||||
onSuccess: (options?: { closeDrawer?: boolean }) => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function ServiceAccountDrawer({
|
||||
onSuccess,
|
||||
}: ServiceAccountDrawerProps): JSX.Element {
|
||||
const [selectedAccountId, setSelectedAccountId] = useQueryState('account');
|
||||
const open = !!selectedAccountId;
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
'tab',
|
||||
parseAsStringEnum<ServiceAccountDrawerTab>(
|
||||
Object.values(ServiceAccountDrawerTab),
|
||||
).withDefault(ServiceAccountDrawerTab.Overview),
|
||||
);
|
||||
const [keysPage, setKeysPage] = useQueryState(
|
||||
'keysPage',
|
||||
parseAsInteger.withDefault(1),
|
||||
);
|
||||
const [, setEditKeyId] = useQueryState(
|
||||
'edit-key',
|
||||
parseAsString.withDefault(''),
|
||||
);
|
||||
const [, setIsAddKeyOpen] = useQueryState(
|
||||
'add-key',
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const [, setIsDisableOpen] = useQueryState(
|
||||
'disable-sa',
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const [localName, setLocalName] = useState('');
|
||||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
data: accountData,
|
||||
isLoading: isAccountLoading,
|
||||
isError: isAccountError,
|
||||
error: accountError,
|
||||
refetch: refetchAccount,
|
||||
} = useGetServiceAccount(
|
||||
{ id: selectedAccountId ?? '' },
|
||||
{ query: { enabled: !!selectedAccountId } },
|
||||
);
|
||||
|
||||
const account = useMemo(
|
||||
(): ServiceAccountRow | null =>
|
||||
accountData?.data ? toServiceAccountRow(accountData.data) : null,
|
||||
[accountData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
setLocalName(account.name ?? '');
|
||||
setLocalRoles(account.roles ?? []);
|
||||
setKeysPage(1);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [account?.id]);
|
||||
|
||||
const isDisabled = account?.status?.toUpperCase() !== 'ACTIVE';
|
||||
|
||||
const isDirty =
|
||||
account !== null &&
|
||||
(localName !== (account.name ?? '') ||
|
||||
JSON.stringify(localRoles) !== JSON.stringify(account.roles ?? []));
|
||||
|
||||
const {
|
||||
roles: availableRoles,
|
||||
isLoading: rolesLoading,
|
||||
isError: rolesError,
|
||||
error: rolesErrorObj,
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
const { data: keysData, isLoading: keysLoading } = useListServiceAccountKeys(
|
||||
{ id: selectedAccountId ?? '' },
|
||||
{ query: { enabled: !!selectedAccountId } },
|
||||
);
|
||||
const keys = keysData?.data ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (keysLoading) {
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.max(1, Math.ceil(keys.length / PAGE_SIZE));
|
||||
if (keysPage > maxPage) {
|
||||
setKeysPage(maxPage);
|
||||
}
|
||||
}, [keysLoading, keys.length, keysPage, setKeysPage]);
|
||||
|
||||
const { mutate: updateAccount, isLoading: isSaving } = useUpdateServiceAccount(
|
||||
{
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast.success('Service account updated successfully', {
|
||||
richColors: true,
|
||||
});
|
||||
refetchAccount();
|
||||
onSuccess({ closeDrawer: false });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMessage =
|
||||
convertToApiError(
|
||||
error as AxiosError<RenderErrorResponseDTO, unknown> | null,
|
||||
)?.getErrorMessage() || 'Failed to update service account';
|
||||
toast.error(errMessage, { richColors: true });
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function handleSave(): void {
|
||||
if (!account || !isDirty) {
|
||||
return;
|
||||
}
|
||||
updateAccount({
|
||||
pathParams: { id: account.id },
|
||||
data: { name: localName, email: account.email, roles: localRoles },
|
||||
});
|
||||
}
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setIsDisableOpen(null);
|
||||
setIsAddKeyOpen(null);
|
||||
setSelectedAccountId(null);
|
||||
setActiveTab(null);
|
||||
setKeysPage(null);
|
||||
setEditKeyId(null);
|
||||
}, [
|
||||
setSelectedAccountId,
|
||||
setActiveTab,
|
||||
setKeysPage,
|
||||
setEditKeyId,
|
||||
setIsAddKeyOpen,
|
||||
setIsDisableOpen,
|
||||
]);
|
||||
|
||||
const drawerContent = (
|
||||
<div className="sa-drawer__layout">
|
||||
<div className="sa-drawer__tabs">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={activeTab}
|
||||
onValueChange={(val): void => {
|
||||
if (val) {
|
||||
setActiveTab(val as ServiceAccountDrawerTab);
|
||||
if (val !== ServiceAccountDrawerTab.Keys) {
|
||||
setKeysPage(null);
|
||||
setEditKeyId(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="sa-drawer__tab-group"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={ServiceAccountDrawerTab.Overview}
|
||||
className="sa-drawer__tab"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
Overview
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={ServiceAccountDrawerTab.Keys}
|
||||
className="sa-drawer__tab"
|
||||
>
|
||||
<Key size={14} />
|
||||
Keys
|
||||
{keys.length > 0 && (
|
||||
<span className="sa-drawer__tab-count">{keys.length}</span>
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
disabled={isDisabled}
|
||||
onClick={(): void => {
|
||||
setIsAddKeyOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add Key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`sa-drawer__body${
|
||||
activeTab === ServiceAccountDrawerTab.Keys ? ' sa-drawer__body--keys' : ''
|
||||
}`}
|
||||
>
|
||||
{isAccountLoading && <Skeleton active paragraph={{ rows: 6 }} />}
|
||||
{isAccountError && (
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
accountError,
|
||||
'An unexpected error occurred while fetching service account details.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!isAccountLoading && !isAccountError && (
|
||||
<>
|
||||
{activeTab === ServiceAccountDrawerTab.Overview && account && (
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={setLocalName}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={setLocalRoles}
|
||||
isDisabled={isDisabled}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
rolesError={rolesError}
|
||||
rolesErrorObj={rolesErrorObj}
|
||||
onRefetchRoles={refetchRoles}
|
||||
/>
|
||||
)}
|
||||
{activeTab === ServiceAccountDrawerTab.Keys && (
|
||||
<KeysTab
|
||||
keys={keys}
|
||||
isLoading={keysLoading}
|
||||
isDisabled={isDisabled}
|
||||
currentPage={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__footer">
|
||||
{activeTab === ServiceAccountDrawerTab.Keys ? (
|
||||
<Pagination
|
||||
current={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={keys.length}
|
||||
showTotal={(total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
<span className="sa-drawer__pagination-range">
|
||||
{range[0]} — {range[1]}
|
||||
</span>
|
||||
<span className="sa-drawer__pagination-total"> of {total}</span>
|
||||
</>
|
||||
)}
|
||||
showSizeChanger={false}
|
||||
hideOnSinglePage
|
||||
onChange={(page): void => {
|
||||
void setKeysPage(page);
|
||||
}}
|
||||
className="sa-drawer__keys-pagination"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{!isDisabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className="sa-drawer__footer-btn"
|
||||
onClick={(): void => {
|
||||
setIsDisableOpen(true);
|
||||
}}
|
||||
>
|
||||
<PowerOff size={12} />
|
||||
Disable Service Account
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && (
|
||||
<div className="sa-drawer__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
type="panel"
|
||||
showCloseButton
|
||||
showOverlay={false}
|
||||
allowOutsideClick
|
||||
header={{ title: 'Service Account Details' }}
|
||||
content={drawerContent}
|
||||
className="sa-drawer"
|
||||
/>
|
||||
|
||||
<DisableAccountModal />
|
||||
|
||||
<AddKeyModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceAccountDrawer;
|
||||
@@ -1,139 +0,0 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import AddKeyModal from '../AddKeyModal';
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys';
|
||||
|
||||
const createdKeyResponse = {
|
||||
data: {
|
||||
id: 'key-1',
|
||||
name: 'Deploy Key',
|
||||
key: 'snz_abc123xyz456secret',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null,
|
||||
},
|
||||
};
|
||||
|
||||
function renderModal(): ReturnType<typeof render> {
|
||||
return render(
|
||||
<NuqsTestingAdapter
|
||||
searchParams={{ account: 'sa-1', 'add-key': 'true' }}
|
||||
hasMemory
|
||||
>
|
||||
<AddKeyModal />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('AddKeyModal', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: jest.fn().mockResolvedValue(undefined) },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.post(SA_KEYS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json(createdKeyResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('"Create Key" is disabled when name is empty; enabled after typing a name', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByRole('button', { name: /Create Key/i })).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'My Key');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Key/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('successful creation transitions to phase 2 with key displayed and security callout', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Key/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Create Key/i }));
|
||||
|
||||
await screen.findByText('snz_abc123xyz456secret');
|
||||
expect(screen.getByText(/Store the key securely/i)).toBeInTheDocument();
|
||||
await screen.findByRole('dialog', { name: /Key Created Successfully/i });
|
||||
});
|
||||
|
||||
it('copy button writes key to clipboard and shows toast.success', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const writeTextSpy = jest
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
renderModal();
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/Enter key name/i), 'Deploy Key');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Create Key/i }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: /Create Key/i }));
|
||||
|
||||
await screen.findByText('snz_abc123xyz456secret');
|
||||
|
||||
const copyBtn = screen
|
||||
.getAllByRole('button')
|
||||
.find((btn) => btn.querySelector('svg'));
|
||||
if (!copyBtn) {
|
||||
throw new Error('Copy button not found');
|
||||
}
|
||||
await user.click(copyBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('snz_abc123xyz456secret');
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key copied to clipboard',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
writeTextSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('Cancel button closes the modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await screen.findByRole('dialog', { name: /Add a New Key/i });
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /Add a New Key/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import EditKeyModal from '../EditKeyModal';
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/key-1';
|
||||
|
||||
const mockKey: ServiceaccounttypesFactorAPIKeyDTO = {
|
||||
id: 'key-1',
|
||||
name: 'Original Key Name',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null as any,
|
||||
key: 'snz_abc123',
|
||||
serviceAccountId: 'sa-1',
|
||||
};
|
||||
|
||||
function renderModal(
|
||||
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null = mockKey,
|
||||
searchParams: Record<string, string> = {
|
||||
account: 'sa-1',
|
||||
'edit-key': 'key-1',
|
||||
},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
|
||||
<EditKeyModal keyItem={keyItem} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('EditKeyModal (URL-controlled)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.put(SA_KEY_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders nothing when edit-key param is absent', () => {
|
||||
renderModal(null, { account: 'sa-1' });
|
||||
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders key data from prop when edit-key param is set', async () => {
|
||||
renderModal();
|
||||
|
||||
expect(
|
||||
await screen.findByDisplayValue('Original Key Name'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('save calls update API, shows toast, and closes modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const nameInput = await screen.findByPlaceholderText(/Enter key name/i);
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Updated Key Name');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key updated successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('cancel clears edit-key param and closes modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await screen.findByDisplayValue('Original Key Name');
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('revoke flow: clicking Revoke Key shows confirmation inside same dialog', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await screen.findByDisplayValue('Original Key Name');
|
||||
await user.click(screen.getByRole('button', { name: /Revoke Key/i }));
|
||||
|
||||
// Same dialog, now showing revoke confirmation
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: /Revoke Original Key Name/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Revoking this key will permanently invalidate it/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('revoke flow: confirming revoke shows toast and closes modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await screen.findByDisplayValue('Original Key Name');
|
||||
await user.click(screen.getByRole('button', { name: /Revoke Key/i }));
|
||||
|
||||
const confirmBtn = await screen.findByRole('button', {
|
||||
name: /^Revoke Key$/i,
|
||||
});
|
||||
await user.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key revoked successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import KeysTab from '../KeysTab';
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockToast = jest.mocked(toast);
|
||||
|
||||
const SA_KEY_ENDPOINT = '*/api/v1/service_accounts/sa-1/keys/:fid';
|
||||
|
||||
const keys: ServiceaccounttypesFactorAPIKeyDTO[] = [
|
||||
{
|
||||
id: 'key-1',
|
||||
name: 'Production Key',
|
||||
expiresAt: 0,
|
||||
lastObservedAt: null as any,
|
||||
key: 'snz_prod_123',
|
||||
serviceAccountId: 'sa-1',
|
||||
},
|
||||
{
|
||||
id: 'key-2',
|
||||
name: 'Staging Key',
|
||||
expiresAt: 1924905600, // 2030-12-31
|
||||
lastObservedAt: new Date('2026-03-10T10:00:00Z'),
|
||||
key: 'snz_stag_456',
|
||||
serviceAccountId: 'sa-1',
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
keys,
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
};
|
||||
|
||||
function renderKeysTab(
|
||||
props: Partial<typeof defaultProps> = {},
|
||||
searchParams: Record<string, string> = { account: 'sa-1' },
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
|
||||
<KeysTab {...defaultProps} {...props} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('KeysTab', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders loading state', () => {
|
||||
renderKeysTab({ isLoading: true });
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no keys and clicking add sets add-key param', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onUrlUpdate = jest.fn();
|
||||
render(
|
||||
<NuqsTestingAdapter
|
||||
searchParams={{ account: 'sa-1' }}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
>
|
||||
<KeysTab {...defaultProps} keys={[]} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/No keys. Start by creating one./i),
|
||||
).toBeInTheDocument();
|
||||
const addBtn = screen.getByRole('button', { name: /\+ Add your first key/i });
|
||||
await user.click(addBtn);
|
||||
expect(onUrlUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryString: expect.stringContaining('add-key=true'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders table with keys', () => {
|
||||
renderKeysTab();
|
||||
|
||||
expect(screen.getByText('Production Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('Staging Key')).toBeInTheDocument();
|
||||
expect(screen.getByText('Never')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dec 31, 2030')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking a row sets the edit-key URL param', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onUrlUpdate = jest.fn();
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
|
||||
<KeysTab {...defaultProps} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
const row = screen.getByText('Production Key').closest('tr');
|
||||
if (!row) {
|
||||
throw new Error('Row not found');
|
||||
}
|
||||
|
||||
await user.click(row);
|
||||
|
||||
expect(onUrlUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryString: expect.stringContaining('edit-key=key-1'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('clicking revoke icon sets revoke-key URL param', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onUrlUpdate = jest.fn();
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
|
||||
<KeysTab {...defaultProps} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
const revokeBtns = screen
|
||||
.getAllByRole('button')
|
||||
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
|
||||
await user.click(revokeBtns[0]);
|
||||
|
||||
expect(onUrlUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryString: expect.stringContaining('revoke-key=key-1'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles successful key revocation via RevokeKeyModal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderKeysTab();
|
||||
|
||||
// Seed the keys cache so RevokeKeyModal can read the key name
|
||||
const revokeBtns = screen
|
||||
.getAllByRole('button')
|
||||
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
|
||||
await user.click(revokeBtns[0]);
|
||||
|
||||
const confirmBtn = await screen.findByRole('button', { name: /Revoke Key/i });
|
||||
await user.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key revoked successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables actions when isDisabled is true', () => {
|
||||
renderKeysTab({ isDisabled: true });
|
||||
|
||||
const revokeBtns = screen
|
||||
.getAllByRole('button')
|
||||
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
|
||||
revokeBtns.forEach((btn) => expect(btn).toBeDisabled());
|
||||
});
|
||||
});
|
||||
@@ -1,246 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountDrawer from '../ServiceAccountDrawer';
|
||||
|
||||
jest.mock('@signozhq/drawer', () => ({
|
||||
DrawerWrapper: ({
|
||||
content,
|
||||
open,
|
||||
}: {
|
||||
content?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null => (open ? <div>{content}</div> : null),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
|
||||
const SA_STATUS_ENDPOINT = '*/api/v1/service_accounts/sa-1/status';
|
||||
|
||||
const activeAccountResponse = {
|
||||
id: 'sa-1',
|
||||
name: 'CI Bot',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const disabledAccountResponse = {
|
||||
...activeAccountResponse,
|
||||
id: 'sa-2',
|
||||
status: 'DISABLED',
|
||||
};
|
||||
|
||||
function renderDrawer(
|
||||
searchParams: Record<string, string> = { account: 'sa-1' },
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
|
||||
<ServiceAccountDrawer onSuccess={jest.fn()} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ServiceAccountDrawer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: activeAccountResponse })),
|
||||
),
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
rest.put(SA_STATUS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders Overview tab by default: editable name input, locked email, Save disabled when not dirty', async () => {
|
||||
renderDrawer();
|
||||
|
||||
expect(await screen.findByDisplayValue('CI Bot')).toBeInTheDocument();
|
||||
expect(screen.getByText('ci-bot@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save Changes/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('editing name enables Save; clicking Save sends correct payload and calls onSuccess', async () => {
|
||||
const onSuccess = jest.fn();
|
||||
const updateSpy = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.put(SA_ENDPOINT, async (req, res, ctx) => {
|
||||
updateSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter searchParams={{ account: 'sa-1' }} hasMemory>
|
||||
<ServiceAccountDrawer onSuccess={onSuccess} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('CI Bot');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'CI Bot Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'CI Bot Updated',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
}),
|
||||
);
|
||||
expect(onSuccess).toHaveBeenCalledWith({ closeDrawer: false });
|
||||
});
|
||||
});
|
||||
|
||||
it('changing roles enables Save; clicking Save sends updated roles in payload', async () => {
|
||||
const updateSpy = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.put(SA_ENDPOINT, async (req, res, ctx) => {
|
||||
updateSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
await user.click(screen.getByLabelText('Roles'));
|
||||
await user.click(await screen.findByTitle('signoz-viewer'));
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /Save Changes/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
roles: expect.arrayContaining(['signoz-admin', 'signoz-viewer']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('"Disable Service Account" opens confirm dialog; confirming sends correct status payload', async () => {
|
||||
const statusSpy = jest.fn();
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.put(SA_STATUS_ENDPOINT, async (req, res, ctx) => {
|
||||
statusSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
|
||||
}),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /Disable Service Account/i }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: /Disable service account CI Bot/i,
|
||||
});
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
const confirmBtns = screen.getAllByRole('button', { name: /^Disable$/i });
|
||||
await user.click(confirmBtns[confirmBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(statusSpy).toHaveBeenCalledWith({ status: 'DISABLED' });
|
||||
});
|
||||
});
|
||||
|
||||
it('disabled account shows read-only name, no Save button, no Disable button', async () => {
|
||||
server.use(
|
||||
rest.get('*/api/v1/service_accounts/sa-2', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: disabledAccountResponse })),
|
||||
),
|
||||
rest.get('*/api/v1/service_accounts/sa-2/keys', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
);
|
||||
|
||||
renderDrawer({ account: 'sa-2' });
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Save Changes/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Disable Service Account/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('CI Bot')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switching to Keys tab shows "No keys" empty state', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderDrawer();
|
||||
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: /Keys/i }));
|
||||
|
||||
await screen.findByText(/No keys/i);
|
||||
});
|
||||
|
||||
it('shows skeleton while loading account data', () => {
|
||||
renderDrawer();
|
||||
|
||||
// Skeleton renders while the fetch is in-flight
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state when account fetch fails', async () => {
|
||||
server.use(
|
||||
rest.get(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ message: 'Server error' })),
|
||||
),
|
||||
);
|
||||
|
||||
renderDrawer();
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/An unexpected error occurred while fetching service account details/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export enum ServiceAccountDrawerTab {
|
||||
Overview = 'overview',
|
||||
Keys = 'keys',
|
||||
}
|
||||
|
||||
export function formatLastObservedAt(
|
||||
lastObservedAt: string | Date | null | undefined,
|
||||
formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string,
|
||||
): string {
|
||||
if (!lastObservedAt) {
|
||||
return '—';
|
||||
}
|
||||
const str =
|
||||
typeof lastObservedAt === 'string'
|
||||
? lastObservedAt
|
||||
: lastObservedAt.toISOString();
|
||||
// Go zero time means the key has never been used
|
||||
if (str.startsWith('0001-01-01')) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(str);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimezoneAdjustedTimestamp(str, DATE_TIME_FORMATS.DASH_DATETIME);
|
||||
}
|
||||
|
||||
export const disabledDate = (current: Dayjs): boolean =>
|
||||
!!current && current < dayjs().startOf('day');
|
||||
@@ -1,218 +0,0 @@
|
||||
.sa-table-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sa-table {
|
||||
.ant-table {
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ant-table-container {
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-table-thead {
|
||||
> tr > th,
|
||||
> tr > td {
|
||||
background: var(--background);
|
||||
font-size: var(--paragraph-small-600-font-size);
|
||||
font-weight: var(--paragraph-small-600-font-weight);
|
||||
line-height: var(--paragraph-small-600-line-height);
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground);
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
border-bottom: none !important;
|
||||
border-top: none !important;
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody {
|
||||
> tr > td {
|
||||
border-bottom: none !important;
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
background: transparent;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
> tr.sa-table-row--tinted > td {
|
||||
background: rgba(171, 189, 255, 0.02);
|
||||
}
|
||||
|
||||
> tr:hover > td {
|
||||
background: rgba(171, 189, 255, 0.04) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-wrapper,
|
||||
.ant-table-container,
|
||||
.ant-spin-nested-loading,
|
||||
.ant-spin-container {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.sa-name-column {
|
||||
.ant-table-column-sorters {
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.ant-table-column-title {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-status-cell {
|
||||
[data-slot='badge'] {
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
align-items: center;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-name-email-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
height: 22px;
|
||||
overflow: hidden;
|
||||
|
||||
.sa-name {
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
color: var(--foreground);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sa-email {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l3-foreground-hover);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-roles-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.sa-dash {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
color: var(--l3-foreground-hover);
|
||||
}
|
||||
|
||||
.sa-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--padding-12) var(--padding-4);
|
||||
gap: var(--spacing-4);
|
||||
color: var(--foreground);
|
||||
|
||||
&__emoji {
|
||||
font-size: var(--font-size-2xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--bg-base-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
|
||||
.ant-pagination-total-text {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.sa-pagination-range {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.sa-pagination-total {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-tooltip {
|
||||
.ant-tooltip-inner {
|
||||
background-color: var(--bg-slate-500);
|
||||
color: var(--foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: normal;
|
||||
padding: var(--padding-2) var(--padding-3);
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ant-tooltip-arrow-content {
|
||||
background-color: var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.sa-table {
|
||||
.ant-table-tbody {
|
||||
> tr.sa-table-row--tinted > td {
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
}
|
||||
|
||||
> tr:hover > td {
|
||||
background: rgba(0, 0, 0, 0.03) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-empty-state {
|
||||
&__text {
|
||||
strong {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Table } from 'antd';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { parseAsInteger, parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import {
|
||||
columns,
|
||||
ServiceAccountsEmptyState,
|
||||
showPaginationTotal,
|
||||
} from './utils';
|
||||
|
||||
import './ServiceAccountsTable.styles.scss';
|
||||
|
||||
export const PAGE_SIZE = 20;
|
||||
|
||||
interface ServiceAccountsTableProps {
|
||||
data: ServiceAccountRow[];
|
||||
loading: boolean;
|
||||
onRowClick?: (row: ServiceAccountRow) => void;
|
||||
}
|
||||
|
||||
function ServiceAccountsTable({
|
||||
data,
|
||||
loading,
|
||||
onRowClick,
|
||||
}: ServiceAccountsTableProps): JSX.Element {
|
||||
const [currentPage, setPage] = useQueryState(
|
||||
'page',
|
||||
parseAsInteger.withDefault(1),
|
||||
);
|
||||
const [searchQuery] = useQueryState('search', parseAsString.withDefault(''));
|
||||
|
||||
return (
|
||||
<div className="sa-table-wrapper">
|
||||
{/* Todo: use new table component from periscope when ready */}
|
||||
<Table<ServiceAccountRow>
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={
|
||||
data.length > PAGE_SIZE
|
||||
? {
|
||||
current: currentPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
total: data.length,
|
||||
showTotal: showPaginationTotal,
|
||||
showSizeChanger: false,
|
||||
onChange: (page: number): void => void setPage(page),
|
||||
className: 'sa-table-pagination',
|
||||
}
|
||||
: false
|
||||
}
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'sa-table-row--tinted' : ''
|
||||
}
|
||||
showSorterTooltip={false}
|
||||
locale={{
|
||||
emptyText: <ServiceAccountsEmptyState searchQuery={searchQuery} />,
|
||||
}}
|
||||
className="sa-table"
|
||||
onRow={(
|
||||
record,
|
||||
): {
|
||||
onClick?: () => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLElement>) => void;
|
||||
style?: React.CSSProperties;
|
||||
tabIndex?: number;
|
||||
role?: string;
|
||||
'aria-label'?: string;
|
||||
} => {
|
||||
if (!onRowClick) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
onClick: (): void => onRowClick(record),
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLElement>): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onRowClick(record);
|
||||
}
|
||||
},
|
||||
style: { cursor: 'pointer' },
|
||||
tabIndex: 0,
|
||||
role: 'button',
|
||||
'aria-label': `View service account ${record.name || record.email}`,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceAccountsTable;
|
||||
@@ -1,96 +0,0 @@
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountsTable from '../ServiceAccountsTable';
|
||||
|
||||
const mockActiveAccount: ServiceAccountRow = {
|
||||
id: 'sa-1',
|
||||
name: 'CI Bot',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockDisabledAccount: ServiceAccountRow = {
|
||||
id: 'sa-2',
|
||||
name: 'Legacy Bot',
|
||||
email: 'legacy@signoz.io',
|
||||
roles: ['signoz-viewer', 'signoz-editor', 'billing-manager'],
|
||||
status: 'DISABLED',
|
||||
createdAt: '2025-06-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
loading: false,
|
||||
onRowClick: jest.fn(),
|
||||
};
|
||||
|
||||
describe('ServiceAccountsTable', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders name, email, role badge, and ACTIVE status badge', () => {
|
||||
render(<ServiceAccountsTable {...defaultProps} data={[mockActiveAccount]} />);
|
||||
|
||||
expect(screen.getByText('CI Bot')).toBeInTheDocument();
|
||||
expect(screen.getByText('ci-bot@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('signoz-admin')).toBeInTheDocument();
|
||||
expect(screen.getByText('ACTIVE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows DISABLED badge and +2 overflow badge for multi-role accounts', () => {
|
||||
render(
|
||||
<ServiceAccountsTable {...defaultProps} data={[mockDisabledAccount]} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('DISABLED')).toBeInTheDocument();
|
||||
expect(screen.getByText('signoz-viewer')).toBeInTheDocument();
|
||||
expect(screen.getByText('+2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRowClick with the correct account when a row is clicked', async () => {
|
||||
const onRowClick = jest.fn() as jest.MockedFunction<
|
||||
(row: ServiceAccountRow) => void
|
||||
>;
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<ServiceAccountsTable
|
||||
{...defaultProps}
|
||||
data={[mockActiveAccount]}
|
||||
onRowClick={onRowClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /View service account CI Bot/i }),
|
||||
);
|
||||
|
||||
expect(onRowClick).toHaveBeenCalledTimes(1);
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'sa-1', email: 'ci-bot@signoz.io' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "No service accounts" empty state when data is empty and no search query', () => {
|
||||
render(<ServiceAccountsTable {...defaultProps} data={[]} />);
|
||||
|
||||
expect(screen.getByText(/No service accounts/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No results for {query}" empty state when search is active', () => {
|
||||
render(
|
||||
<NuqsTestingAdapter searchParams={{ search: 'ghost' }}>
|
||||
<ServiceAccountsTable {...defaultProps} data={[]} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/No results for/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('ghost')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
|
||||
export function NameEmailCell({
|
||||
name,
|
||||
email,
|
||||
}: {
|
||||
name: string;
|
||||
email: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="sa-name-email-cell">
|
||||
{name && (
|
||||
<span className="sa-name" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<Tooltip title={email} overlayClassName="sa-tooltip">
|
||||
<span className="sa-email">{email}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RolesCell({ roles }: { roles: string[] }): JSX.Element {
|
||||
if (!roles || roles.length === 0) {
|
||||
return <span className="sa-dash">—</span>;
|
||||
}
|
||||
const first = roles[0];
|
||||
const overflow = roles.length - 1;
|
||||
const tooltipContent = roles.slice(1).join(', ');
|
||||
|
||||
return (
|
||||
<div className="sa-roles-cell">
|
||||
<Badge color="vanilla">{first}</Badge>
|
||||
{overflow > 0 && (
|
||||
<Tooltip
|
||||
title={tooltipContent}
|
||||
overlayClassName="sa-tooltip"
|
||||
overlayStyle={{ maxWidth: '600px' }}
|
||||
>
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
+{overflow}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }): JSX.Element {
|
||||
if (status?.toUpperCase() === 'ACTIVE') {
|
||||
return (
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
DISABLED
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceAccountsEmptyState({
|
||||
searchQuery,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="sa-empty-state">
|
||||
<span className="sa-empty-state__emoji" role="img" aria-label="monocle face">
|
||||
🧐
|
||||
</span>
|
||||
{searchQuery ? (
|
||||
<p className="sa-empty-state__text">
|
||||
No results for <strong>{searchQuery}</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p className="sa-empty-state__text">
|
||||
No service accounts. Start by creating one to manage keys.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const columns: ColumnsType<ServiceAccountRow> = [
|
||||
{
|
||||
title: 'Name / Email',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
className: 'sa-name-column',
|
||||
sorter: (a, b): number => a.email.localeCompare(b.email),
|
||||
render: (_, record): JSX.Element => (
|
||||
<NameEmailCell name={record.name} email={record.email} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Roles',
|
||||
dataIndex: 'roles',
|
||||
key: 'roles',
|
||||
width: 420,
|
||||
render: (roles: string[]): JSX.Element => <RolesCell roles={roles} />,
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
className: 'sa-status-cell',
|
||||
sorter: (a, b): number =>
|
||||
(a.status?.toUpperCase() === 'ACTIVE' ? 0 : 1) -
|
||||
(b.status?.toUpperCase() === 'ACTIVE' ? 0 : 1),
|
||||
render: (status: string): JSX.Element => <StatusBadge status={status} />,
|
||||
},
|
||||
];
|
||||
|
||||
export const showPaginationTotal = (
|
||||
_total: number,
|
||||
range: number[],
|
||||
): JSX.Element => (
|
||||
<>
|
||||
<span className="sa-pagination-range">
|
||||
{range[0]} — {range[1]}
|
||||
</span>
|
||||
<span className="sa-pagination-total"> of {_total}</span>
|
||||
</>
|
||||
);
|
||||
@@ -35,5 +35,4 @@ export enum LOCALSTORAGE {
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
}
|
||||
|
||||
@@ -86,7 +86,6 @@ const ROUTES = {
|
||||
METER_EXPLORER_VIEWS: '/meter/explorer/views',
|
||||
HOME_PAGE: '/',
|
||||
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
|
||||
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -3,16 +3,14 @@ import { UseQueryResult } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Card, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { useGetGraphCustomSeries } from 'components/CeleryTask/useGetGraphCustomSeries';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
getCustomFiltersForBarChart,
|
||||
getFormattedEndPointStatusCodeChartData,
|
||||
getStatusCodeBarChartWidgetData,
|
||||
statusCodeWidgetInfo,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import { handleGraphClick } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { useGraphClickToShowButton } from 'container/GridCardLayout/useGraphClickToShowButton';
|
||||
import useNavigateToExplorerPages from 'container/GridCardLayout/useNavigateToExplorerPages';
|
||||
@@ -20,15 +18,16 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { getStartAndEndTimesInMilliseconds } from 'pages/MessagingQueues/MessagingQueuesUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Options } from 'uplot';
|
||||
|
||||
import ErrorState from './ErrorState';
|
||||
import { prepareStatusCodeBarChartsConfig } from './utils';
|
||||
|
||||
function StatusCodeBarCharts({
|
||||
endPointStatusCodeBarChartsDataQuery,
|
||||
@@ -67,13 +66,6 @@ function StatusCodeBarCharts({
|
||||
} = endPointStatusCodeLatencyBarChartsDataQuery;
|
||||
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
@@ -119,6 +111,7 @@ function StatusCodeBarCharts({
|
||||
|
||||
const navigateToExplorer = useNavigateToExplorer();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const navigateToExplorerPages = useNavigateToExplorerPages();
|
||||
const { notifications } = useNotifications();
|
||||
@@ -134,12 +127,6 @@ function StatusCodeBarCharts({
|
||||
[],
|
||||
);
|
||||
|
||||
const { getCustomSeries } = useGetGraphCustomSeries({
|
||||
isDarkMode,
|
||||
drawStyle: 'bars',
|
||||
colorMapping,
|
||||
});
|
||||
|
||||
const widget = useMemo<Widgets>(
|
||||
() =>
|
||||
getStatusCodeBarChartWidgetData(domainName, {
|
||||
@@ -193,49 +180,36 @@ function StatusCodeBarCharts({
|
||||
],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
getUPlotChartOptions({
|
||||
apiResponse:
|
||||
currentWidgetInfoIndex === 0
|
||||
? formattedEndPointStatusCodeBarChartsDataPayload
|
||||
: formattedEndPointStatusCodeLatencyBarChartsDataPayload,
|
||||
isDarkMode,
|
||||
dimensions,
|
||||
yAxisUnit: statusCodeWidgetInfo[currentWidgetInfoIndex].yAxisUnit,
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
minTimeScale: minTime,
|
||||
maxTimeScale: maxTime,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
onClickHandler: graphClickHandler,
|
||||
customSeries: getCustomSeries,
|
||||
onDragSelect,
|
||||
colorMapping,
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
[
|
||||
minTime,
|
||||
maxTime,
|
||||
currentWidgetInfoIndex,
|
||||
dimensions,
|
||||
formattedEndPointStatusCodeBarChartsDataPayload,
|
||||
formattedEndPointStatusCodeLatencyBarChartsDataPayload,
|
||||
const config = useMemo(() => {
|
||||
const apiResponse =
|
||||
currentWidgetInfoIndex === 0
|
||||
? formattedEndPointStatusCodeBarChartsDataPayload
|
||||
: formattedEndPointStatusCodeLatencyBarChartsDataPayload;
|
||||
return prepareStatusCodeBarChartsConfig({
|
||||
timezone,
|
||||
isDarkMode,
|
||||
graphClickHandler,
|
||||
getCustomSeries,
|
||||
query: currentQuery,
|
||||
onDragSelect,
|
||||
onClick: graphClickHandler,
|
||||
apiResponse,
|
||||
minTimeScale: minTime,
|
||||
maxTimeScale: maxTime,
|
||||
yAxisUnit: statusCodeWidgetInfo[currentWidgetInfoIndex].yAxisUnit,
|
||||
colorMapping,
|
||||
currentQuery,
|
||||
],
|
||||
);
|
||||
});
|
||||
}, [
|
||||
currentQuery,
|
||||
isDarkMode,
|
||||
minTime,
|
||||
maxTime,
|
||||
graphClickHandler,
|
||||
onDragSelect,
|
||||
formattedEndPointStatusCodeBarChartsDataPayload,
|
||||
formattedEndPointStatusCodeLatencyBarChartsDataPayload,
|
||||
timezone,
|
||||
currentWidgetInfoIndex,
|
||||
colorMapping,
|
||||
]);
|
||||
|
||||
const renderCardContent = useCallback(
|
||||
(query: UseQueryResult<SuccessResponse<any>, unknown>): JSX.Element => {
|
||||
@@ -253,11 +227,20 @@ function StatusCodeBarCharts({
|
||||
!query.isLoading && !query?.data?.payload?.data?.result?.length,
|
||||
})}
|
||||
>
|
||||
<Uplot options={options as Options} data={chartData} />
|
||||
<BarChart
|
||||
config={config}
|
||||
data={chartData}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
timezone={timezone}
|
||||
legendConfig={{
|
||||
position: LegendPosition.BOTTOM,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[options, chartData],
|
||||
[config, chartData, dimensions, timezone],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ExecStats } from 'api/v5/v5';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { get } from 'lodash-es';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export const prepareStatusCodeBarChartsConfig = ({
|
||||
timezone,
|
||||
isDarkMode,
|
||||
query,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
apiResponse,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
yAxisUnit,
|
||||
colorMapping,
|
||||
}: {
|
||||
timezone: Timezone;
|
||||
isDarkMode: boolean;
|
||||
query: Query;
|
||||
onDragSelect: (startTime: number, endTime: number) => void;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
yAxisUnit?: string;
|
||||
colorMapping?: Record<string, string>;
|
||||
}): UPlotConfigBuilder => {
|
||||
const stepIntervals: ExecStats['stepIntervals'] = get(
|
||||
apiResponse,
|
||||
'data.newResult.meta.stepIntervals',
|
||||
{},
|
||||
);
|
||||
const minStepInterval = Math.min(...Object.values(stepIntervals));
|
||||
|
||||
const config = buildBaseConfig({
|
||||
id: v4(),
|
||||
yAxisUnit: yAxisUnit,
|
||||
apiResponse,
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
timezone,
|
||||
onClick,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
stepInterval: minStepInterval,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
});
|
||||
|
||||
const seriesList: QueryData[] = apiResponse?.data?.result || [];
|
||||
seriesList.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
series.queryName || '', // query
|
||||
series.legend || '',
|
||||
);
|
||||
|
||||
const label = query ? getLegend(series, query, baseLabelName) : baseLabelName;
|
||||
|
||||
const currentStepInterval = get(stepIntervals, series.queryName, undefined);
|
||||
|
||||
config.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
label: label,
|
||||
colorMapping: colorMapping ?? {},
|
||||
isDarkMode,
|
||||
stepInterval: currentStepInterval,
|
||||
});
|
||||
});
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -21,10 +21,15 @@ interface MockQueryResult {
|
||||
}
|
||||
|
||||
// Mocks
|
||||
jest.mock('components/Uplot', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => <div data-testid="uplot-mock" />),
|
||||
}));
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/charts/BarChart/BarChart',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: jest
|
||||
.fn()
|
||||
.mockImplementation(() => <div data-testid="bar-chart-mock" />),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('components/CeleryTask/useGetGraphCustomSeries', () => ({
|
||||
useGetGraphCustomSeries: (): { getCustomSeries: jest.Mock } => ({
|
||||
@@ -70,6 +75,24 @@ jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): { notifications: [] } => ({ notifications: [] }),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): {
|
||||
timezone: {
|
||||
name: string;
|
||||
value: string;
|
||||
offset: string;
|
||||
searchIndex: string;
|
||||
};
|
||||
} => ({
|
||||
timezone: {
|
||||
name: 'UTC',
|
||||
value: 'UTC',
|
||||
offset: '+00:00',
|
||||
searchIndex: 'UTC',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
|
||||
getUPlotChartOptions: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
@@ -319,7 +342,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
mockData.payload,
|
||||
'sum',
|
||||
);
|
||||
expect(screen.getByTestId('uplot-mock')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bar-chart-mock')).toBeInTheDocument();
|
||||
expect(screen.getByText('Number of calls')).toBeInTheDocument();
|
||||
expect(screen.getByText('Latency')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -6,16 +6,13 @@ import { Button, Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import listUserPreferences from 'api/v1/user/preferences/list';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import { PersistedAnnouncementBanner } from 'components/AnnouncementBanner';
|
||||
import Header from 'components/Header/Header';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMetricsListQuery } from 'container/MetricsExplorer/Summary/utils';
|
||||
import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config';
|
||||
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
@@ -294,24 +291,6 @@ export default function Home(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="home-container">
|
||||
{IS_SERVICE_ACCOUNTS_ENABLED && (
|
||||
<PersistedAnnouncementBanner
|
||||
type="warning"
|
||||
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
|
||||
message={
|
||||
<>
|
||||
<strong>API Keys</strong> have been deprecated and replaced by{' '}
|
||||
<strong>Service Accounts</strong>. Please migrate to Service Accounts for
|
||||
programmatic API access.
|
||||
</>
|
||||
}
|
||||
action={{
|
||||
label: 'Go to Service Accounts',
|
||||
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="sticky-header">
|
||||
<Header
|
||||
leftComponent={
|
||||
|
||||
@@ -337,31 +337,6 @@
|
||||
|
||||
.login-submit-btn {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 10px 16px;
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--bg-neutral-dark-50);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--primary);
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Form, Input, Select, Typography } from 'antd';
|
||||
import getVersion from 'api/v1/version/get';
|
||||
import get from 'api/v2/sessions/context/get';
|
||||
@@ -392,9 +392,9 @@ function Login(): JSX.Element {
|
||||
disabled={!isNextButtonEnabled}
|
||||
variant="solid"
|
||||
onClick={onNextHandler}
|
||||
data-testid="initiate_login"
|
||||
testId="initiate_login"
|
||||
className="login-submit-btn"
|
||||
suffixIcon={<ArrowRight size={12} />}
|
||||
suffix={<ArrowRight />}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
@@ -406,10 +406,10 @@ function Login(): JSX.Element {
|
||||
variant="solid"
|
||||
type="submit"
|
||||
color="primary"
|
||||
data-testid="callback_authn_submit"
|
||||
testId="callback_authn_submit"
|
||||
data-attr="signup"
|
||||
className="login-submit-btn"
|
||||
suffixIcon={<ArrowRight size={12} />}
|
||||
suffix={<ArrowRight />}
|
||||
>
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
@@ -420,11 +420,11 @@ function Login(): JSX.Element {
|
||||
disabled={!isSubmitButtonEnabled}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="password_authn_submit"
|
||||
testId="password_authn_submit"
|
||||
type="submit"
|
||||
data-attr="signup"
|
||||
className="login-submit-btn"
|
||||
suffixIcon={<ArrowRight size={12} />}
|
||||
suffix={<ArrowRight />}
|
||||
>
|
||||
Sign in with Password
|
||||
</Button>
|
||||
|
||||
@@ -6,16 +6,14 @@ import { Check, ChevronDown, Plus } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import getPendingInvites from 'api/v1/invite/get';
|
||||
import getAll from 'api/v1/user/get';
|
||||
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
|
||||
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
|
||||
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { toISOString } from 'utils/app';
|
||||
|
||||
import { FilterMode, INVITE_PREFIX, MemberStatus } from './utils';
|
||||
import { FilterMode, MemberStatus, toMemberStatus } from './utils';
|
||||
|
||||
import './MembersSettings.styles.scss';
|
||||
|
||||
@@ -35,51 +33,24 @@ function MembersSettings(): JSX.Element {
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
||||
const [selectedMember, setSelectedMember] = useState<MemberRow | null>(null);
|
||||
|
||||
const {
|
||||
data: usersData,
|
||||
isLoading: isUsersLoading,
|
||||
refetch: refetchUsers,
|
||||
} = useQuery({
|
||||
const { data: usersData, isLoading, refetch: refetchUsers } = useQuery({
|
||||
queryFn: getAll,
|
||||
queryKey: ['getOrgUser', org?.[0]?.id],
|
||||
});
|
||||
|
||||
const {
|
||||
data: invitesData,
|
||||
isLoading: isInvitesLoading,
|
||||
refetch: refetchInvites,
|
||||
} = useQuery({
|
||||
queryFn: getPendingInvites,
|
||||
queryKey: ['getPendingInvites'],
|
||||
});
|
||||
|
||||
const isLoading = isUsersLoading || isInvitesLoading;
|
||||
|
||||
const allMembers = useMemo((): MemberRow[] => {
|
||||
const activeMembers: MemberRow[] = (usersData?.data ?? []).map((user) => ({
|
||||
id: user.id,
|
||||
name: user.displayName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
status: MemberStatus.Active,
|
||||
joinedOn: toISOString(user.createdAt),
|
||||
updatedAt: toISOString(user?.updatedAt),
|
||||
}));
|
||||
|
||||
const pendingInvites: MemberRow[] = (invitesData?.data ?? []).map(
|
||||
(invite) => ({
|
||||
id: `${INVITE_PREFIX}${invite.id}`,
|
||||
name: invite.name ?? '',
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
status: MemberStatus.Invited,
|
||||
joinedOn: toISOString(invite.createdAt),
|
||||
token: invite.token ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
return [...activeMembers, ...pendingInvites];
|
||||
}, [usersData, invitesData]);
|
||||
const allMembers = useMemo(
|
||||
(): MemberRow[] =>
|
||||
(usersData?.data ?? []).map((user) => ({
|
||||
id: user.id,
|
||||
name: user.displayName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
status: toMemberStatus(user.status ?? ''),
|
||||
joinedOn: user.createdAt ? String(user.createdAt) : null,
|
||||
updatedAt: user.updatedAt ? String(user.updatedAt) : null,
|
||||
})),
|
||||
[usersData],
|
||||
);
|
||||
|
||||
const filteredMembers = useMemo((): MemberRow[] => {
|
||||
let result = allMembers;
|
||||
@@ -101,11 +72,6 @@ function MembersSettings(): JSX.Element {
|
||||
return result;
|
||||
}, [allMembers, filterMode, searchQuery]);
|
||||
|
||||
const paginatedMembers = useMemo((): MemberRow[] => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return filteredMembers.slice(start, start + PAGE_SIZE);
|
||||
}, [filteredMembers, currentPage]);
|
||||
|
||||
// TODO(nuqs): Replace with nuqs once the nuqs setup and integration is done
|
||||
const setPage = useCallback(
|
||||
(page: number): void => {
|
||||
@@ -123,12 +89,11 @@ function MembersSettings(): JSX.Element {
|
||||
if (currentPage > maxPage) {
|
||||
setPage(maxPage);
|
||||
}
|
||||
if (currentPage < 1) {
|
||||
setPage(1);
|
||||
}
|
||||
}, [filteredMembers.length, currentPage, setPage]);
|
||||
|
||||
const pendingCount = invitesData?.data?.length ?? 0;
|
||||
const pendingCount = allMembers.filter(
|
||||
(m) => m.status === MemberStatus.Invited,
|
||||
).length;
|
||||
const totalCount = allMembers.length;
|
||||
|
||||
const filterMenuItems: MenuProps['items'] = [
|
||||
@@ -167,8 +132,7 @@ function MembersSettings(): JSX.Element {
|
||||
|
||||
const handleInviteComplete = useCallback((): void => {
|
||||
refetchUsers();
|
||||
refetchInvites();
|
||||
}, [refetchUsers, refetchInvites]);
|
||||
}, [refetchUsers]);
|
||||
|
||||
const handleRowClick = useCallback((member: MemberRow): void => {
|
||||
setSelectedMember(member);
|
||||
@@ -180,9 +144,8 @@ function MembersSettings(): JSX.Element {
|
||||
|
||||
const handleMemberEditComplete = useCallback((): void => {
|
||||
refetchUsers();
|
||||
refetchInvites();
|
||||
setSelectedMember(null);
|
||||
}, [refetchUsers, refetchInvites]);
|
||||
}, [refetchUsers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -213,7 +176,6 @@ function MembersSettings(): JSX.Element {
|
||||
|
||||
<div className="members-settings__search">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by name, email, or role..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => {
|
||||
@@ -222,7 +184,6 @@ function MembersSettings(): JSX.Element {
|
||||
}}
|
||||
className="members-search-input"
|
||||
color="secondary"
|
||||
name="members-search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -238,7 +199,7 @@ function MembersSettings(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
<MembersTable
|
||||
data={paginatedMembers}
|
||||
data={filteredMembers}
|
||||
loading={isLoading}
|
||||
total={filteredMembers.length}
|
||||
currentPage={currentPage}
|
||||
@@ -259,7 +220,6 @@ function MembersSettings(): JSX.Element {
|
||||
open={selectedMember !== null}
|
||||
onClose={handleDrawerClose}
|
||||
onComplete={handleMemberEditComplete}
|
||||
onRefetch={handleInviteComplete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { PendingInvite } from 'types/api/user/getPendingInvites';
|
||||
import { UserResponse } from 'types/api/user/getUser';
|
||||
|
||||
import MembersSettings from '../MembersSettings';
|
||||
@@ -13,7 +12,6 @@ jest.mock('@signozhq/sonner', () => ({
|
||||
}));
|
||||
|
||||
const USERS_ENDPOINT = '*/api/v1/user';
|
||||
const INVITES_ENDPOINT = '*/api/v1/invite';
|
||||
|
||||
const mockUsers: UserResponse[] = [
|
||||
{
|
||||
@@ -21,7 +19,8 @@ const mockUsers: UserResponse[] = [
|
||||
displayName: 'Alice Smith',
|
||||
email: 'alice@signoz.io',
|
||||
role: 'ADMIN',
|
||||
createdAt: 1700000000,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
organization: 'TestOrg',
|
||||
orgId: 'org-1',
|
||||
},
|
||||
@@ -30,20 +29,30 @@ const mockUsers: UserResponse[] = [
|
||||
displayName: 'Bob Jones',
|
||||
email: 'bob@signoz.io',
|
||||
role: 'VIEWER',
|
||||
createdAt: 1700000001,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-02T00:00:00.000Z',
|
||||
organization: 'TestOrg',
|
||||
orgId: 'org-1',
|
||||
},
|
||||
];
|
||||
|
||||
const mockInvites: PendingInvite[] = [
|
||||
{
|
||||
id: 'inv-1',
|
||||
displayName: '',
|
||||
email: 'charlie@signoz.io',
|
||||
name: 'Charlie',
|
||||
role: 'EDITOR',
|
||||
createdAt: 1700000002,
|
||||
token: 'tok-abc',
|
||||
status: 'pending_invite',
|
||||
createdAt: '2024-01-03T00:00:00.000Z',
|
||||
organization: 'TestOrg',
|
||||
orgId: 'org-1',
|
||||
},
|
||||
{
|
||||
id: 'user-3',
|
||||
displayName: 'Dave Deleted',
|
||||
email: 'dave@signoz.io',
|
||||
role: 'VIEWER',
|
||||
status: 'deleted',
|
||||
createdAt: '2024-01-04T00:00:00.000Z',
|
||||
organization: 'TestOrg',
|
||||
orgId: 'org-1',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -54,9 +63,6 @@ describe('MembersSettings (integration)', () => {
|
||||
rest.get(USERS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockUsers })),
|
||||
),
|
||||
rest.get(INVITES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockInvites })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -64,14 +70,16 @@ describe('MembersSettings (integration)', () => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('loads and displays active users and pending invites', async () => {
|
||||
it('loads and displays active users, pending invites, and deleted members', async () => {
|
||||
render(<MembersSettings />);
|
||||
|
||||
await screen.findByText('Alice Smith');
|
||||
expect(screen.getByText('Bob Jones')).toBeInTheDocument();
|
||||
expect(screen.getByText('charlie@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dave Deleted')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
|
||||
expect(screen.getByText('INVITED')).toBeInTheDocument();
|
||||
expect(screen.getByText('DELETED')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters to pending invites via the filter dropdown', async () => {
|
||||
@@ -107,7 +115,7 @@ describe('MembersSettings (integration)', () => {
|
||||
expect(screen.queryByText('charlie@signoz.io')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens EditMemberDrawer when a member row is clicked', async () => {
|
||||
it('opens EditMemberDrawer when an active member row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
@@ -117,6 +125,16 @@ describe('MembersSettings (integration)', () => {
|
||||
await screen.findByText('Member Details');
|
||||
});
|
||||
|
||||
it('does not open EditMemberDrawer when a deleted member row is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<MembersSettings />);
|
||||
|
||||
await user.click(await screen.findByText('Dave Deleted'));
|
||||
|
||||
expect(screen.queryByText('Member Details')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens InviteMembersModal when "Invite member" button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const INVITE_PREFIX = 'invite-';
|
||||
|
||||
export enum FilterMode {
|
||||
All = 'all',
|
||||
Invited = 'invited',
|
||||
@@ -8,4 +6,25 @@ export enum FilterMode {
|
||||
export enum MemberStatus {
|
||||
Active = 'Active',
|
||||
Invited = 'Invited',
|
||||
Deleted = 'Deleted',
|
||||
Anonymous = 'Anonymous',
|
||||
}
|
||||
|
||||
export enum UserApiStatus {
|
||||
Active = 'active',
|
||||
PendingInvite = 'pending_invite',
|
||||
Deleted = 'deleted',
|
||||
}
|
||||
|
||||
export function toMemberStatus(apiStatus: string): MemberStatus {
|
||||
switch (apiStatus) {
|
||||
case UserApiStatus.PendingInvite:
|
||||
return MemberStatus.Invited;
|
||||
case UserApiStatus.Deleted:
|
||||
return MemberStatus.Deleted;
|
||||
case UserApiStatus.Active:
|
||||
return MemberStatus.Active;
|
||||
default:
|
||||
return MemberStatus.Anonymous;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
.sa-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
|
||||
height: 100%;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--label-large-500-font-size);
|
||||
font-weight: var(--label-large-500-font-weight);
|
||||
color: var(--text-base-white);
|
||||
letter-spacing: -0.09px;
|
||||
line-height: var(--line-height-normal);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.07px;
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__learn-more {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-status-badge {
|
||||
color: var(--l3-foreground);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.sa-settings-filter-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
background-color: var(--l2-background);
|
||||
|
||||
> span {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.sa-settings-filter-dropdown {
|
||||
.ant-dropdown-menu {
|
||||
padding: var(--padding-3) 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--l2-background);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item {
|
||||
background: transparent !important;
|
||||
padding: var(--padding-1) 0 !important;
|
||||
|
||||
&:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-settings-filter-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.14px;
|
||||
min-width: 170px;
|
||||
|
||||
&:hover {
|
||||
color: var(--card-foreground);
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-settings-search-input {
|
||||
height: 32px;
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.sa-settings {
|
||||
&__title {
|
||||
color: var(--text-base-black);
|
||||
}
|
||||
}
|
||||
|
||||
.sa-settings-filter-option {
|
||||
&:hover {
|
||||
color: var(--bg-neutral-light-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Check, ChevronDown, Plus } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import {
|
||||
getGetServiceAccountQueryKey,
|
||||
useListServiceAccounts,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type {
|
||||
GetServiceAccount200,
|
||||
ListServiceAccounts200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
|
||||
import ServiceAccountsTable, {
|
||||
PAGE_SIZE,
|
||||
} from 'components/ServiceAccountsTable/ServiceAccountsTable';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
parseAsStringEnum,
|
||||
useQueryState,
|
||||
} from 'nuqs';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import {
|
||||
FilterMode,
|
||||
ServiceAccountRow,
|
||||
ServiceAccountStatus,
|
||||
toServiceAccountRow,
|
||||
} from './utils';
|
||||
|
||||
import './ServiceAccountsSettings.styles.scss';
|
||||
|
||||
function ServiceAccountsSettings(): JSX.Element {
|
||||
const [currentPage, setPage] = useQueryState(
|
||||
'page',
|
||||
parseAsInteger.withDefault(1),
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useQueryState(
|
||||
'search',
|
||||
parseAsString.withDefault(''),
|
||||
);
|
||||
const [filterMode, setFilterMode] = useQueryState(
|
||||
'filter',
|
||||
parseAsStringEnum<FilterMode>(Object.values(FilterMode)).withDefault(
|
||||
FilterMode.All,
|
||||
),
|
||||
);
|
||||
const [, setSelectedAccountId] = useQueryState('account');
|
||||
const [, setIsCreateModalOpen] = useQueryState(
|
||||
'create-sa',
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const seedAccountCache = useCallback(
|
||||
(data: ListServiceAccounts200) => {
|
||||
data.data.forEach((account) => {
|
||||
queryClient.setQueryData<GetServiceAccount200>(
|
||||
getGetServiceAccountQueryKey({ id: account.id }),
|
||||
(old) => old ?? { data: account, status: data.status },
|
||||
);
|
||||
});
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const {
|
||||
data: serviceAccountsData,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch: handleCreateSuccess,
|
||||
} = useListServiceAccounts({
|
||||
query: { onSuccess: seedAccountCache },
|
||||
});
|
||||
|
||||
const allAccounts = useMemo(
|
||||
(): ServiceAccountRow[] =>
|
||||
(serviceAccountsData?.data ?? []).map(toServiceAccountRow),
|
||||
[serviceAccountsData],
|
||||
);
|
||||
|
||||
const activeCount = useMemo(
|
||||
() =>
|
||||
allAccounts.filter(
|
||||
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Active,
|
||||
).length,
|
||||
[allAccounts],
|
||||
);
|
||||
|
||||
const disabledCount = useMemo(
|
||||
() =>
|
||||
allAccounts.filter(
|
||||
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
|
||||
).length,
|
||||
[allAccounts],
|
||||
);
|
||||
|
||||
const filteredAccounts = useMemo((): ServiceAccountRow[] => {
|
||||
let result = allAccounts;
|
||||
|
||||
if (filterMode === FilterMode.Active) {
|
||||
result = result.filter(
|
||||
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Active,
|
||||
);
|
||||
} else if (filterMode === FilterMode.Disabled) {
|
||||
result = result.filter(
|
||||
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
|
||||
);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
result = result.filter(
|
||||
(a) =>
|
||||
a.name?.toLowerCase().includes(q) ||
|
||||
a.email?.toLowerCase().includes(q) ||
|
||||
a.roles?.some((role: string) => role.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [allAccounts, filterMode, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredAccounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(filteredAccounts.length / PAGE_SIZE));
|
||||
if (currentPage > maxPage) {
|
||||
setPage(maxPage);
|
||||
} else if (currentPage < 1) {
|
||||
setPage(1);
|
||||
}
|
||||
}, [filteredAccounts.length, currentPage, setPage]);
|
||||
|
||||
const totalCount = allAccounts.length;
|
||||
|
||||
const filterMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: FilterMode.All,
|
||||
label: (
|
||||
<div className="sa-settings-filter-option">
|
||||
<span>All accounts ⎯ {totalCount}</span>
|
||||
{filterMode === FilterMode.All && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.All);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: FilterMode.Active,
|
||||
label: (
|
||||
<div className="sa-settings-filter-option">
|
||||
<span>Active ⎯ {activeCount}</span>
|
||||
{filterMode === FilterMode.Active && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.Active);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: FilterMode.Disabled,
|
||||
label: (
|
||||
<div className="sa-settings-filter-option">
|
||||
<span>Disabled ⎯ {disabledCount}</span>
|
||||
{filterMode === FilterMode.Disabled && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.Disabled);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function getFilterLabel(): string {
|
||||
switch (filterMode) {
|
||||
case FilterMode.Active:
|
||||
return `Active ⎯ ${activeCount}`;
|
||||
case FilterMode.Disabled:
|
||||
return `Disabled ⎯ ${disabledCount}`;
|
||||
default:
|
||||
return `All accounts ⎯ ${totalCount}`;
|
||||
}
|
||||
}
|
||||
const filterLabel = getFilterLabel();
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: ServiceAccountRow): void => {
|
||||
setSelectedAccountId(row.id);
|
||||
},
|
||||
[setSelectedAccountId],
|
||||
);
|
||||
|
||||
const handleDrawerSuccess = useCallback(
|
||||
(options?: { closeDrawer?: boolean }): void => {
|
||||
if (options?.closeDrawer) {
|
||||
setSelectedAccountId(null);
|
||||
}
|
||||
handleCreateSuccess();
|
||||
},
|
||||
[handleCreateSuccess, setSelectedAccountId],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sa-settings">
|
||||
<div className="sa-settings__header">
|
||||
<h1 className="sa-settings__title">Service Accounts</h1>
|
||||
<p className="sa-settings__subtitle">
|
||||
Overview of service accounts added to this workspace.{' '}
|
||||
{/* Todo: to add doc links */}
|
||||
{/* <a
|
||||
href="https://signoz.io/docs/service-accounts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sa-settings__learn-more"
|
||||
>
|
||||
Learn more
|
||||
</a> */}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="sa-settings__controls">
|
||||
<Dropdown
|
||||
menu={{ items: filterMenuItems }}
|
||||
trigger={['click']}
|
||||
overlayClassName="sa-settings-filter-dropdown"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
className="sa-settings-filter-trigger"
|
||||
>
|
||||
<span>{filterLabel}</span>
|
||||
<ChevronDown size={12} className="sa-settings-filter-trigger__chevron" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<div className="sa-settings__search">
|
||||
<Input
|
||||
type="search"
|
||||
name="service-accounts-search"
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => {
|
||||
setSearchQuery(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="sa-settings-search-input"
|
||||
color="secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="primary"
|
||||
onClick={(): void => {
|
||||
void setIsCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
New Service Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
error,
|
||||
'An unexpected error occurred while fetching service accounts.',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ServiceAccountsTable
|
||||
data={filteredAccounts}
|
||||
loading={isLoading}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateServiceAccountModal />
|
||||
|
||||
<ServiceAccountDrawer onSuccess={handleDrawerSuccess} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceAccountsSettings;
|
||||
@@ -1,231 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountsSettings from '../ServiceAccountsSettings';
|
||||
|
||||
const SA_LIST_ENDPOINT = '*/api/v1/service_accounts';
|
||||
const SA_ENDPOINT = '*/api/v1/service_accounts/:id';
|
||||
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
|
||||
const ROLES_ENDPOINT = '*/api/v1/roles';
|
||||
|
||||
jest.mock('@signozhq/drawer', () => ({
|
||||
DrawerWrapper: ({
|
||||
content,
|
||||
open,
|
||||
}: {
|
||||
content?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null => (open ? <div>{content}</div> : null),
|
||||
}));
|
||||
|
||||
const mockServiceAccountsAPI = [
|
||||
{
|
||||
id: 'sa-1',
|
||||
name: 'CI Bot',
|
||||
email: 'ci-bot@signoz.io',
|
||||
roles: ['signoz-admin'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: 1700000000,
|
||||
updatedAt: 1700000001,
|
||||
},
|
||||
{
|
||||
id: 'sa-2',
|
||||
name: 'Monitoring Agent',
|
||||
email: 'monitor@signoz.io',
|
||||
roles: ['signoz-viewer'],
|
||||
status: 'ACTIVE',
|
||||
createdAt: 1700000002,
|
||||
updatedAt: 1700000003,
|
||||
},
|
||||
{
|
||||
id: 'sa-3',
|
||||
name: 'Legacy Bot',
|
||||
email: 'legacy@signoz.io',
|
||||
roles: ['signoz-editor'],
|
||||
status: 'DISABLED',
|
||||
createdAt: 1700000004,
|
||||
updatedAt: 1700000005,
|
||||
},
|
||||
];
|
||||
|
||||
describe('ServiceAccountsSettings (integration)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(
|
||||
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockServiceAccountsAPI })),
|
||||
),
|
||||
rest.get(SA_ENDPOINT, (req, res, ctx) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const account = mockServiceAccountsAPI.find((a) => a.id === id);
|
||||
return account
|
||||
? res(ctx.status(200), ctx.json({ data: account }))
|
||||
: res(ctx.status(404), ctx.json({ message: 'Not found' }));
|
||||
}),
|
||||
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [] })),
|
||||
),
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('loads and displays all accounts with correct ACTIVE and DISABLED badges', async () => {
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
expect(screen.getByText('Monitoring Agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('legacy@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('ACTIVE')).toHaveLength(2);
|
||||
expect(screen.getByText('DISABLED')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filter dropdown to "Active" hides DISABLED accounts', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /All accounts/i }));
|
||||
|
||||
const activeOption = await screen.findByText(/Active ⎯/i);
|
||||
await user.click(activeOption);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
expect(screen.queryByText('Legacy Bot')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('search by name filters accounts in real-time', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(/Search by name or email/i),
|
||||
'legacy',
|
||||
);
|
||||
|
||||
await screen.findByText('Legacy Bot');
|
||||
expect(screen.queryByText('CI Bot')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Monitoring Agent')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking a row opens the drawer with account details visible', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: /View service account CI Bot/i,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /Disable Service Account/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saving changes in the drawer refetches the list', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const listRefetchSpy = jest.fn();
|
||||
|
||||
server.use(
|
||||
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) => {
|
||||
listRefetchSpy();
|
||||
return res(ctx.status(200), ctx.json({ data: mockServiceAccountsAPI }));
|
||||
}),
|
||||
rest.put(SA_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
listRefetchSpy.mockClear();
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: /View service account CI Bot/i }),
|
||||
);
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('CI Bot');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'CI Bot Updated');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
|
||||
|
||||
await screen.findByDisplayValue('CI Bot Updated');
|
||||
expect(listRefetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('"New Service Account" button opens the Create Service Account modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /New Service Account/i }),
|
||||
);
|
||||
|
||||
await screen.findByRole('dialog', { name: /New Service Account/i });
|
||||
expect(screen.getByPlaceholderText('Enter a name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state when API fails', async () => {
|
||||
server.use(
|
||||
rest.get(SA_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ message: 'Internal Server Error' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/An unexpected error occurred while fetching service accounts/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export const IS_SERVICE_ACCOUNTS_ENABLED = false;
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { ServiceaccounttypesServiceAccountDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { toISOString } from 'utils/app';
|
||||
|
||||
export function toServiceAccountRow(
|
||||
sa: ServiceaccounttypesServiceAccountDTO,
|
||||
): ServiceAccountRow {
|
||||
return {
|
||||
id: sa.id,
|
||||
name: sa.name,
|
||||
email: sa.email,
|
||||
roles: sa.roles,
|
||||
status: sa.status,
|
||||
createdAt: toISOString(sa.createdAt),
|
||||
updatedAt: toISOString(sa.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export enum FilterMode {
|
||||
All = 'all',
|
||||
Active = 'active',
|
||||
Disabled = 'disabled',
|
||||
}
|
||||
|
||||
export enum ServiceAccountStatus {
|
||||
Active = 'ACTIVE',
|
||||
Disabled = 'DISABLED',
|
||||
}
|
||||
|
||||
export interface ServiceAccountRow {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
BellDot,
|
||||
Binoculars,
|
||||
Book,
|
||||
Bot,
|
||||
Boxes,
|
||||
BugIcon,
|
||||
Building2,
|
||||
@@ -359,13 +358,6 @@ export const settingsNavSections: SettingsNavSection[] = [
|
||||
isEnabled: false,
|
||||
itemKey: 'members',
|
||||
},
|
||||
{
|
||||
key: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
label: 'Service Accounts',
|
||||
icon: <Bot size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'service-accounts',
|
||||
},
|
||||
{
|
||||
key: ROUTES.API_KEYS,
|
||||
label: 'API Keys',
|
||||
|
||||
@@ -154,7 +154,6 @@ export const routesToSkip = [
|
||||
ROUTES.ALL_DASHBOARD,
|
||||
ROUTES.ORG_SETTINGS,
|
||||
ROUTES.MEMBERS_SETTINGS,
|
||||
ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
ROUTES.INGESTION_SETTINGS,
|
||||
ROUTES.API_KEYS,
|
||||
ROUTES.ERROR_DETAIL,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
export const inviteUser = {
|
||||
status: 'success',
|
||||
data: {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
payload: [
|
||||
{
|
||||
email: 'jane@doe.com',
|
||||
name: 'Jane',
|
||||
token: 'testtoken',
|
||||
createdAt: 1715741587,
|
||||
role: 'VIEWER',
|
||||
organization: 'test',
|
||||
},
|
||||
{
|
||||
email: 'test+in@singoz.io',
|
||||
name: '',
|
||||
token: 'testtoken1',
|
||||
createdAt: 1720095913,
|
||||
role: 'VIEWER',
|
||||
organization: 'test',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
getDashboardById,
|
||||
} from './__mockdata__/dashboards';
|
||||
import { explorerView } from './__mockdata__/explorer_views';
|
||||
import { inviteUser } from './__mockdata__/invite_user';
|
||||
import { licensesSuccessResponse } from './__mockdata__/licenses';
|
||||
import { membersResponse } from './__mockdata__/members';
|
||||
import { queryRangeSuccessResponse } from './__mockdata__/query_range';
|
||||
@@ -175,11 +174,14 @@ export const handlers = [
|
||||
res(ctx.status(200), ctx.json(getDashboardById)),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/invite', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(inviteUser)),
|
||||
),
|
||||
rest.post('http://localhost/api/v1/invite', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(inviteUser)),
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: 'invite sent successfully',
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.put('http://localhost/api/v1/user/:id', (_, res, ctx) =>
|
||||
res(
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from 'container/ServiceAccountsSettings/ServiceAccountsSettings';
|
||||
@@ -5,7 +5,6 @@ import logEvent from 'api/common/logEvent';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config';
|
||||
import { routeConfig } from 'container/SideNav/config';
|
||||
import { getQueryString } from 'container/SideNav/helper';
|
||||
import { settingsNavSections } from 'container/SideNav/menuItems';
|
||||
@@ -86,8 +85,6 @@ function SettingsPage(): JSX.Element {
|
||||
item.key === ROUTES.INGESTION_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
(IS_SERVICE_ACCOUNTS_ENABLED &&
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS) ||
|
||||
item.key === ROUTES.SHORTCUTS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
@@ -119,8 +116,6 @@ function SettingsPage(): JSX.Element {
|
||||
item.key === ROUTES.API_KEYS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
(IS_SERVICE_ACCOUNTS_ENABLED &&
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS) ||
|
||||
item.key === ROUTES.INGESTION_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
@@ -146,9 +141,7 @@ function SettingsPage(): JSX.Element {
|
||||
isEnabled:
|
||||
item.key === ROUTES.API_KEYS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
(IS_SERVICE_ACCOUNTS_ENABLED &&
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS)
|
||||
item.key === ROUTES.MEMBERS_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
|
||||
@@ -17,7 +17,6 @@ import { TFunction } from 'i18next';
|
||||
import {
|
||||
Backpack,
|
||||
BellDot,
|
||||
Bot,
|
||||
Building,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import ChannelsEdit from 'pages/ChannelsEdit';
|
||||
import MembersSettings from 'pages/MembersSettings';
|
||||
import ServiceAccountsSettings from 'pages/ServiceAccountsSettings';
|
||||
import Shortcuts from 'pages/Shortcuts';
|
||||
|
||||
export const organizationSettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
@@ -205,21 +203,6 @@ export const mySettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
},
|
||||
];
|
||||
|
||||
export const serviceAccountsSettings = (
|
||||
t: TFunction,
|
||||
): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: ServiceAccountsSettings,
|
||||
name: (
|
||||
<div className="periscope-tab">
|
||||
<Bot size={16} /> {t('routes:service_accounts').toString()}
|
||||
</div>
|
||||
),
|
||||
route: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
key: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
},
|
||||
];
|
||||
|
||||
export const createAlertChannels = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: (): JSX.Element => (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RouteTabProps } from 'components/RouteTab/types';
|
||||
import { IS_SERVICE_ACCOUNTS_ENABLED } from 'container/ServiceAccountsSettings/config';
|
||||
import { TFunction } from 'i18next';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
organizationSettings,
|
||||
roleDetails,
|
||||
rolesSettings,
|
||||
serviceAccountsSettings,
|
||||
} from './config';
|
||||
|
||||
export const getRoutes = (
|
||||
@@ -65,10 +63,6 @@ export const getRoutes = (
|
||||
|
||||
if (isAdmin) {
|
||||
settings.push(...apiKeys(t), ...membersSettings(t));
|
||||
|
||||
if (IS_SERVICE_ACCOUNTS_ENABLED) {
|
||||
settings.push(...serviceAccountsSettings(t));
|
||||
}
|
||||
}
|
||||
|
||||
// todo: Sagar - check the condition for role list and details page, to whom we want to serve
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Form, Input as AntdInput, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import accept from 'api/v1/invite/id/accept';
|
||||
import getInviteDetails from 'api/v1/invite/id/get';
|
||||
import signUpApi from 'api/v1/register/post';
|
||||
import passwordAuthNContext from 'api/v2/sessions/email_password/post';
|
||||
import afterLogin from 'AppRoutes/utils';
|
||||
@@ -15,9 +11,7 @@ import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { ArrowRight, CircleAlert } from 'lucide-react';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { InviteDetails } from 'types/api/user/getInviteDetails';
|
||||
|
||||
import { FormContainer, Label } from './styles';
|
||||
|
||||
@@ -39,22 +33,6 @@ function SignUp(): JSX.Element {
|
||||
false,
|
||||
);
|
||||
const [formError, setFormError] = useState<APIError | null>();
|
||||
const { search } = useLocation();
|
||||
const params = new URLSearchParams(search);
|
||||
const token = params.get('token');
|
||||
const [isDetailsDisable, setIsDetailsDisable] = useState<boolean>(false);
|
||||
|
||||
const getInviteDetailsResponse = useQuery<
|
||||
SuccessResponseV2<InviteDetails>,
|
||||
APIError
|
||||
>({
|
||||
queryFn: () =>
|
||||
getInviteDetails({
|
||||
inviteId: token || '',
|
||||
}),
|
||||
queryKey: ['getInviteDetails', token],
|
||||
enabled: token !== null,
|
||||
});
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
@@ -64,49 +42,6 @@ function SignUp(): JSX.Element {
|
||||
const password = Form.useWatch('password', form);
|
||||
const confirmPassword = Form.useWatch('confirmPassword', form);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
getInviteDetailsResponse.status === 'success' &&
|
||||
getInviteDetailsResponse.data.data
|
||||
) {
|
||||
const responseDetails = getInviteDetailsResponse.data.data;
|
||||
form.setFieldValue('email', responseDetails.email);
|
||||
form.setFieldValue('organizationName', responseDetails.organization);
|
||||
setIsDetailsDisable(true);
|
||||
|
||||
logEvent('Account Creation Page Visited', {
|
||||
email: responseDetails.email,
|
||||
name: responseDetails.name,
|
||||
company_name: responseDetails.organization,
|
||||
source: 'SigNoz Cloud',
|
||||
});
|
||||
}
|
||||
}, [
|
||||
getInviteDetailsResponse.data?.data,
|
||||
form,
|
||||
getInviteDetailsResponse.status,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
getInviteDetailsResponse.status === 'success' &&
|
||||
getInviteDetailsResponse?.error
|
||||
) {
|
||||
const { error } = getInviteDetailsResponse;
|
||||
notifications.error({
|
||||
message: (error as APIError).getErrorCode(),
|
||||
description: (error as APIError).getErrorMessage(),
|
||||
});
|
||||
}
|
||||
}, [
|
||||
getInviteDetailsResponse,
|
||||
getInviteDetailsResponse.data,
|
||||
getInviteDetailsResponse.status,
|
||||
notifications,
|
||||
]);
|
||||
|
||||
const isSignUp = token === null;
|
||||
|
||||
const signUp = async (values: FormValues): Promise<void> => {
|
||||
try {
|
||||
const { organizationName, password, email } = values;
|
||||
@@ -114,7 +49,6 @@ function SignUp(): JSX.Element {
|
||||
email,
|
||||
orgDisplayName: organizationName,
|
||||
password,
|
||||
token: params.get('token') || undefined,
|
||||
});
|
||||
|
||||
const token = await passwordAuthNContext({
|
||||
@@ -129,25 +63,6 @@ function SignUp(): JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const acceptInvite = async (values: FormValues): Promise<void> => {
|
||||
try {
|
||||
const { password, email } = values;
|
||||
const user = await accept({
|
||||
password,
|
||||
token: params.get('token') || '',
|
||||
});
|
||||
const token = await passwordAuthNContext({
|
||||
email,
|
||||
password,
|
||||
orgId: user.data.orgId,
|
||||
});
|
||||
|
||||
await afterLogin(token.data.accessToken, token.data.refreshToken);
|
||||
} catch (error) {
|
||||
setFormError(error as APIError);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
(async (): Promise<void> => {
|
||||
try {
|
||||
@@ -155,14 +70,10 @@ function SignUp(): JSX.Element {
|
||||
setLoading(true);
|
||||
setFormError(null);
|
||||
|
||||
if (isSignUp) {
|
||||
await signUp(values);
|
||||
logEvent('Account Created Successfully', {
|
||||
email: values.email,
|
||||
});
|
||||
} else {
|
||||
await acceptInvite(values);
|
||||
}
|
||||
await signUp(values);
|
||||
logEvent('Account Created Successfully', {
|
||||
email: values.email,
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
@@ -247,7 +158,6 @@ function SignUp(): JSX.Element {
|
||||
autoFocus
|
||||
required
|
||||
id="signupEmail"
|
||||
disabled={isDetailsDisable}
|
||||
className="signup-form-input"
|
||||
/>
|
||||
</FormContainer.Item>
|
||||
@@ -291,15 +201,13 @@ function SignUp(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSignUp && (
|
||||
<Callout
|
||||
type="info"
|
||||
size="small"
|
||||
showIcon
|
||||
className="signup-info-callout"
|
||||
description="This will create an admin account. If you are not an admin, please ask your admin for an invite link"
|
||||
/>
|
||||
)}
|
||||
<Callout
|
||||
type="info"
|
||||
size="small"
|
||||
showIcon
|
||||
className="signup-info-callout"
|
||||
description="This will create an admin account. If you are not an admin, please ask your admin for an invite link"
|
||||
/>
|
||||
|
||||
{confirmPasswordError && (
|
||||
<Callout
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import afterLogin from 'AppRoutes/utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { InviteDetails } from 'types/api/user/getInviteDetails';
|
||||
import { SignupResponse } from 'types/api/v1/register/post';
|
||||
import { Token } from 'types/api/v2/sessions/email_password/post';
|
||||
|
||||
@@ -32,14 +31,8 @@ jest.mock('lib/history', () => ({
|
||||
|
||||
const REGISTER_ENDPOINT = '*/api/v1/register';
|
||||
const EMAIL_PASSWORD_ENDPOINT = '*/api/v2/sessions/email_password';
|
||||
const INVITE_DETAILS_ENDPOINT = '*/api/v1/invite/*';
|
||||
const ACCEPT_INVITE_ENDPOINT = '*/api/v1/invite/accept';
|
||||
|
||||
interface MockSignupResponse extends SignupResponse {
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
const mockSignupResponse: MockSignupResponse = {
|
||||
const mockSignupResponse: SignupResponse = {
|
||||
orgId: 'test-org-id',
|
||||
createdAt: Date.now(),
|
||||
email: 'test@signoz.io',
|
||||
@@ -53,15 +46,6 @@ const mockTokenResponse: Token = {
|
||||
refreshToken: 'mock-refresh-token',
|
||||
};
|
||||
|
||||
const mockInviteDetails: InviteDetails = {
|
||||
email: 'invited@signoz.io',
|
||||
name: 'Invited User',
|
||||
organization: 'Test Org',
|
||||
createdAt: Date.now(),
|
||||
role: 'ADMIN',
|
||||
token: 'invite-token-123',
|
||||
};
|
||||
|
||||
describe('SignUp Component - Regular Signup', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -288,242 +272,3 @@ describe('SignUp Component - Regular Signup', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SignUp Component - Accept Invite', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.history.pushState({}, '', '/signup?token=invite-token-123');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('Initial Render with Invite', () => {
|
||||
it('pre-fills form fields from invite details', async () => {
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invite-token-123',
|
||||
});
|
||||
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toHaveValue('invited@signoz.io');
|
||||
});
|
||||
});
|
||||
|
||||
it('disables email field when invite details are loaded', async () => {
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invite-token-123',
|
||||
});
|
||||
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show admin account info callout for invite flow', async () => {
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invite-token-123',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText(/this will create an admin account/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful Invite Acceptance', () => {
|
||||
it('successfully accepts invite and logs in user', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(ACCEPT_INVITE_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockSignupResponse,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(EMAIL_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockTokenResponse,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invite-token-123',
|
||||
});
|
||||
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toHaveValue('invited@signoz.io');
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /access my workspace/i,
|
||||
});
|
||||
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.type(confirmPasswordInput, 'password123');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAfterLogin).toHaveBeenCalledWith(
|
||||
'mock-access-token',
|
||||
'mock-refresh-token',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling for Invite', () => {
|
||||
it('displays error when invite details fetch fails', async () => {
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(404),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'INVITE_NOT_FOUND',
|
||||
message: 'Invite not found',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=invalid-token',
|
||||
});
|
||||
|
||||
// Verify form is still accessible and fields are enabled
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
|
||||
expect(emailInput).toBeInTheDocument();
|
||||
expect(emailInput).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('displays error when accept invite API fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(INVITE_DETAILS_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: mockInviteDetails,
|
||||
status: 'success',
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.post(ACCEPT_INVITE_ENDPOINT, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'INVALID_TOKEN',
|
||||
message: 'Invalid or expired invite token',
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<SignUp />, undefined, {
|
||||
initialRoute: '/signup?token=expired-token',
|
||||
});
|
||||
|
||||
const emailInput = await screen.findByLabelText(/email address/i);
|
||||
await waitFor(() => {
|
||||
expect(emailInput).toHaveValue('invited@signoz.io');
|
||||
});
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter new password/i);
|
||||
const confirmPasswordInput = screen.getByPlaceholderText(
|
||||
/confirm your new password/i,
|
||||
);
|
||||
const submitButton = screen.getByRole('button', {
|
||||
name: /access my workspace/i,
|
||||
});
|
||||
|
||||
await user.type(passwordInput, 'password123');
|
||||
await user.type(confirmPasswordInput, 'password123');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/invalid or expired invite token/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { UserResponse } from './getUser';
|
||||
|
||||
export interface Props {
|
||||
token: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface LoginPrecheckResponse {
|
||||
sso: boolean;
|
||||
ssoUrl?: string;
|
||||
canSelfRegister?: boolean;
|
||||
isUser: boolean;
|
||||
}
|
||||
|
||||
export interface PayloadProps {
|
||||
data: UserResponse;
|
||||
status: string;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface Props {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface PayloadProps {
|
||||
data: string;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { User } from 'types/reducer/app';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import { Organization } from './getOrganization';
|
||||
|
||||
export interface Props {
|
||||
inviteId: string;
|
||||
}
|
||||
|
||||
export interface PayloadProps {
|
||||
data: InviteDetails;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface InviteDetails {
|
||||
createdAt: number;
|
||||
email: User['email'];
|
||||
name: User['displayName'];
|
||||
role: ROLES;
|
||||
token: string;
|
||||
organization: Organization['displayName'];
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { User } from 'types/reducer/app';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
export interface PendingInvite {
|
||||
createdAt: number;
|
||||
email: User['email'];
|
||||
name: User['displayName'];
|
||||
role: ROLES;
|
||||
id: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export type PayloadProps = {
|
||||
data: PendingInvite[];
|
||||
status: string;
|
||||
};
|
||||
@@ -7,14 +7,16 @@ export interface Props {
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
createdAt: number;
|
||||
createdAt: number | string;
|
||||
email: string;
|
||||
id: string;
|
||||
displayName: string;
|
||||
orgId: string;
|
||||
organization: string;
|
||||
role: ROLES;
|
||||
updatedAt?: number;
|
||||
updatedAt?: number | string;
|
||||
isRoot?: boolean;
|
||||
status?: 'active' | 'pending_invite' | 'deleted';
|
||||
}
|
||||
export interface PayloadProps {
|
||||
data: UserResponse;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import getLocalStorage from 'api/browser/localstorage/get';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { SKIP_ONBOARDING } from 'constants/onboarding';
|
||||
import dayjs from 'dayjs';
|
||||
import { get } from 'lodash-es';
|
||||
import { getLocation } from 'utils/getLocation';
|
||||
|
||||
@@ -74,19 +73,3 @@ export function buildAbsolutePath({
|
||||
}
|
||||
|
||||
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function toISOString(
|
||||
date: Date | string | number | null | undefined,
|
||||
): string | null {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const d = dayjs(date);
|
||||
|
||||
if (!d.isValid()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
@@ -100,7 +100,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
ROLES_SETTINGS: ['ADMIN'],
|
||||
ROLE_DETAILS: ['ADMIN'],
|
||||
MEMBERS_SETTINGS: ['ADMIN'],
|
||||
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN'],
|
||||
BILLING: ['ADMIN'],
|
||||
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
|
||||
@@ -4506,6 +4506,19 @@
|
||||
"@radix-ui/react-use-callback-ref" "1.1.1"
|
||||
"@radix-ui/react-use-escape-keydown" "1.1.1"
|
||||
|
||||
"@radix-ui/react-dropdown-menu@^2.1.16":
|
||||
version "2.1.16"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz#5ee045c62bad8122347981c479d92b1ff24c7254"
|
||||
integrity sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-id" "1.1.1"
|
||||
"@radix-ui/react-menu" "2.1.16"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
|
||||
"@radix-ui/react-focus-guards@1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.0.tgz#339c1c69c41628c1a5e655f15f7020bf11aa01fa"
|
||||
@@ -4565,6 +4578,30 @@
|
||||
dependencies:
|
||||
"@radix-ui/react-use-layout-effect" "1.1.1"
|
||||
|
||||
"@radix-ui/react-menu@2.1.16":
|
||||
version "2.1.16"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.1.16.tgz#528a5a973c3a7413d3d49eb9ccd229aa52402911"
|
||||
integrity sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-collection" "1.1.7"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-direction" "1.1.1"
|
||||
"@radix-ui/react-dismissable-layer" "1.1.11"
|
||||
"@radix-ui/react-focus-guards" "1.1.3"
|
||||
"@radix-ui/react-focus-scope" "1.1.7"
|
||||
"@radix-ui/react-id" "1.1.1"
|
||||
"@radix-ui/react-popper" "1.2.8"
|
||||
"@radix-ui/react-portal" "1.1.9"
|
||||
"@radix-ui/react-presence" "1.1.5"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-roving-focus" "1.1.11"
|
||||
"@radix-ui/react-slot" "1.2.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.1.1"
|
||||
aria-hidden "^1.2.4"
|
||||
react-remove-scroll "^2.6.3"
|
||||
|
||||
"@radix-ui/react-popover@^1.1.15", "@radix-ui/react-popover@^1.1.2":
|
||||
version "1.1.15"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.15.tgz#9c852f93990a687ebdc949b2c3de1f37cdc4c5d5"
|
||||
@@ -4804,6 +4841,20 @@
|
||||
"@radix-ui/react-roving-focus" "1.0.4"
|
||||
"@radix-ui/react-use-controllable-state" "1.0.1"
|
||||
|
||||
"@radix-ui/react-tabs@^1.1.3":
|
||||
version "1.1.13"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz#3537ce379d7e7ff4eeb6b67a0973e139c2ac1f15"
|
||||
integrity sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-direction" "1.1.1"
|
||||
"@radix-ui/react-id" "1.1.1"
|
||||
"@radix-ui/react-presence" "1.1.5"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-roving-focus" "1.1.11"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
|
||||
"@radix-ui/react-toggle-group@^1.1.7":
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz#e513d6ffdb07509b400ab5b26f2523747c0d51c1"
|
||||
@@ -5675,6 +5726,42 @@
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/ui@0.0.5":
|
||||
version "0.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.5.tgz#8badef53416b7ace0fe61ff01ff3da679a0e4ba5"
|
||||
integrity sha512-4vPvUh3rwpst068qXUZ26JfCQGv1vo1xMSwtKw6wTjiiq1Bf3geP84HWVXycNMIrIeVnUgDGnqe0D4doh+mL8A==
|
||||
dependencies:
|
||||
"@radix-ui/react-checkbox" "^1.2.3"
|
||||
"@radix-ui/react-dialog" "^1.1.11"
|
||||
"@radix-ui/react-dropdown-menu" "^2.1.16"
|
||||
"@radix-ui/react-icons" "^1.3.0"
|
||||
"@radix-ui/react-popover" "^1.1.15"
|
||||
"@radix-ui/react-radio-group" "^1.3.4"
|
||||
"@radix-ui/react-slot" "^1.2.3"
|
||||
"@radix-ui/react-switch" "^1.1.4"
|
||||
"@radix-ui/react-tabs" "^1.1.3"
|
||||
"@radix-ui/react-toggle" "^1.1.6"
|
||||
"@radix-ui/react-toggle-group" "^1.1.7"
|
||||
"@radix-ui/react-tooltip" "^1.2.6"
|
||||
"@tanstack/react-table" "^8.21.3"
|
||||
"@tanstack/react-virtual" "^3.13.9"
|
||||
"@types/lodash-es" "^4.17.12"
|
||||
class-variance-authority "^0.7.0"
|
||||
clsx "^2.1.1"
|
||||
cmdk "^1.1.1"
|
||||
date-fns "^4.1.0"
|
||||
dayjs "^1.11.10"
|
||||
lodash-es "^4.17.21"
|
||||
lucide-react "^0.445.0"
|
||||
lucide-solid "^0.510.0"
|
||||
motion "^11.11.17"
|
||||
next-themes "^0.4.6"
|
||||
nuqs "^2.8.9"
|
||||
react-day-picker "^9.8.1"
|
||||
react-resizable-panels "^4.7.1"
|
||||
sonner "^2.0.7"
|
||||
tailwind-merge "^3.5.0"
|
||||
|
||||
"@sinclair/typebox@^0.25.16":
|
||||
version "0.25.24"
|
||||
resolved "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz"
|
||||
@@ -9573,6 +9660,11 @@ dayjs@^1.10.7, dayjs@^1.11.1:
|
||||
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz"
|
||||
integrity sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==
|
||||
|
||||
dayjs@^1.11.10:
|
||||
version "1.11.20"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.20.tgz#88d919fd639dc991415da5f4cb6f1b6650811938"
|
||||
integrity sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==
|
||||
|
||||
debounce@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
|
||||
@@ -11092,6 +11184,15 @@ fraction.js@^4.3.7:
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
||||
|
||||
framer-motion@^11.18.2:
|
||||
version "11.18.2"
|
||||
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-11.18.2.tgz#0c6bd05677f4cfd3b3bdead4eb5ecdd5ed245718"
|
||||
integrity sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==
|
||||
dependencies:
|
||||
motion-dom "^11.18.1"
|
||||
motion-utils "^11.18.1"
|
||||
tslib "^2.4.0"
|
||||
|
||||
framer-motion@^12.4.13:
|
||||
version "12.4.13"
|
||||
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.4.13.tgz#1efd954f95e6a54685b660929c00f5a61e35256a"
|
||||
@@ -15002,6 +15103,13 @@ moment@^2.29.4:
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
|
||||
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
|
||||
|
||||
motion-dom@^11.18.1:
|
||||
version "11.18.1"
|
||||
resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-11.18.1.tgz#e7fed7b7dc6ae1223ef1cce29ee54bec826dc3f2"
|
||||
integrity sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==
|
||||
dependencies:
|
||||
motion-utils "^11.18.1"
|
||||
|
||||
motion-dom@^12.4.11:
|
||||
version "12.4.11"
|
||||
resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.4.11.tgz#0419c8686cda4d523f08249deeb8fa6683a9b9d3"
|
||||
@@ -15009,6 +15117,11 @@ motion-dom@^12.4.11:
|
||||
dependencies:
|
||||
motion-utils "^12.4.10"
|
||||
|
||||
motion-utils@^11.18.1:
|
||||
version "11.18.1"
|
||||
resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-11.18.1.tgz#671227669833e991c55813cf337899f41327db5b"
|
||||
integrity sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==
|
||||
|
||||
motion-utils@^12.4.10:
|
||||
version "12.4.10"
|
||||
resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.4.10.tgz#3d93acea5454419eaaad8d5e5425cb71cbfa1e7f"
|
||||
@@ -15022,6 +15135,14 @@ motion@12.4.13:
|
||||
framer-motion "^12.4.13"
|
||||
tslib "^2.4.0"
|
||||
|
||||
motion@^11.11.17:
|
||||
version "11.18.2"
|
||||
resolved "https://registry.yarnpkg.com/motion/-/motion-11.18.2.tgz#17fb372f3ed94fc9ee1384a25a9068e9da1951e7"
|
||||
integrity sha512-JLjvFDuFr42NFtcVoMAyC2sEjnpA8xpy6qWPyzQvCloznAyQ8FIXioxWfHiLtgYhoVpfUqSWpn1h9++skj9+Wg==
|
||||
dependencies:
|
||||
framer-motion "^11.18.2"
|
||||
tslib "^2.4.0"
|
||||
|
||||
mri@^1.1.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
|
||||
@@ -15292,6 +15413,13 @@ nuqs@2.8.8:
|
||||
dependencies:
|
||||
"@standard-schema/spec" "1.0.0"
|
||||
|
||||
nuqs@^2.8.9:
|
||||
version "2.8.9"
|
||||
resolved "https://registry.yarnpkg.com/nuqs/-/nuqs-2.8.9.tgz#e2c27d87c0dd0e3b4412fe867bcd0947cc4c998f"
|
||||
integrity sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==
|
||||
dependencies:
|
||||
"@standard-schema/spec" "1.0.0"
|
||||
|
||||
nwsapi@^2.2.2:
|
||||
version "2.2.23"
|
||||
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.23.tgz#59712c3a88e6de2bb0b6ccc1070397267019cf6c"
|
||||
@@ -16824,11 +16952,6 @@ react-helmet-async@*, react-helmet-async@1.3.0:
|
||||
react-fast-compare "^3.2.0"
|
||||
shallowequal "^1.1.0"
|
||||
|
||||
react-hook-form@7.71.2:
|
||||
version "7.71.2"
|
||||
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.71.2.tgz#a5f1d2b855be9ecf1af6e74df9b80f54beae7e35"
|
||||
integrity sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==
|
||||
|
||||
react-hooks-testing-library@0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.npmjs.org/react-hooks-testing-library/-/react-hooks-testing-library-0.6.0.tgz"
|
||||
@@ -16962,6 +17085,11 @@ react-resizable-panels@^3.0.5:
|
||||
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-3.0.5.tgz#50a20645263eed02344de4a70d1319bbc0014bbd"
|
||||
integrity sha512-3z1yN25DMTXLg2wfyFrW32r5k4WEcUa3F7cJ2EgtNK07lnOs4mpM8yWLGunCpkhcQRwJX4fqoLcIh/pHPxzlmQ==
|
||||
|
||||
react-resizable-panels@^4.7.1:
|
||||
version "4.7.3"
|
||||
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-4.7.3.tgz#4040aa0f5c5c4cc4bb685cb69973601ccda3b014"
|
||||
integrity sha512-PYcYMLtvJD+Pr0TQNeMvddcnLOwUa/Yb4iNwU7ThNLlHaQYEEC9MIBWHaBGODzYuXIkPRZ/OWe5sbzG1Rzq5ew==
|
||||
|
||||
react-resizable@3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.4.tgz"
|
||||
@@ -18802,6 +18930,11 @@ tailwind-merge@^2.5.2:
|
||||
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5"
|
||||
integrity sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==
|
||||
|
||||
tailwind-merge@^3.5.0:
|
||||
version "3.5.0"
|
||||
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz#06502f4496ba15151445d97d916a26564d50d1ca"
|
||||
integrity sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==
|
||||
|
||||
tailwindcss-animate@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4"
|
||||
|
||||
2
go.mod
2
go.mod
@@ -11,7 +11,6 @@ require (
|
||||
github.com/SigNoz/signoz-otel-collector v0.144.2
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1
|
||||
github.com/antonmedv/expr v1.15.3
|
||||
github.com/bytedance/sonic v1.14.1
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/dgraph-io/ristretto/v2 v2.3.0
|
||||
@@ -106,6 +105,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
|
||||
@@ -43,74 +43,6 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/invite/{token}", handler.New(provider.authZ.OpenAccess(provider.userHandler.GetInvite), handler.OpenAPIDef{
|
||||
ID: "GetInvite",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get invite",
|
||||
Description: "This endpoint gets an invite by token",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(types.Invite),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/invite/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.DeleteInvite), handler.OpenAPIDef{
|
||||
ID: "DeleteInvite",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Delete invite",
|
||||
Description: "This endpoint deletes an invite by id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/invite", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListInvite), handler.OpenAPIDef{
|
||||
ID: "ListInvite",
|
||||
Tags: []string{"users"},
|
||||
Summary: "List invites",
|
||||
Description: "This endpoint lists all invites",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*types.Invite, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/invite/accept", handler.New(provider.authZ.OpenAccess(provider.userHandler.AcceptInvite), handler.OpenAPIDef{
|
||||
ID: "AcceptInvite",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Accept invite",
|
||||
Description: "This endpoint accepts an invite by token",
|
||||
Request: new(types.PostableAcceptInvite),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.User),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: []handler.OpenAPISecurityScheme{},
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.CreateAPIKey), handler.OpenAPIDef{
|
||||
ID: "CreateAPIKey",
|
||||
Tags: []string{"users"},
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -41,9 +40,7 @@ func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
commentCtx := ctxtypes.CommentFromContext(ctx)
|
||||
authtype, ok := commentCtx.Map()["auth_type"]
|
||||
if ok && (authtype == authtypes.IdentNProviderAPIkey.StringValue()) {
|
||||
if claims.IdentNProvider == authtypes.IdentNProviderAPIkey.StringValue() {
|
||||
if err := claims.IsViewer(); err != nil {
|
||||
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
|
||||
render.Error(rw, err)
|
||||
@@ -93,9 +90,7 @@ func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
commentCtx := ctxtypes.CommentFromContext(ctx)
|
||||
authtype, ok := commentCtx.Map()["auth_type"]
|
||||
if ok && (authtype == authtypes.IdentNProviderAPIkey.StringValue()) {
|
||||
if claims.IdentNProvider == authtypes.IdentNProviderAPIkey.StringValue() {
|
||||
if err := claims.IsEditor(); err != nil {
|
||||
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
|
||||
render.Error(rw, err)
|
||||
@@ -144,9 +139,7 @@ func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
commentCtx := ctxtypes.CommentFromContext(ctx)
|
||||
authtype, ok := commentCtx.Map()["auth_type"]
|
||||
if ok && (authtype == authtypes.IdentNProviderAPIkey.StringValue()) {
|
||||
if claims.IdentNProvider == authtypes.IdentNProviderAPIkey.StringValue() {
|
||||
if err := claims.IsAdmin(); err != nil {
|
||||
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
|
||||
render.Error(rw, err)
|
||||
|
||||
@@ -101,13 +101,8 @@ func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, e
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identity := authtypes.Identity{
|
||||
UserID: user.ID,
|
||||
Role: apiKey.Role,
|
||||
Email: user.Email,
|
||||
OrgID: user.OrgID,
|
||||
}
|
||||
return &identity, nil
|
||||
identity := authtypes.NewIdentity(user.ID, user.OrgID, user.Email, apiKey.Role, provider.Name())
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Post(ctx context.Context, _ *http.Request, _ authtypes.Claims) {
|
||||
|
||||
65
pkg/modules/cloudintegration/cloudintegration.go
Normal file
65
pkg/modules/cloudintegration/cloudintegration.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package cloudintegration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
CreateAccount(ctx context.Context, account *citypes.Account) error
|
||||
|
||||
// GetAccount returns cloud integration account
|
||||
GetAccount(ctx context.Context, orgID, accountID valuer.UUID) (*citypes.Account, error)
|
||||
|
||||
// ListAccounts lists accounts where agent is connected
|
||||
ListAccounts(ctx context.Context, orgID valuer.UUID) ([]*citypes.Account, error)
|
||||
|
||||
// UpdateAccount updates the cloud integration account for a specific organization.
|
||||
UpdateAccount(ctx context.Context, account *citypes.Account) error
|
||||
|
||||
// DisconnectAccount soft deletes/removes a cloud integration account.
|
||||
DisconnectAccount(ctx context.Context, orgID, accountID valuer.UUID) error
|
||||
|
||||
// GetConnectionArtifact returns cloud provider specific connection information,
|
||||
// client side handles how this information is shown
|
||||
GetConnectionArtifact(ctx context.Context, account *citypes.Account, req *citypes.ConnectionArtifactRequest) (*citypes.ConnectionArtifact, error)
|
||||
|
||||
// ListServicesMetadata returns the list of services metadata for a cloud provider attached with the integrationID.
|
||||
// This just returns a summary of the service and not the whole service definition
|
||||
ListServicesMetadata(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID) ([]*citypes.ServiceMetadata, error)
|
||||
|
||||
// GetService returns service definition details for a serviceID. This returns config and
|
||||
// other details required to show in service details page on web client.
|
||||
GetService(ctx context.Context, orgID valuer.UUID, integrationID *valuer.UUID, serviceID string) (*citypes.Service, error)
|
||||
|
||||
// UpdateService updates cloud integration service
|
||||
UpdateService(ctx context.Context, orgID valuer.UUID, service *citypes.CloudIntegrationService) error
|
||||
|
||||
// AgentCheckIn is called by agent to heartbeat and get latest config in response.
|
||||
AgentCheckIn(ctx context.Context, orgID valuer.UUID, req *citypes.AgentCheckInRequest) (*citypes.AgentCheckInResponse, error)
|
||||
|
||||
// GetDashboardByID returns dashboard JSON for a given dashboard id.
|
||||
// this only returns the dashboard when the service (embedded in dashboard id) is enabled
|
||||
// in the org for any cloud integration account
|
||||
GetDashboardByID(ctx context.Context, orgID valuer.UUID, id string) (*dashboardtypes.Dashboard, error)
|
||||
|
||||
// ListDashboards returns list of dashboards across all connected cloud integration accounts
|
||||
// for enabled services in the org. This list gets added to dashboard list page
|
||||
ListDashboards(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
GetConnectionArtifact(http.ResponseWriter, *http.Request)
|
||||
ListAccounts(http.ResponseWriter, *http.Request)
|
||||
GetAccount(http.ResponseWriter, *http.Request)
|
||||
UpdateAccount(http.ResponseWriter, *http.Request)
|
||||
DisconnectAccount(http.ResponseWriter, *http.Request)
|
||||
ListServicesMetadata(http.ResponseWriter, *http.Request)
|
||||
GetService(http.ResponseWriter, *http.Request)
|
||||
UpdateService(http.ResponseWriter, *http.Request)
|
||||
AgentCheckIn(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func (m *module) ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetype
|
||||
|
||||
// add the paths that are not promoted but have indexes
|
||||
for path, indexes := range aggr {
|
||||
path := strings.TrimPrefix(path, telemetrylogs.BodyJSONColumnPrefix)
|
||||
path := strings.TrimPrefix(path, telemetrylogs.BodyV2ColumnPrefix)
|
||||
path = telemetrytypes.BodyJSONStringSearchPrefix + path
|
||||
response = append(response, promotetypes.PromotePath{
|
||||
Path: path,
|
||||
@@ -163,7 +163,7 @@ func (m *module) PromoteAndIndexPaths(
|
||||
}
|
||||
}
|
||||
if len(it.Indexes) > 0 {
|
||||
parentColumn := telemetrylogs.LogsV2BodyJSONColumn
|
||||
parentColumn := telemetrylogs.LogsV2BodyV2Column
|
||||
// if the path is already promoted or is being promoted, add it to the promoted column
|
||||
if _, promoted := existingPromotedPaths[it.Path]; promoted || it.Promote {
|
||||
parentColumn = telemetrylogs.LogsV2BodyPromotedColumn
|
||||
|
||||
@@ -27,7 +27,12 @@ type OrgConfig struct {
|
||||
}
|
||||
|
||||
type PasswordConfig struct {
|
||||
Reset ResetConfig `mapstructure:"reset"`
|
||||
Invite InviteConfig `mapstructure:"invite"`
|
||||
Reset ResetConfig `mapstructure:"reset"`
|
||||
}
|
||||
|
||||
type InviteConfig struct {
|
||||
MaxTokenLifetime time.Duration `mapstructure:"max_token_lifetime"`
|
||||
}
|
||||
|
||||
type ResetConfig struct {
|
||||
@@ -46,6 +51,9 @@ func newConfig() factory.Config {
|
||||
AllowSelf: false,
|
||||
MaxTokenLifetime: 6 * time.Hour,
|
||||
},
|
||||
Invite: InviteConfig{
|
||||
MaxTokenLifetime: 48 * time.Hour,
|
||||
},
|
||||
},
|
||||
Root: RootConfig{
|
||||
Enabled: false,
|
||||
@@ -61,6 +69,10 @@ func (c Config) Validate() error {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::password::reset::max_token_lifetime must be positive")
|
||||
}
|
||||
|
||||
if c.Password.Invite.MaxTokenLifetime <= 0 {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::password::invite::max_token_lifetime must be positive")
|
||||
}
|
||||
|
||||
if c.Root.Enabled {
|
||||
if c.Root.Email.IsZero() {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "user::root::email is required when root user is enabled")
|
||||
|
||||
@@ -27,25 +27,6 @@ func NewHandler(module root.Module, getter root.Getter) root.Handler {
|
||||
return &handler{module: module, getter: getter}
|
||||
}
|
||||
|
||||
func (h *handler) AcceptInvite(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req := new(types.PostableAcceptInvite)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.module.AcceptInvite(ctx, req.InviteToken, req.Password)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func (h *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -104,59 +85,6 @@ func (h *handler) CreateBulkInvite(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusCreated, nil)
|
||||
}
|
||||
|
||||
func (h *handler) GetInvite(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
token := mux.Vars(r)["token"]
|
||||
invite, err := h.module.GetInviteByToken(ctx, token)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, invite)
|
||||
}
|
||||
|
||||
func (h *handler) ListInvite(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
invites, err := h.module.ListInvite(ctx, claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, invites)
|
||||
}
|
||||
|
||||
func (h *handler) DeleteInvite(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
id := mux.Vars(r)["id"]
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.module.DeleteUser(ctx, valuer.MustNewUUID(claims.OrgID), id, claims.UserID); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (h *handler) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -213,9 +141,6 @@ func (h *handler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// temp code - show only active users
|
||||
users = slices.DeleteFunc(users, func(user *types.User) bool { return user.Status != types.UserStatusActive })
|
||||
|
||||
render.Success(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,54 +49,6 @@ func NewModule(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) AcceptInvite(ctx context.Context, token string, password string) (*types.User, error) {
|
||||
// get the user by reset password token
|
||||
user, err := m.store.GetUserByResetPasswordToken(ctx, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// update the password and delete the token
|
||||
err = m.UpdatePasswordByResetPasswordToken(ctx, token, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// query the user again
|
||||
user, err = m.store.GetByOrgIDAndID(ctx, user.OrgID, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m *Module) GetInviteByToken(ctx context.Context, token string) (*types.Invite, error) {
|
||||
// get the user
|
||||
user, err := m.store.GetUserByResetPasswordToken(ctx, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// create a dummy invite obj for backward compatibility
|
||||
invite := &types.Invite{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: user.ID,
|
||||
},
|
||||
Name: user.DisplayName,
|
||||
Email: user.Email,
|
||||
Token: token,
|
||||
Role: user.Role,
|
||||
OrgID: user.OrgID,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
},
|
||||
}
|
||||
|
||||
return invite, nil
|
||||
}
|
||||
|
||||
// CreateBulk implements invite.Module.
|
||||
func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error) {
|
||||
creator, err := m.store.GetUser(ctx, userID)
|
||||
@@ -203,7 +155,7 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
|
||||
|
||||
resetLink := userWithToken.ResetPasswordToken.FactorPasswordResetLink(frontendBaseUrl)
|
||||
|
||||
tokenLifetime := m.config.Password.Reset.MaxTokenLifetime
|
||||
tokenLifetime := m.config.Password.Invite.MaxTokenLifetime
|
||||
humanizedTokenLifetime := strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(tokenLifetime), "", ""))
|
||||
|
||||
if err := m.emailing.SendHTML(ctx, userWithToken.User.Email.String(), "You're Invited to Join SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
|
||||
@@ -218,46 +170,6 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
|
||||
return invites, nil
|
||||
}
|
||||
|
||||
func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) {
|
||||
// find all the users with pending_invite status
|
||||
users, err := m.store.ListUsersByOrgID(ctx, valuer.MustNewUUID(orgID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pendingUsers := slices.DeleteFunc(users, func(user *types.User) bool { return user.Status != types.UserStatusPendingInvite })
|
||||
|
||||
var invites []*types.Invite
|
||||
|
||||
for _, pUser := range pendingUsers {
|
||||
// get the reset password token
|
||||
resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, pUser.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// create a dummy invite obj for backward compatibility
|
||||
invite := &types.Invite{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: pUser.ID,
|
||||
},
|
||||
Name: pUser.DisplayName,
|
||||
Email: pUser.Email,
|
||||
Token: resetPasswordToken.Token,
|
||||
Role: pUser.Role,
|
||||
OrgID: pUser.OrgID,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: pUser.CreatedAt,
|
||||
UpdatedAt: pUser.UpdatedAt, // dummy
|
||||
},
|
||||
}
|
||||
|
||||
invites = append(invites, invite)
|
||||
}
|
||||
|
||||
return invites, nil
|
||||
}
|
||||
|
||||
func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
|
||||
createUserOpts := root.NewCreateUserOptions(opts...)
|
||||
|
||||
@@ -304,10 +216,6 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
|
||||
return nil, errors.WithAdditionalf(err, "cannot update deleted user")
|
||||
}
|
||||
|
||||
if err := existingUser.ErrIfPending(); err != nil {
|
||||
return nil, errors.WithAdditionalf(err, "cannot update pending user")
|
||||
}
|
||||
|
||||
requestor, err := m.store.GetUser(ctx, valuer.MustNewUUID(updatedBy))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -460,7 +368,11 @@ func (module *Module) GetOrCreateResetPasswordToken(ctx context.Context, userID
|
||||
}
|
||||
|
||||
// create a new token
|
||||
resetPasswordToken, err := types.NewResetPasswordToken(password.ID, time.Now().Add(module.config.Password.Reset.MaxTokenLifetime))
|
||||
tokenLifetime := module.config.Password.Reset.MaxTokenLifetime
|
||||
if user.Status == types.UserStatusPendingInvite {
|
||||
tokenLifetime = module.config.Password.Invite.MaxTokenLifetime
|
||||
}
|
||||
resetPasswordToken, err := types.NewResetPasswordToken(password.ID, time.Now().Add(tokenLifetime))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -500,6 +412,9 @@ func (module *Module) ForgotPassword(ctx context.Context, orgID valuer.UUID, ema
|
||||
resetLink := token.FactorPasswordResetLink(frontendBaseURL)
|
||||
|
||||
tokenLifetime := module.config.Password.Reset.MaxTokenLifetime
|
||||
if user.Status == types.UserStatusPendingInvite {
|
||||
tokenLifetime = module.config.Password.Invite.MaxTokenLifetime
|
||||
}
|
||||
humanizedTokenLifetime := strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(tokenLifetime), "", ""))
|
||||
|
||||
if err := module.emailing.SendHTML(
|
||||
|
||||
@@ -41,9 +41,6 @@ type Module interface {
|
||||
|
||||
// invite
|
||||
CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error)
|
||||
ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error)
|
||||
AcceptInvite(ctx context.Context, token string, password string) (*types.User, error)
|
||||
GetInviteByToken(ctx context.Context, token string) (*types.Invite, error)
|
||||
|
||||
// API KEY
|
||||
CreateAPIKey(ctx context.Context, apiKey *types.StorableAPIKey) error
|
||||
@@ -89,10 +86,6 @@ type Getter interface {
|
||||
type Handler interface {
|
||||
// invite
|
||||
CreateInvite(http.ResponseWriter, *http.Request)
|
||||
AcceptInvite(http.ResponseWriter, *http.Request)
|
||||
GetInvite(http.ResponseWriter, *http.Request) // public function
|
||||
ListInvite(http.ResponseWriter, *http.Request)
|
||||
DeleteInvite(http.ResponseWriter, *http.Request)
|
||||
CreateBulkInvite(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListUsers(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -10,13 +10,11 @@ import (
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrylogs"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
type builderQuery[T any] struct {
|
||||
@@ -262,40 +260,6 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// merge body_json and promoted into body
|
||||
if q.spec.Signal == telemetrytypes.SignalLogs {
|
||||
switch typedPayload := payload.(type) {
|
||||
case *qbtypes.RawData:
|
||||
for _, rr := range typedPayload.Rows {
|
||||
seeder := func() error {
|
||||
body, ok := rr.Data[telemetrylogs.LogsV2BodyJSONColumn].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
promoted, ok := rr.Data[telemetrylogs.LogsV2BodyPromotedColumn].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
seed(promoted, body)
|
||||
str, err := sonic.MarshalString(body)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal body")
|
||||
}
|
||||
rr.Data["body"] = str
|
||||
return nil
|
||||
}
|
||||
err := seeder()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
delete(rr.Data, telemetrylogs.LogsV2BodyJSONColumn)
|
||||
delete(rr.Data, telemetrylogs.LogsV2BodyPromotedColumn)
|
||||
}
|
||||
payload = typedPayload
|
||||
}
|
||||
}
|
||||
|
||||
return &qbtypes.Result{
|
||||
Type: q.kind,
|
||||
Value: payload,
|
||||
@@ -423,18 +387,3 @@ func decodeCursor(cur string) (int64, error) {
|
||||
}
|
||||
return strconv.ParseInt(string(b), 10, 64)
|
||||
}
|
||||
|
||||
func seed(promoted map[string]any, body map[string]any) {
|
||||
for key, fromValue := range promoted {
|
||||
if toValue, ok := body[key]; !ok {
|
||||
body[key] = fromValue
|
||||
} else {
|
||||
if fromValue, ok := fromValue.(map[string]any); ok {
|
||||
if toValue, ok := toValue.(map[string]any); ok {
|
||||
seed(fromValue, toValue)
|
||||
body[key] = toValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user