Compare commits

..

28 Commits

Author SHA1 Message Date
swapnil-signoz
bf315253ae fix: lint issues 2026-03-19 11:43:09 +05:30
swapnil-signoz
668ff7bc39 fix: lint and ci issues 2026-03-19 11:34:27 +05:30
swapnil-signoz
07f2aa52fd feat: adding handlers 2026-03-19 01:35:01 +05:30
swapnil-signoz
3416b3ad55 Merge branch 'main' into refactor/cloud-integration-handlers 2026-03-18 21:50:40 +05:30
swapnil-signoz
794a7f4ca6 fix: adding migration to fix wrong index on cloud integration table (#10607)
* fix: adding migration for fixing wrong cloud integration unique index

* refactor: removing std errors pkg

* refactor: normalising account_id if empty

* feat: adding integration test
2026-03-18 16:01:55 +00:00
aniketio-ctrl
fd3b1c5374 fix(checkout): pass downstream error meesage to UI (#10636)
* fix(checkout): pass downstream error meesage to UI

* fix(checkout): pass downstream error meesage to UI

* fix(checkout): pass downstream error meesage to UI

* fix(checkout): pass downstream error meesage to UI

* fix(checkout): pass downstream error meesage to UI
2026-03-18 15:28:01 +00:00
swapnil-signoz
a21fbb4ee0 refactor: clean up 2026-03-18 11:14:05 +05:30
swapnil-signoz
cc4475cab7 refactor: updating store methods 2026-03-17 23:10:15 +05:30
swapnil-signoz
bede6be4b8 feat: adding method for service id creation 2026-03-17 21:09:26 +05:30
swapnil-signoz
538ab686d2 refactor: using serviceID type 2026-03-17 20:49:17 +05:30
swapnil-signoz
c6cdcd0143 refactor: renaming service type to service id 2026-03-17 17:25:29 +05:30
swapnil-signoz
cd9211d718 refactor: clean up types 2026-03-17 17:04:27 +05:30
swapnil-signoz
3651469416 Merge branch 'main' of https://github.com/SigNoz/signoz into refactor/cloud-integration-types 2026-03-16 17:41:52 +05:30
swapnil-signoz
febce75734 refactor: update Dashboard struct comments and remove unused fields 2026-03-16 17:41:28 +05:30
swapnil-signoz
4b94287ac7 refactor: add comments for backward compatibility in PostableAgentCheckInRequest 2026-03-16 15:48:20 +05:30
swapnil-signoz
1575c7c54c refactor: streamlining types 2026-03-16 15:39:32 +05:30
swapnil-signoz
8def3f835b refactor: adding comments and removed wrong code 2026-03-16 11:10:53 +05:30
swapnil-signoz
bb2b9215ba fix: correct GetService signature and remove shadowed Data field 2026-03-14 16:59:07 +05:30
swapnil-signoz
003e2c30d8 Merge branch 'main' into refactor/cloud-integration-types 2026-03-14 16:25:35 +05:30
swapnil-signoz
00fe516d10 refactor: update cloud integration types and module interface 2026-03-14 16:25:16 +05:30
swapnil-signoz
0305f4f7db refactor: using struct for map 2026-03-13 16:09:26 +05:30
swapnil-signoz
c60019a6dc Merge branch 'main' into refactor/cloud-integration-types 2026-03-12 23:41:22 +05:30
swapnil-signoz
acde2a37fa feat: adding updated types for cloud integration 2026-03-12 23:40:44 +05:30
swapnil-signoz
945241a52a Merge branch 'main' into refactor/cloud-integration-types 2026-03-12 19:45:50 +05:30
swapnil-signoz
e967f80c86 Merge branch 'main' into refactor/cloud-integration-types 2026-03-02 16:39:42 +05:30
swapnil-signoz
234585e642 Merge branch 'main' into refactor/cloud-integration-types 2026-03-02 14:49:19 +05:30
swapnil-signoz
7281c36873 refactor: store interfaces to use local types and error 2026-03-02 13:27:46 +05:30
swapnil-signoz
40288776e8 feat: adding cloud integration type for refactor 2026-02-28 16:59:14 +05:30
46 changed files with 2484 additions and 443 deletions

View File

@@ -2101,6 +2101,17 @@ 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:
@@ -3279,6 +3290,53 @@ 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
@@ -3341,6 +3399,151 @@ 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

View File

@@ -198,7 +198,10 @@ func (provider *provider) Checkout(ctx context.Context, organizationID valuer.UU
response, err := provider.zeus.GetCheckoutURL(ctx, activeLicense.Key, body)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to generate checkout session")
if errors.Ast(err, errors.TypeAlreadyExists) {
return nil, errors.WithAdditionalf(err, "checkout has already been completed for this account. Please click 'Refresh Status' to sync your subscription")
}
return nil, err
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
@@ -217,7 +220,7 @@ func (provider *provider) Portal(ctx context.Context, organizationID valuer.UUID
response, err := provider.zeus.GetPortalURL(ctx, activeLicense.Key, body)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to generate portal session")
return nil, err
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil

View File

@@ -2511,6 +2511,25 @@ 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
@@ -3014,6 +3033,17 @@ export type GetGlobalConfig200 = {
status: string;
};
export type ListInvite200 = {
/**
* @type array
*/
data: TypesInviteDTO[];
/**
* @type string
*/
status: string;
};
export type CreateInvite201 = {
data: TypesInviteDTO;
/**
@@ -3022,6 +3052,28 @@ 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

View File

@@ -20,20 +20,26 @@ 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,
@@ -249,6 +255,84 @@ 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
@@ -332,6 +416,257 @@ 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

View File

@@ -0,0 +1,19 @@
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;

View File

@@ -0,0 +1,22 @@
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;

View File

@@ -0,0 +1,20 @@
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;

View File

@@ -0,0 +1,28 @@
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;

View File

@@ -7,6 +7,7 @@ import {
Check,
ChevronDown,
Copy,
Link,
LockKeyhole,
RefreshCw,
Trash2,
@@ -16,11 +17,14 @@ 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 { MemberStatus } from 'container/MembersSettings/utils';
import ROUTES from 'constants/routes';
import { INVITE_PREFIX, MemberStatus } from 'container/MembersSettings/utils';
import { capitalize } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { ROLES } from 'types/roles';
@@ -32,6 +36,7 @@ export interface EditMemberDrawerProps {
open: boolean;
onClose: () => void;
onComplete: () => void;
onRefetch?: () => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
@@ -40,6 +45,7 @@ function EditMemberDrawer({
open,
onClose,
onComplete,
onRefetch,
}: EditMemberDrawerProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
@@ -52,9 +58,11 @@ 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) {
@@ -65,7 +73,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 => {
@@ -81,22 +89,80 @@ 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 {
await update({ userId: member.id, displayName, role: selectedRole });
toast.success('Member details updated successfully', { richColors: true });
onComplete();
onClose();
if (isInvited && inviteId) {
await saveInvitedMember();
} else {
await saveActiveMember();
}
} catch {
toast.error('Failed to update member details', { richColors: true });
toast.error(
isInvited ? 'Failed to update invite' : 'Failed to update member details',
{ richColors: true },
);
} finally {
setIsSaving(false);
}
}, [member, isDirty, displayName, selectedRole, onComplete, onClose]);
}, [
member,
isDirty,
isInvited,
inviteId,
saveInvitedMember,
saveActiveMember,
]);
const handleDelete = useCallback(async (): Promise<void> => {
if (!member) {
@@ -104,23 +170,25 @@ function EditMemberDrawer({
}
setIsDeleting(true);
try {
await deleteUser({ userId: member.id });
toast.success(
isInvited ? 'Invite revoked successfully' : 'Member deleted successfully',
{ richColors: true },
);
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 });
}
setShowDeleteConfirm(false);
onComplete();
onClose();
} catch {
toast.error(
isInvited ? 'Failed to revoke invite' : 'Failed to delete member',
isInvited ? 'Failed to cancel invitation' : 'Failed to delete member',
{ richColors: true },
);
} finally {
setIsDeleting(false);
}
}, [member, isInvited, onComplete, onClose]);
}, [member, isInvited, inviteId, onComplete, onClose]);
const handleGenerateResetLink = useCallback(async (): Promise<void> => {
if (!member) {
@@ -133,7 +201,6 @@ 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 {
@@ -150,7 +217,7 @@ function EditMemberDrawer({
} finally {
setIsGeneratingLink(false);
}
}, [member, isInvited, setLinkType, onClose]);
}, [member, onClose]);
const handleCopyResetLink = useCallback(async (): Promise<void> => {
if (!resetLink) {
@@ -160,18 +227,36 @@ function EditMemberDrawer({
await navigator.clipboard.writeText(resetLink);
setHasCopiedResetLink(true);
setTimeout(() => setHasCopiedResetLink(false), 2000);
toast.success(
linkType === 'invite'
? 'Invite link copied to clipboard'
: 'Reset link copied to clipboard',
{ richColors: true },
);
toast.success('Reset link copied to clipboard', { richColors: true });
} catch {
toast.error('Failed to copy link', {
richColors: true,
});
}
}, [resetLink, linkType]);
}, [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]);
const handleClose = useCallback((): void => {
setShowDeleteConfirm(false);
@@ -263,22 +348,30 @@ function EditMemberDrawer({
onClick={(): void => setShowDeleteConfirm(true)}
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
{isInvited ? 'Cancel Invite' : 'Delete Member'}
</Button>
<div className="edit-member-drawer__footer-divider" />
<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>
{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>
)}
</div>
<div className="edit-member-drawer__footer-right">
@@ -301,21 +394,21 @@ function EditMemberDrawer({
</div>
);
const deleteDialogTitle = isInvited ? 'Revoke Invite' : 'Delete Member';
const deleteDialogTitle = isInvited ? 'Cancel Invitation' : 'Delete Member';
const deleteDialogBody = isInvited ? (
<>
Are you sure you want to revoke the invite for{' '}
Are you sure you want to cancel the invitation 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 remove their
access to the workspace.
<strong>{member?.name || member?.email}</strong>? This will permanently
remove their access to the workspace.
</>
);
const deleteConfirmLabel = isInvited ? 'Revoke Invite' : 'Delete Member';
const deleteConfirmLabel = isInvited ? 'Cancel Invite' : 'Delete Member';
return (
<>
@@ -341,19 +434,17 @@ function EditMemberDrawer({
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowResetLinkDialog(false);
setLinkType(null);
}
}}
title={linkType === 'invite' ? 'Invite Link' : 'Password Reset Link'}
title="Password Reset Link"
showCloseButton
width="base"
className="reset-link-dialog"
>
<div className="reset-link-dialog__content">
<p className="reset-link-dialog__description">
{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.'}
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">

View File

@@ -1,6 +1,7 @@
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';
@@ -47,6 +48,8 @@ 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: {
@@ -57,6 +60,7 @@ 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 = {
@@ -70,12 +74,13 @@ const activeMember = {
};
const invitedMember = {
id: 'abc123',
id: 'invite-abc123',
name: '',
email: 'bob@signoz.io',
role: 'VIEWER' as ROLES,
status: MemberStatus.Invited,
joinedOn: '1700000000000',
token: 'tok-xyz',
};
function renderDrawer(
@@ -97,6 +102,7 @@ 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', () => {
@@ -157,61 +163,36 @@ describe('EditMemberDrawer', () => {
});
});
it('shows revoke invite and copy invite link for invited members; hides Last Modified', () => {
it('shows Cancel Invite and Copy Invite Link for invited members; hides Last Modified', () => {
renderDrawer({ member: invitedMember });
expect(
screen.getByRole('button', { name: /revoke invite/i }),
screen.getByRole('button', { name: /cancel 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 deleteUser after confirming revoke invite for invited members', async () => {
it('calls cancelInvite after confirming Cancel 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: /revoke invite/i }));
await user.click(screen.getByRole('button', { name: /cancel invite/i }));
expect(
await screen.findByText(/Are you sure you want to revoke the invite/i),
await screen.findByText(/are you sure you want to cancel the invitation/i),
).toBeInTheDocument();
const confirmBtns = screen.getAllByRole('button', { name: /revoke invite/i });
const confirmBtns = screen.getAllByRole('button', { name: /cancel invite/i });
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
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(mockCancelInvite).toHaveBeenCalledWith({ id: 'abc123' });
expect(onComplete).toHaveBeenCalled();
});
});
@@ -279,6 +260,7 @@ 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',

View File

@@ -1,6 +1,6 @@
import type React from 'react';
import { Badge } from '@signozhq/badge';
import { Table, Tooltip } from 'antd';
import { Pagination, 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,6 +18,7 @@ export interface MemberRow {
status: MemberStatus;
joinedOn: string | null;
updatedAt?: string | null;
token?: string | null;
}
interface MembersTableProps {
@@ -63,23 +64,11 @@ function StatusBadge({ status }: { status: MemberRow['status'] }): JSX.Element {
</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>;
return (
<Badge color="amber" variant="outline">
INVITED
</Badge>
);
}
function MembersEmptyState({
@@ -210,30 +199,14 @@ function MembersTable({
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: currentPage,
pageSize,
total,
showTotal: showPaginationTotal,
showSizeChanger: false,
onChange: onPageChange,
className: 'members-table-pagination',
hideOnSinglePage: true,
}}
pagination={false}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'members-table-row--tinted' : ''
}
onRow={(record): React.HTMLAttributes<HTMLElement> => {
const isClickable = onRowClick && record.status !== MemberStatus.Deleted;
return {
onClick: (): void => {
if (isClickable) {
onRowClick(record);
}
},
style: isClickable ? { cursor: 'pointer' } : undefined,
};
}}
onRow={(record): React.HTMLAttributes<HTMLElement> => ({
onClick: (): void => onRowClick?.(record),
style: onRowClick ? { cursor: 'pointer' } : undefined,
})}
onChange={(_, __, sorter): void => {
if (onSortChange) {
onSortChange(
@@ -247,6 +220,17 @@ 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>
);
}

View File

@@ -24,12 +24,13 @@ const mockActiveMembers: MemberRow[] = [
];
const mockInvitedMember: MemberRow = {
id: 'inv-abc',
id: 'invite-abc',
name: '',
email: 'charlie@signoz.io',
role: 'EDITOR' as ROLES,
status: MemberStatus.Invited,
joinedOn: null,
token: 'tok-123',
};
const defaultProps = {
@@ -92,34 +93,6 @@ 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="" />);

View File

@@ -6,6 +6,7 @@ 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';
@@ -13,7 +14,7 @@ import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import useUrlQuery from 'hooks/useUrlQuery';
import { useAppContext } from 'providers/App/App';
import { FilterMode, MemberStatus, toMemberStatus } from './utils';
import { FilterMode, INVITE_PREFIX, MemberStatus } from './utils';
import './MembersSettings.styles.scss';
@@ -33,24 +34,51 @@ function MembersSettings(): JSX.Element {
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [selectedMember, setSelectedMember] = useState<MemberRow | null>(null);
const { data: usersData, isLoading, refetch: refetchUsers } = useQuery({
const {
data: usersData,
isLoading: isUsersLoading,
refetch: refetchUsers,
} = useQuery({
queryFn: getAll,
queryKey: ['getOrgUser', org?.[0]?.id],
});
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 {
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: user.createdAt ? String(user.createdAt) : null,
updatedAt: user?.updatedAt ? String(user.updatedAt) : null,
}));
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: invite.createdAt ? String(invite.createdAt) : null,
token: invite.token ?? null,
}),
);
return [...activeMembers, ...pendingInvites];
}, [usersData, invitesData]);
const filteredMembers = useMemo((): MemberRow[] => {
let result = allMembers;
@@ -72,6 +100,11 @@ 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 => {
@@ -91,9 +124,7 @@ function MembersSettings(): JSX.Element {
}
}, [filteredMembers.length, currentPage, setPage]);
const pendingCount = allMembers.filter(
(m) => m.status === MemberStatus.Invited,
).length;
const pendingCount = invitesData?.data?.length ?? 0;
const totalCount = allMembers.length;
const filterMenuItems: MenuProps['items'] = [
@@ -132,7 +163,8 @@ function MembersSettings(): JSX.Element {
const handleInviteComplete = useCallback((): void => {
refetchUsers();
}, [refetchUsers]);
refetchInvites();
}, [refetchUsers, refetchInvites]);
const handleRowClick = useCallback((member: MemberRow): void => {
setSelectedMember(member);
@@ -144,8 +176,9 @@ function MembersSettings(): JSX.Element {
const handleMemberEditComplete = useCallback((): void => {
refetchUsers();
refetchInvites();
setSelectedMember(null);
}, [refetchUsers]);
}, [refetchUsers, refetchInvites]);
return (
<>
@@ -199,7 +232,7 @@ function MembersSettings(): JSX.Element {
</div>
</div>
<MembersTable
data={filteredMembers}
data={paginatedMembers}
loading={isLoading}
total={filteredMembers.length}
currentPage={currentPage}
@@ -220,6 +253,7 @@ function MembersSettings(): JSX.Element {
open={selectedMember !== null}
onClose={handleDrawerClose}
onComplete={handleMemberEditComplete}
onRefetch={handleInviteComplete}
/>
</>
);

View File

@@ -1,5 +1,6 @@
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';
@@ -12,6 +13,7 @@ jest.mock('@signozhq/sonner', () => ({
}));
const USERS_ENDPOINT = '*/api/v1/user';
const INVITES_ENDPOINT = '*/api/v1/invite';
const mockUsers: UserResponse[] = [
{
@@ -19,8 +21,7 @@ const mockUsers: UserResponse[] = [
displayName: 'Alice Smith',
email: 'alice@signoz.io',
role: 'ADMIN',
status: 'active',
createdAt: '2024-01-01T00:00:00.000Z',
createdAt: 1700000000,
organization: 'TestOrg',
orgId: 'org-1',
},
@@ -29,30 +30,20 @@ const mockUsers: UserResponse[] = [
displayName: 'Bob Jones',
email: 'bob@signoz.io',
role: 'VIEWER',
status: 'active',
createdAt: '2024-01-02T00:00:00.000Z',
createdAt: 1700000001,
organization: 'TestOrg',
orgId: 'org-1',
},
];
const mockInvites: PendingInvite[] = [
{
id: 'inv-1',
displayName: '',
email: 'charlie@signoz.io',
name: 'Charlie',
role: 'EDITOR',
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',
createdAt: 1700000002,
token: 'tok-abc',
},
];
@@ -63,6 +54,9 @@ 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 })),
),
);
});
@@ -70,16 +64,14 @@ describe('MembersSettings (integration)', () => {
server.resetHandlers();
});
it('loads and displays active users, pending invites, and deleted members', async () => {
it('loads and displays active users and pending invites', 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 () => {
@@ -115,7 +107,7 @@ describe('MembersSettings (integration)', () => {
expect(screen.queryByText('charlie@signoz.io')).not.toBeInTheDocument();
});
it('opens EditMemberDrawer when an active member row is clicked', async () => {
it('opens EditMemberDrawer when a member row is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<MembersSettings />);
@@ -125,16 +117,6 @@ 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 });

View File

@@ -1,3 +1,5 @@
export const INVITE_PREFIX = 'invite-';
export enum FilterMode {
All = 'all',
Invited = 'invited',
@@ -6,25 +8,4 @@ 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;
}
}

View File

@@ -0,0 +1,25 @@
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',
},
],
},
};

