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:
Sergei Garin 2024-06-20 17:51:43 +03:00 committed by GitHub
parent 114b3a5c5e
commit c5853e0ffc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 354 additions and 168 deletions

View File

@ -77,9 +77,9 @@ import AboutModal from '#/modals/AboutModal'
import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal' import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal'
import * as termsOfServiceModal from '#/modals/TermsOfServiceModal' import * as termsOfServiceModal from '#/modals/TermsOfServiceModal'
import type Backend from '#/services/Backend'
import LocalBackend from '#/services/LocalBackend' import LocalBackend from '#/services/LocalBackend'
import * as projectManager from '#/services/ProjectManager' import * as projectManager from '#/services/ProjectManager'
import type RemoteBackend from '#/services/RemoteBackend'
import * as appBaseUrl from '#/utilities/appBaseUrl' import * as appBaseUrl from '#/utilities/appBaseUrl'
import * as eventModule from '#/utilities/event' import * as eventModule from '#/utilities/event'
@ -223,7 +223,7 @@ function AppRouter(props: AppRouterProps) {
const { localStorage } = localStorageProvider.useLocalStorage() const { localStorage } = localStorageProvider.useLocalStorage()
const { setModal } = modalProvider.useSetModal() const { setModal } = modalProvider.useSetModal()
const navigator2D = navigator2DProvider.useNavigator2D() const navigator2D = navigator2DProvider.useNavigator2D()
const [remoteBackend, setRemoteBackend] = React.useState<Backend | null>(null) const [remoteBackend, setRemoteBackend] = React.useState<RemoteBackend | null>(null)
const [localBackend] = React.useState(() => const [localBackend] = React.useState(() =>
projectManagerUrl != null && projectManagerRootDirectory != null projectManagerUrl != null && projectManagerRootDirectory != null
? new LocalBackend(projectManagerUrl, projectManagerRootDirectory) ? new LocalBackend(projectManagerUrl, projectManagerRootDirectory)
@ -456,6 +456,11 @@ function AppRouter(props: AppRouterProps) {
) )
let result = routes let result = routes
if (detect.IS_DEV_MODE) {
result = <paywall.PaywallDevtools>{result}</paywall.PaywallDevtools>
}
result = <InputBindingsProvider inputBindings={inputBindings}>{result}</InputBindingsProvider> result = <InputBindingsProvider inputBindings={inputBindings}>{result}</InputBindingsProvider>
result = ( result = (
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}> <BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
@ -489,9 +494,6 @@ function AppRouter(props: AppRouterProps) {
</SessionProvider> </SessionProvider>
) )
result = <LoggerProvider logger={logger}>{result}</LoggerProvider> result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
if (detect.IS_DEV_MODE) {
result = <paywall.PaywallDevtools>{result}</paywall.PaywallDevtools>
}
result = ( result = (
<rootComponent.Root navigate={navigate} portalRoot={portalRoot}> <rootComponent.Root navigate={navigate} portalRoot={portalRoot}>
{result} {result}

View File

@ -162,7 +162,7 @@ export const BUTTON_STYLES = twv.tv({
variant: { variant: {
custom: 'focus-visible:outline-offset-2', custom: 'focus-visible:outline-offset-2',
link: { 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]', icon: 'h-[1.25cap] mt-[0.25cap]',
}, },
primary: 'bg-primary text-white hover:bg-primary/70', primary: 'bg-primary text-white hover:bg-primary/70',
@ -171,7 +171,7 @@ export const BUTTON_STYLES = twv.tv({
delete: delete:
'bg-danger/80 hover:bg-danger text-white focus-visible:outline-danger focus-visible:bg-danger', 'bg-danger/80 hover:bg-danger text-white focus-visible:outline-danger focus-visible:bg-danger',
icon: { 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', wrapper: 'w-full h-full',
content: 'w-full h-full', content: 'w-full h-full',
extraClickZone: 'w-full h-full', extraClickZone: 'w-full h-full',

View File

@ -204,6 +204,7 @@ export function Dialog(props: DialogProps) {
slot="title" slot="title"
level={2} level={2}
className={dialogSlots.heading()} className={dialogSlots.heading()}
weight="semibold"
> >
{title} {title}
</ariaComponents.Text.Heading> </ariaComponents.Text.Heading>

View File

@ -26,7 +26,6 @@ import type * as types from './types'
export const Form = React.forwardRef(function Form< export const Form = React.forwardRef(function Form<
Schema extends components.TSchema, Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>, TFieldValues extends components.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined, TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
>( >(
props: types.FormProps<Schema, TFieldValues, TTransformedValues>, props: types.FormProps<Schema, TFieldValues, TTransformedValues>,
@ -206,7 +205,6 @@ export const Form = React.forwardRef(function Form<
}) as unknown as (< }) as unknown as (<
Schema extends components.TSchema, Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>, TFieldValues extends components.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined, TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
>( >(
props: React.RefAttributes<HTMLFormElement> & props: React.RefAttributes<HTMLFormElement> &

View File

@ -36,7 +36,6 @@ export interface FieldChildrenRenderProps {
readonly isDirty: boolean readonly isDirty: boolean
readonly isTouched: boolean readonly isTouched: boolean
readonly isValidating: boolean readonly isValidating: boolean
// eslint-disable-next-line no-restricted-syntax
readonly error?: string | undefined readonly error?: string | undefined
} }

View File

@ -20,7 +20,6 @@ export interface RadioGroupProps<
Schema extends formComponent.TSchema, Schema extends formComponent.TSchema,
TFieldValues extends formComponent.FieldValues<Schema>, TFieldValues extends formComponent.FieldValues<Schema>,
TFieldName extends formComponent.FieldPath<Schema, TFieldValues>, TFieldName extends formComponent.FieldPath<Schema, TFieldValues>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined, TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined,
> extends formComponent.FieldStateProps< > extends formComponent.FieldStateProps<
Omit<aria.AriaRadioGroupProps, 'description' | 'label'>, Omit<aria.AriaRadioGroupProps, 'description' | 'label'>,
@ -48,7 +47,6 @@ export const RadioGroup = React.forwardRef(function RadioGroup<
Schema extends formComponent.TSchema, Schema extends formComponent.TSchema,
TFieldName extends formComponent.FieldPath<Schema, TFieldValues>, TFieldName extends formComponent.FieldPath<Schema, TFieldValues>,
TFieldValues extends formComponent.FieldValues<Schema> = formComponent.FieldValues<Schema>, TFieldValues extends formComponent.FieldValues<Schema> = formComponent.FieldValues<Schema>,
// eslint-disable-next-line no-restricted-syntax
TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined, TTransformedValues extends formComponent.FieldValues<Schema> | undefined = undefined,
>( >(
props: RadioGroupProps<Schema, TFieldValues, TFieldName, TTransformedValues>, props: RadioGroupProps<Schema, TFieldValues, TFieldName, TTransformedValues>,

View File

@ -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 // leading should always be after the text size to make sure it is not stripped by twMerge
variant: { variant: {
custom: '', custom: '',
body: 'text-xs 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]', h1: 'text-xl leading-[29px] before:h-0.5 after:h-[5px] font-bold',
subtitle: 'text-[13.5px] leading-[20px] before:h-[1px] after:h-[3px]', subtitle: 'text-[13.5px] leading-[19px] before:h-[1px] after:h-[3px] font-bold',
}, },
weight: { weight: {
custom: '', custom: '',
@ -88,7 +88,7 @@ export const TEXT_STYLE = twv.tv({
all: 'select-all', all: 'select-all',
}, },
disableLineHeightCompensation: { disableLineHeightCompensation: {
true: '', true: 'before:hidden after:hidden before:w-0 after:w-0',
false: false:
'inline-block flex-col before:block after:block before:flex-none after:flex-none before:w-full after:w-full', '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, disableLineHeightCompensation: false,
textSelection: 'auto', 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]',
},
],
}) })
/** /**

View File

@ -28,8 +28,6 @@ export interface ModalProps
Readonly<tailwindVariants.VariantProps<typeof MODAL_VARIANTS>> { Readonly<tailwindVariants.VariantProps<typeof MODAL_VARIANTS>> {
/** If `true`, disables `data-testid` because it will not be visible. */ /** If `true`, disables `data-testid` because it will not be visible. */
readonly hidden?: boolean 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 centered?: boolean | undefined
readonly style?: React.CSSProperties readonly style?: React.CSSProperties
readonly className?: string readonly className?: string

View File

@ -133,5 +133,9 @@ export function PaywallDevtools(props: PaywallDevtoolsProps) {
* A hook that provides access to the paywall devtools. * A hook that provides access to the paywall devtools.
*/ */
export function usePaywallDevtools() { export function usePaywallDevtools() {
return React.useContext(PaywallDevtoolsContext) const context = React.useContext(PaywallDevtoolsContext)
React.useDebugValue(context)
return context
} }

View File

@ -15,12 +15,7 @@ export interface SvgMaskProps {
readonly src: string readonly src: string
readonly title?: string readonly title?: string
readonly style?: React.CSSProperties 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 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 readonly className?: string | undefined
} }

View File

@ -20,7 +20,6 @@ export * from '@react-stately/tooltip'
* *
* The constraint is defaulted to `never` to make an explicit constraint mandatory. */ * The constraint is defaulted to `never` to make an explicit constraint mandatory. */
export function mergeProps<Constraint extends object = never>() { export function mergeProps<Constraint extends object = never>() {
// eslint-disable-next-line no-restricted-syntax
return <T extends (Partial<Constraint> | null | undefined)[]>(...args: T) => return <T extends (Partial<Constraint> | null | undefined)[]>(...args: T) =>
aria.mergeProps(...args) aria.mergeProps(...args)
} }

View File

@ -3,6 +3,8 @@ import * as React from 'react'
import Plus2Icon from 'enso-assets/plus2.svg' import Plus2Icon from 'enso-assets/plus2.svg'
import * as billingHooks from '#/hooks/billing'
import * as authProvider from '#/providers/AuthProvider' import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider' import * as modalProvider from '#/providers/ModalProvider'
@ -13,6 +15,7 @@ import Category from '#/layouts/CategorySwitcher/Category'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
import type * as column from '#/components/dashboard/column' import type * as column from '#/components/dashboard/column'
import PermissionDisplay from '#/components/dashboard/PermissionDisplay' import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
import * as paywall from '#/components/Paywall'
import ManagePermissionsModal from '#/modals/ManagePermissionsModal' import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
@ -43,6 +46,11 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const { backend, category, dispatchAssetEvent, setQuery } = state const { backend, category, dispatchAssetEvent, setQuery } = state
const asset = item.item const asset = item.item
const { user } = authProvider.useNonPartialUserSession() const { user } = authProvider.useNonPartialUserSession()
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan })
const isUnderPaywall = isFeatureUnderPaywall('share')
const { setModal } = modalProvider.useSetModal() const { setModal } = modalProvider.useSetModal()
const self = asset.permissions?.find( const self = asset.permissions?.find(
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId) backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
@ -89,12 +97,21 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
{backendModule.getAssetPermissionName(other)} {backendModule.getAssetPermissionName(other)}
</PermissionDisplay> </PermissionDisplay>
))} ))}
{managesThisAsset && ( {isUnderPaywall && (
<paywall.PaywallDialogButton
feature="share"
variant="icon"
size="xxsmall"
className="opacity-0 group-hover:opacity-100"
children={false}
/>
)}
{managesThisAsset && !isUnderPaywall && (
<ariaComponents.Button <ariaComponents.Button
size="custom" variant="icon"
variant="custom" size="xsmall"
ref={plusButtonRef} icon={Plus2Icon}
className="shrink-0 rounded-full opacity-0 group-hover:opacity-100 focus-visible:opacity-100" className="opacity-0 group-hover:opacity-100"
onPress={() => { onPress={() => {
setModal( setModal(
<ManagePermissionsModal <ManagePermissionsModal
@ -113,9 +130,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
/> />
) )
}} }}
> />
<img className="size-plus-icon" src={Plus2Icon} />
</ariaComponents.Button>
)} )}
</div> </div>
) )

View File

@ -3,12 +3,16 @@ import * as React from 'react'
import PeopleIcon from 'enso-assets/people.svg' 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 textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import type * as column from '#/components/dashboard/column' import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils' 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. */ /** A heading for the "Shared with" column. */
export default function SharedWithColumnHeading(props: column.AssetColumnHeadingProps) { export default function SharedWithColumnHeading(props: column.AssetColumnHeadingProps) {
@ -16,18 +20,36 @@ export default function SharedWithColumnHeading(props: column.AssetColumnHeading
const { hideColumn } = state const { hideColumn } = state
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const { user } = authProvider.useNonPartialUserSession()
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan })
const isUnderPaywall = isFeatureUnderPaywall('share')
return ( return (
<div className="flex h-table-row w-full items-center gap-icon-with-text"> <div className="flex h-table-row w-full items-center gap-icon-with-text">
<Button <ariaComponents.Button
active variant="icon"
image={PeopleIcon} size="xsmall"
className="size-4" icon={PeopleIcon}
alt={getText('sharedWithColumnHide')} aria-label={getText('sharedWithColumnHide')}
onPress={() => { onPress={() => {
hideColumn(columnUtils.Column.sharedWith) 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> </div>
) )
} }

View File

@ -10,7 +10,7 @@ import * as tailwindVariants from '#/utilities/tailwindVariants'
// ================= // =================
const HORIZONTAL_MENU_BAR_VARIANTS = tailwindVariants.tv({ const HORIZONTAL_MENU_BAR_VARIANTS = tailwindVariants.tv({
base: 'flex h-row gap-drive-bar', base: 'flex items-center h-row gap-drive-bar',
variants: { variants: {
grow: { true: 'grow' }, grow: { true: 'grow' },
}, },

View File

@ -3,6 +3,7 @@ import * as React from 'react'
import * as toast from 'react-toastify' import * as toast from 'react-toastify'
import * as billingHooks from '#/hooks/billing'
import * as setAssetHooks from '#/hooks/setAssetHooks' import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -21,6 +22,7 @@ import ContextMenu from '#/components/ContextMenu'
import ContextMenuEntry from '#/components/ContextMenuEntry' import ContextMenuEntry from '#/components/ContextMenuEntry'
import ContextMenus from '#/components/ContextMenus' import ContextMenus from '#/components/ContextMenus'
import type * as assetRow from '#/components/dashboard/AssetRow' import type * as assetRow from '#/components/dashboard/AssetRow'
import * as paywall from '#/components/Paywall'
import Separator from '#/components/styled/Separator' import Separator from '#/components/styled/Separator'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal' import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
@ -72,6 +74,10 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId) backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
) )
const isCloud = categoryModule.isCloud(category) 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 ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin
const canEditThisAsset = const canEditThisAsset =
@ -304,29 +310,39 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
/> />
)} )}
{isCloud && <Separator hidden={hidden} />} {isCloud && <Separator hidden={hidden} />}
{isCloud && managesThisAsset && self != null && ( {isCloud && managesThisAsset && self != null && (
<ContextMenuEntry <>
hidden={hidden} {isUnderPaywall && (
action="share" <paywall.ContextMenuEntry feature="share" action="share" hidden={hidden} />
doAction={() => { )}
setModal(
<ManagePermissionsModal {!isUnderPaywall && (
backend={backend} <ContextMenuEntry
item={asset} hidden={hidden}
setItem={setAsset} action="share"
self={self} doAction={() => {
eventTarget={eventTarget} setModal(
doRemoveSelf={() => { <ManagePermissionsModal
dispatchAssetEvent({ backend={backend}
type: AssetEventType.removeSelf, item={asset}
id: asset.id, setItem={setAsset}
}) self={self}
}} eventTarget={eventTarget}
/> doRemoveSelf={() => {
) dispatchAssetEvent({
}} type: AssetEventType.removeSelf,
/> id: asset.id,
})
}}
/>
)
}}
/>
)}
</>
)} )}
{isCloud && ( {isCloud && (
<ContextMenuEntry <ContextMenuEntry
hidden={hidden} hidden={hidden}

View File

@ -52,6 +52,7 @@ export default function Settings(props: SettingsProps) {
const isUserInOrganization = organization != null const isUserInOrganization = organization != null
let content: React.JSX.Element | null let content: React.JSX.Element | null
switch (settingsTab) { switch (settingsTab) {
case SettingsTab.account: { case SettingsTab.account: {
content = backend == null ? null : <AccountSettingsTab backend={backend} /> content = backend == null ? null : <AccountSettingsTab backend={backend} />
@ -62,7 +63,7 @@ export default function Settings(props: SettingsProps) {
break break
} }
case SettingsTab.members: { case SettingsTab.members: {
content = backend == null ? null : <MembersSettingsTab backend={backend} /> content = <MembersSettingsTab />
break break
} }
case SettingsTab.userGroups: { case SettingsTab.userGroups: {

View File

@ -44,9 +44,9 @@ export default function DeleteUserAccountSettingsSection(
> >
<div className="flex gap-buttons"> <div className="flex gap-buttons">
<ariaComponents.Button <ariaComponents.Button
size="custom" variant="delete"
variant="custom" size="medium"
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" rounded="full"
onPress={() => { onPress={() => {
setModal( setModal(
<ConfirmDeleteUserModal <ConfirmDeleteUserModal
@ -58,11 +58,9 @@ export default function DeleteUserAccountSettingsSection(
) )
}} }}
> >
<aria.Text className="text inline-block"> {getText('deleteUserAccountButtonLabel')}
{getText('deleteUserAccountButtonLabel')}
</aria.Text>
</ariaComponents.Button> </ariaComponents.Button>
<aria.Text className="text my-auto">{getText('deleteUserAccountWarning')}</aria.Text> <aria.Text className="text-md my-auto">{getText('deleteUserAccountWarning')}</aria.Text>
</div> </div>
</SettingsSection> </SettingsSection>
) )

View File

@ -4,7 +4,10 @@ import * as React from 'react'
import * as reactQuery from '@tanstack/react-query' import * as reactQuery from '@tanstack/react-query'
import * as backendHooks from '#/hooks/backendHooks' 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 * as textProvider from '#/providers/TextProvider'
import MembersSettingsTabBar from '#/layouts/Settings/MembersSettingsTabBar' 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 SettingsSection from '#/components/styled/settings/SettingsSection'
import type * as backendModule from '#/services/Backend' 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 === // === Constants ===
@ -26,15 +31,13 @@ const LIST_USERS_STALE_TIME_MS = 60_000
// === MembersSettingsTab === // === MembersSettingsTab ===
// ========================== // ==========================
/** Props for a {@link MembersSettingsTab}. */
export interface MembersSettingsTabProps {
readonly backend: Backend
}
/** Settings tab for viewing and editing organization members. */ /** Settings tab for viewing and editing organization members. */
export default function MembersSettingsTab(props: MembersSettingsTabProps) { export function MembersSettingsTab() {
const { backend } = props
const { getText } = textProvider.useText() 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({ const [{ data: members }, { data: invitations }] = reactQuery.useSuspenseQueries({
queries: [ 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 ( return (
<SettingsPage> <SettingsPage>
<SettingsSection noFocusArea title={getText('members')} className="overflow-hidden"> <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"> <table className="table-fixed self-start rounded-rows">
<thead> <thead>
@ -67,6 +81,7 @@ export default function MembersSettingsTab(props: MembersSettingsTabProps) {
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="select-text"> <tbody className="select-text">
{members.map(member => ( {members.map(member => (
<tr key={member.email} className="group h-row rounded-rows-child"> <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. */ /** Props for the ResendInvitationButton component. */
interface ResendInvitationButtonProps { interface ResendInvitationButtonProps {
readonly invitation: backendModule.Invitation readonly invitation: backendModule.Invitation
readonly backend: Backend readonly backend: RemoteBackend
} }
/** Button for resending an invitation. */ /** Button for resending an invitation. */
@ -158,7 +173,7 @@ function ResendInvitationButton(props: ResendInvitationButtonProps) {
/** Props for a {@link RemoveMemberButton}. */ /** Props for a {@link RemoveMemberButton}. */
interface RemoveMemberButtonProps { interface RemoveMemberButtonProps {
readonly backend: Backend readonly backend: RemoteBackend
readonly userId: backendModule.UserId readonly userId: backendModule.UserId
} }
@ -167,11 +182,9 @@ function RemoveMemberButton(props: RemoveMemberButtonProps) {
const { backend, userId } = props const { backend, userId } = props
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const queryClient = reactQuery.useQueryClient()
const removeMutation = backendHooks.useBackendMutation(backend, 'removeUser', { const removeMutation = backendHooks.useBackendMutation(backend, 'removeUser', {
mutationKey: [userId], mutationKey: [userId],
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['listUsers'] }), meta: { invalidates: [['listUsers']], awaitInvalidates: true },
}) })
return ( return (
@ -191,7 +204,7 @@ function RemoveMemberButton(props: RemoveMemberButtonProps) {
/** Props for a {@link RemoveInvitationButton}. */ /** Props for a {@link RemoveInvitationButton}. */
interface RemoveInvitationButtonProps { interface RemoveInvitationButtonProps {
readonly backend: Backend readonly backend: RemoteBackend
readonly email: backendModule.EmailAddress readonly email: backendModule.EmailAddress
} }
@ -200,11 +213,10 @@ function RemoveInvitationButton(props: RemoveInvitationButtonProps) {
const { backend, email } = props const { backend, email } = props
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const queryClient = reactQuery.useQueryClient()
const removeMutation = backendHooks.useBackendMutation(backend, 'resendInvitation', { const removeMutation = backendHooks.useBackendMutation(backend, 'resendInvitation', {
mutationKey: [email], mutationKey: [email],
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['listInvitations'] }), meta: { invalidates: [['listInvitations']], awaitInvalidates: true },
}) })
return ( return (
@ -218,3 +230,7 @@ function RemoveInvitationButton(props: RemoveInvitationButtonProps) {
</ariaComponents.Button> </ariaComponents.Button>
) )
} }
export default paywallSettingsLayout.withPaywall(MembersSettingsTab, {
feature: 'inviteUser',
})

View File

@ -1,9 +1,12 @@
/** @file Button bar for managing organization members. */ /** @file Button bar for managing organization members. */
import * as React from 'react' import * as React from 'react'
import type * as billingHooks from '#/hooks/billing'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
import * as paywallComponents from '#/components/Paywall'
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar' import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
import InviteUsersModal from '#/modals/InviteUsersModal' import InviteUsersModal from '#/modals/InviteUsersModal'
@ -12,19 +15,43 @@ import InviteUsersModal from '#/modals/InviteUsersModal'
// === MembersSettingsTabBar === // === MembersSettingsTabBar ===
// ============================= // =============================
/**
*
*/
export interface MembersSettingsTabBarProps {
readonly seatsLeft: number | null
readonly seatsTotal: number
readonly feature: billingHooks.PaywallFeatureName
}
/** Button bar for managing organization members. */ /** 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() const { getText } = textProvider.useText()
return ( return (
<HorizontalMenuBar> <HorizontalMenuBar>
<ariaComponents.DialogTrigger> <ariaComponents.DialogTrigger>
<ariaComponents.Button variant="bar" rounded="full" size="small"> <ariaComponents.Button variant="bar" rounded="full" size="medium">
{getText('inviteMembers')} {getText('inviteMembers')}
</ariaComponents.Button> </ariaComponents.Button>
<InviteUsersModal /> <InviteUsersModal />
</ariaComponents.DialogTrigger> </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> </HorizontalMenuBar>
) )
} }

View File

@ -4,9 +4,11 @@ import * as React from 'react'
import * as mimeTypes from '#/data/mimeTypes' import * as mimeTypes from '#/data/mimeTypes'
import * as backendHooks from '#/hooks/backendHooks' import * as backendHooks from '#/hooks/backendHooks'
import * as billingHooks from '#/hooks/billing'
import * as scrollHooks from '#/hooks/scrollHooks' import * as scrollHooks from '#/hooks/scrollHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider' import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
@ -16,6 +18,7 @@ import UserGroupUserRow from '#/layouts/Settings/UserGroupUserRow'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
import * as paywallComponents from '#/components/Paywall'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner' import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar' import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
import SettingsSection from '#/components/styled/settings/SettingsSection' 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 tailwindMerge from '#/utilities/tailwindMerge'
import * as withPaywall from './withPaywall'
// ============================= // =============================
// === UserGroupsSettingsTab === // === UserGroupsSettingsTab ===
// ============================= // =============================
@ -37,10 +42,11 @@ export interface UserGroupsSettingsTabProps {
} }
/** Settings tab for viewing and editing organization members. */ /** Settings tab for viewing and editing organization members. */
export default function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps) { function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps) {
const { backend } = props const { backend } = props
const { setModal } = modalProvider.useSetModal() const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const { user } = authProvider.useFullUserSession()
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
const users = backendHooks.useBackendListUsers(backend) const users = backendHooks.useBackendListUsers(backend)
const userGroups = backendHooks.useBackendListUserGroupsWithUsers(backend) const userGroups = backendHooks.useBackendListUserGroupsWithUsers(backend)
@ -54,6 +60,12 @@ export default function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps)
) )
const isLoading = userGroups == null || users == null 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 } = const { onScroll: onUserGroupsTableScroll, shadowClassName } =
scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, true) 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"> <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"> <SettingsSection noFocusArea title={getText('userGroups')} className="overflow-hidden">
<HorizontalMenuBar> <HorizontalMenuBar>
<ariaComponents.Button <div className="flex items-center gap-2">
size="custom" {shouldDisplayPaywall && (
variant="custom" <paywallComponents.PaywallDialogButton
className="px-new-project-button-x flex h-row items-center rounded-full bg-frame" feature="userGroupsFull"
onPress={event => { variant="bar"
const rect = event.target.getBoundingClientRect() size="medium"
const position = { pageX: rect.left, pageY: rect.top } rounded="full"
setModal(<NewUserGroupModal backend={backend} event={position} />) iconPosition="end"
}} tooltip={getText('userGroupsPaywallMessage')}
> >
<aria.Text className="text whitespace-nowrap font-semibold"> {getText('newUserGroup')}
{getText('newUserGroup')} </paywallComponents.PaywallDialogButton>
</aria.Text> )}
</ariaComponents.Button> {!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> </HorizontalMenuBar>
<div <div
ref={rootRef} ref={rootRef}
@ -207,9 +240,12 @@ export default function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps)
</div> </div>
</SettingsSection> </SettingsSection>
</div> </div>
<SettingsSection noFocusArea title={getText('users')} className="h-2/5 lg:h-[unset]"> <SettingsSection noFocusArea title={getText('users')} className="h-2/5 lg:h-[unset]">
<MembersTable draggable populateWithSelf backend={backend} /> <MembersTable draggable populateWithSelf backend={backend} />
</SettingsSection> </SettingsSection>
</div> </div>
) )
} }
export default withPaywall.withPaywall(UserGroupsSettingsTab, { feature: 'userGroups' })

View File

@ -24,9 +24,7 @@ export interface PaywallSettingsLayoutProps {
readonly className?: string | undefined readonly className?: string | undefined
} }
const PAYWALL_LAYOUT_STYLES = twv.tv({ const PAYWALL_LAYOUT_STYLES = twv.tv({ base: 'mt-1' })
base: 'mt-1',
})
/** /**
* A layout that shows a paywall for a feature. * 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 paywall is shown if the user's plan does not include the feature.
* The feature is determined by the `isFeatureUnderPaywall` hook. * 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 // eslint-disable-next-line @typescript-eslint/naming-convention
Component: React.ComponentType<P>, Component: React.ComponentType<P>,
props: PaywallSettingsLayoutProps props: PaywallSettingsLayoutProps
) { ) {
const { feature, className } = props const { feature, className } = props
return function WithPaywall(componentProps: P) { return function WithPaywall(componentProps: P & React.JSX.IntrinsicAttributes) {
const { user } = authProvider.useFullUserSession() const { user } = authProvider.useFullUserSession()
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan }) const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })

View File

@ -6,6 +6,8 @@ import DefaultUserIcon from 'enso-assets/default_user.svg'
import * as appUtils from '#/appUtils' import * as appUtils from '#/appUtils'
import * as billing from '#/hooks/billing'
import * as authProvider from '#/providers/AuthProvider' import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider' import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
@ -15,6 +17,7 @@ import UserMenu from '#/layouts/UserMenu'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
import * as paywall from '#/components/Paywall'
import Button from '#/components/styled/Button' import Button from '#/components/styled/Button'
import FocusArea from '#/components/styled/FocusArea' import FocusArea from '#/components/styled/FocusArea'
@ -50,20 +53,30 @@ export default function UserBar(props: UserBarProps) {
const { type: sessionType, user } = authProvider.useNonPartialUserSession() const { type: sessionType, user } = authProvider.useNonPartialUserSession()
const { setModal } = modalProvider.useSetModal() const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const { isFeatureUnderPaywall } = billing.usePaywall({ plan: user?.plan })
const self = const self =
user != null user != null
? projectAsset?.permissions?.find( ? projectAsset?.permissions?.find(
backendModule.isUserPermissionAnd(permissions => permissions.user.userId === user.userId) backendModule.isUserPermissionAnd(permissions => permissions.user.userId === user.userId)
) ?? null ) ?? null
: null : null
const shouldShowShareButton = const shouldShowShareButton =
backend != null && backend != null &&
page === pageSwitcher.Page.editor && page === pageSwitcher.Page.editor &&
projectAsset != null && projectAsset != null &&
setProjectAsset != null && setProjectAsset != null &&
self != null self != null
const shouldShowUpgradeButton = isFeatureUnderPaywall('inviteUser')
const shouldShowInviteButton = const shouldShowInviteButton =
backend != null && sessionType === authProvider.UserSessionType.full && !shouldShowShareButton backend != null &&
sessionType === authProvider.UserSessionType.full &&
!shouldShowShareButton &&
!shouldShowUpgradeButton
return ( return (
<FocusArea active={!invisible} direction="horizontal"> <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 && ( {shouldShowInviteButton && (
<ariaComponents.DialogTrigger> <ariaComponents.DialogTrigger>
<ariaComponents.Button size="medium" variant="tertiary"> <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}> <ariaComponents.Button variant="primary" size="medium" href={appUtils.SUBSCRIBE_PATH}>
{getText('upgrade')} {getText('upgrade')}
</ariaComponents.Button> </ariaComponents.Button>
{shouldShowShareButton && ( {shouldShowShareButton && (
<ariaComponents.Button <ariaComponents.Button
size="medium" size="medium"

View File

@ -5,13 +5,16 @@ import * as reactQuery from '@tanstack/react-query'
import isEmail from 'validator/es/lib/isEmail' import isEmail from 'validator/es/lib/isEmail'
import * as backendHooks from '#/hooks/backendHooks' import * as backendHooks from '#/hooks/backendHooks'
import * as billingHooks from '#/hooks/billing'
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks' import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider' import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
import * as paywallComponents from '#/components/Paywall'
import type * as backendModule from '#/services/Backend' import type * as backendModule from '#/services/Backend'
@ -31,17 +34,43 @@ export interface InviteUsersFormProps {
export function InviteUsersForm(props: InviteUsersFormProps) { export function InviteUsersForm(props: InviteUsersFormProps) {
const { onSubmitted, organizationId } = props const { onSubmitted, organizationId } = props
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const { user } = authProvider.useFullUserSession()
const { isFeatureUnderPaywall, getFeature } = billingHooks.usePaywall({ plan: user.plan })
const [inputValue, setInputValue] = React.useState('') const [inputValue, setInputValue] = React.useState('')
const backend = backendProvider.useRemoteBackendStrict() const backend = backendProvider.useRemoteBackendStrict()
const inputRef = React.useRef<HTMLDivElement>(null) const inputRef = React.useRef<HTMLDivElement>(null)
const formRef = React.useRef<HTMLFormElement>(null) const formRef = React.useRef<HTMLFormElement>(null)
const queryClient = reactQuery.useQueryClient()
const inviteUserMutation = backendHooks.useBackendMutation(backend, 'inviteUser', { const inviteUserMutation = backendHooks.useBackendMutation(backend, 'inviteUser', {
onSuccess: async () => { meta: {
await queryClient.invalidateQueries({ queryKey: ['listInvitations'] }) 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) => { const getEmailsFromInput = eventCallbackHooks.useEventCallback((value: string) => {
return parserUserEmails.parseUserEmails(value) return parserUserEmails.parseUserEmails(value)
}) })
@ -89,10 +118,14 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
if (trimmedValue === '' || entries.length === 0) { if (trimmedValue === '' || entries.length === 0) {
return getText('emailIsRequired') return getText('emailIsRequired')
} else { } else {
for (const entry of entries) { if (entries.length > seatsLeft) {
if (!isEmail(entry.email)) { return getText('inviteFormSeatsLeftError', entries.length - seatsLeft)
// eslint-disable-next-line no-restricted-syntax } else {
return getText('emailIsInvalid') 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"> <ariaComponents.Text className="mb-2">{getText('inviteFormDescription')}</ariaComponents.Text>
{getText('inviteFormDescription')}
</aria.Text>
<ariaComponents.ResizableContentEditableInput <ariaComponents.ResizableContentEditableInput
ref={inputRef} ref={inputRef}
className="mb-4" className="mb-2"
name="email" name="email"
aria-label={getText('inviteEmailFieldLabel')} aria-label={getText('inviteEmailFieldLabel')}
placeholder={getText('inviteEmailFieldPlaceholder')} placeholder={getText('inviteEmailFieldPlaceholder')}
@ -168,15 +199,24 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
{inviteUserMutation.isError && ( {inviteUserMutation.isError && (
<ariaComponents.Alert variant="error" className="mb-4"> <ariaComponents.Alert variant="error" className="mb-4">
{/* eslint-disable-next-line no-restricted-syntax */} {/* eslint-disable-next-line no-restricted-syntax */}
{getText('arbitraryErrorTitle')}. {getText('arbitraryErrorSubtitle')} {getText('arbitraryErrorTitle')}.{'&nbsp;'}
{getText('arbitraryErrorSubtitle')}
</ariaComponents.Alert> </ariaComponents.Alert>
)} )}
{isUnderPaywall && (
<paywallComponents.PaywallAlert
className="mb-4"
feature="inviteUserFull"
label={getText('inviteFormSeatsLeft', seatsLeft)}
/>
)}
<ariaComponents.Button <ariaComponents.Button
type="submit" type="submit"
variant="tertiary" variant="tertiary"
rounded="medium" rounded="xlarge"
size="medium" size="large"
loading={inviteUserMutation.isPending} loading={inviteUserMutation.isPending}
fullWidth fullWidth
> >

View File

@ -1,10 +1,12 @@
/** @file A modal with inputs for user email and permission level. */ /** @file A modal with inputs for user email and permission level. */
import * as React from 'react' import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as toast from 'react-toastify' import * as toast from 'react-toastify'
import isEmail from 'validator/es/lib/isEmail' import isEmail from 'validator/es/lib/isEmail'
import * as backendHooks from '#/hooks/backendHooks' import * as backendHooks from '#/hooks/backendHooks'
import * as billingHooks from '#/hooks/billing'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider' import * as authProvider from '#/providers/AuthProvider'
@ -17,6 +19,7 @@ import Autocomplete from '#/components/Autocomplete'
import Permission from '#/components/dashboard/Permission' import Permission from '#/components/dashboard/Permission'
import PermissionSelector from '#/components/dashboard/PermissionSelector' import PermissionSelector from '#/components/dashboard/PermissionSelector'
import Modal from '#/components/Modal' import Modal from '#/components/Modal'
import * as paywall from '#/components/Paywall'
import FocusArea from '#/components/styled/FocusArea' import FocusArea from '#/components/styled/FocusArea'
import * as backendModule from '#/services/Backend' import * as backendModule from '#/services/Backend'
@ -59,12 +62,26 @@ export default function ManagePermissionsModal<
Asset extends backendModule.AnyAsset = backendModule.AnyAsset, Asset extends backendModule.AnyAsset = backendModule.AnyAsset,
>(props: ManagePermissionsModalProps<Asset>) { >(props: ManagePermissionsModalProps<Asset>) {
const { backend, item, setItem, self, doRemoveSelf, eventTarget } = props const { backend, item, setItem, self, doRemoveSelf, eventTarget } = props
const { user } = authProvider.useNonPartialUserSession() const { user } = authProvider.useFullUserSession()
const { unsetModal } = modalProvider.useSetModal() const { unsetModal } = modalProvider.useSetModal()
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
const { getText } = textProvider.useText() 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 [permissions, setPermissions] = React.useState(item.permissions ?? [])
const [usersAndUserGroups, setUserAndUserGroups] = React.useState< const [usersAndUserGroups, setUserAndUserGroups] = React.useState<
readonly (backendModule.UserGroupInfo | backendModule.UserInfo)[] readonly (backendModule.UserGroupInfo | backendModule.UserInfo)[]
@ -100,9 +117,9 @@ export default function ManagePermissionsModal<
permissions.every( permissions.every(
permission => permission =>
permission.permission !== permissionsModule.PermissionAction.own || 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') const inviteUserMutation = backendHooks.useBackendMutation(backend, 'inviteUser')
@ -114,7 +131,7 @@ export default function ManagePermissionsModal<
setItem(object.merger({ permissions } as Partial<Asset>)) setItem(object.merger({ permissions } as Partial<Asset>))
}, [permissions, /* should never change */ setItem]) }, [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, // 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 // and `organization` is absent only when offline - in which case the user should only
// be able to access the local backend. // be able to access the local backend.
@ -123,12 +140,12 @@ export default function ManagePermissionsModal<
} else { } else {
const canAdd = React.useMemo( const canAdd = React.useMemo(
() => [ () => [
...(listedUsers ?? []).filter( ...(listedUsers.data ?? []).filter(
listedUser => listedUser =>
!permissionsHoldersNames.has(listedUser.name) && !permissionsHoldersNames.has(listedUser.name) &&
!emailsOfUsersWithPermission.has(listedUser.email) !emailsOfUsersWithPermission.has(listedUser.email)
), ),
...(listedUserGroups ?? []).filter( ...(listedUserGroups.data ?? []).filter(
userGroup => !permissionsHoldersNames.has(userGroup.groupName) userGroup => !permissionsHoldersNames.has(userGroup.groupName)
), ),
], ],
@ -299,7 +316,7 @@ export default function ManagePermissionsModal<
}} }}
{...innerProps} {...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 <PermissionSelector
isInput isInput
isDisabled={willInviteNewUser} isDisabled={willInviteNewUser}
@ -315,7 +332,7 @@ export default function ManagePermissionsModal<
autoFocus autoFocus
placeholder={ placeholder={
// `listedUsers` will always include the current user. // `listedUsers` will always include the current user.
(listedUsers ?? []).length > 1 (listedUsers.data ?? []).length > 1
? getText('inviteUserPlaceholder') ? getText('inviteUserPlaceholder')
: getText('inviteFirstUserPlaceholder') : getText('inviteFirstUserPlaceholder')
} }
@ -406,6 +423,13 @@ export default function ManagePermissionsModal<
</div> </div>
))} ))}
</div> </div>
{isUnderPaywall && (
<paywall.PaywallAlert
feature="shareFull"
label={getText('shareFullPaywallMessage')}
/>
)}
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@ -38,13 +38,7 @@ export function SetOrganizationNameModal() {
session && 'user' in session && session.user?.plan != null ? session.user.plan : null session && 'user' in session && session.user?.plan != null ? session.user.plan : null
const { data: organizationName } = reactQuery.useSuspenseQuery({ const { data: organizationName } = reactQuery.useSuspenseQuery({
queryKey: ['organization', userId], queryKey: ['organization', userId],
queryFn: () => { queryFn: () => backend.getOrganization().catch(() => null),
if (backend.type === backendModule.BackendType.remote) {
return backend.getOrganization().catch(() => null)
} else {
return null
}
},
staleTime: Infinity, staleTime: Infinity,
select: data => data?.name ?? '', select: data => data?.name ?? '',
}) })

View File

@ -7,6 +7,7 @@ import * as React from 'react'
import OpenInNewTabIcon from 'enso-assets/open.svg' import OpenInNewTabIcon from 'enso-assets/open.svg'
import * as appUtils from '#/appUtils'
import type * as text from '#/text' import type * as text from '#/text'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
@ -72,6 +73,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
target="_blank" target="_blank"
icon={OpenInNewTabIcon} icon={OpenInNewTabIcon}
iconPosition="end" iconPosition="end"
size="medium"
> >
{getText('learnMore')} {getText('learnMore')}
</ariaComponents.Button> </ariaComponents.Button>
@ -84,7 +86,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
return ( return (
<ariaComponents.DialogTrigger defaultOpen={defaultOpen}> <ariaComponents.DialogTrigger defaultOpen={defaultOpen}>
<ariaComponents.Button variant={'outline'} fullWidth size="medium" rounded="full"> <ariaComponents.Button variant={'outline'} fullWidth size="large" rounded="full">
{getText('subscribe')} {getText('subscribe')}
</ariaComponents.Button> </ariaComponents.Button>
@ -111,6 +113,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
target="_blank" target="_blank"
icon={OpenInNewTabIcon} icon={OpenInNewTabIcon}
iconPosition="end" iconPosition="end"
size="medium"
> >
{getText('learnMore')} {getText('learnMore')}
</ariaComponents.Button> </ariaComponents.Button>
@ -126,7 +129,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
return ( return (
<ariaComponents.DialogTrigger defaultOpen={defaultOpen}> <ariaComponents.DialogTrigger defaultOpen={defaultOpen}>
<ariaComponents.Button variant={'submit'} fullWidth size="medium" rounded="full"> <ariaComponents.Button variant={'submit'} fullWidth size="large" rounded="full">
{getText('subscribe')} {getText('subscribe')}
</ariaComponents.Button> </ariaComponents.Button>
@ -150,6 +153,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
target="_blank" target="_blank"
icon={OpenInNewTabIcon} icon={OpenInNewTabIcon}
iconPosition="end" iconPosition="end"
size="medium"
> >
{getText('learnMore')} {getText('learnMore')}
</ariaComponents.Button> </ariaComponents.Button>
@ -165,10 +169,10 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
<ariaComponents.Button <ariaComponents.Button
fullWidth fullWidth
variant="primary" variant="primary"
size="medium" size="large"
rounded="full" rounded="full"
target="_blank" target="_blank"
href="mailto:contact@enso.org?subject=Upgrading%20to%20Organization%20Plan" href={appUtils.getContactSalesURL()}
> >
{getText('contactSales')} {getText('contactSales')}
</ariaComponents.Button> </ariaComponents.Button>

View File

@ -148,7 +148,7 @@ const AuthContext = React.createContext<AuthContextType | null>(null)
/** Props for an {@link AuthProvider}. */ /** Props for an {@link AuthProvider}. */
export interface AuthProviderProps { export interface AuthProviderProps {
readonly shouldStartInOfflineMode: boolean readonly shouldStartInOfflineMode: boolean
readonly setRemoteBackend: (backend: Backend | null) => void readonly setRemoteBackend: (backend: RemoteBackend | null) => void
readonly authService: authServiceModule.AuthService | null readonly authService: authServiceModule.AuthService | null
/** Callback to execute once the user has authenticated successfully. */ /** Callback to execute once the user has authenticated successfully. */
readonly onAuthenticated: (accessToken: string | null) => void 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. */ /** A React context hook returning the user session for a user that may or may not be logged in. */
export function useUserSession() { export function useUserSession() {
// eslint-disable-next-line no-restricted-syntax
return router.useOutletContext<UserSession | undefined>() return router.useOutletContext<UserSession | undefined>()
} }

View File

@ -10,6 +10,7 @@ import * as categoryModule from '#/layouts/CategorySwitcher/Category'
import type Category from '#/layouts/CategorySwitcher/Category' import type Category from '#/layouts/CategorySwitcher/Category'
import type Backend from '#/services/Backend' import type Backend from '#/services/Backend'
import type RemoteBackend from '#/services/RemoteBackend'
// ====================== // ======================
// === BackendContext === // === BackendContext ===
@ -17,7 +18,7 @@ import type Backend from '#/services/Backend'
/** State contained in a `BackendContext`. */ /** State contained in a `BackendContext`. */
export interface BackendContextType { export interface BackendContextType {
readonly remoteBackend: Backend | null readonly remoteBackend: RemoteBackend | null
readonly localBackend: Backend | null readonly localBackend: Backend | null
} }
@ -28,7 +29,7 @@ const BackendContext = React.createContext<BackendContextType>({
/** Props for a {@link BackendProvider}. */ /** Props for a {@link BackendProvider}. */
export interface BackendProviderProps extends Readonly<React.PropsWithChildren> { export interface BackendProviderProps extends Readonly<React.PropsWithChildren> {
readonly remoteBackend: Backend | null readonly remoteBackend: RemoteBackend | null
readonly localBackend: Backend | null readonly localBackend: Backend | null
} }

View File

@ -11,7 +11,6 @@ declare module '@tanstack/react-query' {
* Specifies the invalidation behavior of a mutation. * Specifies the invalidation behavior of a mutation.
*/ */
interface Register { interface Register {
// eslint-disable-next-line no-restricted-syntax
readonly mutationMeta: { readonly mutationMeta: {
/** /**
* List of query keys to invalidate when the mutation succeeds. * List of query keys to invalidate when the mutation succeeds.