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 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}

View File

@ -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',

View File

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

View File

@ -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> &

View File

@ -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
}

View File

@ -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>,

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
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]',
},
],
})
/**

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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>
)

View File

@ -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>
)
}

View File

@ -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' },
},

View File

@ -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}

View File

@ -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: {

View File

@ -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>
)

View File

@ -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',
})

View File

@ -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>
)
}

View File

@ -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' })

View File

@ -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 })

View File

@ -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"

View File

@ -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')}.{'&nbsp;'}
{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
>

View File

@ -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>

View File

@ -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 ?? '',
})

View File

@ -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>

View File

@ -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>()
}

View File

@ -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
}

View File

@ -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.