New sharing menu (#7406)

- Closes https://github.com/enso-org/cloud-v2/issues/561
- New "Invite" modal
- Change autocomplete multi-select behavior
- Fix scrolling for autocomplete
- Scrolling when there are many users with permissions
- New backend permissions

- ⚠️ Intentional differences from Figma design:
- The permission type selector (the secondary modal) is slightly wider. This is because of minor changes to the text - check thread for details.
- The permission type selector for sharing with new users (the top one, next to the input) is vertically offset 4px more than usual. This is intentional; it means there is roughly the same spacing on either side of the input's border, and also means there is spacing between the "invite" button and the permission type selector
- Many buttons are faded out (`opacity-50`) when they are not interactable.
- Text changes
- "Invite" changes to "Share" in blue button
- "File" changes to "<asset type>" in permission type selector

# Important Notes
Some options don't work because the backend representation for permissions is currently different - in particular, the `admin`, `edit`, and `read` permissions, and the `docs` sub-permission.

ℹ️ Currently only works with new backend permissions - i.e. `ENVIRONMENT` must be `'pbuchu'` in `config.ts`.
This commit is contained in:
somebody1234 2023-08-10 19:09:31 +10:00 committed by GitHub
parent 585ede7741
commit af0e738dec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1333 additions and 865 deletions

View File

@ -11,3 +11,29 @@ export function assert(invariant: boolean, message: string, logger: Logger = con
logger.error('assertion failed: ' + message)
}
}
// This is required to make the definition `Object.prototype.d` not error.
// eslint-disable-next-line no-restricted-syntax
declare global {
// Documentation is already inherited.
/** */
interface Object {
/** Log self and return self. */
$d$: <T>(this: T, message?: string) => T
}
}
Object.defineProperty(Object.prototype, '$d$', {
/** Log self and return self. */
value: function <T>(this: T, message?: string) {
if (message != null) {
console.log(message, this)
} else {
console.log(this)
}
return this
},
enumerable: false,
writable: false,
configurable: false,
})

View File

@ -510,6 +510,10 @@ export default [
property: 'useDebugCallback',
message: 'Avoid leaving debugging statements when committing code',
},
{
property: '$d$',
message: 'Avoid leaving debugging statements when committing code',
},
],
},
},

View File

