mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-28 03:02:25 +00:00
Compare commits
2 Commits
main
...
members-pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35443b3bd3 | ||
|
|
b797464995 |
@@ -1,127 +0,0 @@
|
||||
# Abstractions
|
||||
|
||||
This document provides rules for deciding when a new type, interface, or intermediate representation is warranted in Go code. The goal is to keep the codebase navigable by ensuring every abstraction earns its place.
|
||||
|
||||
## The cost of a new abstraction
|
||||
|
||||
Every exported type, interface, or wrapper is a permanent commitment. It must be named, documented, tested, and understood by every future contributor. It creates a new concept in the codebase vocabulary. Before introducing one, verify that the cost is justified by a concrete benefit that cannot be achieved with existing mechanisms.
|
||||
|
||||
## Before you introduce anything new
|
||||
|
||||
Answer these four questions. If writing a PR, include the answers in the description.
|
||||
|
||||
1. **What already exists?** Name the specific type, function, interface, or library that covers this ground today.
|
||||
2. **What does the new abstraction add?** Name the concrete operation, guarantee, or capability. "Cleaner" or "more reusable" are not sufficient; name what the caller can do that it could not do before.
|
||||
3. **What does the new abstraction drop?** If it wraps or mirrors an existing structure, list what it cannot represent. Every gap must be either justified or handled with an explicit error.
|
||||
4. **Who consumes it?** List the call sites. If there is only one producer and one consumer in the same call chain, you likely need a function, not a type.
|
||||
|
||||
## Rules
|
||||
|
||||
### 1. Prefer functions over types
|
||||
|
||||
If a piece of logic has one input and one output, write a function. Do not create a struct to hold intermediate state that is built in one place and read in one place. A function is easier to test, easier to inline, and does not expand the vocabulary of the codebase.
|
||||
|
||||
```go
|
||||
// Prefer this:
|
||||
func ConvertConfig(src ExternalConfig) (InternalConfig, error)
|
||||
|
||||
// Over this:
|
||||
type ConfigAdapter struct { ... }
|
||||
func NewConfigAdapter(src ExternalConfig) *ConfigAdapter
|
||||
func (a *ConfigAdapter) ToInternal() (InternalConfig, error)
|
||||
```
|
||||
|
||||
The two-step version is only justified when `ConfigAdapter` has multiple distinct consumers that use it in different ways.
|
||||
|
||||
### 2. Do not duplicate structures you do not own
|
||||
|
||||
When a library or external package produces a structured output, operate on that output directly. Do not create a parallel type that mirrors a subset of its fields.
|
||||
|
||||
A partial copy will:
|
||||
- **Silently lose data** when the source has fields or variants the copy does not account for.
|
||||
- **Drift** when the source evolves and the copy is not updated in lockstep.
|
||||
- **Add a conversion step** that doubles the code surface and the opportunity for bugs.
|
||||
|
||||
If you need to shield consumers from a dependency, define a narrow interface over the dependency's type rather than copying its shape into a new struct.
|
||||
|
||||
### 3. Never silently discard input
|
||||
|
||||
If your code receives structured input and cannot handle part of it, return an error. Do not silently return nil, skip the element, or produce a partial result. Silent data loss is the hardest class of bug to detect because the code appears to work, it just produces wrong results.
|
||||
|
||||
```go
|
||||
// Wrong: silently ignores the unrecognized case.
|
||||
default:
|
||||
return nil
|
||||
|
||||
// Right: makes the gap visible.
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported %T value: %v", v, v)
|
||||
```
|
||||
|
||||
This applies broadly: type switches, format conversions, data migrations, enum mappings, configuration parsing. Anywhere a `default` or `else` branch can swallow input, it should surface an error instead.
|
||||
|
||||
### 4. Do not expose methods that lose information
|
||||
|
||||
A method on a structured type should not strip meaning from the structure it belongs to. If a caller needs to iterate over elements for a specific purpose (validation, aggregation, logging), write that logic as a standalone function that operates on the structure with full context, rather than adding a method that returns a reduced view.
|
||||
|
||||
```go
|
||||
// Problematic: callers cannot distinguish how items were related.
|
||||
func (o *Order) AllLineItems() []LineItem { ... }
|
||||
|
||||
// Better: the validation logic operates on the full structure.
|
||||
func ValidateOrder(o *Order) error { ... }
|
||||
```
|
||||
|
||||
Public methods shape how a type is used. Once a lossy accessor exists, callers will depend on it, and the lost information becomes unrecoverable at those call sites.
|
||||
|
||||
### 5. Interfaces should be discovered, not predicted
|
||||
|
||||
Do not define an interface before you have at least two concrete implementations that need it. An interface with one implementation is not abstraction; it is indirection that makes it harder to navigate from call site to implementation.
|
||||
|
||||
The exception is interfaces required for testing (e.g., for mocking an external dependency). In that case, define the interface in the **consuming** package, not the providing package, following the Go convention of [accepting interfaces and returning structs](https://go.dev/wiki/CodeReviewComments#interfaces).
|
||||
|
||||
### 6. Wrappers must add semantics, not just rename
|
||||
|
||||
A wrapper type is justified when it adds meaning, validation, or invariants that the underlying type does not carry. It is not justified when it merely renames fields or reorganizes the same data into a different shape.
|
||||
|
||||
```go
|
||||
// Justified: adds validation that the underlying string does not carry.
|
||||
type OrgID struct{ value string }
|
||||
func NewOrgID(s string) (OrgID, error) { /* validates format */ }
|
||||
|
||||
// Not justified: renames fields with no new invariant or behavior.
|
||||
type UserInfo struct {
|
||||
Name string // same as source.Name
|
||||
Email string // same as source.Email
|
||||
}
|
||||
```
|
||||
|
||||
Ask: what does the wrapper guarantee that the underlying type does not? If the answer is nothing, use the underlying type directly.
|
||||
|
||||
## When a new type IS warranted
|
||||
|
||||
A new type earns its place when it meets **at least one** of these criteria:
|
||||
|
||||
- **Serialization boundary**: It must be persisted, sent over the wire, or written to config. The source type is unsuitable (unexported fields, function pointers, cycles).
|
||||
- **Invariant enforcement**: The constructor or methods enforce constraints that raw data does not carry (e.g., non-empty, validated format, bounded range).
|
||||
- **Multiple distinct consumers**: Three or more call sites use the type in meaningfully different ways. The type is the shared vocabulary between them.
|
||||
- **Dependency firewall**: The type lives in a lightweight package so that consumers avoid importing a heavy dependency.
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- A function is almost always simpler than a type. Start with a function; promote to a type only when you have evidence of need.
|
||||
- Never silently drop data. If you cannot handle it, error.
|
||||
- If your new type mirrors an existing one, you need a strong reason beyond "nicer to work with".
|
||||
- If your type has one producer and one consumer, it is indirection, not abstraction.
|
||||
- Interfaces come from need (multiple implementations), not from prediction.
|
||||
- When in doubt, do not add it. It is easier to add an abstraction later when the need is clear than to remove one after it has spread through the codebase.
|
||||
|
||||
## Further reading
|
||||
|
||||
These works and our own lessions shaped the above guidelines
|
||||
|
||||
- [The Wrong Abstraction](https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction) - Sandi Metz. The wrong abstraction is worse than duplication. If you find yourself passing parameters and adding conditional paths through shared code, inline it back into every caller and let the duplication show you what the right abstraction is.
|
||||
- [Write code that is easy to delete, not easy to extend](https://programmingisterrible.com/post/139222674273/write-code-that-is-easy-to-delete-not-easy-to) - tef. Every abstraction is a bet on the future. Optimize for how cheaply you can remove code when the bet is wrong, not for how easily you can extend it when the bet is right.
|
||||
- [Goodbye, Clean Code](https://overreacted.io/goodbye-clean-code/) - Dan Abramov. A refactoring that removes duplication can look cleaner while making the code harder to change. Clean-looking and easy-to-change are not the same thing.
|
||||
- [A Philosophy of Software Design](https://www.amazon.com/Philosophy-Software-Design-John-Ousterhout/dp/1732102201) - John Ousterhout. Good abstractions are deep: simple interface, complex implementation. A "false abstraction" omits important details while appearing simple, and is worse than no abstraction at all. ([Summary by Pragmatic Engineer](https://blog.pragmaticengineer.com/a-philosophy-of-software-design-review/))
|
||||
- [Simplicity is Complicated](https://go.dev/talks/2015/simplicity-is-complicated.slide) - Rob Pike. Go-specific. Fewer orthogonal concepts that compose predictably beat many overlapping ones. Features were left out of Go deliberately; the same discipline applies to your own code.
|
||||
@@ -1,126 +0,0 @@
|
||||
# Packages
|
||||
|
||||
All shared Go code in SigNoz lives under `pkg/`. Each package represents a distinct domain concept and exposes a clear public interface. This guide covers the conventions for creating, naming, and organising packages so the codebase stays consistent as it grows.
|
||||
|
||||
## How should I name a package?
|
||||
|
||||
Use short, lowercase, single-word names. No underscores or camelCase (`querier`, `cache`, `authz`, not `query_builder` or `dataStore`).
|
||||
|
||||
Names must be **domain-specific**. A package name should tell you what problem domain it deals with, not what data structure it wraps. Prefer `alertmanager` over `manager`, `licensing` over `checker`.
|
||||
|
||||
Avoid generic names like `util`, `helpers`, `common`, `misc`, or `base`. If you can't name it, the code probably belongs in an existing package.
|
||||
|
||||
## When should I create a new package?
|
||||
|
||||
Create a new package when:
|
||||
|
||||
- The functionality represents a **distinct domain concept** (e.g., `authz`, `licensing`, `cache`).
|
||||
- Two or more other packages would import it; it serves as shared infrastructure.
|
||||
- The code has a clear public interface that can stand on its own.
|
||||
|
||||
Do **not** create a new package when:
|
||||
|
||||
- There is already a package that covers the same domain. Extend the existing package instead.
|
||||
- The code is only used in one place. Keep it local to the caller.
|
||||
- You are splitting purely for file size. Use multiple files within the same package instead.
|
||||
|
||||
## How should I lay out a package?
|
||||
|
||||
A typical package looks like:
|
||||
|
||||
```
|
||||
pkg/cache/
|
||||
├── cache.go # Public interface + exported types
|
||||
├── config.go # Configuration types if needed
|
||||
├── memorycache/ # Implementation sub-package
|
||||
├── rediscache/ # Another implementation
|
||||
└── cachetest/ # Test helpers for consumers
|
||||
```
|
||||
|
||||
Follow these rules:
|
||||
|
||||
1. **Interface-first file**: The file matching the package name (e.g., `cache.go` in `pkg/cache/`) should define the public interface and core exported types. Keep implementation details out of this file.
|
||||
|
||||
2. **One responsibility per file**: Name files after what they contain (`config.go`, `handler.go`, `service.go`), not after the package name. If a package merges two concerns, prefix files to group them (e.g., `memory_store.go`, `redis_store.go` in a storage package).
|
||||
|
||||
3. **Sub-packages for implementations**: When a package defines an interface with multiple implementations, put each implementation in its own sub-package (`memorycache/`, `rediscache/`). This keeps the parent package import-free of implementation dependencies.
|
||||
|
||||
4. **Test helpers in `{pkg}test/`**: If consumers need test mocks or builders, put them in a `{pkg}test/` sub-package (e.g., `cachetest/`, `sqlstoretest/`). This avoids polluting the main package with test-only code.
|
||||
|
||||
5. **Test files stay alongside source**: Unit tests go in `_test.go` files next to the code they test, in the same package.
|
||||
|
||||
## How should I name symbols?
|
||||
|
||||
### Exported symbols
|
||||
|
||||
- **Interfaces**: For single-method interfaces, follow the standard `-er` suffix convention (`Reader`, `Writer`, `Closer`). For multi-method interfaces, use clear nouns (`Cache`, `Store`, `Provider`).
|
||||
- **Constructors**: `New<Type>(...)` (e.g., `NewMemoryCache()`).
|
||||
- **Avoid stutter**: Since callers qualify with the package name, don't repeat it. Write `cache.Cache`, not `cache.CacheInterface`. Write `authz.FromRole`, not `authz.AuthzFromRole`.
|
||||
|
||||
### Unexported symbols
|
||||
|
||||
- Struct receivers: one or two characters (`c`, `f`, `br`).
|
||||
- Helper functions: descriptive lowercase names (`parseToken`, `buildQuery`).
|
||||
|
||||
### Constants
|
||||
|
||||
- Use `PascalCase` for exported constants.
|
||||
- When merging files from different origins into one package, watch out for **name collisions** across files. Prefix to disambiguate when two types share a natural name.
|
||||
|
||||
## How should I organise imports?
|
||||
|
||||
Group imports in three blocks separated by blank lines:
|
||||
|
||||
```go
|
||||
import (
|
||||
// 1. Standard library
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
// 2. External dependencies
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
// 3. Internal
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
)
|
||||
```
|
||||
|
||||
Never introduce circular imports. If package A needs package B and B needs A, extract the shared types into a third package (often under `pkg/types/`).
|
||||
|
||||
## Where do shared types go?
|
||||
|
||||
Most types belong in `pkg/types/` under a domain-specific sub-package (e.g., `pkg/types/ruletypes`, `pkg/types/authtypes`).
|
||||
|
||||
Do not put domain logic in `pkg/types/`. Only data structures, constants, and simple methods.
|
||||
|
||||
## How do I merge or move packages?
|
||||
|
||||
When two packages are tightly coupled (one imports the other's constants, they cover the same domain), merge them:
|
||||
|
||||
1. Pick a domain-specific name for the combined package.
|
||||
2. Prefix files to preserve origin (e.g., `memory_store.go`, `redis_store.go`).
|
||||
3. Resolve symbol conflicts explicitly; rename with a prefix rather than silently shadowing.
|
||||
4. Update all consumers in a single change.
|
||||
5. Delete the old packages. Do not leave behind re-export shims.
|
||||
6. Verify with `go build ./...`, `go test ./<new-pkg>/...`, and `go vet ./...`.
|
||||
|
||||
## When should I add documentation?
|
||||
|
||||
Add a `doc.go` with a package-level comment for any package that is non-trivial or has multiple consumers. Keep it to 1–3 sentences:
|
||||
|
||||
```go
|
||||
// Package cache provides a caching interface with pluggable backends
|
||||
// for in-memory and Redis-based storage.
|
||||
package cache
|
||||
```
|
||||
|
||||
## What should I remember?
|
||||
|
||||
- Package names are domain-specific and lowercase. Never generic names like `util` or `common`.
|
||||
- The file matching the package name (e.g., `cache.go`) defines the public interface. Implementation details go elsewhere.
|
||||
- Never introduce circular imports. Extract shared types into `pkg/types/` when needed.
|
||||
- Watch for symbol name collisions when merging packages, prefix to disambiguate.
|
||||
- Put test helpers in a `{pkg}test/` sub-package, not in the main package.
|
||||
- Before submitting, verify with `go build ./...`, `go test ./<your-pkg>/...`, and `go vet ./...`.
|
||||
- Update all consumers when you rename or move symbols.
|
||||
@@ -8,14 +8,4 @@ We adhere to three primary style guides as our foundation:
|
||||
- [Code Review Comments](https://go.dev/wiki/CodeReviewComments) - For understanding common comments in code reviews
|
||||
- [Google Style Guide](https://google.github.io/styleguide/go/) - Additional practices from Google
|
||||
|
||||
We **recommend** (almost enforce) reviewing these guides before contributing to the codebase. They provide valuable insights into writing idiomatic Go code and will help you understand our approach to backend development. In addition, we have a few additional rules that make certain areas stricter than the above which can be found in area-specific files in this package:
|
||||
|
||||
- [Abstractions](abstractions.md) - When to introduce new types and intermediate representations
|
||||
- [Errors](errors.md) - Structured error handling
|
||||
- [Endpoint](endpoint.md) - HTTP endpoint patterns
|
||||
- [Flagger](flagger.md) - Feature flag patterns
|
||||
- [Handler](handler.md) - HTTP handler patterns
|
||||
- [Integration](integration.md) - Integration testing
|
||||
- [Provider](provider.md) - Dependency injection and provider patterns
|
||||
- [Packages](packages.md) — Naming, layout, and conventions for `pkg/` packages
|
||||
- [SQL](sql.md) - Database and SQL patterns
|
||||
We **recommend** (almost enforce) reviewing these guides before contributing to the codebase. They provide valuable insights into writing idiomatic Go code and will help you understand our approach to backend development. In addition, we have a few additional rules that make certain areas stricter than the above which can be found in area-specific files in this package.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { CloudDownloadOutlined } from '@ant-design/icons';
|
||||
import { Button, Dropdown, MenuProps } from 'antd';
|
||||
import { Excel } from 'antd-table-saveas-excel';
|
||||
import { unparse } from 'papaparse';
|
||||
|
||||
import { DownloadProps } from './Download.types';
|
||||
@@ -8,36 +8,25 @@ import { DownloadProps } from './Download.types';
|
||||
import './Download.styles.scss';
|
||||
|
||||
function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const downloadExcelFile = async (): Promise<void> => {
|
||||
setIsDownloading(true);
|
||||
|
||||
try {
|
||||
const headers = Object.keys(Object.assign({}, ...data)).map((item) => {
|
||||
const updatedTitle = item
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
return {
|
||||
title: updatedTitle,
|
||||
dataIndex: item,
|
||||
};
|
||||
});
|
||||
|
||||
const excelLib = await import('antd-table-saveas-excel');
|
||||
|
||||
const excel = new excelLib.Excel();
|
||||
excel
|
||||
.addSheet(fileName)
|
||||
.addColumns(headers)
|
||||
.addDataSource(data, {
|
||||
str2Percent: true,
|
||||
})
|
||||
.saveAs(`${fileName}.xlsx`);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
const downloadExcelFile = (): void => {
|
||||
const headers = Object.keys(Object.assign({}, ...data)).map((item) => {
|
||||
const updatedTitle = item
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
return {
|
||||
title: updatedTitle,
|
||||
dataIndex: item,
|
||||
};
|
||||
});
|
||||
const excel = new Excel();
|
||||
excel
|
||||
.addSheet(fileName)
|
||||
.addColumns(headers)
|
||||
.addDataSource(data, {
|
||||
str2Percent: true,
|
||||
})
|
||||
.saveAs(`${fileName}.xlsx`);
|
||||
};
|
||||
|
||||
const downloadCsvFile = (): void => {
|
||||
@@ -70,7 +59,7 @@ function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
|
||||
<Dropdown menu={menu} trigger={['click']}>
|
||||
<Button
|
||||
className="download-button"
|
||||
loading={isLoading || isDownloading}
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
type="link"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Popover, Typography } from 'antd';
|
||||
import { Excel } from 'antd-table-saveas-excel';
|
||||
import { FileDigit, FileDown, Sheet } from 'lucide-react';
|
||||
import { unparse } from 'papaparse';
|
||||
|
||||
@@ -8,34 +8,25 @@ import { DownloadProps } from './DownloadV2.types';
|
||||
import './DownloadV2.styles.scss';
|
||||
|
||||
function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const downloadExcelFile = async (): Promise<void> => {
|
||||
setIsDownloading(true);
|
||||
|
||||
try {
|
||||
const headers = Object.keys(Object.assign({}, ...data)).map((item) => {
|
||||
const updatedTitle = item
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
return {
|
||||
title: updatedTitle,
|
||||
dataIndex: item,
|
||||
};
|
||||
});
|
||||
const excelLib = await import('antd-table-saveas-excel');
|
||||
const excel = new excelLib.Excel();
|
||||
excel
|
||||
.addSheet(fileName)
|
||||
.addColumns(headers)
|
||||
.addDataSource(data, {
|
||||
str2Percent: true,
|
||||
})
|
||||
.saveAs(`${fileName}.xlsx`);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
const downloadExcelFile = (): void => {
|
||||
const headers = Object.keys(Object.assign({}, ...data)).map((item) => {
|
||||
const updatedTitle = item
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
return {
|
||||
title: updatedTitle,
|
||||
dataIndex: item,
|
||||
};
|
||||
});
|
||||
const excel = new Excel();
|
||||
excel
|
||||
.addSheet(fileName)
|
||||
.addColumns(headers)
|
||||
.addDataSource(data, {
|
||||
str2Percent: true,
|
||||
})
|
||||
.saveAs(`${fileName}.xlsx`);
|
||||
};
|
||||
|
||||
const downloadCsvFile = (): void => {
|
||||
@@ -63,7 +54,6 @@ function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
|
||||
type="text"
|
||||
onClick={downloadExcelFile}
|
||||
className="action-btns"
|
||||
loading={isDownloading}
|
||||
>
|
||||
Excel (.xlsx)
|
||||
</Button>
|
||||
|
||||
@@ -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