mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-27 18:52:53 +00:00
Compare commits
2 Commits
main
...
members-pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35443b3bd3 | ||
|
|
b797464995 |
@@ -13,5 +13,6 @@
|
||||
"pipelines": "Pipelines",
|
||||
"archives": "Archives",
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles"
|
||||
"roles": "Roles",
|
||||
"members": "Members"
|
||||
}
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
"pipelines": "Pipelines",
|
||||
"archives": "Archives",
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles"
|
||||
"roles": "Roles",
|
||||
"members": "Members"
|
||||
}
|
||||
|
||||
259
frontend/src/components/MembersTable/MembersTable.styles.scss
Normal file
259
frontend/src/components/MembersTable/MembersTable.styles.scss
Normal file
@@ -0,0 +1,259 @@
|
||||
// ─── 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;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Permissions dash ─────────────────────────────────────────────────────────
|
||||
.member-dash {
|
||||
font-size: 14px;
|
||||
color: var(--slate-50, #62687c);
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
// ─── 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-dash,
|
||||
.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;
|
||||
}
|
||||
}
|
||||
228
frontend/src/components/MembersTable/MembersTable.tsx
Normal file
228
frontend/src/components/MembersTable/MembersTable.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
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;
|
||||
}
|
||||
|
||||
interface MembersTableProps {
|
||||
data: MemberRow[];
|
||||
loading: boolean;
|
||||
total: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
searchQuery: string;
|
||||
onPageChange: (page: number) => 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,
|
||||
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: 'Permissions',
|
||||
key: 'permissions',
|
||||
width: 250,
|
||||
render: (): JSX.Element => <span className="member-dash">―</span>,
|
||||
},
|
||||
{
|
||||
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: 220,
|
||||
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]} — {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' : ''
|
||||
}
|
||||
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;
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
.invite-member-modal {
|
||||
.ant-modal-content {
|
||||
background: #121317;
|
||||
border: 1px solid #161922;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #161922;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--vanilla-100);
|
||||
letter-spacing: -0.065px;
|
||||
}
|
||||
|
||||
&__close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--vanilla-400);
|
||||
line-height: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
&__form {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__fields {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&--email {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&--role {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--vanilla-400);
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
width: 100% !important;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
&__select {
|
||||
width: 100% !important;
|
||||
height: 32px;
|
||||
|
||||
.ant-select-selector {
|
||||
background: var(--ink-300) !important;
|
||||
border-color: var(--slate-400) !important;
|
||||
border-radius: 2px !important;
|
||||
height: 32px !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--vanilla-100);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: rgba(192, 193, 195, 0.4);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
&__delete-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 1px;
|
||||
|
||||
&--with-label {
|
||||
padding-bottom: calc(20px + 8px + 1px); // label height + gap + fine tune
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #161922;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&__footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__cancel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: #161922;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--vanilla-400);
|
||||
line-height: 24px;
|
||||
|
||||
&:hover {
|
||||
color: var(--vanilla-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invite-member-modal__select-dropdown {
|
||||
.ant-select-item {
|
||||
color: var(--vanilla-400);
|
||||
|
||||
&-option-selected {
|
||||
color: var(--vanilla-100);
|
||||
background: var(--slate-300) !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--slate-400) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Plus, Trash2, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Form, Modal, Select } from 'antd';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import './InviteMemberModal.styles.scss';
|
||||
|
||||
interface InviteRow {
|
||||
email: string;
|
||||
role: ROLES;
|
||||
}
|
||||
|
||||
interface InviteMemberModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS: { label: string; value: ROLES }[] = [
|
||||
{ label: 'Admin', value: 'ADMIN' },
|
||||
{ label: 'Editor', value: 'EDITOR' },
|
||||
{ label: 'Viewer', value: 'VIEWER' },
|
||||
];
|
||||
|
||||
function InviteMemberModal({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: InviteMemberModalProps): JSX.Element {
|
||||
const [form] = Form.useForm<{ members: InviteRow[] }>();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
}, [form, onClose]);
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<void> => {
|
||||
let values: { members: InviteRow[] };
|
||||
try {
|
||||
values = await form.validateFields();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
let hasError = false;
|
||||
|
||||
await Promise.all(
|
||||
values.members.map(async (member) => {
|
||||
try {
|
||||
await sendInvite({
|
||||
email: member.email,
|
||||
name: '',
|
||||
role: member.role,
|
||||
frontendBaseUrl: window.location.origin,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
hasError = true;
|
||||
const apiErr = err as {
|
||||
getErrorCode?: () => string;
|
||||
getErrorMessage?: () => string;
|
||||
response?: { status: number };
|
||||
};
|
||||
if (apiErr?.response?.status === 409) {
|
||||
notifications.error({
|
||||
message: `${member.email} is already a member`,
|
||||
});
|
||||
} else {
|
||||
notifications.error({
|
||||
message: `Failed to invite ${member.email}`,
|
||||
description: apiErr?.getErrorMessage?.() ?? 'An error occurred',
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (!hasError) {
|
||||
notifications.success({ message: 'Invites sent successfully' });
|
||||
form.resetFields();
|
||||
onSuccess();
|
||||
onClose();
|
||||
} else {
|
||||
onSuccess();
|
||||
}
|
||||
}, [form, notifications, onClose, onSuccess]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
width={560}
|
||||
centered
|
||||
destroyOnClose
|
||||
footer={null}
|
||||
closable={false}
|
||||
className="invite-member-modal"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="invite-member-modal__header">
|
||||
<span className="invite-member-modal__title">Invite Team Members</span>
|
||||
<button
|
||||
type="button"
|
||||
className="invite-member-modal__close"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={{ members: [{ email: '', role: 'VIEWER' as ROLES }] }}
|
||||
className="invite-member-modal__form"
|
||||
>
|
||||
<Form.List name="members">
|
||||
{(fields, { add, remove }): JSX.Element => (
|
||||
<>
|
||||
{fields.map(({ key, name }, index) => (
|
||||
<div key={key} className="invite-member-modal__row">
|
||||
<div className="invite-member-modal__fields">
|
||||
<div className="invite-member-modal__field invite-member-modal__field--email">
|
||||
{index === 0 && (
|
||||
<label
|
||||
htmlFor={`member-${name}-email`}
|
||||
className="invite-member-modal__label"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
)}
|
||||
<Form.Item
|
||||
name={[name, 'email']}
|
||||
rules={[
|
||||
{ required: true, message: '' },
|
||||
{ type: 'email', message: '' },
|
||||
]}
|
||||
noStyle
|
||||
>
|
||||
<Input
|
||||
id={`member-${name}-email`}
|
||||
placeholder="john@example.com"
|
||||
className="invite-member-modal__input"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="invite-member-modal__field invite-member-modal__field--role">
|
||||
{index === 0 && (
|
||||
<label
|
||||
htmlFor={`member-${name}-role`}
|
||||
className="invite-member-modal__label"
|
||||
>
|
||||
Roles
|
||||
</label>
|
||||
)}
|
||||
<Form.Item
|
||||
name={[name, 'role']}
|
||||
rules={[{ required: true, message: '' }]}
|
||||
noStyle
|
||||
>
|
||||
<Select
|
||||
id={`member-${name}-role`}
|
||||
placeholder="Select role"
|
||||
className="invite-member-modal__select"
|
||||
options={ROLE_OPTIONS}
|
||||
popupClassName="invite-member-modal__select-dropdown"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`invite-member-modal__delete-wrap ${
|
||||
index === 0 ? 'invite-member-modal__delete-wrap--with-label' : ''
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(): void => remove(name)}
|
||||
disabled={fields.length === 1}
|
||||
aria-label="Remove"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="invite-member-modal__footer">
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={(): void => add({ email: '', role: 'VIEWER' as ROLES })}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add another
|
||||
</Button>
|
||||
<div className="invite-member-modal__footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="invite-member-modal__cancel"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</button>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Inviting...' : 'Invite Team Members'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteMemberModal;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
232
frontend/src/container/MembersSettings/MembersSettings.tsx
Normal file
232
frontend/src/container/MembersSettings/MembersSettings.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
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 MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import InviteMemberModal from './InviteMemberModal/InviteMemberModal';
|
||||
|
||||
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 {
|
||||
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,
|
||||
}));
|
||||
|
||||
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: 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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="members-settings">
|
||||
{/* Page header */}
|
||||
<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>
|
||||
{/* Table */}
|
||||
<MembersTable
|
||||
data={paginatedMembers}
|
||||
loading={isLoading}
|
||||
total={filteredMembers.length}
|
||||
currentPage={currentPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
searchQuery={searchQuery}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
|
||||
{/* Invite modal */}
|
||||
<InviteMemberModal
|
||||
open={isInviteModalOpen}
|
||||
onClose={(): void => setIsInviteModalOpen(false)}
|
||||
onSuccess={handleInviteSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MembersSettings;
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
7
frontend/src/pages/MembersSettings/index.tsx
Normal file
7
frontend/src/pages/MembersSettings/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import MembersSettingsContainer from 'container/MembersSettings/MembersSettings';
|
||||
|
||||
function MembersSettings(): JSX.Element {
|
||||
return <MembersSettingsContainer />;
|
||||
}
|
||||
|
||||
export default MembersSettings;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user