Compare commits

...

4 Commits

Author SHA1 Message Date
SagarRajput-7
ce1850a6b7 Merge branch 'main' into members-page 2026-03-03 01:35:03 +05:30
SagarRajput-7
02abe9cebc feat: added password modal and other functionality 2026-03-02 17:40:07 +05:30
SagarRajput-7
35443b3bd3 feat: added components and styles 2026-02-27 22:03:52 +05:30
SagarRajput-7
b797464995 feat: added members page and listing and edit view 2026-02-27 21:58:29 +05:30
22 changed files with 2479 additions and 6 deletions

View File

@@ -53,6 +53,8 @@
"@signozhq/combobox": "0.0.2",
"@signozhq/command": "0.0.0",
"@signozhq/design-tokens": "2.1.1",
"@signozhq/dialog": "0.0.2",
"@signozhq/drawer": "0.0.4",
"@signozhq/icons": "0.1.0",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
@@ -290,4 +292,4 @@
"on-headers": "^1.1.0",
"tmp": "0.2.4"
}
}
}

View File

@@ -13,5 +13,6 @@
"pipelines": "Pipelines",
"archives": "Archives",
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles"
"roles": "Roles",
"members": "Members"
}

View File

@@ -13,5 +13,6 @@
"pipelines": "Pipelines",
"archives": "Archives",
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles"
"roles": "Roles",
"members": "Members"
}

View File

@@ -74,5 +74,6 @@
"METER_EXPLORER": "SigNoz | Meter Explorer",
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles"
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members"
}

View File

@@ -18,6 +18,8 @@ import '@signozhq/checkbox';
import '@signozhq/combobox';
import '@signozhq/command';
import '@signozhq/design-tokens';
import '@signozhq/dialog';
import '@signozhq/drawer';
import '@signozhq/icons';
import '@signozhq/input';
import '@signozhq/popover';

View File

