🐞 Add antivirus inside Twake Drive (#725)
🐞 Add antivirus inside Twake Drive (#725)
This commit is contained in:
@@ -135,6 +135,20 @@ export class DriveApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
static async checkMalware(companyId: string, id: string) {
|
||||
return await Api.post<any, DriveItem>(
|
||||
`/internal/services/documents/v1/companies/${companyId}/item/${id}/check_malware${appendTdriveToken()}`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
static async reScan(companyId: string, id: string) {
|
||||
return await Api.post<any, DriveItem>(
|
||||
`/internal/services/documents/v1/companies/${companyId}/item/${id}/rescan${appendTdriveToken()}`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
static getDownloadUrl(companyId: string, id: string, versionId?: string) {
|
||||
if (versionId)
|
||||
return Api.route(`/internal/services/documents/v1/companies/${companyId}/item/${id}/download?version_id=${versionId}`);
|
||||
|
||||
@@ -3,12 +3,20 @@ import useRouterCompany from '@features/router/hooks/use-router-company';
|
||||
import { useCallback } from 'react';
|
||||
import { useRecoilValue, useRecoilCallback, useRecoilState } from 'recoil';
|
||||
import { DriveApiClient } from '../api-client/api-client';
|
||||
import { DriveItemAtom, DriveItemChildrenAtom, DriveItemPagination, DriveItemSort } from '../state/store';
|
||||
import {
|
||||
DriveItemAtom,
|
||||
DriveItemChildrenAtom,
|
||||
DriveItemPagination,
|
||||
DriveItemSort,
|
||||
} from '../state/store';
|
||||
import { BrowseFilter, DriveItem, DriveItemVersion } from '../types';
|
||||
import { SharedWithMeFilterState } from '../state/shared-with-me-filter';
|
||||
import Languages from 'features/global/services/languages-service';
|
||||
import { useUserQuota } from 'features/users/hooks/use-user-quota';
|
||||
|
||||
import AlertManager from 'app/features/global/services/alert-manager-service';
|
||||
import FeatureTogglesService, {
|
||||
FeatureNames,
|
||||
} from '@features/global/services/feature-toggles-service';
|
||||
/**
|
||||
* Returns the children of a drive item
|
||||
* @returns
|
||||
@@ -19,6 +27,7 @@ export const useDriveActions = (inPublicSharing?: boolean) => {
|
||||
const sortItem = useRecoilValue(DriveItemSort);
|
||||
const [ paginateItem ] = useRecoilState(DriveItemPagination);
|
||||
const { getQuota } = useUserQuota();
|
||||
const AVEnabled = FeatureTogglesService.isActiveFeatureName(FeatureNames.COMPANY_AV_ENABLED);
|
||||
|
||||
const refresh = useRecoilCallback(
|
||||
({ set, snapshot }) =>
|
||||
@@ -35,7 +44,13 @@ export const useDriveActions = (inPublicSharing?: boolean) => {
|
||||
set(DriveItemPagination, pagination);
|
||||
}
|
||||
try {
|
||||
const details = await DriveApiClient.browse(companyId, parentId, filter, sortItem, pagination);
|
||||
const details = await DriveApiClient.browse(
|
||||
companyId,
|
||||
parentId,
|
||||
filter,
|
||||
sortItem,
|
||||
pagination,
|
||||
);
|
||||
set(DriveItemChildrenAtom(parentId), details.children);
|
||||
set(DriveItemAtom(parentId), details);
|
||||
for (const child of details.children) {
|
||||
@@ -87,10 +102,31 @@ export const useDriveActions = (inPublicSharing?: boolean) => {
|
||||
);
|
||||
|
||||
const download = useCallback(
|
||||
async (id: string, versionId?: string) => {
|
||||
async (id: string, isMalicious = false, versionId?: string) => {
|
||||
try {
|
||||
const url = DriveApiClient.getDownloadUrl(companyId, id, versionId);
|
||||
(window as any).open(url, '_blank').focus();
|
||||
// if AV is enabled
|
||||
if (AVEnabled) {
|
||||
// if the file is malicious
|
||||
if (isMalicious) {
|
||||
// toggle confirm for user
|
||||
AlertManager.confirm(
|
||||
() => {
|
||||
(window as any).open(url, '_blank').focus();
|
||||
},
|
||||
() => {
|
||||
return;
|
||||
},
|
||||
{
|
||||
text: Languages.t('hooks.use-drive-actions.av_confirm_file_download'),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
(window as any).open(url, '_blank').focus();
|
||||
}
|
||||
} else {
|
||||
(window as any).open(url, '_blank').focus();
|
||||
}
|
||||
} catch (e) {
|
||||
ToasterService.error(Languages.t('hooks.use-drive-actions.unable_download_file'));
|
||||
}
|
||||
@@ -99,10 +135,34 @@ export const useDriveActions = (inPublicSharing?: boolean) => {
|
||||
);
|
||||
|
||||
const downloadZip = useCallback(
|
||||
async (ids: string[], isDirectory = false) => {
|
||||
async (ids: string[], isDirectory = false, containsMalicious = false) => {
|
||||
try {
|
||||
const url = await DriveApiClient.getDownloadZipUrl(companyId, ids, isDirectory);
|
||||
(window as any).open(url, '_blank').focus();
|
||||
const triggerDownload = async () => {
|
||||
const url = await DriveApiClient.getDownloadZipUrl(companyId, ids, isDirectory);
|
||||
(window as any).open(url, '_blank').focus();
|
||||
};
|
||||
if (AVEnabled) {
|
||||
const containsMaliciousFiles =
|
||||
containsMalicious ||
|
||||
(ids.length === 1 && (await DriveApiClient.checkMalware(companyId, ids[0])));
|
||||
if (containsMaliciousFiles) {
|
||||
AlertManager.confirm(
|
||||
async () => {
|
||||
await triggerDownload();
|
||||
},
|
||||
() => {
|
||||
return;
|
||||
},
|
||||
{
|
||||
text: Languages.t('hooks.use-drive-actions.av_confirm_folder_download'),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await triggerDownload();
|
||||
}
|
||||
} else {
|
||||
await triggerDownload();
|
||||
}
|
||||
} catch (e) {
|
||||
ToasterService.error(Languages.t('hooks.use-drive-actions.unable_download_file'));
|
||||
}
|
||||
@@ -142,7 +202,12 @@ export const useDriveActions = (inPublicSharing?: boolean) => {
|
||||
try {
|
||||
const newItem = await DriveApiClient.update(companyId, id, update);
|
||||
if (previousName && previousName !== newItem.name && !update.name)
|
||||
ToasterService.warn(Languages.t('hooks.use-drive-actions.update_caused_a_rename', [previousName, newItem.name]));
|
||||
ToasterService.warn(
|
||||
Languages.t('hooks.use-drive-actions.update_caused_a_rename', [
|
||||
previousName,
|
||||
newItem.name,
|
||||
]),
|
||||
);
|
||||
await refresh(id || '', true);
|
||||
if (!inPublicSharing) await refresh(parentId || '', true);
|
||||
if (update?.parent_id !== parentId) await refresh(update?.parent_id || '', true);
|
||||
@@ -183,12 +248,47 @@ export const useDriveActions = (inPublicSharing?: boolean) => {
|
||||
parentId,
|
||||
filter,
|
||||
sortItem,
|
||||
pagination
|
||||
pagination,
|
||||
);
|
||||
return details;
|
||||
},
|
||||
[paginateItem, refresh],
|
||||
);
|
||||
|
||||
const checkMalware = useCallback(
|
||||
async (item: Partial<DriveItem>) => {
|
||||
try {
|
||||
await DriveApiClient.checkMalware(companyId, item.id || '');
|
||||
} catch (e) {
|
||||
ToasterService.error(Languages.t('hooks.use-drive-actions.unable_rescan_file'));
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
return { create, refresh, download, downloadZip, remove, restore, update, updateLevel, nextPage };
|
||||
const reScan = useCallback(
|
||||
async (item: Partial<DriveItem>) => {
|
||||
try {
|
||||
await DriveApiClient.reScan(companyId, item.id || '');
|
||||
await refresh(item.parent_id || '', true);
|
||||
} catch (e) {
|
||||
ToasterService.error(Languages.t('hooks.use-drive-actions.unable_rescan_file'));
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
);
|
||||
|
||||
return {
|
||||
create,
|
||||
refresh,
|
||||
download,
|
||||
downloadZip,
|
||||
remove,
|
||||
restore,
|
||||
update,
|
||||
updateLevel,
|
||||
reScan,
|
||||
checkMalware,
|
||||
nextPage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -56,6 +56,7 @@ export type DriveItem = {
|
||||
|
||||
size: number;
|
||||
scope: string;
|
||||
av_status: string;
|
||||
};
|
||||
|
||||
export type DriveFileAccessLevelForInherited = 'none' | 'manage';
|
||||
|
||||
@@ -12,6 +12,7 @@ export enum FeatureNames {
|
||||
COMPANY_DISPLAY_EMAIL = 'company:display_email',
|
||||
COMPANY_USER_QUOTA = 'company:user_quota',
|
||||
COMPANY_MANAGE_ACCESS = 'company:managed_access',
|
||||
COMPANY_AV_ENABLED = 'company:av_enabled',
|
||||
}
|
||||
|
||||
export type FeatureValueType = boolean | number;
|
||||
@@ -31,6 +32,7 @@ availableFeaturesWithDefaults.set(FeatureNames.COMPANY_SHARED_DRIVE, true);
|
||||
availableFeaturesWithDefaults.set(FeatureNames.COMPANY_DISPLAY_EMAIL, true);
|
||||
availableFeaturesWithDefaults.set(FeatureNames.COMPANY_USER_QUOTA, false);
|
||||
availableFeaturesWithDefaults.set(FeatureNames.COMPANY_MANAGE_ACCESS, true);
|
||||
availableFeaturesWithDefaults.set(FeatureNames.COMPANY_AV_ENABLED, false);
|
||||
|
||||
/**
|
||||
* ChannelServiceImpl that allow you to manage feature flipping in Tdrive using react feature toggles
|
||||
|
||||
@@ -44,7 +44,7 @@ export const useOnBuildContextMenu = (
|
||||
DriveCurrentFolderAtom({ initialFolderId: initialParentId || 'root' }),
|
||||
);
|
||||
|
||||
const { download, downloadZip, update, restore } = useDriveActions();
|
||||
const { download, downloadZip, update, restore, reScan } = useDriveActions();
|
||||
const setCreationModalState = useSetRecoilState(CreateModalAtom);
|
||||
const setUploadModalState = useSetRecoilState(UploadModelAtom);
|
||||
const setSelectorModalState = useSetRecoilState(SelectorModalAtom);
|
||||
@@ -66,6 +66,8 @@ export const useOnBuildContextMenu = (
|
||||
const inTrash = parent.path?.[0]?.id.includes('trash') || viewId?.includes('trash');
|
||||
const isPersonal = item?.scope === 'personal';
|
||||
const selectedCount = checked.length;
|
||||
const notSafe =
|
||||
!item?.is_directory && !['uploaded', 'safe'].includes(item?.av_status || '');
|
||||
|
||||
let menu: any[] = [];
|
||||
|
||||
@@ -75,26 +77,41 @@ export const useOnBuildContextMenu = (
|
||||
const access = upToDateItem.access || 'none';
|
||||
const hideShareItem = access === 'read' || getPublicLinkToken() || inTrash;
|
||||
const hideManageAccessItem =
|
||||
access === 'read'
|
||||
|| getPublicLinkToken()
|
||||
|| inTrash
|
||||
|| !FeatureTogglesService.isActiveFeatureName(FeatureNames.COMPANY_MANAGE_ACCESS);
|
||||
access === 'read' ||
|
||||
getPublicLinkToken() ||
|
||||
inTrash ||
|
||||
!FeatureTogglesService.isActiveFeatureName(FeatureNames.COMPANY_MANAGE_ACCESS);
|
||||
const newMenuActions = [
|
||||
{
|
||||
type: 'menu',
|
||||
icon: 'share-alt',
|
||||
text: Languages.t('components.item_context_menu.share'),
|
||||
hide: hideShareItem,
|
||||
hide: hideShareItem || notSafe,
|
||||
onClick: () => setPublicLinkModalState({ open: true, id: item.id }),
|
||||
},
|
||||
{
|
||||
type: 'menu',
|
||||
icon: 'users-alt',
|
||||
text: Languages.t('components.item_context_menu.manage_access'),
|
||||
hide: hideManageAccessItem,
|
||||
hide: hideManageAccessItem || notSafe,
|
||||
onClick: () => setAccessModalState({ open: true, id: item.id }),
|
||||
},
|
||||
{ type: 'separator', hide: inTrash || (hideShareItem && hideManageAccessItem) },
|
||||
{
|
||||
type: 'menu',
|
||||
icon: 'shield-check',
|
||||
text: Languages.t('components.item_context_menu.rescan_document'),
|
||||
hide: !(item.av_status === 'scan_failed'),
|
||||
onClick: () => {
|
||||
reScan(item);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
hide:
|
||||
inTrash ||
|
||||
(hideShareItem && hideManageAccessItem) ||
|
||||
(notSafe && !(item.av_status === 'scan_failed')),
|
||||
},
|
||||
{
|
||||
type: 'menu',
|
||||
icon: 'download-alt',
|
||||
@@ -104,7 +121,7 @@ export const useOnBuildContextMenu = (
|
||||
downloadZip([item!.id]);
|
||||
console.log(item!.id);
|
||||
} else {
|
||||
download(item.id);
|
||||
download(item.id, notSafe);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -120,12 +137,12 @@ export const useOnBuildContextMenu = (
|
||||
window.open(route, '_blank');
|
||||
}
|
||||
}, // */
|
||||
{ type: 'separator' },
|
||||
{ type: 'separator', hide: notSafe },
|
||||
{
|
||||
type: 'menu',
|
||||
icon: 'folder-question',
|
||||
text: Languages.t('components.item_context_menu.move'),
|
||||
hide: access === 'read' || inTrash || inPublicSharing,
|
||||
hide: access === 'read' || inTrash || inPublicSharing || notSafe,
|
||||
onClick: () =>
|
||||
setSelectorModalState({
|
||||
open: true,
|
||||
@@ -150,7 +167,7 @@ export const useOnBuildContextMenu = (
|
||||
type: 'menu',
|
||||
icon: 'file-edit-alt',
|
||||
text: Languages.t('components.item_context_menu.rename'),
|
||||
hide: access === 'read' || inTrash,
|
||||
hide: access === 'read' || inTrash || notSafe,
|
||||
onClick: () => setPropertiesModalState({ open: true, id: item.id, inPublicSharing }),
|
||||
},
|
||||
{
|
||||
@@ -160,7 +177,8 @@ export const useOnBuildContextMenu = (
|
||||
hide:
|
||||
!item.access_info.public?.level ||
|
||||
item.access_info.public?.level === 'none' ||
|
||||
inTrash,
|
||||
inTrash ||
|
||||
notSafe,
|
||||
onClick: () => {
|
||||
copyToClipboard(getPublicLink(item || parent?.item));
|
||||
ToasterService.success(
|
||||
@@ -172,10 +190,10 @@ export const useOnBuildContextMenu = (
|
||||
type: 'menu',
|
||||
icon: 'history',
|
||||
text: Languages.t('components.item_context_menu.versions'),
|
||||
hide: item.is_directory || inTrash,
|
||||
hide: item.is_directory || inTrash || notSafe,
|
||||
onClick: () => setVersionModal({ open: true, id: item.id }),
|
||||
},
|
||||
{ type: 'separator', hide: access !== 'manage' || inTrash },
|
||||
{ type: 'separator', hide: access !== 'manage' || inTrash || notSafe },
|
||||
{
|
||||
type: 'menu',
|
||||
icon: 'trash',
|
||||
@@ -238,15 +256,25 @@ export const useOnBuildContextMenu = (
|
||||
type: 'menu',
|
||||
text: Languages.t('components.item_context_menu.download_multiple'),
|
||||
hide: inTrash,
|
||||
onClick: () =>
|
||||
selectedCount === 1 ? download(checked[0].id) : downloadZip(checked.map(c => c.id)),
|
||||
onClick: () => {
|
||||
const containsMalicious = checked.some(c => c.av_status === 'malicious');
|
||||
if (selectedCount === 1) {
|
||||
download(checked[0].id);
|
||||
} else {
|
||||
downloadZip(
|
||||
checked.map(c => c.id),
|
||||
false,
|
||||
containsMalicious,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'menu',
|
||||
text: Languages.t('components.item_context_menu.clear_selection'),
|
||||
onClick: () => setChecked({}),
|
||||
},
|
||||
{ type: 'separator', hide: parent.access === 'read' },
|
||||
{ type: 'separator', hide: parent.access === 'read' || notSafe },
|
||||
{
|
||||
type: 'menu',
|
||||
text: Languages.t('components.item_context_menu.delete_multiple'),
|
||||
@@ -517,7 +545,9 @@ export const useOnBuildFileContextMenu = () => {
|
||||
{
|
||||
type: 'menu',
|
||||
text: Languages.t('components.item_context_menu.download'),
|
||||
onClick: () => download(item.id),
|
||||
onClick: () => {
|
||||
download(item.id);
|
||||
},
|
||||
},
|
||||
];
|
||||
return menuItems;
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { DotsHorizontalIcon } from '@heroicons/react/outline';
|
||||
import {
|
||||
DotsHorizontalIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldExclamationIcon,
|
||||
BanIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
import { Button } from '@atoms/button/button';
|
||||
import { Base, BaseSmall } from '@atoms/text';
|
||||
import Menu from '@components/menus/menu';
|
||||
import useRouterCompany from '@features/router/hooks/use-router-company';
|
||||
import { useDrivePreview } from '@features/drive/hooks/use-drive-preview';
|
||||
import { formatBytes } from '@features/drive/utils';
|
||||
import Languages from '@features/global/services/languages-service';
|
||||
import { useState } from 'react';
|
||||
import { PublicIcon } from '../components/public-icon';
|
||||
import { CheckableIcon, DriveItemOverlayProps, DriveItemProps } from './common';
|
||||
|
||||
import './style.scss';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import RouterServices from '@features/router/services/router-service';
|
||||
import { DocumentIcon } from './document-icon';
|
||||
import { hasAnyPublicLinkAccess } from '@features/files/utils/access-info-helpers';
|
||||
import { formatDateShort } from 'app/features/global/utils/Numbers';
|
||||
import FeatureTogglesService, {
|
||||
FeatureNames,
|
||||
} from '@features/global/services/feature-toggles-service';
|
||||
|
||||
export const DocumentRow = ({
|
||||
item,
|
||||
@@ -27,6 +37,7 @@ export const DocumentRow = ({
|
||||
const [hover, setHover] = useState(false);
|
||||
const {open} = useDrivePreview();
|
||||
const company = useRouterCompany();
|
||||
const notSafe = ['malicious', 'skipped', 'scan_failed'].includes(item.av_status);
|
||||
|
||||
const preview = () => {
|
||||
open(item);
|
||||
@@ -39,7 +50,7 @@ export const DocumentRow = ({
|
||||
className={
|
||||
'flex flex-row items-center border border-zinc-200 dark:border-zinc-800 px-4 py-3 cursor-pointer ' +
|
||||
(checked
|
||||
? 'bg-blue-500 bg-opacity-10 hover:bg-opacity-25 '
|
||||
? (notSafe ? 'bg-rose-500' : 'bg-blue-500') + ' bg-opacity-10 hover:bg-opacity-25'
|
||||
: 'hover:bg-zinc-500 hover:bg-opacity-10 ') +
|
||||
(className || '')
|
||||
}
|
||||
@@ -48,7 +59,9 @@ export const DocumentRow = ({
|
||||
onClick={e => {
|
||||
if (e.shiftKey || e.ctrlKey) onCheck(!checked);
|
||||
else if (onClick) onClick();
|
||||
else preview();
|
||||
else {
|
||||
if (!notSafe) preview();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -76,6 +89,20 @@ export const DocumentRow = ({
|
||||
<div className="shrink-0 ml-4 text-right lg:w-24 sm:w-20 ">
|
||||
<BaseSmall>{formatBytes(item.size)}</BaseSmall>
|
||||
</div>
|
||||
{FeatureTogglesService.isActiveFeatureName(FeatureNames.COMPANY_AV_ENABLED) && (
|
||||
<div className="shrink-0 ml-4 text-right lg:w-24 sm:w-20 ">
|
||||
<BaseSmall title={Languages.t(`scenes.app.drive.document_row.av_${item?.av_status}`)}>
|
||||
{item?.av_status === 'scanning' && (
|
||||
<ShieldExclamationIcon className="w-5 text-yellow-400" />
|
||||
)}
|
||||
{item?.av_status === 'malicious' && (
|
||||
<ShieldExclamationIcon className="w-5 text-rose-400" />
|
||||
)}
|
||||
{item?.av_status === 'skipped' && <BanIcon className="w-5 text-gray-400" />}
|
||||
{item?.av_status === 'scan_failed' && <BanIcon className="w-5 text-gray-400" />}
|
||||
</BaseSmall>
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0 ml-4">
|
||||
<Menu menu={onBuildContextMenu}>
|
||||
<Button
|
||||
|
||||
@@ -49,7 +49,7 @@ const VersionModalContent = ({ id }: { id: string }) => {
|
||||
if (!item?.last_version_cache) return <></>;
|
||||
|
||||
return (
|
||||
<ModalContent title={Languages.t('components.VersionModalContent_version') + " " + item?.name}>
|
||||
<ModalContent title={Languages.t('components.VersionModalContent_version') + ' ' + item?.name}>
|
||||
<UploadZone
|
||||
overClassName={'!m-4'}
|
||||
disableClick
|
||||
@@ -66,12 +66,14 @@ const VersionModalContent = ({ id }: { id: string }) => {
|
||||
}}
|
||||
>
|
||||
{access !== 'read' && (
|
||||
<div className={'flex flex-row items-center bg-zinc-100 dark:bg-zinc-900 rounded-md mb-4 p-4'}>
|
||||
<div
|
||||
className={
|
||||
'flex flex-row items-center bg-zinc-100 dark:bg-zinc-900 rounded-md mb-4 p-4'
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row">
|
||||
<div className="grow flex items-center">
|
||||
<Base>
|
||||
{Languages.t('components.VersionModalContent_version_dec')}
|
||||
</Base>
|
||||
<Base>{Languages.t('components.VersionModalContent_version_dec')}</Base>
|
||||
</div>
|
||||
<div className="shrink-0 ml-4 flex items-center">
|
||||
<Button
|
||||
@@ -108,8 +110,11 @@ const VersionModalContent = ({ id }: { id: string }) => {
|
||||
<BaseSmall>{formatBytes(version.file_metadata.size || 0)}</BaseSmall>
|
||||
</div>
|
||||
<div className="shrink-0 ml-4">
|
||||
<Button theme="outline" onClick={() => download(id, version.id)}>
|
||||
{Languages.t('components.VersionModalContent_donwload')}
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => download(id, item.av_status === 'malicious', version.id)}
|
||||
>
|
||||
{Languages.t('components.VersionModalContent_donwload')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ export default class ErrorBoundary extends React.Component<PropsType, { hasError
|
||||
ErrorBoundary.lastError = {
|
||||
error: {
|
||||
name: error,
|
||||
info: errorInfo.componentStack,
|
||||
info: "",
|
||||
},
|
||||
};
|
||||
RouterServices.replace(RouterServices.addRedirection(RouterServices.pathnames.ERROR));
|
||||
|
||||
Reference in New Issue
Block a user