mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 16:01:30 +03:00
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:
parent
256a01a2ac
commit
db669a67fb
@ -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()
|
||||
|
1
app/dashboard/src/assets/credit_card.svg
Normal file
1
app/dashboard/src/assets/credit_card.svg
Normal 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 |
@ -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)
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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' : ''}
|
||||
>
|
||||
|
@ -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}
|
||||
|
@ -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 (
|
||||
<React.Suspense fallback={<loader.Loader />}>
|
||||
<AssetProjectSessionsInternal {...props} />
|
||||
</React.Suspense>
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<React.Suspense fallback={<loader.Loader />}>
|
||||
<AssetProjectSessionsInternal {...props} />
|
||||
</React.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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,10 +313,9 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
||||
entries: [
|
||||
{
|
||||
type: SettingsEntryType.custom,
|
||||
render: context =>
|
||||
context.backend && (
|
||||
<MembersTable backend={context.backend} draggable populateWithSelf />
|
||||
),
|
||||
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
|
||||
}
|
||||
|
||||
// ==============================
|
||||
|
@ -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={() => {
|
||||
setTab(tabData.settingsTab)
|
||||
}}
|
||||
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>
|
||||
|
@ -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(
|
||||
|
@ -804,4 +804,11 @@ export default class LocalBackend extends Backend {
|
||||
override logEvent() {
|
||||
return this.invalidOperation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid operation.
|
||||
*/
|
||||
override createCustomerPortalSession() {
|
||||
return this.invalidOperation()
|
||||
}
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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`
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user