Billing Page iteration one (#10497)

#### Tl;dr
Closes: enso-org/cloud-v2#1158
This PR adds a redirect to the stripe page where the user can manage his subscription

<details><summary>Demo Presentation</summary>
<p>

https://github.com/enso-org/enso/assets/61194245/360fdc7e-46ff-49fa-9936-e5c61fe6f917

</p>
</details>

---

#### Context:
Our first iteration was to add our billing page but after a few iterations, we decided to postpone it in favor of more important features.

#### This Change:
1. creates a link for a private user session
2. redirect the user to that page (open in a new tab) when the user clicks on the billing tab

#### Test Plan:
Go over how you plan to test it. Your test plan should be more thorough the riskier the change is. For major changes, I like to describe how I E2E tested it and will monitor the rollout.

---
This commit is contained in:
Sergei Garin 2024-07-23 22:10:21 +03:00 committed by GitHub
parent 256a01a2ac
commit db669a67fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 166 additions and 65 deletions

View File

@ -50,7 +50,6 @@ test.test('extra columns should stick to top of scroll container', async ({ page
}
},
})
await actions.reload({ page })
await actions.locateAccessedByProjectsColumnToggle(page).click()
await actions.locateAccessedDataColumnToggle(page).click()

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16"><path fill="#000" fill-rule="evenodd" d="M0 5c0-1.1.9-2 2-2h12a2 2 0 0 1 2 2H0Zm0 2h16v4a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V7Zm14 2h-3v1h3V9Z" clip-rule="evenodd"/><path fill="#000" d="M0 5h16v2H0z" opacity=".3"/></svg>

After

Width:  |  Height:  |  Size: 288 B

View File

