mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 18:01:38 +03:00
Paywalls for User invites, sharing, and group management (#10203)
#### Tl;dr This PR adds paywalls for Invites, Sharing, Members settings, and User Group settings <details><summary>Demo Presentation</summary> <p>
This commit is contained in:
parent
114b3a5c5e
commit
c5853e0ffc
@ -77,9 +77,9 @@ import AboutModal from '#/modals/AboutModal'
|
||||
import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal'
|
||||
import * as termsOfServiceModal from '#/modals/TermsOfServiceModal'
|
||||
|
||||
import type Backend from '#/services/Backend'
|
||||
import LocalBackend from '#/services/LocalBackend'
|
||||
import * as projectManager from '#/services/ProjectManager'
|
||||
import type RemoteBackend from '#/services/RemoteBackend'
|
||||
|
||||
import * as appBaseUrl from '#/utilities/appBaseUrl'
|
||||
import * as eventModule from '#/utilities/event'
|
||||
@ -223,7 +223,7 @@ function AppRouter(props: AppRouterProps) {
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const navigator2D = navigator2DProvider.useNavigator2D()
|
||||
const [remoteBackend, setRemoteBackend] = React.useState<Backend | null>(null)
|
||||
const [remoteBackend, setRemoteBackend] = React.useState<RemoteBackend | null>(null)
|
||||
const [localBackend] = React.useState(() =>
|
||||
projectManagerUrl != null && projectManagerRootDirectory != null
|
||||
? new LocalBackend(projectManagerUrl, projectManagerRootDirectory)
|
||||
@ -456,6 +456,11 @@ function AppRouter(props: AppRouterProps) {
|
||||
)
|
||||
|
||||
let result = routes
|
||||
|
||||
if (detect.IS_DEV_MODE) {
|
||||
result = <paywall.PaywallDevtools>{result}</paywall.PaywallDevtools>
|
||||
}
|
||||
|
||||
result = <InputBindingsProvider inputBindings={inputBindings}>{result}</InputBindingsProvider>
|
||||
result = (
|
||||
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
|
||||
@ -489,9 +494,6 @@ function AppRouter(props: AppRouterProps) {
|
||||
</SessionProvider>
|
||||
)
|
||||
result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
|
||||
if (detect.IS_DEV_MODE) {
|
||||
result = <paywall.PaywallDevtools>{result}</paywall.PaywallDevtools>
|
||||
}
|
||||
result = (
|
||||
<rootComponent.Root navigate={navigate} portalRoot={portalRoot}>
|
||||
{result}
|
||||
|
@ -162,7 +162,7 @@ export const BUTTON_STYLES = twv.tv({
|
||||
variant: {
|
||||
custom: 'focus-visible:outline-offset-2',
|
||||
link: {
|
||||
base: 'inline-flex px-0 py-0 rounded-sm text-primary/50 underline hover:text-primary border-none',
|
||||
base: 'inline-block px-0 py-0 rounded-sm text-primary/50 underline hover:text-primary border-none',
|
||||
icon: 'h-[1.25cap] mt-[0.25cap]',
|
||||
},
|
||||
primary: 'bg-primary text-white hover:bg-primary/70',
|
||||
@ -171,7 +171,7 @@ export const BUTTON_STYLES = twv.tv({
|
||||
delete:
|
||||
'bg-danger/80 hover:bg-danger text-white focus-visible:outline-danger focus-visible:bg-danger',
|
||||
icon: {
|
||||
base: 'opacity-80 hover:opacity-100 focus-visible:opacity-100 text-primary',
|
||||
base: 'border-0 opacity-80 hover:opacity-100 focus-visible:opacity-100 text-primary',
|
||||
wrapper: 'w-full h-full',
|
||||
content: 'w-full h-full',
|
||||
extraClickZone: 'w-full h-full',
|
||||
|
@ -204,6 +204,7 @@ export function Dialog(props: DialogProps) {
|
||||
slot="title"
|
||||
level={2}
|
||||
className={dialogSlots.heading()}
|
||||
weight="semibold"
|
||||
>
|
||||
{title}
|
||||
</ariaComponents.Text.Heading>
|
||||
|
@ -26,7 +26,6 @@ import type * as types from './types'
|
||||
export const Form = React.forwardRef(function Form<
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: types.FormProps<Schema, TFieldValues, TTransformedValues>,
|
||||
@ -206,7 +205,6 @@ export const Form = React.forwardRef(function Form<
|
||||
}) as unknown as (<
|
||||
Schema extends components.TSchema,
|
||||
TFieldValues extends components.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: React.RefAttributes<HTMLFormElement> &
|
||||
|
@ -36,7 +36,6 @@ export interface FieldChildrenRenderProps {
|
||||
readonly isDirty: boolean
|
||||
readonly isTouched: boolean
|
||||
readonly isValidating: boolean
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
readonly error?: string | undefined
|
||||
}
|
||||
|
||||
|
@ -20,7 +20,6 @@ export interface RadioGroupProps<
|
||||
Schema extends formComponent.TSchema,
|
||||
TFieldValues extends formComponent.FieldValues<Schema>,
|
||||
TFieldName extends formComponent.FieldPath<Schema, TFieldValues>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined,
|
||||
> extends formComponent.FieldStateProps<
|
||||
Omit<aria.AriaRadioGroupProps, 'description' | 'label'>,
|
||||
@ -48,7 +47,6 @@ export const RadioGroup = React.forwardRef(function RadioGroup<
|
||||
Schema extends formComponent.TSchema,
|
||||
TFieldName extends formComponent.FieldPath<Schema, TFieldValues>,
|
||||
TFieldValues extends formComponent.FieldValues<Schema> = formComponent.FieldValues<Schema>,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined,
|
||||
>(
|
||||
props: RadioGroupProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
|
||||
|
@ -42,9 +42,9 @@ export const TEXT_STYLE = twv.tv({
|
||||
// leading should always be after the text size to make sure it is not stripped by twMerge
|
||||
variant: {
|
||||
custom: '',
|
||||
body: 'text-xs leading-[20px] before:h-[1px] after:h-[3px]',
|
||||
h1: 'text-xl leading-[29px] before:h-0.5 after:h-[5px]',
|
||||
subtitle: 'text-[13.5px] leading-[20px] before:h-[1px] after:h-[3px]',
|
||||
body: 'text-xs leading-[20px] before:h-[1px] after:h-[3px] font-medium',
|
||||
h1: 'text-xl leading-[29px] before:h-0.5 after:h-[5px] font-bold',
|
||||
subtitle: 'text-[13.5px] leading-[19px] before:h-[1px] after:h-[3px] font-bold',
|
||||
},
|
||||
weight: {
|
||||
custom: '',
|
||||
@ -88,7 +88,7 @@ export const TEXT_STYLE = twv.tv({
|
||||
all: 'select-all',
|
||||
},
|
||||
disableLineHeightCompensation: {
|
||||
true: '',
|
||||
true: 'before:hidden after:hidden before:w-0 after:w-0',
|
||||
false:
|
||||
'inline-block flex-col before:block after:block before:flex-none after:flex-none before:w-full after:w-full',
|
||||
},
|
||||
@ -105,24 +105,6 @@ export const TEXT_STYLE = twv.tv({
|
||||
disableLineHeightCompensation: false,
|
||||
textSelection: 'auto',
|
||||
},
|
||||
compoundVariants: [
|
||||
{ variant: 'h1', class: 'font-bold' },
|
||||
{
|
||||
variant: 'h1',
|
||||
disableLineHeightCompensation: true,
|
||||
class: 'before:h-[unset] after:h-[unset]',
|
||||
},
|
||||
{
|
||||
variant: 'body',
|
||||
disableLineHeightCompensation: true,
|
||||
class: 'before:h-[unset] after:h-[unset]',
|
||||
},
|
||||
{
|
||||
variant: 'subtitle',
|
||||
disableLineHeightCompensation: true,
|
||||
class: 'before:h-[unset] after:h-[unset]',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
/**
|
||||
|
@ -28,8 +28,6 @@ export interface ModalProps
|
||||
Readonly<tailwindVariants.VariantProps<typeof MODAL_VARIANTS>> {
|
||||
/** If `true`, disables `data-testid` because it will not be visible. */
|
||||
readonly hidden?: boolean
|
||||
// This can intentionally be `undefined`, in order to simplify consumers of this component.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
readonly centered?: boolean | undefined
|
||||
readonly style?: React.CSSProperties
|
||||
readonly className?: string
|
||||
|
@ -133,5 +133,9 @@ export function PaywallDevtools(props: PaywallDevtoolsProps) {
|
||||
* A hook that provides access to the paywall devtools.
|
||||
*/
|
||||
export function usePaywallDevtools() {
|
||||
return React.useContext(PaywallDevtoolsContext)
|
||||
const context = React.useContext(PaywallDevtoolsContext)
|
||||
|
||||
React.useDebugValue(context)
|
||||
|
||||
return context
|
||||
}
|
||||
|
@ -15,12 +15,7 @@ export interface SvgMaskProps {
|
||||
readonly src: string
|
||||
readonly title?: string
|
||||
readonly style?: React.CSSProperties
|
||||
// Allowing `undefined` is fine here as this prop has a fallback.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
readonly color?: string | undefined
|
||||
// Allowing `undefined` is fine here as this prop is being transparently passed through to the
|
||||
// underlying `div`.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
readonly className?: string | undefined
|
||||
}
|
||||
|
||||
|
@ -20,7 +20,6 @@ export * from '@react-stately/tooltip'
|
||||
*
|
||||
* The constraint is defaulted to `never` to make an explicit constraint mandatory. */
|
||||
export function mergeProps<Constraint extends object = never>() {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return <T extends (Partial<Constraint> | null | undefined)[]>(...args: T) =>
|
||||
aria.mergeProps(...args)
|
||||
}
|
||||
|
@ -3,6 +3,8 @@ import * as React from 'react'
|
||||
|
||||
import Plus2Icon from 'enso-assets/plus2.svg'
|
||||
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
@ -13,6 +15,7 @@ import Category from '#/layouts/CategorySwitcher/Category'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
|
||||
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
|
||||
|
||||
@ -43,6 +46,11 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
const { backend, category, dispatchAssetEvent, setQuery } = state
|
||||
const asset = item.item
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan })
|
||||
|
||||
const isUnderPaywall = isFeatureUnderPaywall('share')
|
||||
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const self = asset.permissions?.find(
|
||||
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
|
||||
@ -89,12 +97,21 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
{backendModule.getAssetPermissionName(other)}
|
||||
</PermissionDisplay>
|
||||
))}
|
||||
{managesThisAsset && (
|
||||
{isUnderPaywall && (
|
||||
<paywall.PaywallDialogButton
|
||||
feature="share"
|
||||
variant="icon"
|
||||
size="xxsmall"
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
children={false}
|
||||
/>
|
||||
)}
|
||||
{managesThisAsset && !isUnderPaywall && (
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
ref={plusButtonRef}
|
||||
className="shrink-0 rounded-full opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
||||
variant="icon"
|
||||
size="xsmall"
|
||||
icon={Plus2Icon}
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
@ -113,9 +130,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<img className="size-plus-icon" src={Plus2Icon} />
|
||||
</ariaComponents.Button>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
@ -3,12 +3,16 @@ import * as React from 'react'
|
||||
|
||||
import PeopleIcon from 'enso-assets/people.svg'
|
||||
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import Button from '#/components/styled/Button'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
|
||||
/** A heading for the "Shared with" column. */
|
||||
export default function SharedWithColumnHeading(props: column.AssetColumnHeadingProps) {
|
||||
@ -16,18 +20,36 @@ export default function SharedWithColumnHeading(props: column.AssetColumnHeading
|
||||
const { hideColumn } = state
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan })
|
||||
|
||||
const isUnderPaywall = isFeatureUnderPaywall('share')
|
||||
|
||||
return (
|
||||
<div className="flex h-table-row w-full items-center gap-icon-with-text">
|
||||
<Button
|
||||
active
|
||||
image={PeopleIcon}
|
||||
className="size-4"
|
||||
alt={getText('sharedWithColumnHide')}
|
||||
<ariaComponents.Button
|
||||
variant="icon"
|
||||
size="xsmall"
|
||||
icon={PeopleIcon}
|
||||
aria-label={getText('sharedWithColumnHide')}
|
||||
onPress={() => {
|
||||
hideColumn(columnUtils.Column.sharedWith)
|
||||
}}
|
||||
/>
|
||||
<aria.Text className="text-header">{getText('sharedWithColumnName')}</aria.Text>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<aria.Text className="text-header">{getText('sharedWithColumnName')}</aria.Text>
|
||||
|
||||
{isUnderPaywall && (
|
||||
<paywall.PaywallDialogButton
|
||||
feature="share"
|
||||
variant="icon"
|
||||
children={false}
|
||||
size="xsmall"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ import * as tailwindVariants from '#/utilities/tailwindVariants'
|
||||
// =================
|
||||
|
||||
const HORIZONTAL_MENU_BAR_VARIANTS = tailwindVariants.tv({
|
||||
base: 'flex h-row gap-drive-bar',
|
||||
base: 'flex items-center h-row gap-drive-bar',
|
||||
variants: {
|
||||
grow: { true: 'grow' },
|
||||
},
|
||||
|
@ -3,6 +3,7 @@ import * as React from 'react'
|
||||
|
||||
import * as toast from 'react-toastify'
|
||||
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
@ -21,6 +22,7 @@ import ContextMenu from '#/components/ContextMenu'
|
||||
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
||||
import ContextMenus from '#/components/ContextMenus'
|
||||
import type * as assetRow from '#/components/dashboard/AssetRow'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
import Separator from '#/components/styled/Separator'
|
||||
|
||||
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||
@ -72,6 +74,10 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
|
||||
)
|
||||
const isCloud = categoryModule.isCloud(category)
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan })
|
||||
const isUnderPaywall = isFeatureUnderPaywall('share')
|
||||
|
||||
const ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
|
||||
const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin
|
||||
const canEditThisAsset =
|
||||
@ -304,29 +310,39 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
/>
|
||||
)}
|
||||
{isCloud && <Separator hidden={hidden} />}
|
||||
|
||||
{isCloud && managesThisAsset && self != null && (
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
action="share"
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
backend={backend}
|
||||
item={asset}
|
||||
setItem={setAsset}
|
||||
self={self}
|
||||
eventTarget={eventTarget}
|
||||
doRemoveSelf={() => {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.removeSelf,
|
||||
id: asset.id,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{isUnderPaywall && (
|
||||
<paywall.ContextMenuEntry feature="share" action="share" hidden={hidden} />
|
||||
)}
|
||||
|
||||
{!isUnderPaywall && (
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
action="share"
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
backend={backend}
|
||||
item={asset}
|
||||
setItem={setAsset}
|
||||
self={self}
|
||||
eventTarget={eventTarget}
|
||||
doRemoveSelf={() => {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.removeSelf,
|
||||
id: asset.id,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isCloud && (
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
|
@ -52,6 +52,7 @@ export default function Settings(props: SettingsProps) {
|
||||
const isUserInOrganization = organization != null
|
||||
|
||||
let content: React.JSX.Element | null
|
||||
|
||||
switch (settingsTab) {
|
||||
case SettingsTab.account: {
|
||||
content = backend == null ? null : <AccountSettingsTab backend={backend} />
|
||||
@ -62,7 +63,7 @@ export default function Settings(props: SettingsProps) {
|
||||
break
|
||||
}
|
||||
case SettingsTab.members: {
|
||||
content = backend == null ? null : <MembersSettingsTab backend={backend} />
|
||||
content = <MembersSettingsTab />
|
||||
break
|
||||
}
|
||||
case SettingsTab.userGroups: {
|
||||
|
@ -44,9 +44,9 @@ export default function DeleteUserAccountSettingsSection(
|
||||
>
|
||||
<div className="flex gap-buttons">
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="button relative rounded-full bg-danger px-delete-user-account-button-x text-inversed opacity-full before:absolute before:inset-0 before:rounded-full before:transition-all hover:opacity-full before:hover:bg-primary/10"
|
||||
variant="delete"
|
||||
size="medium"
|
||||
rounded="full"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteUserModal
|
||||
@ -58,11 +58,9 @@ export default function DeleteUserAccountSettingsSection(
|
||||
)
|
||||
}}
|
||||
>
|
||||
<aria.Text className="text inline-block">
|
||||
{getText('deleteUserAccountButtonLabel')}
|
||||
</aria.Text>
|
||||
{getText('deleteUserAccountButtonLabel')}
|
||||
</ariaComponents.Button>
|
||||
<aria.Text className="text my-auto">{getText('deleteUserAccountWarning')}</aria.Text>
|
||||
<aria.Text className="text-md my-auto">{getText('deleteUserAccountWarning')}</aria.Text>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)
|
||||
|
@ -4,7 +4,10 @@ import * as React from 'react'
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import MembersSettingsTabBar from '#/layouts/Settings/MembersSettingsTabBar'
|
||||
@ -14,7 +17,9 @@ import SettingsPage from '#/components/styled/settings/SettingsPage'
|
||||
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||
|
||||
import type * as backendModule from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
import type RemoteBackend from '#/services/RemoteBackend'
|
||||
|
||||
import * as paywallSettingsLayout from './withPaywall'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -26,15 +31,13 @@ const LIST_USERS_STALE_TIME_MS = 60_000
|
||||
// === MembersSettingsTab ===
|
||||
// ==========================
|
||||
|
||||
/** Props for a {@link MembersSettingsTab}. */
|
||||
export interface MembersSettingsTabProps {
|
||||
readonly backend: Backend
|
||||
}
|
||||
|
||||
/** Settings tab for viewing and editing organization members. */
|
||||
export default function MembersSettingsTab(props: MembersSettingsTabProps) {
|
||||
const { backend } = props
|
||||
export function MembersSettingsTab() {
|
||||
const { getText } = textProvider.useText()
|
||||
const backend = backendProvider.useRemoteBackendStrict()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
|
||||
const { isFeatureUnderPaywall, getFeature } = billingHooks.usePaywall({ plan: user.plan })
|
||||
|
||||
const [{ data: members }, { data: invitations }] = reactQuery.useSuspenseQueries({
|
||||
queries: [
|
||||
@ -51,10 +54,21 @@ export default function MembersSettingsTab(props: MembersSettingsTabProps) {
|
||||
],
|
||||
})
|
||||
|
||||
const isUnderPaywall = isFeatureUnderPaywall('inviteUserFull')
|
||||
const feature = getFeature('inviteUser')
|
||||
|
||||
const seatsLeft = isUnderPaywall
|
||||
? feature.meta.maxSeats - (members.length + invitations.length)
|
||||
: null
|
||||
|
||||
return (
|
||||
<SettingsPage>
|
||||
<SettingsSection noFocusArea title={getText('members')} className="overflow-hidden">
|
||||
<MembersSettingsTabBar />
|
||||
<MembersSettingsTabBar
|
||||
seatsLeft={seatsLeft}
|
||||
seatsTotal={feature.meta.maxSeats}
|
||||
feature="inviteUserFull"
|
||||
/>
|
||||
|
||||
<table className="table-fixed self-start rounded-rows">
|
||||
<thead>
|
||||
@ -67,6 +81,7 @@ export default function MembersSettingsTab(props: MembersSettingsTabProps) {
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="select-text">
|
||||
{members.map(member => (
|
||||
<tr key={member.email} className="group h-row rounded-rows-child">
|
||||
@ -125,7 +140,7 @@ export default function MembersSettingsTab(props: MembersSettingsTabProps) {
|
||||
/** Props for the ResendInvitationButton component. */
|
||||
interface ResendInvitationButtonProps {
|
||||
readonly invitation: backendModule.Invitation
|
||||
readonly backend: Backend
|
||||
readonly backend: RemoteBackend
|
||||
}
|
||||
|
||||
/** Button for resending an invitation. */
|
||||
@ -158,7 +173,7 @@ function ResendInvitationButton(props: ResendInvitationButtonProps) {
|
||||
|
||||
/** Props for a {@link RemoveMemberButton}. */
|
||||
interface RemoveMemberButtonProps {
|
||||
readonly backend: Backend
|
||||
readonly backend: RemoteBackend
|
||||
readonly userId: backendModule.UserId
|
||||
}
|
||||
|
||||
@ -167,11 +182,9 @@ function RemoveMemberButton(props: RemoveMemberButtonProps) {
|
||||
const { backend, userId } = props
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const queryClient = reactQuery.useQueryClient()
|
||||
|
||||
const removeMutation = backendHooks.useBackendMutation(backend, 'removeUser', {
|
||||
mutationKey: [userId],
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['listUsers'] }),
|
||||
meta: { invalidates: [['listUsers']], awaitInvalidates: true },
|
||||
})
|
||||
|
||||
return (
|
||||
@ -191,7 +204,7 @@ function RemoveMemberButton(props: RemoveMemberButtonProps) {
|
||||
|
||||
/** Props for a {@link RemoveInvitationButton}. */
|
||||
interface RemoveInvitationButtonProps {
|
||||
readonly backend: Backend
|
||||
readonly backend: RemoteBackend
|
||||
readonly email: backendModule.EmailAddress
|
||||
}
|
||||
|
||||
@ -200,11 +213,10 @@ function RemoveInvitationButton(props: RemoveInvitationButtonProps) {
|
||||
const { backend, email } = props
|
||||
|
||||
const { getText } = textProvider.useText()
|
||||
const queryClient = reactQuery.useQueryClient()
|
||||
|
||||
const removeMutation = backendHooks.useBackendMutation(backend, 'resendInvitation', {
|
||||
mutationKey: [email],
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['listInvitations'] }),
|
||||
meta: { invalidates: [['listInvitations']], awaitInvalidates: true },
|
||||
})
|
||||
|
||||
return (
|
||||
@ -218,3 +230,7 @@ function RemoveInvitationButton(props: RemoveInvitationButtonProps) {
|
||||
</ariaComponents.Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default paywallSettingsLayout.withPaywall(MembersSettingsTab, {
|
||||
feature: 'inviteUser',
|
||||
})
|
||||
|
@ -1,9 +1,12 @@
|
||||
/** @file Button bar for managing organization members. */
|
||||
import * as React from 'react'
|
||||
|
||||
import type * as billingHooks from '#/hooks/billing'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as paywallComponents from '#/components/Paywall'
|
||||
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
|
||||
|
||||
import InviteUsersModal from '#/modals/InviteUsersModal'
|
||||
@ -12,19 +15,43 @@ import InviteUsersModal from '#/modals/InviteUsersModal'
|
||||
// === MembersSettingsTabBar ===
|
||||
// =============================
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface MembersSettingsTabBarProps {
|
||||
readonly seatsLeft: number | null
|
||||
readonly seatsTotal: number
|
||||
readonly feature: billingHooks.PaywallFeatureName
|
||||
}
|
||||
|
||||
/** Button bar for managing organization members. */
|
||||
export default function MembersSettingsTabBar() {
|
||||
export default function MembersSettingsTabBar(props: MembersSettingsTabBarProps) {
|
||||
const { seatsLeft, seatsTotal, feature } = props
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
return (
|
||||
<HorizontalMenuBar>
|
||||
<ariaComponents.DialogTrigger>
|
||||
<ariaComponents.Button variant="bar" rounded="full" size="small">
|
||||
<ariaComponents.Button variant="bar" rounded="full" size="medium">
|
||||
{getText('inviteMembers')}
|
||||
</ariaComponents.Button>
|
||||
|
||||
<InviteUsersModal />
|
||||
</ariaComponents.DialogTrigger>
|
||||
|
||||
{seatsLeft != null && (
|
||||
<div className="flex gap-1">
|
||||
<ariaComponents.Text>
|
||||
{seatsLeft <= 0 ? getText('noSeatsLeft') : getText('seatsLeft', seatsLeft, seatsTotal)}
|
||||
</ariaComponents.Text>
|
||||
|
||||
<paywallComponents.PaywallDialogButton
|
||||
feature={feature}
|
||||
variant="link"
|
||||
showIcon={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</HorizontalMenuBar>
|
||||
)
|
||||
}
|
||||
|
@ -4,9 +4,11 @@ import * as React from 'react'
|
||||
import * as mimeTypes from '#/data/mimeTypes'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
import * as scrollHooks from '#/hooks/scrollHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -16,6 +18,7 @@ import UserGroupUserRow from '#/layouts/Settings/UserGroupUserRow'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as paywallComponents from '#/components/Paywall'
|
||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
|
||||
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||
@ -27,6 +30,8 @@ import type Backend from '#/services/Backend'
|
||||
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
|
||||
import * as withPaywall from './withPaywall'
|
||||
|
||||
// =============================
|
||||
// === UserGroupsSettingsTab ===
|
||||
// =============================
|
||||
@ -37,10 +42,11 @@ export interface UserGroupsSettingsTabProps {
|
||||
}
|
||||
|
||||
/** Settings tab for viewing and editing organization members. */
|
||||
export default function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps) {
|
||||
function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps) {
|
||||
const { backend } = props
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const users = backendHooks.useBackendListUsers(backend)
|
||||
const userGroups = backendHooks.useBackendListUserGroupsWithUsers(backend)
|
||||
@ -54,6 +60,12 @@ export default function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps)
|
||||
)
|
||||
const isLoading = userGroups == null || users == null
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
||||
|
||||
const isUnderPaywall = isFeatureUnderPaywall('userGroupsFull')
|
||||
const userGroupsLeft = isUnderPaywall ? 1 - (userGroups?.length ?? 0) : Infinity
|
||||
const shouldDisplayPaywall = isUnderPaywall ? userGroupsLeft <= 0 : false
|
||||
|
||||
const { onScroll: onUserGroupsTableScroll, shadowClassName } =
|
||||
scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, true)
|
||||
|
||||
@ -126,20 +138,41 @@ export default function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps)
|
||||
<div className="flex h-3/5 w-settings-main-section max-w-full flex-col gap-settings-subsection lg:h-[unset] lg:min-w">
|
||||
<SettingsSection noFocusArea title={getText('userGroups')} className="overflow-hidden">
|
||||
<HorizontalMenuBar>
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
className="px-new-project-button-x flex h-row items-center rounded-full bg-frame"
|
||||
onPress={event => {
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
const position = { pageX: rect.left, pageY: rect.top }
|
||||
setModal(<NewUserGroupModal backend={backend} event={position} />)
|
||||
}}
|
||||
>
|
||||
<aria.Text className="text whitespace-nowrap font-semibold">
|
||||
{getText('newUserGroup')}
|
||||
</aria.Text>
|
||||
</ariaComponents.Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{shouldDisplayPaywall && (
|
||||
<paywallComponents.PaywallDialogButton
|
||||
feature="userGroupsFull"
|
||||
variant="bar"
|
||||
size="medium"
|
||||
rounded="full"
|
||||
iconPosition="end"
|
||||
tooltip={getText('userGroupsPaywallMessage')}
|
||||
>
|
||||
{getText('newUserGroup')}
|
||||
</paywallComponents.PaywallDialogButton>
|
||||
)}
|
||||
{!shouldDisplayPaywall && (
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="bar"
|
||||
onPress={event => {
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
const position = { pageX: rect.left, pageY: rect.top }
|
||||
setModal(<NewUserGroupModal backend={backend} event={position} />)
|
||||
}}
|
||||
>
|
||||
{getText('newUserGroup')}
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
|
||||
{isUnderPaywall && (
|
||||
<span className="text-xs">
|
||||
{userGroupsLeft <= 0
|
||||
? getText('userGroupsPaywallMessage')
|
||||
: getText('userGroupsLimitMessage', userGroupsLeft)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</HorizontalMenuBar>
|
||||
<div
|
||||
ref={rootRef}
|
||||
@ -207,9 +240,12 @@ export default function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps)
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
<SettingsSection noFocusArea title={getText('users')} className="h-2/5 lg:h-[unset]">
|
||||
<MembersTable draggable populateWithSelf backend={backend} />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withPaywall.withPaywall(UserGroupsSettingsTab, { feature: 'userGroups' })
|
||||
|
@ -24,9 +24,7 @@ export interface PaywallSettingsLayoutProps {
|
||||
readonly className?: string | undefined
|
||||
}
|
||||
|
||||
const PAYWALL_LAYOUT_STYLES = twv.tv({
|
||||
base: 'mt-1',
|
||||
})
|
||||
const PAYWALL_LAYOUT_STYLES = twv.tv({ base: 'mt-1' })
|
||||
|
||||
/**
|
||||
* A layout that shows a paywall for a feature.
|
||||
@ -46,14 +44,14 @@ export function PaywallSettingsLayout(props: PaywallSettingsLayoutProps) {
|
||||
* The paywall is shown if the user's plan does not include the feature.
|
||||
* The feature is determined by the `isFeatureUnderPaywall` hook.
|
||||
*/
|
||||
export function withPaywall<P extends Record<string, unknown>>(
|
||||
export function withPaywall<P>(
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Component: React.ComponentType<P>,
|
||||
props: PaywallSettingsLayoutProps
|
||||
) {
|
||||
const { feature, className } = props
|
||||
|
||||
return function WithPaywall(componentProps: P) {
|
||||
return function WithPaywall(componentProps: P & React.JSX.IntrinsicAttributes) {
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
||||
|
@ -6,6 +6,8 @@ import DefaultUserIcon from 'enso-assets/default_user.svg'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as billing from '#/hooks/billing'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
@ -15,6 +17,7 @@ import UserMenu from '#/layouts/UserMenu'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
import Button from '#/components/styled/Button'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
@ -50,20 +53,30 @@ export default function UserBar(props: UserBarProps) {
|
||||
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const { isFeatureUnderPaywall } = billing.usePaywall({ plan: user?.plan })
|
||||
|
||||
const self =
|
||||
user != null
|
||||
? projectAsset?.permissions?.find(
|
||||
backendModule.isUserPermissionAnd(permissions => permissions.user.userId === user.userId)
|
||||
) ?? null
|
||||
: null
|
||||
|
||||
const shouldShowShareButton =
|
||||
backend != null &&
|
||||
page === pageSwitcher.Page.editor &&
|
||||
projectAsset != null &&
|
||||
setProjectAsset != null &&
|
||||
self != null
|
||||
|
||||
const shouldShowUpgradeButton = isFeatureUnderPaywall('inviteUser')
|
||||
|
||||
const shouldShowInviteButton =
|
||||
backend != null && sessionType === authProvider.UserSessionType.full && !shouldShowShareButton
|
||||
backend != null &&
|
||||
sessionType === authProvider.UserSessionType.full &&
|
||||
!shouldShowShareButton &&
|
||||
!shouldShowUpgradeButton
|
||||
|
||||
return (
|
||||
<FocusArea active={!invisible} direction="horizontal">
|
||||
@ -84,6 +97,12 @@ export default function UserBar(props: UserBarProps) {
|
||||
}}
|
||||
/>
|
||||
|
||||
{shouldShowUpgradeButton && (
|
||||
<paywall.PaywallDialogButton feature={'inviteUser'} size="medium" variant="tertiary">
|
||||
{getText('invite')}
|
||||
</paywall.PaywallDialogButton>
|
||||
)}
|
||||
|
||||
{shouldShowInviteButton && (
|
||||
<ariaComponents.DialogTrigger>
|
||||
<ariaComponents.Button size="medium" variant="tertiary">
|
||||
@ -97,6 +116,7 @@ export default function UserBar(props: UserBarProps) {
|
||||
<ariaComponents.Button variant="primary" size="medium" href={appUtils.SUBSCRIBE_PATH}>
|
||||
{getText('upgrade')}
|
||||
</ariaComponents.Button>
|
||||
|
||||
{shouldShowShareButton && (
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
|
@ -5,13 +5,16 @@ import * as reactQuery from '@tanstack/react-query'
|
||||
import isEmail from 'validator/es/lib/isEmail'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as paywallComponents from '#/components/Paywall'
|
||||
|
||||
import type * as backendModule from '#/services/Backend'
|
||||
|
||||
@ -31,17 +34,43 @@ export interface InviteUsersFormProps {
|
||||
export function InviteUsersForm(props: InviteUsersFormProps) {
|
||||
const { onSubmitted, organizationId } = props
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { isFeatureUnderPaywall, getFeature } = billingHooks.usePaywall({ plan: user.plan })
|
||||
const [inputValue, setInputValue] = React.useState('')
|
||||
const backend = backendProvider.useRemoteBackendStrict()
|
||||
const inputRef = React.useRef<HTMLDivElement>(null)
|
||||
const formRef = React.useRef<HTMLFormElement>(null)
|
||||
const queryClient = reactQuery.useQueryClient()
|
||||
|
||||
const inviteUserMutation = backendHooks.useBackendMutation(backend, 'inviteUser', {
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['listInvitations'] })
|
||||
meta: {
|
||||
invalidates: [['listInvitations']],
|
||||
awaitInvalidates: true,
|
||||
},
|
||||
})
|
||||
|
||||
const [{ data: usersCount }, { data: invitationsCount }] = reactQuery.useSuspenseQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['listInvitations'],
|
||||
queryFn: async () => backend.listInvitations(),
|
||||
select: (invitations: backendModule.Invitation[]) => invitations.length,
|
||||
},
|
||||
{
|
||||
queryKey: ['listUsers'],
|
||||
queryFn: async () => backend.listUsers(),
|
||||
select: (users: backendModule.User[]) => users.length,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const isUnderPaywall = isFeatureUnderPaywall('inviteUserFull')
|
||||
const feature = getFeature('inviteUser')
|
||||
|
||||
const seatsLeft = isUnderPaywall
|
||||
? Math.max(feature.meta.maxSeats - (usersCount + invitationsCount), 0)
|
||||
: Infinity
|
||||
|
||||
const getEmailsFromInput = eventCallbackHooks.useEventCallback((value: string) => {
|
||||
return parserUserEmails.parseUserEmails(value)
|
||||
})
|
||||
@ -89,10 +118,14 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
|
||||
if (trimmedValue === '' || entries.length === 0) {
|
||||
return getText('emailIsRequired')
|
||||
} else {
|
||||
for (const entry of entries) {
|
||||
if (!isEmail(entry.email)) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return getText('emailIsInvalid')
|
||||
if (entries.length > seatsLeft) {
|
||||
return getText('inviteFormSeatsLeftError', entries.length - seatsLeft)
|
||||
} else {
|
||||
for (const entry of entries) {
|
||||
if (!isEmail(entry.email)) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return getText('emailIsInvalid')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,13 +175,11 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<aria.Text className="mb-2 text-sm text-primary">
|
||||
{getText('inviteFormDescription')}
|
||||
</aria.Text>
|
||||
<ariaComponents.Text className="mb-2">{getText('inviteFormDescription')}</ariaComponents.Text>
|
||||
|
||||
<ariaComponents.ResizableContentEditableInput
|
||||
ref={inputRef}
|
||||
className="mb-4"
|
||||
className="mb-2"
|
||||
name="email"
|
||||
aria-label={getText('inviteEmailFieldLabel')}
|
||||
placeholder={getText('inviteEmailFieldPlaceholder')}
|
||||
@ -168,15 +199,24 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
|
||||
{inviteUserMutation.isError && (
|
||||
<ariaComponents.Alert variant="error" className="mb-4">
|
||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||
{getText('arbitraryErrorTitle')}. {getText('arbitraryErrorSubtitle')}
|
||||
{getText('arbitraryErrorTitle')}.{' '}
|
||||
{getText('arbitraryErrorSubtitle')}
|
||||
</ariaComponents.Alert>
|
||||
)}
|
||||
|
||||
{isUnderPaywall && (
|
||||
<paywallComponents.PaywallAlert
|
||||
className="mb-4"
|
||||
feature="inviteUserFull"
|
||||
label={getText('inviteFormSeatsLeft', seatsLeft)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ariaComponents.Button
|
||||
type="submit"
|
||||
variant="tertiary"
|
||||
rounded="medium"
|
||||
size="medium"
|
||||
rounded="xlarge"
|
||||
size="large"
|
||||
loading={inviteUserMutation.isPending}
|
||||
fullWidth
|
||||
>
|
||||
|
@ -1,10 +1,12 @@
|
||||
/** @file A modal with inputs for user email and permission level. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import * as toast from 'react-toastify'
|
||||
import isEmail from 'validator/es/lib/isEmail'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
@ -17,6 +19,7 @@ import Autocomplete from '#/components/Autocomplete'
|
||||
import Permission from '#/components/dashboard/Permission'
|
||||
import PermissionSelector from '#/components/dashboard/PermissionSelector'
|
||||
import Modal from '#/components/Modal'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
@ -59,12 +62,26 @@ export default function ManagePermissionsModal<
|
||||
Asset extends backendModule.AnyAsset = backendModule.AnyAsset,
|
||||
>(props: ManagePermissionsModalProps<Asset>) {
|
||||
const { backend, item, setItem, self, doRemoveSelf, eventTarget } = props
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { getText } = textProvider.useText()
|
||||
const listedUsers = backendHooks.useBackendListUsers(backend)
|
||||
const listedUserGroups = backendHooks.useBackendListUserGroups(backend)
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
||||
const isUnderPaywall = isFeatureUnderPaywall('shareFull')
|
||||
|
||||
const listedUsers = reactQuery.useQuery({
|
||||
queryKey: ['listUsers'],
|
||||
queryFn: () => backend.listUsers(),
|
||||
enabled: !isUnderPaywall,
|
||||
select: data => (isUnderPaywall ? [] : data),
|
||||
})
|
||||
|
||||
const listedUserGroups = reactQuery.useQuery({
|
||||
queryKey: ['listUserGroups'],
|
||||
queryFn: () => backend.listUserGroups(),
|
||||
})
|
||||
|
||||
const [permissions, setPermissions] = React.useState(item.permissions ?? [])
|
||||
const [usersAndUserGroups, setUserAndUserGroups] = React.useState<
|
||||
readonly (backendModule.UserGroupInfo | backendModule.UserInfo)[]
|
||||
@ -100,9 +117,9 @@ export default function ManagePermissionsModal<
|
||||
permissions.every(
|
||||
permission =>
|
||||
permission.permission !== permissionsModule.PermissionAction.own ||
|
||||
(backendModule.isUserPermission(permission) && permission.user.userId === user?.userId)
|
||||
(backendModule.isUserPermission(permission) && permission.user.userId === user.userId)
|
||||
),
|
||||
[user?.userId, permissions, self.permission]
|
||||
[user.userId, permissions, self.permission]
|
||||
)
|
||||
|
||||
const inviteUserMutation = backendHooks.useBackendMutation(backend, 'inviteUser')
|
||||
@ -114,7 +131,7 @@ export default function ManagePermissionsModal<
|
||||
setItem(object.merger({ permissions } as Partial<Asset>))
|
||||
}, [permissions, /* should never change */ setItem])
|
||||
|
||||
if (backend.type === backendModule.BackendType.local || user == null) {
|
||||
if (backend.type === backendModule.BackendType.local) {
|
||||
// This should never happen - the local backend does not have the "shared with" column,
|
||||
// and `organization` is absent only when offline - in which case the user should only
|
||||
// be able to access the local backend.
|
||||
@ -123,12 +140,12 @@ export default function ManagePermissionsModal<
|
||||
} else {
|
||||
const canAdd = React.useMemo(
|
||||
() => [
|
||||
...(listedUsers ?? []).filter(
|
||||
...(listedUsers.data ?? []).filter(
|
||||
listedUser =>
|
||||
!permissionsHoldersNames.has(listedUser.name) &&
|
||||
!emailsOfUsersWithPermission.has(listedUser.email)
|
||||
),
|
||||
...(listedUserGroups ?? []).filter(
|
||||
...(listedUserGroups.data ?? []).filter(
|
||||
userGroup => !permissionsHoldersNames.has(userGroup.groupName)
|
||||
),
|
||||
],
|
||||
@ -299,7 +316,7 @@ export default function ManagePermissionsModal<
|
||||
}}
|
||||
{...innerProps}
|
||||
>
|
||||
<div className="flex grow items-center gap-user-permission rounded-full border border-primary/10 px-1">
|
||||
<div className="flex w-0 grow items-center gap-user-permission rounded-full border border-primary/10 px-1">
|
||||
<PermissionSelector
|
||||
isInput
|
||||
isDisabled={willInviteNewUser}
|
||||
@ -315,7 +332,7 @@ export default function ManagePermissionsModal<
|
||||
autoFocus
|
||||
placeholder={
|
||||
// `listedUsers` will always include the current user.
|
||||
(listedUsers ?? []).length > 1
|
||||
(listedUsers.data ?? []).length > 1
|
||||
? getText('inviteUserPlaceholder')
|
||||
: getText('inviteFirstUserPlaceholder')
|
||||
}
|
||||
@ -406,6 +423,13 @@ export default function ManagePermissionsModal<
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isUnderPaywall && (
|
||||
<paywall.PaywallAlert
|
||||
feature="shareFull"
|
||||
label={getText('shareFullPaywallMessage')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
@ -38,13 +38,7 @@ export function SetOrganizationNameModal() {
|
||||
session && 'user' in session && session.user?.plan != null ? session.user.plan : null
|
||||
const { data: organizationName } = reactQuery.useSuspenseQuery({
|
||||
queryKey: ['organization', userId],
|
||||
queryFn: () => {
|
||||
if (backend.type === backendModule.BackendType.remote) {
|
||||
return backend.getOrganization().catch(() => null)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
queryFn: () => backend.getOrganization().catch(() => null),
|
||||
staleTime: Infinity,
|
||||
select: data => data?.name ?? '',
|
||||
})
|
||||
|
@ -7,6 +7,7 @@ import * as React from 'react'
|
||||
|
||||
import OpenInNewTabIcon from 'enso-assets/open.svg'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
import type * as text from '#/text'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
@ -72,6 +73,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
||||
target="_blank"
|
||||
icon={OpenInNewTabIcon}
|
||||
iconPosition="end"
|
||||
size="medium"
|
||||
>
|
||||
{getText('learnMore')}
|
||||
</ariaComponents.Button>
|
||||
@ -84,7 +86,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
||||
|
||||
return (
|
||||
<ariaComponents.DialogTrigger defaultOpen={defaultOpen}>
|
||||
<ariaComponents.Button variant={'outline'} fullWidth size="medium" rounded="full">
|
||||
<ariaComponents.Button variant={'outline'} fullWidth size="large" rounded="full">
|
||||
{getText('subscribe')}
|
||||
</ariaComponents.Button>
|
||||
|
||||
@ -111,6 +113,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
||||
target="_blank"
|
||||
icon={OpenInNewTabIcon}
|
||||
iconPosition="end"
|
||||
size="medium"
|
||||
>
|
||||
{getText('learnMore')}
|
||||
</ariaComponents.Button>
|
||||
@ -126,7 +129,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
||||
|
||||
return (
|
||||
<ariaComponents.DialogTrigger defaultOpen={defaultOpen}>
|
||||
<ariaComponents.Button variant={'submit'} fullWidth size="medium" rounded="full">
|
||||
<ariaComponents.Button variant={'submit'} fullWidth size="large" rounded="full">
|
||||
{getText('subscribe')}
|
||||
</ariaComponents.Button>
|
||||
|
||||
@ -150,6 +153,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
||||
target="_blank"
|
||||
icon={OpenInNewTabIcon}
|
||||
iconPosition="end"
|
||||
size="medium"
|
||||
>
|
||||
{getText('learnMore')}
|
||||
</ariaComponents.Button>
|
||||
@ -165,10 +169,10 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
|
||||
<ariaComponents.Button
|
||||
fullWidth
|
||||
variant="primary"
|
||||
size="medium"
|
||||
size="large"
|
||||
rounded="full"
|
||||
target="_blank"
|
||||
href="mailto:contact@enso.org?subject=Upgrading%20to%20Organization%20Plan"
|
||||
href={appUtils.getContactSalesURL()}
|
||||
>
|
||||
{getText('contactSales')}
|
||||
</ariaComponents.Button>
|
||||
|
@ -148,7 +148,7 @@ const AuthContext = React.createContext<AuthContextType | null>(null)
|
||||
/** Props for an {@link AuthProvider}. */
|
||||
export interface AuthProviderProps {
|
||||
readonly shouldStartInOfflineMode: boolean
|
||||
readonly setRemoteBackend: (backend: Backend | null) => void
|
||||
readonly setRemoteBackend: (backend: RemoteBackend | null) => void
|
||||
readonly authService: authServiceModule.AuthService | null
|
||||
/** Callback to execute once the user has authenticated successfully. */
|
||||
readonly onAuthenticated: (accessToken: string | null) => void
|
||||
@ -842,7 +842,6 @@ export function useNonPartialUserSession() {
|
||||
|
||||
/** A React context hook returning the user session for a user that may or may not be logged in. */
|
||||
export function useUserSession() {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return router.useOutletContext<UserSession | undefined>()
|
||||
}
|
||||
|
||||
|
@ -10,6 +10,7 @@ import * as categoryModule from '#/layouts/CategorySwitcher/Category'
|
||||
import type Category from '#/layouts/CategorySwitcher/Category'
|
||||
|
||||
import type Backend from '#/services/Backend'
|
||||
import type RemoteBackend from '#/services/RemoteBackend'
|
||||
|
||||
// ======================
|
||||
// === BackendContext ===
|
||||
@ -17,7 +18,7 @@ import type Backend from '#/services/Backend'
|
||||
|
||||
/** State contained in a `BackendContext`. */
|
||||
export interface BackendContextType {
|
||||
readonly remoteBackend: Backend | null
|
||||
readonly remoteBackend: RemoteBackend | null
|
||||
readonly localBackend: Backend | null
|
||||
}
|
||||
|
||||
@ -28,7 +29,7 @@ const BackendContext = React.createContext<BackendContextType>({
|
||||
|
||||
/** Props for a {@link BackendProvider}. */
|
||||
export interface BackendProviderProps extends Readonly<React.PropsWithChildren> {
|
||||
readonly remoteBackend: Backend | null
|
||||
readonly remoteBackend: RemoteBackend | null
|
||||
readonly localBackend: Backend | null
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,6 @@ declare module '@tanstack/react-query' {
|
||||
* Specifies the invalidation behavior of a mutation.
|
||||
*/
|
||||
interface Register {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
readonly mutationMeta: {
|
||||
/**
|
||||
* List of query keys to invalidate when the mutation succeeds.
|
||||
|
Loading…
Reference in New Issue
Block a user