mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-09 15:02:21 +00:00
Compare commits
1 Commits
main
...
service-ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bd2d2a081 |
@@ -0,0 +1,142 @@
|
||||
.create-sa-modal {
|
||||
max-width: 530px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
|
||||
|
||||
[data-slot='dialog-header'] {
|
||||
padding: var(--padding-4);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--label-base-400-font-size);
|
||||
font-weight: var(--label-base-400-font-weight);
|
||||
line-height: var(--label-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--bg-base-white);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
padding: 0;
|
||||
|
||||
.create-sa-modal__content {
|
||||
padding: var(--padding-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.create-sa-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.ant-form-item-label > label {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
min-height: 32px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
padding: 0 var(--padding-2) !important;
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--l3-foreground);
|
||||
opacity: 0.4;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
letter-spacing: -0.07px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--bg-base-white);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector,
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__helper {
|
||||
font-size: var(--paragraph-small-400-font-size);
|
||||
color: var(--l3-foreground);
|
||||
margin: calc(var(--spacing-2) * -1) 0 var(--spacing-4) 0;
|
||||
line-height: var(--paragraph-small-400-line-height);
|
||||
}
|
||||
}
|
||||
|
||||
.create-sa-modal__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
border-top: 1px solid var(--secondary);
|
||||
gap: var(--spacing-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.create-sa-modal {
|
||||
[data-slot='dialog-title'] {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
|
||||
.create-sa-form {
|
||||
&__select {
|
||||
.ant-select-selector {
|
||||
.ant-select-selection-item {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { ChevronDown, X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Form, Input, Select } from 'antd';
|
||||
import { useCreateServiceAccount } from 'api/generated/services/serviceaccount';
|
||||
|
||||
import './CreateServiceAccountModal.styles.scss';
|
||||
|
||||
interface CreateServiceAccountModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
email?: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
function CreateServiceAccountModal({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: CreateServiceAccountModalProps): JSX.Element {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { mutateAsync: createServiceAccount } = useCreateServiceAccount();
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
}, [form, onClose]);
|
||||
|
||||
const handleSubmit = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setIsSubmitting(true);
|
||||
await createServiceAccount({
|
||||
data: {
|
||||
name: values.name.trim(),
|
||||
email: values.email?.trim() ?? '',
|
||||
roles: values.roles ?? [],
|
||||
},
|
||||
});
|
||||
toast.success('Service account created successfully', { richColors: true });
|
||||
form.resetFields();
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
// If it's a form validation error (no message property typical of AntD), skip
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) {
|
||||
return;
|
||||
}
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'An error occurred';
|
||||
toast.error(`Failed to create service account: ${errorMessage}`, {
|
||||
richColors: true,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [form, createServiceAccount, onSuccess, onClose]);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
title="New Service Account"
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
showCloseButton
|
||||
width="narrow"
|
||||
className="create-sa-modal"
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<div className="create-sa-modal__content">
|
||||
<Form form={form} layout="vertical" className="create-sa-form">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Name"
|
||||
rules={[{ required: true, message: 'Name is required' }]}
|
||||
className="create-sa-form__item"
|
||||
>
|
||||
<Input placeholder="Enter a name" className="create-sa-form__input" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="Email Address"
|
||||
className="create-sa-form__item"
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
className="create-sa-form__input"
|
||||
/>
|
||||
</Form.Item>
|
||||
<p className="create-sa-form__helper">
|
||||
Used only for notifications about this service account. It is not used for
|
||||
authentication.
|
||||
</p>
|
||||
|
||||
<Form.Item name="roles" label="Roles" className="create-sa-form__item">
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Select roles"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
className="create-sa-form__select"
|
||||
getPopupContainer={(triggerNode): HTMLElement =>
|
||||
(triggerNode?.closest('.create-sa-modal') as HTMLElement) ||
|
||||
document.body
|
||||
}
|
||||
>
|
||||
<Select.Option value="VIEWER">Viewer</Select.Option>
|
||||
<Select.Option value="EDITOR">Editor</Select.Option>
|
||||
<Select.Option value="ADMIN">Admin</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="create-sa-modal__footer">
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Service Account'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateServiceAccountModal;
|
||||
@@ -0,0 +1,185 @@
|
||||
.add-key-modal {
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Expiry segmented toggle using ToggleGroup
|
||||
&__expiry-toggle {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
|
||||
&-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--foreground);
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__datepicker {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
.ant-picker {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
input {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.ant-picker-suffix {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__key-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__key-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 var(--padding-2);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l1-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
border-top: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
border-left: 1px solid var(--border);
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
&__callout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-3);
|
||||
background: rgba(78, 116, 248, 0.1);
|
||||
border: 1px solid rgba(78, 116, 248, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--foreground);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__callout-icon {
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__expiry-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__expiry-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: var(--padding-4);
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: var(--margin-2);
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__learn-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
color: var(--primary);
|
||||
font-size: var(--font-size-sm);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
271
frontend/src/components/ServiceAccountDrawer/AddKeyModal.tsx
Normal file
271
frontend/src/components/ServiceAccountDrawer/AddKeyModal.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogWrapper } from '@signozhq/dialog';
|
||||
import { ArrowUpRight, Check, Copy, Info } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import { useCreateServiceAccountKey } from 'api/generated/services/serviceaccount';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { format } from 'date-fns';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import './AddKeyModal.styles.scss';
|
||||
|
||||
interface AddKeyModalProps {
|
||||
open: boolean;
|
||||
accountId: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
type Phase = 'form' | 'created';
|
||||
type ExpiryMode = 'none' | 'date';
|
||||
|
||||
function AddKeyModal({
|
||||
open,
|
||||
accountId,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: AddKeyModalProps): JSX.Element {
|
||||
const [phase, setPhase] = useState<Phase>('form');
|
||||
const [keyName, setKeyName] = useState('');
|
||||
const [expiryMode, setExpiryMode] = useState<ExpiryMode>('none');
|
||||
const [expiryDate, setExpiryDate] = useState<Dayjs | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [
|
||||
createdKey,
|
||||
setCreatedKey,
|
||||
] = useState<ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO | null>(null);
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPhase('form');
|
||||
setKeyName('');
|
||||
setExpiryMode('none');
|
||||
setExpiryDate(null);
|
||||
setIsSubmitting(false);
|
||||
setCreatedKey(null);
|
||||
setHasCopied(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { mutateAsync: createKey } = useCreateServiceAccountKey();
|
||||
|
||||
const handleCreate = useCallback(async (): Promise<void> => {
|
||||
if (!keyName.trim()) {
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const expiresAt =
|
||||
expiryMode === 'date' && expiryDate ? expiryDate.unix() : 0;
|
||||
const response = await createKey({
|
||||
pathParams: { id: accountId },
|
||||
data: { name: keyName.trim(), expires_at: expiresAt },
|
||||
});
|
||||
const keyData = response?.data;
|
||||
if (keyData) {
|
||||
setCreatedKey(keyData);
|
||||
setPhase('created');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to create key', { richColors: true });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [keyName, expiryMode, expiryDate, accountId, createKey]);
|
||||
|
||||
const handleCopy = useCallback(async (): Promise<void> => {
|
||||
if (!createdKey?.key) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey.key);
|
||||
setHasCopied(true);
|
||||
setTimeout(() => setHasCopied(false), 2000);
|
||||
toast.success('Key copied to clipboard', { richColors: true });
|
||||
} catch {
|
||||
toast.error('Failed to copy key', { richColors: true });
|
||||
}
|
||||
}, [createdKey]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
if (phase === 'created') {
|
||||
onSuccess();
|
||||
}
|
||||
onClose();
|
||||
}, [phase, onSuccess, onClose]);
|
||||
|
||||
const expiryLabel = (): string => {
|
||||
if (expiryMode === 'none' || !expiryDate) {
|
||||
return 'Never';
|
||||
}
|
||||
try {
|
||||
return format(expiryDate.toDate(), 'MMM d, yyyy');
|
||||
} catch {
|
||||
return 'Never';
|
||||
}
|
||||
};
|
||||
|
||||
const title = phase === 'form' ? 'Add a New Key' : 'Key Created Successfully';
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
title={title}
|
||||
width="base"
|
||||
className="add-key-modal"
|
||||
showCloseButton
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
{phase === 'form' && (
|
||||
<div className="add-key-modal__form">
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label" htmlFor="key-name">
|
||||
Name <span style={{ color: 'var(--destructive)' }}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="key-name"
|
||||
value={keyName}
|
||||
onChange={(e): void => setKeyName(e.target.value)}
|
||||
placeholder="Enter key name e.g.: Service Owner"
|
||||
className="add-key-modal__input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label">Expiration</label>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={expiryMode}
|
||||
onValueChange={(val): void => {
|
||||
if (val) {
|
||||
setExpiryMode(val as ExpiryMode);
|
||||
if (val === 'none') {
|
||||
setExpiryDate(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="add-key-modal__expiry-toggle"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="none"
|
||||
className="add-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
No Expiration
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="date"
|
||||
className="add-key-modal__expiry-toggle-btn"
|
||||
>
|
||||
Set Expiration Date
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{expiryMode === 'date' && (
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label">Expiration Date</label>
|
||||
<div className="add-key-modal__datepicker">
|
||||
<DatePicker
|
||||
value={expiryDate}
|
||||
onChange={(date): void => setExpiryDate(date)}
|
||||
style={{ width: '100%', height: 32 }}
|
||||
popupClassName="add-key-modal__datepicker-popup"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-key-modal__footer">
|
||||
<a
|
||||
href="https://signoz.io/docs/service-accounts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="add-key-modal__learn-more"
|
||||
>
|
||||
Learn more about Service Account Keys
|
||||
<ArrowUpRight size={12} />
|
||||
</a>
|
||||
<div className="add-key-modal__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!keyName.trim() || isSubmitting}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Key'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'created' && createdKey && (
|
||||
<div className="add-key-modal__form">
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label">API Key</label>
|
||||
<div className="add-key-modal__key-display">
|
||||
<span className="add-key-modal__key-text">{createdKey.key}</span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="add-key-modal__copy-btn"
|
||||
>
|
||||
{hasCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__expiry-meta">
|
||||
<span className="add-key-modal__expiry-label">Expiration</span>
|
||||
<Badge color="vanilla">{expiryLabel()}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__callout">
|
||||
<Info size={12} className="add-key-modal__callout-icon" />
|
||||
<span>
|
||||
Store the key securely. This is the only time it will be displayed.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__footer">
|
||||
<span />
|
||||
<div className="add-key-modal__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddKeyModal;
|
||||
@@ -0,0 +1,192 @@
|
||||
.edit-key-modal {
|
||||
.ant-modal-content {
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
padding: var(--padding-4) var(--padding-5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
font-size: var(--label-large-500-font-size);
|
||||
font-weight: var(--label-large-500-font-weight);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
color: var(--foreground);
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: var(--padding-5);
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__key-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--padding-2);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&__key-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: monospace;
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__lock-icon {
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__expiry-toggle {
|
||||
display: flex;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
&-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
&--active {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__datepicker {
|
||||
.ant-picker {
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
|
||||
input {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.ant-picker-suffix {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__meta-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
border-top: 1px solid var(--secondary);
|
||||
margin-top: var(--margin-6);
|
||||
padding-top: var(--padding-4);
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__footer-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--destructive);
|
||||
font-size: var(--label-small-400-font-size);
|
||||
font-weight: var(--label-small-400-font-weight);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
296
frontend/src/components/ServiceAccountDrawer/EditKeyModal.tsx
Normal file
296
frontend/src/components/ServiceAccountDrawer/EditKeyModal.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { LockKeyhole, Trash2, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { DatePicker, Modal } from 'antd';
|
||||
import {
|
||||
useRevokeServiceAccountKey,
|
||||
useUpdateServiceAccountKey,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { format } from 'date-fns';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import './EditKeyModal.styles.scss';
|
||||
|
||||
interface EditKeyModalProps {
|
||||
open: boolean;
|
||||
accountId: string;
|
||||
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
type ExpiryMode = 'none' | 'date';
|
||||
|
||||
function EditKeyModal({
|
||||
open,
|
||||
accountId,
|
||||
keyItem,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: EditKeyModalProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const [localName, setLocalName] = useState('');
|
||||
const [expiryMode, setExpiryMode] = useState<ExpiryMode>('none');
|
||||
const [localDate, setLocalDate] = useState<Dayjs | null>(null);
|
||||
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isRevoking, setIsRevoking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyItem) {
|
||||
setLocalName(keyItem.name ?? '');
|
||||
if (keyItem.expires_at === 0) {
|
||||
setExpiryMode('none');
|
||||
setLocalDate(null);
|
||||
} else {
|
||||
setExpiryMode('date');
|
||||
setLocalDate(dayjs.unix(keyItem.expires_at));
|
||||
}
|
||||
}
|
||||
}, [keyItem]);
|
||||
|
||||
const originalExpiresAt = keyItem?.expires_at ?? 0;
|
||||
const currentExpiresAt =
|
||||
expiryMode === 'none' || !localDate ? 0 : localDate.unix();
|
||||
const isDirty =
|
||||
keyItem !== null &&
|
||||
(localName !== (keyItem.name ?? '') ||
|
||||
currentExpiresAt !== originalExpiresAt);
|
||||
|
||||
const { mutateAsync: updateKey } = useUpdateServiceAccountKey();
|
||||
const { mutateAsync: revokeKey } = useRevokeServiceAccountKey();
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!keyItem || !isDirty) {return;}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateKey({
|
||||
pathParams: { id: accountId, fid: keyItem.id },
|
||||
data: { name: localName, expires_at: currentExpiresAt },
|
||||
});
|
||||
toast.success('Key updated successfully', { richColors: true });
|
||||
onSuccess();
|
||||
} catch {
|
||||
toast.error('Failed to update key', { richColors: true });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [keyItem, isDirty, localName, currentExpiresAt, accountId, updateKey, onSuccess]);
|
||||
|
||||
const handleRevoke = useCallback(async (): Promise<void> => {
|
||||
if (!keyItem) {return;}
|
||||
setIsRevoking(true);
|
||||
try {
|
||||
await revokeKey({
|
||||
pathParams: { id: accountId, fid: keyItem.id },
|
||||
});
|
||||
toast.success('Key revoked successfully', { richColors: true });
|
||||
setIsRevokeConfirmOpen(false);
|
||||
onSuccess();
|
||||
} catch {
|
||||
toast.error('Failed to revoke key', { richColors: true });
|
||||
} finally {
|
||||
setIsRevoking(false);
|
||||
}
|
||||
}, [keyItem, accountId, revokeKey, onSuccess]);
|
||||
|
||||
const formatLastUsed = useCallback(
|
||||
(lastUsed: Date | null | undefined): string => {
|
||||
if (!lastUsed) {return '—';}
|
||||
try {
|
||||
return formatTimezoneAdjustedTimestamp(
|
||||
String(lastUsed),
|
||||
DATE_TIME_FORMATS.DASH_DATETIME,
|
||||
);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
},
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const expiryDisplayLabel = (): string => {
|
||||
if (expiryMode === 'none' || !localDate) {return 'Never';}
|
||||
try {
|
||||
return format(localDate.toDate(), 'MMM d, yyyy');
|
||||
} catch {
|
||||
return 'Never';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title="Edit Key"
|
||||
width={530}
|
||||
footer={null}
|
||||
className="edit-key-modal"
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="edit-key-modal__form">
|
||||
{/* Name */}
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="edit-key-name"
|
||||
value={localName}
|
||||
onChange={(e): void => setLocalName(e.target.value)}
|
||||
className="edit-key-modal__input"
|
||||
placeholder="Enter key name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Key (read-only masked) */}
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label">Key</label>
|
||||
<div className="edit-key-modal__key-display">
|
||||
<span className="edit-key-modal__key-text">
|
||||
{keyItem?.key ?? '—'}
|
||||
</span>
|
||||
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expiration toggle */}
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label">Expiration</label>
|
||||
<div className="edit-key-modal__expiry-toggle">
|
||||
<Button
|
||||
variant={expiryMode === 'none' ? 'solid' : 'ghost'}
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={`edit-key-modal__expiry-toggle-btn${expiryMode === 'none' ? ' edit-key-modal__expiry-toggle-btn--active' : ''}`}
|
||||
onClick={(): void => {
|
||||
setExpiryMode('none');
|
||||
setLocalDate(null);
|
||||
}}
|
||||
>
|
||||
No Expiration
|
||||
</Button>
|
||||
<Button
|
||||
variant={expiryMode === 'date' ? 'solid' : 'ghost'}
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className={`edit-key-modal__expiry-toggle-btn${expiryMode === 'date' ? ' edit-key-modal__expiry-toggle-btn--active' : ''}`}
|
||||
onClick={(): void => setExpiryMode('date')}
|
||||
>
|
||||
Set Expiration Date
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expiryMode === 'date' && (
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label">Expiration Date</label>
|
||||
<div className="edit-key-modal__datepicker">
|
||||
<DatePicker
|
||||
value={localDate}
|
||||
onChange={(date): void => setLocalDate(date)}
|
||||
style={{ width: '100%', height: 32 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expiry meta */}
|
||||
<div className="edit-key-modal__meta">
|
||||
<span className="edit-key-modal__meta-label">Expiry</span>
|
||||
<Badge color="vanilla">{expiryDisplayLabel()}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Last used meta */}
|
||||
<div className="edit-key-modal__meta">
|
||||
<span className="edit-key-modal__meta-label">Last Used</span>
|
||||
<Badge color="vanilla">
|
||||
{formatLastUsed(keyItem?.last_used ?? null)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="edit-key-modal__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="edit-key-modal__footer-danger"
|
||||
onClick={(): void => setIsRevokeConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</button>
|
||||
<div className="edit-key-modal__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!isDirty || isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Revoke confirm dialog */}
|
||||
<DialogWrapper
|
||||
open={isRevokeConfirmOpen}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {setIsRevokeConfirmOpen(false);}
|
||||
}}
|
||||
title={`Revoke ${keyItem?.name ?? 'key'}?`}
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="delete-dialog__body">
|
||||
Revoking this key will permanently invalidate it. Any systems using this
|
||||
key will lose access immediately.
|
||||
</p>
|
||||
<DialogFooter className="delete-dialog__footer">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setIsRevokeConfirmOpen(false)}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
disabled={isRevoking}
|
||||
onClick={handleRevoke}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isRevoking ? 'Revoking...' : 'Revoke Key'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditKeyModal;
|
||||
241
frontend/src/components/ServiceAccountDrawer/KeysTab.tsx
Normal file
241
frontend/src/components/ServiceAccountDrawer/KeysTab.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Tooltip } from 'antd';
|
||||
import {
|
||||
useListServiceAccountKeys,
|
||||
useRevokeServiceAccountKey,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { format } from 'date-fns';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import EditKeyModal from './EditKeyModal';
|
||||
|
||||
interface KeysTabProps {
|
||||
accountId: string;
|
||||
onKeyCountChange: (n: number) => void;
|
||||
onAddKeyClick: () => void;
|
||||
}
|
||||
|
||||
function formatExpiry(expiresAt: number): JSX.Element {
|
||||
if (expiresAt === 0) {
|
||||
return <span className="keys-tab__expiry--never">Never</span>;
|
||||
}
|
||||
const expiryDate = new Date(expiresAt * 1000);
|
||||
if (expiryDate < new Date()) {
|
||||
return (
|
||||
<span className="keys-tab__expiry--expired">
|
||||
{format(expiryDate, 'MMM d, yyyy')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span>{format(expiryDate, 'MMM d, yyyy')}</span>;
|
||||
}
|
||||
|
||||
function KeysTab({
|
||||
accountId,
|
||||
onKeyCountChange,
|
||||
onAddKeyClick,
|
||||
}: KeysTabProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
const [
|
||||
editKey,
|
||||
setEditKey,
|
||||
] = useState<ServiceaccounttypesFactorAPIKeyDTO | null>(null);
|
||||
const [
|
||||
revokeTarget,
|
||||
setRevokeTarget,
|
||||
] = useState<ServiceaccounttypesFactorAPIKeyDTO | null>(null);
|
||||
const [isRevoking, setIsRevoking] = useState(false);
|
||||
|
||||
const { data: keysData, refetch } = useListServiceAccountKeys({
|
||||
id: accountId,
|
||||
});
|
||||
|
||||
const keys = keysData?.data ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
onKeyCountChange(keys.length);
|
||||
}, [keys.length, onKeyCountChange]);
|
||||
|
||||
const { mutateAsync: revokeKey } = useRevokeServiceAccountKey();
|
||||
|
||||
const handleRevoke = useCallback(async (): Promise<void> => {
|
||||
if (!revokeTarget) {
|
||||
return;
|
||||
}
|
||||
setIsRevoking(true);
|
||||
try {
|
||||
await revokeKey({
|
||||
pathParams: { id: accountId, fid: revokeTarget.id },
|
||||
});
|
||||
toast.success('Key revoked successfully', { richColors: true });
|
||||
setRevokeTarget(null);
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error('Failed to revoke key', { richColors: true });
|
||||
} finally {
|
||||
setIsRevoking(false);
|
||||
}
|
||||
}, [revokeTarget, revokeKey, accountId, refetch]);
|
||||
|
||||
const handleKeySuccess = useCallback((): void => {
|
||||
setEditKey(null);
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const formatLastUsed = useCallback(
|
||||
(lastUsed: Date | null | undefined): string => {
|
||||
if (!lastUsed) {
|
||||
return '—';
|
||||
}
|
||||
try {
|
||||
return formatTimezoneAdjustedTimestamp(
|
||||
String(lastUsed),
|
||||
DATE_TIME_FORMATS.DASH_DATETIME,
|
||||
);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
},
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return (
|
||||
<div className="keys-tab__empty">
|
||||
<span className="keys-tab__empty-emoji" role="img" aria-label="searching">
|
||||
🧐
|
||||
</span>
|
||||
<p className="keys-tab__empty-text">No keys. Start by creating one.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="keys-tab__learn-more"
|
||||
onClick={onAddKeyClick}
|
||||
>
|
||||
+ Add your first key
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="keys-tab__table-wrap">
|
||||
{/* Header row */}
|
||||
<div className="keys-tab__table-header">
|
||||
<span className="keys-tab__col-name">Name</span>
|
||||
<span className="keys-tab__col-expiry">Expiry</span>
|
||||
<span className="keys-tab__col-last-used">Last Used</span>
|
||||
<span className="keys-tab__col-action" />
|
||||
</div>
|
||||
|
||||
{/* Data rows */}
|
||||
{keys.map((keyItem, idx) => (
|
||||
<div
|
||||
key={keyItem.id}
|
||||
className={`keys-tab__table-row${
|
||||
idx % 2 === 1 ? ' keys-tab__table-row--alt' : ''
|
||||
}`}
|
||||
onClick={(): void => setEditKey(keyItem)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
setEditKey(keyItem);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="keys-tab__col-name keys-tab__name-text">
|
||||
{keyItem.name ?? '—'}
|
||||
</span>
|
||||
<span className="keys-tab__col-expiry">
|
||||
{formatExpiry(keyItem.expires_at)}
|
||||
</span>
|
||||
<span className="keys-tab__col-last-used">
|
||||
{formatLastUsed(keyItem.last_used ?? null)}
|
||||
</span>
|
||||
<span className="keys-tab__col-action">
|
||||
<Tooltip title="Revoke Key">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
color="destructive"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
setRevokeTarget(keyItem);
|
||||
}}
|
||||
className="keys-tab__revoke-btn"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Count footer */}
|
||||
<div className="keys-tab__table-footer">
|
||||
<span className="keys-tab__count">
|
||||
1 — {keys.length} of {keys.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revoke confirm dialog */}
|
||||
<DialogWrapper
|
||||
open={revokeTarget !== null}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
setRevokeTarget(null);
|
||||
}
|
||||
}}
|
||||
title={`Revoke ${revokeTarget?.name ?? 'key'}?`}
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="delete-dialog__body">
|
||||
Revoking this key will permanently invalidate it. Any systems using
|
||||
this key will lose access immediately.
|
||||
</p>
|
||||
<DialogFooter className="delete-dialog__footer">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setRevokeTarget(null)}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
disabled={isRevoking}
|
||||
onClick={handleRevoke}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isRevoking ? 'Revoking...' : 'Revoke Key'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
|
||||
<EditKeyModal
|
||||
open={editKey !== null}
|
||||
accountId={accountId}
|
||||
keyItem={editKey}
|
||||
onClose={(): void => setEditKey(null)}
|
||||
onSuccess={handleKeySuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeysTab;
|
||||
147
frontend/src/components/ServiceAccountDrawer/OverviewTab.tsx
Normal file
147
frontend/src/components/ServiceAccountDrawer/OverviewTab.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { ChevronDown, LockKeyhole } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Select } from 'antd';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
interface OverviewTabProps {
|
||||
account: ServiceAccountRow;
|
||||
localName: string;
|
||||
onNameChange: (v: string) => void;
|
||||
localRoles: string[];
|
||||
onRolesChange: (v: string[]) => void;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
account,
|
||||
localName,
|
||||
onNameChange,
|
||||
localRoles,
|
||||
onRolesChange,
|
||||
isDisabled,
|
||||
}: OverviewTabProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const formatTimestamp = useCallback(
|
||||
(ts: string | null | undefined): string => {
|
||||
if (!ts) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
|
||||
},
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Name */}
|
||||
<div className="sa-drawer__field">
|
||||
<label className="sa-drawer__label" htmlFor="sa-name">
|
||||
Name
|
||||
</label>
|
||||
{isDisabled ? (
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{localName || '—'}</span>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id="sa-name"
|
||||
value={localName}
|
||||
onChange={(e): void => onNameChange(e.target.value)}
|
||||
className="sa-drawer__input"
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email (always locked) */}
|
||||
<div className="sa-drawer__field">
|
||||
<label className="sa-drawer__label" htmlFor="sa-email">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<span className="sa-drawer__input-text">{account.email || '—'}</span>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Roles */}
|
||||
<div className="sa-drawer__field">
|
||||
<label className="sa-drawer__label" htmlFor="sa-roles">
|
||||
Roles
|
||||
</label>
|
||||
{isDisabled ? (
|
||||
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
|
||||
<div className="sa-drawer__disabled-roles">
|
||||
{localRoles.length > 0 ? (
|
||||
localRoles.map((r) => (
|
||||
<Badge key={r} color="vanilla">
|
||||
{capitalize(r)}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="sa-drawer__input-text">—</span>
|
||||
)}
|
||||
</div>
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
id="sa-roles"
|
||||
mode="multiple"
|
||||
value={localRoles}
|
||||
onChange={(roles): void => onRolesChange(roles as string[])}
|
||||
className="sa-drawer__role-select"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
placeholder="Select roles"
|
||||
getPopupContainer={(triggerNode): HTMLElement =>
|
||||
(triggerNode?.closest('.sa-drawer') as HTMLElement) || document.body
|
||||
}
|
||||
>
|
||||
<Select.Option value="ADMIN">{capitalize('ADMIN')}</Select.Option>
|
||||
<Select.Option value="EDITOR">{capitalize('EDITOR')}</Select.Option>
|
||||
<Select.Option value="VIEWER">{capitalize('VIEWER')}</Select.Option>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="sa-drawer__meta">
|
||||
<div className="sa-drawer__meta-item">
|
||||
<span className="sa-drawer__meta-label">Status</span>
|
||||
{account.status?.toUpperCase() === 'ACTIVE' ? (
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="vanilla" variant="outline">
|
||||
DISABLED
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__meta-item">
|
||||
<span className="sa-drawer__meta-label">Created At</span>
|
||||
<Badge color="vanilla">{formatTimestamp(account.createdAt)}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__meta-item">
|
||||
<span className="sa-drawer__meta-label">Updated At</span>
|
||||
<Badge color="vanilla">{formatTimestamp(account.updatedAt)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverviewTab;
|
||||
@@ -0,0 +1,466 @@
|
||||
.sa-drawer {
|
||||
&__layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__tab-group {
|
||||
// ToggleGroup container — let it inherit default styles
|
||||
}
|
||||
|
||||
&__tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
&__tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--secondary);
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--padding-5) var(--padding-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
&__footer {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
&__footer-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: var(--label-small-400-font-size);
|
||||
font-weight: var(--label-small-400-font-weight);
|
||||
line-height: var(--label-small-400-line-height);
|
||||
letter-spacing: var(--label-small-400-letter-spacing);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
&--primary {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
// Field styles (mirrors EditMemberDrawer)
|
||||
&__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 32px;
|
||||
background: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
color: var(--l1-foreground);
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 32px;
|
||||
padding: 0 var(--padding-2);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
&--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&__input-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__lock-icon {
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&__role-select {
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: var(--l2-background) !important;
|
||||
border-color: var(--border) !important;
|
||||
border-radius: 2px;
|
||||
padding: 2px var(--padding-2) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.ant-select-selection-overflow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.ant-select-selection-overflow-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
padding: 0 4px 0 6px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-select-selection-item-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--foreground);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&:not(.ant-select-disabled):hover .ant-select-selector {
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__disabled-roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
margin-top: var(--margin-1);
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
|
||||
[data-slot='badge'] {
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
align-items: center;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
// Keys tab styles
|
||||
.keys-tab {
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--padding-8) var(--padding-4);
|
||||
gap: var(--spacing-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__empty-emoji {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
&__empty-text {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__learn-more {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&__table-wrap {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 var(--padding-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&__table-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 38px;
|
||||
padding: 0 var(--padding-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
}
|
||||
|
||||
&--alt {
|
||||
background: rgba(171, 189, 255, 0.02);
|
||||
|
||||
&:hover {
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__col-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__col-expiry {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__col-last-used {
|
||||
width: 180px;
|
||||
text-align: right;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__col-action {
|
||||
width: 40px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__name-text {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--l1-foreground);
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// Override col styles in data rows for non-header cells
|
||||
&__table-row &__col-expiry,
|
||||
&__table-row &__col-last-used {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--l2-foreground);
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
&__expiry--never {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&__expiry--expired {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__revoke-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__table-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 var(--padding-4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
&__count {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Activate/disable confirm dialogs
|
||||
.sa-activate-dialog,
|
||||
.sa-disable-dialog {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
|
||||
[data-slot='dialog-title'] {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__body {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l2-foreground);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
margin: 0;
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--margin-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
import {
|
||||
Check,
|
||||
Key,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
PowerOff,
|
||||
Trash2,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import {
|
||||
useUpdateServiceAccount,
|
||||
useUpdateServiceAccountStatus,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import KeysTab from './KeysTab';
|
||||
import OverviewTab from './OverviewTab';
|
||||
|
||||
import './ServiceAccountDrawer.styles.scss';
|
||||
|
||||
export interface ServiceAccountDrawerProps {
|
||||
account: ServiceAccountRow | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function ServiceAccountDrawer({
|
||||
account,
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ServiceAccountDrawerProps): JSX.Element {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'keys'>('overview');
|
||||
const [keyCount, setKeyCount] = useState(0);
|
||||
const [isActivateConfirmOpen, setIsActivateConfirmOpen] = useState(false);
|
||||
const [isDisableConfirmOpen, setIsDisableConfirmOpen] = useState(false);
|
||||
const [localName, setLocalName] = useState('');
|
||||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
const [isAddKeyOpen, setIsAddKeyOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (account) {
|
||||
setLocalName(account.name ?? '');
|
||||
setLocalRoles(account.roles ?? []);
|
||||
setActiveTab('overview');
|
||||
}
|
||||
}, [account]);
|
||||
|
||||
const isDisabled = account?.status?.toUpperCase() !== 'ACTIVE';
|
||||
|
||||
const isDirty =
|
||||
account !== null &&
|
||||
(localName !== (account.name ?? '') ||
|
||||
JSON.stringify(localRoles) !== JSON.stringify(account.roles ?? []));
|
||||
|
||||
const { mutateAsync: updateAccount } = useUpdateServiceAccount();
|
||||
const { mutateAsync: updateStatus } = useUpdateServiceAccountStatus();
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!account || !isDirty) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateAccount({
|
||||
pathParams: { id: account.id },
|
||||
data: { name: localName, email: account.email, roles: localRoles },
|
||||
});
|
||||
toast.success('Service account updated successfully', { richColors: true });
|
||||
onSuccess();
|
||||
} catch {
|
||||
toast.error('Failed to update service account', { richColors: true });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [account, isDirty, localName, localRoles, updateAccount, onSuccess]);
|
||||
|
||||
const handleDisable = useCallback(async (): Promise<void> => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
setIsDisabling(true);
|
||||
try {
|
||||
await updateStatus({
|
||||
pathParams: { id: account.id },
|
||||
data: { status: 'DISABLED' },
|
||||
});
|
||||
toast.success('Service account disabled', { richColors: true });
|
||||
onSuccess();
|
||||
} catch {
|
||||
toast.error('Failed to disable service account', { richColors: true });
|
||||
} finally {
|
||||
setIsDisabling(false);
|
||||
}
|
||||
}, [account, updateStatus, onSuccess]);
|
||||
|
||||
const handleActivate = useCallback(async (): Promise<void> => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
setIsActivating(true);
|
||||
try {
|
||||
await updateStatus({
|
||||
pathParams: { id: account.id },
|
||||
data: { status: 'ACTIVE' },
|
||||
});
|
||||
toast.success('Service account activated', { richColors: true });
|
||||
setIsActivateConfirmOpen(false);
|
||||
onSuccess();
|
||||
} catch {
|
||||
toast.error('Failed to activate service account', { richColors: true });
|
||||
} finally {
|
||||
setIsActivating(false);
|
||||
}
|
||||
}, [account, updateStatus, onSuccess]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setIsActivateConfirmOpen(false);
|
||||
setIsDisableConfirmOpen(false);
|
||||
setIsAddKeyOpen(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleKeySuccess = useCallback((): void => {
|
||||
// Keys tab will refetch internally; just update count if needed
|
||||
}, []);
|
||||
|
||||
const drawerContent = (
|
||||
<div className="sa-drawer__layout">
|
||||
<div className="sa-drawer__tabs">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={activeTab}
|
||||
onValueChange={(val): void => {
|
||||
if (val) {
|
||||
setActiveTab(val as 'overview' | 'keys');
|
||||
}
|
||||
}}
|
||||
className="sa-drawer__tab-group"
|
||||
>
|
||||
<ToggleGroupItem value="overview" className="sa-drawer__tab">
|
||||
<LayoutGrid size={14} />
|
||||
Overview
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="keys" className="sa-drawer__tab">
|
||||
<Key size={14} />
|
||||
Keys
|
||||
{keyCount > 0 && <span className="sa-drawer__tab-count">{keyCount}</span>}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{activeTab === 'keys' && (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="primary"
|
||||
onClick={(): void => setIsAddKeyOpen(true)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add Key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__body">
|
||||
{activeTab === 'overview' && account && (
|
||||
<OverviewTab
|
||||
account={account}
|
||||
localName={localName}
|
||||
onNameChange={setLocalName}
|
||||
localRoles={localRoles}
|
||||
onRolesChange={setLocalRoles}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'keys' && account && (
|
||||
<KeysTab
|
||||
accountId={account.id}
|
||||
onKeyCountChange={setKeyCount}
|
||||
onAddKeyClick={(): void => setIsAddKeyOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sa-drawer__footer">
|
||||
{isDisabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="sa-drawer__footer-btn sa-drawer__footer-btn--primary"
|
||||
onClick={(): void => setIsActivateConfirmOpen(true)}
|
||||
>
|
||||
<Check size={12} />
|
||||
Activate Service Account
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="sa-drawer__footer-btn sa-drawer__footer-btn--danger"
|
||||
onClick={(): void => setIsDisableConfirmOpen(true)}
|
||||
>
|
||||
<PowerOff size={12} />
|
||||
Disable Service Account
|
||||
</button>
|
||||
{activeTab === 'overview' && (
|
||||
<div className="sa-drawer__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!isDirty || isSaving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
type="panel"
|
||||
showCloseButton
|
||||
showOverlay={false}
|
||||
allowOutsideClick
|
||||
header={{ title: 'Service Account Details' }}
|
||||
content={drawerContent}
|
||||
className="sa-drawer"
|
||||
/>
|
||||
|
||||
{/* Activate confirm dialog */}
|
||||
<DialogWrapper
|
||||
open={isActivateConfirmOpen}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
setIsActivateConfirmOpen(false);
|
||||
}
|
||||
}}
|
||||
title={`Activate service account ${account?.name ?? ''}?`}
|
||||
width="narrow"
|
||||
className="alert-dialog sa-activate-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="sa-activate-dialog__body">
|
||||
Reactivating this service account will restore access for all its keys and
|
||||
any systems using them.
|
||||
</p>
|
||||
<DialogFooter className="sa-activate-dialog__footer">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setIsActivateConfirmOpen(false)}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={isActivating}
|
||||
onClick={handleActivate}
|
||||
>
|
||||
<Check size={12} />
|
||||
{isActivating ? 'Activating...' : 'Activate'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
|
||||
{/* Disable confirm dialog */}
|
||||
<DialogWrapper
|
||||
open={isDisableConfirmOpen}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
setIsDisableConfirmOpen(false);
|
||||
}
|
||||
}}
|
||||
title={`Disable service account ${account?.name ?? ''}?`}
|
||||
width="narrow"
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="delete-dialog__body">
|
||||
Disabling this service account will revoke access for all its keys. Any
|
||||
systems using this account will lose access immediately.
|
||||
</p>
|
||||
<DialogFooter className="delete-dialog__footer">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setIsDisableConfirmOpen(false)}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
disabled={isDisabling}
|
||||
onClick={handleDisable}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isDisabling ? 'Disabling...' : 'Disable'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogWrapper>
|
||||
|
||||
{account && (
|
||||
<AddKeyModal
|
||||
open={isAddKeyOpen}
|
||||
accountId={account.id}
|
||||
onClose={(): void => setIsAddKeyOpen(false)}
|
||||
onSuccess={handleKeySuccess}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceAccountDrawer;
|
||||
@@ -0,0 +1,191 @@
|
||||
.sa-table-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sa-table {
|
||||
.ant-table {
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ant-table-container {
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-table-thead {
|
||||
> tr > th,
|
||||
> tr > td {
|
||||
background: var(--background);
|
||||
font-size: var(--paragraph-small-600-font-size);
|
||||
font-weight: var(--paragraph-small-600-font-weight);
|
||||
line-height: var(--paragraph-small-600-line-height);
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground);
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
border-bottom: none !important;
|
||||
border-top: none !important;
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody {
|
||||
> tr > td {
|
||||
border-bottom: none !important;
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
background: transparent;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
> tr.sa-table-row--tinted > td {
|
||||
background: rgba(171, 189, 255, 0.02);
|
||||
}
|
||||
|
||||
> tr:hover > td {
|
||||
background: rgba(171, 189, 255, 0.04) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-wrapper,
|
||||
.ant-table-container,
|
||||
.ant-spin-nested-loading,
|
||||
.ant-spin-container {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.sa-status-cell {
|
||||
[data-slot='badge'] {
|
||||
padding: var(--padding-1) var(--padding-2);
|
||||
align-items: center;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-name-email-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
height: 22px;
|
||||
overflow: hidden;
|
||||
|
||||
.sa-name {
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
color: var(--foreground);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sa-email {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--l3-foreground-hover);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.07px;
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-roles-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.sa-dash {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
color: var(--l3-foreground-hover);
|
||||
}
|
||||
|
||||
.sa-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--padding-12) var(--padding-4);
|
||||
gap: var(--spacing-4);
|
||||
color: var(--foreground);
|
||||
|
||||
&__emoji {
|
||||
font-size: var(--font-size-2xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
margin: 0;
|
||||
line-height: var(--paragraph-base-400-font-height);
|
||||
|
||||
strong {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--bg-base-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: var(--padding-2) var(--padding-4);
|
||||
|
||||
.ant-pagination-total-text {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.sa-pagination-range {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.sa-pagination-total {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.sa-table {
|
||||
.ant-table-tbody {
|
||||
> tr.sa-table-row--tinted > td {
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
}
|
||||
|
||||
> tr:hover > td {
|
||||
background: rgba(0, 0, 0, 0.03) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-empty-state {
|
||||
&__text {
|
||||
strong {
|
||||
color: var(--bg-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { Pagination, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import './ServiceAccountsTable.styles.scss';
|
||||
|
||||
interface ServiceAccountsTableProps {
|
||||
data: ServiceAccountRow[];
|
||||
loading: boolean;
|
||||
total: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
searchQuery: string;
|
||||
onPageChange: (page: number) => void;
|
||||
onRowClick?: (row: ServiceAccountRow) => void;
|
||||
}
|
||||
|
||||
function NameEmailCell({
|
||||
name,
|
||||
email,
|
||||
}: {
|
||||
name: string;
|
||||
email: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="sa-name-email-cell">
|
||||
{name && (
|
||||
<span className="sa-name" title={name}>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<Tooltip title={email} overlayClassName="sa-tooltip">
|
||||
<span className="sa-email">{email}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RolesCell({ roles }: { roles: string[] }): JSX.Element {
|
||||
if (!roles || roles.length === 0) {
|
||||
return <span className="sa-dash">—</span>;
|
||||
}
|
||||
const first = roles[0];
|
||||
const overflow = roles.length - 1;
|
||||
return (
|
||||
<div className="sa-roles-cell">
|
||||
<Badge color="vanilla">{first}</Badge>
|
||||
{overflow > 0 && (
|
||||
<Badge color="vanilla" variant="outline">
|
||||
+{overflow}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }): JSX.Element {
|
||||
if (status?.toUpperCase() === 'ACTIVE') {
|
||||
return (
|
||||
<Badge color="forest" variant="outline">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge color="vanilla" variant="outline">
|
||||
DISABLED
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceAccountsEmptyState({
|
||||
searchQuery,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="sa-empty-state">
|
||||
<span className="sa-empty-state__emoji" role="img" aria-label="robot">
|
||||
🤖
|
||||
</span>
|
||||
{searchQuery ? (
|
||||
<p className="sa-empty-state__text">
|
||||
No results for <strong>{searchQuery}</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p className="sa-empty-state__text">
|
||||
No service accounts. Start by creating one to manage keys.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceAccountsTable({
|
||||
data,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
pageSize,
|
||||
searchQuery,
|
||||
onPageChange,
|
||||
onRowClick,
|
||||
}: ServiceAccountsTableProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const formatCreatedAt = (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<ServiceAccountRow> = [
|
||||
{
|
||||
title: 'Name / Email',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (_, record): JSX.Element => (
|
||||
<NameEmailCell name={record.name} email={record.email} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Roles',
|
||||
dataIndex: 'roles',
|
||||
key: 'roles',
|
||||
width: 200,
|
||||
render: (roles: string[]): JSX.Element => <RolesCell roles={roles} />,
|
||||
},
|
||||
{
|
||||
title: 'Permissions',
|
||||
key: 'permissions',
|
||||
width: 240,
|
||||
render: (): JSX.Element => <span className="sa-dash">—</span>,
|
||||
},
|
||||
{
|
||||
title: 'Keys',
|
||||
key: 'keys',
|
||||
width: 96,
|
||||
align: 'right' as const,
|
||||
render: (): JSX.Element => <span className="sa-dash">—</span>,
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 96,
|
||||
align: 'right' as const,
|
||||
className: 'sa-status-cell',
|
||||
render: (status: string): JSX.Element => <StatusBadge status={status} />,
|
||||
},
|
||||
];
|
||||
|
||||
const showPaginationTotal = (_total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
<span className="sa-pagination-range">
|
||||
{range[0]} — {range[1]}
|
||||
</span>
|
||||
<span className="sa-pagination-total"> of {_total}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
// Silence unused import warning — formatCreatedAt used by future columns
|
||||
void formatCreatedAt;
|
||||
|
||||
return (
|
||||
<div className="sa-table-wrapper">
|
||||
<Table<ServiceAccountRow>
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'sa-table-row--tinted' : ''
|
||||
}
|
||||
showSorterTooltip={false}
|
||||
locale={{
|
||||
emptyText: <ServiceAccountsEmptyState searchQuery={searchQuery} />,
|
||||
}}
|
||||
className="sa-table"
|
||||
onRow={(record): { onClick: () => void; style?: React.CSSProperties } => ({
|
||||
onClick: (): void => onRowClick?.(record),
|
||||
style: onRowClick ? { cursor: 'pointer' } : undefined,
|
||||
})}
|
||||
/>
|
||||
{total > pageSize && (
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showTotal={showPaginationTotal}
|
||||
showSizeChanger={false}
|
||||
onChange={onPageChange}
|
||||
className="sa-table-pagination"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceAccountsTable;
|
||||
@@ -86,6 +86,7 @@ const ROUTES = {
|
||||
METER_EXPLORER_VIEWS: '/meter/explorer/views',
|
||||
HOME_PAGE: '/',
|
||||
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
|
||||
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
.sa-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
|
||||
height: 100%;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--label-large-500-font-size);
|
||||
font-weight: var(--label-large-500-font-weight);
|
||||
color: var(--text-base-white);
|
||||
letter-spacing: -0.09px;
|
||||
line-height: var(--line-height-normal);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.07px;
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__learn-more {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-settings-filter-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
background-color: var(--l2-background);
|
||||
|
||||
> span {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.sa-settings-filter-dropdown {
|
||||
.ant-dropdown-menu {
|
||||
padding: var(--padding-3) 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--l2-background);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item {
|
||||
background: transparent !important;
|
||||
padding: var(--padding-1) 0 !important;
|
||||
|
||||
&:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sa-settings-filter-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.14px;
|
||||
min-width: 170px;
|
||||
|
||||
&:hover {
|
||||
color: var(--card-foreground);
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.sa-settings-search-input {
|
||||
height: 32px;
|
||||
color: var(--l1-foreground);
|
||||
background-color: var(--l2-background);
|
||||
border-color: var(--border);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.sa-settings {
|
||||
&__title {
|
||||
color: var(--text-base-black);
|
||||
}
|
||||
}
|
||||
|
||||
.sa-settings-filter-option {
|
||||
&:hover {
|
||||
color: var(--bg-neutral-light-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Check, ChevronDown, Plus } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
|
||||
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
|
||||
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
|
||||
import ServiceAccountsTable from 'components/ServiceAccountsTable/ServiceAccountsTable';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
import { FilterMode, ServiceAccountRow } from './utils';
|
||||
|
||||
import './ServiceAccountsSettings.styles.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function ServiceAccountsSettings(): JSX.Element {
|
||||
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>(FilterMode.All);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [selectedAccount, setSelectedAccount] = useState<ServiceAccountRow | null>(null);
|
||||
|
||||
const {
|
||||
data: serviceAccountsData,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useListServiceAccounts();
|
||||
|
||||
const allAccounts = useMemo(
|
||||
(): ServiceAccountRow[] =>
|
||||
(serviceAccountsData?.data ?? []).map((sa) => ({
|
||||
id: sa.id,
|
||||
name: sa.name,
|
||||
email: sa.email,
|
||||
roles: sa.roles,
|
||||
status: sa.status,
|
||||
createdAt: sa.createdAt ? String(sa.createdAt) : null,
|
||||
updatedAt: sa.updatedAt ? String(sa.updatedAt) : null,
|
||||
})),
|
||||
[serviceAccountsData],
|
||||
);
|
||||
|
||||
const activeCount = useMemo(
|
||||
() => allAccounts.filter((a) => a.status?.toUpperCase() === 'ACTIVE').length,
|
||||
[allAccounts],
|
||||
);
|
||||
|
||||
const disabledCount = useMemo(
|
||||
() => allAccounts.filter((a) => a.status?.toUpperCase() !== 'ACTIVE').length,
|
||||
[allAccounts],
|
||||
);
|
||||
|
||||
const filteredAccounts = useMemo((): ServiceAccountRow[] => {
|
||||
let result = allAccounts;
|
||||
|
||||
if (filterMode === FilterMode.Active) {
|
||||
result = result.filter((a) => a.status?.toUpperCase() === 'ACTIVE');
|
||||
} else if (filterMode === FilterMode.Disabled) {
|
||||
result = result.filter((a) => a.status?.toUpperCase() !== 'ACTIVE');
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(a) =>
|
||||
a.name?.toLowerCase().includes(q) || a.email?.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [allAccounts, filterMode, searchQuery]);
|
||||
|
||||
const paginatedAccounts = useMemo((): ServiceAccountRow[] => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return filteredAccounts.slice(start, start + PAGE_SIZE);
|
||||
}, [filteredAccounts, currentPage]);
|
||||
|
||||
const setPage = useCallback(
|
||||
(page: number): void => {
|
||||
urlQuery.set('page', String(page));
|
||||
history.replace({ search: urlQuery.toString() });
|
||||
},
|
||||
[history, urlQuery],
|
||||
);
|
||||
|
||||
const totalCount = allAccounts.length;
|
||||
|
||||
const filterMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: FilterMode.All,
|
||||
label: (
|
||||
<div className="sa-settings-filter-option">
|
||||
<span>All accounts ⎯ {totalCount}</span>
|
||||
{filterMode === FilterMode.All && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.All);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: FilterMode.Active,
|
||||
label: (
|
||||
<div className="sa-settings-filter-option">
|
||||
<span>Active ⎯ {activeCount}</span>
|
||||
{filterMode === FilterMode.Active && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.Active);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: FilterMode.Disabled,
|
||||
label: (
|
||||
<div className="sa-settings-filter-option">
|
||||
<span>Disabled ⎯ {disabledCount}</span>
|
||||
{filterMode === FilterMode.Disabled && <Check size={14} />}
|
||||
</div>
|
||||
),
|
||||
onClick: (): void => {
|
||||
setFilterMode(FilterMode.Disabled);
|
||||
setPage(1);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filterLabel =
|
||||
filterMode === FilterMode.Active
|
||||
? `Active ⎯ ${activeCount}`
|
||||
: filterMode === FilterMode.Disabled
|
||||
? `Disabled ⎯ ${disabledCount}`
|
||||
: `All accounts ⎯ ${totalCount}`;
|
||||
|
||||
const handleRowClick = useCallback((row: ServiceAccountRow): void => {
|
||||
setSelectedAccount(row);
|
||||
}, []);
|
||||
|
||||
const handleDrawerClose = useCallback((): void => {
|
||||
setSelectedAccount(null);
|
||||
}, []);
|
||||
|
||||
const handleDrawerSuccess = useCallback((): void => {
|
||||
refetch();
|
||||
setSelectedAccount(null);
|
||||
}, [refetch]);
|
||||
|
||||
const handleCreateSuccess = useCallback((): void => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sa-settings">
|
||||
<div className="sa-settings__header">
|
||||
<h1 className="sa-settings__title">Service Accounts</h1>
|
||||
<p className="sa-settings__subtitle">
|
||||
Service accounts are used for machine-to-machine authentication via API
|
||||
keys.{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/service-accounts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sa-settings__learn-more"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="sa-settings__controls">
|
||||
<Dropdown
|
||||
menu={{ items: filterMenuItems }}
|
||||
trigger={['click']}
|
||||
overlayClassName="sa-settings-filter-dropdown"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
className="sa-settings-filter-trigger"
|
||||
>
|
||||
<span>{filterLabel}</span>
|
||||
<ChevronDown size={12} className="sa-settings-filter-trigger__chevron" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<div className="sa-settings__search">
|
||||
<Input
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => {
|
||||
setSearchQuery(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="sa-settings-search-input"
|
||||
color="secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="primary"
|
||||
onClick={(): void => setIsCreateModalOpen(true)}
|
||||
>
|
||||
<Plus size={12} />
|
||||
New Service Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ServiceAccountsTable
|
||||
data={paginatedAccounts}
|
||||
loading={isLoading}
|
||||
total={filteredAccounts.length}
|
||||
currentPage={currentPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
searchQuery={searchQuery}
|
||||
onPageChange={setPage}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
|
||||
<CreateServiceAccountModal
|
||||
open={isCreateModalOpen}
|
||||
onClose={(): void => setIsCreateModalOpen(false)}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
|
||||
<ServiceAccountDrawer
|
||||
account={selectedAccount}
|
||||
open={selectedAccount !== null}
|
||||
onClose={handleDrawerClose}
|
||||
onSuccess={handleDrawerSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceAccountsSettings;
|
||||
15
frontend/src/container/ServiceAccountsSettings/utils.ts
Normal file
15
frontend/src/container/ServiceAccountsSettings/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export enum FilterMode {
|
||||
All = 'all',
|
||||
Active = 'active',
|
||||
Disabled = 'disabled',
|
||||
}
|
||||
|
||||
export interface ServiceAccountRow {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
BellDot,
|
||||
Binoculars,
|
||||
Book,
|
||||
Bot,
|
||||
Boxes,
|
||||
BugIcon,
|
||||
Building2,
|
||||
@@ -358,6 +359,13 @@ export const settingsNavSections: SettingsNavSection[] = [
|
||||
isEnabled: false,
|
||||
itemKey: 'members',
|
||||
},
|
||||
{
|
||||
key: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
label: 'Service Accounts',
|
||||
icon: <Bot size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'service-accounts',
|
||||
},
|
||||
{
|
||||
key: ROUTES.API_KEYS,
|
||||
label: 'API Keys',
|
||||
|
||||
@@ -154,6 +154,7 @@ export const routesToSkip = [
|
||||
ROUTES.ALL_DASHBOARD,
|
||||
ROUTES.ORG_SETTINGS,
|
||||
ROUTES.MEMBERS_SETTINGS,
|
||||
ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
ROUTES.INGESTION_SETTINGS,
|
||||
ROUTES.API_KEYS,
|
||||
ROUTES.ERROR_DETAIL,
|
||||
|
||||
1
frontend/src/pages/ServiceAccountsSettings/index.tsx
Normal file
1
frontend/src/pages/ServiceAccountsSettings/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from 'container/ServiceAccountsSettings/ServiceAccountsSettings';
|
||||
@@ -84,6 +84,7 @@ function SettingsPage(): JSX.Element {
|
||||
item.key === ROUTES.INGESTION_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
item.key === ROUTES.SHORTCUTS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
@@ -115,6 +116,7 @@ function SettingsPage(): JSX.Element {
|
||||
item.key === ROUTES.API_KEYS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
item.key === ROUTES.INGESTION_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
@@ -140,7 +142,8 @@ function SettingsPage(): JSX.Element {
|
||||
isEnabled:
|
||||
item.key === ROUTES.API_KEYS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
|
||||
@@ -17,6 +17,7 @@ import { TFunction } from 'i18next';
|
||||
import {
|
||||
Backpack,
|
||||
BellDot,
|
||||
Bot,
|
||||
Building,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import ChannelsEdit from 'pages/ChannelsEdit';
|
||||
import MembersSettings from 'pages/MembersSettings';
|
||||
import ServiceAccountsSettings from 'pages/ServiceAccountsSettings';
|
||||
import Shortcuts from 'pages/Shortcuts';
|
||||
|
||||
export const organizationSettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
@@ -203,6 +205,22 @@ export const mySettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
},
|
||||
];
|
||||
|
||||
export const serviceAccountsSettings = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_t: TFunction,
|
||||
): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: ServiceAccountsSettings,
|
||||
name: (
|
||||
<div className="periscope-tab">
|
||||
<Bot size={16} /> Service Accounts
|
||||
</div>
|
||||
),
|
||||
route: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
key: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
},
|
||||
];
|
||||
|
||||
export const createAlertChannels = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: (): JSX.Element => (
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
organizationSettings,
|
||||
roleDetails,
|
||||
rolesSettings,
|
||||
serviceAccountsSettings,
|
||||
} from './config';
|
||||
|
||||
export const getRoutes = (
|
||||
@@ -61,7 +62,11 @@ export const getRoutes = (
|
||||
settings.push(...alertChannels(t));
|
||||
|
||||
if (isAdmin) {
|
||||
settings.push(...apiKeys(t), ...membersSettings(t));
|
||||
settings.push(
|
||||
...apiKeys(t),
|
||||
...membersSettings(t),
|
||||
...serviceAccountsSettings(t),
|
||||
);
|
||||
}
|
||||
|
||||
// todo: Sagar - check the condition for role list and details page, to whom we want to serve
|
||||
|
||||
@@ -100,6 +100,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
ROLES_SETTINGS: ['ADMIN'],
|
||||
ROLE_DETAILS: ['ADMIN'],
|
||||
MEMBERS_SETTINGS: ['ADMIN'],
|
||||
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN'],
|
||||
BILLING: ['ADMIN'],
|
||||
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
|
||||
Reference in New Issue
Block a user