@@ -0,0 +1,324 @@
.edit-member-drawer {
&__layout {
display: flex;
flex-direction: column;
height: calc(100vh - 48px);
}
&__body {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 16px;
}
&__field {
display: flex;
flex-direction: column;
gap: 8px;
}
&__label {
font-size: 14px;
font-weight: 400;
color: var(--vanilla-400, #c0c1c3);
line-height: 20px;
letter-spacing: -0.07px;
cursor: default;
}
&__input {
height: 32px;
width: 100%;
background: var(--bg-ink-300, #16181d) !important;
border-color: var(--slate-400, #1d212d) !important;
color: var(--vanilla-100, #fff) !important;
border-radius: 2px;
&::placeholder {
color: var(--vanilla-400, #c0c1c3);
}
}
&__input-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 8px;
border-radius: 2px;
background: var(--bg-ink-300, #16181d);
border: 1px solid var(--slate-400, #1d212d);
&--disabled {
cursor: not-allowed;
opacity: 0.8;
}
}
&__email-text {
font-size: 14px;
font-weight: 400;
color: var(--vanilla-400, #c0c1c3);
line-height: 18px;
letter-spacing: -0.07px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
&__lock-icon {
color: var(--vanilla-400, #c0c1c3);
flex-shrink: 0;
margin-left: 6px;
opacity: 0.6;
}
&__role-select {
width: 100%;
height: 32px;
.ant-select-selector {
height: 32px !important;
background: var(--bg-ink-300, #16181d) !important;
border-color: var(--slate-400, #1d212d) !important;
border-radius: 2px !important;
padding: 0 8px !important;
display: flex;
align-items: center;
}
.ant-select-selection-item {
font-size: 14px;
color: var(--vanilla-100, #fff);
line-height: 32px !important;
letter-spacing: -0.07px;
}
.ant-select-arrow {
color: var(--vanilla-400, #c0c1c3);
}
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--vanilla-400, #c0c1c3) !important;
}
}
&__meta {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 4px;
}
&__meta-item {
display: flex;
flex-direction: column;
gap: 4px;
}
&__meta-label {
font-family: Inter, sans-serif;
font-size: 12px;
font-weight: 500;
color: var(--vanilla-400, #c0c1c3);
line-height: 20px;
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 56px;
padding: 0 16px;
border-top: 1px solid var(--slate-400, #1d212d);
flex-shrink: 0;
background: var(--bg-ink-400, #121317);
}
&__footer-left {
display: flex;
align-items: center;
gap: 16px;
}
&__footer-right {
display: flex;
align-items: center;
gap: 12px;
}
&__footer-divider {
width: 1px;
height: 21px;
background: var(--slate-400, #1d212d);
flex-shrink: 0;
}
&__footer-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
font-family: Inter, sans-serif;
font-size: 11px;
font-weight: 400;
line-height: 1;
letter-spacing: 0;
transition: opacity 0.15s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:not(:disabled):hover {
opacity: 0.8;
}
&--danger {
color: var(--bg-cherry-500, #e5484d);
}
&--warning {
color: var(--bg-amber-500, #ffcd56);
}
}
&__delete-body {
font-size: 13px;
font-weight: 400;
color: var(--vanilla-400, #b0b2b4);
line-height: 20px;
letter-spacing: -0.065px;
margin: 0;
strong {
font-weight: 500;
color: var(--vanilla-100, #fff);
}
}
&__delete-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
}
.reset-link-dialog {
[data-slot='dialog-description'] {
width: 510px;
}
&__content {
display: flex;
flex-direction: column;
gap: 16px;
}
&__description {
font-size: 13px;
font-weight: 400;
color: var(--vanilla-400, #b0b2b4);
line-height: 20px;
letter-spacing: -0.065px;
margin: 0;
white-space: normal;
word-break: break-word;
}
&__link-row {
display: flex;
align-items: center;
height: 32px;
overflow: hidden;
background: var(--bg-ink-300, #16181d);
border: 1px solid var(--slate-400, #1d212d);
border-radius: 2px;
}
&__link-text-wrap {
flex: 1;
min-width: 0;
overflow: hidden;
}
&__link-text {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 8px;
font-size: 14px;
font-weight: 400;
color: var(--vanilla-400, #c0c1c3);
line-height: 18px;
letter-spacing: -0.07px;
}
&__copy-btn {
flex-shrink: 0;
height: 32px !important;
border-radius: 0 2px 2px 0 !important;
border-top: none !important;
border-right: none !important;
border-bottom: none !important;
border-left: 1px solid var(--slate-400, #1d212d) !important;
min-width: 64px;
}
}
.lightMode {
.edit-member-drawer {
&__label,
&__email-text,
&__meta-label {
color: #5a5f6e;
}
&__input {
background: #f5f5f7 !important;
border-color: #d9d9d9 !important;
color: #1d1f29 !important;
}
&__input-wrapper {
background: #f5f5f7;
border-color: #d9d9d9;
}
&__role-select {
.ant-select-selector {
background: #f5f5f7 !important;
border-color: #d9d9d9 !important;
}
.ant-select-selection-item {
color: #1d1f29;
}
}
&__footer-divider {
background: #e8e8e8;
}
&__delete-body {
color: #5a5f6e;
strong {
color: #1d1f29;
}
}
}
}

View File

@@ -0,0 +1,471 @@
import { useCallback, useEffect, useState } from 'react';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { DrawerWrapper } from '@signozhq/drawer';
import {
Check,
ChevronDown,
Copy,
Link,
LockKeyhole,
RefreshCw,
Trash2,
X,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { Select } from 'antd';
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
import sendInvite from 'api/v1/invite/create';
import cancelInvite from 'api/v1/invite/id/delete';
import deleteUser from 'api/v1/user/id/delete';
import update from 'api/v1/user/id/update';
import { MemberRow } from 'components/MembersTable/MembersTable';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { useTimezone } from 'providers/Timezone';
import { ROLES } from 'types/roles';
import './EditMemberDrawer.styles.scss';
export interface EditMemberDrawerProps {
member: MemberRow | null;
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
const INVITE_PREFIX = 'invite-';
function formatRoleLabel(role: string): string {
return role.charAt(0).toUpperCase() + role.slice(1).toLowerCase();
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function EditMemberDrawer({
member,
open,
onClose,
onSuccess,
}: EditMemberDrawerProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const [displayName, setDisplayName] = useState('');
const [selectedRole, setSelectedRole] = useState<ROLES>('VIEWER');
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [resetLink, setResetLink] = useState<string | null>(null);
const [showResetLinkDialog, setShowResetLinkDialog] = useState(false);
const [hasCopiedResetLink, setHasCopiedResetLink] = useState(false);
const isInvited = member?.status === '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) {
setDisplayName(member.name);
setSelectedRole(member.role);
}
}, [member]);
const isDirty =
member !== null &&
(displayName !== member.name || selectedRole !== member.role);
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
if (!ts) {
return '—';
}
const d = new Date(ts);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
},
[formatTimezoneAdjustedTimestamp],
);
const handleSave = useCallback(async (): Promise<void> => {
if (!member || !isDirty) {
return;
}
setIsSaving(true);
try {
if (isInvited && inviteId) {
await cancelInvite({ id: inviteId });
await sendInvite({
email: member.email,
name: displayName,
role: selectedRole,
frontendBaseUrl: window.location.origin,
});
toast.success('Invite updated successfully', { richColors: true });
} else {
await update({
userId: member.id,
displayName,
role: selectedRole,
});
toast.success('Member details updated successfully', { richColors: true });
}
onSuccess();
onClose();
} catch {
toast.error(
isInvited ? 'Failed to update invite' : 'Failed to update member details',
{ richColors: true },
);
} finally {
setIsSaving(false);
}
}, [
member,
isDirty,
isInvited,
inviteId,
displayName,
selectedRole,
onSuccess,
onClose,
]);
const handleDelete = useCallback(async (): Promise<void> => {
if (!member) {
return;
}
setIsDeleting(true);
try {
if (isInvited && inviteId) {
await cancelInvite({ id: inviteId });
toast.success('Invitation cancelled successfully', { richColors: true });
} else {
await deleteUser({ userId: member.id });
toast.success('Member deleted successfully', { richColors: true });
}
setShowDeleteConfirm(false);
onSuccess();
onClose();
} catch {
toast.error(
isInvited ? 'Failed to cancel invitation' : 'Failed to delete member',
{ richColors: true },
);
} finally {
setIsDeleting(false);
}
}, [member, isInvited, inviteId, onSuccess, onClose]);
const handleGenerateResetLink = useCallback(async (): Promise<void> => {
if (!member) {
return;
}
setIsGeneratingLink(true);
try {
const response = await getResetPasswordToken({ userId: member.id });
if (response?.data?.token) {
const link = `${window.location.origin}/password-reset?token=${response.data.token}`;
setResetLink(link);
setHasCopiedResetLink(false);
setShowResetLinkDialog(true);
onClose();
}
} catch {
toast.error('Failed to generate password reset link', {
richColors: true,
position: 'top-right',
});
} finally {
setIsGeneratingLink(false);
}
}, [member, onClose]);
const handleCopyResetLink = useCallback(async (): Promise<void> => {
if (!resetLink) {
return;
}
try {
await navigator.clipboard.writeText(resetLink);
setHasCopiedResetLink(true);
setTimeout(() => setHasCopiedResetLink(false), 2000);
toast.success('Reset link copied to clipboard', { richColors: true });
} catch {
toast.error('Failed to copy link', {
richColors: true,
});
}
}, [resetLink]);
const handleCopyInviteLink = useCallback(async (): Promise<void> => {
if (!member?.token) {
toast.error('Invite link is not available', {
richColors: true,
position: 'top-right',
});
return;
}
const inviteLink = `${window.location.origin}${ROUTES.SIGN_UP}?token=${member.token}`;
try {
await navigator.clipboard.writeText(inviteLink);
toast.success('Invite link copied to clipboard', {
richColors: true,
position: 'top-right',
});
} catch {
toast.error('Failed to copy invite link', {
richColors: true,
position: 'top-right',
});
}
}, [member]);
const handleClose = useCallback((): void => {
setShowDeleteConfirm(false);
onClose();
}, [onClose]);
const joinedOnLabel = isInvited ? 'Invited On' : 'Joined On';
const drawerContent = (
<div className="edit-member-drawer__layout">
<div className="edit-member-drawer__body">
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-name">
Name
</label>
<Input
id="member-name"
value={displayName}
onChange={(e): void => setDisplayName(e.target.value)}
className="edit-member-drawer__input"
placeholder="Enter name"
/>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-email">
Email Address
</label>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<span className="edit-member-drawer__email-text">
{member?.email || '—'}
</span>
<LockKeyhole size={16} className="edit-member-drawer__lock-icon" />
</div>
</div>
<div className="edit-member-drawer__field">
<label className="edit-member-drawer__label" htmlFor="member-role">
Roles
</label>
<Select
id="member-role"
value={selectedRole}
onChange={(role): void => setSelectedRole(role as ROLES)}
className="edit-member-drawer__role-select"
suffixIcon={<ChevronDown size={14} />}
getPopupContainer={(triggerNode): HTMLElement =>
(triggerNode?.closest('.edit-member-drawer') as HTMLElement) ||
document.body
}
>
<Select.Option value="ADMIN">{formatRoleLabel('ADMIN')}</Select.Option>
<Select.Option value="EDITOR">{formatRoleLabel('EDITOR')}</Select.Option>
<Select.Option value="VIEWER">{formatRoleLabel('VIEWER')}</Select.Option>
</Select>
</div>
<div className="edit-member-drawer__meta">
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Status</span>
{member?.status === 'Active' ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : (
<Badge color="amber" variant="outline">
INVITED
</Badge>
)}
</div>
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
</div>
{!isInvited && (
<div className="edit-member-drawer__meta-item">
<span className="edit-member-drawer__meta-label">Last Modified</span>
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
</div>
)}
</div>
</div>
<div className="edit-member-drawer__footer">
<div className="edit-member-drawer__footer-left">
<button
type="button"
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
onClick={(): void => setShowDeleteConfirm(true)}
>
<Trash2 size={12} />
{isInvited ? 'Cancel Invite' : 'Delete Member'}
</button>
<div className="edit-member-drawer__footer-divider" />
{isInvited ? (
<button
type="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
type="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">
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</div>
</div>
</div>
);
const deleteDialogTitle = isInvited ? 'Cancel Invitation' : 'Delete Member';
const deleteDialogBody = isInvited
? `Are you sure you want to cancel the invitation for ${member?.email}? They will no longer be able to join the workspace using this invite.`
: `Are you sure you want to delete ${
member?.name || member?.email
}? This will permanently remove their access to the workspace.`;
const deleteConfirmLabel = isInvited ? 'Cancel Invite' : 'Delete Member';
return (
<>
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
direction="right"
type="panel"
showCloseButton
showOverlay={false}
allowOutsideClick
header={{ title: 'Member Details' }}
content={drawerContent}
className="edit-member-drawer"
/>
<DialogWrapper
open={showResetLinkDialog}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowResetLinkDialog(false);
}
}}
title="Password Reset Link"
showCloseButton
width="base"
className="reset-link-dialog"
>
<div className="reset-link-dialog__content">
<p className="reset-link-dialog__description">
This creates a one-time link the team member can use to set a new password
for their SigNoz account.
</p>
<div className="reset-link-dialog__link-row">
<div className="reset-link-dialog__link-text-wrap">
<span className="reset-link-dialog__link-text">{resetLink}</span>
</div>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={handleCopyResetLink}
prefixIcon={
hasCopiedResetLink ? <Check size={12} /> : <Copy size={12} />
}
className="reset-link-dialog__copy-btn"
>
{hasCopiedResetLink ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
</DialogWrapper>
<DialogWrapper
open={showDeleteConfirm}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setShowDeleteConfirm(false);
}
}}
title={deleteDialogTitle}
width="narrow"
className="alert-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="edit-member-drawer__delete-body">{deleteDialogBody}</p>
<DialogFooter className="edit-member-drawer__delete-footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setShowDeleteConfirm(false)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isDeleting}
onClick={handleDelete}
>
<Trash2 size={12} />
{isDeleting ? 'Processing...' : deleteConfirmLabel}
</Button>
</DialogFooter>
</DialogWrapper>
</>
);
}
export default EditMemberDrawer;

View File

@@ -0,0 +1,420 @@
.invite-members-modal {
display: flex !important;
flex-direction: column !important;
max-width: 700px !important;
padding: 0 !important;
gap: 0 !important;
background: #121317 !important;
border: 1px solid #161922 !important;
border-radius: 4px !important;
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04) !important;
[data-slot='dialog-header'] {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #161922;
margin: 0;
flex-shrink: 0;
background: transparent;
}
[data-slot='dialog-title'] {
font-family: Inter, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
color: #ffffff;
margin: 0;
}
// Close button rendered by DialogWrapper
[data-slot='dialog-close'] {
color: #c0c1c3;
opacity: 0.7;
transition: opacity 0.15s;
background: transparent;
border: none;
&:hover {
opacity: 1;
}
}
}
// ─── Content area ─────────────────────────────────────────────────────────────
.invite-members-modal__content {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
flex: 1;
}
// ─── Table Structure ──────────────────────────────────────────────────────────
.invite-members-modal__table {
width: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.invite-members-modal__table-header {
display: flex;
gap: 16px;
align-items: center;
flex-shrink: 0;
height: auto;
width: 100%;
justify-content: flex-end;
.email-header {
flex: 0 0 220px;
width: 220px;
}
.role-header {
flex: 1 0 0;
min-width: 0;
}
.action-header {
flex: 0 0 32px;
width: 32px;
}
.table-header-cell {
color: var(--l1-foreground);
font-family: Inter, sans-serif;
font-size: 13px;
font-style: normal;
font-weight: 600;
line-height: 100%;
letter-spacing: -0.065px;
}
}
.invite-members-modal__container {
display: flex;
flex-direction: column;
gap: 16px !important;
width: 100%;
flex: 0 1 auto;
min-height: 0;
overflow-x: hidden;
}
// ─── Rows & Cells ─────────────────────────────────────────────────────────────
.team-member-row {
display: flex;
width: 100%;
gap: 16px;
> div.email-cell {
flex: 0 0 220px;
width: 220px;
}
> div.role-cell {
flex: 1 0 0;
min-width: 0;
}
> div.action-cell {
flex: 0 0 32px;
width: 32px;
}
}
.team-member-cell {
display: flex;
flex-direction: column;
gap: 8px;
&.action-cell {
display: flex;
align-items: flex-end;
justify-content: center;
height: 32px;
}
}
// ─── Form Elements ────────────────────────────────────────────────────────────
.team-member-email-input {
width: 100%;
height: 32px !important;
border-radius: 2px;
background: var(--l3-background);
border: 1px solid var(--l3-border);
color: var(--l1-foreground);
font-family: Inter, sans-serif;
font-size: 13px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.065px;
padding: 6px 8px;
box-sizing: border-box;
&::placeholder {
color: var(--l3-foreground);
}
&:hover {
border-color: var(--l3-border);
}
&:focus {
border-color: var(--bg-robin-500);
box-shadow: none;
}
}
.team-member-role-select {
width: 100%;
.ant-select-selector {
height: 32px !important;
border-radius: 2px !important;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
color: var(--l1-foreground) !important;
font-family: Inter, sans-serif !important;
font-size: 13px !important;
font-weight: 400 !important;
line-height: 1 !important;
letter-spacing: -0.065px !important;
padding: 0 8px !important;
box-sizing: border-box !important;
.ant-select-selection-placeholder {
color: var(--l3-foreground) !important;
}
.ant-select-selection-item {
color: var(--l1-foreground) !important;
line-height: 30px !important;
}
}
.ant-select-arrow {
color: var(--l3-foreground) !important;
}
&.ant-select-focused .ant-select-selector {
border-color: var(--bg-robin-500) !important;
}
&:hover .ant-select-selector {
border-color: var(--bg-robin-500) !important;
}
}
// ─── Actions ──────────────────────────────────────────────────────────────────
.remove-team-member-button {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
border: none !important;
border-radius: 2px !important;
background: transparent !important;
color: #e5484d !important;
opacity: 0.6 !important;
cursor: pointer;
padding: 0 !important;
transition: background-color 0.2s, opacity 0.2s;
box-shadow: none !important;
svg {
color: #e5484d !important;
width: 14px !important;
height: 14px !important;
}
&:hover {
background: rgba(229, 72, 77, 0.1) !important;
opacity: 0.9 !important;
color: #e5484d !important;
svg {
color: #e5484d !important;
}
}
&:active {
opacity: 0.7 !important;
background: rgba(229, 72, 77, 0.15) !important;
}
&:focus-visible {
outline: 2px solid var(--bg-robin-500);
outline-offset: 2px;
}
}
// ─── Validation ───────────────────────────────────────────────────────────────
.email-error-message {
font-size: 12px;
font-weight: 400;
line-height: 16px;
margin-top: 4px;
width: 100%;
display: block;
color: var(--bg-cherry-500);
}
.invite-team-members-error-callout {
background: rgba(229, 72, 77, 0.1);
border: 1px solid rgba(229, 72, 77, 0.2);
border-radius: 4px;
animation: horizontal-shaking 300ms ease-out;
}
@keyframes horizontal-shaking {
0% {
transform: translateX(0);
}
25% {
transform: translateX(5px);
}
50% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
100% {
transform: translateX(0);
}
}
// ─── Footer ───────────────────────────────────────────────────────────────────
.invite-members-modal__footer {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: space-between !important;
padding: 0 16px !important;
height: 64px !important;
min-height: 64px !important;
border-top: 1px solid #161922 !important;
gap: 0 !important;
flex-shrink: 0;
}
.invite-members-modal__footer-right {
display: flex;
align-items: center;
gap: 12px;
}
.add-another-member-button {
&:hover {
border-color: var(--bg-robin-500) !important;
border-style: dashed !important;
color: var(--l1-foreground);
svg,
[class*='icon'] {
color: var(--l1-foreground) !important;
}
}
}
// ─── Light mode overrides ─────────────────────────────────────────────────────
.lightMode {
.invite-members-modal {
background: #ffffff !important;
border-color: #e9e9e9 !important;
[data-slot='dialog-header'] {
border-bottom-color: #e9e9e9;
}
[data-slot='dialog-title'] {
color: #121317;
}
}
.invite-members-modal__table-header {
.table-header-cell {
color: var(--l3-foreground);
}
}
.team-member-email-input {
background: var(--bg-vanilla-200) !important;
border-color: var(--bg-vanilla-300) !important;
color: var(--text-ink-500) !important;
&::placeholder {
color: var(--text-neutral-light-200) !important;
}
&:hover {
border-color: var(--bg-vanilla-300) !important;
}
&:focus {
border-color: var(--bg-robin-500) !important;
}
}
.team-member-role-select {
.ant-select-selector {
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
color: var(--l1-foreground) !important;
.ant-select-selection-placeholder {
color: var(--l3-foreground) !important;
}
.ant-select-selection-item {
color: var(--l1-foreground) !important;
}
}
.ant-select-arrow {
color: var(--l3-foreground) !important;
}
}
.remove-team-member-button {
color: var(--bg-cherry-500) !important;
svg {
color: var(--bg-cherry-500) !important;
}
&:hover {
background: rgba(245, 101, 101, 0.1) !important;
}
}
.add-another-member-button {
border-color: var(--text-vanilla-300) !important;
color: var(--l3-foreground);
svg {
color: var(--l3-foreground) !important;
}
&:hover {
border-color: var(--bg-robin-500) !important;
color: var(--l1-foreground);
background: rgba(78, 116, 248, 0.1) !important;
svg {
color: var(--l1-foreground) !important;
}
}
}
.invite-members-modal__footer {
border-top-color: #e9e9e9 !important;
}
}

View File

@@ -0,0 +1,335 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@signozhq/button';
import { Callout } from '@signozhq/callout';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { Select, Typography } from 'antd';
import sendInvite from 'api/v1/invite/create';
import { cloneDeep, debounce } from 'lodash-es';
import { ChevronDown, CircleAlert, Plus, Trash2, X } from 'lucide-react';
import { ROLES } from 'types/roles';
import { v4 as uuid } from 'uuid';
import './InviteMembersModal.styles.scss';
interface InviteRow {
id: string;
email: string;
role: ROLES | '';
}
export interface InviteMembersModalProps {
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
const EMPTY_ROW = (): InviteRow => ({ id: uuid(), email: '', role: '' });
const isRowTouched = (row: InviteRow): boolean =>
row.email.trim() !== '' || Boolean(row.role && row.role.trim() !== '');
function InviteMembersModal({
open,
onClose,
onSuccess,
}: InviteMembersModalProps): JSX.Element {
const [rows, setRows] = useState<InviteRow[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [emailValidity, setEmailValidity] = useState<Record<string, boolean>>(
{},
);
const [hasInvalidEmails, setHasInvalidEmails] = useState<boolean>(false);
const [hasInvalidRoles, setHasInvalidRoles] = useState<boolean>(false);
const resetAndClose = useCallback((): void => {
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
setEmailValidity({});
setHasInvalidEmails(false);
setHasInvalidRoles(false);
onClose();
}, [onClose]);
useEffect(() => {
if (open) {
setRows([EMPTY_ROW(), EMPTY_ROW(), EMPTY_ROW()]);
}
}, [open]);
const getValidationErrorMessage = (): string => {
if (hasInvalidEmails && hasInvalidRoles) {
return 'Please enter valid emails and select roles for team members';
}
if (hasInvalidEmails) {
return 'Please enter valid emails for team members';
}
return 'Please select roles for team members';
};
const validateAllUsers = useCallback((): boolean => {
let isValid = true;
let hasEmailErrors = false;
let hasRoleErrors = false;
const updatedEmailValidity: Record<string, boolean> = {};
const touchedRows = rows.filter(isRowTouched);
touchedRows.forEach((row) => {
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(row.email);
const roleValid = Boolean(row.role && row.role.trim() !== '');
if (!emailValid || !row.email) {
isValid = false;
hasEmailErrors = true;
}
if (!roleValid) {
isValid = false;
hasRoleErrors = true;
}
if (row.id) {
updatedEmailValidity[row.id] = emailValid;
}
});
setEmailValidity(updatedEmailValidity);
setHasInvalidEmails(hasEmailErrors);
setHasInvalidRoles(hasRoleErrors);
return isValid;
}, [rows]);
const debouncedValidateEmail = useMemo(
() =>
debounce((email: string, rowId: string) => {
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
setEmailValidity((prev) => ({ ...prev, [rowId]: isValid }));
}, 500),
[],
);
const updateEmail = (id: string, email: string): void => {
const updatedRows = cloneDeep(rows);
const rowToUpdate = updatedRows.find((r) => r.id === id);
if (rowToUpdate) {
rowToUpdate.email = email;
setRows(updatedRows);
if (hasInvalidEmails) {
setHasInvalidEmails(false);
}
if (emailValidity[id] === false) {
setEmailValidity((prev) => ({ ...prev, [id]: true }));
}
debouncedValidateEmail(email, id);
}
};
const updateRole = (id: string, role: ROLES): void => {
const updatedRows = cloneDeep(rows);
const rowToUpdate = updatedRows.find((r) => r.id === id);
if (rowToUpdate) {
rowToUpdate.role = role;
setRows(updatedRows);
if (hasInvalidRoles) {
setHasInvalidRoles(false);
}
}
};
const addRow = (): void => {
setRows((prev) => [...prev, EMPTY_ROW()]);
};
const removeRow = (id: string): void => {
setRows((prev) => prev.filter((r) => r.id !== id));
};
const handleSubmit = useCallback(async (): Promise<void> => {
if (!validateAllUsers()) {
return;
}
const touchedRows = rows.filter(isRowTouched);
if (touchedRows.length === 0) {
return;
}
setIsSubmitting(true);
let hasError = false;
await Promise.all(
touchedRows.map(
async (row): Promise<void> => {
try {
await sendInvite({
email: row.email.trim(),
name: '',
role: row.role as ROLES,
frontendBaseUrl: window.location.origin,
});
} catch (err: unknown) {
hasError = true;
const apiErr = err as {
response?: { status: number };
getErrorMessage?: () => string;
};
if (apiErr?.response?.status === 409) {
toast.error(`${row.email} is already a member`, { richColors: true });
} else {
const errorMessage = apiErr?.getErrorMessage?.() ?? 'An error occurred';
toast.error(`Failed to invite ${row.email}: ${errorMessage}`, {
richColors: true,
});
}
}
},
),
);
setIsSubmitting(false);
if (!hasError) {
toast.success('Invites sent successfully', { richColors: true });
resetAndClose();
onSuccess?.();
} else {
onSuccess?.();
}
}, [rows, onSuccess, resetAndClose, validateAllUsers]);
const touchedRows = rows.filter(isRowTouched);
const isSubmitDisabled = isSubmitting || touchedRows.length === 0;
return (
<DialogWrapper
title="Invite Team Members"
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
resetAndClose();
}
}}
showCloseButton
width="wide"
className="invite-members-modal"
disableOutsideClick
>
<div className="invite-members-modal__content">
<div className="invite-members-modal__table">
<div className="invite-members-modal__table-header">
<div className="table-header-cell email-header">Email address</div>
<div className="table-header-cell role-header">Roles</div>
<div className="table-header-cell action-header" />
</div>
<div className="invite-members-modal__container">
{rows.map(
(row): JSX.Element => (
<div key={row.id} className="team-member-row">
<div className="team-member-cell email-cell">
<Input
type="email"
placeholder="john@signoz.io"
value={row.email}
onChange={(e): void => updateEmail(row.id, e.target.value)}
className="team-member-email-input"
/>
{emailValidity[row.id] === false && row.email.trim() !== '' && (
<Typography.Text className="email-error-message">
Invalid email address
</Typography.Text>
)}
</div>
<div className="team-member-cell role-cell">
<Select
value={row.role || undefined}
onChange={(role): void => updateRole(row.id, role as ROLES)}
className="team-member-role-select"
placeholder="Select roles"
suffixIcon={<ChevronDown size={14} />}
getPopupContainer={(triggerNode): HTMLElement =>
(triggerNode?.closest('.invite-members-modal') as HTMLElement) ||
document.body
}
>
<Select.Option value="VIEWER">Viewer</Select.Option>
<Select.Option value="EDITOR">Editor</Select.Option>
<Select.Option value="ADMIN">Admin</Select.Option>
</Select>
</div>
<div className="team-member-cell action-cell">
{rows.length > 1 && (
<Button
variant="ghost"
color="secondary"
className="remove-team-member-button"
onClick={(): void => removeRow(row.id)}
aria-label="Remove row"
>
<Trash2 size={12} />
</Button>
)}
</div>
</div>
),
)}
</div>
</div>
{(hasInvalidEmails || hasInvalidRoles) && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className="invite-team-members-error-callout"
description={getValidationErrorMessage()}
/>
)}
</div>
{/* Footer */}
<DialogFooter className="invite-members-modal__footer">
<Button
variant="dashed"
color="secondary"
size="sm"
className="add-another-member-button"
prefixIcon={<Plus size={12} />}
onClick={addRow}
>
Add another
</Button>
<div className="invite-members-modal__footer-right">
<Button
type="button"
variant="solid"
color="secondary"
size="sm"
onClick={resetAndClose}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
onClick={handleSubmit}
disabled={isSubmitDisabled}
>
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
</Button>
</div>
</DialogFooter>
</DialogWrapper>
);
}
export default InviteMembersModal;

View File

@@ -0,0 +1,250 @@
// ─── Table wrapper ────────────────────────────────────────────────────────────
.members-table-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 4px;
}
// ─── Ant Design Table overrides ───────────────────────────────────────────────
.members-table {
// Flatten Ant Design chrome
.ant-table {
background: transparent;
font-size: 14px;
}
.ant-table-container {
border-radius: 0 !important;
border: none !important;
}
// ── Header ──────────────────────────────────────────────────────────────
.ant-table-thead {
> tr > th,
> tr > td {
background: var(--bg-ink-500, #0b0c0e);
font-family: Inter, sans-serif;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.44px;
text-transform: uppercase;
color: var(--vanilla-400, #c0c1c3);
line-height: 18px;
padding: 8px 16px;
border-bottom: none !important;
border-top: none !important;
// Remove the column divider pseudo-element
&::before {
display: none !important;
}
// Sorter icon color
.ant-table-column-sorter {
color: var(--vanilla-400, #c0c1c3);
opacity: 0.6;
}
.ant-table-column-sorter-up.active,
.ant-table-column-sorter-down.active {
color: var(--vanilla-100, #ffffff);
opacity: 1;
}
}
}
// ── Body rows ────────────────────────────────────────────────────────────
.ant-table-tbody {
> tr > td {
border-bottom: none !important;
padding: 8px 16px;
background: transparent;
transition: none;
}
// Even-indexed rows (0, 2, 4 …) get a subtle tint — matches Figma
> tr.members-table-row--tinted > td {
background: rgba(171, 189, 255, 0.02);
}
// Hover — slightly brighter tint
> tr:hover > td {
background: rgba(171, 189, 255, 0.04) !important;
}
}
// Remove outer table border
.ant-table-wrapper,
.ant-table-container,
.ant-spin-nested-loading,
.ant-spin-container {
border: none !important;
box-shadow: none !important;
}
// ── Status cell — right-align badge and add 4px right padding ─────────
.member-status-cell {
padding-right: 4px !important;
}
}
// ─── Name / Email cell ────────────────────────────────────────────────────────
.member-name-email-cell {
display: flex;
align-items: center;
gap: 4px;
height: 22px;
overflow: hidden;
.member-name {
font-size: 14px;
font-weight: 500;
color: var(--vanilla-400, #c0c1c3);
line-height: 20px;
letter-spacing: -0.07px;
white-space: nowrap;
flex-shrink: 0;
}
.member-email {
font-size: 14px;
font-weight: 400;
color: var(--slate-50, #62687c);
line-height: 20px;
letter-spacing: -0.07px;
flex: 1 0 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// ─── Joined On ────────────────────────────────────────────────────────────────
.member-joined-date {
font-size: 14px;
font-weight: 400;
color: var(--vanilla-400, #c0c1c3);
line-height: 18px;
letter-spacing: -0.07px;
white-space: nowrap;
}
.member-joined-dash {
font-size: 14px;
color: var(--slate-50, #62687c);
line-height: 18px;
letter-spacing: -0.07px;
}
// ─── Empty state ─────────────────────────────────────────────────────────────
.members-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 16px;
gap: 8px;
&__emoji {
font-size: 24px;
line-height: 1;
}
&__text {
font-size: 14px;
font-weight: 400;
color: var(--vanilla-400, #c0c1c3);
margin: 0;
line-height: 20px;
strong {
font-weight: 500;
color: var(--vanilla-100, #ffffff);
}
}
}
// ─── Pagination ───────────────────────────────────────────────────────────────
.members-table-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 16px;
.ant-pagination-total-text {
margin-right: auto;
}
.members-pagination-range {
font-family: Inter, sans-serif;
font-size: 12px;
color: var(--vanilla-400, #c0c1c3);
}
.members-pagination-total {
font-family: Inter, sans-serif;
font-size: 12px;
color: var(--vanilla-400, #c0c1c3);
opacity: 0.5;
}
}
// ─── Light mode overrides ─────────────────────────────────────────────────────
.lightMode {
.members-table {
.ant-table-thead {
> tr > th,
> tr > td {
background: #f5f5f7;
color: #5a5f6e;
}
}
.ant-table-tbody {
> tr.members-table-row--tinted > td {
background: rgba(0, 0, 0, 0.015);
}
> tr:hover > td {
background: rgba(0, 0, 0, 0.03) !important;
}
}
}
.member-name-email-cell {
.member-name {
color: #1d1f29;
}
.member-email {
color: #5a5f6e;
}
}
.member-joined-dash {
color: #5a5f6e;
}
.member-joined-date {
color: #1d1f29;
}
.members-empty-state {
&__text {
color: #5a5f6e;
strong {
color: #1d1f29;
}
}
}
.members-pagination-range,
.members-pagination-total {
color: #5a5f6e;
}
}

View File

@@ -0,0 +1,232 @@
import type React from 'react';
import { Badge } from '@signozhq/badge';
import { Pagination, Table, Tooltip } from 'antd';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { useTimezone } from 'providers/Timezone';
import { ROLES } from 'types/roles';
import './MembersTable.styles.scss';
export interface MemberRow {
id: string;
name: string;
email: string;
role: ROLES;
status: 'Active' | 'Invited';
joinedOn: string | null;
updatedAt?: string | null;
token?: string | null;
}
interface MembersTableProps {
data: MemberRow[];
loading: boolean;
total: number;
currentPage: number;
pageSize: number;
searchQuery: string;
onPageChange: (page: number) => void;
onRowClick?: (member: MemberRow) => void;
onSortChange?: (
sorter: SorterResult<MemberRow> | SorterResult<MemberRow>[],
) => void;
}
function formatRoleLabel(role: string): string {
return role.charAt(0).toUpperCase() + role.slice(1).toLowerCase();
}
function NameEmailCell({
name,
email,
}: {
name: string;
email: string;
}): JSX.Element {
return (
<div className="member-name-email-cell">
{name && (
<span className="member-name" title={name}>
{name}
</span>
)}
<Tooltip title={email} overlayClassName="member-tooltip">
<span className="member-email">{email}</span>
</Tooltip>
</div>
);
}
function StatusBadge({ status }: { status: MemberRow['status'] }): JSX.Element {
if (status === 'Active') {
return (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
);
}
return (
<Badge color="amber" variant="outline">
INVITED
</Badge>
);
}
function MembersEmptyState({
searchQuery,
}: {
searchQuery: string;
}): JSX.Element {
return (
<div className="members-empty-state">
<span className="members-empty-state__emoji">🧐</span>
{searchQuery ? (
<p className="members-empty-state__text">
No results for <strong>{searchQuery}</strong>
</p>
) : (
<p className="members-empty-state__text">No members found</p>
)}
</div>
);
}
function MembersTable({
data,
loading,
total,
currentPage,
pageSize,
searchQuery,
onPageChange,
onRowClick,
onSortChange,
}: MembersTableProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const formatJoinedOn = (date: string | null): string => {
if (!date) {
return '—';
}
const d = new Date(date);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.DASH_DATETIME);
};
const columns: ColumnsType<MemberRow> = [
{
title: 'Name / Email',
dataIndex: 'name',
key: 'name',
render: (_, record): JSX.Element => (
<NameEmailCell name={record.name} email={record.email} />
),
},
{
title: 'Roles',
dataIndex: 'role',
key: 'role',
width: 180,
render: (role: ROLES): JSX.Element => (
<Badge color="vanilla">{formatRoleLabel(role)}</Badge>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'right' as const,
className: 'member-status-cell',
sorter: (a, b): number => a.status.localeCompare(b.status),
render: (status: MemberRow['status']): JSX.Element => (
<StatusBadge status={status} />
),
},
{
title: 'Joined On',
dataIndex: 'joinedOn',
key: 'joinedOn',
width: 250,
align: 'right' as const,
sorter: (a, b): number => {
if (!a.joinedOn && !b.joinedOn) {
return 0;
}
if (!a.joinedOn) {
return 1;
}
if (!b.joinedOn) {
return -1;
}
return new Date(a.joinedOn).getTime() - new Date(b.joinedOn).getTime();
},
render: (joinedOn: string | null): JSX.Element => {
const formatted = formatJoinedOn(joinedOn);
const isDash = formatted === '—';
return (
<span className={isDash ? 'member-joined-dash' : 'member-joined-date'}>
{formatted}
</span>
);
},
},
];
const showPaginationTotal = (_total: number, range: number[]): JSX.Element => (
<>
<span className="members-pagination-range">
{range[0]} &#8212; {range[1]}
</span>
<span className="members-pagination-total"> of {_total}</span>
</>
);
return (
<div className="members-table-wrapper">
<Table<MemberRow>
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={false}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'members-table-row--tinted' : ''
}
onRow={(record): React.HTMLAttributes<HTMLElement> => ({
onClick: (): void => onRowClick?.(record),
style: onRowClick ? { cursor: 'pointer' } : undefined,
})}
onChange={(_, __, sorter): void => {
if (onSortChange) {
onSortChange(
sorter as SorterResult<MemberRow> | SorterResult<MemberRow>[],
);
}
}}
showSorterTooltip={false}
locale={{
emptyText: <MembersEmptyState searchQuery={searchQuery} />,
}}
className="members-table"
/>
{total > pageSize && (
<Pagination
current={currentPage}
pageSize={pageSize}
total={total}
showTotal={showPaginationTotal}
showSizeChanger={false}
onChange={onPageChange}
className="members-table-pagination"
/>
)}
</div>
);
}
export default MembersTable;

View File

@@ -56,6 +56,7 @@ const ROUTES = {
TRACE_EXPLORER: '/trace-explorer',
BILLING: '/settings/billing',
ROLES_SETTINGS: '/settings/roles',
MEMBERS_SETTINGS: '/settings/members',
SUPPORT: '/support',
LOGS_SAVE_VIEWS: '/logs/saved-views',
TRACES_SAVE_VIEWS: '/traces/saved-views',

View File

@@ -0,0 +1,105 @@
.members-settings {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 8px 24px 16px;
height: 100%;
}
.members-settings__header {
display: flex;
flex-direction: column;
gap: 4px;
.ant-typography {
margin-bottom: 0 !important;
}
}
.members-settings__title {
font-size: 18px !important;
font-weight: 500 !important;
color: var(--vanilla-100) !important;
letter-spacing: -0.09px;
line-height: 28px !important;
margin: 0 !important;
}
.members-settings__subtitle {
font-size: 14px;
color: var(--vanilla-400);
letter-spacing: -0.07px;
line-height: 20px;
}
.members-settings__controls {
display: flex;
align-items: center;
gap: 8px;
}
.members-filter-trigger {
display: flex;
align-items: center;
gap: 4px;
border: 1px solid var(--border);
border-radius: 2px;
background-color: var(--l2-background);
&__chevron {
flex-shrink: 0;
color: var(--card-foreground);
}
}
.members-filter-dropdown {
.ant-dropdown-menu {
background: rgba(18, 19, 23, 0.9);
border: 1px solid var(--slate-400);
border-radius: 4px;
backdrop-filter: blur(20px);
padding: 11px 13px;
box-shadow: 4px 10px 16px 0 rgba(0, 0, 0, 0.2);
}
.ant-dropdown-menu-item {
background: transparent;
padding: 4px 0;
&:hover {
background: transparent;
}
}
}
.members-filter-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
font-size: 14px;
color: var(--vanilla-100);
letter-spacing: 0.14px;
min-width: 170px;
&__check {
color: var(--vanilla-100);
font-size: 14px;
}
}
.members-settings__search {
flex: 1;
min-width: 0;
}
.members-search-input {
height: 32px;
color: var(--l1-foreground);
background-color: var(--l2-background);
border-color: var(--border);
&::placeholder {
color: var(--l2-foreground);
}
}

View File

@@ -0,0 +1,256 @@
import { useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/button';
import { ChevronDown, Plus } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import type { MenuProps } from 'antd';
import { Dropdown, Typography } from 'antd';
import getPendingInvites from 'api/v1/invite/get';
import getAll from 'api/v1/user/get';
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import useUrlQuery from 'hooks/useUrlQuery';
import { useAppContext } from 'providers/App/App';
import './MembersSettings.styles.scss';
const PAGE_SIZE = 20;
type FilterMode = 'all' | 'invited';
function MembersSettings(): JSX.Element {
const { org } = useAppContext();
const history = useHistory();
const urlQuery = useUrlQuery();
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<FilterMode>('all');
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [selectedMember, setSelectedMember] = useState<MemberRow | null>(null);
const {
data: usersData,
isLoading: isUsersLoading,
refetch: refetchUsers,
} = useQuery({
queryFn: getAll,
queryKey: ['getOrgUser', org?.[0]?.id],
});
const {
data: invitesData,
isLoading: isInvitesLoading,
refetch: refetchInvites,
} = useQuery({
queryFn: getPendingInvites,
queryKey: ['getPendingInvites'],
});
const isLoading = isUsersLoading || isInvitesLoading;
const allMembers = useMemo((): MemberRow[] => {
const activeMembers: MemberRow[] = (usersData?.data ?? []).map((user) => ({
id: user.id,
name: user.displayName,
email: user.email,
role: user.role,
status: 'Active' as const,
joinedOn: user.createdAt ? String(user.createdAt) : null,
updatedAt: (user as { updatedAt?: number }).updatedAt
? String((user as { updatedAt?: number }).updatedAt)
: null,
}));
const pendingInvites: MemberRow[] = (invitesData?.data ?? []).map(
(invite) => ({
id: `invite-${invite.id}`,
name: invite.name ?? '',
email: invite.email,
role: invite.role,
status: 'Invited' as const,
joinedOn: invite.createdAt ? String(invite.createdAt) : null,
token: invite.token ?? null,
}),
);
return [...activeMembers, ...pendingInvites];
}, [usersData, invitesData]);
const filteredMembers = useMemo((): MemberRow[] => {
let result = allMembers;
if (filterMode === 'invited') {
result = result.filter((m) => m.status === 'Invited');
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(m) =>
m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q),
);
}
return result;
}, [allMembers, filterMode, searchQuery]);
const paginatedMembers = useMemo((): MemberRow[] => {
const start = (currentPage - 1) * PAGE_SIZE;
return filteredMembers.slice(start, start + PAGE_SIZE);
}, [filteredMembers, currentPage]);
const setPage = useCallback(
(page: number): void => {
urlQuery.set('page', String(page));
history.replace({ search: urlQuery.toString() });
},
[history, urlQuery],
);
const pendingCount = invitesData?.data?.length ?? 0;
const totalCount = allMembers.length;
const filterMenuItems: MenuProps['items'] = [
{
key: 'all',
label: (
<div className="members-filter-option">
<span>All members {totalCount}</span>
{filterMode === 'all' && (
<span className="members-filter-option__check"></span>
)}
</div>
),
onClick: (): void => {
setFilterMode('all');
setPage(1);
},
},
{
key: 'invited',
label: (
<div className="members-filter-option">
<span>Pending invites {pendingCount}</span>
{filterMode === 'invited' && (
<span className="members-filter-option__check"></span>
)}
</div>
),
onClick: (): void => {
setFilterMode('invited');
setPage(1);
},
},
];
const filterLabel =
filterMode === 'all'
? `All members ⎯ ${totalCount}`
: `Pending invites ⎯ ${pendingCount}`;
const handleInviteSuccess = useCallback((): void => {
refetchUsers();
refetchInvites();
}, [refetchUsers, refetchInvites]);
const handleRowClick = useCallback((member: MemberRow): void => {
setSelectedMember(member);
}, []);
const handleDrawerClose = useCallback((): void => {
setSelectedMember(null);
}, []);
const handleMemberEditSuccess = useCallback((): void => {
refetchUsers();
refetchInvites();
setSelectedMember(null);
}, [refetchUsers, refetchInvites]);
return (
<>
<div className="members-settings">
<div className="members-settings__header">
<Typography.Title level={5} className="members-settings__title">
Members
</Typography.Title>
<Typography.Text className="members-settings__subtitle">
Overview of people added to this workspace.
</Typography.Text>
</div>
{/* Controls row */}
<div className="members-settings__controls">
<Dropdown
menu={{ items: filterMenuItems }}
trigger={['click']}
overlayClassName="members-filter-dropdown"
>
<Button
variant="solid"
size="sm"
color="secondary"
className="members-filter-trigger"
>
<span>{filterLabel}</span>
<ChevronDown size={12} className="members-filter-trigger__chevron" />
</Button>
</Dropdown>
<div className="members-settings__search">
<Input
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e): void => {
setSearchQuery(e.target.value);
setPage(1);
}}
className="members-search-input"
color="secondary"
/>
</div>
<Button
variant="solid"
size="sm"
color="primary"
onClick={(): void => setIsInviteModalOpen(true)}
>
<Plus size={12} />
Invite member
</Button>
</div>
</div>
<MembersTable
data={paginatedMembers}
loading={isLoading}
total={filteredMembers.length}
currentPage={currentPage}
pageSize={PAGE_SIZE}
searchQuery={searchQuery}
onPageChange={setPage}
onRowClick={handleRowClick}
/>
<InviteMembersModal
open={isInviteModalOpen}
onClose={(): void => setIsInviteModalOpen(false)}
onSuccess={handleInviteSuccess}
/>
<EditMemberDrawer
member={selectedMember}
open={selectedMember !== null}
onClose={handleDrawerClose}
onSuccess={handleMemberEditSuccess}
/>
</>
);
}
export default MembersSettings;

View File

@@ -33,6 +33,7 @@ import {
Unplug,
User,
UserPlus,
Users,
} from 'lucide-react';
import { SecondaryMenuItemKey, SidebarItem } from './sideNav.types';
@@ -326,6 +327,13 @@ export const settingsMenuItems: SidebarItem[] = [
isEnabled: false,
itemKey: 'members-sso',
},
{
key: ROUTES.MEMBERS_SETTINGS,
label: 'Members',
icon: <Users size={16} />,
isEnabled: false,
itemKey: 'members',
},
{
key: ROUTES.CUSTOM_DOMAIN_SETTINGS,
label: 'Custom Domain',

View File

@@ -153,6 +153,7 @@ export const routesToSkip = [
ROUTES.VERSION,
ROUTES.ALL_DASHBOARD,
ROUTES.ORG_SETTINGS,
ROUTES.MEMBERS_SETTINGS,
ROUTES.INGESTION_SETTINGS,
ROUTES.CUSTOM_DOMAIN_SETTINGS,
ROUTES.API_KEYS,

View File

@@ -0,0 +1,7 @@
import MembersSettingsContainer from 'container/MembersSettings/MembersSettings';
function MembersSettings(): JSX.Element {
return <MembersSettingsContainer />;
}
export default MembersSettings;

View File

@@ -83,6 +83,7 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SHORTCUTS
? true
: item.isEnabled,
@@ -112,6 +113,7 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.INGESTION_SETTINGS
? true
: item.isEnabled,
@@ -137,6 +139,7 @@ function SettingsPage(): JSX.Element {
isEnabled:
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.ROLES_SETTINGS
? true
: item.isEnabled,

View File

@@ -27,8 +27,10 @@ import {
Plus,
Shield,
User,
Users,
} from 'lucide-react';
import ChannelsEdit from 'pages/ChannelsEdit';
import MembersSettings from 'pages/MembersSettings';
import Shortcuts from 'pages/Shortcuts';
export const organizationSettings = (t: TFunction): RouteTabProps['routes'] => [
@@ -150,6 +152,19 @@ export const billingSettings = (t: TFunction): RouteTabProps['routes'] => [
},
];
export const membersSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: MembersSettings,
name: (
<div className="periscope-tab">
<Users size={16} /> {t('routes:members').toString()}
</div>
),
route: ROUTES.MEMBERS_SETTINGS,
key: ROUTES.MEMBERS_SETTINGS,
},
];
export const rolesSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: RolesSettings,

View File

@@ -12,6 +12,7 @@ import {
generalSettings,
ingestionSettings,
keyboardShortcuts,
membersSettings,
multiIngestionSettings,
mySettings,
organizationSettings,
@@ -60,7 +61,7 @@ export const getRoutes = (
settings.push(...alertChannels(t));
if (isAdmin) {
settings.push(...apiKeys(t));
settings.push(...apiKeys(t), ...membersSettings(t));
}
if ((isCloudUser || isEnterpriseSelfHostedUser) && isAdmin) {

View File

@@ -98,6 +98,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLES_SETTINGS: ['ADMIN'],
MEMBERS_SETTINGS: ['ADMIN'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -4111,7 +4111,7 @@
aria-hidden "^1.1.1"
react-remove-scroll "2.5.4"
"@radix-ui/react-dialog@^1.1.11", "@radix-ui/react-dialog@^1.1.6":
"@radix-ui/react-dialog@^1.1.1", "@radix-ui/react-dialog@^1.1.11", "@radix-ui/react-dialog@^1.1.6":
version "1.1.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz#1de3d7a7e9a17a9874d29c07f5940a18a119b632"
integrity sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==
@@ -5066,6 +5066,35 @@
resolved "https://registry.yarnpkg.com/@signozhq/design-tokens/-/design-tokens-2.1.1.tgz#9c36d433fd264410713cc0c5ebdd75ce0ebecba3"
integrity sha512-SdziCHg5Lwj+6oY6IRUPplaKZ+kTHjbrlhNj//UoAJ8aQLnRdR2F/miPzfSi4vrYw88LtXxNA9J9iJyacCp37A==
"@signozhq/dialog@0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@signozhq/dialog/-/dialog-0.0.2.tgz#55bd8e693f76325fda9aabe3197350e0adc163c4"
integrity sha512-YT5t3oZpGkAuWptTqhCgVtLjxsRQrEIrQHFoXpP9elM1+O4TS9WHr+07BLQutOVg6u9n9pCvW3OYf0SCETkDVQ==
dependencies:
"@radix-ui/react-dialog" "^1.1.11"
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
lucide-react "^0.445.0"
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/drawer@0.0.4":
version "0.0.4"
resolved "https://registry.yarnpkg.com/@signozhq/drawer/-/drawer-0.0.4.tgz#7c6e6779602113f55df8a55076e68b9cc13c7d79"
integrity sha512-m/shStl5yVPjHjrhDAh3EeKqqTtMmZUBVlgJPUGgoNV3sFsuN6JNaaAtEJI8cQBWkbEEiHLWKVkL/vhbQ7YrUg==
dependencies:
"@radix-ui/react-dialog" "^1.1.11"
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
lucide-react "^0.445.0"
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
vaul "^1.1.2"
"@signozhq/icons@0.1.0", "@signozhq/icons@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@signozhq/icons/-/icons-0.1.0.tgz#00dfb430dbac423bfff715876f91a7b8a72509e4"
@@ -19823,6 +19852,13 @@ vary@~1.1.2:
resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
vaul@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vaul/-/vaul-1.1.2.tgz#c959f8b9dc2ed4f7d99366caee433fbef91f5ba9"
integrity sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==
dependencies:
"@radix-ui/react-dialog" "^1.1.1"
vfile-location@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-4.1.0.tgz#69df82fb9ef0a38d0d02b90dd84620e120050dd0"