♻️ front: moved driveitem access entities editing to a helper file (#50)

This commit is contained in:
Eric Doughty-Papassideris
2024-04-21 22:10:36 +02:00
committed by ericlinagora
parent ecf106c5f8
commit fc3ebaa911
10 changed files with 225 additions and 131 deletions
@@ -38,6 +38,8 @@ export type DriveItem = {
scope: string;
};
export type DriveFileAccessLevelForInherited = 'none' | 'manage';
export type DriveFileAccessLevelForPublicLink = 'none' | 'read' | 'write';
export type DriveFileAccessLevel = 'none' | 'read' | 'write' | 'manage';
export type DriveItemAccessInfo = {
@@ -50,7 +52,7 @@ export type DriveItemAccessInfo = {
entities: AuthEntity[];
};
type AuthEntity = {
export type AuthEntity = {
type: 'user' | 'channel' | 'company' | 'folder';
id: string | 'parent';
level: DriveFileAccessLevel;
@@ -0,0 +1,167 @@
import {
AuthEntity,
DriveItemAccessInfo,
DriveFileAccessLevel,
DriveFileAccessLevelForInherited,
DriveFileAccessLevelForPublicLink,
DriveItem,
} from '@features/drive/types';
const entityMatcher = (entityType: AuthEntity['type'], entityId?: string) =>
(entity: AuthEntity) =>
entity && entity.type === entityType && (entityId == undefined || entity.id === entityId);
/** Updates the level of entities matching the given type and id, or adds the return
* of updater with no argument.
*
* If an item is not unique, all are modified and a console warning is printed except for `entityType` "channel".
*/
function upsertEntities(
entities: AuthEntity[] | undefined,
entityType: AuthEntity['type'],
entityId: string | undefined,
level: DriveFileAccessLevel,
) {
const updater = (existing?: AuthEntity) => {
if (!existing && !entityId) throw new Error("Cannot create entry without entityId");
return {
...(existing || { type: entityType, id: entityId }),
level,
};
};
let found = false;
if (!entities || entities.length == 0) return [updater()];
const matcher = entityMatcher(entityType, entityId);
const mapped = entities.map(item => {
if (!matcher(item)) return item;
if (found && entityType != "channel") console.warn(`DriveItem has more than one access_info entry for '${entityType}' id = ${entityId}:`, item);
found = true;
return updater(item);
}).filter(x => x);
if (found) return mapped;
return [...entities, updater()];
}
/** If `level == false`, deletes matching entries, otherwise calls `upsertEntities`
*/
function editEntities(
entities: AuthEntity[] | undefined,
entityType: AuthEntity['type'],
entityId: string | undefined,
level: DriveFileAccessLevel | false,
) {
if (level) return upsertEntities(entities, entityType, entityId, level);
const matcher = entityMatcher(entityType, entityId);
return entities && entities.filter(item => !matcher(item));
}
/** Return a list of all DriveItemAccessInfo for entities with the provided type sorted by id */
const accessInfosOfEntitiesOfType = (item: DriveItem | undefined, entityType: AuthEntity['type']) =>
item?.access_info.entities.filter(entityMatcher(entityType)).sort((a, b) => a.id.localeCompare(b.id)) || [];
/** Find access level for the given entity in the DriveItem's access_info and return it's level if any, or undefined */
export function accessLevelOfEntityForItem(
item: DriveItem | undefined,
entityType: AuthEntity['type'],
entityId: string,
) {
const matcher = entityMatcher(entityType, entityId);
return item?.access_info.entities.filter(matcher)[0]?.level;
}
/** Return the item with the access right for the given entity added or changed to level; or if its false, remove if any */
const itemWithEntityAccessChanged = (
item: DriveItem,
entityType: AuthEntity['type'],
entityId: string | undefined,
level: DriveFileAccessLevel | false,
) => ({
...item,
access_info: {
...item.access_info,
entities: editEntities(item.access_info.entities, entityType, entityId, level),
},
} as DriveItem);
// Note this just assumes uniqueness and just logs in the console if that is not the case and uses the first one.
const getAccessLevelOfUniqueForType = (item: DriveItem | undefined, entityType: AuthEntity['type'], entityId?: string) => {
if (!item) return undefined;
const accesses = item.access_info.entities.filter(entityMatcher(entityType, entityId));
if (accesses.length != 1 && entityType != "channel")
console.warn(`DriveItem doesn't have exactly one access_info entry for '${entityType}${entityId ? " id: " + entityId : ""}':`, item);
return accesses[0]?.level;
}
/** Return a shallow copy of item with the access right for the given user added or changed to level; or if its false, removed */
export const changeUserAccess = (item: DriveItem, userId: string, level: DriveFileAccessLevel | false) =>
itemWithEntityAccessChanged(item, "user", userId, level);
/** Return a list of all DriveItemAccessInfo for users sorted by id */
export const getAllUserAccesses = (item: DriveItem) => accessInfosOfEntitiesOfType(item, "user");
/** Return the access level for the provided user; an entry is expected to exist (or there is a `console.warn`) */
export const getUserAccessLevel = (item: DriveItem, userId: string) => getAccessLevelOfUniqueForType(item, "user", userId);
/** Return current access level inherited from parent folder by item */
export const getInheritedAccessLevel = (item?: DriveItem) => getAccessLevelOfUniqueForType(item, "folder");
/** Return a shallow copy of item with the access right for the given entity added or changed to level; or if its false, removed */
export const changeInheritedAccess = (item: DriveItem, level: DriveFileAccessLevelForInherited | false) =>
itemWithEntityAccessChanged(item, "folder", "parent", level);
/** Return access level for the company type entity if any */
export const getCompanyAccessLevel = (item?: DriveItem) => getAccessLevelOfUniqueForType(item, "company");
/** Return a shallow copy of item with the access right for the given entity changed to level; or if its false, removed.
* Removing is irreversible. Calling this method setting a level on a `DriveItem` that doesn't have a company entry will
* throw an Error.
*/
export const changeCompanyAccessLevel = (item: DriveItem, level: DriveFileAccessLevel | false) =>
itemWithEntityAccessChanged(item, "company", undefined, level);
/** Return the first, if any, access level of a channel */
export const getFirstChannelAccessLevel = (item?: DriveItem) => getAccessLevelOfUniqueForType(item, "channel");
/** Return a shallow copy of item with the access right for the given entity changed to level; or if its false, removed.
* Removing is irreversible. Calling this method setting a level on a `DriveItem` that doesn't have a channel entry will
* throw an Error.
*/
export const changeAllChannelAccessLevels = (item: DriveItem, level: DriveFileAccessLevel | false) =>
itemWithEntityAccessChanged(item, "channel", undefined, level);
/** Return a shallow copy of item with the public link properties for the given entity changed
*
* - level defaults to 'none' if not set and this is called without a value in `changes` for it
* - sets all fields in `changes` argument as they are, even if their value is null, undefined, false etc
* - runtime does not validate that fields in `changes` argument are properly typed or correctly named
*/
export function changePublicLink(
item: DriveItem,
changes: {
level?: DriveFileAccessLevelForPublicLink,
expiration?: number,
password?: string,
},
) {
const publicSettings : DriveItemAccessInfo["public"] = item.access_info.public ? { ...item.access_info.public } : { token: '', level: "none" };
Object.entries(changes).forEach(([field, value]) => (publicSettings as { [k: string]: unknown })[field] = value);
return {
...item,
access_info: {
...item.access_info,
public: publicSettings,
},
} as DriveItem;
}
/** Return the access level of the public link if it's set, and it isn't `"none"`, or returns false */
export const hasAnyPublicLinkAccess = (item?: Partial<DriveItem>) : Exclude<DriveFileAccessLevel, "none"> | false =>
(item?.access_info?.public?.level && item.access_info.public.level != "none") ? item.access_info.public.level : false;
@@ -25,6 +25,7 @@ import RouterServices from '@features/router/services/router-service';
import useRouterCompany from '@features/router/hooks/use-router-company';
import _ from 'lodash';
import Languages from 'features/global/services/languages-service';
import { hasAnyPublicLinkAccess } from '@features/files/utils/access-info-helpers';
/**
* This will build the context menu in different contexts
@@ -50,7 +51,7 @@ export const useOnBuildContextMenu = (children: DriveItem[], initialParentId?: s
const { open: preview } = useDrivePreview();
const { viewId } = useRouteState();
const company = useRouterCompany();
function getIdsFromArray(arr: DriveItem[]): string[] {
return arr.map((obj) => obj.id);
}
@@ -309,9 +310,7 @@ export const useOnBuildContextMenu = (children: DriveItem[], initialParentId?: s
{
type: 'menu',
text: Languages.t('components.item_context_menu.copy_link'),
hide:
!parent?.item?.access_info?.public?.level ||
parent?.item?.access_info?.public?.level === 'none',
hide: !hasAnyPublicLinkAccess(item),
onClick: () => {
copyToClipboard(getPublicLink(item || parent?.item));
ToasterService.success(
@@ -13,6 +13,7 @@ import { useHistory } from 'react-router-dom';
import RouterServices from '@features/router/services/router-service';
import useRouteState from 'app/features/router/hooks/use-route-state';
import { DocumentIcon } from './document-icon';
import { hasAnyPublicLinkAccess } from '@features/files/utils/access-info-helpers';
export const DocumentRow = ({
item,
@@ -79,7 +80,7 @@ export const DocumentRow = ({
<Base className="flex maxWidth100">{item.name}</Base>
</div>
<div className="shrink-0 ml-4">
{item?.access_info?.public?.level !== 'none' && (
{hasAnyPublicLinkAccess(item) && (
<PublicIcon className="h-5 w-5 text-blue-500" />
)}
</div>
@@ -7,6 +7,7 @@ import { formatBytes } from '@features/drive/utils';
import { useState } from 'react';
import { PublicIcon } from '../components/public-icon';
import { CheckableIcon, DriveItemProps } from './common';
import { hasAnyPublicLinkAccess } from '@features/files/utils/access-info-helpers';
import './style.scss';
export const FolderRow = ({
@@ -49,7 +50,7 @@ export const FolderRow = ({
<Base className="!font-semibold flex maxWidth100">{item.name}</Base>
</div>
<div className="shrink-0 ml-4">
{item?.access_info?.public?.level !== 'none' && (
{hasAnyPublicLinkAccess(item) && (
<PublicIcon className="h-5 w-5 text-blue-500" />
)}
</div>
@@ -10,6 +10,7 @@ import Languages from 'features/global/services/languages-service';
import { useHistory } from 'react-router-dom';
import useRouterCompany from '@features/router/hooks/use-router-company';
import RouterServices from '@features/router/services/router-service';
import { hasAnyPublicLinkAccess } from '@features/files/utils/access-info-helpers';
export default ({
path: livePath,
@@ -193,7 +194,7 @@ const PathItem = ({
})()}
</Title>
</a>
{item?.access_info?.public?.level && item?.access_info?.public?.level !== 'none' && (
{hasAnyPublicLinkAccess(item) && (
<PublicIcon className="h-5 w-5 ml-2" />
)}
{first && !!user?.id && viewId?.includes('trash') && (
@@ -15,6 +15,7 @@ import { ArrowLeftIcon, LockClosedIcon } from '@heroicons/react/outline';
import { PublicLinkAccessOptions } from './public-link-access-options';
import { CuteDepictionOfFolderHierarchy } from './cute-depiction-of-folder-hierarchy';
import { InheritAccessOptions } from './inherit-access-options';
import { changePublicLink, hasAnyPublicLinkAccess } from '@features/files/utils/access-info-helpers';
export type AccessModalType = {
open: boolean;
@@ -94,20 +95,9 @@ const AccessModalContent = (props: {
refresh(id);
refreshCompany();
}, []);
const havePublicLink = (item?.access_info?.public?.level || 'none') !== 'none';
const havePublicLink = hasAnyPublicLinkAccess(item);
const haveAdvancedSettings = parentItem?.parent_id !== null || havePublicLink;
const updatePublicAccess = (key: string, value: string | number, skipLoading?: true) =>
update({
access_info: {
entities: item?.access_info.entities || [],
public: {
...item!.access_info!.public!,
[key]: value || '',
},
},
}, skipLoading);
return (
<ModalContent
title={
@@ -138,10 +128,10 @@ const AccessModalContent = (props: {
password={item?.access_info?.public?.password}
expiration={item?.access_info?.public?.expiration}
onChangePassword={(password: string) => {
updatePublicAccess('password', password || '', true);
item && changePublicLink(item, { password: password || '' });
}}
onChangeExpiration={(expiration: number) => {
updatePublicAccess('expiration', expiration || 0);
item && changePublicLink(item, { expiration: expiration || 0 });
}}
/>}
{ parentItem?.parent_id !== null && <>
@@ -1,6 +1,14 @@
import Languages from 'features/global/services/languages-service';
import type { DriveItem } from 'app/features/drive/types';
import {
changeAllChannelAccessLevels,
changeCompanyAccessLevel,
changeInheritedAccess,
getCompanyAccessLevel,
getFirstChannelAccessLevel,
getInheritedAccessLevel,
} from '@features/files/utils/access-info-helpers';
import { Base, Info, Subtitle } from '@atoms/text';
import { Checkbox } from '@atoms/input/input-checkbox';
@@ -12,21 +20,17 @@ export const InheritAccessOptions = (props: {
disabled: boolean,
onUpdate: (item: Partial<DriveItem>) => void,
}) => {
const folderEntity = props.item?.access_info.entities.filter(a => a.type === 'folder')?.[0] || {
type: 'folder',
id: 'parent',
level: 'manage',
};
const companyEntity = props.item?.access_info.entities.filter(a => a.type === 'company')?.[0];
const channelEntities = props.item?.access_info.entities.filter(a => a.type === 'channel') || [];
// TODO: The default to 'manage' surprises me but it's what previous code did, as this commit is a refactoring, aim is to not affect function
const folderEntityLevel = getInheritedAccessLevel(props.item) || 'manage';
const companyEntityLevel = getCompanyAccessLevel(props.item);
const channelEntitiesLevel = getFirstChannelAccessLevel(props.item);
return (
<>
{(companyEntity || folderEntity?.level === 'none' || channelEntities.length > 0) &&
{(companyEntityLevel || folderEntityLevel === 'none' || channelEntitiesLevel) &&
<Subtitle className="block mt-4 mb-1">{Languages.t('components.internal-access_access_manage')}</Subtitle>}
{folderEntity && (
<div className="p-4 flex flex-row items-center justify-center">
{ <div className="p-4 flex flex-row items-center justify-center">
<div className="grow">
<Base>{Languages.t('components.internal-access_inherit_parent')}</Base>
<br />
@@ -36,23 +40,15 @@ export const InheritAccessOptions = (props: {
<Checkbox
disabled={props.disabled}
onChange={status => {
props.onUpdate({
access_info: {
entities: [
...(props.item?.access_info.entities.filter(a => a.type !== 'folder') || []),
{ ...folderEntity, level: status ? 'manage' : 'none' },
],
public: props.item?.access_info.public,
},
});
props.item && props.onUpdate(changeInheritedAccess(props.item, status ? 'manage' : 'none'));
}}
value={folderEntity.level === 'manage'}
value={folderEntityLevel === 'manage'}
/>
</div>
</div>
)}
}
{companyEntity && folderEntity.level === 'none' && (
{ companyEntityLevel && folderEntityLevel === 'none' && (
<div className="p-4 flex flex-row items-center justify-center">
<div className="grow">
<Base>{Languages.t('components.internal-access_company_member')}</Base>
@@ -61,29 +57,21 @@ export const InheritAccessOptions = (props: {
<AccessLevelDropdown
disabled={props.disabled}
onChange={level => {
props.onUpdate({
access_info: {
entities: [
...(props.item?.access_info.entities.filter(a => a.type !== 'company') || []),
...(level !== 'remove' ? [{ ...companyEntity, level }] : []),
],
public: props.item?.access_info.public,
},
});
props.item && props.onUpdate(changeCompanyAccessLevel(props.item, level === 'remove' ? false : level));
}}
level={companyEntity.level}
level={companyEntityLevel}
/>
</div>
</div>
)}
{channelEntities.length > 0 && (
{channelEntitiesLevel && (
<div className="p-4 border-b flex flex-row items-center justify-center">
<div className="grow">
<Base>{Languages.t('components.internal-access_cannal')}</Base>
<br />
<Info>
{channelEntities.length} {Languages.t('components.internal-access_cannal_info')}
{channelEntitiesLevel.length} {Languages.t('components.internal-access_cannal_info')}
</Info>
</div>
<div className="shrink-0 ml-2">
@@ -92,41 +80,21 @@ export const InheritAccessOptions = (props: {
hiddenLevels={['none']}
canRemove
onChange={level => {
if (level === 'remove') {
if (level === 'remove')
AlertManager.confirm(
async () => {
//Remove channel access
props.onUpdate({
access_info: {
entities:
props.item?.access_info?.entities.filter(e => e.type !== 'channel') || [],
public: props.item?.access_info.public,
},
});
},
() => {
//Do nothing
props.item && props.onUpdate(changeAllChannelAccessLevels(props.item, false));
},
() => { /* Do nothing */ },
{
text: Languages.t('components.internal-access_cannal_info_give_back'),
},
);
} else {
props.onUpdate({
access_info: {
entities:
props.item?.access_info?.entities.map(e => {
if (e.type === 'channel') {
return { ...e, level };
}
return e;
}) || [],
public: props.item?.access_info.public,
},
});
}
else
props.item && props.onUpdate(changeAllChannelAccessLevels(props.item, level));
}}
level={channelEntities[0].level}
level={channelEntitiesLevel}
/>
</div>
</div>
@@ -9,11 +9,11 @@ import { useState } from 'react';
import SelectUsers from '../../components/select-users';
import { AccessLevelDropdown } from './access-level-dropdown';
import Languages from 'features/global/services/languages-service';
import { changeUserAccess, getUserAccessLevel, getAllUserAccesses } from '@features/files/utils/access-info-edits';
export const InternalAccessManager = ({ id, disabled }: { id: string; disabled: boolean }) => {
const { item } = useDriveItem(id);
const userEntities = item?.access_info.entities.filter(a => a.type === 'user') || [];
return (
<>
@@ -21,11 +21,9 @@ export const InternalAccessManager = ({ id, disabled }: { id: string; disabled:
<div className="rounded-md border mt-2 dark:border-zinc-700">
<UserAccessSelector id={id} disabled={disabled} />
{userEntities
?.sort((a, b) => a?.id?.localeCompare(b?.id))
?.map(user => (
<UserAccessLevel key={user.id} id={id} userId={user?.id} disabled={disabled} />
))}
{item && getAllUserAccesses(item)?.map(user =>
<UserAccessLevel key={user.id} id={id} userId={user?.id} disabled={disabled} />
)}
<div className="-mb-px" />
</div>
</>
@@ -43,18 +41,8 @@ const UserAccessSelector = ({ id, disabled }: { id: string; disabled: boolean })
className="rounded-r-none"
level={level}
onChange={(users: UserType[]) => {
const id = users[0]?.id;
update({
access_info: {
entities: [
//Add or replace existing user
...(item?.access_info.entities.filter(a => a.type !== 'user' || a.id !== id) ||
[]),
...((id ? [{ type: 'user', id, level }] : []) as any),
],
public: item?.access_info.public,
},
});
const id = users[0]?.id; //TODO: all others ignored
item && id && update(changeUserAccess(item, id, level));
}}
initialUsers={[]}
/>
@@ -83,7 +71,6 @@ const UserAccessLevel = ({
const { item, loading, update } = useDriveItem(id);
const user = useUser(userId);
const { user: currentUser } = useCurrentUser();
const level = item?.access_info.entities.filter(a => a.type === 'user' && a.id === userId)?.[0]?.level || 'none';
return (
<UserBlock
className="p-4 border-t dark:border-zinc-700"
@@ -92,26 +79,9 @@ const UserAccessLevel = ({
suffix={
<AccessLevelDropdown
disabled={loading || disabled || user?.id === currentUser?.id}
level={level}
level={(item && getUserAccessLevel(item, userId)) || "none"}
canRemove={true}
onChange={
level =>
update({
access_info: {
entities:
level === 'remove'
? item?.access_info?.entities.filter(
e => e.type !== 'user' || e.id !== userId,
) || []
: item?.access_info?.entities.map(e => {
if (e.type === 'user' && e.id === userId)
return { ...e, level };
return e;
}) || [],
public: item?.access_info.public,
},
})
}
onChange={level => item && update(changeUserAccess(item, userId, level === 'remove' ? false : level))}
/>
}
/>
@@ -7,7 +7,8 @@ import { AccessLevelDropdown } from './access-level-dropdown';
import Languages from 'features/global/services/languages-service';
import { Button } from '@atoms/button/button';
import { LinkIcon, UserGroupIcon, CheckCircleIcon } from '@heroicons/react/outline';
import type { DriveFileAccessLevel } from 'app/features/drive/types';
import type { DriveFileAccessLevelForPublicLink } from 'app/features/drive/types';
import { changePublicLink } from '@features/files/utils/access-info-helpers';
export const PublicLinkManager = ({ id, disabled }: { id: string; disabled?: boolean }) => {
const { item, loading, update } = useDriveItem(id);
@@ -15,15 +16,10 @@ export const PublicLinkManager = ({ id, disabled }: { id: string; disabled?: boo
const defaultPublicLinkLevel = 'read';
const publicLinkLevel = item?.access_info?.public?.level || 'none';
const havePublicLink = publicLinkLevel !== 'none';
const [publicLinkCreationLevel, setPublicLinkCreationLevel] = useState<DriveFileAccessLevel>(defaultPublicLinkLevel);
const [publicLinkCreationLevel, setPublicLinkCreationLevel] = useState<DriveFileAccessLevelForPublicLink>(defaultPublicLinkLevel);
const publicLinkCreationLevelSafe = havePublicLink ? publicLinkLevel || defaultPublicLinkLevel : publicLinkCreationLevel;
const updatePublicLinkLevel = (level: DriveFileAccessLevel) => {
update({
access_info: {
entities: item?.access_info.entities || [],
public: { ...(item?.access_info?.public || { token: '' }), level },
},
});
const updatePublicLinkLevel = (level: DriveFileAccessLevelForPublicLink) => {
item && update(changePublicLink(item, { level }));
if (level === 'none')
setPublicLinkCreationLevel(defaultPublicLinkLevel);
};
@@ -57,9 +53,8 @@ export const PublicLinkManager = ({ id, disabled }: { id: string; disabled?: boo
setDidJustCompleteACopy(true);
setTimeout(() => setDidJustCompleteACopy(false), 1500);
}
} else {
} else
updatePublicLinkLevel(publicLinkCreationLevel);
}
}}
theme={didJustCompleteACopy ? "green" : "primary"}
className="absolute top-0 right-0 justify-center"