mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-10 07:22:09 +00:00
feat: multiple style and functionality fixes
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { ChevronDown, X } from '@signozhq/icons';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Form, Input, Select } from 'antd';
|
||||
import { Form, Input } from 'antd';
|
||||
import { useCreateServiceAccount } from 'api/generated/services/serviceaccount';
|
||||
import RolesSelect, { useRoles } from 'components/RolesSelect';
|
||||
|
||||
import './CreateServiceAccountModal.styles.scss';
|
||||
|
||||
@@ -16,7 +17,7 @@ interface CreateServiceAccountModalProps {
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
email?: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
@@ -27,8 +28,25 @@ function CreateServiceAccountModal({
|
||||
}: CreateServiceAccountModalProps): JSX.Element {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submittable, setSubmittable] = useState(false);
|
||||
|
||||
const values = Form.useWatch([], form);
|
||||
|
||||
useEffect(() => {
|
||||
form
|
||||
.validateFields({ validateOnly: true })
|
||||
.then(() => setSubmittable(true))
|
||||
.catch(() => setSubmittable(false));
|
||||
}, [values, form]);
|
||||
|
||||
const { mutateAsync: createServiceAccount } = useCreateServiceAccount();
|
||||
const {
|
||||
roles,
|
||||
isLoading: rolesLoading,
|
||||
isError: rolesError,
|
||||
error: rolesErrorObj,
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
form.resetFields();
|
||||
@@ -42,8 +60,8 @@ function CreateServiceAccountModal({
|
||||
await createServiceAccount({
|
||||
data: {
|
||||
name: values.name.trim(),
|
||||
email: values.email?.trim() ?? '',
|
||||
roles: values.roles ?? [],
|
||||
email: values.email.trim(),
|
||||
roles: values.roles,
|
||||
},
|
||||
});
|
||||
toast.success('Service account created successfully', { richColors: true });
|
||||
@@ -93,6 +111,10 @@ function CreateServiceAccountModal({
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="Email Address"
|
||||
rules={[
|
||||
{ required: true, message: 'Email Address is required' },
|
||||
{ type: 'email', message: 'Please enter a valid email address' },
|
||||
]}
|
||||
className="create-sa-form__item"
|
||||
>
|
||||
<Input
|
||||
@@ -106,21 +128,26 @@ function CreateServiceAccountModal({
|
||||
authentication.
|
||||
</p>
|
||||
|
||||
<Form.Item name="roles" label="Roles" className="create-sa-form__item">
|
||||
<Select
|
||||
<Form.Item
|
||||
name="roles"
|
||||
label="Roles"
|
||||
rules={[{ required: true, message: 'At least one role is required' }]}
|
||||
className="create-sa-form__item"
|
||||
>
|
||||
<RolesSelect
|
||||
mode="multiple"
|
||||
roles={roles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={refetchRoles}
|
||||
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>
|
||||
@@ -142,7 +169,7 @@ function CreateServiceAccountModal({
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || !submittable}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Service Account'}
|
||||
</Button>
|
||||
|
||||
25
frontend/src/components/RolesSelect/RolesSelect.styles.scss
Normal file
25
frontend/src/components/RolesSelect/RolesSelect.styles.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
.roles-select-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
color: var(--bg-cherry-500);
|
||||
font-size: 12px;
|
||||
|
||||
&__msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__retry-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
color: var(--bg-cherry-500);
|
||||
}
|
||||
}
|
||||
171
frontend/src/components/RolesSelect/RolesSelect.tsx
Normal file
171
frontend/src/components/RolesSelect/RolesSelect.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { CircleAlert, RefreshCw } from '@signozhq/icons';
|
||||
import { Checkbox, Select } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { useListRoles } from 'api/generated/services/role';
|
||||
import type { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './RolesSelect.styles.scss';
|
||||
|
||||
export interface RoleOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function useRoles(): {
|
||||
roles: RoletypesRoleDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | undefined;
|
||||
refetch: () => void;
|
||||
} {
|
||||
const { data, isLoading, isError, error, refetch } = useListRoles();
|
||||
return {
|
||||
roles: data?.data ?? [],
|
||||
isLoading,
|
||||
isError,
|
||||
error: convertToApiError(error),
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoleOptions(roles: RoletypesRoleDTO[]): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
label: role.name ?? '',
|
||||
value: role.name ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
function ErrorContent({
|
||||
error,
|
||||
onRefetch,
|
||||
}: {
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
}): JSX.Element {
|
||||
const errorMessage = error?.message || 'Failed to load roles';
|
||||
|
||||
return (
|
||||
<div className="roles-select-error">
|
||||
<span className="roles-select-error__msg">
|
||||
<CircleAlert size={12} />
|
||||
{errorMessage}
|
||||
</span>
|
||||
{onRefetch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onRefetch();
|
||||
}}
|
||||
className="roles-select-error__retry-btn"
|
||||
title="Retry"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BaseProps {
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
|
||||
roles?: RoletypesRoleDTO[];
|
||||
loading?: boolean;
|
||||
isError?: boolean;
|
||||
error?: APIError;
|
||||
onRefetch?: () => void;
|
||||
}
|
||||
|
||||
interface SingleProps extends BaseProps {
|
||||
mode?: 'single';
|
||||
value?: string;
|
||||
onChange?: (role: string) => void;
|
||||
}
|
||||
|
||||
interface MultipleProps extends BaseProps {
|
||||
mode: 'multiple';
|
||||
value?: string[];
|
||||
onChange?: (roles: string[]) => void;
|
||||
}
|
||||
|
||||
export type RolesSelectProps = SingleProps | MultipleProps;
|
||||
|
||||
function RolesSelect(props: RolesSelectProps): JSX.Element {
|
||||
const externalRoles = props.roles;
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: internalLoading,
|
||||
isError: internalError,
|
||||
error: internalErrorObj,
|
||||
refetch: internalRefetch,
|
||||
} = useListRoles({
|
||||
query: { enabled: externalRoles === undefined },
|
||||
});
|
||||
|
||||
const roles = externalRoles ?? data?.data ?? [];
|
||||
const options = getRoleOptions(roles);
|
||||
|
||||
const {
|
||||
mode,
|
||||
id,
|
||||
placeholder = 'Select role',
|
||||
className,
|
||||
getPopupContainer,
|
||||
loading = internalLoading,
|
||||
isError = internalError,
|
||||
error = convertToApiError(internalErrorObj),
|
||||
onRefetch = externalRoles === undefined ? internalRefetch : undefined,
|
||||
} = props;
|
||||
|
||||
const notFoundContent = isError ? (
|
||||
<ErrorContent error={error} onRefetch={onRefetch} />
|
||||
) : undefined;
|
||||
|
||||
if (mode === 'multiple') {
|
||||
const { value = [], onChange } = props as MultipleProps;
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
mode="multiple"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
optionRender={(option): JSX.Element => (
|
||||
<Checkbox
|
||||
checked={value.includes(option.value as string)}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
)}
|
||||
getPopupContainer={getPopupContainer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { value, onChange } = props as SingleProps;
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
loading={loading}
|
||||
notFoundContent={notFoundContent}
|
||||
options={options}
|
||||
getPopupContainer={getPopupContainer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RolesSelect;
|
||||
2
frontend/src/components/RolesSelect/index.ts
Normal file
2
frontend/src/components/RolesSelect/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { RoleOption, RolesSelectProps } from './RolesSelect';
|
||||
export { default, getRoleOptions, useRoles } from './RolesSelect';
|
||||
@@ -9,7 +9,6 @@ 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';
|
||||
@@ -105,7 +104,7 @@ function AddKeyModal({
|
||||
return 'Never';
|
||||
}
|
||||
try {
|
||||
return format(expiryDate.toDate(), 'MMM d, yyyy');
|
||||
return expiryDate.format('MMM D, YYYY');
|
||||
} catch {
|
||||
return 'Never';
|
||||
}
|
||||
@@ -143,7 +142,7 @@ function AddKeyModal({
|
||||
</div>
|
||||
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label">Expiration</label>
|
||||
<span className="add-key-modal__label">Expiration</span>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={expiryMode}
|
||||
@@ -174,9 +173,12 @@ function AddKeyModal({
|
||||
|
||||
{expiryMode === 'date' && (
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label">Expiration Date</label>
|
||||
<label className="add-key-modal__label" htmlFor="expiry-date">
|
||||
Expiration Date
|
||||
</label>
|
||||
<div className="add-key-modal__datepicker">
|
||||
<DatePicker
|
||||
id="expiry-date"
|
||||
value={expiryDate}
|
||||
onChange={(date): void => setExpiryDate(date)}
|
||||
style={{ width: '100%', height: 32 }}
|
||||
@@ -222,7 +224,7 @@ function AddKeyModal({
|
||||
{phase === 'created' && createdKey && (
|
||||
<div className="add-key-modal__form">
|
||||
<div className="add-key-modal__field">
|
||||
<label className="add-key-modal__label">API Key</label>
|
||||
<span className="add-key-modal__label">API Key</span>
|
||||
<div className="add-key-modal__key-display">
|
||||
<span className="add-key-modal__key-text">{createdKey.key}</span>
|
||||
<Button
|
||||
@@ -252,12 +254,7 @@ function AddKeyModal({
|
||||
<div className="add-key-modal__footer">
|
||||
<span />
|
||||
<div className="add-key-modal__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<Button variant="solid" color="primary" size="sm" onClick={handleClose}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -11,11 +11,12 @@ import {
|
||||
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 type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { formatLastUsed } from './utils';
|
||||
|
||||
import './EditKeyModal.styles.scss';
|
||||
|
||||
interface EditKeyModalProps {
|
||||
@@ -28,6 +29,7 @@ interface EditKeyModalProps {
|
||||
|
||||
type ExpiryMode = 'none' | 'date';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function EditKeyModal({
|
||||
open,
|
||||
accountId,
|
||||
@@ -68,7 +70,9 @@ function EditKeyModal({
|
||||
const { mutateAsync: revokeKey } = useRevokeServiceAccountKey();
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!keyItem || !isDirty) {return;}
|
||||
if (!keyItem || !isDirty) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateKey({
|
||||
@@ -82,10 +86,20 @@ function EditKeyModal({
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [keyItem, isDirty, localName, currentExpiresAt, accountId, updateKey, onSuccess]);
|
||||
}, [
|
||||
keyItem,
|
||||
isDirty,
|
||||
localName,
|
||||
currentExpiresAt,
|
||||
accountId,
|
||||
updateKey,
|
||||
onSuccess,
|
||||
]);
|
||||
|
||||
const handleRevoke = useCallback(async (): Promise<void> => {
|
||||
if (!keyItem) {return;}
|
||||
if (!keyItem) {
|
||||
return;
|
||||
}
|
||||
setIsRevoking(true);
|
||||
try {
|
||||
await revokeKey({
|
||||
@@ -101,25 +115,18 @@ function EditKeyModal({
|
||||
}
|
||||
}, [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 '—';
|
||||
}
|
||||
},
|
||||
const handleFormatLastUsed = useCallback(
|
||||
(lastUsed: Date | null | undefined): string =>
|
||||
formatLastUsed(lastUsed, formatTimezoneAdjustedTimestamp),
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const expiryDisplayLabel = (): string => {
|
||||
if (expiryMode === 'none' || !localDate) {return 'Never';}
|
||||
if (expiryMode === 'none' || !localDate) {
|
||||
return 'Never';
|
||||
}
|
||||
try {
|
||||
return format(localDate.toDate(), 'MMM d, yyyy');
|
||||
return localDate.format('MMM D, YYYY');
|
||||
} catch {
|
||||
return 'Never';
|
||||
}
|
||||
@@ -153,24 +160,33 @@ function EditKeyModal({
|
||||
|
||||
{/* 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>
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-display">
|
||||
Key
|
||||
</label>
|
||||
<div id="edit-key-display" 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">
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-expiry-toggle">
|
||||
Expiration
|
||||
</label>
|
||||
<div
|
||||
id="edit-key-expiry-toggle"
|
||||
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' : ''}`}
|
||||
className={`edit-key-modal__expiry-toggle-btn${
|
||||
expiryMode === 'none'
|
||||
? ' edit-key-modal__expiry-toggle-btn--active'
|
||||
: ''
|
||||
}`}
|
||||
onClick={(): void => {
|
||||
setExpiryMode('none');
|
||||
setLocalDate(null);
|
||||
@@ -182,7 +198,11 @@ function EditKeyModal({
|
||||
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' : ''}`}
|
||||
className={`edit-key-modal__expiry-toggle-btn${
|
||||
expiryMode === 'date'
|
||||
? ' edit-key-modal__expiry-toggle-btn--active'
|
||||
: ''
|
||||
}`}
|
||||
onClick={(): void => setExpiryMode('date')}
|
||||
>
|
||||
Set Expiration Date
|
||||
@@ -192,8 +212,10 @@ function EditKeyModal({
|
||||
|
||||
{expiryMode === 'date' && (
|
||||
<div className="edit-key-modal__field">
|
||||
<label className="edit-key-modal__label">Expiration Date</label>
|
||||
<div className="edit-key-modal__datepicker">
|
||||
<label className="edit-key-modal__label" htmlFor="edit-key-datepicker">
|
||||
Expiration Date
|
||||
</label>
|
||||
<div id="edit-key-datepicker" className="edit-key-modal__datepicker">
|
||||
<DatePicker
|
||||
value={localDate}
|
||||
onChange={(date): void => setLocalDate(date)}
|
||||
@@ -213,7 +235,7 @@ function EditKeyModal({
|
||||
<div className="edit-key-modal__meta">
|
||||
<span className="edit-key-modal__meta-label">Last Used</span>
|
||||
<Badge color="vanilla">
|
||||
{formatLastUsed(keyItem?.last_used ?? null)}
|
||||
{handleFormatLastUsed(keyItem?.last_used ?? null)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -228,12 +250,7 @@ function EditKeyModal({
|
||||
Revoke Key
|
||||
</button>
|
||||
<div className="edit-key-modal__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -255,7 +272,9 @@ function EditKeyModal({
|
||||
<DialogWrapper
|
||||
open={isRevokeConfirmOpen}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {setIsRevokeConfirmOpen(false);}
|
||||
if (!isOpen) {
|
||||
setIsRevokeConfirmOpen(false);
|
||||
}
|
||||
}}
|
||||
title={`Revoke ${keyItem?.name ?? 'key'}?`}
|
||||
width="narrow"
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
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 dayjs from 'dayjs';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import EditKeyModal from './EditKeyModal';
|
||||
import { formatLastUsed } from './utils';
|
||||
|
||||
interface KeysTabProps {
|
||||
accountId: string;
|
||||
@@ -25,15 +25,15 @@ 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()) {
|
||||
const expiryDate = dayjs.unix(expiresAt);
|
||||
if (expiryDate.isBefore(dayjs())) {
|
||||
return (
|
||||
<span className="keys-tab__expiry--expired">
|
||||
{format(expiryDate, 'MMM d, yyyy')}
|
||||
{expiryDate.format('MMM D, YYYY')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span>{format(expiryDate, 'MMM d, yyyy')}</span>;
|
||||
return <span>{expiryDate.format('MMM D, YYYY')}</span>;
|
||||
}
|
||||
|
||||
function KeysTab({
|
||||
@@ -88,20 +88,9 @@ function KeysTab({
|
||||
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 '—';
|
||||
}
|
||||
},
|
||||
const handleFormatLastUsed = useCallback(
|
||||
(lastUsed: Date | null | undefined): string =>
|
||||
formatLastUsed(lastUsed, formatTimezoneAdjustedTimestamp),
|
||||
[formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
@@ -157,7 +146,7 @@ function KeysTab({
|
||||
{formatExpiry(keyItem.expires_at)}
|
||||
</span>
|
||||
<span className="keys-tab__col-last-used">
|
||||
{formatLastUsed(keyItem.last_used ?? null)}
|
||||
{handleFormatLastUsed(keyItem.last_used ?? null)}
|
||||
</span>
|
||||
<span className="keys-tab__col-action">
|
||||
<Tooltip title="Revoke Key">
|
||||
@@ -201,8 +190,8 @@ function KeysTab({
|
||||
disableOutsideClick={false}
|
||||
>
|
||||
<p className="delete-dialog__body">
|
||||
Revoking this key will permanently invalidate it. Any systems using
|
||||
this key will lose access immediately.
|
||||
Revoking this key will permanently invalidate it. Any systems using this
|
||||
key will lose access immediately.
|
||||
</p>
|
||||
<DialogFooter className="delete-dialog__footer">
|
||||
<Button
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import { ChevronDown, LockKeyhole } from '@signozhq/icons';
|
||||
import { LockKeyhole } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Select } from 'antd';
|
||||
import type { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
interface OverviewTabProps {
|
||||
account: ServiceAccountRow;
|
||||
@@ -15,6 +16,11 @@ interface OverviewTabProps {
|
||||
localRoles: string[];
|
||||
onRolesChange: (v: string[]) => void;
|
||||
isDisabled: boolean;
|
||||
availableRoles: RoletypesRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
rolesError?: boolean;
|
||||
rolesErrorObj?: APIError | undefined;
|
||||
onRefetchRoles?: () => void;
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
@@ -24,6 +30,11 @@ function OverviewTab({
|
||||
localRoles,
|
||||
onRolesChange,
|
||||
isDisabled,
|
||||
availableRoles,
|
||||
rolesLoading,
|
||||
rolesError,
|
||||
rolesErrorObj,
|
||||
onRefetchRoles,
|
||||
}: OverviewTabProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
@@ -86,7 +97,7 @@ function OverviewTab({
|
||||
{localRoles.length > 0 ? (
|
||||
localRoles.map((r) => (
|
||||
<Badge key={r} color="vanilla">
|
||||
{capitalize(r)}
|
||||
{r}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
@@ -96,22 +107,22 @@ function OverviewTab({
|
||||
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
<RolesSelect
|
||||
id="sa-roles"
|
||||
mode="multiple"
|
||||
roles={availableRoles}
|
||||
loading={rolesLoading}
|
||||
isError={rolesError}
|
||||
error={rolesErrorObj}
|
||||
onRefetch={onRefetchRoles}
|
||||
value={localRoles}
|
||||
onChange={(roles): void => onRolesChange(roles as string[])}
|
||||
className="sa-drawer__role-select"
|
||||
suffixIcon={<ChevronDown size={14} />}
|
||||
onChange={onRolesChange}
|
||||
placeholder="Select roles"
|
||||
className="sa-drawer__role-select"
|
||||
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>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useUpdateServiceAccount,
|
||||
useUpdateServiceAccountStatus,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
@@ -65,6 +66,13 @@ function ServiceAccountDrawer({
|
||||
(localName !== (account.name ?? '') ||
|
||||
JSON.stringify(localRoles) !== JSON.stringify(account.roles ?? []));
|
||||
|
||||
const {
|
||||
roles: availableRoles,
|
||||
isLoading: rolesLoading,
|
||||
isError: rolesError,
|
||||
error: rolesErrorObj,
|
||||
refetch: refetchRoles,
|
||||
} = useRoles();
|
||||
const { mutateAsync: updateAccount } = useUpdateServiceAccount();
|
||||
const { mutateAsync: updateStatus } = useUpdateServiceAccountStatus();
|
||||
|
||||
@@ -182,6 +190,11 @@ function ServiceAccountDrawer({
|
||||
localRoles={localRoles}
|
||||
onRolesChange={setLocalRoles}
|
||||
isDisabled={isDisabled}
|
||||
availableRoles={availableRoles}
|
||||
rolesLoading={rolesLoading}
|
||||
rolesError={rolesError}
|
||||
rolesErrorObj={rolesErrorObj}
|
||||
onRefetchRoles={refetchRoles}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'keys' && account && (
|
||||
|
||||
18
frontend/src/components/ServiceAccountDrawer/utils.ts
Normal file
18
frontend/src/components/ServiceAccountDrawer/utils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
|
||||
export function formatLastUsed(
|
||||
lastUsed: Date | null | undefined,
|
||||
formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string,
|
||||
): string {
|
||||
if (!lastUsed) {
|
||||
return '—';
|
||||
}
|
||||
const d = new Date(lastUsed);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
return formatTimezoneAdjustedTimestamp(
|
||||
d.toISOString(),
|
||||
DATE_TIME_FORMATS.DASH_DATETIME,
|
||||
);
|
||||
}
|
||||
@@ -168,6 +168,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sa-tooltip {
|
||||
.ant-tooltip-inner {
|
||||
background-color: var(--bg-slate-500);
|
||||
color: var(--foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: normal;
|
||||
padding: var(--padding-2) var(--padding-3);
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ant-tooltip-arrow-content {
|
||||
background-color: var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.sa-table {
|
||||
.ant-table-tbody {
|
||||
|
||||
@@ -46,13 +46,21 @@ function RolesCell({ roles }: { roles: string[] }): JSX.Element {
|
||||
}
|
||||
const first = roles[0];
|
||||
const overflow = roles.length - 1;
|
||||
const tooltipContent = roles.slice(1).join(', ');
|
||||
|
||||
return (
|
||||
<div className="sa-roles-cell">
|
||||
<Badge color="vanilla">{first}</Badge>
|
||||
{overflow > 0 && (
|
||||
<Badge color="vanilla" variant="outline">
|
||||
+{overflow}
|
||||
</Badge>
|
||||
<Tooltip
|
||||
title={tooltipContent}
|
||||
overlayClassName="sa-tooltip"
|
||||
overlayStyle={{ maxWidth: '600px' }}
|
||||
>
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
+{overflow}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -67,7 +75,7 @@ function StatusBadge({ status }: { status: string }): JSX.Element {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge color="vanilla" variant="outline">
|
||||
<Badge color="vanilla" variant="outline" className="sa-status-badge">
|
||||
DISABLED
|
||||
</Badge>
|
||||
);
|
||||
@@ -80,8 +88,8 @@ function ServiceAccountsEmptyState({
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="sa-empty-state">
|
||||
<span className="sa-empty-state__emoji" role="img" aria-label="robot">
|
||||
🤖
|
||||
<span className="sa-empty-state__emoji" role="img" aria-label="monocle face">
|
||||
🧐
|
||||
</span>
|
||||
{searchQuery ? (
|
||||
<p className="sa-empty-state__text">
|
||||
@@ -132,27 +140,14 @@ function ServiceAccountsTable({
|
||||
title: 'Roles',
|
||||
dataIndex: 'roles',
|
||||
key: 'roles',
|
||||
width: 200,
|
||||
width: 420,
|
||||
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,
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
className: 'sa-status-cell',
|
||||
render: (status: string): JSX.Element => <StatusBadge status={status} />,
|
||||
|
||||
@@ -119,7 +119,7 @@ function MembersSettings(): JSX.Element {
|
||||
return;
|
||||
}
|
||||
const maxPage = Math.ceil(filteredMembers.length / PAGE_SIZE);
|
||||
if (currentPage > maxPage) {
|
||||
if (currentPage > maxPage || currentPage < 1) {
|
||||
setPage(maxPage);
|
||||
}
|
||||
}, [filteredMembers.length, currentPage, setPage]);
|
||||
|
||||
@@ -50,6 +50,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sa-status-badge {
|
||||
color: var(--l3-foreground);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.sa-settings-filter-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Check, ChevronDown, Plus } from '@signozhq/icons';
|
||||
@@ -11,7 +11,7 @@ import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccount
|
||||
import ServiceAccountsTable from 'components/ServiceAccountsTable/ServiceAccountsTable';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
import { FilterMode, ServiceAccountRow } from './utils';
|
||||
import { FilterMode, ServiceAccountRow, ServiceAccountStatus } from './utils';
|
||||
|
||||
import './ServiceAccountsSettings.styles.scss';
|
||||
|
||||
@@ -27,7 +27,10 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>(FilterMode.All);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [selectedAccount, setSelectedAccount] = useState<ServiceAccountRow | null>(null);
|
||||
const [
|
||||
selectedAccount,
|
||||
setSelectedAccount,
|
||||
] = useState<ServiceAccountRow | null>(null);
|
||||
|
||||
const {
|
||||
data: serviceAccountsData,
|
||||
@@ -50,12 +53,18 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
);
|
||||
|
||||
const activeCount = useMemo(
|
||||
() => allAccounts.filter((a) => a.status?.toUpperCase() === 'ACTIVE').length,
|
||||
() =>
|
||||
allAccounts.filter(
|
||||
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Active,
|
||||
).length,
|
||||
[allAccounts],
|
||||
);
|
||||
|
||||
const disabledCount = useMemo(
|
||||
() => allAccounts.filter((a) => a.status?.toUpperCase() !== 'ACTIVE').length,
|
||||
() =>
|
||||
allAccounts.filter(
|
||||
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
|
||||
).length,
|
||||
[allAccounts],
|
||||
);
|
||||
|
||||
@@ -63,16 +72,22 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
let result = allAccounts;
|
||||
|
||||
if (filterMode === FilterMode.Active) {
|
||||
result = result.filter((a) => a.status?.toUpperCase() === 'ACTIVE');
|
||||
result = result.filter(
|
||||
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Active,
|
||||
);
|
||||
} else if (filterMode === FilterMode.Disabled) {
|
||||
result = result.filter((a) => a.status?.toUpperCase() !== 'ACTIVE');
|
||||
result = result.filter(
|
||||
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
|
||||
);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(a) =>
|
||||
a.name?.toLowerCase().includes(q) || a.email?.toLowerCase().includes(q),
|
||||
a.name?.toLowerCase().includes(q) ||
|
||||
a.email?.toLowerCase().includes(q) ||
|
||||
a.roles?.some((role: string) => role.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +107,17 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
[history, urlQuery],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredAccounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(filteredAccounts.length / PAGE_SIZE));
|
||||
if (currentPage > maxPage || currentPage < 1) {
|
||||
setPage(maxPage);
|
||||
}
|
||||
}, [filteredAccounts.length, currentPage, setPage]);
|
||||
|
||||
const totalCount = allAccounts.length;
|
||||
|
||||
const filterMenuItems: MenuProps['items'] = [
|
||||
@@ -167,15 +193,15 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
<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
|
||||
keys. {/* Todo: to add doc links */}
|
||||
{/* <a
|
||||
href="https://signoz.io/docs/service-accounts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sa-settings__learn-more"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</a> */}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,12 +4,17 @@ export enum FilterMode {
|
||||
Disabled = 'disabled',
|
||||
}
|
||||
|
||||
export enum ServiceAccountStatus {
|
||||
Active = 'ACTIVE',
|
||||
Disabled = 'DISABLED',
|
||||
}
|
||||
|
||||
export interface ServiceAccountRow {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
status: string;
|
||||
status: ServiceAccountStatus | string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user