This commit is contained in:
Sergey Garin 2024-05-21 19:06:37 +03:00
parent d21140e422
commit 91107f3755
55 changed files with 2015 additions and 578 deletions

View File

@ -87,10 +87,6 @@ const RESTRICTED_SYNTAXES = [
selector: `:matches(ImportDefaultSpecifier[local.name=/^${NAME}/i], ImportNamespaceSpecifier > Identifier[name=/^${NAME}/i])`,
message: `Don't prefix modules with \`${NAME}\``,
},
{
selector: 'TSTypeLiteral',
message: 'No object types - use interfaces instead',
},
{
selector: 'ForOfStatement > .left[kind=let]',
message: 'Use `for (const x of xs)`, not `for (let x of xs)`',
@ -137,14 +133,6 @@ const RESTRICTED_SYNTAXES = [
selector: `TSAsExpression:not(:has(TSTypeReference > Identifier[name=const]))`,
message: 'Avoid `as T`. Consider using a type annotation instead.',
},
{
selector: `:matches(\
TSUndefinedKeyword,\
Identifier[name=undefined],\
UnaryExpression[operator=void]:not(:has(CallExpression.argument)), BinaryExpression[operator=/^===?$/]:has(UnaryExpression.left[operator=typeof]):has(Literal.right[value=undefined])\
)`,
message: 'Use `null` instead of `undefined`, `void 0`, or `typeof x === "undefined"`',
},
{
selector: 'ExportNamedDeclaration > VariableDeclaration[kind=let]',
message: 'Use `export const` instead of `export let`',
@ -453,11 +441,6 @@ export default [
selector: ':not(TSModuleDeclaration)[declare=true]',
message: 'No ambient declarations',
},
{
selector: 'ExportDefaultDeclaration:has(Identifier.declaration)',
message:
'Use `export default` on the declaration, instead of as a separate statement',
},
],
// This rule does not work with TypeScript, and TypeScript already does this.
'no-undef': 'off',

View File

@ -70,6 +70,7 @@ import * as subscribeSuccess from '#/pages/subscribe/SubscribeSuccess'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as loader from '#/components/Loader'
import * as paywall from '#/components/Paywall'
import * as rootComponent from '#/components/Root'
import AboutModal from '#/modals/AboutModal'
@ -88,7 +89,6 @@ import * as object from '#/utilities/object'
import * as authServiceModule from '#/authentication/service'
import type * as types from '../../types/types'
import * as reactQueryDevtools from './ReactQueryDevtools'
// ============================
// === Global configuration ===
@ -176,7 +176,7 @@ export default function App(props: AppProps) {
const routerFuture: Partial<router.FutureConfig> = {
/* we want to use startTransition to enable concurrent rendering */
/* eslint-disable-next-line @typescript-eslint/naming-convention */
v7_startTransition: true,
v7_startTransition: false,
}
// Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`.
@ -200,8 +200,6 @@ export default function App(props: AppProps) {
</ModalProvider>
</LocalStorageProvider>
</router.BrowserRouter>
<reactQueryDevtools.ReactQueryDevtools />
</>
)
}
@ -486,6 +484,9 @@ 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

@ -43,3 +43,17 @@ export const ALL_PATHS_REGEX = new RegExp(
// ===========
export const SEARCH_PARAMS_PREFIX = 'cloud-ide_'
/**
* Build a Subscription URL for a given plan.
*/
export function getUpgradeURL(plan: string): string {
return SUBSCRIBE_PATH + '?plan=' + plan
}
/**
* Build a Subscription URL for contacting sales.
*/
export function getContactSalesURL(): string {
return 'mailto:contact@enso.org?subject=Upgrading%20to%20Organization%20Plan'
}

View File

@ -16,8 +16,8 @@ import SvgMask from '#/components/SvgMask'
/** Props for a {@link Button}. */
export type ButtonProps =
| (BaseButtonProps & Omit<aria.ButtonProps, 'onPress' | 'type'> & PropsWithoutHref)
| (BaseButtonProps & Omit<aria.LinkProps, 'onPress' | 'type'> & PropsWithHref)
| (BaseButtonProps & Omit<aria.ButtonProps, 'children' | 'onPress' | 'type'> & PropsWithoutHref)
| (BaseButtonProps & Omit<aria.LinkProps, 'children' | 'onPress' | 'type'> & PropsWithHref)
/**
* Props for a button with an href.
@ -54,7 +54,7 @@ export interface BaseButtonProps extends Omit<twv.VariantProps<typeof BUTTON_STY
* If the handler returns a promise, the button will be in a loading state until the promise resolves.
*/
readonly onPress?: (event: aria.PressEvent) => Promise<void> | void
readonly children?: React.ReactNode
readonly testId?: string
readonly formnovalidate?: boolean
@ -65,20 +65,46 @@ export const BUTTON_STYLES = twv.tv({
variants: {
isDisabled: { true: 'disabled:opacity-50 disabled:cursor-not-allowed' },
isFocused: {
true: 'focus:outline-none focus-visible:outline focus-visible:outline-primary',
true: 'focus:outline-none focus-visible:outline focus-visible:outline-primary focus-visible:outline-offset-2',
},
loading: { true: { base: 'cursor-wait' } },
fullWidth: { true: 'w-full' },
size: {
custom: '',
hero: 'px-8 py-4 text-lg font-bold',
large: 'px-6 py-3 text-base font-bold',
medium: 'px-4 py-2 text-sm font-bold',
small: 'px-3 pt-1 pb-[5px] text-xs font-medium',
xsmall: 'px-2 pt-1 pb-[5px] text-xs font-medium',
xxsmall: 'px-1.5 pt-1 pb-[5px] text-xs font-medium',
custom: { base: '', extraClickZone: 'after:inset-[-12px]' },
hero: { base: 'px-8 py-4 text-lg font-bold', content: 'gap-[0.75em]' },
large: {
base: 'px-2.5 py-[5px] text-sm font-bold',
content: 'gap-2',
text: 'pt-0.5 pb-1 leading-[22px]',
extraClickZone: 'after:inset-[-6px]',
},
medium: {
base: 'px-[7px] py-[3px] text-xs font-bold',
text: 'pt-[1px] pb-[3px] leading-5',
content: 'gap-2',
icon: 'w-4 h-4',
extraClickZone: 'after:inset-[-8px]',
},
small: {
base: 'px-3 py-1 text-xs font-medium',
content: 'gap-1',
icon: 'w-4 h-4',
extraClickZone: 'after:inset-[-10px]',
},
xsmall: {
base: 'px-2 py-1 text-xs font-medium',
content: 'gap-1',
icon: 'w-3.5 h-3.5',
extraClickZone: 'after:inset-[-12px]',
},
xxsmall: {
base: 'px-1.5 py-0.5 text-xs font-medium',
content: 'gap-0.5',
icon: 'w-3.5 h-3.5',
extraClickZone: 'after:inset-[-12px]',
},
},
iconOnly: { true: '' },
iconOnly: { true: { base: '', icon: 'w-full h-full' } },
rounded: {
full: 'rounded-full',
large: 'rounded-lg',
@ -91,17 +117,24 @@ export const BUTTON_STYLES = twv.tv({
},
variant: {
custom: 'focus-visible:outline-offset-2',
link: 'inline-flex px-0 py-0 rounded-sm text-primary/50 underline hover:text-primary focus-visible:outline-offset-0',
primary: 'bg-primary text-white hover:bg-primary/70 focus-visible:outline-offset-2',
tertiary: 'bg-share text-white hover:bg-share/90 focus-visible:outline-offset-2',
cancel: 'bg-selected-frame opacity-80 hover:opacity-100 focus-visible:outline-offset-2',
delete: 'bg-delete text-white focus-visible:outline-offset-2',
link: {
base: 'inline-flex px-0 py-0 rounded-sm text-primary/50 underline hover:text-primary border-none',
content: 'gap-1',
icon: 'h-[1em]',
},
primary: 'bg-primary text-white hover:bg-primary/70',
tertiary: 'bg-share text-white hover:bg-share/90',
cancel: 'bg-white/50 hover:bg-white',
delete:
'bg-danger/80 hover:bg-danger text-white focus-visible:outline-danger focus-visible:bg-danger',
icon: {
base: 'opacity-70 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-offset-0',
base: 'opacity-80 hover:opacity-100 focus-visible:opacity-100',
wrapper: 'w-full h-full',
content: 'w-full h-full',
icon: 'w-fit h-fit',
extraClickZone: 'w-full h-full',
},
ghost:
'opacity-80 hover:opacity-100 hover:bg-white focus-visible:opacity-100 focus-visible:bg-white',
submit: 'bg-invite text-white opacity-80 hover:opacity-100 focus-visible:outline-offset-2',
outline: 'border-primary/40 text-primary hover:border-primary focus-visible:outline-offset-2',
},
@ -114,11 +147,12 @@ export const BUTTON_STYLES = twv.tv({
},
},
slots: {
extraClickZone: 'flex relative after:inset-[-12px] after:absolute',
extraClickZone: 'flex relative after:absolute after:cursor-pointer',
wrapper: 'relative block',
loader: 'absolute inset-0 flex items-center justify-center',
content: 'flex items-center gap-[0.5em]',
icon: 'h-[1.5em] flex-none',
text: '',
icon: 'h-[1.5em] flex-none aspect-square',
},
defaultVariants: {
loading: false,
@ -130,12 +164,24 @@ export const BUTTON_STYLES = twv.tv({
showIconOnHover: false,
},
compoundVariants: [
{ variant: 'icon', size: 'xxsmall', class: 'p-0.5 rounded-full', iconOnly: true },
{ variant: 'icon', size: 'xsmall', class: 'p-1 rounded-full', iconOnly: true },
{ variant: 'icon', size: 'small', class: 'p-1 rounded-full', iconOnly: true },
{ variant: 'icon', size: 'medium', class: 'p-2 rounded-full', iconOnly: true },
{ variant: 'icon', size: 'large', class: 'p-3 rounded-full', iconOnly: true },
{ variant: 'icon', size: 'hero', class: 'p-4 rounded-full', iconOnly: true },
{ variant: 'icon', isFocused: true, class: 'focus-visible:outline-offset-0' },
{ variant: 'link', isFocused: true, class: 'focus-visible:outline-offset-1' },
{
variant: 'icon',
size: 'xxsmall',
class: 'aspect-square rounded-full w-4 p-0',
iconOnly: true,
},
{
variant: 'icon',
size: 'xsmall',
class: 'aspect-square p-0 rounded-full w-5',
iconOnly: true,
},
{ size: 'small', class: 'aspect-square p-0 rounded-full w-6', iconOnly: true },
{ size: 'medium', class: 'aspect-square p-0 rounded-full w-8', iconOnly: true },
{ size: 'large', class: 'aspect-square p-0 rounded-full w-10', iconOnly: true },
{ size: 'hero', class: 'aspect-square p-0 rounded-full w-16', iconOnly: true },
{ variant: 'link', size: 'xxsmall', class: 'font-medium' },
{ variant: 'link', size: 'xsmall', class: 'font-medium' },
{ variant: 'link', size: 'small', class: 'font-medium' },
@ -230,6 +276,7 @@ export const Button = React.forwardRef(function Button(
loader,
extraClickZone,
icon: iconClasses,
text,
} = BUTTON_STYLES({
isDisabled,
loading: isLoading,
@ -260,7 +307,7 @@ export const Button = React.forwardRef(function Button(
return (
<>
{iconComponent}
<>{children}</>
<span className={text()}>{children}</span>
</>
)
}

View File

@ -14,12 +14,14 @@ const STYLES = twv.tv({
base: 'flex w-full flex-1 shrink-0',
variants: {
wrap: { true: 'flex-wrap' },
direction: { column: 'flex-col justify-center', row: 'flex-row items-center' },
direction: { column: 'flex-col', row: 'flex-row' },
gap: {
custom: '',
large: 'gap-3.5',
medium: 'gap-2',
small: 'gap-1.5',
xsmall: 'gap-1',
xxsmall: 'gap-0.5',
none: 'gap-0',
},
align: {
@ -31,6 +33,11 @@ const STYLES = twv.tv({
evenly: 'justify-evenly',
},
},
compoundVariants: [
{ direction: 'column', align: 'start', class: 'items-start' },
{ direction: 'column', align: 'center', class: 'items-center' },
{ direction: 'column', align: 'end', class: 'items-end' },
],
})
/**

View File

@ -2,7 +2,6 @@
* Can be used to display alerts, confirmations, or other content. */
import * as React from 'react'
import clsx from 'clsx'
import * as twv from 'tailwind-variants'
import * as aria from '#/components/aria'
@ -48,10 +47,14 @@ const DIALOG_STYLES = twv.tv({
modal: 'w-full max-w-md min-h-[100px] max-h-[90vh]',
fullscreen: 'w-full h-full max-w-full max-h-full bg-clip-border',
},
hideCloseButton: { true: { closeButton: 'hidden' } },
},
slots: {
header:
'sticky grid grid-cols-[1fr_auto_1fr] items-center border-b border-primary/10 px-3.5 py-2 text-primary',
'sticky grid grid-cols-[1fr_auto_1fr] items-center border-b border-primary/10 px-3.5 pt-1.5 pb-0.5',
closeButton: 'col-start-1 col-end-1 mr-auto',
heading: 'col-start-2 col-end-2 my-0',
content: 'relative flex-auto overflow-y-auto p-3.5',
},
})
@ -83,7 +86,7 @@ export function Dialog(props: DialogProps) {
const root = portal.useStrictPortalContext()
const shouldRenderTitle = typeof title === 'string'
const dialogSlots = DIALOG_STYLES({ className, type, rounded })
const dialogSlots = DIALOG_STYLES({ className, type, rounded, hideCloseButton })
utlities.useInteractOutside({
ref: dialogRef,
@ -151,23 +154,21 @@ export function Dialog(props: DialogProps) {
{shouldRenderTitle && (
<aria.Header className={dialogSlots.header()}>
<ariaComponents.CloseButton
className={clsx('col-start-1 col-end-1 mr-auto mt-0.5', {
hidden: hideCloseButton,
})}
className={dialogSlots.closeButton()}
onPress={opts.close}
/>
<aria.Heading
<ariaComponents.Text.Heading
slot="title"
level={2}
className="col-start-2 col-end-2 my-0 text-base font-semibold leading-6"
className={dialogSlots.heading()}
>
{title}
</aria.Heading>
</ariaComponents.Text.Heading>
</aria.Header>
)}
<div className="relative flex-auto overflow-y-auto p-3.5">
<div className={dialogSlots.content()}>
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader minHeight="h32" />}>
{typeof children === 'function' ? children(opts) : children}

View File

@ -31,7 +31,7 @@ export interface PopoverProps
export const POPOVER_STYLES = twv.tv({
extend: variants.DIALOG_BACKGROUND,
base: 'shadow-2xl w-full',
base: 'shadow-2xl w-full overflow-clip',
variants: {
isEntering: {
true: 'animate-in fade-in placement-bottom:slide-in-from-top-1 placement-top:slide-in-from-bottom-1 placement-left:slide-in-from-right-1 placement-right:slide-in-from-left-1 ease-out duration-200',
@ -40,11 +40,11 @@ export const POPOVER_STYLES = twv.tv({
true: 'animate-out fade-out placement-bottom:slide-out-to-top-1 placement-top:slide-out-to-bottom-1 placement-left:slide-out-to-right-1 placement-right:slide-out-to-left-1 ease-in duration-150',
},
size: {
xsmall: { base: 'max-w-xs', content: 'p-2.5' },
small: { base: 'max-w-sm', content: 'p-3.5' },
medium: { base: 'max-w-md', content: 'p-3.5' },
large: { base: 'max-w-lg', content: 'px-4 py-4' },
hero: { base: 'max-w-xl', content: 'px-6 py-5' },
xsmall: { base: 'max-w-xs', dialog: 'p-2.5' },
small: { base: 'max-w-sm', dialog: 'p-3.5' },
medium: { base: 'max-w-md', dialog: 'p-3.5' },
large: { base: 'max-w-lg', dialog: 'px-4 py-4' },
hero: { base: 'max-w-xl', dialog: 'px-6 py-5' },
},
rounded: {
none: '',
@ -57,7 +57,7 @@ export const POPOVER_STYLES = twv.tv({
},
},
slots: {
content: 'flex-auto overflow-y-auto',
dialog: 'max-h-[inherit] overflow-y-auto',
},
defaultVariants: { rounded: 'xxlarge', size: 'small' },
})
@ -106,20 +106,36 @@ export function Popover(props: PopoverProps) {
>
{opts => (
<dialogStackProvider.DialogStackRegistrar id={dialogId} type="popover">
<aria.Dialog id={dialogId} ref={dialogRef}>
<aria.Dialog
id={dialogId}
ref={dialogRef}
className={POPOVER_STYLES({
...opts,
size,
rounded,
className:
typeof className === 'function'
? className({
placement: opts.placement,
isExiting: opts.isExiting,
isEntering: opts.isEntering,
trigger: opts.trigger,
defaultClassName: undefined,
})
: className,
}).dialog()}
>
{({ close }) => {
closeRef.current = close
return (
<div className={POPOVER_STYLES({ ...opts, size, rounded }).content()}>
<dialogProvider.DialogProvider value={{ close, dialogId }}>
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader minHeight="h16" />}>
{typeof children === 'function' ? children({ ...opts, close }) : children}
</React.Suspense>
</errorBoundary.ErrorBoundary>
</dialogProvider.DialogProvider>
</div>
<dialogProvider.DialogProvider value={{ close, dialogId }}>
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader minHeight="h16" />}>
{typeof children === 'function' ? children({ ...opts, close }) : children}
</React.Suspense>
</errorBoundary.ErrorBoundary>
</dialogProvider.DialogProvider>
)
}}
</aria.Dialog>

View File

@ -8,6 +8,7 @@ import * as twv from 'tailwind-variants'
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as mergeRefs from '#/utilities/mergeRefs'
@ -16,7 +17,7 @@ import * as varants from './variants'
const CONTENT_EDITABLE_STYLES = twv.tv({
extend: varants.INPUT_STYLES,
base: '',
slots: { placeholder: 'text-primary/25 absolute inset-0 pointer-events-none' },
slots: { placeholder: 'opacity-50 absolute inset-0 pointer-events-none' },
})
/**
@ -106,22 +107,22 @@ export const ResizableContentEditableInput = React.forwardRef(
}}
/>
<span className={placeholderClass({ class: value ? 'hidden' : '' })}>
<ariaComponents.Text className={placeholderClass({ class: value ? 'hidden' : '' })}>
{placeholder}
</span>
</ariaComponents.Text>
</div>
{description != null && (
<aria.Text slot="description" className={descriptionClass()}>
<ariaComponents.Text slot="description" className={descriptionClass()}>
{description}
</aria.Text>
</ariaComponents.Text>
)}
</div>
{errorMessage != null && (
<aria.Text slot="errorMessage" className={error()}>
<ariaComponents.Text slot="errorMessage" color="danger" className={error()}>
{errorMessage}
</aria.Text>
</ariaComponents.Text>
)}
</aria.TextField>
)

View File

@ -6,15 +6,23 @@
import * as twv from 'tailwind-variants'
import * as text from '../../Text'
export const INPUT_STYLES = twv.tv({
base: 'w-full overflow-hidden block cursor-text rounded-md border-2 border-primary/10 bg-transparent px-1.5 pb-1 pt-2 focus-within:border-primary/50 transition-colors duration-200',
base: 'w-full overflow-hidden block cursor-text rounded-md border-2 border-primary/10 bg-transparent px-1.5 pb-1 pt-1.5 focus-within:border-primary/50 transition-colors duration-200',
variants: { isInvalid: { true: 'border-red-500/70 focus-within:border-red-500' } },
slots: {
inputContainer: 'block max-h-32 min-h-5 text-sm font-normal relative overflow-auto',
description: 'mt-1 block text-xs text-primary/40 select-none pointer-events-none',
error: 'block text-xs text-red-500',
inputContainer: text.TEXT_STYLE({
className: 'block max-h-32 min-h-6 text-sm font-medium relative overflow-auto',
variant: 'body',
}),
description: 'block select-none pointer-events-none opacity-80',
error: 'block',
textArea: 'block h-auto w-full max-h-full resize-none bg-transparent',
resizableSpan:
'pointer-events-none invisible absolute block max-h-32 min-h-10 overflow-y-auto break-all text-sm',
resizableSpan: text.TEXT_STYLE({
className:
'pointer-events-none invisible absolute block max-h-32 min-h-10 overflow-y-auto break-all',
variant: 'body',
}),
},
})

View File

@ -24,7 +24,7 @@ export interface TextProps
}
export const TEXT_STYLE = twv.tv({
base: 'inline-block',
base: 'inline-block flex-col before:block after:block before:flex-none after:flex-none before:w-full after:w-full',
variants: {
color: {
custom: '',
@ -39,9 +39,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] pt-[1px] pb-[3px]',
h1: 'text-xl leading-[29px] pt-[2px] pb-[5px]',
subtitle: 'text-[13.5px] leading-[20px] pt-[1px] pb-[3px]',
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]',
},
weight: {
custom: '',
@ -102,17 +102,17 @@ export const TEXT_STYLE = twv.tv({
{
variant: 'h1',
disableLineHeightCompensation: true,
class: 'pt-[unset] pb-[unset]',
class: 'before:h-[unset] after:h-[unset]',
},
{
variant: 'body',
disableLineHeightCompensation: true,
class: 'pt-[unset] pb-[unset]',
class: 'before:h-[unset] after:h-[unset]',
},
{
variant: 'subtitle',
disableLineHeightCompensation: true,
class: 'pt-[unset] pb-[unset]',
class: 'before:h-[unset] after:h-[unset]',
},
],
})

View File

@ -4,6 +4,8 @@ import * as twv from 'tailwind-variants'
import * as aria from '#/components/aria'
import * as portal from '#/components/Portal'
import * as text from '../Text'
// =================
// === Constants ===
// =================
@ -18,7 +20,7 @@ export const TOOLTIP_STYLES = twv.tv({
},
size: {
custom: '',
medium: 'text-xs leading-[25px] px-2 py-1',
medium: text.TEXT_STYLE({ className: 'px-2 py-1', color: 'custom', balance: true }),
},
rounded: {
custom: '',

View File

@ -232,7 +232,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
}`}
>
<div
className={`relative max-h-autocomplete-suggestions w-full overflow-auto rounded-default ${
className={`relative max-h-autocomplete-suggestions w-full overflow-y-auto overflow-x-hidden rounded-default ${
isDropdownVisible && matchingItems.length !== 0 ? '' : 'h'
}`}
>

View File

@ -74,7 +74,13 @@ function DefaultFallbackComponent(props: errorBoundary.FallbackProps): React.JSX
subtitle={getText('arbitraryErrorSubtitle')}
>
<ariaComponents.ButtonGroup align="center">
<ariaComponents.Button variant="submit" size="medium" onPress={resetErrorBoundary}>
<ariaComponents.Button
variant="submit"
size="large"
rounded="full"
className="w-24"
onPress={resetErrorBoundary}
>
{getText('tryAgain')}
</ariaComponents.Button>
</ariaComponents.ButtonGroup>

View File

@ -67,6 +67,7 @@ const ACTION_TO_TEXT_ID: Readonly<Record<inputBindings.DashboardBindingKey, text
/** Props for a {@link MenuEntry}. */
export interface MenuEntryProps {
readonly icon?: string
readonly hidden?: boolean
readonly action: inputBindings.DashboardBindingKey
/** Overrides the text for the menu entry. */
@ -80,11 +81,12 @@ export interface MenuEntryProps {
/** An item in a menu. */
export default function MenuEntry(props: MenuEntryProps) {
const { hidden = false, action, label, isDisabled = false, title } = props
const { hidden = false, action, label, isDisabled = false, title, icon } = props
const { isContextMenuEntry = false, doAction } = props
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const info = inputBindings.metadata[action]
React.useEffect(() => {
// This is slower (but more convenient) than registering every shortcut in the context menu
// at once.
@ -109,9 +111,10 @@ export default function MenuEntry(props: MenuEntryProps) {
}`}
>
<div title={title} className="flex items-center gap-menu-entry whitespace-nowrap">
<SvgMask src={info.icon ?? BlankIcon} color={info.color} className="size-icon" />
<SvgMask src={icon ?? info.icon ?? BlankIcon} color={info.color} className="h-4 w-4" />
<aria.Text slot="label">{label ?? getText(ACTION_TO_TEXT_ID[action])}</aria.Text>
</div>
<KeyboardShortcut action={action} />
</div>
</UnstyledButton>

View File

@ -0,0 +1,48 @@
/**
* @file
*
* A context menu entry that opens a paywall dialog.
*/
import * as React from 'react'
import LockIcon from 'enso-assets/lock.svg'
import type * as billingHooks from '#/hooks/billing'
import * as modalProvider from '#/providers/ModalProvider'
import type * as contextMenuEntry from '#/components/ContextMenuEntry'
import ContextMenuEntryBase from '#/components/ContextMenuEntry'
import * as paywallDialog from './PaywallDialog'
/**
* Props for {@link ContextMenuEntry}.
*/
export interface ContextMenuEntryProps
extends Omit<contextMenuEntry.ContextMenuEntryProps, 'doAction' | 'isDisabled'> {
readonly feature: billingHooks.PaywallFeatureName
}
/**
* A context menu entry that opens a paywall dialog.
*/
export function ContextMenuEntry(props: ContextMenuEntryProps) {
const { feature, ...rest } = props
const { setModal } = modalProvider.useSetModal()
return (
<>
<ContextMenuEntryBase
{...rest}
icon={LockIcon}
doAction={() => {
setModal(
<paywallDialog.PaywallDialog modalProps={{ defaultOpen: true }} feature={feature} />
)
}}
/>
</>
)
}

View File

@ -0,0 +1,67 @@
/**
* @file
*
* A paywall alert.
*/
import * as React from 'react'
import clsx from 'clsx'
import LockIcon from 'enso-assets/lock.svg'
import type * as billingHooks from '#/hooks/billing'
import * as ariaComponents from '#/components/AriaComponents'
import * as paywall from '#/components/Paywall'
import SvgMask from '#/components/SvgMask'
/**
* Props for {@link PaywallAlert}.
*/
export interface PaywallAlertProps extends Omit<ariaComponents.AlertProps, 'children'> {
readonly feature: billingHooks.PaywallFeatureName
readonly label: string
readonly showUpgradeButton?: boolean
readonly upgradeButtonProps?: Omit<paywall.UpgradeButtonProps, 'feature'>
}
/**
* A paywall alert.
*/
export function PaywallAlert(props: PaywallAlertProps) {
const {
label,
showUpgradeButton = true,
feature,
upgradeButtonProps,
className,
...alertProps
} = props
return (
<ariaComponents.Alert
variant="outline"
size="small"
rounded="large"
className={clsx('border border-primary/20', className)}
{...alertProps}
>
<div className="flex items-center gap-2">
<SvgMask src={LockIcon} className="h-5 w-5 flex-none text-primary" />
<ariaComponents.Text>
{label}{' '}
{showUpgradeButton && (
<paywall.UpgradeButton
feature={feature}
variant="link"
size="small"
{...upgradeButtonProps}
/>
)}
</ariaComponents.Text>
</div>
</ariaComponents.Alert>
)
}

View File

@ -0,0 +1,54 @@
/**
* @file
*
* A dialog that prompts the user to upgrade to a paid plan.
*/
import * as React from 'react'
import * as billingHooks from '#/hooks/billing'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import * as components from './components'
import * as upgradeButton from './UpgradeButton'
/**
* Props for a {@link PaywallDialog}.
*/
export interface PaywallDialogProps extends ariaComponents.DialogProps {
readonly feature: billingHooks.PaywallFeatureName
}
/**
* A dialog that prompts the user to upgrade to a paid plan.
*/
export function PaywallDialog(props: PaywallDialogProps) {
const { feature, type = 'modal', title, ...dialogProps } = props
const { getText } = textProvider.useText()
const { getFeature } = billingHooks.usePaywallFeatures()
const { bulletPointsTextId, label, descriptionTextId } = getFeature(feature)
return (
<ariaComponents.Dialog type={type} title={title ?? getText(label)} {...dialogProps}>
<div className="flex flex-col">
<components.PaywallLock feature={feature} className="mb-2" />
<ariaComponents.Text variant="subtitle">{getText(descriptionTextId)}</ariaComponents.Text>
<components.PaywallBulletPoints bulletPointsTextId={bulletPointsTextId} className="my-2" />
<upgradeButton.UpgradeButton
feature={feature}
rounded="xlarge"
className="mt-2"
size="large"
/>
</div>
</ariaComponents.Dialog>
)
}

View File

@ -0,0 +1,35 @@
/**
* @file
*
* A button that opens a paywall dialog when clicked.
*/
import * as React from 'react'
import * as ariaComponents from '#/components/AriaComponents'
import * as components from './components'
import * as paywallDialog from './PaywallDialog'
/**
* Props for a {@link PaywallDialogButton}.
*/
export interface PaywallDialogButtonProps extends components.PaywallButtonProps {
readonly dialogProps?: paywallDialog.PaywallDialogProps
readonly dialogTriggerProps?: ariaComponents.DialogTriggerProps
}
/**
* A button that opens a paywall dialog when clicked
*/
export function PaywallDialogButton(props: PaywallDialogButtonProps) {
const { feature, dialogProps, dialogTriggerProps, ...buttonProps } = props
return (
<ariaComponents.DialogTrigger {...dialogTriggerProps}>
<components.PaywallButton feature={feature} {...buttonProps} />
<paywallDialog.PaywallDialog feature={feature} {...dialogProps} />
</ariaComponents.DialogTrigger>
)
}

View File

@ -0,0 +1,56 @@
/**
* @file
*
* A screen that shows a paywall.
*/
import * as React from 'react'
import * as tw from 'tailwind-merge'
import * as billingHooks from '#/hooks/billing'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import * as components from './components'
import * as upgradeButton from './UpgradeButton'
/**
* Props for a {@link PaywallScreen}.
*/
export interface PaywallScreenProps {
readonly feature: billingHooks.PaywallFeatureName
readonly className?: string
}
/**
* A screen that shows a paywall.
*/
export function PaywallScreen(props: PaywallScreenProps) {
const { feature, className } = props
const { getText } = textProvider.useText()
const { getFeature } = billingHooks.usePaywallFeatures()
const { bulletPointsTextId, descriptionTextId } = getFeature(feature)
return (
<div className={tw.twMerge('flex flex-col items-start', className)}>
<components.PaywallLock feature={feature} />
<ariaComponents.Text.Heading level="2">
{getText('paywallScreenTitle')}
</ariaComponents.Text.Heading>
<ariaComponents.Text balance variant="subtitle" className="mt-1 max-w-[720px]">
{getText(descriptionTextId)}
</ariaComponents.Text>
<components.PaywallBulletPoints bulletPointsTextId={bulletPointsTextId} className="my-3" />
<upgradeButton.UpgradeButton feature={feature} className="mt-0.5 min-w-36" />
</div>
)
}

View File

@ -0,0 +1,73 @@
/**
* @file
*
* A button that links to the upgrade page.
*/
import * as React from 'react'
import * as appUtils from '#/appUtils'
import * as billingHooks from '#/hooks/billing'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
/**
* Props for an {@link UpgradeButton}.
*/
export type UpgradeButtonProps = Omit<ariaComponents.ButtonProps, 'variant'> & {
readonly feature: billingHooks.PaywallFeatureName
readonly variant?: ariaComponents.ButtonProps['variant']
}
/**
* A button that links to the upgrade page.
*/
export function UpgradeButton(props: UpgradeButtonProps) {
const {
feature,
variant,
href,
size = 'medium',
rounded = 'xlarge',
children,
...buttonProps
} = props
const { getText } = textProvider.useText()
const { getFeature } = billingHooks.usePaywallFeatures()
const { level } = getFeature(feature)
const levelLabel = getText(level.label)
const isEnterprise = level === billingHooks.PAYWALL_LEVELS.enterprise
const child =
children ?? (isEnterprise ? getText('contactSales') : getText('upgradeTo', levelLabel))
return (
<ariaComponents.Button
variant={variant ?? VARIANT_BY_LEVEL[level.name]}
size={size}
rounded={rounded}
href={
isEnterprise ? appUtils.getContactSalesURL() : href ?? appUtils.getUpgradeURL(level.name)
}
/* This is safe because we are passing all props to the button */
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
{...(buttonProps as any)}
>
{child}
</ariaComponents.Button>
)
}
const VARIANT_BY_LEVEL: Record<
billingHooks.PaywallLevelName,
ariaComponents.ButtonProps['variant']
> = {
free: 'primary',
enterprise: 'primary',
solo: 'outline',
team: 'submit',
}

View File

@ -0,0 +1,61 @@
/**
* @file
*
* A component that renders a list of bullet points for a paywall.
*/
import * as React from 'react'
import * as tw from 'tailwind-merge'
import Check from 'enso-assets/check_mark.svg'
import type * as text from '#/text'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import SvgMask from '#/components/SvgMask'
/**
* Props for a {@link PaywallBulletPoints}.
*/
export interface PaywallBulletPointsProps {
readonly bulletPointsTextId: text.TextId
readonly className?: string
}
/**
* A component that renders a list of bullet points for a paywall.
*/
export function PaywallBulletPoints(props: PaywallBulletPointsProps) {
const { bulletPointsTextId, className } = props
const { getText } = textProvider.useText()
const bulletPoints = getText(bulletPointsTextId)
.split(';')
.map(bulletPoint => bulletPoint.trim())
if (bulletPoints.length === 0) {
return null
} else {
return (
<ul className={tw.twMerge('m-0 flex w-full list-inside list-none flex-col gap-1', className)}>
{bulletPoints.map(bulletPoint => (
<li key={bulletPoint} className="flex items-start gap-1.5">
<div className="m-0 flex">
<div className="m-0 flex">
<span className="mt-1 flex aspect-square h-4 flex-none place-items-center justify-center rounded-full bg-green/30">
<SvgMask src={Check} className="text-green" />
</span>
</div>
</div>
<ariaComponents.Text className="flex-grow" variant="body">
{bulletPoint}
</ariaComponents.Text>
</li>
))}
</ul>
)
}
}

View File

@ -0,0 +1,54 @@
/**
* @file
*
* A styled button that shows that a feature is behind a paywall
*/
import * as React from 'react'
import PaywallBlocked from 'enso-assets/lock.svg'
import * as billingHooks from '#/hooks/billing'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
/**
* Props for {@link PaywallButton}.
*/
export type PaywallButtonProps = Omit<ariaComponents.ButtonProps, 'variant'> & {
readonly feature: billingHooks.PaywallFeatureName
readonly variant?: ariaComponents.ButtonProps['variant']
readonly iconOnly?: boolean
readonly showIcon?: boolean
}
/**
* A styled button that shows that a feature is behind a paywall
*/
export function PaywallButton(props: PaywallButtonProps) {
const { feature, iconOnly = false, showIcon = true, children, ...buttonProps } = props
const { getText } = textProvider.useText()
const { getFeature } = billingHooks.usePaywallFeatures()
const { level } = getFeature(feature)
const levelLabel = getText(level.label)
const showChildren = !iconOnly
const childrenContent = children ?? getText('upgradeTo', levelLabel)
return (
<ariaComponents.Button
variant="primary"
size="medium"
icon={showIcon === true ? PaywallBlocked : null}
iconPosition="end"
tooltip={getText('paywallScreenDescription', levelLabel)}
{...buttonProps}
>
{showChildren && childrenContent}
</ariaComponents.Button>
)
}

View File

@ -0,0 +1,133 @@
/**
* @file
*
* A component that provides a UI for toggling paywall features.
*/
import * as React from 'react'
import DevtoolsLogo from 'enso-assets/enso_logo.svg'
import * as billing from '#/hooks/billing'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import Portal from '#/components/Portal'
/**
* Configuration for a paywall feature.
*/
export interface PaywallDevtoolsFeatureConfiguration {
readonly isForceEnabled: boolean | null
}
const PaywallDevtoolsContext = React.createContext<{
features: Record<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
}>({
features: {
share: { isForceEnabled: null },
shareFull: { isForceEnabled: null },
userGroups: { isForceEnabled: null },
userGroupsFull: { isForceEnabled: null },
inviteUser: { isForceEnabled: null },
inviteUserFull: { isForceEnabled: null },
},
})
/**
* Props for the {@link PaywallDevtools} component.
*/
interface PaywallDevtoolsProps extends React.PropsWithChildren {}
/**
* A component that provides a UI for toggling paywall features.
*/
export function PaywallDevtools(props: PaywallDevtoolsProps) {
const { children } = props
const { getText } = textProvider.useText()
const [features, setFeatures] = React.useState<
Record<billing.PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
>({
share: { isForceEnabled: null },
shareFull: { isForceEnabled: null },
userGroups: { isForceEnabled: null },
userGroupsFull: { isForceEnabled: null },
inviteUser: { isForceEnabled: null },
inviteUserFull: { isForceEnabled: null },
})
const { getFeature } = billing.usePaywallFeatures()
const onConfigurationChange = React.useCallback(
(feature: billing.PaywallFeatureName, configuration: PaywallDevtoolsFeatureConfiguration) => {
setFeatures(prev => ({ ...prev, [feature]: configuration }))
},
[]
)
return (
<PaywallDevtoolsContext.Provider value={{ features }}>
{children}
<Portal>
<ariaComponents.DialogTrigger>
<ariaComponents.Button
icon={DevtoolsLogo}
aria-label={getText('paywallDevtoolsButtonLabel')}
variant="icon"
rounded="full"
size="large"
className="fixed bottom-16 right-4 z-50"
data-ignore-click-outside
/>
<ariaComponents.Popover>
<ariaComponents.Text.Heading>
{getText('paywallDevtoolsPopoverHeading')}
</ariaComponents.Text.Heading>
<div className="flex flex-col gap-1">
{Object.entries(features).map(([feature, configuration]) => {
// eslint-disable-next-line no-restricted-syntax
const featureName = feature as billing.PaywallFeatureName
const { label, descriptionTextId } = getFeature(featureName)
return (
<div key={feature} className="flex flex-col">
<aria.Switch
className="group flex items-center gap-1"
isSelected={configuration.isForceEnabled ?? true}
onChange={value => {
onConfigurationChange(featureName, {
isForceEnabled: value,
})
}}
>
<div className="box-border flex h-[14px] w-[22px] shrink-0 cursor-default rounded-full border border-solid border-white/30 bg-yellow-600 bg-clip-padding p-[2px] shadow-inner outline-none ring-black transition duration-200 ease-in-out group-focus-visible:ring-2 group-pressed:bg-yellow-700 group-selected:bg-amber-800 group-selected:group-pressed:bg-amber-900">
<span className="h-2 w-2 translate-x-0 transform rounded-full bg-white shadow transition duration-200 ease-in-out group-selected:translate-x-[100%]" />
</div>
<ariaComponents.Text className="flex-1">{getText(label)}</ariaComponents.Text>
</aria.Switch>
<ariaComponents.Text variant="body" color="disabled">
{getText(descriptionTextId)}
</ariaComponents.Text>
</div>
)
})}
</div>
</ariaComponents.Popover>
</ariaComponents.DialogTrigger>
</Portal>
</PaywallDevtoolsContext.Provider>
)
}
/**
* A hook that provides access to the paywall devtools.
*/
export function usePaywallDevtools() {
return React.useContext(PaywallDevtoolsContext)
}

View File

@ -0,0 +1,43 @@
/**
* @file A lock icon with a label indicating the paywall level required to access a feature.
*/
import * as tw from 'tailwind-merge'
import LockIcon from 'enso-assets/lock.svg'
import * as billingHooks from '#/hooks/billing'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import SvgMask from '#/components/SvgMask'
/**
* Props for a {@link PaywallLock}.
*/
export interface PaywallLockProps {
readonly feature: billingHooks.PaywallFeatureName
readonly className?: string
}
/**
* A lock icon with a label indicating the paywall level required to access a feature.
*/
export function PaywallLock(props: PaywallLockProps) {
const { feature, className } = props
const { getText } = textProvider.useText()
const { getFeature } = billingHooks.usePaywallFeatures()
const { level } = getFeature(feature)
const levelLabel = getText(level.label)
return (
<div className={tw.twMerge('flex w-full items-center gap-1', className)}>
<SvgMask src={LockIcon} className="-mt-0.5 h-4 w-4" />
<ariaComponents.Text variant="subtitle">
{getText('paywallAvailabilityLevel', levelLabel)}
</ariaComponents.Text>
</div>
)
}

View File

@ -0,0 +1,9 @@
/**
* @file
*
* Barrel file for the Paywall components.
*/
export * from './PaywallLock'
export * from './PaywallBulletPoints'
export * from './PaywallButton'
export * from './PaywallDevtools'

View File

@ -0,0 +1,20 @@
/**
* @file
*
* Barrel file for Paywall components.
*/
export * from './PaywallScreen'
export * from './PaywallDialogButton'
export * from './PaywallDialog'
export * from './UpgradeButton'
export * from './PaywallAlert'
export * from './ContextMenuEntry'
/* eslint-disable no-restricted-syntax */
export {
PaywallButton,
type PaywallButtonProps,
PaywallDevtools,
usePaywallDevtools,
type PaywallDevtoolsFeatureConfiguration,
} from './components'
/* eslint-enable no-restricted-syntax */

View File

@ -82,14 +82,17 @@ export function Result(props: ResultProps) {
) : null}
{typeof title === 'string' ? (
<aria.Heading level={2} className="mb-2 text-2xl leading-10">
<aria.Heading level={2} className="mb-2 text-2xl leading-10 text-primary/60">
{title}
</aria.Heading>
) : (
title
)}
<aria.Text elementType="p" className="max-w-[750px] text-balance text-lg leading-6">
<aria.Text
elementType="p"
className="max-w-[750px] text-balance text-lg leading-6 text-primary/60"
>
{subtitle}
</aria.Text>

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'
@ -10,9 +12,10 @@ import AssetEventType from '#/events/AssetEventType'
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 UnstyledButton from '#/components/UnstyledButton'
import * as paywall from '#/components/Paywall'
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
@ -41,6 +44,11 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const { 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)
@ -83,10 +91,21 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
{backendModule.getAssetPermissionName(other)}
</PermissionDisplay>
))}
{managesThisAsset && (
<UnstyledButton
ref={plusButtonRef}
className="shrink-0 rounded-full transparent group-hover:opacity-100 focus-visible:opacity-100"
{isUnderPaywall && (
<paywall.PaywallDialogButton
feature="share"
variant="icon"
size="xxsmall"
className="opacity-0 group-hover:opacity-100"
children={false}
/>
)}
{managesThisAsset && !isUnderPaywall && (
<ariaComponents.Button
variant="icon"
size="xsmall"
icon={Plus2Icon}
className="opacity-0 group-hover:opacity-100"
onPress={() => {
setModal(
<ManagePermissionsModal
@ -104,9 +123,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
/>
)
}}
>
<img className="size-plus-icon" src={Plus2Icon} />
</UnstyledButton>
/>
)}
</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,35 @@ 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-drive-table-heading w-full items-center gap-icon-with-text">
<Button
active
image={PeopleIcon}
className="size-icon"
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>
<aria.Text className="text-header flex items-center gap-0.5">
{getText('sharedWithColumnName')}
{isUnderPaywall && (
<paywall.PaywallDialogButton
feature="share"
variant="icon"
children={false}
size="xxsmall"
/>
)}
</aria.Text>
</div>
)
}

View File

@ -17,7 +17,7 @@ export default function HorizontalMenuBar(props: HorizontalMenuBarProps) {
return (
<FocusArea direction="horizontal">
{innerProps => (
<div className="flex h-row gap-drive-bar" {...innerProps}>
<div className="flex items-center gap-drive-bar" {...innerProps}>
{children}
</div>
)}

View File

@ -1,10 +1,8 @@
/** @file A styled button representing a tab on a sidebar. */
import * as React from 'react'
import * as aria from '#/components/aria'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
import SvgMask from '#/components/SvgMask'
import UnstyledButton from '#/components/UnstyledButton'
import type * as aria from '#/components/aria'
import * as ariaComponent from '#/components/AriaComponents'
// ========================
// === SidebarTabButton ===
@ -20,38 +18,23 @@ export interface SidebarTabButtonProps {
readonly icon: string
readonly label: string
readonly onPress: (event: aria.PressEvent) => void
readonly isPending?: boolean
}
/** A styled button representing a tab on a sidebar. */
export default function SidebarTabButton(props: SidebarTabButtonProps) {
const {
isDisabled = false,
autoFocus = false,
active = false,
icon,
label,
onPress,
isPending = false,
} = props
const { isDisabled = false, active = false, icon, label, onPress } = props
return (
<UnstyledButton
autoFocus={autoFocus}
<ariaComponent.Button
variant="ghost"
size="medium"
onPress={onPress}
isDisabled={isDisabled}
className={`relative rounded-full ${active ? 'focus-default' : ''}`}
icon={icon}
rounded="full"
className={active ? 'bg-white opacity-100' : ''}
>
<div
className={`button icon-with-text h-row px-button-x transition-colors selectable hover:bg-selected-frame ${active ? 'disabled bg-selected-frame active' : ''}`}
>
{active && isPending ? (
<StatelessSpinner state={statelessSpinner.SpinnerState.loadingMedium} size={16} />
) : (
<SvgMask src={icon} />
)}
<aria.Text className="text">{label}</aria.Text>
</div>
</UnstyledButton>
{label}
</ariaComponent.Button>
)
}

View File

@ -0,0 +1,171 @@
/**
* @file
*
* Paywall configuration for different plans.
*/
import type * as text from '#/text'
import * as backend from '#/services/Backend'
/**
* Registered paywall features.
*/
export const PAYWALL_FEATURES = {
userGroups: 'userGroups',
userGroupsFull: 'userGroupsFull',
inviteUser: 'inviteUser',
inviteUserFull: 'inviteUserFull',
share: 'share',
shareFull: 'shareFull',
} as const
/**
* Paywall features.
*/
export type PaywallFeatureName = keyof typeof PAYWALL_FEATURES
/**
* Paywall level names
*/
export type PaywallLevelName = backend.Plan | 'free'
/**
* Paywall level values.
* Used to define the paywall levels and their corresponding labels.
* The value is a number that represents the level of the paywall.
* Because the paywall levels are ordered and inclusive, the value is used to compare the levels.
*/
export type PaywallLevelValue =
| (0 & { readonly name: PaywallLevelName; readonly label: text.TextId })
| (1 & { readonly name: PaywallLevelName; readonly label: text.TextId })
| (2 & { readonly name: PaywallLevelName; readonly label: text.TextId })
| (3 & { readonly name: PaywallLevelName; readonly label: text.TextId })
/**
* Paywall levels configuration.
*/
export const PAYWALL_LEVELS: Record<PaywallLevelName, PaywallLevelValue> = {
free: Object.assign(0, { name: 'free', label: 'freePlanName' } as const),
[backend.Plan.solo]: Object.assign(1, {
name: backend.Plan.solo,
label: 'soloPlanName',
} as const),
[backend.Plan.team]: Object.assign(2, {
name: backend.Plan.team,
label: 'teamPlanName',
} as const),
[backend.Plan.enterprise]: Object.assign(3, {
name: backend.Plan.enterprise,
label: 'enterprisePlanName',
} as const),
}
/**
*
*/
export type PaywallLevel = (typeof PAYWALL_LEVELS)[keyof typeof PAYWALL_LEVELS]
/**
* Paywall feature labels.
*/
const PAYWALL_FEATURES_LABELS: Record<PaywallFeatureName, text.TextId> = {
userGroups: 'userGroupsFeatureLabel',
userGroupsFull: 'userGroupsFullFeatureLabel',
inviteUser: 'inviteUserFeatureLabel',
inviteUserFull: 'inviteUserFullFeatureLabel',
share: 'shareFeatureLabel',
shareFull: 'shareFullFeatureLabel',
} satisfies { [K in PaywallFeatureName]: `${K}FeatureLabel` }
const PAYWALL_FEATURE_META = {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
inviteUser: { maxSeats: 10 },
inviteUserFull: undefined,
userGroups: { maxGroups: 1 },
userGroupsFull: undefined,
share: undefined,
shareFull: undefined,
} satisfies { [K in PaywallFeatureName]: unknown }
/**
* Basic feature configuration.
*/
interface BasicFeatureConfiguration {
readonly level: PaywallLevel
readonly bulletPointsTextId: `${PaywallFeatureName}FeatureBulletPoints`
readonly descriptionTextId: `${PaywallFeatureName}FeatureDescription`
}
/**
* Feature configuration.
*/
export type FeatureConfiguration<Key extends PaywallFeatureName = PaywallFeatureName> =
BasicFeatureConfiguration & {
readonly name: Key
readonly label: (typeof PAYWALL_FEATURES_LABELS)[Key]
readonly meta: (typeof PAYWALL_FEATURE_META)[Key]
}
const PAYWALL_CONFIGURATION: Record<PaywallFeatureName, BasicFeatureConfiguration> = {
userGroups: {
level: PAYWALL_LEVELS.team,
bulletPointsTextId: 'userGroupsFeatureBulletPoints',
descriptionTextId: 'userGroupsFeatureDescription',
},
userGroupsFull: {
level: PAYWALL_LEVELS.enterprise,
bulletPointsTextId: 'userGroupsFullFeatureBulletPoints',
descriptionTextId: 'userGroupsFullFeatureDescription',
},
inviteUser: {
level: PAYWALL_LEVELS.team,
bulletPointsTextId: 'inviteUserFeatureBulletPoints',
descriptionTextId: 'inviteUserFeatureDescription',
},
inviteUserFull: {
level: PAYWALL_LEVELS.enterprise,
bulletPointsTextId: 'inviteUserFullFeatureBulletPoints',
descriptionTextId: 'inviteUserFullFeatureDescription',
},
share: {
level: PAYWALL_LEVELS.team,
bulletPointsTextId: 'shareFeatureBulletPoints',
descriptionTextId: 'shareFeatureDescription',
},
shareFull: {
level: PAYWALL_LEVELS.enterprise,
bulletPointsTextId: 'shareFullFeatureBulletPoints',
descriptionTextId: 'shareFullFeatureDescription',
},
}
/**
* Map a plan to a paywall level.
*/
export function mapPlanOnPaywall(plan: backend.Plan | undefined): PaywallLevel {
return plan != null ? PAYWALL_LEVELS[plan] : PAYWALL_LEVELS.free
}
/**
* Check if a given string is a valid feature name.
*/
export function isFeatureName(name: string): name is PaywallFeatureName {
return name in PAYWALL_FEATURES
}
/**
* Get the configuration for a given feature.
*/
export function getFeatureConfiguration<Key extends PaywallFeatureName>(
feature: Key
): FeatureConfiguration<Key> {
const configuration = PAYWALL_CONFIGURATION[feature]
return {
...configuration,
name: feature,
label: PAYWALL_FEATURES_LABELS[feature],
meta: PAYWALL_FEATURE_META[feature],
}
}

View File

@ -0,0 +1,12 @@
/**
* @file
*
* Barrel file for billing hooks.
*/
export * from './paywallHooks'
export * from './paywallFeaturesHooks'
// eslint-disable-next-line no-restricted-syntax
export type { PaywallFeatureName, PaywallLevel, PaywallLevelName } from './FeaturesConfiguration'
// eslint-disable-next-line no-restricted-syntax
export { PAYWALL_LEVELS } from './FeaturesConfiguration'

View File

@ -0,0 +1,35 @@
/**
* @file
*
* Hooks for paywall features.
*/
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
import * as paywallFeaturesConfiguration from './FeaturesConfiguration'
/**
* A hook that provides access to the paywall features configuration.
*/
export function usePaywallFeatures() {
const getFeature = eventCallbackHooks.useEventCallback(
<Key extends paywallFeaturesConfiguration.PaywallFeatureName>(feature: Key) => {
return paywallFeaturesConfiguration.getFeatureConfiguration<Key>(feature)
}
)
const valueIsFeature = eventCallbackHooks.useEventCallback(
(value: string): value is paywallFeaturesConfiguration.PaywallFeatureName =>
value in paywallFeaturesConfiguration.PAYWALL_FEATURES
)
const getMaybeFeature = eventCallbackHooks.useEventCallback((feature: string) =>
valueIsFeature(feature) ? getFeature(feature) : null
)
return {
getFeature,
valueIsFeature,
getMaybeFeature,
} as const
}

View File

@ -0,0 +1,51 @@
/**
* @file
*
* Hooks for paywall-related functionality.
*/
import * as eventCallbackHooks from '#/hooks/eventCallbackHooks'
import * as paywall from '#/components/Paywall'
import type * as backend from '#/services/Backend'
import * as paywallConfiguration from './FeaturesConfiguration'
import * as paywallFeatures from './paywallFeaturesHooks'
/**
* Props for the {@link usePaywall} hook.
*/
export interface UsePaywallProps {
readonly plan?: backend.Plan | undefined
}
/**
* A hook that provides paywall-related functionality.
*/
export function usePaywall(props: UsePaywallProps) {
const { plan } = props
const { getFeature } = paywallFeatures.usePaywallFeatures()
const { features } = paywall.usePaywallDevtools()
const getPaywallLevel = eventCallbackHooks.useEventCallback(() =>
paywallConfiguration.mapPlanOnPaywall(plan)
)
const isFeatureUnderPaywall = eventCallbackHooks.useEventCallback(
(feature: paywallConfiguration.PaywallFeatureName) => {
const featureConfig = getFeature(feature)
const { isForceEnabled } = features[feature]
const { level } = featureConfig
const paywallLevel = getPaywallLevel()
if (isForceEnabled == null) {
return paywallLevel >= level
} else {
return !isForceEnabled
}
}
)
return { isFeatureUnderPaywall, getPaywallLevel, getFeature } as const
}

View File

@ -19,6 +19,8 @@ import LoadingScreen from '#/pages/authentication/LoadingScreen'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as reactQueryDevtools from './ReactQueryDevtools'
// =================
// === Constants ===
// =================
@ -101,13 +103,15 @@ function run(props: Omit<app.AppProps, 'portalRoot'>) {
{...props}
supportsDeepLinks={actuallySupportsDeepLinks}
portalRoot={portalRoot}
/>
)}
</React.Suspense>
</errorBoundary.ErrorBoundary>
</reactQuery.QueryClientProvider>
</React.StrictMode>
)
/>)}
</React.Suspense>
</errorBoundary.ErrorBoundary>
<reactQueryDevtools.ReactQueryDevtools />
</reactQuery.QueryClientProvider>
</React.StrictMode>
)
}
/** Global configuration for the {@link App} component. */

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'
@ -22,6 +23,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'
@ -74,6 +76,10 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
const self = asset.permissions?.find(
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
)
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user?.plan })
const isUnderPaywall = isFeatureUnderPaywall('share')
const isCloud = backend.type === backendModule.BackendType.remote
const ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin
@ -310,28 +316,38 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
/>
)}
{isCloud && <Separator hidden={hidden} />}
{isCloud && managesThisAsset && self != null && (
<ContextMenuEntry
hidden={hidden}
action="share"
doAction={() => {
setModal(
<ManagePermissionsModal
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
item={asset}
setItem={setAsset}
self={self}
eventTarget={eventTarget}
doRemoveSelf={() => {
dispatchAssetEvent({
type: AssetEventType.removeSelf,
id: asset.id,
})
}}
/>
)
}}
/>
)}
</>
)}
{isCloud && (
<ContextMenuEntry
hidden={hidden}

View File

@ -7,8 +7,8 @@ import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import SettingsSection from '#/components/styled/settings/SettingsSection'
import UnstyledButton from '#/components/UnstyledButton'
import ConfirmDeleteUserModal from '#/modals/ConfirmDeleteUserModal'
@ -31,8 +31,10 @@ export default function DeleteUserAccountSettingsSection() {
className="flex flex-col items-start gap-settings-section-header rounded-2.5xl border-2 border-danger px-[1rem] pb-[0.9375rem] pt-[0.5625rem]"
>
<div className="flex gap-buttons">
<UnstyledButton
className="button bg-danger px-delete-user-account-button-x text-inversed opacity-full hover:opacity-full"
<ariaComponents.Button
variant="delete"
size="medium"
rounded="full"
onPress={() => {
setModal(
<ConfirmDeleteUserModal
@ -44,11 +46,9 @@ export default function DeleteUserAccountSettingsSection() {
)
}}
>
<aria.Text className="text inline-block">
{getText('deleteUserAccountButtonLabel')}
</aria.Text>
</UnstyledButton>
<aria.Text className="text my-auto">{getText('deleteUserAccountWarning')}</aria.Text>
{getText('deleteUserAccountButtonLabel')}
</ariaComponents.Button>
<aria.Text className="text-md my-auto">{getText('deleteUserAccountWarning')}</aria.Text>
</div>
</SettingsSection>
)

View File

@ -3,6 +3,9 @@ import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as billingHooks from '#/hooks/billing'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
@ -15,6 +18,8 @@ import SettingsSection from '#/components/styled/settings/SettingsSection'
import type * as backendModule from '#/services/Backend'
import type Backend from '#/services/Backend'
import * as paywallSettingsLayout from './withPaywall'
// ==========================
// === MembersSettingsTab ===
// ==========================
@ -22,9 +27,16 @@ import type Backend from '#/services/Backend'
const LIST_USERS_STALE_TIME = 60_000
/** Settings tab for viewing and editing organization members. */
export default function MembersSettingsTab() {
/**
* Settings tab for viewing and editing organization members.
*/
function MembersSettingsTab() {
const { getText } = textProvider.useText()
const { backend } = backendProvider.useStrictBackend()
const { user } = authProvider.useFullUserSession()
const { isFeatureUnderPaywall, getFeature } = billingHooks.usePaywall({ plan: user.plan })
const [{ data: members }, { data: invitations }] = reactQuery.useSuspenseQueries({
queries: [
@ -41,10 +53,21 @@ export default function MembersSettingsTab() {
],
})
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>
@ -57,6 +80,7 @@ export default function MembersSettingsTab() {
</th>
</tr>
</thead>
<tbody className="select-text">
{members.map(member => (
<tr key={member.email} className="group h-row rounded-rows-child">
@ -157,17 +181,12 @@ function RemoveMemberButton(props: RemoveMemberButtonProps) {
const { email } = props
const { getText } = textProvider.useText()
const queryClient = reactQuery.useQueryClient()
const removeMutation = reactQuery.useMutation({
mutationKey: ['removeUser', email],
mutationFn: async () => {
// TODO: Implement remove member mutation
},
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: ['listUsers'],
}),
meta: { invalidates: [['listUsers']], awaitInvalidates: true },
})
return (
@ -188,15 +207,11 @@ function RemoveInvitationButton(props: RemoveMemberButtonProps) {
const { backend, email } = props
const { getText } = textProvider.useText()
const queryClient = reactQuery.useQueryClient()
const removeMutation = reactQuery.useMutation({
mutationKey: ['resendInvitation', email],
mutationFn: () => backend.deleteInvitation(email),
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: ['listInvitations'],
}),
meta: { invalidates: [['listInvitations']], awaitInvalidates: true },
})
return (
@ -212,3 +227,5 @@ function RemoveInvitationButton(props: RemoveMemberButtonProps) {
</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,42 @@ 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="cancel" rounded="full" size="small">
<ariaComponents.Button variant="cancel" rounded="full" size="medium">
{getText('inviteMembers')}
</ariaComponents.Button>
<InviteUsersModal />
</ariaComponents.DialogTrigger>
<div>
{seatsLeft != null && (
<ariaComponents.Text>
{seatsLeft <= 0 ? getText('noSeatsLeft') : getText('seatsLeft', seatsLeft, seatsTotal)}{' '}
<paywallComponents.PaywallDialogButton
feature={feature}
variant="link"
showIcon={false}
/>
</ariaComponents.Text>
)}
</div>
</HorizontalMenuBar>
)
}

View File

@ -0,0 +1,32 @@
/** @file Settings tab for viewing and editing roles for all users in the organization. */
import * as React from 'react'
import * as billingHooks from '#/hooks/billing'
import * as authProvider from '#/providers/AuthProvider'
import * as paywallComponents from '#/components/Paywall'
import * as components from './components'
// =============================
// === UserGroupsSettingsTab ===
// =============================
/** Settings tab for viewing and editing organization members. */
export default function UserGroupsSettingsTab() {
const { user } = authProvider.useFullUserSession()
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
const showPaywall = isFeatureUnderPaywall('userGroups')
if (showPaywall) {
return (
<div className="mt-1">
<paywallComponents.PaywallScreen feature="userGroups" />
</div>
)
} else {
return <components.UserGroupsSettingsTabContent />
}
}

View File

@ -3,6 +3,7 @@ import * as React from 'react'
import * as mimeTypes from '#/data/mimeTypes'
import * as billingHooks from '#/hooks/billing'
import * as scrollHooks from '#/hooks/scrollHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -16,10 +17,11 @@ import UserGroupRow from '#/layouts/Settings/UserGroupRow'
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'
import UnstyledButton from '#/components/UnstyledButton'
import NewUserGroupModal from '#/modals/NewUserGroupModal'
@ -27,14 +29,10 @@ import * as backendModule from '#/services/Backend'
import * as object from '#/utilities/object'
// =============================
// === UserGroupsSettingsTab ===
// =============================
/** Settings tab for viewing and editing organization members. */
export default function UserGroupsSettingsTab() {
export function UserGroupsSettingsTabContent() {
const { backend } = backendProvider.useStrictBackend()
const { user } = authProvider.useNonPartialUserSession()
const { user } = authProvider.useFullUserSession()
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
@ -48,6 +46,12 @@ export default function UserGroupsSettingsTab() {
[users]
)
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 usersByGroup = React.useMemo(() => {
const map = new Map<backendModule.UserGroupId, backendModule.User[]>()
for (const otherUser of users ?? []) {
@ -212,48 +216,71 @@ export default function UserGroupsSettingsTab() {
<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>
<UnstyledButton
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
onPress={event => {
const placeholderId = backendModule.newPlaceholderUserGroupId()
const rect = event.target.getBoundingClientRect()
const position = { pageX: rect.left, pageY: rect.top }
setModal(
<NewUserGroupModal
event={position}
userGroups={userGroups}
onSubmit={groupName => {
if (user != null) {
const id = placeholderId
const { organizationId } = user
setUserGroups(oldUserGroups => [
...(oldUserGroups ?? []),
{ organizationId, id, groupName },
])
}
}}
onSuccess={newUserGroup => {
setUserGroups(
oldUserGroups =>
oldUserGroups?.map(userGroup =>
userGroup.id !== placeholderId ? userGroup : newUserGroup
) ?? null
)
}}
onFailure={() => {
setUserGroups(
oldUserGroups =>
oldUserGroups?.filter(userGroup => userGroup.id !== placeholderId) ?? null
)
}}
/>
)
}}
>
<aria.Text className="text whitespace-nowrap font-semibold">
{getText('newUserGroup')}
</aria.Text>
</UnstyledButton>
<div className="flex items-center gap-2">
{shouldDisplayPaywall && (
<paywallComponents.PaywallDialogButton
feature="userGroupsFull"
variant="cancel"
size="medium"
rounded="full"
iconPosition="end"
tooltip={getText('userGroupsPaywallMessage')}
>
{getText('newUserGroup')}
</paywallComponents.PaywallDialogButton>
)}
{!shouldDisplayPaywall && (
<ariaComponents.Button
variant="cancel"
rounded="full"
size="medium"
onPress={event => {
const placeholderId = backendModule.newPlaceholderUserGroupId()
const rect = event.target.getBoundingClientRect()
const position = { pageX: rect.left, pageY: rect.top }
setModal(
<NewUserGroupModal
event={position}
userGroups={userGroups}
onSubmit={groupName => {
const id = placeholderId
const { organizationId } = user
setUserGroups(oldUserGroups => [
...(oldUserGroups ?? []),
{ organizationId, id, groupName },
])
}}
onSuccess={newUserGroup => {
setUserGroups(
oldUserGroups =>
oldUserGroups?.map(userGroup =>
userGroup.id !== placeholderId ? userGroup : newUserGroup
) ?? null
)
}}
onFailure={() => {
setUserGroups(
oldUserGroups =>
oldUserGroups?.filter(userGroup => userGroup.id !== placeholderId) ??
null
)
}}
/>
)
}}
>
{getText('newUserGroup')}
</ariaComponents.Button>
)}
{isUnderPaywall && (
<span className="text-xs">
{userGroupsLeft <= 0
? getText('userGroupsPaywallMessage')
: getText('userGroupsLimitMessage', userGroupsLeft)}
</span>
)}
</div>
</HorizontalMenuBar>
<div
ref={rootRef}
@ -318,6 +345,7 @@ export default function UserGroupsSettingsTab() {
</div>
</SettingsSection>
</div>
<SettingsSection noFocusArea title={getText('users')} className="h-2/5 lg:h-[unset]">
<MembersTable draggable populateWithSelf />
</SettingsSection>

View File

@ -0,0 +1,7 @@
/**
* @file
*
* Barrel file for the UserGroupsSettingsTabContent component.
*/
export * from './UserGroupsSettingsTabContent'

View File

@ -0,0 +1,9 @@
/**
* @file
*
* Barrel file for the UserGroupsSettingsTab component.
*/
import UserGroupsSettingsTab from './UserGroupsSettingsTab'
export default UserGroupsSettingsTab

View File

@ -0,0 +1,68 @@
/**
* @file
*
* This file contains a higher-order component that wraps a component in a paywall.
* The paywall is shown if the user's plan does not include the feature.
* The feature is determined by the `isFeatureUnderPaywall` hook.
*/
import * as React from 'react'
import * as twv from 'tailwind-variants'
import * as billingHooks from '#/hooks/billing'
import * as authProvider from '#/providers/AuthProvider'
import * as paywallComponents from '#/components/Paywall'
/**
* Props for the `withPaywall` HOC.
*/
export interface PaywallSettingsLayoutProps {
readonly feature: billingHooks.PaywallFeatureName
readonly className?: string | undefined
}
const PAYWALL_LAYOUT_STYLES = twv.tv({
base: 'mt-1',
})
/**
* A layout that shows a paywall for a feature.
*/
export function PaywallSettingsLayout(props: PaywallSettingsLayoutProps) {
const { feature, className } = props
return (
<div className={PAYWALL_LAYOUT_STYLES({ className })}>
<paywallComponents.PaywallScreen feature={feature} />
</div>
)
}
/**
* Wraps a component in a paywall.
* 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>>(
// eslint-disable-next-line @typescript-eslint/naming-convention
Component: React.ComponentType<P>,
props: PaywallSettingsLayoutProps
) {
const { feature, className } = props
return function WithPaywall(componentProps: P) {
const { user } = authProvider.useFullUserSession()
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
const showPaywall = isFeatureUnderPaywall(feature)
if (showPaywall) {
return <PaywallSettingsLayout feature={feature} className={className} />
} else {
return <Component {...componentProps} />
}
}
}

View File

@ -12,6 +12,7 @@ import * as textProvider from '#/providers/TextProvider'
import SettingsTab from '#/layouts/Settings/SettingsTab'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import FocusArea from '#/components/styled/FocusArea'
import SidebarTabButton from '#/components/styled/SidebarTabButton'
@ -133,19 +134,22 @@ export default function SettingsSidebar(props: SettingsSidebarProps) {
>
{section.name}
</aria.Header>
{section.tabs.map(tab => (
<SidebarTabButton
key={tab.settingsTab}
isDisabled={(tab.organizationOnly ?? false) && !isUserInOrganization}
id={tab.settingsTab}
icon={tab.icon}
label={tab.name}
active={tab.settingsTab === settingsTab}
onPress={() => {
setSettingsTab(tab.settingsTab)
}}
/>
))}
<ariaComponents.ButtonGroup gap="xxsmall" direction="column" align="start">
{section.tabs.map(tab => (
<SidebarTabButton
key={tab.settingsTab}
isDisabled={(tab.organizationOnly ?? false) && !isUserInOrganization}
id={tab.settingsTab}
icon={tab.icon}
label={tab.name}
active={tab.settingsTab === settingsTab}
onPress={() => {
setSettingsTab(tab.settingsTab)
}}
/>
))}
</ariaComponents.ButtonGroup>
</div>
))}
</div>

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 backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
@ -16,6 +18,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 FocusArea from '#/components/styled/FocusArea'
import UnstyledButton from '#/components/UnstyledButton'
@ -50,20 +53,29 @@ export default function UserBar(props: UserBarProps) {
const { setModal, updateModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useStrictBackend()
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.type === backendModule.BackendType.remote &&
page === pageSwitcher.Page.editor &&
projectAsset != null &&
setProjectAsset != null &&
self != null
const shouldShowUpgradeButton = isFeatureUnderPaywall('inviteUser')
const shouldShowInviteButton =
sessionType === authProvider.UserSessionType.full && !shouldShowShareButton
sessionType === authProvider.UserSessionType.full &&
!shouldShowShareButton &&
!shouldShowUpgradeButton
return (
<FocusArea active={!invisible} direction="horizontal">
@ -83,6 +95,17 @@ export default function UserBar(props: UserBarProps) {
}}
/>
{shouldShowUpgradeButton && (
<paywall.PaywallDialogButton
feature={'inviteUser'}
rounded="full"
size="xsmall"
variant="tertiary"
>
{getText('invite')}
</paywall.PaywallDialogButton>
)}
{shouldShowInviteButton && (
<ariaComponents.DialogTrigger>
<ariaComponents.Button rounded="full" size="xsmall" variant="tertiary">
@ -101,6 +124,7 @@ export default function UserBar(props: UserBarProps) {
>
{getText('upgrade')}
</ariaComponents.Button>
{shouldShowShareButton && (
<UnstyledButton
className="text my-auto rounded-full bg-share px-button-x text-inversed"

View File

@ -4,13 +4,16 @@ import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import isEmail from 'validator/es/lib/isEmail'
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'
@ -39,23 +42,46 @@ 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.useStrictBackend()
const inputRef = React.useRef<HTMLDivElement>(null)
const formRef = React.useRef<HTMLFormElement>(null)
const queryClient = reactQuery.useQueryClient()
const inviteUserMutation = reactQuery.useMutation({
mutationKey: ['inviteUser'],
mutationFn: async (params: InviteUsersMutationParams) =>
mutationFn: (params: InviteUsersMutationParams) =>
backend.inviteUser({ organizationId: params.organizationId, userEmail: params.email }),
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)
})
@ -103,10 +129,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')
}
}
}
@ -156,13 +186,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')}
@ -182,15 +210,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,11 @@
/** @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 asyncEffectHooks from '#/hooks/asyncEffectHooks'
import * as billingHooks from '#/hooks/billing'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
@ -13,12 +14,13 @@ import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
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 UnstyledButton from '#/components/UnstyledButton'
import * as backendModule from '#/services/Backend'
@ -58,7 +60,12 @@ export default function ManagePermissionsModal<
Asset extends backendModule.AnyAsset = backendModule.AnyAsset,
>(props: ManagePermissionsModalProps<Asset>) {
const { item, setItem, self, doRemoveSelf, eventTarget } = props
const { user: user } = authProvider.useNonPartialUserSession()
const { user } = authProvider.useFullUserSession()
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
const isUnderPaywall = isFeatureUnderPaywall('shareFull')
const { backend } = backendProvider.useStrictBackend()
const { unsetModal } = modalProvider.useSetModal()
const toastAndLog = toastAndLogHooks.useToastAndLog()
@ -98,9 +105,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]
)
React.useEffect(() => {
@ -109,299 +116,300 @@ export default function ManagePermissionsModal<
setItem(object.merger({ permissions } as Partial<Asset>))
}, [permissions, /* should never change */ setItem])
if (backend.type === backendModule.BackendType.local || user == null) {
// 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.
// This MUST be an error, otherwise the hooks below are considered as conditionally called.
throw new Error('Cannot share assets on the local backend.')
} else {
const listedUsers = asyncEffectHooks.useAsyncEffect(null, () => backend.listUsers(), [])
const listedUserGroups = asyncEffectHooks.useAsyncEffect(
null,
() => backend.listUserGroups(),
[]
)
const canAdd = React.useMemo(
() => [
...(listedUsers ?? []).filter(
listedUser =>
!permissionsHoldersNames.has(listedUser.name) &&
!emailsOfUsersWithPermission.has(listedUser.email)
),
...(listedUserGroups ?? []).filter(
userGroup => !permissionsHoldersNames.has(userGroup.groupName)
),
],
[emailsOfUsersWithPermission, permissionsHoldersNames, listedUsers, listedUserGroups]
)
const willInviteNewUser = React.useMemo(() => {
if (usersAndUserGroups.length !== 0 || email == null || email === '') {
return false
} else {
const lowercase = email.toLowerCase()
return (
lowercase !== '' &&
!permissionsHoldersNames.has(lowercase) &&
!emailsOfUsersWithPermission.has(lowercase) &&
!canAdd.some(
userOrGroup =>
('name' in userOrGroup && userOrGroup.name.toLowerCase() === lowercase) ||
('email' in userOrGroup && userOrGroup.email.toLowerCase() === lowercase) ||
('groupName' in userOrGroup && userOrGroup.groupName.toLowerCase() === lowercase)
)
)
}
}, [
usersAndUserGroups.length,
email,
emailsOfUsersWithPermission,
permissionsHoldersNames,
canAdd,
])
const { data: listedUsers } = reactQuery.useQuery({
queryKey: ['listUsers', isUnderPaywall],
queryFn: () => backend.listUsers(),
enabled: !isUnderPaywall,
})
const doSubmit = async () => {
if (willInviteNewUser) {
try {
setUserAndUserGroups([])
setEmail('')
if (email != null) {
await backend.inviteUser({
organizationId: user.organizationId,
userEmail: backendModule.EmailAddress(email),
})
toast.toast.success(getText('inviteSuccess', email))
}
} catch (error) {
toastAndLog('couldNotInviteUser', error, email ?? '(unknown)')
}
} else {
const { data: listedUserGroups } = reactQuery.useQuery({
queryKey: ['listUserGroups'],
queryFn: () => backend.listUserGroups(),
})
const canAdd = React.useMemo(
() => [
...(listedUsers ?? []).filter(
listedUser =>
!permissionsHoldersNames.has(listedUser.name) &&
!emailsOfUsersWithPermission.has(listedUser.email)
),
...(listedUserGroups ?? []).filter(
userGroup => !permissionsHoldersNames.has(userGroup.groupName)
),
],
[emailsOfUsersWithPermission, permissionsHoldersNames, listedUsers, listedUserGroups]
)
const willInviteNewUser = React.useMemo(() => {
if (usersAndUserGroups.length !== 0 || email == null || email === '') {
return false
} else {
const lowercase = email.toLowerCase()
return (
lowercase !== '' &&
!permissionsHoldersNames.has(lowercase) &&
!emailsOfUsersWithPermission.has(lowercase) &&
!canAdd.some(
userOrGroup =>
('name' in userOrGroup && userOrGroup.name.toLowerCase() === lowercase) ||
('email' in userOrGroup && userOrGroup.email.toLowerCase() === lowercase) ||
('groupName' in userOrGroup && userOrGroup.groupName.toLowerCase() === lowercase)
)
)
}
}, [
usersAndUserGroups.length,
email,
emailsOfUsersWithPermission,
permissionsHoldersNames,
canAdd,
])
const doSubmit = async () => {
if (willInviteNewUser) {
try {
setUserAndUserGroups([])
const addedPermissions = usersAndUserGroups.map<backendModule.AssetPermission>(
newUserOrUserGroup =>
'userId' in newUserOrUserGroup
? { user: newUserOrUserGroup, permission: action }
: { userGroup: newUserOrUserGroup, permission: action }
)
const addedUsersIds = new Set(
addedPermissions.flatMap(permission =>
backendModule.isUserPermission(permission) ? [permission.user.userId] : []
)
)
const addedUserGroupsIds = new Set(
addedPermissions.flatMap(permission =>
backendModule.isUserGroupPermission(permission) ? [permission.userGroup.id] : []
)
)
const isPermissionNotBeingOverwritten = (permission: backendModule.AssetPermission) =>
backendModule.isUserPermission(permission)
? !addedUsersIds.has(permission.user.userId)
: !addedUserGroupsIds.has(permission.userGroup.id)
try {
setPermissions(oldPermissions =>
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...addedPermissions].sort(
backendModule.compareAssetPermissions
)
)
await backend.createPermission({
actorsIds: addedPermissions.map(permission =>
backendModule.isUserPermission(permission)
? permission.user.userId
: permission.userGroup.id
),
resourceId: item.id,
action: action,
setEmail('')
if (email != null) {
await backend.inviteUser({
organizationId: user.organizationId,
userEmail: backendModule.EmailAddress(email),
})
} catch (error) {
setPermissions(oldPermissions =>
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...oldPermissions].sort(
backendModule.compareAssetPermissions
)
)
toastAndLog('setPermissionsError', error)
toast.toast.success(getText('inviteSuccess', email))
}
} catch (error) {
toastAndLog('couldNotInviteUser', error, email ?? '(unknown)')
}
} else {
setUserAndUserGroups([])
const addedPermissions = usersAndUserGroups.map<backendModule.AssetPermission>(
newUserOrUserGroup =>
'userId' in newUserOrUserGroup
? { user: newUserOrUserGroup, permission: action }
: { userGroup: newUserOrUserGroup, permission: action }
)
const addedUsersIds = new Set(
addedPermissions.flatMap(permission =>
backendModule.isUserPermission(permission) ? [permission.user.userId] : []
)
)
const addedUserGroupsIds = new Set(
addedPermissions.flatMap(permission =>
backendModule.isUserGroupPermission(permission) ? [permission.userGroup.id] : []
)
)
const isPermissionNotBeingOverwritten = (permission: backendModule.AssetPermission) =>
backendModule.isUserPermission(permission)
? !addedUsersIds.has(permission.user.userId)
: !addedUserGroupsIds.has(permission.userGroup.id)
try {
setPermissions(oldPermissions =>
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...addedPermissions].sort(
backendModule.compareAssetPermissions
)
)
await backend.createPermission({
actorsIds: addedPermissions.map(permission =>
backendModule.isUserPermission(permission)
? permission.user.userId
: permission.userGroup.id
),
resourceId: item.id,
action: action,
})
} catch (error) {
setPermissions(oldPermissions =>
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...oldPermissions].sort(
backendModule.compareAssetPermissions
)
)
toastAndLog('setPermissionsError', error)
}
}
const doDelete = async (permissionId: backendModule.UserPermissionIdentifier) => {
if (permissionId === self.user.userId) {
doRemoveSelf()
} else {
const oldPermission = permissions.find(
permission => backendModule.getAssetPermissionId(permission) === permissionId
)
try {
setPermissions(oldPermissions =>
oldPermissions.filter(
permission => backendModule.getAssetPermissionId(permission) !== permissionId
)
)
await backend.createPermission({
actorsIds: [permissionId],
resourceId: item.id,
action: null,
})
} catch (error) {
if (oldPermission != null) {
setPermissions(oldPermissions =>
[...oldPermissions, oldPermission].sort(backendModule.compareAssetPermissions)
)
}
toastAndLog('setPermissionsError', error)
}
}
}
return (
<Modal
centered={eventTarget == null}
className="absolute left top size-full overflow-hidden bg-dim"
>
<div
tabIndex={-1}
style={
position != null
? {
left: position.left + window.scrollX,
top: position.top + window.scrollY,
}
: {}
}
className="sticky w-manage-permissions-modal rounded-default before:absolute before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default"
onClick={mouseEvent => {
mouseEvent.stopPropagation()
}}
onContextMenu={mouseEvent => {
mouseEvent.stopPropagation()
mouseEvent.preventDefault()
}}
>
<div className="relative flex flex-col gap-modal rounded-default p-modal">
<div className="flex h-row items-center gap-modal-tabs px-modal-tab-bar-x">
<aria.Heading level={2} className="text text-sm font-bold">
{getText('invite')}
</aria.Heading>
{/* Space reserved for other tabs. */}
</div>
<FocusArea direction="horizontal">
{innerProps => (
<form
className="flex gap-input-with-button"
onSubmit={event => {
event.preventDefault()
void doSubmit()
}}
{...innerProps}
>
<div className="flex grow items-center gap-user-permission rounded-full border border-primary/10 px-manage-permissions-modal-input">
<PermissionSelector
input
isDisabled={willInviteNewUser}
selfPermission={self.permission}
typeSelectorYOffsetPx={TYPE_SELECTOR_Y_OFFSET_PX}
action={permissionsModule.PermissionAction.view}
assetType={item.type}
onChange={setAction}
/>
<div className="-mx-button-px grow">
<Autocomplete
multiple
autoFocus
placeholder={
// `listedUsers` will always include the current user.
listedUsers?.length !== 1
? getText('inviteUserPlaceholder')
: getText('inviteFirstUserPlaceholder')
}
type="text"
itemsToString={items =>
items.length === 1 && items[0] != null && 'email' in items[0]
? items[0].email
: getText('xUsersSelected', items.length)
}
values={usersAndUserGroups}
setValues={setUserAndUserGroups}
items={canAdd}
itemToKey={userOrGroup =>
'userId' in userOrGroup ? userOrGroup.userId : userOrGroup.id
}
itemToString={userOrGroup =>
'name' in userOrGroup
? `${userOrGroup.name} (${userOrGroup.email})`
: userOrGroup.groupName
}
matches={(userOrGroup, text) =>
('email' in userOrGroup &&
userOrGroup.email.toLowerCase().includes(text.toLowerCase())) ||
('name' in userOrGroup &&
userOrGroup.name.toLowerCase().includes(text.toLowerCase())) ||
('groupName' in userOrGroup &&
userOrGroup.groupName.toLowerCase().includes(text.toLowerCase()))
}
text={email}
setText={setEmail}
/>
</div>
</div>
<UnstyledButton
isDisabled={
willInviteNewUser
? email == null || !isEmail(email)
: usersAndUserGroups.length === 0 ||
(email != null && emailsOfUsersWithPermission.has(email))
}
className="button bg-invite px-button-x text-tag-text selectable enabled:active"
onPress={doSubmit}
>
<div className="h-text py-modal-invite-button-text-y">
{willInviteNewUser ? getText('invite') : getText('share')}
</div>
</UnstyledButton>
</form>
)}
</FocusArea>
<div className="max-h-manage-permissions-modal-permissions-list overflow-auto px-manage-permissions-modal-input">
{editablePermissions.map(permission => (
<div
key={backendModule.getAssetPermissionName(permission)}
className="flex h-row items-center"
>
<Permission
asset={item}
self={self}
isOnlyOwner={isOnlyOwner}
permission={permission}
setPermission={newPermission => {
const permissionId = backendModule.getAssetPermissionId(newPermission)
setPermissions(oldPermissions =>
oldPermissions.map(oldPermission =>
backendModule.getAssetPermissionId(oldPermission) === permissionId
? newPermission
: oldPermission
)
)
if (permissionId === self.user.userId) {
// This must run only after the permissions have
// been updated through `setItem`.
setTimeout(() => {
unsetModal()
}, 0)
}
}}
doDelete={id => {
if (id === self.user.userId) {
unsetModal()
}
void doDelete(id)
}}
/>
</div>
))}
</div>
</div>
</div>
</Modal>
)
}
const doDelete = async (permissionId: backendModule.UserPermissionIdentifier) => {
if (permissionId === self.user.userId) {
doRemoveSelf()
} else {
const oldPermission = permissions.find(
permission => backendModule.getAssetPermissionId(permission) === permissionId
)
try {
setPermissions(oldPermissions =>
oldPermissions.filter(
permission => backendModule.getAssetPermissionId(permission) !== permissionId
)
)
await backend.createPermission({
actorsIds: [permissionId],
resourceId: item.id,
action: null,
})
} catch (error) {
if (oldPermission != null) {
setPermissions(oldPermissions =>
[...oldPermissions, oldPermission].sort(backendModule.compareAssetPermissions)
)
}
toastAndLog('setPermissionsError', error)
}
}
}
return (
<Modal
centered={eventTarget == null}
className="absolute left top size-full overflow-hidden bg-dim"
>
<div
tabIndex={-1}
style={
position != null
? {
left: position.left + window.scrollX,
top: position.top + window.scrollY,
}
: {}
}
className="sticky w-manage-permissions-modal rounded-default before:absolute before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default"
onClick={mouseEvent => {
mouseEvent.stopPropagation()
}}
onContextMenu={mouseEvent => {
mouseEvent.stopPropagation()
mouseEvent.preventDefault()
}}
>
<div className="relative flex flex-col gap-modal rounded-default p-modal">
<div className="flex h-row items-center gap-modal-tabs px-modal-tab-bar-x">
<aria.Heading level={2} className="text text-sm font-bold">
{getText('invite')}
</aria.Heading>
{/* Space reserved for other tabs. */}
</div>
<FocusArea direction="horizontal">
{innerProps => (
<form
className="flex gap-input-with-button"
onSubmit={event => {
event.preventDefault()
void doSubmit()
}}
{...innerProps}
>
<div className="flex grow items-center gap-user-permission rounded-full border border-primary/10 px-manage-permissions-modal-input">
<PermissionSelector
input
isDisabled={willInviteNewUser}
selfPermission={self.permission}
typeSelectorYOffsetPx={TYPE_SELECTOR_Y_OFFSET_PX}
action={permissionsModule.PermissionAction.view}
assetType={item.type}
onChange={setAction}
/>
<div className="w-0 grow">
<Autocomplete
multiple
autoFocus
placeholder={
// `listedUsers` will always include the current user.
listedUsers?.length !== 1
? getText('inviteUserPlaceholder')
: getText('inviteFirstUserPlaceholder')
}
type="text"
itemsToString={items =>
items.length === 1 && items[0] != null && 'email' in items[0]
? items[0].email
: getText('xUsersSelected', items.length)
}
values={usersAndUserGroups}
setValues={setUserAndUserGroups}
items={canAdd}
itemToKey={userOrGroup =>
'userId' in userOrGroup ? userOrGroup.userId : userOrGroup.id
}
itemToString={userOrGroup =>
'name' in userOrGroup
? `${userOrGroup.name} (${userOrGroup.email})`
: userOrGroup.groupName
}
matches={(userOrGroup, text) =>
('email' in userOrGroup &&
userOrGroup.email.toLowerCase().includes(text.toLowerCase())) ||
('name' in userOrGroup &&
userOrGroup.name.toLowerCase().includes(text.toLowerCase())) ||
('groupName' in userOrGroup &&
userOrGroup.groupName.toLowerCase().includes(text.toLowerCase()))
}
text={email}
setText={setEmail}
/>
</div>
</div>
<ariaComponents.Button
rounded="full"
size="xsmall"
variant="submit"
isDisabled={
willInviteNewUser
? email == null || !isEmail(email)
: usersAndUserGroups.length === 0 ||
(email != null && emailsOfUsersWithPermission.has(email))
}
onPress={doSubmit}
>
{willInviteNewUser ? getText('invite') : getText('share')}
</ariaComponents.Button>
</form>
)}
</FocusArea>
<div className="max-h-manage-permissions-modal-permissions-list overflow-auto px-manage-permissions-modal-input">
{editablePermissions.map(permission => (
<div
key={backendModule.getAssetPermissionName(permission)}
className="flex h-row items-center"
>
<Permission
asset={item}
self={self}
isOnlyOwner={isOnlyOwner}
permission={permission}
setPermission={newPermission => {
const permissionId = backendModule.getAssetPermissionId(newPermission)
setPermissions(oldPermissions =>
oldPermissions.map(oldPermission =>
backendModule.getAssetPermissionId(oldPermission) === permissionId
? newPermission
: oldPermission
)
)
if (permissionId === self.user.userId) {
// This must run only after the permissions have
// been updated through `setItem`.
setTimeout(() => {
unsetModal()
}, 0)
}
}}
doDelete={id => {
if (id === self.user.userId) {
unsetModal()
}
void doDelete(id)
}}
/>
</div>
))}
</div>
{isUnderPaywall && (
<paywall.PaywallAlert feature="shareFull" label={getText('shareFullPaywallMessage')} />
)}
</div>
</div>
</Modal>
)
}

View File

@ -34,7 +34,6 @@ export function SetOrganizationNameModal() {
const userPlan =
session && 'user' in session && session.user?.plan != null ? session.user.plan : null
const queryClient = reactQuery.useQueryClient()
const { data: organizationName } = reactQuery.useSuspenseQuery({
queryKey: ['organization', userId],
queryFn: () => {
@ -51,7 +50,7 @@ export function SetOrganizationNameModal() {
const submit = reactQuery.useMutation({
mutationKey: ['organization', userId],
mutationFn: (name: string) => backend.updateOrganization({ name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['organization', userId] }),
meta: { invalidates: [['organization', userId]], awaitInvalidates: true },
})
const shouldShowModal =

View File

@ -9,6 +9,7 @@ import type * as stripeJs from '@stripe/stripe-js'
import OpenInNewTabIcon from 'enso-assets/open.svg'
import * as appUtils from '#/appUtils'
import type * as text from '#/text'
import * as textProvider from '#/providers/TextProvider'
@ -77,6 +78,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
target="_blank"
icon={OpenInNewTabIcon}
iconPosition="end"
size="medium"
>
{getText('learnMore')}
</ariaComponents.Button>
@ -89,7 +91,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>
@ -114,6 +116,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
target="_blank"
icon={OpenInNewTabIcon}
iconPosition="end"
size="medium"
>
{getText('learnMore')}
</ariaComponents.Button>
@ -129,7 +132,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>
@ -151,6 +154,7 @@ const COMPONENT_PER_PLAN: Record<backendModule.Plan, ComponentForPlan> = {
target="_blank"
icon={OpenInNewTabIcon}
iconPosition="end"
size="medium"
>
{getText('learnMore')}
</ariaComponents.Button>
@ -166,10 +170,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

@ -9,6 +9,7 @@ import * as sentry from '@sentry/react'
import isNetworkError from 'is-network-error'
import * as router from 'react-router-dom'
import * as toast from 'react-toastify'
import invariant from 'tiny-invariant'
import * as detect from 'enso-common/src/detect'
import * as gtag from 'enso-common/src/gtag'
@ -899,3 +900,14 @@ export function useUserSession() {
// eslint-disable-next-line no-restricted-syntax
return router.useOutletContext<UserSession | undefined>()
}
/**
* A React context hook returning the user session for a user that is fully logged in.
*/
export function useFullUserSession(): FullUserSession {
const session = router.useOutletContext<UserSession>()
invariant(session.type === UserSessionType.full, 'Expected a full user session.')
return session
}

View File

@ -6,13 +6,75 @@
import * as reactQuery from '@tanstack/react-query'
declare module '@tanstack/react-query' {
/**
* Specifies the invalidation behavior of a mutation.
*/
interface Register {
readonly mutationMeta: {
/**
* List of query keys to invalidate when the mutation succeeds.
*/
readonly invalidates?: reactQuery.QueryKey[]
/**
* List of query keys to await invalidation before the mutation is considered successful.
*
* If `true`, all `invalidates` are awaited.
*
* If `false`, no invalidations are awaited.
*
* You can also provide an array of query keys to await.
*
* Queries that are not listed in invalidates will be ignored.
* @default false
*/
readonly awaitInvalidates?: reactQuery.QueryKey[] | boolean
}
}
}
/**
* Create a new React Query client.
*/
export function createReactQueryClient() {
return new reactQuery.QueryClient({
const queryClient: reactQuery.QueryClient = new reactQuery.QueryClient({
mutationCache: new reactQuery.MutationCache({
onSuccess: (_data, _variables, _context, mutation) => {
const shouldAwaitInvalidates = mutation.meta?.awaitInvalidates ?? false
const invalidates = mutation.meta?.invalidates ?? []
const invalidatesToAwait = (() => {
if (Array.isArray(shouldAwaitInvalidates)) {
return shouldAwaitInvalidates
} else {
return shouldAwaitInvalidates ? invalidates : []
}
})()
const invalidatesToIgnore = invalidates.filter(
queryKey => !invalidatesToAwait.includes(queryKey)
)
for (const queryKey of invalidatesToIgnore) {
void queryClient.invalidateQueries({
predicate: query => reactQuery.matchQuery({ queryKey }, query),
})
}
if (invalidatesToAwait.length > 0) {
// eslint-disable-next-line no-restricted-syntax
return Promise.all(
invalidatesToAwait.map(queryKey =>
queryClient.invalidateQueries({
predicate: query => reactQuery.matchQuery({ queryKey }, query),
})
)
)
}
},
}),
defaultOptions: {
queries: {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
staleTime: 2 * 60 * 1000, // 2 minutes
retry: (failureCount, error) => {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const statusesToIgnore = [401, 403, 404]
@ -28,4 +90,6 @@ export function createReactQueryClient() {
},
},
})
return queryClient
}

View File

@ -200,6 +200,8 @@
"profilePicture": "Profile picture",
"settingsFor": "Settings for ",
"inviteMembers": "Invite Members",
"seatsLeft": "You have $0 / $1 seats left on your plan. Upgrade to invite more users.",
"noSeatsLeft": "You have reached the limit of users for your plan. Upgrade to invite more users.",
"status": "Status",
"active": "Active",
"pendingInvitation": "Pending Invitation",
@ -340,6 +342,8 @@
"confirmPrompt": "Are you sure you want to $0?",
"couldNotInviteUser": "Could not invite user $0",
"inviteFormDescription": "Invite users to join your organization by entering their email addresses below.",
"inviteFormSeatsLeft": "You have $0 seats left on your plan. Upgrade to invite more",
"inviteFormSeatsLeftError": "You have exceed the number of seats on your plan by $0",
"inviteSuccess": "You've invited $0 to join Enso!",
"inviteUserLinkCopyDescription": "You can also copy the invite link to send it directly",
"inviteSubmit": "Send invites",
@ -443,6 +447,7 @@
"editorPageAltText": "Graph Editor",
"settingsPageAltText": "Settings",
"freePlanName": "Free",
"soloPlanName": "Solo",
"soloPlanSubtitle": "For individuals",
"soloPlanPricing": "$60 per user / month",
@ -588,6 +593,8 @@
"userGroups": "User Groups",
"userGroup": "User Group",
"newUserGroup": "New User Group",
"userGroupsPaywallMessage": "You have reached the limit of user groups for your plan. Upgrade to create more user groups.",
"userGroupsLimitMessage": "You can create up to $0 user group(s)",
"userGroupNamePlaceholder": "Enter the name of the user group",
"assetSearchFieldLabel": "Search through items",
@ -635,5 +642,34 @@
"contactSalesDescription": "Contact our sales team to learn more about our Enterprise plan.",
"ContactSalesButtonLabel": "Contact Us",
"setOrgNameTitle": "Set your organization name"
"setOrgNameTitle": "Set your organization name",
"paywallScreenTitle": "Unlock the potential of Enso",
"paywallScreenDescription": "Upgrade to $0 to unlock additional features and get access to priority support.",
"paywallAvailabilityLevel": "Available on $0 plan",
"userGroupsFeatureLabel": "User Groups",
"userGroupsFeatureBulletPoints": "Create user group to manage permissions;Assign user group to assets;Assign users to group",
"userGroupsFeatureDescription": "Get fine-grained control over who can access your assets by creating user groups and assigning them to assets. Assign users to groups to manage their permissions.",
"userGroupsFullFeatureLabel": "Unlimited User Groups",
"userGroupsFullFeatureBulletPoints": "Create unlimited user groups to manage permissions;Assign user groups to assets;Assign users to groups",
"userGroupsFullFeatureDescription": "Get fine-grained control over who can access your assets by creating user groups and assigning them to assets. Assign users to groups to manage their permissions.",
"inviteUserFeatureLabel": "Invite Users",
"inviteUserFeatureBulletPoints": "Invite users to join your organization;Manage user access to assets;Assign users to user groups",
"inviteUserFeatureDescription": "Invite users to join your organization and manage their access to assets. Assign users to user groups to manage their permissions.",
"inviteUserFullFeatureLabel": "Unlimited Invitations",
"inviteUserFullFeatureBulletPoints": "Invite unlimited users to join your organization;Manage user access to assets;Assign users to user groups",
"inviteUserFullFeatureDescription": "Invite unlimited users to join your organization and manage their access to assets. Assign users to user groups to manage their permissions.",
"shareFeatureLabel": "Share Assets",
"shareFeatureBulletPoints": "Share assets with other users;Manage shared assets;Assign shared assets to user groups",
"shareFeatureDescription": "Share assets with other users and manage shared assets. Assign shared assets to user groups to manage their permissions.",
"shareFullFeatureLabel": "Unlimited Sharing",
"shareFullFeatureBulletPoints": "Share unlimited assets with other users;Manage shared assets;Assign shared assets to user groups",
"shareFullFeatureDescription": "Share unlimited assets with other users and manage shared assets. Assign shared assets to user groups to manage their permissions.",
"shareFullPaywallMessage": "You can share assets only with single user group. Upgrade to share assets with multiple user groups and users.",
"paywallDevtoolsButtonLabel": "Open Enso Devtools",
"paywallDevtoolsPopoverHeading": "Enso Devtools"
}

View File

@ -110,6 +110,13 @@ interface PlaceholderOverrides {
readonly logEventBackendError: [string]
readonly subscribeSuccessSubtitle: [string]
readonly paywallAvailabilityLevel: [plan: string]
readonly paywallScreenDescription: [plan: string]
readonly userGroupsLimitMessage: [limit: number]
readonly inviteFormSeatsLeftError: [exceedBy: number]
readonly inviteFormSeatsLeft: [seatsLeft: number]
readonly seatsLeft: [seatsLeft: number, seatsTotal: number]
}
/** An tuple of `string` for placeholders for each {@link TextId}. */