@ -59,7 +59,7 @@ export interface BaseButtonProps<Render>
* Handler that is called when the press is released over the target.
* 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 onPress?: ((event: aria.PressEvent) => Promise<void> | void) | null | undefined
readonly contentClassName?: string
readonly testId?: string
readonly isDisabled?: boolean
@ -244,7 +244,7 @@ export const BUTTON_STYLES = twv.tv({
wrapper: 'relative block',
loader: 'absolute inset-0 flex items-center justify-center',
content: 'flex items-center gap-[0.5em]',
text: 'inline-flex items-center justify-center gap-1',
text: 'inline-flex items-center justify-center gap-1 w-full',
icon: 'h-[1.906cap] w-[1.906cap] flex-none aspect-square flex items-center justify-center',
},
defaultVariants: {
@ -364,7 +364,7 @@ export const Button = React.forwardRef(function Button(
const handlePress = (event: aria.PressEvent): void => {
if (!isDisabled) {
const result = onPress(event)
const result = onPress?.(event)
if (result instanceof Promise) {
setImplicitlyLoading(true)

View File

@ -2,6 +2,10 @@
import * as React from 'react'
import * as copyHook from '#/hooks/copyHooks'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import * as twv from '#/utilities/tailwindVariants'
@ -11,14 +15,16 @@ import * as twv from '#/utilities/tailwindVariants'
// =================
const COPY_BLOCK_STYLES = twv.tv({
base: 'relative grid grid-cols-[minmax(0,_1fr)_auto] max-w-full bg-primary/10 items-center',
base: ariaComponents.TEXT_STYLE({
class: 'max-w-full bg-primary/5 border-primary/10',
}),
variants: {
size: {
small: 'py-2 pl-2 pr-1',
medium: 'py-3 pl-3 pr-2',
large: 'py-4 pl-4 pr-2.5',
small: 'py-[1.5px] px-[5.5px]',
medium: 'py-[3.5px] px-[7.5px]',
large: 'py-[5.5px] px-[11.5px]',
},
roundings: {
rounded: {
custom: '',
small: 'rounded-sm',
medium: 'rounded-md',
@ -26,15 +32,8 @@ const COPY_BLOCK_STYLES = twv.tv({
full: 'rounded-full',
},
},
slots: {
titleBlock: 'col-span-1 text-sm text-primary/60',
copyTextBlock: 'flex-auto text-sm text-primary/60 text-nowrap overflow-x-auto scroll-hidden',
copyButton: 'flex-none',
},
defaultVariants: {
size: 'medium',
roundings: 'medium',
},
slots: { copyTextBlock: 'flex-auto text-nowrap overflow-x-auto scroll-hidden w-full' },
defaultVariants: { size: 'medium', rounded: 'full' },
})
// =================
@ -52,12 +51,21 @@ export interface CopyBlockProps {
/** A block of text with a copy button. */
export function CopyBlock(props: CopyBlockProps) {
const { copyText, className, onCopy = () => {} } = props
const { copyTextBlock, base, copyButton } = COPY_BLOCK_STYLES()
const { getText } = textProvider.useText()
const { mutateAsync, isSuccess } = copyHook.useCopy({ copyText, onCopy })
const { copyTextBlock, base } = COPY_BLOCK_STYLES()
return (
<div className={base({ className })}>
<div className={copyTextBlock()}>{copyText}</div>
<ariaComponents.CopyButton copyText={copyText} onCopy={onCopy} className={copyButton()} />
</div>
<ariaComponents.Button
variant="custom"
size="custom"
onPress={() => mutateAsync()}
tooltip={isSuccess ? getText('copied') : getText('copy')}
className={base({ className })}
>
<span className={copyTextBlock()}>{copyText}</span>
</ariaComponents.Button>
)
}

View File

@ -35,7 +35,7 @@ const STATUS_ICON_MAP: Readonly<Record<Status, StatusIcon>> = {
}
const RESULT_STYLES = twv.tv({
base: 'flex flex-col items-center justify-center px-6 py-4 text-center h-[max-content]',
base: 'flex flex-col items-center justify-center max-w-full px-6 py-4 text-center h-[max-content]',
variants: {
centered: {
horizontal: 'mx-auto',

View File

@ -1,7 +1,6 @@
/** @file A styled button representing a tab on a sidebar. */
import * as React from 'react'
import type * as aria from '#/components/aria'
import * as ariaComponent from '#/components/AriaComponents'
// ========================
@ -17,7 +16,7 @@ export interface SidebarTabButtonProps {
readonly active?: boolean
readonly icon: string
readonly label: string
readonly onPress: (event: aria.PressEvent) => void
readonly onPress: ariaComponent.ButtonProps['onPress']
}
/** A styled button representing a tab on a sidebar. */
@ -26,11 +25,12 @@ export default function SidebarTabButton(props: SidebarTabButtonProps) {
return (
<ariaComponent.Button
variant="ghost"
size="medium"
onPress={onPress}
isDisabled={isDisabled}
icon={icon}
variant="ghost"
loaderPosition="icon"
size="medium"
isDisabled={isDisabled}
rounded="full"
className={active ? 'bg-white opacity-100' : ''}
>

View File

@ -42,6 +42,7 @@ export default function AssetProjectSession(props: AssetProjectSessionProps) {
<div className="flex items-center gap-1">
<ariaComponents.DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Button active image={LogsIcon} alt={getText('showLogs')} onPress={() => {}} />
<ProjectLogsModal
isOpen={isOpen}
backend={backend}

View File

@ -5,6 +5,7 @@ import * as reactQuery from '@tanstack/react-query'
import AssetProjectSession from '#/layouts/AssetProjectSession'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as loader from '#/components/Loader'
import type * as backendModule from '#/services/Backend'
@ -25,9 +26,11 @@ export interface AssetProjectSessionsProps {
/** A list of previous versions of an asset. */
export default function AssetProjectSessions(props: AssetProjectSessionsProps) {
return (
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader />}>
<AssetProjectSessionsInternal {...props} />
</React.Suspense>
</errorBoundary.ErrorBoundary>
)
}

View File

@ -41,7 +41,7 @@ export interface SettingsProps {
/** Settings screen. */
export default function Settings() {
const backend = backendProvider.useRemoteBackend()
const backend = backendProvider.useRemoteBackendStrict()
const localBackend = backendProvider.useLocalBackend()
const [tab, setTab] = searchParamsState.useSearchParamsState(
'SettingsTab',
@ -58,6 +58,7 @@ export default function Settings() {
const organization = backendHooks.useBackendGetOrganization(backend)
const isQueryBlank = !/\S/.test(query)
const client = reactQuery.useQueryClient()
const updateUserMutation = backendHooks.useBackendMutation(backend, 'updateUser', {
meta: { invalidates: [authQueryKey], awaitInvalidates: true },
})
@ -75,6 +76,7 @@ export default function Settings() {
},
meta: { invalidates: [[localBackend?.type, 'listDirectory']], awaitInvalidates: true },
})
const updateLocalRootPath = updateLocalRootPathMutation.mutateAsync
const context = React.useMemo<settingsData.SettingsContext>(
@ -89,6 +91,7 @@ export default function Settings() {
updateLocalRootPath,
toastAndLog,
getText,
queryClient: client,
}),
[
accessToken,
@ -101,6 +104,7 @@ export default function Settings() {
updateOrganization,
updateUser,
user,
client,
]
)
@ -197,8 +201,9 @@ export default function Settings() {
/>
</aria.Popover>
</aria.MenuTrigger>
<ariaComponents.Text variant="h1" className="font-bold">
<span>{getText('settingsFor')}</span>
{getText('settingsFor')}
</ariaComponents.Text>
<ariaComponents.Text

View File

@ -11,7 +11,7 @@ enum SettingsTabType {
local = 'local',
// features = 'features',
// notifications = 'notifications',
// billingAndPlans = 'billing-and-plans',
billingAndPlans = 'billing-and-plans',
members = 'members',
userGroups = 'user-groups',
// appearance = 'appearance',

View File

@ -1,11 +1,13 @@
/** @file Metadata for rendering each settings section. */
import * as React from 'react'
import type * as reactQuery from '@tanstack/react-query'
import isEmail from 'validator/lib/isEmail'
import type * as text from 'enso-common/src/text'
import ComputerIcon from '#/assets/computer.svg'
import CreditCardIcon from '#/assets/credit_card.svg'
import KeyboardShortcutsIcon from '#/assets/keyboard_shortcuts.svg'
import LogIcon from '#/assets/log.svg'
import PeopleSettingsIcon from '#/assets/people_settings.svg'
@ -35,6 +37,7 @@ import * as menuEntry from '#/components/MenuEntry'
import * as backend from '#/services/Backend'
import type Backend from '#/services/Backend'
import type LocalBackend from '#/services/LocalBackend'
import type RemoteBackend from '#/services/RemoteBackend'
import * as object from '#/utilities/object'
@ -139,7 +142,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
{
type: SettingsEntryType.custom,
aliasesId: 'profilePictureSettingsCustomEntryAliases',
render: context => context.backend && <ProfilePictureInput backend={context.backend} />,
render: context => <ProfilePictureInput backend={context.backend} />,
},
],
},
@ -220,8 +223,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
{
type: SettingsEntryType.custom,
aliasesId: 'organizationProfilePictureSettingsCustomEntryAliases',
render: context =>
context.backend && <OrganizationProfilePictureInput backend={context.backend} />,
render: context => <OrganizationProfilePictureInput backend={context.backend} />,
},
],
},
@ -247,6 +249,33 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
},
],
},
[SettingsTabType.billingAndPlans]: {
nameId: 'billingAndPlansSettingsTab',
settingsTab: SettingsTabType.billingAndPlans,
icon: CreditCardIcon,
organizationOnly: true,
visible: context => context.organization?.subscription != null,
sections: [],
onPress: context =>
context.queryClient
.getMutationCache()
.build(context.queryClient, {
mutationKey: ['billing', 'customerPortalSession'],
mutationFn: () =>
context.backend
.createCustomerPortalSession()
.then(url => {
if (url != null) {
window.open(url, '_blank')?.focus()
}
})
.catch(err => {
context.toastAndLog('arbitraryErrorTitle', err)
throw err
}),
})
.execute({} satisfies unknown),
},
[SettingsTabType.members]: {
nameId: 'membersSettingsTab',
settingsTab: SettingsTabType.members,
@ -256,12 +285,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
sections: [
{
nameId: 'membersSettingsSection',
entries: [
{
type: SettingsEntryType.custom,
render: () => <MembersSettingsSection />,
},
],
entries: [{ type: SettingsEntryType.custom, render: () => <MembersSettingsSection /> }],
},
],
},
@ -278,8 +302,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
entries: [
{
type: SettingsEntryType.custom,
render: context =>
context.backend && <UserGroupsSettingsSection backend={context.backend} />,
render: context => <UserGroupsSettingsSection backend={context.backend} />,
},
],
},
@ -290,8 +313,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
entries: [
{
type: SettingsEntryType.custom,
render: context =>
context.backend && (
render: context => (
<MembersTable backend={context.backend} draggable populateWithSelf />
),
},
@ -340,8 +362,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
entries: [
{
type: SettingsEntryType.custom,
render: context =>
context.backend && <ActivityLogSettingsSection backend={context.backend} />,
render: context => <ActivityLogSettingsSection backend={context.backend} />,
},
],
},
@ -360,6 +381,7 @@ export const SETTINGS_DATA: SettingsData = [
{
nameId: 'accessSettingsTabSection',
tabs: [
SETTINGS_TAB_DATA[SettingsTabType.billingAndPlans],
SETTINGS_TAB_DATA[SettingsTabType.members],
SETTINGS_TAB_DATA[SettingsTabType.userGroups],
],
@ -386,7 +408,7 @@ export const ALL_SETTINGS_TABS = SETTINGS_DATA.flatMap(section =>
export interface SettingsContext {
readonly accessToken: string
readonly user: backend.User
readonly backend: Backend | null
readonly backend: RemoteBackend
readonly localBackend: LocalBackend | null
readonly organization: backend.OrganizationInfo | null
readonly updateUser: (variables: Parameters<Backend['updateUser']>) => Promise<void>
@ -396,6 +418,7 @@ export interface SettingsContext {
readonly updateLocalRootPath: (rootPath: string) => Promise<void>
readonly toastAndLog: toastAndLogHooks.ToastAndLogCallback
readonly getText: textProvider.GetText
readonly queryClient: reactQuery.QueryClient
}
// ==============================
@ -463,6 +486,7 @@ export interface SettingsTabData {
* a paywall is shown instead of the settings tab. */
readonly feature?: billing.PaywallFeatureName
readonly sections: readonly SettingsSectionData[]
readonly onPress?: (context: SettingsContext) => Promise<void> | void
}
// ==============================

View File

@ -62,6 +62,7 @@ export default function SettingsSidebar(props: SettingsSidebarProps) {
>
{name}
</aria.Header>
<ariaComponents.ButtonGroup gap="xxsmall" direction="column" align="start">
{visibleTabData.map(tabData => (
<SidebarTabButton
@ -70,9 +71,14 @@ export default function SettingsSidebar(props: SettingsSidebarProps) {
icon={tabData.icon}
label={getText(tabData.nameId)}
active={tabData.settingsTab === tab}
onPress={() => {
onPress={() =>
tabData.onPress
? tabData.onPress(context)
: // even though this function returns void, we don't want to
// complicate things by returning only in case of custom onPress
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
setTab(tabData.settingsTab)
}}
}
/>
))}
</ariaComponents.ButtonGroup>

View File

@ -52,7 +52,10 @@ export default function BackendProvider(props: BackendProviderProps) {
// === useRemoteBackend ===
// ========================
/** Get the Remote Backend. */
/**
* Get the Remote Backend. Since the RemoteBackend is always defined, `null` is never returned.
* @deprecated Use {@link useRemoteBackendStrict} instead.
*/
export function useRemoteBackend() {
return React.useContext(BackendContext).remoteBackend
}
@ -61,9 +64,10 @@ export function useRemoteBackend() {
// === useRemoteBackendStrict ===
// ==============================
/** Get the Remote Backend.
* @throws {Error} when no Remote Backend exists. This should only happen if the user is not logged
* in. */
/**
* Get the Remote Backend.
* @throws {Error} when no Remote Backend exists. This should never happen.
*/
export function useRemoteBackendStrict() {
const remoteBackend = React.useContext(BackendContext).remoteBackend
if (remoteBackend == null) {
@ -90,14 +94,10 @@ export function useLocalBackend() {
* @throws {Error} when neither the Remote Backend nor the Local Backend are supported.
* This should never happen unless the build is misconfigured. */
export function useBackend(category: Category) {
const remoteBackend = useRemoteBackend()
const remoteBackend = useRemoteBackendStrict()
const localBackend = useLocalBackend()
if (categoryModule.isCloud(category)) {
invariant(
remoteBackend != null,
`This distribution of ${common.PRODUCT_NAME} does not support the Cloud Backend.`
)
return remoteBackend
} else {
invariant(

View File

@ -804,4 +804,11 @@ export default class LocalBackend extends Backend {
override logEvent() {
return this.invalidOperation()
}
/**
* Invalid operation.
*/
override createCustomerPortalSession() {
return this.invalidOperation()
}
}

View File

@ -1111,6 +1111,22 @@ export default class RemoteBackend extends Backend {
await download.downloadWithHeaders(url, this.client.defaultHeaders, name)
}
/**
* Fetch the URL of the customer portal.
*/
override async createCustomerPortalSession() {
const response = await this.post<backend.CreateCustomerPortalSessionResponse>(
remoteBackendPaths.getCustomerPortalSessionPath(),
{}
)
if (!responseIsSuccessful(response)) {
return await this.throw(response, 'getCustomerPortalUrlBackendError')
} else {
return (await response.json()).url
}
}
/** Get the default version given the type of version (IDE or backend). */
private async getDefaultVersion(versionType: backend.VersionType) {
const cached = this.defaultVersions[versionType]

View File

@ -74,6 +74,20 @@ export const CANCEL_SUBSCRIPTION_PATH = 'payments/subscription'
export const GET_LOG_EVENTS_PATH = 'log_events'
/** Relative HTTP path to the "post log event" endpoint of the Cloud backend API. */
export const POST_LOG_EVENT_PATH = 'logs'
/**
* Relative HTTP path to the "get customer portal session" endpoint of the Cloud backend API.
*/
export function getCustomerPortalSessionPath(returnUrl?: string) {
const baseUrl = 'payments/customer-portal-sessions/create'
if (returnUrl === undefined) {
return baseUrl
} else {
return `${baseUrl}?returnUrl=${returnUrl}`
}
}
/** Relative HTTP path to the "change user groups" endpoint of the Cloud backend API. */
export function changeUserGroupPath(userId: backend.UserId) {
return `users/${userId}/usergroups`

View File

@ -492,6 +492,14 @@ export interface UserGroupPermission {
/** User permission for a specific user or user group. */
export type AssetPermission = UserGroupPermission | UserPermission
/**
* Response from the "create customer portal session" endpoint.
* Returns a URL that the user can use to access the customer portal and manage their subscription.
*/
export interface CreateCustomerPortalSessionResponse {
readonly url: string | null
}
/** Whether an {@link AssetPermission} is a {@link UserPermission}. */
export function isUserPermission(permission: AssetPermission): permission is UserPermission {
return 'user' in permission
@ -1458,4 +1466,11 @@ export default abstract class Backend {
): Promise<void>
/** Download from an arbitrary URL that is assumed to originate from this backend. */
abstract download(url: string, name?: string): Promise<void>
/**
* Get the URL for the customer portal.
* @see https://stripe.com/docs/billing/subscriptions/integrating-customer-portal
* @param returnUrl - The URL to redirect to after the customer visits the portal.
*/
abstract createCustomerPortalSession(returnUrl: string): Promise<string | null>
}

View File

@ -145,6 +145,7 @@
"logEventBackendError": "Could not log an event '$0'",
"getDefaultVersionBackendError": "No default $0 version found",
"duplicateUserGroupError": "This user group already exists",
"getCustomerPortalUrlBackendError": "Could not get customer portal URL",
"directoryAssetType": "folder",
"projectAssetType": "project",
@ -706,6 +707,7 @@
"localSettingsSection": "Local",
"localRootPathSettingsInput": "Root Folder",
"accessSettingsTabSection": "Access",
"billingAndPlansSettingsTab": "Billing",
"membersSettingsTab": "Members",
"membersSettingsSection": "Members",
"userGroupsSettingsTab": "User groups",