View File

@@ -9,6 +9,7 @@ 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';
@@ -174,14 +175,11 @@ 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({
status: 'success',
data: 'invite sent successfully',
}),
),
res(ctx.status(200), ctx.json(inviteUser)),
),
rest.put('http://localhost/api/v1/user/:id', (_, res, ctx) =>
res(

View File

@@ -1,9 +1,13 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useLocation } from 'react-router-dom';
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';
@@ -11,7 +15,9 @@ 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';
@@ -33,6 +39,22 @@ 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>();
@@ -42,6 +64,49 @@ 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;
@@ -49,6 +114,7 @@ function SignUp(): JSX.Element {
email,
orgDisplayName: organizationName,
password,
token: params.get('token') || undefined,
});
const token = await passwordAuthNContext({
@@ -63,6 +129,25 @@ 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 {
@@ -70,10 +155,14 @@ function SignUp(): JSX.Element {
setLoading(true);
setFormError(null);
await signUp(values);
logEvent('Account Created Successfully', {
email: values.email,
});
if (isSignUp) {
await signUp(values);
logEvent('Account Created Successfully', {
email: values.email,
});
} else {
await acceptInvite(values);
}
setLoading(false);
} catch (error) {
@@ -158,6 +247,7 @@ function SignUp(): JSX.Element {
autoFocus
required
id="signupEmail"
disabled={isDetailsDisable}
className="signup-form-input"
/>
</FormContainer.Item>
@@ -201,13 +291,15 @@ function SignUp(): JSX.Element {
</div>
</div>
<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"
/>
{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"
/>
)}
{confirmPasswordError && (
<Callout

View File

@@ -1,6 +1,7 @@
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';
@@ -31,8 +32,14 @@ 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';
const mockSignupResponse: SignupResponse = {
interface MockSignupResponse extends SignupResponse {
orgId: string;
}
const mockSignupResponse: MockSignupResponse = {
orgId: 'test-org-id',
createdAt: Date.now(),
email: 'test@signoz.io',
@@ -46,6 +53,15 @@ 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();
@@ -272,3 +288,242 @@ 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();
});
});
});

View File

@@ -0,0 +1,20 @@
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;
}

View File

@@ -0,0 +1,7 @@
export interface Props {
id: string;
}
export interface PayloadProps {
data: string;
}

View File

@@ -0,0 +1,22 @@
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'];
}

View File

@@ -0,0 +1,16 @@
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;
};

