mirror of
https://github.com/enso-org/enso.git
synced 2024-11-26 17:06:48 +03:00
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:
parent
585ede7741
commit
af0e738dec
@ -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,
|
||||
})
|
||||
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
@ -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.
|
||||
|
@ -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. */
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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 ===
|
||||
// ===============
|
||||
|
@ -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,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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>()
|
||||
|
||||
// =================
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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}. */
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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`.
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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())
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
@ -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,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
@ -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'
|
||||
|
@ -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),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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, \
|
||||
|
11
app/ide-desktop/lib/types/globals.d.ts
vendored
11
app/ide-desktop/lib/types/globals.d.ts
vendored
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user