Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { useI18n } from '@/src/locales/client';
import { useCallback, useEffect, useState } from 'react';
import { IpRange, IpRangeProperty, RestrictionType } from './types';
import { DialRadioButton } from '@epam/ai-dial-ui-kit';
import { KeysI18nKey } from '@/src/constants/i18n';
import Field from '@/src/components/Common/Field/Field';
import { useSaveValidationContext, ValidationActionType } from '@/src/context/SaveValidationContext';
import RangeItems from './RangeItems';

interface Props<T> {
elementId?: string;
entity: T;
originalEntity?: T;
onChange?: (allowedIpAddressRanges?: string[]) => void;
}

const AccessRestrictionField = <T extends { allowedIpAddressRanges?: string[] }>({
elementId,
onChange,
entity,
originalEntity,
}: Props<T>) => {
const t = useI18n();
const { dispatch } = useSaveValidationContext();

const [selectedRadio, setSelectedRadio] = useState<RestrictionType>(RestrictionType.ALLOW_ALL);
const [ipRanges, setIpRanges] = useState<IpRange[]>([]);

const initialize = useCallback((entity: T) => {
if (!entity.allowedIpAddressRanges) {
setSelectedRadio(RestrictionType.ALLOW_ALL);
setIpRanges([]);
} else if (entity.allowedIpAddressRanges.length === 0) {
setSelectedRadio(RestrictionType.BLOCK_ALL);
setIpRanges([]);
} else {
setSelectedRadio(RestrictionType.RANGES);
const ranges = entity.allowedIpAddressRanges.map((range) => {
const [ip, mask] = range.split('/');
return {
ip,
mask: mask ? parseInt(mask) : undefined,
};
});
setIpRanges(ranges);
}
}, []);

useEffect(() => {
initialize(entity);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const handleRadioChange = useCallback(
(option: RestrictionType) => {
setSelectedRadio(option);

if (option === RestrictionType.ALLOW_ALL) {
dispatch({ type: ValidationActionType.SetField, field: 'ipRanges', isValid: true });
} else if (option === RestrictionType.BLOCK_ALL) {
dispatch({ type: ValidationActionType.SetField, field: 'ipRanges', isValid: true });
}
},
[dispatch],
);

const handleAddRange = useCallback(() => {
setIpRanges([...ipRanges, { ip: null, mask: null }]);
}, [ipRanges]);

const handleRemoveRange = useCallback(
(index: number) => {
const newIpRanges = structuredClone(ipRanges);
newIpRanges.splice(index, 1);

setIpRanges(newIpRanges);
},
[ipRanges],
);

const handleUpdateRange = useCallback(
(property: IpRangeProperty, value: string | number | undefined, index: number) => {
const newIpRanges = structuredClone(ipRanges);
const targetRange = newIpRanges[index];
if (property === IpRangeProperty.IP) {
targetRange.ip = value ? value.toString() : undefined;
} else if (property === IpRangeProperty.MASK) {
targetRange.mask = value ? +value : undefined;
}

setIpRanges(newIpRanges);
},
[ipRanges],
);

useEffect(() => {
if (selectedRadio === RestrictionType.ALLOW_ALL) {
onChange?.();
} else if (selectedRadio === RestrictionType.BLOCK_ALL) {
onChange?.([]);
} else if (selectedRadio === RestrictionType.RANGES) {
const rangesArray = ipRanges.map((range) => `${range.ip}/${range.mask}`);
onChange?.(rangesArray);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedRadio, ipRanges]);

useEffect(() => {
// When discard need to reinitialize Fied
if (entity === originalEntity) {
initialize(entity);
}
}, [entity, originalEntity, initialize]);

return (
<div className="flex flex-col w-full relative gap-2">
<Field fieldTitle={t(KeysI18nKey.RestrictionFieldLabel)} htmlFor={elementId} />

<div className="flex flex-col gap-4">
<DialRadioButton
inputId={`${elementId}-allow-all`}
name={`${elementId}-restriction-options`}
value={RestrictionType.ALLOW_ALL}
checked={selectedRadio === RestrictionType.ALLOW_ALL}
onChange={() => handleRadioChange(RestrictionType.ALLOW_ALL)}
label={t(KeysI18nKey.AllowAllRestriction)}
/>

<DialRadioButton
inputId={`${elementId}-block-all`}
name={`${elementId}-restriction-options`}
value={RestrictionType.BLOCK_ALL}
checked={selectedRadio === RestrictionType.BLOCK_ALL}
onChange={() => handleRadioChange(RestrictionType.BLOCK_ALL)}
label={t(KeysI18nKey.BlockAllRestriction)}
/>

<DialRadioButton
inputId={`${elementId}-ranges`}
name={`${elementId}-restriction-options`}
value={RestrictionType.RANGES}
checked={selectedRadio === RestrictionType.RANGES}
onChange={() => handleRadioChange(RestrictionType.RANGES)}
label={t(KeysI18nKey.RangesRestriction)}
/>
</div>

{selectedRadio === RestrictionType.RANGES && (
<RangeItems
ranges={ipRanges}
onAddRange={handleAddRange}
onUpdateRange={handleUpdateRange}
onRemoveRange={handleRemoveRange}
/>
)}
</div>
);
};

export default AccessRestrictionField;
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,18 @@ import { useSaveValidationContext, ValidationActionType } from '@/src/context/Sa
import { useI18n } from '@/src/locales/client';
import { DialKey } from '@/src/models/dial/key';
import { getControlClassName } from '@/src/utils/entities/view';
import AccessRestrictionField from './AccessRestrictionField';

interface Props {
entity: DialKey;
originalEntity?: DialKey;
names: string[];
keys: string[];
isKeyImmutable?: boolean;
onChangeKey: (key: DialKey) => void;
}

const KeyProperties: FC<Props> = ({ entity, names, keys, isKeyImmutable, onChangeKey }) => {
const KeyProperties: FC<Props> = ({ entity, originalEntity, names, keys, isKeyImmutable, onChangeKey }) => {
const t = useI18n();
const { dispatch } = useSaveValidationContext();
const containerClassName = getControlClassName(!isKeyImmutable);
Expand Down Expand Up @@ -78,6 +80,13 @@ const KeyProperties: FC<Props> = ({ entity, names, keys, isKeyImmutable, onChang
[entity, onChangeKey],
);

const onChangeAccessRestriction = useCallback(
(allowedIpAddressRanges?: string[]) => {
onChangeKey({ ...entity, allowedIpAddressRanges });
},
[entity, onChangeKey],
);

const onChange = useCallback(
(key: DialKey) => {
onChangeKey(key);
Expand Down Expand Up @@ -152,6 +161,15 @@ const KeyProperties: FC<Props> = ({ entity, names, keys, isKeyImmutable, onChang
)}

{!isKeyImmutable && <ValidityPeriod onChange={onChangeExpiresAt} />}

{isKeyImmutable && (
<AccessRestrictionField
elementId="ip-access-restriction"
onChange={onChangeAccessRestriction}
entity={entity}
originalEntity={originalEntity}
/>
)}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import classNames from 'classnames';

import { BASE_BUTTON_ICON_PROPS, STANDARD_CONTROL_WIDTH } from '@/src/constants/main-layout';
import { useI18n } from '@/src/locales/client';
import { FC, useCallback, useEffect, useState } from 'react';
import { IpRange, IpRangeError, IpRangeProperty } from './types';
import {
ButtonAppearance,
DialNumberInputField,
DialPrimaryButton,
DialRemoveButton,
DialTextInputField,
} from '@epam/ai-dial-ui-kit';
import { ButtonsI18nKey, EntityFieldsI18nKey, EntityPlaceholdersI18nKey } from '@/src/constants/i18n';
import { IconPlus } from '@tabler/icons-react';
import { useSaveValidationContext, ValidationActionType } from '@/src/context/SaveValidationContext';
import { getIpAddressError, getIPMaskError } from '@/src/utils/validation/ip-error';

interface Props {
ranges: IpRange[];
onAddRange: () => void;
onRemoveRange: (index: number) => void;
onUpdateRange: (property: IpRangeProperty, value: string | number | undefined, index: number) => void;
}

const RangeItems: FC<Props> = ({ ranges, onAddRange, onRemoveRange, onUpdateRange }) => {
const t = useI18n();
const { dispatch } = useSaveValidationContext();
const [errors, setErrors] = useState<IpRangeError[]>([]);

const validateRanges = useCallback(
(ranges: IpRange[]) => {
let isRangesValid = true;
const newErrors: IpRangeError[] = [];
ranges.forEach((range) => {
const ipError = range?.ip !== null ? getIpAddressError(range.ip, t, true) : null;
const maskError = range?.mask !== null ? getIPMaskError(range.mask, t, true, 0, 24) : null;
if (!range?.ip || !range?.mask || ipError || maskError) {
isRangesValid = false;
}
const rangeError = {
ip: ipError,
mask: maskError,
};
newErrors.push(rangeError);
});
setErrors(newErrors);
dispatch({ type: ValidationActionType.SetField, field: 'ipRanges', isValid: isRangesValid });
},
[t, dispatch],
);

useEffect(() => {
validateRanges(ranges);
}, [validateRanges, ranges]);

return (
<div className={classNames('flex flex-col gap-2 pl-6 pt-2', STANDARD_CONTROL_WIDTH)}>
{ranges.map((range, index) => (
<div key={index} className="flex flex-row gap-1 items-start">
<DialTextInputField
containerClassName="flex w-full flex-1"
label={index === 0 ? t(EntityFieldsI18nKey.IpRange) : null}
placeholder={t(EntityPlaceholdersI18nKey.IpRange)}
elementId={`ip-${index}`}
onChange={(value) => {
onUpdateRange(IpRangeProperty.IP, value, index);
}}
invalid={!!errors?.[index]?.ip}
errorText={errors?.[index]?.ip?.text}
value={range.ip || ''}
/>
<span className={classNames('text-secondary leading-[40px]', index === 0 && 'mt-6')}>/</span>
<DialNumberInputField
elementId={`mask-${index}`}
value={range.mask || undefined}
fieldTitle={index === 0 ? t(EntityFieldsI18nKey.Mask) : undefined}
containerClassName="w-[120px]"
elementClassName="h-[40px]"
placeholder={t(EntityPlaceholdersI18nKey.Mask)}
onChange={(value) => {
onUpdateRange(IpRangeProperty.MASK, value, index);
}}
invalid={!!errors?.[index]?.mask}
errorText={errors?.[index]?.mask?.text}
/>
<DialRemoveButton
className={classNames(index === 0 && 'mt-6')}
onClick={() => {
onRemoveRange(index);
}}
/>
</div>
))}
<DialPrimaryButton
className="w-fit mt-2"
appearance={ButtonAppearance.Link}
iconBefore={<IconPlus {...BASE_BUTTON_ICON_PROPS} />}
label={t(ButtonsI18nKey.Add)}
onClick={onAddRange}
/>
</div>
);
};

export default RangeItems;
22 changes: 22 additions & 0 deletions apps/ai-dial-admin/src/components/Keys/View/Properties/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { FieldError } from '@/src/models/error';

export enum RestrictionType {
ALLOW_ALL = 'allow_all',
BLOCK_ALL = 'block_all',
RANGES = 'ranges',
}

export interface IpRange {
ip?: string | null;
mask?: number | null;
}

export enum IpRangeProperty {
IP = 'ip',
MASK = 'mask',
}

export interface IpRangeError {
ip: FieldError | null;
mask: FieldError | null;
}
13 changes: 12 additions & 1 deletion apps/ai-dial-admin/src/components/Keys/View/TabsContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,20 @@ interface Props {
names: string[];
keys: string[];
selectedKey: DialKey;
originalKey: DialKey;
onChange: (key: DialKey) => void;
}

const TabsContent: FC<Props> = ({ activeTab, roles, selectedKey, onChange, selectedFormat, keys, names }) => {
const TabsContent: FC<Props> = ({
activeTab,
roles,
selectedKey,
originalKey,
onChange,
selectedFormat,
keys,
names,
}) => {
const t = useI18n();

const headerPostfix = useMemo(() => {
Expand Down Expand Up @@ -84,6 +94,7 @@ const TabsContent: FC<Props> = ({ activeTab, roles, selectedKey, onChange, selec
headerPostfix={headerPostfix}
>
<KeyProperties
originalEntity={originalKey}
entity={selectedKey}
names={names}
keys={keys}
Expand Down
1 change: 1 addition & 0 deletions apps/ai-dial-admin/src/components/Keys/View/View.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ const KeyView: FC<Props> = ({ originalKey, etag, ...props }) => {
activeTab={activeTab}
selectedKey={selectedKey}
onChange={setSelectedKey}
originalKey={originalKey}
{...props}
/>
)}
Expand Down
Loading
Loading