View File

@@ -7,16 +7,14 @@ export interface Props {
}
export interface UserResponse {
createdAt: number | string;
createdAt: number;
email: string;
id: string;
displayName: string;
orgId: string;
organization: string;
role: ROLES;
updatedAt?: number | string;
isRoot?: boolean;
status?: 'active' | 'pending_invite' | 'deleted';
updatedAt?: number;
}
export interface PayloadProps {
data: UserResponse;

View File

@@ -0,0 +1,195 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/gorilla/mux"
)
//nolint:unused
func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/connection_artifact", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.GetConnectionArtifact),
handler.OpenAPIDef{
ID: "GetConnectionArtifact",
Tags: []string{"cloudintegration"},
Summary: "Get connection artifact",
Description: "This endpoint returns a connection artifact for the specified cloud provider and creates new cloud integration account",
Request: new(citypes.PostableConnectionArtifact),
RequestContentType: "application/json",
Response: new(citypes.GettableConnectionArtifact),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.ListAccounts),
handler.OpenAPIDef{
ID: "ListAccounts",
Tags: []string{"cloudintegration"},
Summary: "List accounts",
Description: "This endpoint lists the accounts for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableAccounts),
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/cloud_integrations/{cloud_provider}/accounts/{id}", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.GetAccount),
handler.OpenAPIDef{
ID: "GetAccount",
Tags: []string{"cloudintegration"},
Summary: "Get account",
Description: "This endpoint gets an account for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableAccount),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.UpdateAccount),
handler.OpenAPIDef{
ID: "UpdateAccount",
Tags: []string{"cloudintegration"},
Summary: "Update account",
Description: "This endpoint updates an account for the specified cloud provider",
Request: new(citypes.UpdatableAccount),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.DisconnectAccount),
handler.OpenAPIDef{
ID: "DisconnectAccount",
Tags: []string{"cloudintegration"},
Summary: "Disconnect account",
Description: "This endpoint disconnects an account for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.ListServicesMetadata),
handler.OpenAPIDef{
ID: "ListServicesMetadata",
Tags: []string{"cloudintegration"},
Summary: "List services metadata",
Description: "This endpoint lists the services metadata for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableServicesMetadata),
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/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.GetService),
handler.OpenAPIDef{
ID: "GetService",
Tags: []string{"cloudintegration"},
Summary: "Get service",
Description: "This endpoint gets a service for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableService),
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/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.UpdateService),
handler.OpenAPIDef{
ID: "UpdateService",
Tags: []string{"cloudintegration"},
Summary: "Update service",
Description: "This endpoint updates a service for the specified cloud provider",
Request: new(citypes.UpdatableService),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/agent-check-in", handler.New(
provider.authZ.ViewAccess(provider.cloudIntegrationHandler.AgentCheckIn),
handler.OpenAPIDef{
ID: "AgentCheckIn",
Tags: []string{"cloudintegration"},
Summary: "Agent check-in",
Description: "This endpoint is called by the deployed agent to check in",
Request: new(citypes.PostableAgentCheckInRequest),
RequestContentType: "application/json",
Response: new(citypes.GettableAgentCheckInResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer), // agent role is viewer
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/fields"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
@@ -50,6 +51,8 @@ type provider struct {
zeusHandler zeus.Handler
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
// TODO: wire up later
cloudIntegrationHandler cloudintegration.Handler //nolint:unused
}
func NewFactory(

View File

@@ -43,6 +43,74 @@ 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"},

View File

@@ -53,6 +53,7 @@ type Module interface {
}
type Handler interface {
// GetConnectionArtifact creates a new cloud integration account and returns the connection artifact
GetConnectionArtifact(http.ResponseWriter, *http.Request)
ListAccounts(http.ResponseWriter, *http.Request)
GetAccount(http.ResponseWriter, *http.Request)

View File

@@ -27,6 +27,25 @@ 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()
@@ -85,6 +104,59 @@ 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()
@@ -141,6 +213,9 @@ 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)
}

View File

@@ -49,6 +49,54 @@ 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)
@@ -170,6 +218,46 @@ 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...)
@@ -216,6 +304,10 @@ 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

View File

@@ -41,6 +41,9 @@ 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
@@ -86,6 +89,10 @@ 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)

View File

@@ -177,7 +177,7 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
onConflictClause := ""
if len(onConflictSetStmts) > 0 {
onConflictClause = fmt.Sprintf(
"conflict(id, provider, org_id) do update SET\n%s",
"conflict(id) do update SET\n%s",
strings.Join(onConflictSetStmts, ",\n"),
)
}
@@ -202,6 +202,8 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
Exec(ctx)
if dbErr != nil {
// for now returning internal error even if there is a conflict,
// will be handled better in the future iteration
return nil, model.InternalError(fmt.Errorf(
"could not upsert cloud account record: %w", dbErr,
))

View File

@@ -28,6 +28,9 @@ const SpanSearchScopeRoot = "isroot"
const SpanSearchScopeEntryPoint = "isentrypoint"
const OrderBySpanCount = "span_count"
// Deprecated: Use the new emailing service instead
var InviteEmailTemplate = GetOrDefaultEnv("INVITE_EMAIL_TEMPLATE", "/root/templates/invitation_email.gotmpl")
var MetricsExplorerClickhouseThreads = GetOrDefaultEnvInt("METRICS_EXPLORER_CLICKHOUSE_THREADS", 8)
var UpdatedMetricsMetadataCachePrefix = GetOrDefaultEnv("METRICS_UPDATED_METADATA_CACHE_KEY", "UPDATED_METRICS_METADATA")

View File

@@ -175,6 +175,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateRulesV4ToV5Factory(sqlstore, telemetryStore),
sqlmigration.NewAddStatusUserFactory(sqlstore, sqlschema),
sqlmigration.NewDeprecateUserInviteFactory(sqlstore, sqlschema),
sqlmigration.NewUpdateCloudIntegrationUniqueIndexFactory(sqlstore, sqlschema),
)
}

View File

@@ -0,0 +1,255 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type updateCloudIntegrationUniqueIndex struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewUpdateCloudIntegrationUniqueIndexFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("update_cloud_integration_index"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &updateCloudIntegrationUniqueIndex{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
},
)
}
func (migration *updateCloudIntegrationUniqueIndex) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
type cloudIntegrationRow struct {
bun.BaseModel `bun:"table:cloud_integration"`
ID string `bun:"id"`
AccountID string `bun:"account_id"`
Provider string `bun:"provider"`
OrgID string `bun:"org_id"`
Config string `bun:"config"`
UpdatedAt time.Time `bun:"updated_at"`
}
type cloudIntegrationAccountConfig struct {
Regions []string `json:"regions"`
}
// duplicateGroup holds the keeper (first element) and losers (rest) for a duplicate (account_id, provider, org_id) group.
type duplicateGroup struct {
keeper *cloudIntegrationRow
losers []*cloudIntegrationRow
}
func (migration *updateCloudIntegrationUniqueIndex) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
sqls := [][]byte{}
// Step 1: Drop the wrong index on (id, provider, org_id)
dropSqls := migration.sqlschema.Operator().DropIndex(
(&sqlschema.UniqueIndex{
TableName: "cloud_integration",
ColumnNames: []sqlschema.ColumnName{"id", "provider", "org_id"},
}).Named("unique_cloud_integration"),
)
sqls = append(sqls, dropSqls...)
// Step 2: Normalize empty-string account_id to NULL
// Older table structure could store "" instead of NULL for unconnected accounts.
// Empty strings would violate the partial unique index since '' = '' (unlike NULL != NULL).
_, err = tx.NewUpdate().
TableExpr("cloud_integration").
Set("account_id = NULL").
Where("account_id = ''").
Exec(ctx)
if err != nil {
return err
}
// Step 3: Fetch all active rows with non-null account_id, ordered for grouping
var activeRows []*cloudIntegrationRow
err = tx.NewSelect().
Model(&activeRows).
Where("removed_at IS NULL").
Where("account_id IS NOT NULL").
OrderExpr("account_id, provider, org_id, updated_at DESC").
Scan(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
// Group by (account_id, provider, org_id)
groups := groupCloudIntegrationRows(activeRows)
now := time.Now()
var loserIDs []string
for _, group := range groups {
if len(group.losers) == 0 {
continue
}
// Step 4: Merge config from losers into keeper
if err = mergeCloudIntegrationConfigs(ctx, tx, group); err != nil {
return err
}
// Step 5: Reassign non-conflicting cloud_integration_service rows to keeper
for _, loser := range group.losers {
_, err = tx.NewUpdate().
TableExpr("cloud_integration_service").
Set("cloud_integration_id = ?", group.keeper.ID).
Where("cloud_integration_id = ?", loser.ID).
Where("type NOT IN (?)",
tx.NewSelect().
TableExpr("cloud_integration_service").
Column("type").
Where("cloud_integration_id = ?", group.keeper.ID),
).
Exec(ctx)
if err != nil {
return err
}
loserIDs = append(loserIDs, loser.ID)
}
}
// Step 6: Soft-delete all loser rows
if len(loserIDs) > 0 {
_, err = tx.NewUpdate().
TableExpr("cloud_integration").
Set("removed_at = ?", now).
Set("updated_at = ?", now).
Where("id IN (?)", bun.In(loserIDs)).
Exec(ctx)
if err != nil {
return err
}
}
// Step 7: Create the correct partial unique index on (account_id, provider, org_id) WHERE removed_at IS NULL
createSqls := migration.sqlschema.Operator().CreateIndex(
&sqlschema.PartialUniqueIndex{
TableName: "cloud_integration",
ColumnNames: []sqlschema.ColumnName{"account_id", "provider", "org_id"},
Where: "removed_at IS NULL",
},
)
sqls = append(sqls, createSqls...)
for _, sql := range sqls {
if _, err = tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *updateCloudIntegrationUniqueIndex) Down(ctx context.Context, db *bun.DB) error {
return nil
}
// groupCloudIntegrationRows groups rows by (account_id, provider, org_id).
// Rows must be pre-sorted by account_id, provider, org_id, updated_at DESC
// so the first row in each group is the keeper (most recently updated).
func groupCloudIntegrationRows(rows []*cloudIntegrationRow) []duplicateGroup {
if len(rows) == 0 {
return nil
}
var groups []duplicateGroup
var current duplicateGroup
current.keeper = rows[0]
for i := 1; i < len(rows); i++ {
row := rows[i]
if row.AccountID == current.keeper.AccountID &&
row.Provider == current.keeper.Provider &&
row.OrgID == current.keeper.OrgID {
current.losers = append(current.losers, row)
} else {
groups = append(groups, current)
current = duplicateGroup{keeper: row}
}
}
groups = append(groups, current)
return groups
}
// mergeCloudIntegrationConfigs unions the EnabledRegions from all rows in the group into the keeper's config and updates
func mergeCloudIntegrationConfigs(ctx context.Context, tx bun.Tx, group duplicateGroup) error {
regionSet := make(map[string]struct{})
// Parse keeper's config
parseRegions(group.keeper.Config, regionSet)
// Parse each loser's config
for _, loser := range group.losers {
parseRegions(loser.Config, regionSet)
}
// Build merged config
mergedRegions := make([]string, 0, len(regionSet))
for region := range regionSet {
mergedRegions = append(mergedRegions, region)
}
merged := cloudIntegrationAccountConfig{Regions: mergedRegions}
mergedJSON, err := json.Marshal(merged)
if err != nil {
return err
}
// Update keeper's config
_, err = tx.NewUpdate().
TableExpr("cloud_integration").
Set("config = ?", string(mergedJSON)).
Where("id = ?", group.keeper.ID).
Exec(ctx)
return err
}
// parseRegions unmarshals a config JSON string and adds its regions to the set.
func parseRegions(configJSON string, regionSet map[string]struct{}) {
if configJSON == "" {
return
}
var config cloudIntegrationAccountConfig
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return
}
for _, region := range config.Regions {
regionSet[region] = struct{}{}
}
}