@ -42,12 +42,12 @@ function isAuthEvent(value: string): value is AuthEvent {
/** Callback called in response to authentication state changes.
*
* @see {@link Api["listen"]} */
* @see {@link Api["listen"]}. */
export type ListenerCallback = (event: AuthEvent, data?: unknown) => void
/** Unsubscribe the {@link ListenerCallback} from authentication state changes.
*
* @see {@link Api["listen"]} */
* @see {@link Api["listen"]}. */
type UnsubscribeFunction = () => void
/** Used to subscribe to {@link AuthEvent}s.

View File

@ -67,14 +67,15 @@ interface BaseUserSession<Type extends UserSessionType> {
export interface OfflineUserSession extends Pick<BaseUserSession<UserSessionType.offline>, 'type'> {
accessToken: null
organization: null
user: null
}
/** The singleton instance of {@link OfflineUserSession}.
* Minimizes React re-renders. */
const OFFLINE_USER_SESSION: OfflineUserSession = {
/** The singleton instance of {@link OfflineUserSession}. Minimizes React re-renders. */
const OFFLINE_USER_SESSION: Readonly<OfflineUserSession> = {
type: UserSessionType.offline,
accessToken: null,
organization: null,
user: null,
}
/** Object containing the currently signed-in user's session data, if the user has not yet set their
@ -89,6 +90,7 @@ export interface PartialUserSession extends BaseUserSession<UserSessionType.part
export interface FullUserSession extends BaseUserSession<UserSessionType.full> {
/** User's organization information. */
organization: backendModule.UserOrOrganization
user: backendModule.SimpleUser
}
/** A user session for a user that may be either fully registered,
@ -259,9 +261,14 @@ export function AuthProvider(props: AuthProviderProps) {
setBackendWithoutSavingType(backend)
}
let organization: backendModule.UserOrOrganization | null
let user: backendModule.SimpleUser | null
while (true) {
try {
organization = await backend.usersMe()
user =
(await backend.listUsers()).find(
listedUser => listedUser.email === organization?.email
) ?? null
break
} catch {
// The value may have changed after the `await`.
@ -283,7 +290,7 @@ export function AuthProvider(props: AuthProviderProps) {
history.replaceState(null, '', url.toString())
}
let newUserSession: UserSession
if (organization == null) {
if (organization == null || user == null) {
newUserSession = {
type: UserSessionType.partial,
...session,
@ -293,6 +300,7 @@ export function AuthProvider(props: AuthProviderProps) {
type: UserSessionType.full,
...session,
organization,
user,
}
/** Save access token so can be reused by Enso backend. */

View File

@ -108,7 +108,7 @@ export interface AuthConfig {
export interface AuthService {
/** @see {@link cognito.Cognito}. */
cognito: cognito.Cognito
/** @see {@link listen.ListenFunction} */
/** @see {@link listen.ListenFunction}. */
registerAuthEventListener: listen.ListenFunction
}

View File

@ -2,6 +2,7 @@
import * as dateTime from './dateTime'
import * as newtype from '../newtype'
import * as permissions from './permissions'
// =============
// === Types ===
@ -52,7 +53,7 @@ export type SecretId = newtype.Newtype<string, 'SecretId'>
/** Create a {@link SecretId}. */
export const SecretId = newtype.newtypeConstructor<SecretId>()
/** Unique identifier for an arbitrary asset */
/** Unique identifier for an arbitrary asset. */
export type AssetId = IdType[keyof IdType]
/** Unique identifier for a file tag or project tag. */
@ -124,7 +125,7 @@ export interface ProjectStateType {
type: ProjectState
}
/** Common `Project` fields returned by all `Project`-related endpoints. */
/** Common `Project` fields returned by all `Project`-related endpoints. */
export interface BaseProject {
organizationId: string
projectId: ProjectId
@ -295,9 +296,14 @@ export interface SimpleUser {
/** Backend representation of user permission types. */
export enum PermissionAction {
own = 'Own',
execute = 'Execute',
admin = 'Admin',
edit = 'Edit',
read = 'Read',
readAndDocs = 'Read_docs',
readAndExec = 'Read_exec',
view = 'View',
viewAndDocs = 'View_docs',
viewAndExec = 'View_exec',
}
/** User permission for a specific user. */
@ -306,13 +312,6 @@ export interface UserPermission {
permission: PermissionAction
}
/** User permissions for a specific user. This is only returned by
* {@link groupPermissionsByUser}. */
export interface UserPermissions {
user: User
permissions: PermissionAction[]
}
/** The type returned from the "update directory" endpoint. */
export interface UpdatedDirectory {
id: DirectoryId
@ -350,16 +349,25 @@ export interface IdType {
[AssetType.specialEmpty]: EmptyAssetId
}
/** The english name of each asset type. */
export const ASSET_TYPE_NAME: Record<AssetType, string> = {
[AssetType.directory]: 'folder',
[AssetType.project]: 'project',
[AssetType.file]: 'file',
[AssetType.secret]: 'secret',
[AssetType.specialLoading]: 'special loading asset',
[AssetType.specialEmpty]: 'special empty asset',
} as const
/** Integers (starting from 0) corresponding to the order in which each asset type should appear
* in a directory listing. */
export const ASSET_TYPE_ORDER: Record<AssetType, number> = {
// This is a sequence of numbers, not magic numbers. `999` and `1000` are arbitrary numbers
// that are higher than the number of possible asset types.
/* eslint-disable @typescript-eslint/no-magic-numbers */
[AssetType.directory]: 0,
[AssetType.project]: 1,
[AssetType.file]: 2,
// These are not magic constants; `3` is simply the next number after `2`.
// `999` and `1000` are arbitrary numbers chosen to be higher than the number of possible
// asset types.
/* eslint-disable @typescript-eslint/no-magic-numbers */
[AssetType.secret]: 3,
[AssetType.specialLoading]: 999,
[AssetType.specialEmpty]: 1000,
@ -434,6 +442,39 @@ export const assetIsSecret = assetIsType(AssetType.secret)
export const assetIsFile = assetIsType(AssetType.file)
/* eslint-disable no-restricted-syntax */
// ==============================
// === compareUserPermissions ===
// ==============================
/** A value returned from a compare function passed to {@link Array.sort}, indicating that the
* first argument was less than the second argument. */
const COMPARE_LESS_THAN = -1
/** Return a positive number when `a > b`, a negative number when `a < b`, and `0`
* when `a === b`. */
export function compareUserPermissions(a: UserPermission, b: UserPermission) {
const relativePermissionPrecedence =
permissions.PERMISSION_ACTION_PRECEDENCE[a.permission] -
permissions.PERMISSION_ACTION_PRECEDENCE[b.permission]
if (relativePermissionPrecedence !== 0) {
return relativePermissionPrecedence
} else {
const aName = a.user.user_name
const bName = b.user.user_name
const aEmail = a.user.user_email
const bEmail = b.user.user_email
return aName < bName
? COMPARE_LESS_THAN
: aName > bName
? 1
: aEmail < bEmail
? COMPARE_LESS_THAN
: aEmail > bEmail
? 1
: 0
}
}
// =================
// === Endpoints ===
// =================
@ -455,7 +496,7 @@ export interface InviteUserRequestBody {
export interface CreatePermissionRequestBody {
userSubjects: Subject[]
resourceId: AssetId
actions: PermissionAction[]
action: PermissionAction | null
}
/** HTTP request body for the "create directory" endpoint. */
@ -597,30 +638,6 @@ export function stripProjectExtension(name: string) {
return name.replace(/\.tar\.gz$|\.zip$|\.enso-project/, '')
}
// ==============================
// === groupPermissionsByUser ===
// ==============================
/** Converts an array of {@link UserPermission}s to an array of {@link UserPermissions}. */
export function groupPermissionsByUser(permissions: UserPermission[]) {
const users: UserPermissions[] = []
const userMap: Record<Subject, UserPermissions> = {}
for (const permission of permissions) {
const existingUser = userMap[permission.user.pk]
if (existingUser != null) {
existingUser.permissions.push(permission.permission)
} else {
const newUser: UserPermissions = {
user: permission.user,
permissions: [permission.permission],
}
users.push(newUser)
userMap[permission.user.pk] = newUser
}
}
return users
}
// ===============
// === Backend ===
// ===============

View File

@ -9,6 +9,7 @@ import PlusIcon from 'enso-assets/plus.svg'
import TagIcon from 'enso-assets/tag.svg'
import TimeIcon from 'enso-assets/time.svg'
import * as assetEvent from './events/assetEvent'
import * as authProvider from '../authentication/providers/auth'
import * as backend from './backend'
import * as dateTime from './dateTime'
@ -17,9 +18,9 @@ import * as tableColumn from './components/tableColumn'
import * as uniqueString from '../uniqueString'
import * as assetsTable from './components/assetsTable'
import PermissionDisplay, * as permissionDisplay from './components/permissionDisplay'
import AssetNameColumn from './components/assetNameColumn'
import ManagePermissionsModal from './components/managePermissionsModal'
import PermissionDisplay from './components/permissionDisplay'
// =============
// === Types ===
@ -57,9 +58,6 @@ export type ExtraColumn = (typeof EXTRA_COLUMNS)[number]
// === Constants ===
// =================
/** An immutable empty array, useful as a React prop. */
const EMPTY_ARRAY: never[] = []
/** The list of extra columns, in order. */
// This MUST be `as const`, to generate the `ExtraColumn` type above.
// eslint-disable-next-line no-restricted-syntax
@ -143,12 +141,7 @@ function LastModifiedColumn(props: AssetColumnProps<backend.AnyAsset>) {
/** Props for a {@link UserPermissionDisplay}. */
interface InternalUserPermissionDisplayProps {
user: backend.UserPermissions
item: backend.Asset
emailsOfUsersWithPermission: Set<backend.EmailAddress>
ownsThisAsset: boolean
onDelete: () => void
onPermissionsChange: (permissions: backend.PermissionAction[]) => void
user: backend.UserPermission
}
// =============================
@ -157,77 +150,15 @@ interface InternalUserPermissionDisplayProps {
/** Displays permissions for a user on a specific asset. */
function UserPermissionDisplay(props: InternalUserPermissionDisplayProps) {
const {
user,
item,
emailsOfUsersWithPermission,
ownsThisAsset,
onDelete,
onPermissionsChange,
} = props
const { setModal } = modalProvider.useSetModal()
const [permissions, setPermissions] = React.useState(user.permissions)
const [oldPermissions, setOldPermissions] = React.useState(user.permissions)
const [isHovered, setIsHovered] = React.useState(false)
const [isDeleting, setIsDeleting] = React.useState(false)
const { user } = props
const [permissions, setPermissions] = React.useState(user.permission)
React.useEffect(() => {
setPermissions(user.permissions)
}, [user.permissions])
setPermissions(user.permission)
}, [user.permission])
return isDeleting ? null : (
<PermissionDisplay
key={user.user.pk}
permissions={permissionDisplay.permissionActionsToPermissions(permissions)}
className={ownsThisAsset ? 'cursor-pointer hover:shadow-soft hover:z-10' : ''}
onClick={event => {
event.stopPropagation()
if (ownsThisAsset) {
setModal(
<ManagePermissionsModal
key={Number(new Date())}
user={user.user}
initialPermissions={user.permissions}
asset={item}
emailsOfUsersWithPermission={emailsOfUsersWithPermission}
eventTarget={event.currentTarget}
onSubmit={(_users, newPermissions) => {
if (newPermissions.length === 0) {
setIsDeleting(true)
} else {
setOldPermissions(permissions)
setPermissions(newPermissions)
onPermissionsChange(newPermissions)
}
}}
onSuccess={(_users, newPermissions) => {
if (newPermissions.length === 0) {
onDelete()
}
}}
onFailure={() => {
setIsDeleting(false)
setPermissions(oldPermissions)
onPermissionsChange(oldPermissions)
}}
/>
)
}
}}
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
>
{isHovered && (
<div className="relative">
<div className="absolute text-primary bottom-2 left-1/2 -translate-x-1/2 rounded-full shadow-soft bg-white px-2 py-1">
{user.user.user_email}
</div>
</div>
)}
return (
<PermissionDisplay key={user.user.pk} action={permissions}>
{user.user.user_name}
</PermissionDisplay>
)
@ -239,22 +170,20 @@ function UserPermissionDisplay(props: InternalUserPermissionDisplayProps) {
/** A column listing the users with which this asset is shared. */
function SharedWithColumn(props: AssetColumnProps<backend.AnyAsset>) {
const { item } = props
const {
item,
setItem,
state: { dispatchAssetEvent },
} = props
const session = authProvider.useNonPartialUserSession()
const { setModal } = modalProvider.useSetModal()
const [permissions, setPermissions] = React.useState(() =>
backend.groupPermissionsByUser(item.permissions ?? [])
)
const [oldPermissions, setOldPermissions] = React.useState(permissions)
const [isHovered, setIsHovered] = React.useState(false)
const emailsOfUsersWithPermission = React.useMemo(
() => new Set(permissions.map(permission => permission.user.user_email)),
[permissions]
)
const selfPermission = item.permissions?.find(
const self = item.permissions?.find(
permission => permission.user.user_email === session.organization?.email
)?.permission
const ownsThisAsset = selfPermission === backend.PermissionAction.own
)
const managesThisAsset =
self?.permission === backend.PermissionAction.own ||
self?.permission === backend.PermissionAction.admin
return (
<div
className="flex items-center gap-1"
@ -265,69 +194,25 @@ function SharedWithColumn(props: AssetColumnProps<backend.AnyAsset>) {
setIsHovered(false)
}}
>
{permissions.map(user => (
<UserPermissionDisplay
key={user.user.user_email}
user={user}
item={item}
emailsOfUsersWithPermission={emailsOfUsersWithPermission}
ownsThisAsset={ownsThisAsset}
onDelete={() => {
setPermissions(
permissions.filter(
permission => permission.user.user_email !== user.user.user_email
)
)
}}
onPermissionsChange={newPermissions => {
setPermissions(
permissions.map(permission =>
permission.user.user_email === user.user.user_email
? { user: user.user, permissions: newPermissions }
: permission
)
)
}}
/>
{(item.permissions ?? []).map(user => (
<UserPermissionDisplay key={user.user.user_email} user={user} />
))}
{ownsThisAsset && isHovered && (
{managesThisAsset && isHovered && (
<button
onClick={event => {
event.stopPropagation()
setModal(
<ManagePermissionsModal
key={uniqueString.uniqueString()}
asset={item}
initialPermissions={EMPTY_ARRAY}
emailsOfUsersWithPermission={emailsOfUsersWithPermission}
item={item}
setItem={setItem}
self={self}
eventTarget={event.currentTarget}
onSubmit={(users, newPermissions) => {
setOldPermissions(permissions)
setPermissions([
...permissions,
...users.map(user => {
const userPermissions: backend.UserPermissions = {
user: {
pk: user.id,
// The names come from a third-party API
// and cannot be changed.
/* eslint-disable @typescript-eslint/naming-convention */
user_name: user.name,
user_email: user.email,
/** {@link SharedWithColumn} is only accessible
* if `session.organization` is not `null`. */
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
organization_id: session.organization!.id,
/* eslint-enable @typescript-eslint/naming-convention */
},
permissions: newPermissions,
}
return userPermissions
}),
])
}}
onFailure={() => {
setPermissions(oldPermissions)
doRemoveSelf={() => {
dispatchAssetEvent({
type: assetEvent.AssetEventType.removeSelf,
id: item.id,
})
}}
/>
)

View File

@ -17,7 +17,7 @@ export interface AssetInfoBarProps {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default function AssetInfoBar(_props: AssetInfoBarProps) {
return (
<div className="flex items-center shrink-0 bg-frame-bg rounded-full gap-3 h-8 px-2 cursor-default pointer-events-auto">
<div className="flex items-center shrink-0 bg-frame rounded-full gap-3 h-8 px-2 cursor-default pointer-events-auto">
<Button
active={false}
disabled

View File

@ -94,6 +94,7 @@ function AssetRow(props: AssetRowProps<backendModule.AnyAsset>) {
} = props
const { backend } = backendProvider.useBackend()
const { setModal } = modalProvider.useSetModal()
const { user } = authProvider.useNonPartialUserSession()
const toastAndLog = hooks.useToastAndLog()
const [item, setItem] = React.useState(rawItem)
const [presence, setPresence] = React.useState(presenceModule.Presence.present)
@ -158,6 +159,27 @@ function AssetRow(props: AssetRowProps<backendModule.AnyAsset>) {
}
break
}
case assetEventModule.AssetEventType.removeSelf: {
// This is not triggered from the asset list, so it uses `item.id` instead of `key`.
if (event.id === item.id && user != null) {
setPresence(presenceModule.Presence.deleting)
try {
await backend.createPermission({
action: null,
resourceId: item.id,
userSubjects: [user.id],
})
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
id: key,
})
} catch (error) {
setPresence(presenceModule.Presence.present)
toastAndLog('Unable to delete project', error)
}
}
break
}
}
})
@ -290,7 +312,7 @@ export default function AssetsTable(props: AssetsTableProps) {
doOpenIde,
doCloseIde: rawDoCloseIde,
} = props
const { organization } = authProvider.useNonPartialUserSession()
const { organization, user } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { setModal } = modalProvider.useSetModal()
const [initialized, setInitialized] = React.useState(false)
@ -446,7 +468,7 @@ export default function AssetsTable(props: AssetsTableProps) {
title,
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: event.parentId ?? backendModule.DirectoryId(''),
permissions: permissions.tryGetSingletonOwnerPermission(organization),
permissions: permissions.tryGetSingletonOwnerPermission(organization, user),
projectState: null,
type: backendModule.AssetType.directory,
}
@ -474,7 +496,7 @@ export default function AssetsTable(props: AssetsTableProps) {
title: projectName,
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: event.parentId ?? backendModule.DirectoryId(''),
permissions: permissions.tryGetSingletonOwnerPermission(organization),
permissions: permissions.tryGetSingletonOwnerPermission(organization, user),
projectState: { type: backendModule.ProjectState.placeholder },
type: backendModule.AssetType.project,
}
@ -505,7 +527,7 @@ export default function AssetsTable(props: AssetsTableProps) {
id: backendModule.FileId(uniqueString.uniqueString()),
title: file.name,
parentId: event.parentId ?? backendModule.DirectoryId(''),
permissions: permissions.tryGetSingletonOwnerPermission(organization),
permissions: permissions.tryGetSingletonOwnerPermission(organization, user),
modifiedAt: dateTime.toRfc3339(new Date()),
projectState: null,
}))
@ -516,7 +538,7 @@ export default function AssetsTable(props: AssetsTableProps) {
id: backendModule.ProjectId(uniqueString.uniqueString()),
title: file.name,
parentId: event.parentId ?? backendModule.DirectoryId(''),
permissions: permissions.tryGetSingletonOwnerPermission(organization),
permissions: permissions.tryGetSingletonOwnerPermission(organization, user),
modifiedAt: dateTime.toRfc3339(new Date()),
projectState: {
type: backendModule.ProjectState.new,
@ -561,7 +583,7 @@ export default function AssetsTable(props: AssetsTableProps) {
title: event.name,
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: event.parentId ?? backendModule.DirectoryId(''),
permissions: permissions.tryGetSingletonOwnerPermission(organization),
permissions: permissions.tryGetSingletonOwnerPermission(organization, user),
projectState: null,
type: backendModule.AssetType.secret,
}

View File

@ -8,83 +8,105 @@ import * as React from 'react'
/** A zero-width space. Useful to make a `div` take up at least one line. */
const ZWSP = '\u200b'
// ====================
// === Autocomplete ===
// ====================
/** Base props for a {@link Autocomplete}. */
export interface BaseAutocompleteProps {
interface InternalBaseAutocompleteProps<T> {
multiple?: boolean
type?: React.HTMLInputTypeAttribute
itemNamePlural?: string
initialValue?: string | null
inputRef?: React.MutableRefObject<HTMLInputElement | null>
placeholder?: string
values: T[]
autoFocus?: boolean
disabled?: boolean
maxItemsToShow?: number
/** This may change as the user types in the input. */
items: string[]
items: T[]
itemToKey: (item: T) => string
itemToString: (item: T) => string
itemsToString?: (items: T[]) => string
matches: (item: T, text: string) => boolean
className?: string
inputClassName?: string
optionsClassName?: string
/** This callback is only called when the text is changed. */
onInput?: (value: string) => void
text?: string | null
setText?: (text: string | null) => void
}
/** {@link AutocompleteProps} when `multiple` is `false`. */
interface InternalSingleAutocompleteProps extends BaseAutocompleteProps {
inputRef?: React.MutableRefObject<HTMLInputElement | null>
interface InternalSingleAutocompleteProps<T> extends InternalBaseAutocompleteProps<T> {
/** Whether selecting multiple values is allowed. */
multiple?: false
onChange: (value: [string]) => void
setValues: (value: [T]) => void
itemsToString?: never
}
/** {@link AutocompleteProps} when `multiple` is `true`. */
interface InternalMultipleAutocompleteProps extends BaseAutocompleteProps {
interface InternalMultipleAutocompleteProps<T> extends InternalBaseAutocompleteProps<T> {
/** Whether selecting multiple values is allowed. */
multiple: true
/** This is `null` when multiple values are selected, causing the input to switch to a
* {@link HTMLTextAreaElement}. */
inputRef?: React.MutableRefObject<HTMLInputElement | null>
/** Whether selecting multiple values is allowed. */
multiple: true
onChange: (value: string[]) => void
setValues: (value: T[]) => void
itemsToString: (items: T[]) => string
}
/** {@link AutocompleteProps} when the text cannot be edited. */
interface WithoutText {
text?: never
setText?: never
}
/** {@link AutocompleteProps} when the text can be edited. */
interface WithText {
text: string | null
setText: (text: string | null) => void
}
/** Props for a {@link Autocomplete}. */
export type AutocompleteProps = InternalMultipleAutocompleteProps | InternalSingleAutocompleteProps
export type AutocompleteProps<T> = (
| InternalMultipleAutocompleteProps<T>
| InternalSingleAutocompleteProps<T>
) &
(WithoutText | WithText)
/** A select menu with a dropdown. */
export default function Autocomplete(
props: InternalMultipleAutocompleteProps | InternalSingleAutocompleteProps
) {
export default function Autocomplete<T>(props: AutocompleteProps<T>) {
const {
type = 'text',
itemNamePlural = 'items',
inputRef: rawInputRef,
initialValue,
autoFocus,
disabled = false,
multiple,
maxItemsToShow = 1,
type = 'text',
inputRef: rawInputRef,
placeholder,
values,
setValues,
text,
setText,
autoFocus,
items,
onInput,
onChange,
itemToKey,
itemToString,
itemsToString,
matches,
className,
inputClassName,
optionsClassName,
} = props
const [values, setValues] = React.useState(initialValue != null ? [initialValue] : [])
const [isDropdownVisible, setIsDropdownVisible] = React.useState(false)
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
const [valuesText, setValuesText] = React.useState('')
const valuesSet = React.useMemo(() => new Set(values), [values])
/** This input should only act like a multiple select only when `multiple` is true,
* there are multiple items, and all selected items are in the autocomplete list. */
const actuallyMultiple =
multiple === true &&
items.length > 1 &&
(values.length > 1 || (values[0] != null && items.includes(values[0])))
const canEditText = setText != null && values.length === 0
const isMultipleAndCustomValue = multiple === true && text != null
const matchingItems = React.useMemo(
() => (text == null ? items : items.filter(item => matches(item, text))),
[items, matches, text]
)
React.useEffect(() => {
if (actuallyMultiple) {
if (!canEditText) {
setIsDropdownVisible(true)
}
}, [actuallyMultiple])
}, [canEditText])
React.useEffect(() => {
const onClick = () => {
@ -96,49 +118,32 @@ export default function Autocomplete(
}
}, [])
// This is required. React emits an error when conditionally setting `value`. See:
// https://react.dev/reference/react-dom/components/input#im-getting-an-error-a-component-is-changing-an-uncontrolled-input-to-be-controlled
// `rawInputRef` MUST either alwoys be set, or always not be set, otherwise this `useRef` hook
// is called conditionally, which is not allowed in React.
// This is INCORRECT, but SAFE to use in hooks as its value will be set by the time any hook
// runs.
// eslint-disable-next-line react-hooks/rules-of-hooks
const inputRef = rawInputRef ?? React.useRef<HTMLInputElement>(null)
const fallbackInputRef = React.useRef<HTMLInputElement>(null)
const inputRef = rawInputRef ?? fallbackInputRef
/** Set values, while also changing the input text if the input is not using multi-select. */
const overrideValues = React.useCallback(
// This type is a little too wide but it is unavoidable.
(newItems: string[] | [string]) => {
if (!actuallyMultiple) {
setIsDropdownVisible(false)
}
// This type is a little too wide but it is unavoidable.
/** Set values, while also changing the input text. */
const overrideValues = (newItems: T[] | [T]) => {
if (multiple !== true || (newItems.length === 1 && !items.includes(newItems[0]))) {
setIsDropdownVisible(false)
}
if (multiple === true) {
setValues(newItems)
const firstItem = newItems[0]
if (inputRef.current != null) {
inputRef.current.value = firstItem
}
setValuesText(
newItems.length <= maxItemsToShow
? newItems.join(', ')
: `${newItems.length} ${itemNamePlural} selected`
)
if (multiple === true) {
onChange(newItems)
} else {
onChange([newItems[0]])
}
onInput?.('')
},
[
onChange,
onInput,
inputRef,
actuallyMultiple,
/* should never change */ itemNamePlural,
/* should never change */ multiple,
/* should never change */ maxItemsToShow,
]
)
} else {
setValues([newItems[0]])
}
setText?.(null)
}
const toggleValue = (value: T) => {
overrideValues(
multiple === true && !isMultipleAndCustomValue
? valuesSet.has(value)
? values.filter(theItem => theItem !== value)
: [...values, value]
: [value]
)
}
const onKeyDown = (event: React.KeyboardEvent) => {
switch (event.key) {
@ -168,10 +173,10 @@ export default function Autocomplete(
// Do not prevent default; the input needs to handle the event too.
if (selectedIndex != null) {
const item = items[selectedIndex]
// If `item` is `null`, silently error. If it *is* `null`, it is out of range
// If `item` is `null`, silently error. This is because it is out of range
// anyway, so no item will be selected in the UI.
if (item != null) {
overrideValues(actuallyMultiple ? [...items, item] : [item])
toggleValue(item)
}
setSelectedIndex(null)
}
@ -189,13 +194,37 @@ export default function Autocomplete(
}
return (
<div onKeyDown={onKeyDown}>
<div className={`flex flex-1 ${disabled ? 'cursor-not-allowed' : ''}`}>
{actuallyMultiple ? (
<div onKeyDown={onKeyDown} className={className}>
<div className="flex flex-1">
{canEditText ? (
<input
type={type}
ref={inputRef}
autoFocus={autoFocus}
size={1}
value={text ?? ''}
placeholder={placeholder}
className={`grow ${inputClassName ?? ''}`}
onFocus={() => {
setIsDropdownVisible(true)
}}
onBlur={() => {
requestAnimationFrame(() => {
setIsDropdownVisible(false)
})
}}
onChange={event => {
setIsDropdownVisible(true)
setText(
event.currentTarget.value === '' ? null : event.currentTarget.value
)
}}
/>
) : (
<div
ref={element => element?.focus()}
tabIndex={-1}
className="grow cursor-pointer bg-gray-200 rounded-xl px-2 py-1"
className={`grow cursor-pointer ${inputClassName ?? ''}`}
onClick={() => {
setIsDropdownVisible(true)
}}
@ -205,70 +234,40 @@ export default function Autocomplete(
})
}}
>
{valuesText.replace(/-/g, '\u2060-\u2060') || ZWSP}
{itemsToString?.(values) ?? ZWSP}
</div>
) : (
<input
type={type}
ref={inputRef}
autoFocus={autoFocus}
disabled={disabled}
size={1}
className={`grow bg-gray-200 rounded-xl px-2 py-1 ${
disabled ? 'pointer-events-none opacity-70' : ''
} ${className ?? ''}`}
defaultValue={values}
onFocus={() => {
setIsDropdownVisible(true)
}}
onBlur={() => {
requestAnimationFrame(() => {
setIsDropdownVisible(false)
})
}}
onInput={event => {
setIsDropdownVisible(true)
onInput?.(event.currentTarget.value)
}}
onChange={event => {
onChange([event.target.value])
}}
/>
)}
</div>
<div className={`relative h-0 ${optionsClassName ?? ''}`}>
<div
className={`absolute bg-white z-10 w-full rounded-lg shadow-soft max-h-10lh ${
isDropdownVisible ? 'overflow-auto' : 'overflow-hidden h-0'
}`}
>
{items.map((item, index) => (
<div
key={item}
className={`cursor-pointer first:rounded-t-lg last:rounded-b-lg hover:bg-gray-100 p-1 ${
index === selectedIndex
? 'bg-gray-100'
: valuesSet.has(item)
? 'bg-gray-200'
: 'bg-white'
}`}
onMouseDown={event => {
event.preventDefault()
}}
onClick={event => {
event.stopPropagation()
overrideValues(
actuallyMultiple
? valuesSet.has(item)
? values.filter(theItem => theItem !== item)
: [...values, item]
: [item]
)
}}
>
{item}
</div>
))}
<div className={`h-0 ${optionsClassName ?? ''}`}>
<div className="relative w-full h-max">
<div className="absolute bg-frame-selected rounded-2xl backdrop-blur-3xl top-0 w-full h-full" />
<div
className={`relative rounded-2xl overflow-auto w-full max-h-10lh ${
isDropdownVisible ? '' : 'h-0'
}`}
>
{matchingItems.map((item, index) => (
<div
key={itemToKey(item)}
className={`relative cursor-pointer first:rounded-t-2xl last:rounded-b-2xl hover:bg-black-a5 p-1 z-10 ${
index === selectedIndex
? 'bg-black-a5'
: valuesSet.has(item)
? 'bg-black-a10'
: ''
}`}
onMouseDown={event => {
event.preventDefault()
}}
onClick={event => {
event.stopPropagation()
toggleValue(item)
}}
>
{itemToString(item)}
</div>
))}
</div>
</div>
</div>
</div>

View File

@ -27,8 +27,8 @@ export default function BackendSwitcher(props: BackendSwitcherProps) {
<div
className={`rounded-l-full px-2.5 py-1 ${
backend.type === backendModule.BackendType.remote
? 'bg-frame-selected-bg'
: 'bg-frame-bg'
? 'bg-frame-selected'
: 'bg-frame'
}`}
>
<button
@ -49,8 +49,8 @@ export default function BackendSwitcher(props: BackendSwitcherProps) {
<div
className={`rounded-r-full px-2.5 py-1 ${
backend.type === backendModule.BackendType.local
? 'bg-frame-selected-bg'
: 'bg-frame-bg'
? 'bg-frame-selected'
: 'bg-frame'
}`}
>
<button

View File

@ -22,7 +22,7 @@ import Twemoji from './twemoji'
// === Newtypes ===
// ================
/** Create a {@link chat.MessageId} */
/** Create a {@link chat.MessageId}. */
const MessageId = newtype.newtypeConstructor<chat.MessageId>()
// =================

View File

@ -16,7 +16,7 @@ import Modal from './modal'
/** Props for a {@link ConfirmDeleteModal}. */
export interface ConfirmDeleteModalProps {
/** Must fit in the sentence "Are you sure you want to delete <description>"? */
/** Must fit in the sentence "Are you sure you want to delete <description>?". */
description: string
doDelete: () => void
}

View File

@ -60,7 +60,8 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.deleteMultiple:
case assetEventModule.AssetEventType.downloadSelected: {
case assetEventModule.AssetEventType.downloadSelected:
case assetEventModule.AssetEventType.removeSelf: {
// Ignored. These events should all be unrelated to directories.
// `deleteMultiple` and `downloadSelected` are handled by `AssetRow`.
break

View File

@ -37,7 +37,7 @@ export default function DriveBar(props: DriveBarProps) {
<div className="flex py-0.5">
<div className="flex gap-2.5">
<button
className="flex items-center bg-frame-bg rounded-full h-8 px-2.5"
className="flex items-center bg-frame rounded-full h-8 px-2.5"
onClick={() => {
unsetModal()
doCreateProject(null)
@ -45,7 +45,7 @@ export default function DriveBar(props: DriveBarProps) {
>
<span className="font-semibold leading-5 h-6 py-px">New Project</span>
</button>
<div className="flex items-center bg-frame-bg rounded-full gap-3 h-8 px-3">
<div className="flex items-center bg-frame rounded-full gap-3 h-8 px-3">
{backend.type !== backendModule.BackendType.local && (
<>
<Button

View File

@ -51,7 +51,8 @@ export default function FileNameColumn(props: FileNameColumnProps) {
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.deleteMultiple:
case assetEventModule.AssetEventType.downloadSelected: {
case assetEventModule.AssetEventType.downloadSelected:
case assetEventModule.AssetEventType.removeSelf: {
// Ignored. These events should all be unrelated to projects.
// `deleteMultiple` and `downloadSelected` are handled by `AssetRow`.
break

View File

@ -12,7 +12,7 @@ const DEBOUNCE_MS = 1000
// === Input ===
// =============
/** Props for an `<input>` HTML element/ */
/** Props for an `<input>` HTML element. */
type InputAttributes = JSX.IntrinsicElements['input']
/** Props for an {@link Input}. */

View File

@ -1,78 +1,38 @@
/** @file A modal with inputs for user email and permission level. */
import * as React from 'react'
import * as toastify from 'react-toastify'
import CloseIcon from 'enso-assets/close.svg'
import * as toast from 'react-toastify'
import * as auth from '../../authentication/providers/auth'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as errorModule from '../../error'
import * as hooks from '../../hooks'
import * as modalProvider from '../../providers/modal'
import Autocomplete from './autocomplete'
import Modal from './modal'
import PermissionSelector from './permissionSelector'
import UserPermissions from './userPermissions'
// =================
// === Constants ===
// =================
/** The maximum number of items to show in the {@link Autocomplete} before it switches to showing
* "X items selected". */
const MAX_AUTOCOMPLETE_ITEMS_TO_SHOW = 3
/** The vertical offset of the {@link PermissionTypeSelector} from its parent element, for the
* input to invite new users. */
const TYPE_SELECTOR_Y_OFFSET_PX = 32
// ==============================
// === ManagePermissionsModal ===
// ==============================
/** Possible actions that a {@link ManagePermissionsModal} can perform. */
enum ManagePermissionsAction {
/** The default action. Add permissions for a new user. */
share = 'share',
/** Update permissions. */
update = 'update',
/** Remove access for a user from this asset. */
remove = 'remove',
/** Invite a user not yet in the organization. */
inviteToOrganization = 'invite-to-organization',
}
/** The text on the submit button, for each action. */
const SUBMIT_BUTTON_TEXT: Record<ManagePermissionsAction, string> = {
[ManagePermissionsAction.share]: 'Share',
[ManagePermissionsAction.update]: 'Update',
[ManagePermissionsAction.remove]: 'Remove',
[ManagePermissionsAction.inviteToOrganization]: 'Invite to organization',
} as const
/** The classes specific to each action, for the submit button. */
const ACTION_CSS_CLASS: Record<ManagePermissionsAction, string> = {
[ManagePermissionsAction.share]: 'bg-blue-600',
[ManagePermissionsAction.update]: 'bg-blue-600',
[ManagePermissionsAction.remove]: 'bg-red-700',
[ManagePermissionsAction.inviteToOrganization]: 'bg-blue-600',
} as const
/** Props for a {@link ManagePermissionsModal}. */
export interface ManagePermissionsModalProps {
asset: backendModule.Asset
initialPermissions: backendModule.PermissionAction[]
emailsOfUsersWithPermission: Set<backendModule.EmailAddress>
/* If present, the user cannot be changed. */
user?: backendModule.User
onSubmit: (
users: backendModule.SimpleUser[],
permissions: backendModule.PermissionAction[]
) => void
onSuccess?: (
users: backendModule.SimpleUser[],
permissions: backendModule.PermissionAction[]
) => void
onFailure?: (
users: backendModule.SimpleUser[],
permissions: backendModule.PermissionAction[]
) => void
item: backendModule.AnyAsset
setItem: React.Dispatch<React.SetStateAction<backendModule.AnyAsset>>
self: backendModule.UserPermission
/** Remove the current user's permissions from this asset. This MUST be a prop because it should
* change the assets list. */
doRemoveSelf: () => void
eventTarget: HTMLElement
}
@ -80,64 +40,51 @@ export interface ManagePermissionsModalProps {
* @throws {Error} when the current backend is the local backend, or when the user is offline.
* This should never happen, as this modal should not be accessible in either case. */
export default function ManagePermissionsModal(props: ManagePermissionsModalProps) {
const {
asset,
initialPermissions,
emailsOfUsersWithPermission,
user: rawUser,
onSubmit: rawOnSubmit,
onSuccess,
onFailure,
eventTarget,
} = props
const { item, setItem, self, doRemoveSelf, eventTarget } = props
const { organization } = auth.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal()
const position = React.useMemo(() => eventTarget.getBoundingClientRect(), [eventTarget])
const [willInviteNewUser, setWillInviteNewUser] = React.useState(false)
const toastAndLog = hooks.useToastAndLog()
const [permissions, setPermissions] = React.useState(item.permissions ?? [])
const [users, setUsers] = React.useState<backendModule.SimpleUser[]>([])
const [matchingUsers, setMatchingUsers] = React.useState(users)
const [emails, setEmails] = React.useState<string[]>(
rawUser != null ? [rawUser.user_email] : []
)
const [permissions, setPermissions] = React.useState(new Set<backendModule.PermissionAction>())
const user = React.useMemo(() => {
const firstEmail = emails[0]
if (rawUser != null) {
return rawUser
} else if (firstEmail != null && emails.length === 1) {
return asset.permissions?.find(permission => permission.user.user_email === firstEmail)
?.user
} else {
return null
}
}, [rawUser, emails, /* should never change */ asset.permissions])
const userEmailRef = React.useRef<HTMLInputElement>(null)
const errorMessage = React.useMemo(
const [email, setEmail] = React.useState<string | null>(null)
const [action, setAction] = React.useState(backendModule.PermissionAction.view)
const emailValidityRef = React.useRef<HTMLInputElement>(null)
const position = React.useMemo(() => eventTarget.getBoundingClientRect(), [eventTarget])
const editablePermissions = React.useMemo(
() =>
emails.length === 0
? 'An email address must be provided.'
: userEmailRef.current?.validity.valid === false
? 'The email address provided is invalid.'
: !willInviteNewUser && user == null && permissions.size === 0
? 'Permissions must be set when adding a new user.'
: null,
[user, emails, permissions, willInviteNewUser]
self.permission === backendModule.PermissionAction.own
? permissions
: permissions.filter(
permission => permission.permission !== backendModule.PermissionAction.own
),
[permissions, self.permission]
)
const usernamesOfUsersWithPermission = React.useMemo(
() => new Set(item.permissions?.map(userPermission => userPermission.user.user_name)),
[item.permissions]
)
const emailsOfUsersWithPermission = React.useMemo(
() =>
new Set<string>(
item.permissions?.map(userPermission => userPermission.user.user_email)
),
[item.permissions]
)
const isOnlyOwner = React.useMemo(
() =>
self.permission === backendModule.PermissionAction.own &&
permissions.every(
permission =>
permission.permission !== backendModule.PermissionAction.own ||
permission.user.user_email === organization?.email
),
[organization?.email, permissions, self.permission]
)
const canSubmit = errorMessage == null
const action =
user != null
? permissions.size !== 0
? ManagePermissionsAction.update
: ManagePermissionsAction.remove
: willInviteNewUser
? ManagePermissionsAction.inviteToOrganization
: ManagePermissionsAction.share
React.useEffect(() => {
setItem(oldItem => ({ ...oldItem, permissions }))
}, [permissions, /* should never change */ setItem])
if (backend.type === backendModule.BackendType.local || organization == null) {
// This should never happen - the local backend does not have the "shared with" column,
@ -146,187 +93,265 @@ export default function ManagePermissionsModal(props: ManagePermissionsModalProp
// This MUST be an error, otherwise the hooks below are considered as conditionally called.
throw new Error('Unable to share projects on the local backend.')
} else {
React.useEffect(() => {
if (user == null) {
void (async () => {
const listedUsers = await backend.listUsers()
const newUsers = listedUsers.filter(
listedUser => !emailsOfUsersWithPermission.has(listedUser.email)
const listedUsers = hooks.useAsyncEffect([], () => backend.listUsers(), [])
const allUsers = React.useMemo(
() =>
listedUsers.filter(
listedUser =>
!usernamesOfUsersWithPermission.has(listedUser.name) &&
!emailsOfUsersWithPermission.has(listedUser.email)
),
[emailsOfUsersWithPermission, usernamesOfUsersWithPermission, listedUsers]
)
const willInviteNewUser = React.useMemo(() => {
if (users.length !== 0) {
return false
} else if (email == null || email === '') {
return true
} else {
const lowercase = email.toLowerCase()
return (
lowercase !== '' &&
!usernamesOfUsersWithPermission.has(lowercase) &&
!emailsOfUsersWithPermission.has(lowercase) &&
!allUsers.some(
innerUser =>
innerUser.name.toLowerCase() === lowercase ||
innerUser.email.toLowerCase() === lowercase
)
setUsers(newUsers)
if (emails.length <= 1) {
const lowercaseEmail = emails[0]?.toLowerCase() ?? ''
setMatchingUsers(
newUsers.filter(newUser =>
newUser.email.toLowerCase().includes(lowercaseEmail)
)
)
}
})()
)
}
// `emails` is NOT a dependency. `matchingUsers` is updated based on `email` elsewhere.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
rawUser,
/* should never change */ backend,
/* should never change */ emailsOfUsersWithPermission,
users.length,
email,
emailsOfUsersWithPermission,
usernamesOfUsersWithPermission,
allUsers,
])
const onEmailsChange = React.useCallback(
(newEmails: string[]) => {
setEmails(newEmails)
const lowercaseEmail =
newEmails[0] != null
? backendModule.EmailAddress(newEmails[0].toLowerCase())
: null
if (
userEmailRef.current?.validity.valid === true &&
newEmails.length === 1 &&
lowercaseEmail != null
) {
// A new user will be invited to the organization.
setWillInviteNewUser(
lowercaseEmail !== '' &&
!emailsOfUsersWithPermission.has(lowercaseEmail) &&
!users.some(
innerUser => innerUser.email.toLowerCase() === lowercaseEmail
)
)
}
},
[users, /* should never change */ emailsOfUsersWithPermission]
)
const onSubmit = React.useCallback(
async (formEvent: React.FormEvent) => {
formEvent.preventDefault()
const isEmailInvalid = userEmailRef.current?.validity.valid === false
const isPermissionsInvalid =
!willInviteNewUser && user == null && permissions.size === 0
const usersMap = Object.fromEntries(
users.map(theUser => [theUser.email.toLowerCase(), theUser])
)
const finalUsers: backendModule.SimpleUser[] =
rawUser != null
? [{ id: rawUser.pk, email: rawUser.user_email, name: rawUser.user_name }]
: emails.flatMap(email => {
const theUser = usersMap[email.toLowerCase()]
return theUser != null ? [theUser] : []
})
const firstEmail = emails[0]
if (isEmailInvalid || isPermissionsInvalid) {
// This should never happen. Do nothing.
} else if (willInviteNewUser && firstEmail != null) {
unsetModal()
try {
const doSubmit = async () => {
if (willInviteNewUser) {
try {
setUsers([])
if (email != null) {
await backend.inviteUser({
organizationId: organization.id,
userEmail: backendModule.EmailAddress(firstEmail),
userEmail: backendModule.EmailAddress(email),
})
} catch (error) {
toastify.toast.error(errorModule.tryGetMessage(error) ?? 'Unknown error.')
toast.toast.success(`You've invited ${email} to join Enso!`)
}
} else if (finalUsers.length !== 0) {
unsetModal()
const permissionsArray = [...permissions]
try {
rawOnSubmit(finalUsers, permissionsArray)
await backend.createPermission({
userSubjects: finalUsers.map(finalUser => finalUser.id),
resourceId: asset.id,
actions: permissionsArray,
})
onSuccess?.(finalUsers, permissionsArray)
} catch {
onFailure?.(finalUsers, permissionsArray)
const finalUserEmails = finalUsers.map(finalUser => `'${finalUser.email}'`)
toastify.toast.error(
`Unable to set permissions of ${finalUserEmails.join(', ')}.`
} catch (error) {
toastAndLog('Could not invite user', error)
}
} else {
setUsers([])
const addedUsersPermissions = users.map<backendModule.UserPermission>(newUser => ({
user: {
// The names come from a third-party API and cannot be
// changed.
/* eslint-disable @typescript-eslint/naming-convention */
organization_id: organization.id,
pk: newUser.id,
user_email: newUser.email,
user_name: newUser.name,
/* eslint-enable @typescript-eslint/naming-convention */
},
permission: action,
}))
const addedUsersPks = new Set(addedUsersPermissions.map(newUser => newUser.user.pk))
const oldUsersPermissions = permissions.filter(userPermission =>
addedUsersPks.has(userPermission.user.pk)
)
try {
setPermissions(oldPermissions =>
[
...oldPermissions.filter(
oldUserPermissions => !addedUsersPks.has(oldUserPermissions.user.pk)
),
...addedUsersPermissions,
].sort(backendModule.compareUserPermissions)
)
await backend.createPermission({
userSubjects: addedUsersPermissions.map(
userPermissions => userPermissions.user.pk
),
resourceId: item.id,
action: action,
})
} catch (error) {
setPermissions(oldPermissions =>
[
...oldPermissions.filter(
permission => !addedUsersPks.has(permission.user.pk)
),
...oldUsersPermissions,
].sort(backendModule.compareUserPermissions)
)
const usernames = addedUsersPermissions.map(
userPermissions => userPermissions.user.user_name
)
toastAndLog(`Unable to set permissions for ${usernames.join(', ')}`, error)
}
}
}
const doDelete = async (userToDelete: backendModule.User) => {
if (userToDelete.pk === self.user.pk) {
doRemoveSelf()
} else {
const oldPermission = permissions.find(
userPermission => userPermission.user.pk === userToDelete.pk
)
try {
setPermissions(oldPermissions =>
oldPermissions.filter(
oldUserPermissions => oldUserPermissions.user.pk !== userToDelete.pk
)
)
await backend.createPermission({
userSubjects: [userToDelete.pk],
resourceId: item.id,
action: null,
})
} catch (error) {
if (oldPermission != null) {
setPermissions(oldPermissions =>
[...oldPermissions, oldPermission].sort(
backendModule.compareUserPermissions
)
)
}
toastAndLog(`Unable to set permissions of '${userToDelete.user_email}'`, error)
}
},
[
emails,
permissions,
willInviteNewUser,
asset.id,
organization.id,
users,
user,
rawOnSubmit,
onSuccess,
onFailure,
/* should never change */ unsetModal,
/* should never change */ backend,
/* should never change */ rawUser,
]
)
}
}
return (
<Modal className="absolute overflow-hidden bg-opacity-25 w-full h-full top-0 left-0 z-10">
<form
<Modal className="absolute overflow-hidden bg-dim w-full h-full top-0 left-0 z-10">
<div
style={{
left: position.left + window.scrollX,
top: position.top + window.scrollY,
}}
className="sticky bg-white shadow-soft rounded-lg w-64"
onSubmit={onSubmit}
className="sticky w-115.25"
onClick={mouseEvent => {
mouseEvent.stopPropagation()
}}
onContextMenu={mouseEvent => {
mouseEvent.stopPropagation()
mouseEvent.preventDefault()
}}
>
<button type="button" className="absolute right-0 m-2" onClick={unsetModal}>
<img src={CloseIcon} />
</button>
<h2 className="inline-block font-semibold m-2">
{user == null ? 'Share' : 'Update permissions'}
</h2>
<div className="mx-2 my-1">
<label htmlFor="share_with_user_email">Email</label>
</div>
<div className="mx-2">
<Autocomplete
autoFocus
multiple
maxItemsToShow={MAX_AUTOCOMPLETE_ITEMS_TO_SHOW}
disabled={rawUser != null}
inputRef={userEmailRef}
type="email"
itemNamePlural="users"
initialValue={emails[0] ?? null}
items={matchingUsers.map(matchingUser => matchingUser.email)}
onInput={newEmail => {
const lowercaseEmail = newEmail.toLowerCase()
setMatchingUsers(
users.filter(innerUser =>
innerUser.email.includes(lowercaseEmail)
)
)
<div className="absolute bg-frame-selected backdrop-blur-3xl rounded-2xl h-full w-full" />
<div className="relative flex flex-col rounded-2xl gap-2 p-2">
<div>
<h2 className="text-sm font-bold">Invite</h2>
{/* Space reserved for other tabs. */}
</div>
<form
className="flex gap-1"
onSubmit={event => {
event.preventDefault()
void doSubmit()
}}
onChange={onEmailsChange}
/>
>
<div className="flex items-center grow rounded-full border border-black-a10 gap-2 px-1">
<PermissionSelector
disabled={willInviteNewUser}
selfPermission={self.permission}
typeSelectorYOffsetPx={TYPE_SELECTOR_Y_OFFSET_PX}
action={backendModule.PermissionAction.view}
assetType={item.type}
onChange={setAction}
/>
<input
readOnly
hidden
ref={emailValidityRef}
type="email"
className="hidden"
value={email ?? ''}
/>
<Autocomplete
multiple
autoFocus
placeholder="Type usernames or emails to search or invite"
type="text"
itemsToString={items =>
items.length === 1 && items[0] != null
? items[0].email
: `${items.length} users selected`
}
values={users}
setValues={setUsers}
items={allUsers}
itemToKey={user => user.id}
itemToString={user => `${user.name} (${user.email})`}
matches={(user, text) =>
user.email.toLowerCase().includes(text.toLowerCase()) ||
user.name.toLowerCase().includes(text.toLowerCase())
}
className="grow"
inputClassName="bg-transparent leading-170 h-6 py-px"
text={email}
setText={setEmail}
/>
</div>
<button
type="submit"
disabled={
users.length === 0 ||
(email != null && emailsOfUsersWithPermission.has(email)) ||
(willInviteNewUser &&
emailValidityRef.current?.validity.valid !== true)
}
className="text-tag-text bg-invite rounded-full px-2 py-1 disabled:opacity-30"
>
<div className="h-6 py-0.5">
{willInviteNewUser ? 'Invite' : 'Share'}
</div>
</button>
</form>
<div className="overflow-auto pl-1 pr-12 max-h-80">
{editablePermissions.map(userPermissions => (
<div
key={userPermissions.user.pk}
className="flex items-center h-8"
>
<UserPermissions
asset={item}
self={self}
isOnlyOwner={isOnlyOwner}
userPermission={userPermissions}
setUserPermission={newUserPermission => {
setPermissions(oldPermissions =>
oldPermissions.map(oldUserPermission =>
oldUserPermission.user.pk ===
newUserPermission.user.pk
? newUserPermission
: oldUserPermission
)
)
if (newUserPermission.user.pk === self.user.pk) {
// This must run only after the permissions have
// been updated through `setItem`.
setTimeout(() => {
unsetModal()
}, 0)
}
}}
doDelete={userToDelete => {
if (userToDelete.pk === self.user.pk) {
unsetModal()
}
void doDelete(userToDelete)
}}
/>
</div>
))}
</div>
</div>
{!willInviteNewUser && (
<>
<div className="mx-2 my-1">Permission</div>
<PermissionSelector
className="m-1"
initialPermissions={initialPermissions}
onChange={setPermissions}
/>
</>
)}
<input
type="submit"
disabled={!canSubmit}
{...(errorMessage != null ? { title: errorMessage } : {})}
className={`inline-block text-white rounded-full px-4 py-1 m-2 ${
ACTION_CSS_CLASS[action]
} ${canSubmit ? 'hover:cursor-pointer' : 'opacity-50'}`}
value={SUBMIT_BUTTON_TEXT[action]}
/>
</form>
</div>
</Modal>
)
}

View File

@ -10,28 +10,33 @@ import * as modalProvider from '../../providers/modal'
/** Props for a {@link Modal}. */
export interface ModalProps extends React.PropsWithChildren {
centered?: boolean
style?: React.CSSProperties
className?: string
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void
}
/** A fullscreen modal with content at the center.
* The background is fully opaque by default;
* background transparency can be enabled with Tailwind's `bg-opacity` classes,
* like `className="bg-opacity-50"` */
/** A fullscreen modal with content at the center. The background is fully opaque by default;
* background transparency can be enabled with Tailwind's `bg-opacity` classes, like
* `className="bg-opacity-50"`. */
export default function Modal(props: ModalProps) {
const { children, centered = false, className } = props
const { children, centered = false, style, className, onClick } = props
const { unsetModal } = modalProvider.useSetModal()
return (
<div
style={style}
className={`inset-0 bg-primary z-10 ${
centered ? 'fixed w-screen h-screen grid place-items-center ' : ''
}${className ?? ''}`}
onClick={event => {
if (event.currentTarget === event.target && getSelection()?.type !== 'Range') {
event.stopPropagation()
unsetModal()
}
}}
onClick={
onClick ??
(event => {
if (event.currentTarget === event.target && getSelection()?.type !== 'Range') {
event.stopPropagation()
unsetModal()
}
})
}
>
{children}
</div>

View File

@ -2,109 +2,7 @@
import * as React from 'react'
import * as backend from '../backend'
// =============
// === Types ===
// =============
/** Type of permission. This determines what kind of border is displayed. */
export enum Permission {
owner = 'owner',
admin = 'admin',
edit = 'edit',
read = 'read',
view = 'view',
}
/** Properties common to all permissions. */
interface BasePermissions<T extends Permission> {
type: T
}
/** Owner permissions for an asset. */
interface OwnerPermissions extends BasePermissions<Permission.owner> {}
/** Admin permissions for an asset. */
interface AdminPermissions extends BasePermissions<Permission.admin> {}
/** Editor permissions for an asset. */
interface EditPermissions extends BasePermissions<Permission.edit> {}
/** Reader permissions for an asset. */
interface ReadPermissions extends BasePermissions<Permission.read> {
execute: boolean
docs: boolean
}
/** Viewer permissions for an asset. */
interface ViewPermissions extends BasePermissions<Permission.view> {
execute: boolean
docs: boolean
}
/** Detailed permission information. This is used to draw the border. */
export type Permissions =
| AdminPermissions
| EditPermissions
| OwnerPermissions
| ReadPermissions
| ViewPermissions
// =================
// === Constants ===
// =================
/** CSS classes for each permission. */
export const PERMISSION_CLASS_NAME: Record<Permission, string> = {
[Permission.owner]: 'text-tag-text bg-permission-owner',
[Permission.admin]: 'text-tag-text bg-permission-admin',
[Permission.edit]: 'text-tag-text bg-permission-edit',
[Permission.read]: 'text-tag-text bg-permission-read',
[Permission.view]: 'text-tag-text-2 bg-permission-view',
} as const
/** Precedences for each permission. A lower number means a higher priority. */
const PERMISSION_PRECEDENCE: Record<Permission, number> = {
// These are not magic numbers - they are just a sequence of numbers.
/* eslint-disable @typescript-eslint/no-magic-numbers */
[Permission.owner]: 0,
[Permission.admin]: 1,
[Permission.edit]: 2,
[Permission.read]: 3,
[Permission.view]: 4,
/* eslint-enable @typescript-eslint/no-magic-numbers */
}
/** The corresponding `Permissions` for each backend `PermissionAction`. */
export const PERMISSION: Record<backend.PermissionAction, Permissions> = {
[backend.PermissionAction.own]: { type: Permission.owner },
[backend.PermissionAction.execute]: {
type: Permission.read,
execute: true,
docs: false,
},
[backend.PermissionAction.edit]: { type: Permission.edit },
[backend.PermissionAction.view]: { type: Permission.view, execute: false, docs: false },
}
// ======================
// === permissionsToX ===
// ======================
/** Converts an array of {@link backend.PermissionAction} to a {@link Permissions}. */
export function permissionActionsToPermissions(
permissions: backend.PermissionAction[]
): Permissions {
return permissions.reduce<Permissions>(
(result, action) => {
const actionResult = PERMISSION[action]
return PERMISSION_PRECEDENCE[actionResult.type] <= PERMISSION_PRECEDENCE[result.type]
? actionResult
: result
},
{ type: Permission.view, execute: false, docs: false }
)
}
import * as permissionsModule from '../permissions'
// =================
// === Component ===
@ -112,7 +10,7 @@ export function permissionActionsToPermissions(
/** Props for a {@link PermissionDisplay}. */
export interface PermissionDisplayProps extends React.PropsWithChildren {
permissions: Permissions
action: backend.PermissionAction
className?: string
onClick?: React.MouseEventHandler<HTMLDivElement>
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>
@ -121,16 +19,17 @@ export interface PermissionDisplayProps extends React.PropsWithChildren {
/** Colored border around icons and text indicating permissions. */
export default function PermissionDisplay(props: PermissionDisplayProps) {
const { permissions, className, onClick, onMouseEnter, onMouseLeave, children } = props
const { action, className, onClick, onMouseEnter, onMouseLeave, children } = props
const permission = permissionsModule.FROM_PERMISSION_ACTION[action]
switch (permissions.type) {
case Permission.owner:
case Permission.admin:
case Permission.edit: {
switch (permission.type) {
case permissionsModule.Permission.owner:
case permissionsModule.Permission.admin:
case permissionsModule.Permission.edit: {
return (
<div
className={`${
PERMISSION_CLASS_NAME[permissions.type]
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
} inline-block rounded-full h-6 px-1.75 py-0.5 ${className ?? ''}`}
onClick={onClick}
onMouseEnter={onMouseEnter}
@ -140,8 +39,8 @@ export default function PermissionDisplay(props: PermissionDisplayProps) {
</div>
)
}
case Permission.read:
case Permission.view: {
case permissionsModule.Permission.read:
case permissionsModule.Permission.view: {
return (
<div
className={`relative inline-block rounded-full ${className ?? ''}`}
@ -149,15 +48,15 @@ export default function PermissionDisplay(props: PermissionDisplayProps) {
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{permissions.docs && (
{permission.docs && (
<div className="border-permission-docs clip-path-top border-2 rounded-full absolute w-full h-full" />
)}
{permissions.execute && (
{permission.execute && (
<div className="border-permission-exec clip-path-bottom border-2 rounded-full absolute w-full h-full" />
)}
<div
className={`${
PERMISSION_CLASS_NAME[permissions.type]
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
} rounded-full h-6 px-1.75 py-0.5 m-1`}
>
{children}

View File

@ -1,27 +1,26 @@
/** @file A horizontal selector for all possible permissions. */
/** @file A selector for all possible permissions. */
import * as React from 'react'
import * as backend from '../backend'
import * as set from '../../set'
import * as permissionsModule from '../permissions'
import PermissionDisplay, * as permissionDisplay from './permissionDisplay'
import Modal from './modal'
import PermissionTypeSelector from './permissionTypeSelector'
// =================
// === Constants ===
// =================
/** A list of all permissions, and relevant associated data. */
const PERMISSIONS = [
// `name` currently holds the same as `action`. However, it may need to change in the future,
// for features like internalization, so it is kept separate.
{ action: backend.PermissionAction.own, name: 'Own' },
{ action: backend.PermissionAction.execute, name: 'Execute' },
{ action: backend.PermissionAction.edit, name: 'Edit' },
{ action: backend.PermissionAction.view, name: 'View' },
].map(object => ({
...object,
permission: permissionDisplay.PERMISSION[object.action],
}))
/** The horizontal offset of the {@link PermissionTypeSelector} from its parent element. */
const TYPE_SELECTOR_X_OFFSET_PX = -8
/** The vertical offset of the {@link PermissionTypeSelector} from its parent element. */
const TYPE_SELECTOR_Y_OFFSET_PX = 28
/** The vertical offset of the label's clip path from its parent element. */
const LABEL_CLIP_Y_OFFSET_PX = 0.5
/** The border radius of the permission label. */
const LABEL_BORDER_RADIUS_PX = 12
/** The width of the straight section of the permission label. */
const LABEL_STRAIGHT_WIDTH_PX = 97
// ==========================
// === PermissionSelector ===
@ -29,74 +28,187 @@ const PERMISSIONS = [
/** Props for a {@link PermissionSelector}. */
export interface PermissionSelectorProps {
showDelete?: boolean
disabled?: boolean
/** Overrides the vertical offset of the {@link PermissionTypeSelector}. */
typeSelectorYOffsetPx?: number
error?: string | null
selfPermission: backend.PermissionAction
/** If this prop changes, the internal state will be updated too. */
initialPermissions?: backend.PermissionAction[] | null
action: backend.PermissionAction
assetType: backend.AssetType
className?: string
permissionClassName?: string
onChange: (permissions: Set<backend.PermissionAction>) => void
onChange: (action: backend.PermissionAction) => void
doDelete?: () => void
}
/** A horizontal selector for all possible permissions. */
export default function PermissionSelector(props: PermissionSelectorProps) {
const { initialPermissions, className, permissionClassName, onChange } = props
const [permissions, setPermissions] = React.useState(() => new Set<backend.PermissionAction>())
const {
showDelete = false,
disabled = false,
typeSelectorYOffsetPx,
error,
selfPermission,
action: actionRaw,
assetType,
className,
onChange,
doDelete,
} = props
const [action, setActionRaw] = React.useState(actionRaw)
const [TheChild, setTheChild] = React.useState<(() => JSX.Element) | null>()
const permission = permissionsModule.FROM_PERMISSION_ACTION[action]
React.useEffect(() => {
if (initialPermissions != null) {
const initialPermissionsSet = new Set(initialPermissions)
setPermissions(initialPermissionsSet)
onChange(initialPermissionsSet)
const setAction = (newAction: backend.PermissionAction) => {
setActionRaw(newAction)
onChange(newAction)
}
const doShowPermissionTypeSelector = (event: React.SyntheticEvent<HTMLElement>) => {
const position = event.currentTarget.getBoundingClientRect()
const originalLeft = position.left + window.scrollX
const originalTop = position.top + window.scrollY
const left = originalLeft + TYPE_SELECTOR_X_OFFSET_PX
const top = originalTop + (typeSelectorYOffsetPx ?? TYPE_SELECTOR_Y_OFFSET_PX)
// The border radius of the label. This is half of the label's height.
const r = LABEL_BORDER_RADIUS_PX
const clipPath =
// A rectangle covering the entire screen
'path(evenodd, "M0 0L3840 0 3840 2160 0 2160Z' +
// Move to top left of label
`M${originalLeft + LABEL_BORDER_RADIUS_PX} ${originalTop + LABEL_CLIP_Y_OFFSET_PX}` +
// Top straight edge of label
`h${LABEL_STRAIGHT_WIDTH_PX}` +
// Right semicircle of label
`a${r} ${r} 0 0 1 0 ${r * 2}` +
// Bottom straight edge of label
`h-${LABEL_STRAIGHT_WIDTH_PX}` +
// Left semicircle of label
`a${r} ${r} 0 0 1 0 -${r * 2}Z")`
setTheChild(oldTheChild =>
oldTheChild != null
? null
: function Child() {
return (
<Modal
className="fixed w-full h-full z-10"
onClick={() => {
setTheChild(null)
}}
>
<div style={{ clipPath }} className="absolute bg-dim w-full h-full" />
<PermissionTypeSelector
showDelete={showDelete}
type={permission.type}
assetType={assetType}
selfPermission={selfPermission}
style={{ left, top }}
onChange={type => {
setTheChild(null)
if (type === permissionsModule.Permission.delete) {
doDelete?.()
} else {
setAction(
permissionsModule.TYPE_TO_PERMISSION_ACTION[type]
)
}
}}
/>
</Modal>
)
}
)
}
let permissionDisplay: JSX.Element
switch (permission.type) {
case permissionsModule.Permission.read:
case permissionsModule.Permission.view: {
permissionDisplay = (
<div className="flex gap-px w-30.25">
<button
type="button"
disabled={disabled}
{...(disabled && error != null ? { title: error } : {})}
className={`${
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
} grow rounded-l-full h-6 px-1.75 py-0.5 disabled:opacity-30`}
onClick={doShowPermissionTypeSelector}
>
{permission.type}
</button>
<button
type="button"
disabled={disabled}
{...(disabled && error != null ? { title: error } : {})}
className={`${
permissionsModule.DOCS_CLASS_NAME
} grow h-6 px-1.75 py-0.5 disabled:opacity-30 ${
permission.docs ? '' : 'opacity-30'
}`}
onClick={event => {
event.stopPropagation()
setAction(
permissionsModule.toPermissionAction({
type: permission.type,
execute: false,
docs: !permission.docs,
})
)
}}
>
docs
</button>
<button
type="button"
disabled={disabled}
{...(disabled && error != null ? { title: error } : {})}
className={`${
permissionsModule.EXEC_CLASS_NAME
} grow rounded-r-full h-6 px-1.75 py-0.5 disabled:opacity-30 ${
permission.execute ? '' : 'opacity-30'
}`}
onClick={event => {
event.stopPropagation()
setAction(
permissionsModule.toPermissionAction({
type: permission.type,
execute: !permission.execute,
docs: false,
})
)
}}
>
exec
</button>
</div>
)
break
}
// `onChange` is NOT a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialPermissions])
default: {
permissionDisplay = (
<button
type="button"
disabled={disabled}
{...(disabled && error != null ? { title: error } : {})}
className={`${
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
} rounded-full h-6 w-30.25 disabled:opacity-30`}
onClick={doShowPermissionTypeSelector}
>
{permission.type}
</button>
)
break
}
}
return (
<div className={`flex justify-items-center ${className ?? ''}`}>
{PERMISSIONS.map(object => {
const { name, action, permission } = object
return (
<div className="flex flex-1" key={action}>
<label
htmlFor={`share_with_permission_${action.toLowerCase()}`}
className="m-auto"
>
<PermissionDisplay
permissions={permission}
className={`cursor-pointer ${
permissions.has(action) ? '' : 'opacity-50'
} ${permissionClassName ?? ''}`}
>
{name}
</PermissionDisplay>
</label>
<input
type="checkbox"
checked={permissions.has(action)}
id={`share_with_permission_${action.toLowerCase()}`}
name="share_with_permission_input"
className="w-0 h-0"
onChange={event => {
const element = event.currentTarget
let newPermissions: Set<backend.PermissionAction>
if (action === backend.PermissionAction.own) {
newPermissions = new Set(element.checked ? [action] : [])
} else {
newPermissions = set.withPresence(
permissions,
action,
element.checked
)
newPermissions.delete(backend.PermissionAction.own)
}
setPermissions(newPermissions)
onChange(newPermissions)
}}
/>
</div>
)
})}
<div className={className}>
{permissionDisplay}
{TheChild && <TheChild />}
</div>
)
}

View File

@ -0,0 +1,143 @@
/** @file A selector for all possible permission types. */
import * as React from 'react'
import * as backend from '../backend'
import * as permissions from '../permissions'
// =================
// === Constants ===
// =================
const CAPITALIZED_ASSET_TYPE: Record<backend.AssetType, string> = {
[backend.AssetType.directory]: 'Folder',
[backend.AssetType.project]: 'Project',
[backend.AssetType.file]: 'File',
[backend.AssetType.secret]: 'Secret',
// These assets should never be visible, since they don't have columns.
[backend.AssetType.specialEmpty]: 'Empty asset',
[backend.AssetType.specialLoading]: 'Loading asset',
} as const
/** Data needed to display a single permission type. */
interface PermissionTypeData {
type: permissions.Permission
previous: permissions.Permission | null
description: (type: backend.AssetType) => string
}
/** Data needed to display each permission type. */
const PERMISSION_TYPE_DATA: PermissionTypeData[] = [
{
type: permissions.Permission.view,
previous: null,
description: type =>
CAPITALIZED_ASSET_TYPE[type] +
` visibility only. Optionally, edit docs${
type === backend.AssetType.project ? ' and execute project' : ''
}.`,
},
{
type: permissions.Permission.read,
previous: permissions.Permission.view,
description: type => CAPITALIZED_ASSET_TYPE[type] + ' content reading.',
},
{
type: permissions.Permission.edit,
previous: permissions.Permission.read,
description: type => CAPITALIZED_ASSET_TYPE[type] + ' editing.',
},
{
type: permissions.Permission.admin,
previous: permissions.Permission.edit,
description: () => 'Sharing management.',
},
{
type: permissions.Permission.owner,
previous: permissions.Permission.admin,
description: type => CAPITALIZED_ASSET_TYPE[type] + ' removal permission.',
},
{
type: permissions.Permission.delete,
previous: null,
description: () => 'Remove all permissions from this user.',
},
]
// ==============================
// === PermissionTypeSelector ===
// ==============================
/** Props for a {@link PermissionTypeSelector}. */
export interface PermissionTypeSelectorProps {
showDelete?: boolean
selfPermission: backend.PermissionAction
type: permissions.Permission
assetType: backend.AssetType
style?: React.CSSProperties
onChange: (permission: permissions.Permission) => void
}
/** A selector for all possible permission types. */
export default function PermissionTypeSelector(props: PermissionTypeSelectorProps) {
const { showDelete = false, selfPermission, type, assetType, style, onChange } = props
return (
<div
style={style}
className="sticky pointer-events-auto w-min"
onClick={event => {
event.stopPropagation()
}}
>
<div className="absolute bg-frame-selected rounded-2xl backdrop-blur-3xl w-full h-full" />
<div className="relative flex flex-col w-112.5 p-1">
{PERMISSION_TYPE_DATA.filter(
data =>
(showDelete ? true : data.type !== permissions.Permission.delete) &&
(selfPermission === backend.PermissionAction.own
? true
: data.type !== permissions.Permission.owner)
).map(data => (
<button
key={data.type}
type="button"
disabled={type === data.type}
className={`flex items-center rounded-full gap-2 h-8 px-1 ${
type === data.type ? 'bg-black-a5' : ''
}`}
onClick={() => {
onChange(data.type)
}}
>
<div
className={`rounded-full w-13 h-5 my-1 py-0.5 ${
permissions.PERMISSION_CLASS_NAME[data.type]
}`}
>
{data.type}
</div>
<span className="font-normal leading-170 h-6.5 pt-1">
<span className="h-5.5 py-px">=</span>
</span>
{data.previous != null && (
<>
<div
className={`text-center rounded-full w-13 h-5 my-1 py-0.5 ${
permissions.PERMISSION_CLASS_NAME[data.previous]
}`}
>
{data.previous}
</div>
<span className="font-normal leading-170 h-6.5 pt-1">
<span className="h-5.5 py-px">+</span>
</span>
</>
)}
<div className="leading-170 h-6.5 pt-1">
<span className="h-5.5 py-px">{data.description(assetType)}</span>
</div>
</button>
))}
</div>
</div>
)
}

View File

@ -208,7 +208,8 @@ export default function ProjectIcon(props: ProjectIconProps) {
case assetEventModule.AssetEventType.uploadFiles:
case assetEventModule.AssetEventType.createSecret:
case assetEventModule.AssetEventType.deleteMultiple:
case assetEventModule.AssetEventType.downloadSelected: {
case assetEventModule.AssetEventType.downloadSelected:
case assetEventModule.AssetEventType.removeSelf: {
// Ignored. Any missing project-related events should be handled by
// `ProjectNameColumn`. `deleteMultiple` and `downloadSelected` are handled by
// `AssetRow`.

View File

@ -72,7 +72,8 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.deleteMultiple:
case assetEventModule.AssetEventType.downloadSelected: {
case assetEventModule.AssetEventType.downloadSelected:
case assetEventModule.AssetEventType.removeSelf: {
// Ignored. Any missing project-related events should be handled by `ProjectIcon`.
// `deleteMultiple` and `downloadSelected` are handled by `AssetRow`.
break

View File

@ -40,7 +40,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
// TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
// context menu entry should be re-added.
// Backend implementation is tracked here: https://github.com/enso-org/cloud-v2/issues/505.
const doRename = async (/* _newName: string */) => {
const doRename = async () => {
await Promise.resolve(null)
}
@ -52,7 +52,8 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.deleteMultiple:
case assetEventModule.AssetEventType.downloadSelected: {
case assetEventModule.AssetEventType.downloadSelected:
case assetEventModule.AssetEventType.removeSelf: {
// Ignored. These events should all be unrelated to secrets.
// `deleteMultiple` and `downloadSelected` are handled by `AssetRow`.
break

View File

@ -186,7 +186,7 @@ export default function Table<T, State = never, RowState = never, Key extends st
return (
<th
key={column.id}
className={`text-vs font-semibold ${column.className ?? ''}`}
className={`text-sm font-semibold ${column.className ?? ''}`}
>
<Heading
// @ts-expect-error The following line is safe; the type error occurs

View File

@ -14,7 +14,7 @@ interface StateProp<State> {
state: State
}
/** `tablerowState` and `setTableRowState` */
/** `tablerowState` and `setTableRowState`. */
interface InternalTableRowStateProps<TableRowState> {
rowState: TableRowState
setRowState: React.Dispatch<React.SetStateAction<TableRowState>>
@ -153,8 +153,8 @@ export default function TableRow<T, State = never, RowState = never, Key extends
setSelected={setSelected}
/** This is SAFE, as the type is defined such that they MUST be
* present if it is specified as a generic parameter.
* See the type definitions of {@link TableRowProps} and {@link TableProps}.
*/
* See the type definitions of {@link TableRowProps} and
* {@link TableProps}. */
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
state={state!}
rowState={rowState}

View File

@ -57,7 +57,7 @@ export default function TopBar(props: TopBarProps) {
<div className="grow" />
{page !== pageSwitcher.Page.editor && (
<>
<div className="search-bar absolute flex items-center text-primary bg-frame-bg rounded-full -translate-x-1/2 gap-2.5 left-1/2 h-8 w-98.25 px-2">
<div className="search-bar absolute flex items-center text-primary bg-frame rounded-full -translate-x-1/2 gap-2.5 left-1/2 h-8 w-98.25 px-2">
<label htmlFor="search">
<img src={FindIcon} className="opacity-80" />
</label>

View File

@ -25,7 +25,7 @@ export default function UserBar(props: UserBarProps) {
const { isHelpChatOpen, setIsHelpChatOpen, onSignOut } = props
const { updateModal } = modalProvider.useSetModal()
return (
<div className="flex shrink-0 items-center bg-frame-bg rounded-full gap-3 h-8 pl-2 pr-0.75 cursor-default pointer-events-auto">
<div className="flex shrink-0 items-center bg-frame rounded-full gap-3 h-8 pl-2 pr-0.75 cursor-default pointer-events-auto">
<Button
active={isHelpChatOpen}
image={ChatIcon}

View File

@ -0,0 +1,85 @@
/** @file A user and their permissions for a specific asset. */
import * as React from 'react'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as hooks from '../../hooks'
import PermissionSelector from './permissionSelector'
/** Props for a {@link UserPermissions}. */
export interface UserPermissionsProps {
asset: backendModule.Asset
self: backendModule.UserPermission
isOnlyOwner: boolean
userPermission: backendModule.UserPermission
setUserPermission: (userPermissions: backendModule.UserPermission) => void
doDelete: (user: backendModule.User) => void
}
/** A user and their permissions for a specific asset. */
export default function UserPermissions(props: UserPermissionsProps) {
const {
asset,
self,
isOnlyOwner,
userPermission: initialUserPermission,
setUserPermission: outerSetUserPermission,
doDelete,
} = props
const { backend } = backendProvider.useBackend()
const toastAndLog = hooks.useToastAndLog()
const [userPermissions, setUserPermissions] = React.useState(initialUserPermission)
React.useEffect(() => {
setUserPermissions(initialUserPermission)
}, [initialUserPermission])
const doSetUserPermission = async (newUserPermissions: backendModule.UserPermission) => {
try {
setUserPermissions(newUserPermissions)
outerSetUserPermission(newUserPermissions)
await backend.createPermission({
userSubjects: [newUserPermissions.user.pk],
resourceId: asset.id,
action: newUserPermissions.permission,
})
} catch (error) {
setUserPermissions(userPermissions)
outerSetUserPermission(userPermissions)
toastAndLog(
`Unable to set permissions of '${newUserPermissions.user.user_email}'`,
error
)
}
}
return (
<div className="flex gap-3 items-center">
<PermissionSelector
showDelete
disabled={isOnlyOwner && userPermissions.user.pk === self.user.pk}
error={
isOnlyOwner
? `This ${
backendModule.ASSET_TYPE_NAME[asset.type]
} must have at least one owner.`
: null
}
selfPermission={self.permission}
action={userPermissions.permission}
assetType={asset.type}
onChange={async permissions => {
await doSetUserPermission({
...userPermissions,
permission: permissions,
})
}}
doDelete={() => {
doDelete(userPermissions.user)
}}
/>
<span className="leading-170 h-6 py-px">{userPermissions.user.user_name}</span>
</div>
)
}

View File

@ -1,4 +1,4 @@
/** @file Utilities for manipulating and displaying dates and times */
/** @file Utilities for manipulating and displaying dates and times. */
import * as newtype from '../newtype'
// =================
@ -19,7 +19,7 @@ export type Rfc3339DateTime = newtype.Newtype<string, 'Rfc3339DateTime'>
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const Rfc3339DateTime = newtype.newtypeConstructor<Rfc3339DateTime>()
/** Formats date time into the preferred format: `YYYY-MM-DD, hh:mm`. */
/** Format a {@link Date} into the preferred format: `YYYY-MM-DD, hh:mm`. */
export function formatDateTime(date: Date) {
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
@ -29,8 +29,7 @@ export function formatDateTime(date: Date) {
return `${year}-${month}-${dayOfMonth}, ${hour}:${minute}`
}
// TODO[sb]: Is this DD/MM/YYYY or MM/DD/YYYY?
/** Formats date time into the preferred chat-frienly format: `DD/MM/YYYY, hh:mm PM`. */
/** Format a {@link Date} into the preferred chat-frienly format: `DD/MM/YYYY, hh:mm PM`. */
export function formatDateTimeChatFriendly(date: Date) {
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
@ -46,7 +45,7 @@ export function formatDateTimeChatFriendly(date: Date) {
return `${dayOfMonth}/${month}/${year} ${hour}:${minute} ${amOrPm}`
}
/** Formats a {@link Date} as a {@link Rfc3339DateTime} */
/** Format a {@link Date} as a {@link Rfc3339DateTime}. */
export function toRfc3339(date: Date) {
return Rfc3339DateTime(date.toISOString())
}

View File

@ -26,6 +26,7 @@ export enum AssetEventType {
cancelOpeningAllProjects = 'cancel-opening-all-projects',
deleteMultiple = 'delete-multiple',
downloadSelected = 'download-selected',
removeSelf = 'remove-self',
}
/** Properties common to all asset state change events. */
@ -43,6 +44,7 @@ interface AssetEvents {
cancelOpeningAllProjects: AssetCancelOpeningAllProjectsEvent
deleteMultiple: AssetDeleteMultipleEvent
downloadSelected: AssetDownloadSelectedEvent
removeSelf: AssetRemoveSelfEvent
}
/** A type to ensure that {@link AssetEvents} contains every {@link AssetLEventType}. */
@ -96,5 +98,10 @@ export interface AssetDeleteMultipleEvent extends AssetBaseEvent<AssetEventType.
export interface AssetDownloadSelectedEvent
extends AssetBaseEvent<AssetEventType.downloadSelected> {}
/** A signal to remove the current user's permissions for an asset.. */
export interface AssetRemoveSelfEvent extends AssetBaseEvent<AssetEventType.removeSelf> {
id: backendModule.AssetId
}
/** Every possible type of asset event. */
export type AssetEvent = AssetEvents[keyof AssetEvents]

View File

@ -1,23 +0,0 @@
/** @file Utilities for working with permissions. */
import * as backend from './backend'
/** Returns an array containing the owner permission if `owner` is not `null`;
* else returns an empty array (`[]`). */
export function tryGetSingletonOwnerPermission(owner: backend.UserOrOrganization | null) {
return owner != null
? [
{
user: {
// The names are defined by the backend and cannot be changed.
/* eslint-disable @typescript-eslint/naming-convention */
pk: backend.Subject(''),
organization_id: owner.id,
user_email: owner.email,
user_name: owner.name,
/* eslint-enable @typescript-eslint/naming-convention */
},
permission: backend.PermissionAction.own,
},
]
: []
}

View File

@ -0,0 +1,221 @@
/** @file Utilities for working with permissions. */
import * as backend from './backend'
/** This file MUST be `.tsx` even though it does not contain JSX, in order for Tailwind to include
* the classes in this file. */
// ==================
// === Permission ===
// ==================
/** Type of permission. This determines what kind of border is displayed. */
export enum Permission {
owner = 'owner',
admin = 'admin',
edit = 'edit',
read = 'read',
view = 'view',
delete = 'delete',
}
/** CSS classes for each permission. */
export const PERMISSION_CLASS_NAME: Readonly<Record<Permission, string>> = {
[Permission.owner]: 'text-tag-text bg-permission-owner',
[Permission.admin]: 'text-tag-text bg-permission-admin',
[Permission.edit]: 'text-tag-text bg-permission-edit',
[Permission.read]: 'text-tag-text bg-permission-read',
[Permission.view]: 'text-tag-text-2 bg-permission-view',
[Permission.delete]: 'text-tag-text bg-delete',
} as const
/** Precedences for each permission. A lower number means a higher priority. */
export const PERMISSION_PRECEDENCE: Readonly<Record<Permission, number>> = {
// These are not magic numbers - they are just a sequence of numbers.
/* eslint-disable @typescript-eslint/no-magic-numbers */
[Permission.owner]: 0,
[Permission.admin]: 1,
[Permission.edit]: 2,
[Permission.read]: 3,
[Permission.view]: 4,
[Permission.delete]: 1000,
/* eslint-enable @typescript-eslint/no-magic-numbers */
}
/** Precedences for each permission action. A lower number means a higher priority. */
export const PERMISSION_ACTION_PRECEDENCE: Readonly<Record<backend.PermissionAction, number>> = {
// These are not magic numbers - they are just a sequence of numbers.
/* eslint-disable @typescript-eslint/no-magic-numbers */
[backend.PermissionAction.own]: 0,
[backend.PermissionAction.admin]: 1,
[backend.PermissionAction.edit]: 2,
[backend.PermissionAction.read]: 3,
[backend.PermissionAction.readAndDocs]: 4,
[backend.PermissionAction.readAndExec]: 5,
[backend.PermissionAction.view]: 6,
[backend.PermissionAction.viewAndDocs]: 7,
[backend.PermissionAction.viewAndExec]: 8,
/* eslint-enable @typescript-eslint/no-magic-numbers */
}
/** CSS classes for the docs permission. */
export const DOCS_CLASS_NAME = 'text-tag-text bg-permission-docs'
/** CSS classes for the execute permission. */
export const EXEC_CLASS_NAME = 'text-tag-text bg-permission-exec'
/** The corresponding {@link Permissions} for each {@link backend.PermissionAction}. */
export const FROM_PERMISSION_ACTION: Readonly<
Record<backend.PermissionAction, Readonly<Permissions>>
> = {
[backend.PermissionAction.own]: { type: Permission.owner },
[backend.PermissionAction.admin]: { type: Permission.admin },
[backend.PermissionAction.edit]: { type: Permission.edit },
[backend.PermissionAction.read]: {
type: Permission.read,
execute: false,
docs: false,
},
[backend.PermissionAction.readAndDocs]: {
type: Permission.read,
execute: false,
docs: true,
},
[backend.PermissionAction.readAndExec]: {
type: Permission.read,
execute: true,
docs: false,
},
[backend.PermissionAction.view]: {
type: Permission.view,
execute: false,
docs: false,
},
[backend.PermissionAction.viewAndDocs]: {
type: Permission.view,
execute: false,
docs: true,
},
[backend.PermissionAction.viewAndExec]: {
type: Permission.view,
execute: true,
docs: false,
},
}
/** The corresponding {@link backend.PermissionAction} for each {@link Permission}.
* Assumes no docs sub-permission and no execute sub-permission. */
export const TYPE_TO_PERMISSION_ACTION: Readonly<Record<Permission, backend.PermissionAction>> = {
[Permission.owner]: backend.PermissionAction.own,
[Permission.admin]: backend.PermissionAction.admin,
[Permission.edit]: backend.PermissionAction.edit,
[Permission.read]: backend.PermissionAction.read,
[Permission.view]: backend.PermissionAction.view,
// SHould never happen, but provide a fallback just in case.
[Permission.delete]: backend.PermissionAction.view,
}
/** The equivalent backend `PermissionAction` for a `Permissions`. */
export function toPermissionAction(permissions: Permissions): backend.PermissionAction {
switch (permissions.type) {
case Permission.owner: {
return backend.PermissionAction.own
}
case Permission.admin: {
return backend.PermissionAction.admin
}
case Permission.edit: {
return backend.PermissionAction.edit
}
case Permission.read: {
return permissions.execute
? permissions.docs
? /* should never happen, but use a fallback value */
backend.PermissionAction.readAndExec
: backend.PermissionAction.readAndExec
: permissions.docs
? backend.PermissionAction.readAndDocs
: backend.PermissionAction.read
}
case Permission.view: {
return permissions.execute
? permissions.docs
? /* should never happen, but use a fallback value */
backend.PermissionAction.viewAndExec
: backend.PermissionAction.viewAndExec
: permissions.docs
? backend.PermissionAction.viewAndDocs
: backend.PermissionAction.view
}
}
}
// ===================
// === Permissions ===
// ===================
/** Properties common to all permissions. */
interface BasePermissions<T extends Permission> {
type: T
}
/** Owner permissions for an asset. */
interface OwnerPermissions extends BasePermissions<Permission.owner> {}
/** Admin permissions for an asset. */
interface AdminPermissions extends BasePermissions<Permission.admin> {}
/** Editor permissions for an asset. */
interface EditPermissions extends BasePermissions<Permission.edit> {}
/** Reader permissions for an asset. */
interface ReadPermissions extends BasePermissions<Permission.read> {
docs: boolean
execute: boolean
}
/** Viewer permissions for an asset. */
interface ViewPermissions extends BasePermissions<Permission.view> {
docs: boolean
execute: boolean
}
/** Detailed permission information. This is used to draw the border. */
export type Permissions =
| AdminPermissions
| EditPermissions
| OwnerPermissions
| ReadPermissions
| ViewPermissions
export const DEFAULT_PERMISSIONS: Readonly<Permissions> = {
type: Permission.view,
docs: false,
execute: false,
}
// ======================================
// === tryGetSingletonOwnerPermission ===
// ======================================
/** Return an array containing the owner permission if `owner` is not `null`,
* else return an empty array (`[]`). */
export function tryGetSingletonOwnerPermission(
owner: backend.UserOrOrganization | null,
user: backend.SimpleUser | null
): backend.UserPermission[] {
return owner != null
? [
{
user: {
// The names are defined by the backend and cannot be changed.
/* eslint-disable @typescript-eslint/naming-convention */
pk: user?.id ?? backend.Subject(''),
organization_id: owner.id,
user_email: owner.email,
user_name: owner.name,
/* eslint-enable @typescript-eslint/naming-convention */
},
permission: backend.PermissionAction.own,
},
]
: []
}

View File

@ -1,5 +1,5 @@
/** @file This module defines the Project Manager endpoint.
* @see The protocol spec
* @see
* https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-project-manager.md */
import * as dateTime from './dateTime'
import * as newtype from '../newtype'

View File

@ -279,7 +279,8 @@ export class RemoteBackend extends backend.Backend {
return (await response.json()).assets
.map(
asset =>
// This type assertion is safe; it is only needed to convert `type` to a newtype.
// This type assertion is safe; it is only needed to convert `type` to a
// newtype.
// eslint-disable-next-line no-restricted-syntax
({ ...asset, type: asset.id.match(/^(.+?)-/)?.[1] } as backend.AnyAsset)
)
@ -289,6 +290,10 @@ export class RemoteBackend extends backend.Backend {
? { ...asset, projectState: { type: backend.ProjectState.openInProgress } }
: asset
)
.map(asset => ({
...asset,
permissions: (asset.permissions ?? []).sort(backend.compareUserPermissions),
}))
}
}

View File

@ -46,7 +46,7 @@ export function tryGetError<T>(error: MustNotBeKnown<T>): string | null {
}
/** Like {@link tryGetMessage} but return the string representation of the value if it is not an
* {@link Error} */
* {@link Error}. */
export function getMessageOrToString<T>(error: MustNotBeKnown<T>) {
return tryGetMessage(error) ?? String(error)
}

View File

@ -1,9 +1,8 @@
@import url("https://fonts.googleapis.com/css2?family=M+PLUS+1:wght@500;600;700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=M+PLUS+1:wght@400;500;600;700&display=swap");
@import "react-toastify/dist/ReactToastify.css";
body {
margin: 0;
backdrop-filter: blur(64px);
background: url("enso-assets/background.png") 0 0, rgba(255, 255, 255, 0.8);
background-blend-mode: lighten;
}
@ -56,8 +55,7 @@ body {
::-webkit-scrollbar-thumb {
border-radius: 8px;
border: 1px solid rgba(220, 220, 220, 0.5);
background-color: rgba(190, 190, 190, 0.5);
background-color: rgba(0, 0, 0, 0.1);
}
::-webkit-scrollbar-corner {
@ -119,6 +117,10 @@ body {
display: none; /* Safari and Chrome */
}
.backface-invisible {
backface-visibility: hidden;
}
.rounded-rows > tbody > tr:nth-child(odd) {
> td {
background-color: rgba(0, 0, 0, 0.03);

View File

@ -27,17 +27,22 @@ export const theme = {
// Should be `#3e515f14`, but `bg-opacity` does not work with RGBA.
label: '#f0f1f3',
help: '#3f68ce',
invite: '#0e81d4',
cloud: '#0666be',
'frame-bg': 'rgba(255, 255, 255, 0.40)',
'frame-selected-bg': 'rgba(255, 255, 255, 0.70)',
delete: 'rgba(243, 24, 10, 0.87)',
dim: 'rgba(0, 0, 0, 0.25)',
frame: 'rgba(255, 255, 255, 0.40)',
'frame-selected': 'rgba(255, 255, 255, 0.70)',
'black-a5': 'rgba(0, 0, 0, 0.05)',
'black-a10': 'rgba(0, 0, 0, 0.10)',
'tag-text': 'rgba(255, 255, 255, 0.90)',
'tag-text-2': 'rgba(0, 0, 0, 0.60)',
'permission-owner': 'rgba(236, 2, 2, 0.70)',
'permission-admin': 'rgba(252, 60, 0, 0.70)',
'permission-edit': 'rgba(255, 138, 0, 0.90)',
'permission-read': 'rgba(152, 174, 18, 0.80)',
'permission-exec': 'rgba(236, 2, 2, 0.70)',
'permission-docs': 'rgba(91, 8, 226, 0.64)',
'permission-exec': 'rgba(236, 2, 2, 0.70)',
'permission-view': 'rgba(0, 0, 0, 0.10)',
'call-to-action': '#fa6c08',
'gray-350': '#b7bcc5',
@ -47,7 +52,7 @@ export const theme = {
},
fontSize: {
xs: '0.71875rem',
vs: '0.8125rem',
sm: '0.8125rem',
},
spacing: {
'0.75': '0.1875rem',
@ -56,15 +61,20 @@ export const theme = {
'3.25': '0.8125rem',
'4.75': '1.1875rem',
'5.5': '1.375rem',
'6.5': '1.625rem',
'9.5': '2.375rem',
'13': '3.25rem',
'18': '4.5rem',
'29': '7.25rem',
'30': '7.5rem',
'30.25': '7.5625rem',
'42': '10.5rem',
'54': '13.5rem',
'70': '17.5rem',
'83.5': '20.875rem',
'98.25': '24.5625rem',
'112.5': '28.125rem',
'115.25': '28.8125rem',
'140': '35rem',
'10lh': '10lh',
},
@ -82,6 +92,9 @@ export const theme = {
backdropBlur: {
xs: '2px',
},
lineHeight: {
'170': '170%',
},
boxShadow: {
soft: `0 0.5px 2.2px 0px #00000008, 0 1.2px 5.3px 0px #0000000b, \
0 2.3px 10px 0 #0000000e, 0 4px 18px 0 #00000011, 0 7.5px 33.4px 0 #00000014, \

View File

@ -65,6 +65,7 @@ interface AuthenticationApi {
// JSDocs here are intentionally empty as these interfaces originate from elsewhere.
declare global {
// Documentation is already inherited.
/** */
interface Window {
enso?: AppRunner & Enso
@ -72,8 +73,15 @@ declare global {
authenticationApi: AuthenticationApi
}
// Documentation is already inherited.
/** */
interface Object {
/** Log self and return self. Only available in development mode. */
$d$: <T>(this: T, message?: string) => T
}
namespace NodeJS {
/** */
/** Environment variables. */
interface ProcessEnv {
/* eslint-disable @typescript-eslint/naming-convention */
APPLEID: string
@ -92,6 +100,7 @@ declare global {
// eslint-disable-next-line no-restricted-syntax
const REDIRECT_OVERRIDE: string | undefined
/* eslint-disable @typescript-eslint/naming-convention */
/** Only exists in development mode. */
// This is a function.
// eslint-disable-next-line no-restricted-syntax
const assert: (invariant: boolean, message: string) => void