View File

@@ -10,34 +10,35 @@ import (
type Account struct {
types.Identifiable
types.TimeAuditable
ProviderAccountId *string `json:"providerAccountID,omitempty"`
Provider CloudProviderType `json:"provider"`
RemovedAt *time.Time `json:"removedAt,omitempty"`
AgentReport *AgentReport `json:"agentReport,omitempty"`
OrgID valuer.UUID `json:"orgID"`
Config *AccountConfig `json:"config,omitempty"`
ProviderAccountId *string `json:"providerAccountID" required:"true" nullable:"true"`
Provider CloudProviderType `json:"provider" required:"true"`
RemovedAt *time.Time `json:"removedAt,omitempty" required:"true" nullable:"true"`
AgentReport *AgentReport `json:"agentReport,omitempty" required:"true" nullable:"true"`
OrgID valuer.UUID `json:"orgID" required:"true"`
Config *AccountConfig `json:"config,omitempty" required:"true" nullable:"false"`
}
// AgentReport represents heartbeats sent by the agent.
type AgentReport struct {
TimestampMillis int64 `json:"timestampMillis"`
Data map[string]any `json:"data"`
TimestampMillis int64 `json:"timestampMillis" required:"true"`
Data map[string]any `json:"data" required:"true" nullable:"true"`
}
type AccountConfig struct {
// required till new providers are added
AWS *AWSAccountConfig `json:"aws,omitempty" required:"true" nullable:"false"`
}
type GettableAccounts struct {
Accounts []*Account `json:"accounts"`
Accounts []*Account `json:"accounts" required:"true" nullable:"false"`
}
type GettableAccount = Account
type UpdatableAccount struct {
Config *AccountConfig `json:"config"`
}
type AccountConfig struct {
AWS *AWSAccountConfig `json:"aws,omitempty"`
Config *AccountConfig `json:"config" required:"true" nullable:"false"`
}
type AWSAccountConfig struct {
Regions []string `json:"regions"`
Regions []string `json:"regions" required:"true" nullable:"false"`
}

View File

@@ -1,88 +1,87 @@
package cloudintegrationtypes
import "github.com/SigNoz/signoz/pkg/types/integrationtypes"
import "time"
type ConnectionArtifactRequest struct {
Aws *AWSConnectionArtifactRequest `json:"aws"`
// required till new providers are added
Aws *AWSConnectionArtifactRequest `json:"aws" required:"true" nullable:"false"`
}
type AWSConnectionArtifactRequest struct {
DeploymentRegion string `json:"deploymentRegion"`
Regions []string `json:"regions"`
DeploymentRegion string `json:"deploymentRegion" required:"true"`
Regions []string `json:"regions" required:"true" nullable:"false"`
}
type PostableConnectionArtifact = ConnectionArtifactRequest
type ConnectionArtifact struct {
Aws *AWSConnectionArtifact `json:"aws"`
// required till new providers are added
Aws *AWSConnectionArtifact `json:"aws" required:"true" nullable:"false"`
}
type AWSConnectionArtifact struct {
ConnectionUrl string `json:"connectionURL"`
ConnectionUrl string `json:"connectionURL" required:"true"`
}
type GettableConnectionArtifact = ConnectionArtifact
type AccountStatus struct {
Id string `json:"id"`
ProviderAccountId *string `json:"providerAccountID,omitempty"`
Status integrationtypes.AccountStatus `json:"status"`
}
type GettableAccountStatus = AccountStatus
type AgentCheckInRequest struct {
// older backward compatible fields are mapped to new fields
// CloudIntegrationId string `json:"cloudIntegrationId"`
// AccountId string `json:"accountId"`
// New fields
ProviderAccountId string `json:"providerAccountId"`
CloudAccountId string `json:"cloudAccountId"`
ProviderAccountId string `json:"providerAccountId" required:"false"`
CloudAccountId string `json:"cloudAccountId" required:"false"`
Data map[string]any `json:"data,omitempty"`
Data map[string]any `json:"data,omitempty" required:"true" nullable:"true"`
}
type PostableAgentCheckInRequest struct {
AgentCheckInRequest
// following are backward compatible fields for older running agents
// which gets mapped to new fields in AgentCheckInRequest
CloudIntegrationId string `json:"cloud_integration_id"`
CloudAccountId string `json:"cloud_account_id"`
CloudIntegrationId string `json:"cloud_integration_id" required:"false"`
CloudAccountId string `json:"cloud_account_id" required:"false"`
}
type AgentCheckInResponse struct {
// Older fields for backward compatibility are mapped to new fields below
// CloudIntegrationId string `json:"cloud_integration_id"`
// AccountId string `json:"account_id"`
// backward-compatible JSON key
RemovedAt *time.Time `json:"removed_at" required:"true" nullable:"true"`
// New fields
ProviderAccountId string `json:"providerAccountId" required:"true"`
CloudAccountId string `json:"cloudAccountId" required:"true"`
// IntegrationConfig populates data related to integration that is required for an agent
// to start collecting telemetry data
// keeping JSON key snake_case for backward compatibility
IntegrationConfig *IntegrationConfig `json:"integration_config,omitempty" required:"true" nullable:"false"`
}
type GettableAgentCheckInResponse struct {
AgentCheckInResponse
// For backward compatibility
CloudIntegrationId string `json:"cloud_integration_id"`
AccountId string `json:"account_id"`
}
type AgentCheckInResponse struct {
// Older fields for backward compatibility are mapped to new fields below
// CloudIntegrationId string `json:"cloud_integration_id"`
// AccountId string `json:"account_id"`
// New fields
ProviderAccountId string `json:"providerAccountId"`
CloudAccountId string `json:"cloudAccountId"`
// IntegrationConfig populates data related to integration that is required for an agent
// to start collecting telemetry data
// keeping JSON key snake_case for backward compatibility
IntegrationConfig *IntegrationConfig `json:"integration_config,omitempty"`
CloudIntegrationId string `json:"cloud_integration_id" required:"true"`
AccountId string `json:"account_id" required:"true"`
}
type IntegrationConfig struct {
EnabledRegions []string `json:"enabledRegions"` // backward compatible
Telemetry *AWSCollectionStrategy `json:"telemetry,omitempty"` // backward compatible
EnabledRegions []string `json:"enabledRegions" required:"true" nullable:"false"` // backward compatible
Telemetry *AWSCollectionStrategy `json:"telemetry,omitempty" required:"true" nullable:"false"` // backward compatible
// new fields
AWS *AWSIntegrationConfig `json:"aws,omitempty"`
// required till new providers are added
AWS *AWSIntegrationConfig `json:"aws,omitempty" required:"true" nullable:"false"`
}
type AWSIntegrationConfig struct {
EnabledRegions []string `json:"enabledRegions"`
Telemetry *AWSCollectionStrategy `json:"telemetry,omitempty"`
EnabledRegions []string `json:"enabledRegions" required:"true" nullable:"false"`
Telemetry *AWSCollectionStrategy `json:"telemetry,omitempty" required:"true" nullable:"false"`
}

View File

@@ -11,13 +11,9 @@ import (
)
var (
S3Sync = valuer.NewString("s3sync")
// ErrCodeInvalidServiceID is the error code for invalid service id.
ErrCodeInvalidServiceID = errors.MustNewCode("invalid_service_id")
)
type ServiceID struct{ valuer.String }
type CloudIntegrationService struct {
types.Identifiable
types.TimeAuditable
@@ -26,32 +22,67 @@ type CloudIntegrationService struct {
CloudIntegrationID valuer.UUID `json:"cloudIntegrationID"`
}
type ServiceConfig struct {
// required till new providers are added
AWS *AWSServiceConfig `json:"aws,omitempty" required:"true" nullable:"false"`
}
// ServiceMetadata helps to quickly list available services and whether it is enabled or not.
// As getting complete service definition is a heavy operation and the response is also large,
// initial integration page load can be very slow.
type ServiceMetadata struct {
ServiceDefinitionMetadata
// if the service is enabled for the account
Enabled bool `json:"enabled"`
Enabled bool `json:"enabled" required:"true"`
}
// ServiceDefinitionMetadata represents service definition metadata. This is useful for showing service tab in frontend.
type ServiceDefinitionMetadata struct {
Id string `json:"id" required:"true"`
Title string `json:"title" required:"true"`
Icon string `json:"icon" required:"true"`
}
type GettableServicesMetadata struct {
Services []*ServiceMetadata `json:"services"`
Services []*ServiceMetadata `json:"services" required:"true" nullable:"false"`
}
type Service struct {
ServiceDefinition
ServiceConfig *ServiceConfig `json:"serviceConfig"`
ServiceConfig *ServiceConfig `json:"serviceConfig" required:"false" nullable:"false"`
}
type GettableService = Service
type UpdatableService struct {
Config *ServiceConfig `json:"config"`
Config *ServiceConfig `json:"config" required:"true" nullable:"false"`
}
type ServiceConfig struct {
AWS *AWSServiceConfig `json:"aws,omitempty"`
type ServiceDefinition struct {
ServiceDefinitionMetadata
Overview string `json:"overview" required:"true"` // markdown
Assets Assets `json:"assets" required:"true"`
SupportedSignals SupportedSignals `json:"supported_signals" required:"true"`
DataCollected DataCollected `json:"dataCollected" required:"true"`
Strategy *CollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
}
// SupportedSignals for cloud provider's service.
type SupportedSignals struct {
Logs bool `json:"logs"`
Metrics bool `json:"metrics"`
}
// DataCollected is curated static list of metrics and logs, this is shown as part of service overview.
type DataCollected struct {
Logs []CollectedLogAttribute `json:"logs"`
Metrics []CollectedMetric `json:"metrics"`
}
// CollectionStrategy is cloud provider specific configuration for signal collection,
// this is used by agent to understand the nitty-gritty for collecting telemetry for the cloud provider.
type CollectionStrategy struct {
AWS *AWSCollectionStrategy `json:"aws,omitempty"`
}
type AWSServiceConfig struct {
@@ -70,45 +101,11 @@ type AWSServiceMetricsConfig struct {
Enabled bool `json:"enabled"`
}
// ServiceDefinitionMetadata represents service definition metadata. This is useful for showing service tab in frontend.
type ServiceDefinitionMetadata struct {
Id string `json:"id"`
Title string `json:"title"`
Icon string `json:"icon"`
}
type ServiceDefinition struct {
ServiceDefinitionMetadata
Overview string `json:"overview"` // markdown
Assets Assets `json:"assets"`
SupportedSignals SupportedSignals `json:"supported_signals"`
DataCollected DataCollected `json:"dataCollected"`
Strategy *CollectionStrategy `json:"telemetryCollectionStrategy"`
}
// CollectionStrategy is cloud provider specific configuration for signal collection,
// this is used by agent to understand the nitty-gritty for collecting telemetry for the cloud provider.
type CollectionStrategy struct {
AWS *AWSCollectionStrategy `json:"aws,omitempty"`
}
// Assets represents the collection of dashboards.
type Assets struct {
Dashboards []Dashboard `json:"dashboards"`
}
// SupportedSignals for cloud provider's service.
type SupportedSignals struct {
Logs bool `json:"logs"`
Metrics bool `json:"metrics"`
}
// DataCollected is curated static list of metrics and logs, this is shown as part of service overview.
type DataCollected struct {
Logs []CollectedLogAttribute `json:"logs"`
Metrics []CollectedMetric `json:"metrics"`
}
// CollectedLogAttribute represents a log attribute that is present in all log entries for a service,
// this is shown as part of service overview.
type CollectedLogAttribute struct {
@@ -175,39 +172,6 @@ type Dashboard struct {
Definition dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
}
// SupportedServices is the map of supported services for each cloud provider.
var SupportedServices = map[CloudProviderType][]ServiceID{
CloudProviderTypeAWS: {
{valuer.NewString("alb")},
{valuer.NewString("api-gateway")},
{valuer.NewString("dynamodb")},
{valuer.NewString("ec2")},
{valuer.NewString("ecs")},
{valuer.NewString("eks")},
{valuer.NewString("elasticache")},
{valuer.NewString("lambda")},
{valuer.NewString("msk")},
{valuer.NewString("rds")},
{valuer.NewString("s3sync")},
{valuer.NewString("sns")},
{valuer.NewString("sqs")},
},
}
// NewServiceID returns a new ServiceID from a string, validated against the supported services for the given cloud provider.
func NewServiceID(provider CloudProviderType, service string) (ServiceID, error) {
services, ok := SupportedServices[provider]
if !ok {
return ServiceID{}, errors.NewInvalidInputf(ErrCodeInvalidServiceID, "no services defined for cloud provider: %s", provider)
}
for _, s := range services {
if s.StringValue() == service {
return s, nil
}
}
return ServiceID{}, errors.NewInvalidInputf(ErrCodeInvalidServiceID, "invalid service id %q for cloud provider %s", service, provider)
}
// UTILS
// GetCloudIntegrationDashboardID returns the dashboard id for a cloud integration, given the cloud provider, service id, and dashboard id.

View File

@@ -0,0 +1,75 @@
package cloudintegrationtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type ServiceID struct{ valuer.String }
var (
AWSServiceALB = ServiceID{valuer.NewString("alb")}
AWSServiceAPIGateway = ServiceID{valuer.NewString("api-gateway")}
AWSServiceDynamoDB = ServiceID{valuer.NewString("dynamodb")}
AWSServiceEC2 = ServiceID{valuer.NewString("ec2")}
AWSServiceECS = ServiceID{valuer.NewString("ecs")}
AWSServiceEKS = ServiceID{valuer.NewString("eks")}
AWSServiceElastiCache = ServiceID{valuer.NewString("elasticache")}
AWSServiceLambda = ServiceID{valuer.NewString("lambda")}
AWSServiceMSK = ServiceID{valuer.NewString("msk")}
AWSServiceRDS = ServiceID{valuer.NewString("rds")}
AWSServiceS3Sync = ServiceID{valuer.NewString("s3sync")}
AWSServiceSNS = ServiceID{valuer.NewString("sns")}
AWSServiceSQS = ServiceID{valuer.NewString("sqs")}
)
func (ServiceID) Enum() []any {
return []any{
AWSServiceALB,
AWSServiceAPIGateway,
AWSServiceDynamoDB,
AWSServiceEC2,
AWSServiceECS,
AWSServiceEKS,
AWSServiceElastiCache,
AWSServiceLambda,
AWSServiceMSK,
AWSServiceRDS,
AWSServiceS3Sync,
AWSServiceSNS,
AWSServiceSQS,
}
}
// SupportedServices is the map of supported services for each cloud provider.
var SupportedServices = map[CloudProviderType][]ServiceID{
CloudProviderTypeAWS: {
AWSServiceALB,
AWSServiceAPIGateway,
AWSServiceDynamoDB,
AWSServiceEC2,
AWSServiceECS,
AWSServiceEKS,
AWSServiceElastiCache,
AWSServiceLambda,
AWSServiceMSK,
AWSServiceRDS,
AWSServiceS3Sync,
AWSServiceSNS,
AWSServiceSQS,
},
}
// NewServiceID returns a new ServiceID from a string, validated against the supported services for the given cloud provider.
func NewServiceID(provider CloudProviderType, service string) (ServiceID, error) {
services, ok := SupportedServices[provider]
if !ok {
return ServiceID{}, errors.NewInvalidInputf(ErrCodeInvalidServiceID, "no services defined for cloud provider: %s", provider)
}
for _, s := range services {
if s.StringValue() == service {
return s, nil
}
}
return ServiceID{}, errors.NewInvalidInputf(ErrCodeInvalidServiceID, "invalid service id %q for cloud provider %s", service, provider)
}

View File

@@ -30,6 +30,22 @@ type Invite struct {
InviteLink string `bun:"-" json:"inviteLink"`
}
type InviteEmailData struct {
CustomerName string
InviterName string
InviterEmail string
Link string
}
type PostableAcceptInvite struct {
DisplayName string `json:"displayName"`
InviteToken string `json:"token"`
Password string `json:"password"`
// reference URL to track where the register request is coming from
SourceURL string `json:"sourceUrl"`
}
type PostableInvite struct {
Name string `json:"name"`
Email valuer.Email `json:"email"`
@@ -63,6 +79,10 @@ func (request *PostableBulkInviteRequest) UnmarshalJSON(data []byte) error {
return nil
}
type GettableCreateInviteResponse struct {
InviteToken string `json:"token"`
}
func NewInvite(name string, role Role, orgID valuer.UUID, email valuer.Email) (*Invite, error) {
invite := &Invite{
Identifiable: Identifiable{
@@ -81,3 +101,23 @@ func NewInvite(name string, role Role, orgID valuer.UUID, email valuer.Email) (*
return invite, nil
}
func (request *PostableAcceptInvite) UnmarshalJSON(data []byte) error {
type Alias PostableAcceptInvite
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if temp.InviteToken == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invite token is required")
}
if !IsPasswordValid(temp.Password) {
return ErrInvalidPassword
}
*request = PostableAcceptInvite(temp)
return nil
}

View File

@@ -1,7 +1,7 @@
"""Fixtures for cloud integration tests."""
from http import HTTPStatus
from typing import Callable, Optional
from typing import Callable
import pytest
import requests
@@ -18,14 +18,12 @@ def create_cloud_integration_account(
request: pytest.FixtureRequest,
signoz: types.SigNoz,
) -> Callable[[str, str], dict]:
created_account_id: Optional[str] = None
cloud_provider_used: Optional[str] = None
created_accounts: list[tuple[str, str]] = []
def _create(
admin_token: str,
cloud_provider: str = "aws",
) -> dict:
nonlocal created_account_id, cloud_provider_used
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts/generate-connection-url"
request_payload = {
@@ -52,35 +50,31 @@ def create_cloud_integration_account(
), f"Failed to create test account: {response.status_code}"
data = response.json().get("data", response.json())
created_account_id = data.get("account_id")
cloud_provider_used = cloud_provider
created_accounts.append((data.get("account_id"), cloud_provider))
return data
def _disconnect(admin_token: str, cloud_provider: str) -> requests.Response:
assert created_account_id
disconnect_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts/{created_account_id}/disconnect"
return requests.post(
signoz.self.host_configs["8080"].get(disconnect_endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
# Yield factory to the test
yield _create
# Post-test cleanup: generate admin token and disconnect the created account
if created_account_id and cloud_provider_used:
# Post-test cleanup: disconnect all created accounts
if created_accounts:
get_token = request.getfixturevalue("get_token")
try:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
r = _disconnect(admin_token, cloud_provider_used)
if r.status_code != HTTPStatus.OK:
logger.info(
"Disconnect cleanup returned %s for account %s",
r.status_code,
created_account_id,
for account_id, cloud_provider in created_accounts:
disconnect_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts/{account_id}/disconnect"
r = requests.post(
signoz.self.host_configs["8080"].get(disconnect_endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
logger.info("Cleaned up test account: %s", created_account_id)
if r.status_code != HTTPStatus.OK:
logger.info(
"Disconnect cleanup returned %s for account %s",
r.status_code,
account_id,
)
logger.info("Cleaned up test account: %s", account_id)
except Exception as exc: # pylint: disable=broad-except
logger.info("Post-test disconnect cleanup failed: %s", exc)

View File

@@ -1,7 +1,5 @@
"""Fixtures for cloud integration tests."""
from http import HTTPStatus
import requests
from fixtures import types
@@ -16,7 +14,7 @@ def simulate_agent_checkin(
cloud_provider: str,
account_id: str,
cloud_account_id: str,
) -> dict:
) -> requests.Response:
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/agent-check-in"
checkin_payload = {
@@ -32,16 +30,11 @@ def simulate_agent_checkin(
timeout=10,
)
if response.status_code != HTTPStatus.OK:
if not response.ok:
logger.error(
"Agent check-in failed: %s, response: %s",
response.status_code,
response.text,
)
assert (
response.status_code == HTTPStatus.OK
), f"Agent check-in failed: {response.status_code}"
response_data = response.json()
return response_data.get("data", response_data)
return response

View File

@@ -1,3 +1,4 @@
import uuid
from http import HTTPStatus
from typing import Callable
@@ -5,6 +6,8 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.cloudintegrations import create_cloud_integration_account
from fixtures.cloudintegrationsutils import simulate_agent_checkin
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
@@ -142,3 +145,42 @@ def test_generate_connection_url_unsupported_provider(
assert (
"unsupported cloud provider" in response_data["error"].lower()
), "Error message should indicate unsupported provider"
def test_duplicate_cloud_account_checkins(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Test that two accounts cannot check in with the same cloud_account_id."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
cloud_provider = "aws"
same_cloud_account_id = str(uuid.uuid4())
# Create two separate cloud integration accounts via generate-connection-url
account1 = create_cloud_integration_account(admin_token, cloud_provider)
account1_id = account1["account_id"]
account2 = create_cloud_integration_account(admin_token, cloud_provider)
account2_id = account2["account_id"]
assert account1_id != account2_id, "Two accounts should have different internal IDs"
# First check-in succeeds: account1 claims cloud_account_id
response = simulate_agent_checkin(
signoz, admin_token, cloud_provider, account1_id, same_cloud_account_id
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200 for first check-in, got {response.status_code}: {response.text}"
#
# Second check-in should fail: account2 tries to use the same cloud_account_id
response = simulate_agent_checkin(
signoz, admin_token, cloud_provider, account2_id, same_cloud_account_id
)
assert (
response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
), f"Expected 500 for duplicate cloud_account_id, got {response.status_code}: {response.text}"

View File

@@ -57,9 +57,12 @@ def test_list_connected_accounts_with_account(
# Simulate agent check-in to mark as connected
cloud_account_id = str(uuid.uuid4())
simulate_agent_checkin(
response = simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
# List accounts
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts"
@@ -161,9 +164,12 @@ def test_update_account_config(
# Simulate agent check-in to mark as connected
cloud_account_id = str(uuid.uuid4())
simulate_agent_checkin(
response = simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
# Update account configuration
endpoint = (
@@ -226,9 +232,12 @@ def test_disconnect_account(
# Simulate agent check-in to mark as connected
cloud_account_id = str(uuid.uuid4())
simulate_agent_checkin(
response = simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
# Disconnect the account
endpoint = (

View File

@@ -61,9 +61,12 @@ def test_list_services_with_account(
account_id = account_data["account_id"]
cloud_account_id = str(uuid.uuid4())
simulate_agent_checkin(
response = simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
# List services for the account
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services?cloud_account_id={cloud_account_id}"
@@ -152,9 +155,12 @@ def test_get_service_details_with_account(
account_id = account_data["account_id"]
cloud_account_id = str(uuid.uuid4())
simulate_agent_checkin(
response = simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
# Get list of services first
list_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services"
@@ -253,9 +259,12 @@ def test_update_service_config(
account_id = account_data["account_id"]
cloud_account_id = str(uuid.uuid4())
simulate_agent_checkin(
response = simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
# Get list of services to pick a valid service ID
list_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services"
@@ -365,9 +374,12 @@ def test_update_service_config_invalid_service(
account_id = account_data["account_id"]
cloud_account_id = str(uuid.uuid4())
simulate_agent_checkin(
response = simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
# Try to update config for invalid service
fake_service_id = "non-existent-service"
@@ -409,9 +421,12 @@ def test_update_service_config_disable_service(
account_id = account_data["account_id"]
cloud_account_id = str(uuid.uuid4())
simulate_agent_checkin(
response = simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
# Get a valid service
list_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services"

View File

@@ -121,23 +121,6 @@ def test_invite_and_register(
assert invited_user["email"] == "editor@integration.test"
assert invited_user["role"] == "EDITOR"
# Verify the user user appears in the users list but as pending_invite status
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(user for user in user_response if user["email"] == "editor@integration.test"),
None,
)
assert found_user is not None
assert found_user["status"] == "pending_invite"
assert found_user["role"] == "EDITOR"
reset_token = invited_user["token"]
# Reset the password to complete the invite flow (activates the user and also grants authz)
@@ -248,3 +231,85 @@ def test_self_access(
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["role"] == "EDITOR"
def test_old_invite_flow(signoz: types.SigNoz, get_token: Callable[[str, str], str]):
admin_token = get_token("admin@integration.test", "password123Z$")
# invite a new user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": "oldinviteflow@integration.test", "role": "VIEWER", "name": "old invite flow"},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED
# get the invite token using get api
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
timeout=2,
headers={
"Authorization": f"Bearer {admin_token}"
},
)
invite_response = response.json()["data"]
found_invite = next(
(
invite
for invite in invite_response
if invite["email"] == "oldinviteflow@integration.test"
),
None,
)
# accept the invite
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={
"password": "password123Z$",
"displayName": "old invite flow",
"token": f"{found_invite['token']}",
},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
# verify the invite token has been deleted
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/invite/{found_invite['token']}"),
timeout=2,
)
assert response.status_code in (HTTPStatus.NOT_FOUND, HTTPStatus.BAD_REQUEST)
# verify that admin endpoints cannot be called
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={
"Authorization": f"Bearer {get_token("oldinviteflow@integration.test", "password123Z$")}"
},
)
assert response.status_code == HTTPStatus.FORBIDDEN
# verify the user has been created
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={
"Authorization": f"Bearer {admin_token}"
},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(user for user in user_response if user["email"] == "oldinviteflow@integration.test"),
None,
)
assert found_user is not None
assert found_user["role"] == "VIEWER"
assert found_user["displayName"] == "old invite flow"
assert found_user["email"] == "oldinviteflow@integration.test"