mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 13:31:54 +00:00
feat/dotcom: use Enterprise Portal for all subscriptions UI (#64115)
Overview Loom: 1. https://www.loom.com/share/988465de1c3a4caaa08fc6b22ffb74e5?sid=860f28cd-4dfb-4758-9bd9-d2f485ceb317 2. Announcement: https://www.loom.com/share/8560388e0a4a404caf67d908820ed0d0?sid=6482815f-ac60-4251-95cc-307f527a60dd tl;dr    TODO: - [x] Fix the "edit instance type" input - [x] ~Subscription view: reload on changing the Enterprise Portal environment selector~ -> https://linear.app/sourcegraph/issue/CORE-245/dotcom-subscriptions-ui-enterprise-portal-instance-selector-doesnt, use a jank reload for now - [x] CI Closes https://linear.app/sourcegraph/issue/CORE-100/enterprise-portal-migrate-away-from-dotcom-db-as-source-of-truth ## Test plan Manual testing for UI via `sg start dotcom` -> https://sourcegraph.test:3443/site-admin/dotcom/product/subscriptions The PRs this PR is stacked on top of implement testing of the backend capabilities
This commit is contained in:
parent
fef7af964b
commit
6e828b0a14
@ -701,10 +701,8 @@ ts_project(
|
||||
"src/enterprise/codeintel/sort.ts",
|
||||
"src/enterprise/codeintel/useCodeIntel.ts",
|
||||
"src/enterprise/codeintel/useSearchBasedCodeIntel.ts",
|
||||
"src/enterprise/dotcom/productSubscriptions/AccountName.tsx",
|
||||
"src/enterprise/dotcom/productSubscriptions/ProductLicenseValidity.tsx",
|
||||
"src/enterprise/dotcom/productSubscriptions/ProductSubscriptionLabel.tsx",
|
||||
"src/enterprise/dotcom/productSubscriptions/ProductSubscriptionNode.tsx",
|
||||
"src/enterprise/dotcom/productSubscriptions/features.ts",
|
||||
"src/enterprise/embed/EmbeddedWebApp.tsx",
|
||||
"src/enterprise/embed/OpenNewTabAnchorLink.tsx",
|
||||
@ -981,7 +979,6 @@ ts_project(
|
||||
"src/enterprise/productSubscription/ExpirationDate.tsx",
|
||||
"src/enterprise/productSubscription/LicenseGenerationKeyWarning.tsx",
|
||||
"src/enterprise/productSubscription/ProductCertificate.tsx",
|
||||
"src/enterprise/productSubscription/ProductLicenseInfoDescription.tsx",
|
||||
"src/enterprise/productSubscription/ProductLicenseTags.tsx",
|
||||
"src/enterprise/productSubscription/TrueUpStatusSummary.tsx",
|
||||
"src/enterprise/rbac/SiteAdminRolesPage.tsx",
|
||||
@ -1025,9 +1022,11 @@ ts_project(
|
||||
"src/enterprise/site-admin/UserManagement/backend.ts",
|
||||
"src/enterprise/site-admin/UserManagement/components/RoleAssignmentModal.tsx",
|
||||
"src/enterprise/site-admin/backend.ts",
|
||||
"src/enterprise/site-admin/dotcom/customers/SiteAdminCustomersPage.tsx",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/CodyGatewayRateLimitModal.tsx",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/CodyServicesSection.tsx",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/EnterprisePortalEnvSelector.tsx",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/EnterprisePortalEnvWarning.tsx",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/InstanceTypeBadge.tsx",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/SiteAdminCreateProductSubscriptionPage.tsx",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/SiteAdminGenerateProductLicenseForSubscriptionForm.tsx",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/SiteAdminLicenseKeyLookupPage.tsx",
|
||||
@ -1035,10 +1034,11 @@ ts_project(
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/SiteAdminProductSubscriptionNode.tsx",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/SiteAdminProductSubscriptionPage.tsx",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/SiteAdminProductSubscriptionsPage.tsx",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/backend.ts",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/enterpriseportal.ts",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/enterpriseportalgen/codyaccess-CodyAccessService_connectquery.ts",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/enterpriseportalgen/codyaccess_pb.ts",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/enterpriseportalgen/subscriptions-SubscriptionsService_connectquery.ts",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/enterpriseportalgen/subscriptions_pb.ts",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/plandata.ts",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/testUtils.ts",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/utils.ts",
|
||||
@ -1944,8 +1944,6 @@ ts_project(
|
||||
"src/enterprise/repo/settings/RepoSettingsLogsPage.test.tsx",
|
||||
"src/enterprise/repo/settings/utils.test.ts",
|
||||
"src/enterprise/searchContexts/SearchContextsList.test.tsx",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/SiteAdminGenerateProductLicenseForSubscriptionForm.test.tsx",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/SiteAdminProductLicenseNode.test.tsx",
|
||||
"src/enterprise/site-admin/dotcom/productSubscriptions/utils.test.ts",
|
||||
"src/enterprise/user/settings/auth/UserSettingsPermissionsPage.test.tsx",
|
||||
"src/featureFlags/lib/parseUrlOverrideFeatureFlags.test.ts",
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { Link } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { ProductLicenseSubscriptionAccount } from '../../../graphql-operations'
|
||||
import { userURL } from '../../../user'
|
||||
|
||||
/**
|
||||
* Displays the account name as a link.
|
||||
*/
|
||||
export const AccountName: React.FunctionComponent<
|
||||
React.PropsWithChildren<{
|
||||
account: Pick<ProductLicenseSubscriptionAccount, 'username' | 'displayName'> | null
|
||||
link?: string
|
||||
}>
|
||||
> = ({ account, link }) =>
|
||||
account ? (
|
||||
<>
|
||||
<Link to={link || userURL(account.username)}>{account.username}</Link>{' '}
|
||||
{account.displayName && `(${account.displayName})`}
|
||||
</>
|
||||
) : (
|
||||
<em>(Account deleted)</em>
|
||||
)
|
||||
@ -6,8 +6,12 @@ import classNames from 'classnames'
|
||||
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
|
||||
import { Icon, Label } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { ProductLicenseFields } from '../../../graphql-operations'
|
||||
import { isProductLicenseExpired } from '../../../productSubscription/helpers'
|
||||
import {
|
||||
EnterpriseSubscriptionLicenseCondition_Status,
|
||||
type EnterpriseSubscriptionLicenseKey_Info,
|
||||
type EnterpriseSubscriptionLicenseCondition,
|
||||
} from '../../site-admin/dotcom/productSubscriptions/enterpriseportalgen/subscriptions_pb'
|
||||
|
||||
const getIcon = (isExpired: boolean, isRevoked: boolean): string => {
|
||||
if (isExpired) {
|
||||
@ -46,32 +50,36 @@ const getText = (isExpired: boolean, isRevoked: boolean): string => {
|
||||
*/
|
||||
export const ProductLicenseValidity: React.FunctionComponent<
|
||||
React.PropsWithChildren<{
|
||||
license: ProductLicenseFields
|
||||
licenseInfo: EnterpriseSubscriptionLicenseKey_Info | undefined
|
||||
licenseConditions: EnterpriseSubscriptionLicenseCondition[]
|
||||
variant?: 'icon-only' | 'no-icon'
|
||||
className?: string
|
||||
}>
|
||||
> = ({ license: { info, revokedAt, revokeReason }, variant, className = '' }) => {
|
||||
const expiresAt = info?.expiresAt ?? 0
|
||||
> = ({ licenseInfo: info, licenseConditions: conditions, variant, className = '' }) => {
|
||||
const expiresAt = info?.expireTime?.toDate() ?? 0
|
||||
const isExpired = isProductLicenseExpired(expiresAt)
|
||||
const isRevoked = !!revokedAt
|
||||
const timestamp = revokedAt ?? expiresAt
|
||||
const timestampSuffix = isExpired || isRevoked ? 'ago' : 'remaining'
|
||||
|
||||
const revoked = conditions.find(
|
||||
condition => condition.status === EnterpriseSubscriptionLicenseCondition_Status.REVOKED
|
||||
)
|
||||
const timestamp = revoked?.lastTransitionTime?.toDate() ?? expiresAt
|
||||
const timestampSuffix = isExpired || revoked ? 'ago' : 'remaining'
|
||||
|
||||
if (variant === 'icon-only') {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ValidityIcon isExpired={isExpired} isRevoked={isRevoked} />
|
||||
<ValidityIcon isExpired={isExpired} isRevoked={!!revoked} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={className}>
|
||||
{variant !== 'no-icon' && <ValidityIcon isExpired={isExpired} isRevoked={isRevoked} />}
|
||||
{getText(isExpired, isRevoked)}, <Timestamp date={timestamp} noAbout={true} noAgo={true} utc={true} />{' '}
|
||||
{variant !== 'no-icon' && <ValidityIcon isExpired={isExpired} isRevoked={!!revoked} />}
|
||||
{getText(isExpired, !!revoked)}, <Timestamp date={timestamp} noAbout={true} noAgo={true} utc={true} />{' '}
|
||||
{timestampSuffix}
|
||||
{!isExpired && isRevoked && revokeReason && (
|
||||
{revoked?.message && (
|
||||
<>
|
||||
<Label className="ml-2 mb-0 d-inline">Reason:</Label> {revokeReason}
|
||||
<Label className="ml-2 mb-0 d-inline">Reason:</Label> {revoked.message}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { ProductSubscriptionFields, SiteAdminProductSubscriptionFields } from '../../../graphql-operations'
|
||||
import { formatUserCount } from '../../../productSubscription/helpers'
|
||||
|
||||
/**
|
||||
@ -9,18 +8,20 @@ import { formatUserCount } from '../../../productSubscription/helpers'
|
||||
*/
|
||||
export const ProductSubscriptionLabel: React.FunctionComponent<
|
||||
React.PropsWithChildren<{
|
||||
productSubscription: ProductSubscriptionFields | SiteAdminProductSubscriptionFields
|
||||
productName?: string
|
||||
userCount?: bigint
|
||||
className?: string
|
||||
}>
|
||||
> = ({ productSubscription, className = '' }) => (
|
||||
> = ({ productName, userCount, className = '' }) => (
|
||||
<span className={className}>
|
||||
{productSubscription.activeLicense?.info ? (
|
||||
<>
|
||||
{productSubscription.activeLicense.info.productNameWithBrand} (
|
||||
{formatUserCount(productSubscription.activeLicense.info.userCount)})
|
||||
</>
|
||||
{productName && userCount ? (
|
||||
<>{productSubscriptionLabel(productName, userCount)}</>
|
||||
) : (
|
||||
<span className="text-muted font-italic">No plan selected</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
export function productSubscriptionLabel(productName?: string, userCount?: bigint): string {
|
||||
return `${productName} (${formatUserCount(Number(userCount))})`
|
||||
}
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { gql } from '@sourcegraph/http-client'
|
||||
import { Link } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { ProductSubscriptionFields } from '../../../graphql-operations'
|
||||
|
||||
import { ProductSubscriptionLabel } from './ProductSubscriptionLabel'
|
||||
|
||||
export const productSubscriptionFragment = gql`
|
||||
fragment ProductSubscriptionFields on ProductSubscription {
|
||||
id
|
||||
name
|
||||
account {
|
||||
id
|
||||
username
|
||||
displayName
|
||||
}
|
||||
activeLicense {
|
||||
licenseKey
|
||||
info {
|
||||
productNameWithBrand
|
||||
tags
|
||||
userCount
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
createdAt
|
||||
isArchived
|
||||
url
|
||||
}
|
||||
`
|
||||
|
||||
export const ProductSubscriptionNodeHeader: React.FunctionComponent<React.PropsWithChildren<unknown>> = () => (
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Plan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
|
||||
export interface ProductSubscriptionNodeProps {
|
||||
node: ProductSubscriptionFields
|
||||
}
|
||||
|
||||
export const ProductSubscriptionNode: React.FunctionComponent<
|
||||
React.PropsWithChildren<ProductSubscriptionNodeProps>
|
||||
> = ({ node }) => (
|
||||
<tr>
|
||||
<td className="text-nowrap">
|
||||
<Link to={node.url} className="mr-3 font-weight-bold">
|
||||
{node.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="w-100">
|
||||
<ProductSubscriptionLabel productSubscription={node} className="mr-3" />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@ -1,17 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { H3 } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { ProductLicenseInfoFields } from '../../graphql-operations'
|
||||
import { formatUserCount } from '../../productSubscription/helpers'
|
||||
|
||||
export const ProductLicenseInfoDescription: React.FunctionComponent<
|
||||
React.PropsWithChildren<{
|
||||
licenseInfo: ProductLicenseInfoFields
|
||||
className?: string
|
||||
}>
|
||||
> = ({ licenseInfo, className = '' }) => (
|
||||
<H3 className={className}>
|
||||
{licenseInfo.productNameWithBrand} ({formatUserCount(licenseInfo.userCount)})
|
||||
</H3>
|
||||
)
|
||||
@ -1,107 +0,0 @@
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
|
||||
import { type Observable, Subject } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
|
||||
import { createAggregateError } from '@sourcegraph/common'
|
||||
import { gql } from '@sourcegraph/http-client'
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { H2 } from '@sourcegraph/wildcard'
|
||||
|
||||
import { queryGraphQL } from '../../../../backend/graphql'
|
||||
import { FilteredConnection } from '../../../../components/FilteredConnection'
|
||||
import { PageTitle } from '../../../../components/PageTitle'
|
||||
import type { CustomerFields, CustomersResult, CustomersVariables } from '../../../../graphql-operations'
|
||||
import { userURL } from '../../../../user'
|
||||
import { AccountName } from '../../../dotcom/productSubscriptions/AccountName'
|
||||
|
||||
const siteAdminCustomerFragment = gql`
|
||||
fragment CustomerFields on User {
|
||||
id
|
||||
username
|
||||
displayName
|
||||
}
|
||||
`
|
||||
|
||||
interface SiteAdminCustomerNodeProps {
|
||||
node: CustomerFields
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a customer in a connection in the site admin area.
|
||||
*/
|
||||
const SiteAdminCustomerNode: React.FunctionComponent<React.PropsWithChildren<SiteAdminCustomerNodeProps>> = ({
|
||||
node,
|
||||
}) => (
|
||||
<li className="list-group-item py-2">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<span className="mr-3">
|
||||
<AccountName account={node} link={`${userURL(node.username)}/subscriptions`} />
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
interface Props extends TelemetryV2Props {}
|
||||
|
||||
/**
|
||||
* Displays a list of customers associated with user accounts on Sourcegraph.com.
|
||||
*/
|
||||
export const SiteAdminProductCustomersPage: React.FunctionComponent<React.PropsWithChildren<Props>> = props => {
|
||||
useEffect(() => props.telemetryRecorder.recordEvent('admin.customers', 'view'), [props.telemetryRecorder])
|
||||
|
||||
const updates = useMemo(() => new Subject<void>(), [])
|
||||
const nodeProps: Pick<SiteAdminCustomerNodeProps, Exclude<keyof SiteAdminCustomerNodeProps, 'node'>> = {}
|
||||
|
||||
return (
|
||||
<div className="site-admin-customers-page">
|
||||
<PageTitle title="Customers" />
|
||||
<div className="d-flex justify-content-between align-items-center mb-1">
|
||||
<H2 className="mb-0">Customers</H2>
|
||||
</div>
|
||||
<FilteredConnection<
|
||||
CustomerFields,
|
||||
Pick<SiteAdminCustomerNodeProps, Exclude<keyof SiteAdminCustomerNodeProps, 'node'>>
|
||||
>
|
||||
className="list-group list-group-flush mt-3"
|
||||
noun="customer"
|
||||
pluralNoun="customers"
|
||||
queryConnection={queryCustomers}
|
||||
nodeComponent={SiteAdminCustomerNode}
|
||||
nodeComponentProps={nodeProps}
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
updates={updates}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function queryCustomers(args: Partial<CustomersVariables>): Observable<CustomersResult['users']> {
|
||||
return queryGraphQL<CustomersResult>(
|
||||
gql`
|
||||
query Customers($first: Int, $query: String) {
|
||||
users(first: $first, query: $query) {
|
||||
nodes {
|
||||
...CustomerFields
|
||||
}
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
${siteAdminCustomerFragment}
|
||||
`,
|
||||
{
|
||||
first: args.first,
|
||||
query: args.query,
|
||||
}
|
||||
).pipe(
|
||||
map(({ data, errors }) => {
|
||||
if (!data?.users || (errors && errors.length > 0)) {
|
||||
throw createAggregateError(errors)
|
||||
}
|
||||
return data.users
|
||||
})
|
||||
)
|
||||
}
|
||||
@ -9,7 +9,6 @@ import { logger } from '@sourcegraph/common'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import {
|
||||
H3,
|
||||
ProductStatusBadge,
|
||||
Container,
|
||||
Text,
|
||||
H4,
|
||||
@ -112,21 +111,31 @@ export const CodyServicesSection: React.FunctionComponent<Props> = ({
|
||||
telemetryRecorder,
|
||||
])
|
||||
|
||||
const header = <H3>Cody services</H3>
|
||||
|
||||
if (getCodyGatewayAccessLoading && !codyGatewayAccessResponse) {
|
||||
return <LoadingSpinner />
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<LoadingSpinner />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (getCodyGatewayAccessError) {
|
||||
return <ErrorAlert className="my-2" error={getCodyGatewayAccessError} />
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<ErrorAlert className="my-2" error={getCodyGatewayAccessError} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const { access: codyGatewayAccess } = codyGatewayAccessResponse!
|
||||
|
||||
return (
|
||||
<>
|
||||
<H3>
|
||||
Cody services <ProductStatusBadge status="beta" />
|
||||
</H3>
|
||||
{header}
|
||||
<Container className="mb-3">
|
||||
<>
|
||||
<div className="form-group mb-2">
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
|
||||
import { mdiInformationOutline } from '@mdi/js'
|
||||
|
||||
import { Icon, Select, Tooltip } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { EnterprisePortalEnvironment } from './enterpriseportal'
|
||||
|
||||
interface Props {
|
||||
env: EnterprisePortalEnvironment | undefined
|
||||
setEnv: (env: EnterprisePortalEnvironment) => void
|
||||
}
|
||||
|
||||
export const EnterprisePortalEnvSelector: React.FunctionComponent<Props> = ({ env, setEnv }) => (
|
||||
<Select
|
||||
id="enterprise-portal-env"
|
||||
name="enterprise-portal-env"
|
||||
onChange={event => {
|
||||
setEnv(event.target.value as EnterprisePortalEnvironment)
|
||||
}}
|
||||
value={env ?? undefined}
|
||||
isCustomStyle={true}
|
||||
selectSize="sm"
|
||||
className="mr-2 ml-2 mb-0 mt-0"
|
||||
label={
|
||||
<>
|
||||
Enterprise Portal{' '}
|
||||
<Tooltip content="Selects the Enterprise Portal environment to interact with.">
|
||||
<Icon aria-label="Show help text" svgPath={mdiInformationOutline} />
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{[
|
||||
{ label: '✅ Production', value: 'prod' },
|
||||
{ label: '🚧 Development', value: 'dev' },
|
||||
]
|
||||
.concat(window.context.deployType === 'dev' ? [{ label: '👻 Local', value: 'local' }] : [])
|
||||
.map(opt => (
|
||||
<option key={opt.value} value={opt.value} label={opt.label} />
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
|
||||
export function getDefaultEnterprisePortalEnv(): EnterprisePortalEnvironment {
|
||||
return window.context.deployType === 'dev' ? 'local' : 'prod'
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import { Alert, Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { EnterprisePortalEnvironment } from './enterpriseportal'
|
||||
|
||||
export interface EnterprisePortalEnvWarningProps {
|
||||
env: EnterprisePortalEnvironment
|
||||
/**
|
||||
* For example, 'creating a subscription' - this will be inserted into the
|
||||
* warning text.
|
||||
*/
|
||||
actionText: string
|
||||
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a warning about the user's action if the selected env is not 'prod'.
|
||||
*/
|
||||
export const EnterprisePortalEnvWarning: React.FunctionComponent<EnterprisePortalEnvWarningProps> = ({
|
||||
env,
|
||||
actionText,
|
||||
className,
|
||||
}) => {
|
||||
if (env === 'prod') {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Alert variant="danger" className={className}>
|
||||
<Text>
|
||||
You are {actionText} for the <strong>{env}</strong> Enterprise Portal deployment. Everything you do here
|
||||
will only be visible to non-production environments that connect to this specific Enterprise Portal
|
||||
deployment.
|
||||
</Text>
|
||||
<Text className="mb-0">
|
||||
If you are {actionText} for a customer, select <strong>Production</strong> from the "Enterprise Portal"
|
||||
dropdown in the top right
|
||||
</Text>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { Badge, type BadgeVariantType } from '@sourcegraph/wildcard'
|
||||
|
||||
import { EnterpriseSubscriptionInstanceType } from './enterpriseportalgen/subscriptions_pb'
|
||||
|
||||
export interface InstanceTypeBadgeProps {
|
||||
instanceType: EnterpriseSubscriptionInstanceType
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays instance type in a cute badge with relevant tooltips.
|
||||
*/
|
||||
export const InstanceTypeBadge: React.FunctionComponent<InstanceTypeBadgeProps> = ({ instanceType, className }) => {
|
||||
let variant: BadgeVariantType = 'outlineSecondary'
|
||||
let tooltip = ''
|
||||
switch (instanceType) {
|
||||
case EnterpriseSubscriptionInstanceType.INTERNAL: {
|
||||
tooltip = 'This subscription is for Sourcegraph-internal instances.'
|
||||
break
|
||||
}
|
||||
case EnterpriseSubscriptionInstanceType.PRIMARY: {
|
||||
variant = 'primary'
|
||||
tooltip = "This subscription is for a customer's primary, production Sourcegraph instance."
|
||||
break
|
||||
}
|
||||
case EnterpriseSubscriptionInstanceType.SECONDARY: {
|
||||
variant = 'info'
|
||||
tooltip =
|
||||
"This subscription is for a customer's secondary Sourcegraph instance, such as one for staging new releases."
|
||||
break
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Badge variant={variant} small={true} tooltip={tooltip} className={className}>
|
||||
{EnterpriseSubscriptionInstanceType[instanceType]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@ -1,118 +1,35 @@
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { mdiPlus } from '@mdi/js'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { merge, of, type Observable } from 'rxjs'
|
||||
import { catchError, concatMap, map, tap } from 'rxjs/operators'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Navigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { asError, type ErrorLike, isErrorLike } from '@sourcegraph/common'
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Button, useEventObservable, Link, Alert, Icon, Form, Container, PageHeader } from '@sourcegraph/wildcard'
|
||||
import {
|
||||
Alert,
|
||||
Form,
|
||||
Container,
|
||||
PageHeader,
|
||||
useForm,
|
||||
Select,
|
||||
ErrorAlert,
|
||||
getDefaultInputProps,
|
||||
useField,
|
||||
createRequiredValidator,
|
||||
composeValidators,
|
||||
Input,
|
||||
Text,
|
||||
Label,
|
||||
type ValidationResult,
|
||||
} from '@sourcegraph/wildcard'
|
||||
|
||||
import type { AuthenticatedUser } from '../../../../auth'
|
||||
import { mutateGraphQL, queryGraphQL } from '../../../../backend/graphql'
|
||||
import { FilteredConnection } from '../../../../components/FilteredConnection'
|
||||
import { LoaderButton } from '../../../../components/LoaderButton'
|
||||
import { PageTitle } from '../../../../components/PageTitle'
|
||||
import type {
|
||||
CreateProductSubscriptionVariables,
|
||||
ProductSubscriptionAccountsResult,
|
||||
ProductSubscriptionAccountsVariables,
|
||||
ProductSubscriptionAccountFields,
|
||||
CreateProductSubscriptionResult,
|
||||
} from '../../../../graphql-operations'
|
||||
|
||||
interface UserCreateSubscriptionNodeProps extends TelemetryV2Props {
|
||||
/**
|
||||
* The user to display in this list item.
|
||||
*/
|
||||
node: ProductSubscriptionAccountFields
|
||||
authenticatedUser: AuthenticatedUser
|
||||
}
|
||||
|
||||
const createProductSubscription = (
|
||||
args: CreateProductSubscriptionVariables
|
||||
): Observable<CreateProductSubscriptionResult['dotcom']['createProductSubscription']> =>
|
||||
mutateGraphQL<CreateProductSubscriptionResult>(
|
||||
gql`
|
||||
mutation CreateProductSubscription($accountID: ID!) {
|
||||
dotcom {
|
||||
createProductSubscription(accountID: $accountID) {
|
||||
urlForSiteAdmin
|
||||
uuid
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
args
|
||||
).pipe(
|
||||
map(dataOrThrowErrors),
|
||||
map(data => data.dotcom.createProductSubscription)
|
||||
)
|
||||
|
||||
const UserCreateSubscriptionNode: React.FunctionComponent<React.PropsWithChildren<UserCreateSubscriptionNodeProps>> = (
|
||||
props: UserCreateSubscriptionNodeProps
|
||||
) => {
|
||||
const [onSubmit, createdSubscription] = useEventObservable(
|
||||
useCallback(
|
||||
(
|
||||
submits: Observable<React.FormEvent<HTMLFormElement>>
|
||||
): Observable<
|
||||
CreateProductSubscriptionResult['dotcom']['createProductSubscription'] | 'saving' | ErrorLike
|
||||
> =>
|
||||
submits.pipe(
|
||||
tap(event => event.preventDefault()),
|
||||
tap(() => props.telemetryRecorder.recordEvent('admin.productSubscriptions', 'create')),
|
||||
concatMap(() =>
|
||||
merge(
|
||||
of('saving' as const),
|
||||
createProductSubscription({ accountID: props.node.id }).pipe(
|
||||
catchError(error => [asError(error)])
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
[props.node.id, props.telemetryRecorder]
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{createdSubscription &&
|
||||
createdSubscription !== 'saving' &&
|
||||
!isErrorLike(createdSubscription) &&
|
||||
createdSubscription.urlForSiteAdmin && (
|
||||
<Navigate replace={true} to={createdSubscription.urlForSiteAdmin} />
|
||||
)}
|
||||
<li className="list-group-item py-2">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<Link to={`/users/${props.node.username}`}>{props.node.username}</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Form onSubmit={onSubmit}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createdSubscription === 'saving'}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<Icon aria-hidden={true} svgPath={mdiPlus} /> Create new subscription
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
{isErrorLike(createdSubscription) && <Alert variant="danger">{createdSubscription.message}</Alert>}
|
||||
{createdSubscription &&
|
||||
createdSubscription !== 'saving' &&
|
||||
!isErrorLike(createdSubscription) &&
|
||||
!createdSubscription.urlForSiteAdmin && (
|
||||
<Alert variant="danger">No subscription URL available (only accessible to site admins)</Alert>
|
||||
)}
|
||||
</li>
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { type EnterprisePortalEnvironment, useCreateEnterpriseSubscription, queryClient } from './enterpriseportal'
|
||||
import { getDefaultEnterprisePortalEnv, EnterprisePortalEnvSelector } from './EnterprisePortalEnvSelector'
|
||||
import { EnterprisePortalEnvWarning } from './EnterprisePortalEnvWarning'
|
||||
import { EnterpriseSubscriptionInstanceType } from './enterpriseportalgen/subscriptions_pb'
|
||||
|
||||
interface Props extends TelemetryV2Props {
|
||||
authenticatedUser: AuthenticatedUser
|
||||
@ -125,51 +42,244 @@ interface Props extends TelemetryV2Props {
|
||||
*/
|
||||
export const SiteAdminCreateProductSubscriptionPage: React.FunctionComponent<
|
||||
React.PropsWithChildren<Props>
|
||||
> = props => {
|
||||
> = props => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page {...props} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
interface FormData {
|
||||
displayName: string
|
||||
salesforceSubscriptionID: string
|
||||
instanceDomain: string
|
||||
instanceType: EnterpriseSubscriptionInstanceType
|
||||
message: string
|
||||
}
|
||||
|
||||
const QUERY_PARAM_ENV = 'env'
|
||||
|
||||
const DISPLAY_NAME_VALIDATOR = createRequiredValidator(
|
||||
'Brief, human-friendly, globally unique name for this subscription is required. This can be changed later.'
|
||||
)
|
||||
|
||||
const MESSAGE_VALIDATOR = createRequiredValidator('A message about the creation of this subscription is required.')
|
||||
|
||||
const SALESFORCE_SUBSCRIPTION_ID_VALIDATOR: (value: string | undefined) => ValidationResult = value => {
|
||||
if (!value) {
|
||||
return // not required
|
||||
}
|
||||
if (!value.startsWith('a1a')) {
|
||||
return 'Salesforce subscription ID must start with "a1a"'
|
||||
}
|
||||
if (value.length < 17) {
|
||||
return 'Salesforce subscription ID must be 17 characters long'
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const Page: React.FunctionComponent<React.PropsWithChildren<Props>> = props => {
|
||||
useEffect(() => props.telemetryRecorder.recordEvent('admin.productSubscriptions.create', 'view'))
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [env, setEnv] = useState<EnterprisePortalEnvironment>(
|
||||
(searchParams.get(QUERY_PARAM_ENV) as EnterprisePortalEnvironment) || getDefaultEnterprisePortalEnv()
|
||||
)
|
||||
useEffect(() => {
|
||||
searchParams.set(QUERY_PARAM_ENV, env)
|
||||
setSearchParams(searchParams)
|
||||
}, [env, setSearchParams, searchParams])
|
||||
|
||||
const { mutateAsync: createSubscription, error, data: createdSubscription } = useCreateEnterpriseSubscription(env)
|
||||
|
||||
const {
|
||||
formAPI,
|
||||
ref: formRef,
|
||||
handleSubmit,
|
||||
} = useForm<FormData>({
|
||||
initialValues: {
|
||||
displayName: '',
|
||||
message: '',
|
||||
salesforceSubscriptionID: '',
|
||||
instanceDomain: '',
|
||||
instanceType: EnterpriseSubscriptionInstanceType.PRIMARY,
|
||||
},
|
||||
onSubmit: async ({
|
||||
message,
|
||||
displayName,
|
||||
instanceDomain,
|
||||
salesforceSubscriptionID,
|
||||
instanceType,
|
||||
}: FormData) => {
|
||||
props.telemetryRecorder.recordEvent('admin.productSubscriptions', 'create')
|
||||
await createSubscription({
|
||||
message,
|
||||
subscription: {
|
||||
displayName,
|
||||
instanceDomain,
|
||||
instanceType,
|
||||
salesforce: salesforceSubscriptionID
|
||||
? {
|
||||
subscriptionId: salesforceSubscriptionID,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const displayName = useField({
|
||||
name: 'displayName',
|
||||
formApi: formAPI,
|
||||
validators: {
|
||||
sync: DISPLAY_NAME_VALIDATOR,
|
||||
},
|
||||
})
|
||||
|
||||
const message = useField({
|
||||
name: 'message',
|
||||
formApi: formAPI,
|
||||
validators: {
|
||||
sync: MESSAGE_VALIDATOR,
|
||||
},
|
||||
})
|
||||
|
||||
const instanceType = useField({
|
||||
name: 'instanceType',
|
||||
formApi: formAPI,
|
||||
})
|
||||
|
||||
const salesforceSubscriptionID = useField({
|
||||
name: 'salesforceSubscriptionID',
|
||||
formApi: formAPI,
|
||||
validators: {
|
||||
sync: useMemo(
|
||||
() =>
|
||||
composeValidators<string, unknown>([
|
||||
value => {
|
||||
if (instanceType.input.value !== EnterpriseSubscriptionInstanceType.INTERNAL && !value) {
|
||||
return 'Salesforce subscription ID is required for non-internal instances.'
|
||||
}
|
||||
return
|
||||
},
|
||||
SALESFORCE_SUBSCRIPTION_ID_VALIDATOR,
|
||||
]),
|
||||
[instanceType.input.value]
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
const instanceDomain = useField({
|
||||
name: 'instanceDomain',
|
||||
formApi: formAPI,
|
||||
})
|
||||
|
||||
// A subscription was created, navigate to the management page
|
||||
if (createdSubscription?.subscription) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/site-admin/dotcom/product/subscriptions/${createdSubscription.subscription?.id}?env=${env}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="site-admin-create-product-subscription-page">
|
||||
<PageTitle title="Create product subscription" />
|
||||
<PageHeader headingElement="h2" path={[{ text: 'Create product subscription' }]} className="mb-2" />
|
||||
<PageTitle title="Create Enterprise subscription" />
|
||||
<PageHeader
|
||||
headingElement="h2"
|
||||
path={[
|
||||
{ text: 'Enterprise subscriptions', to: `/site-admin/dotcom/product/subscriptions?env=${env}` },
|
||||
{ text: 'Create Enterprise subscription' },
|
||||
]}
|
||||
className="mb-2"
|
||||
actions={<EnterprisePortalEnvSelector env={env} setEnv={setEnv} />}
|
||||
/>
|
||||
<Container className="mb-3">
|
||||
<FilteredConnection<ProductSubscriptionAccountFields, Props>
|
||||
{...props}
|
||||
className="list-group list-group-flush"
|
||||
noun="user"
|
||||
pluralNoun="users"
|
||||
queryConnection={queryAccounts}
|
||||
nodeComponent={UserCreateSubscriptionNode}
|
||||
nodeComponentProps={props}
|
||||
/>
|
||||
{error && <ErrorAlert className="mt-2" error={error} />}
|
||||
<Alert variant="info">
|
||||
<Text>
|
||||
<strong>You are creating an Enterprise subscription for a SINGLE Sourcegraph instance</strong>.
|
||||
Customers with multiple Sourcegraph instances should have a separate subscription for each.{' '}
|
||||
<strong>Each subscription should only have licenses for a SINGLE Sourcegraph instance.</strong>
|
||||
</Text>
|
||||
<Text className="mb-0">
|
||||
The Salesforce subscription ID can be set to link multiple Enterprise subscriptions
|
||||
corresponding to a single customer.
|
||||
</Text>
|
||||
</Alert>
|
||||
<EnterprisePortalEnvWarning env={env} actionText="creating a subscription" />
|
||||
|
||||
<Form ref={formRef} onSubmit={handleSubmit}>
|
||||
<Label className="w-100 mt-2">
|
||||
Display name
|
||||
<Input
|
||||
autoFocus={true}
|
||||
message="Brief, human-friendly, globally unique name for this subscription."
|
||||
placeholder="Example: 'Acme Corp. (testing instance)'"
|
||||
disabled={formAPI.submitted}
|
||||
{...getDefaultInputProps(displayName)}
|
||||
/>
|
||||
</Label>
|
||||
<Select
|
||||
id="instance-type"
|
||||
label="Instance type"
|
||||
message="Select the type of instance this subscription is used for. A production instance might be a PRIMARY instance, while a testing or staging instance would be a SECONDARY instance. INTERNAL instances are used internally at Sourcegraph."
|
||||
value={instanceType.input.value}
|
||||
disabled={formAPI.submitted}
|
||||
onChange={event => {
|
||||
instanceType.input.onChange(
|
||||
parseInt(event.target.value, 10) as EnterpriseSubscriptionInstanceType
|
||||
)
|
||||
}}
|
||||
>
|
||||
{[
|
||||
EnterpriseSubscriptionInstanceType.PRIMARY,
|
||||
EnterpriseSubscriptionInstanceType.SECONDARY,
|
||||
EnterpriseSubscriptionInstanceType.INTERNAL,
|
||||
].map(type => (
|
||||
<option key={type} value={type}>
|
||||
{EnterpriseSubscriptionInstanceType[type].toString()}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Label className="w-100 mt-2">
|
||||
Salesforce subscription ID
|
||||
<Input
|
||||
message="This is VERY important to provide for all subscriptions used by customers. Only leave blank if this subscription is for internal usage."
|
||||
placeholder="Example: 'a1a...'"
|
||||
disabled={formAPI.submitted}
|
||||
{...getDefaultInputProps(salesforceSubscriptionID)}
|
||||
/>
|
||||
</Label>
|
||||
<Label className="w-100 mt-2">
|
||||
Instance domain
|
||||
<Input
|
||||
message="External domain of the Sourcegraph instance that will be used by this subscription. Required for Cody Analytics. Must be set manually."
|
||||
placeholder="Example: 'acmecorp.com'"
|
||||
disabled={formAPI.submitted}
|
||||
{...getDefaultInputProps(instanceDomain)}
|
||||
/>
|
||||
</Label>
|
||||
<Label className="w-100 mt-2">
|
||||
Message
|
||||
<Input
|
||||
message="Note to associate with the creation of this Enterprise subscription."
|
||||
placeholder="Example: 'Set up test instance subscription for Acme Corp.'"
|
||||
disabled={formAPI.submitted}
|
||||
{...getDefaultInputProps(message)}
|
||||
/>
|
||||
</Label>
|
||||
<LoaderButton
|
||||
type="submit"
|
||||
className="mt-2"
|
||||
disabled={formAPI.submitted || !formAPI.valid || formAPI.validating}
|
||||
variant="primary"
|
||||
loading={formAPI.submitted}
|
||||
alwaysShowLabel={true}
|
||||
label="Create subscription"
|
||||
/>
|
||||
</Form>
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function queryAccounts(
|
||||
args: Partial<ProductSubscriptionAccountsVariables>
|
||||
): Observable<ProductSubscriptionAccountsResult['users']> {
|
||||
return queryGraphQL<ProductSubscriptionAccountsResult>(
|
||||
gql`
|
||||
query ProductSubscriptionAccounts($first: Int, $query: String) {
|
||||
users(first: $first, query: $query) {
|
||||
nodes {
|
||||
...ProductSubscriptionAccountFields
|
||||
}
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
fragment ProductSubscriptionAccountFields on User {
|
||||
id
|
||||
username
|
||||
}
|
||||
`,
|
||||
args
|
||||
).pipe(
|
||||
map(dataOrThrowErrors),
|
||||
map(data => data.users)
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
import { noop } from 'lodash'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { noOpTelemetryRecorder } from '@sourcegraph/shared/src/telemetry'
|
||||
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
|
||||
import { renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
|
||||
|
||||
import { SiteAdminGenerateProductLicenseForSubscriptionForm } from './SiteAdminGenerateProductLicenseForSubscriptionForm'
|
||||
import { mockLicense } from './testUtils'
|
||||
|
||||
describe('SiteAdminGenerateProductLicenseForSubscriptionForm', () => {
|
||||
test('renders', () => {
|
||||
expect(
|
||||
renderWithBrandedContext(
|
||||
<MockedTestProvider mocks={[]}>
|
||||
<SiteAdminGenerateProductLicenseForSubscriptionForm
|
||||
subscriptionID="s"
|
||||
latestLicense={mockLicense}
|
||||
onCancel={noop}
|
||||
subscriptionAccount="foo"
|
||||
onGenerate={noop}
|
||||
telemetryRecorder={noOpTelemetryRecorder}
|
||||
/>
|
||||
</MockedTestProvider>
|
||||
).baseElement
|
||||
).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
@ -1,14 +1,12 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
|
||||
import { Timestamp } from '@bufbuild/protobuf'
|
||||
import { UTCDate } from '@date-fns/utc'
|
||||
import { mdiChatQuestionOutline } from '@mdi/js'
|
||||
import classNames from 'classnames'
|
||||
import { addDays, endOfDay } from 'date-fns'
|
||||
import { noop } from 'lodash'
|
||||
|
||||
import { useMutation } from '@sourcegraph/http-client'
|
||||
import type { Scalars } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@ -23,23 +21,18 @@ import {
|
||||
Text,
|
||||
Checkbox,
|
||||
H4,
|
||||
Icon,
|
||||
Tooltip,
|
||||
Label,
|
||||
Container,
|
||||
} from '@sourcegraph/wildcard'
|
||||
|
||||
import { Collapsible } from '../../../../components/Collapsible'
|
||||
import { LoaderButton } from '../../../../components/LoaderButton'
|
||||
import type {
|
||||
GenerateProductLicenseForSubscriptionResult,
|
||||
GenerateProductLicenseForSubscriptionVariables,
|
||||
ProductLicenseFields,
|
||||
ProductLicenseInfoFields,
|
||||
} from '../../../../graphql-operations'
|
||||
import { ExpirationDate } from '../../../productSubscription/ExpirationDate'
|
||||
import { hasUnknownTags, ProductLicenseTags, UnknownTagWarning } from '../../../productSubscription/ProductLicenseTags'
|
||||
|
||||
import { GENERATE_PRODUCT_LICENSE } from './backend'
|
||||
import { type EnterprisePortalEnvironment, useCreateEnterpriseSubscriptionLicense } from './enterpriseportal'
|
||||
import { EnterprisePortalEnvWarning } from './EnterprisePortalEnvWarning'
|
||||
import type { EnterpriseSubscription, EnterpriseSubscriptionLicense } from './enterpriseportalgen/subscriptions_pb'
|
||||
import {
|
||||
ALL_PLANS,
|
||||
TAG_AIR_GAPPED,
|
||||
@ -52,32 +45,21 @@ import {
|
||||
|
||||
import styles from './SiteAdminGenerateProductLicenseForSubscriptionForm.module.scss'
|
||||
|
||||
// TODO: Maybe a field for a custom comment on what instance the key is for?
|
||||
// In accordance with:
|
||||
// Add trial and instance:test or instance:whatever_name_is_appropriate tags, so that we can identify which license keys are test and which are not
|
||||
// from https://handbook.sourcegraph.com/departments/technical-success/ce/process/license_keys.
|
||||
|
||||
// TODO: Add MAU switch.
|
||||
|
||||
interface License extends Omit<ProductLicenseFields, 'subscription' | 'info'> {
|
||||
info: Omit<ProductLicenseInfoFields, 'productNameWithBrand'> | null
|
||||
}
|
||||
interface Props extends TelemetryV2Props {
|
||||
subscriptionID: Scalars['ID']
|
||||
subscriptionAccount: string
|
||||
latestLicense: License | undefined
|
||||
env: EnterprisePortalEnvironment
|
||||
subscription: EnterpriseSubscription
|
||||
latestLicense: EnterpriseSubscriptionLicense | undefined
|
||||
onGenerate: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
message: string
|
||||
/** Comma-separated additional license tags. */
|
||||
tags: string
|
||||
customer: string
|
||||
salesforceSubscriptionID: string
|
||||
salesforceOpportunityID: string
|
||||
plan: string
|
||||
userCount: number
|
||||
userCount: bigint
|
||||
expiresAt: Date
|
||||
trueUp: boolean
|
||||
trial: boolean
|
||||
@ -87,31 +69,31 @@ interface FormData {
|
||||
codeInsights: boolean
|
||||
}
|
||||
|
||||
const getEmptyFormData = (account: string, latestLicense: License | undefined): FormData => {
|
||||
const getEmptyFormData = (latestLicense: EnterpriseSubscriptionLicense | undefined): FormData => {
|
||||
const licenseData = latestLicense?.license?.value
|
||||
const formData: FormData = {
|
||||
message: '',
|
||||
tags: '',
|
||||
customer: account,
|
||||
salesforceSubscriptionID: latestLicense?.info?.salesforceSubscriptionID ?? '',
|
||||
salesforceOpportunityID: latestLicense?.info?.salesforceOpportunityID ?? '',
|
||||
plan: latestLicense?.info?.tags.find(tag => tag.startsWith('plan:'))?.slice('plan:'.length) ?? '',
|
||||
userCount: latestLicense?.info?.userCount ?? 1,
|
||||
salesforceOpportunityID: licenseData?.info?.salesforceOpportunityId ?? '',
|
||||
plan: licenseData?.info?.tags.find(tag => tag.startsWith('plan:'))?.slice('plan:'.length) ?? '',
|
||||
userCount: licenseData?.info?.userCount ?? BigInt(1),
|
||||
expiresAt: endOfDay(new UTCDate(UTCDate.now())),
|
||||
trial: latestLicense?.info?.tags.includes(TAG_TRIAL.tagValue) ?? false,
|
||||
trueUp: latestLicense?.info?.tags.includes(TAG_TRUEUP.tagValue) ?? false,
|
||||
airGapped: latestLicense?.info?.tags.includes(TAG_AIR_GAPPED.tagValue) ?? false,
|
||||
batchChanges: latestLicense?.info?.tags.includes(TAG_BATCH_CHANGES.tagValue) ?? false,
|
||||
codeInsights: latestLicense?.info?.tags.includes(TAG_CODE_INSIGHTS.tagValue) ?? false,
|
||||
disableTelemetry: latestLicense?.info?.tags.includes(TAG_DISABLE_TELEMETRY_EXPORT.tagValue) ?? false,
|
||||
trial: licenseData?.info?.tags.includes(TAG_TRIAL.tagValue) ?? false,
|
||||
trueUp: licenseData?.info?.tags.includes(TAG_TRUEUP.tagValue) ?? false,
|
||||
airGapped: licenseData?.info?.tags.includes(TAG_AIR_GAPPED.tagValue) ?? false,
|
||||
batchChanges: licenseData?.info?.tags.includes(TAG_BATCH_CHANGES.tagValue) ?? false,
|
||||
codeInsights: licenseData?.info?.tags.includes(TAG_CODE_INSIGHTS.tagValue) ?? false,
|
||||
disableTelemetry: licenseData?.info?.tags.includes(TAG_DISABLE_TELEMETRY_EXPORT.tagValue) ?? false,
|
||||
}
|
||||
|
||||
if (latestLicense?.info) {
|
||||
if (licenseData?.info) {
|
||||
// Based on the tag-less formData created above, generate the list of tags to add.
|
||||
// We then only add additional tags for the things that aren't yet expressed,
|
||||
// to avoid duplicates and let the specific flags on form data handle addition
|
||||
// of their tag values.
|
||||
const presentTags = getTagsFromFormData(formData)
|
||||
formData.tags =
|
||||
latestLicense?.info?.tags
|
||||
licenseData?.info?.tags
|
||||
.filter(tag => !tag.startsWith('plan:') && !tag.startsWith('customer:') && !presentTags.includes(tag))
|
||||
.join(',') ?? ''
|
||||
}
|
||||
@ -136,7 +118,6 @@ const tagsFromString = (tagString: string): string[] =>
|
||||
const getTagsFromFormData = (formData: FormData): string[] =>
|
||||
Array.from(
|
||||
new Set([
|
||||
`customer:${formData.customer}`,
|
||||
...(formData.plan ? [`plan:${formData.plan}`] : []),
|
||||
...(formData.trueUp &&
|
||||
ALL_PLANS.find(other => other.label === formData.plan)?.additionalTags?.some(
|
||||
@ -162,9 +143,6 @@ const getTagsForTelemetry = (formData: FormData): { [key: string]: number } => (
|
||||
disableTelemetry: formData.disableTelemetry ? 1 : 0,
|
||||
})
|
||||
|
||||
const HANDBOOK_INFO_URL =
|
||||
'https://handbook.sourcegraph.com/ce/license_keys#how-to-create-a-license-key-for-a-new-prospect-or-new-customer'
|
||||
|
||||
/**
|
||||
* Displays a form to generate a new product license for a product subscription.
|
||||
*
|
||||
@ -172,12 +150,12 @@ const HANDBOOK_INFO_URL =
|
||||
*/
|
||||
export const SiteAdminGenerateProductLicenseForSubscriptionForm: React.FunctionComponent<
|
||||
React.PropsWithChildren<Props>
|
||||
> = ({ latestLicense, subscriptionID, subscriptionAccount, onGenerate, onCancel, telemetryRecorder }) => {
|
||||
> = ({ env, latestLicense, subscription, onGenerate, onCancel, telemetryRecorder }) => {
|
||||
const labelId = 'generateLicense'
|
||||
|
||||
const [hasAcknowledgedInfo, setHasAcknowledgedInfo] = useState(false)
|
||||
|
||||
const [formData, setFormData] = useState<FormData>(getEmptyFormData(subscriptionAccount, latestLicense))
|
||||
const [formData, setFormData] = useState<FormData>(getEmptyFormData(latestLicense))
|
||||
|
||||
const onPlanChange = useCallback<React.ChangeEventHandler<HTMLSelectElement>>(
|
||||
event => setFormData(formData => ({ ...formData, plan: event.target.value })),
|
||||
@ -189,17 +167,18 @@ export const SiteAdminGenerateProductLicenseForSubscriptionForm: React.FunctionC
|
||||
event => setFormData(formData => ({ ...formData, [key]: event.target.value })),
|
||||
[key]
|
||||
)
|
||||
const onCustomerChange = useOnChange('customer')
|
||||
const onSFSubscriptionIDChange = useOnChange('salesforceSubscriptionID')
|
||||
const onMessageChange = useOnChange('message')
|
||||
const onSFOpportunityIDChange = useOnChange('salesforceOpportunityID')
|
||||
|
||||
const [sfOpportunityIDError, setSFOpportunityIDError] = useState<string | undefined>(undefined)
|
||||
|
||||
const onTagsChange = useCallback<React.ChangeEventHandler<HTMLInputElement>>(
|
||||
event => setFormData(formData => ({ ...formData, tags: event.target.value || '' })),
|
||||
[]
|
||||
)
|
||||
|
||||
const onUserCountChange = useCallback<React.ChangeEventHandler<HTMLInputElement>>(
|
||||
event => setFormData(formData => ({ ...formData, userCount: event.target.valueAsNumber })),
|
||||
event => setFormData(formData => ({ ...formData, userCount: BigInt(event.target.valueAsNumber || 0) })),
|
||||
[]
|
||||
)
|
||||
|
||||
@ -259,7 +238,7 @@ export const SiteAdminGenerateProductLicenseForSubscriptionForm: React.FunctionC
|
||||
|
||||
const date: Date = dateRegex.test(event.target.value)
|
||||
? new UTCDate(event.target.value)
|
||||
: getEmptyFormData(subscriptionAccount, latestLicense).expiresAt
|
||||
: getEmptyFormData(latestLicense).expiresAt
|
||||
|
||||
const expiresAt = endOfDay(new UTCDate(date))
|
||||
|
||||
@ -268,31 +247,10 @@ export const SiteAdminGenerateProductLicenseForSubscriptionForm: React.FunctionC
|
||||
expiresAt,
|
||||
}))
|
||||
},
|
||||
[subscriptionAccount, latestLicense]
|
||||
[latestLicense]
|
||||
)
|
||||
|
||||
const [generateLicense, { loading, error }] = useMutation<
|
||||
GenerateProductLicenseForSubscriptionResult['dotcom']['generateProductLicenseForSubscription'],
|
||||
GenerateProductLicenseForSubscriptionVariables
|
||||
>(GENERATE_PRODUCT_LICENSE, {
|
||||
variables: {
|
||||
productSubscriptionID: subscriptionID,
|
||||
license: {
|
||||
tags: getTagsFromFormData(formData),
|
||||
userCount: formData.userCount,
|
||||
expiresAt: Math.floor(formData.expiresAt.getTime() / 1000),
|
||||
salesforceSubscriptionID:
|
||||
formData.salesforceSubscriptionID.trim().length > 0
|
||||
? formData.salesforceSubscriptionID.trim()
|
||||
: undefined,
|
||||
salesforceOpportunityID:
|
||||
formData.salesforceOpportunityID.trim().length > 0
|
||||
? formData.salesforceOpportunityID.trim()
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
onCompleted: onGenerate,
|
||||
})
|
||||
const { mutate: generateLicense, isPending: isLoading, error } = useCreateEnterpriseSubscriptionLicense(env)
|
||||
|
||||
const onSubmit = useCallback<React.FormEventHandler>(
|
||||
event => {
|
||||
@ -300,16 +258,74 @@ export const SiteAdminGenerateProductLicenseForSubscriptionForm: React.FunctionC
|
||||
telemetryRecorder.recordEvent('admin.productSubscription.license', 'generate', {
|
||||
metadata: getTagsForTelemetry(formData),
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
generateLicense()
|
||||
generateLicense(
|
||||
{
|
||||
message: formData.message,
|
||||
license: {
|
||||
subscriptionId: subscription.id,
|
||||
license: {
|
||||
// We only support creating old-school license keys
|
||||
case: 'key',
|
||||
value: {
|
||||
info: {
|
||||
tags: getTagsFromFormData(formData),
|
||||
userCount: formData.userCount,
|
||||
expireTime: Timestamp.fromDate(formData.expiresAt),
|
||||
salesforceOpportunityId:
|
||||
formData.salesforceOpportunityID.trim().length > 0
|
||||
? formData.salesforceOpportunityID.trim()
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: onGenerate,
|
||||
}
|
||||
)
|
||||
},
|
||||
[formData, telemetryRecorder, generateLicense]
|
||||
[formData, telemetryRecorder, generateLicense, subscription, onGenerate]
|
||||
)
|
||||
|
||||
const tags = useDebounce<string[]>(tagsFromString(formData.tags), 300)
|
||||
|
||||
const selectedPlan = formData.plan ? ALL_PLANS.find(plan => plan.label === formData.plan) : undefined
|
||||
|
||||
const infoAlerts = (
|
||||
<>
|
||||
<Alert variant="warning" className="flex-shrink-0">
|
||||
<Text>
|
||||
Each subscription must map to exactly ONE Sourcegraph instance.{' '}
|
||||
<strong>
|
||||
DO NOT create licenses used by multiple Sourcegraph instances within a single subscription
|
||||
</strong>{' '}
|
||||
- instead, create a NEW subscription with the appropriate Salesforce subscription ID and a relevant
|
||||
display name.
|
||||
</Text>
|
||||
<Text className="mb-0">
|
||||
Existing licenses can be re-linked to a new subscription by reaching out to{' '}
|
||||
<Link rel="noopener" target="_blank" to="https://sourcegraph.slack.com/archives/C05GJPTSZCZ">
|
||||
#discuss-core-services
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Alert variant="info" className="flex-shrink-0">
|
||||
More documentation can be found in the{' '}
|
||||
<Link
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
to="https://www.notion.so/sourcegraph/Customer-License-Key-Management-f44f84e295f84f2482ee9e15a038c987?pvs=4"
|
||||
>
|
||||
"Customer License Key Management" Notion page
|
||||
</Link>
|
||||
.
|
||||
</Alert>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
position="center"
|
||||
@ -320,28 +336,18 @@ export const SiteAdminGenerateProductLicenseForSubscriptionForm: React.FunctionC
|
||||
>
|
||||
<H3 className="flex-shrink-0" id={labelId}>
|
||||
Generate new Sourcegraph license
|
||||
{hasAcknowledgedInfo && (
|
||||
<>
|
||||
{' '}
|
||||
<Link rel="noopener" target="_blank" to={HANDBOOK_INFO_URL}>
|
||||
<Tooltip content="Show handbook page with additional information on the license issuance process">
|
||||
<Icon aria-label="Show handbook page" svgPath={mdiChatQuestionOutline} />
|
||||
</Tooltip>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</H3>
|
||||
|
||||
{error && <ErrorAlert error={error} />}
|
||||
|
||||
{!hasAcknowledgedInfo && (
|
||||
<>
|
||||
<Alert variant="info" className="flex-shrink-0">
|
||||
Please read the{' '}
|
||||
<Link rel="noopener" target="_blank" to={HANDBOOK_INFO_URL}>
|
||||
guide for how to create a license key for a new prospect or new customer.
|
||||
</Link>
|
||||
</Alert>
|
||||
<EnterprisePortalEnvWarning
|
||||
env={env}
|
||||
actionText="creating a license key"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
{infoAlerts}
|
||||
<Button variant="secondary" onClick={() => setHasAcknowledgedInfo(true)}>
|
||||
Acknowledge information
|
||||
</Button>
|
||||
@ -350,6 +356,7 @@ export const SiteAdminGenerateProductLicenseForSubscriptionForm: React.FunctionC
|
||||
|
||||
{hasAcknowledgedInfo && (
|
||||
<>
|
||||
{infoAlerts}
|
||||
<div
|
||||
className={classNames(
|
||||
styles.modalContainer,
|
||||
@ -357,10 +364,20 @@ export const SiteAdminGenerateProductLicenseForSubscriptionForm: React.FunctionC
|
||||
)}
|
||||
>
|
||||
<Form onSubmit={onSubmit}>
|
||||
<Input
|
||||
id="site-admin-create-product-subscription-page__salesforce_op_id_input"
|
||||
label="Message"
|
||||
description="Enter a message about the creation of this license."
|
||||
type="text"
|
||||
required={true}
|
||||
disabled={isLoading}
|
||||
value={formData.message}
|
||||
onChange={onMessageChange}
|
||||
/>
|
||||
<Select
|
||||
id="site-admin-create-product-subscription-page__plan_select"
|
||||
label="Plan"
|
||||
disabled={loading}
|
||||
disabled={isLoading}
|
||||
value={formData.plan}
|
||||
onChange={onPlanChange}
|
||||
description="Select the plan the license is for."
|
||||
@ -403,41 +420,44 @@ export const SiteAdminGenerateProductLicenseForSubscriptionForm: React.FunctionC
|
||||
id="productSubscription__trial"
|
||||
aria-label="Is trial"
|
||||
label="This license is for a trial"
|
||||
disabled={loading}
|
||||
disabled={isLoading}
|
||||
checked={formData.trial}
|
||||
onChange={onIsTrialChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="site-admin-create-product-subscription-page__customer_input"
|
||||
label="Customer"
|
||||
description="Name of the customer. Will be encoded into the key for easier identification."
|
||||
type="text"
|
||||
disabled={loading}
|
||||
value={formData.customer || ''}
|
||||
onChange={onCustomerChange}
|
||||
required={true}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="site-admin-create-product-subscription-page__salesforce_sub_id_input"
|
||||
label="Salesforce Subscription ID"
|
||||
description="Enter the corresponding Subscription ID from Salesforce."
|
||||
type="text"
|
||||
disabled={loading}
|
||||
value={formData.salesforceSubscriptionID}
|
||||
onChange={onSFSubscriptionIDChange}
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="site-admin-create-product-subscription-page__salesforce_op_id_input"
|
||||
label="Salesforce Opportunity ID"
|
||||
description="Enter the corresponding Opportunity ID from Salesforce."
|
||||
description="Enter the corresponding Opportunity ID from Salesforce. This is VERY important to provide for all subscriptions used by customers. It cannot be changed after a license has been created."
|
||||
type="text"
|
||||
disabled={loading}
|
||||
disabled={isLoading}
|
||||
error={sfOpportunityIDError}
|
||||
value={formData.salesforceOpportunityID}
|
||||
onChange={onSFOpportunityIDChange}
|
||||
onChange={event => {
|
||||
onSFOpportunityIDChange(event)
|
||||
const { value } = event.target
|
||||
if (!value) {
|
||||
setSFOpportunityIDError(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (!value.startsWith('006')) {
|
||||
setSFOpportunityIDError(
|
||||
'Salesforce opportunity ID must start with "006"'
|
||||
)
|
||||
return
|
||||
}
|
||||
if (value.length < 17) {
|
||||
setSFOpportunityIDError(
|
||||
'Salesforce opportunity ID must be longer than 17 characters'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// No problems
|
||||
setSFOpportunityIDError(undefined)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
@ -445,8 +465,8 @@ export const SiteAdminGenerateProductLicenseForSubscriptionForm: React.FunctionC
|
||||
label="Users"
|
||||
min={1}
|
||||
id="site-admin-create-product-subscription-page__userCount"
|
||||
disabled={!selectedPlan || loading}
|
||||
value={formData.userCount}
|
||||
disabled={!selectedPlan || isLoading}
|
||||
value={Number(formData.userCount)}
|
||||
onChange={onUserCountChange}
|
||||
description="The maximum number of users permitted on this license."
|
||||
className="w-100"
|
||||
@ -574,12 +594,12 @@ export const SiteAdminGenerateProductLicenseForSubscriptionForm: React.FunctionC
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Collapsible titleAtStart={true} title="Additional Information">
|
||||
<Collapsible titleAtStart={true} title={<H4>Additional information</H4>}>
|
||||
<Input
|
||||
type="text"
|
||||
label="Tags"
|
||||
id="site-admin-create-product-subscription-page__tags"
|
||||
disabled={loading}
|
||||
disabled={isLoading}
|
||||
value={formData.tags}
|
||||
onChange={onTagsChange}
|
||||
list="known-tags"
|
||||
@ -587,11 +607,13 @@ export const SiteAdminGenerateProductLicenseForSubscriptionForm: React.FunctionC
|
||||
message={
|
||||
<Text className="text-danger">
|
||||
Note that specifying tags manually is no longer required and the
|
||||
form should handle all options.
|
||||
form should handle all options. For example, the{' '}
|
||||
<span className="text-monospace">customer:</span> tag is
|
||||
automatically added on license creation.
|
||||
<br />
|
||||
Only use this if you know what you're doing!
|
||||
<br />
|
||||
All the tags are displayed at the end of the form as well.
|
||||
All final tags are displayed at the end of the form as well.
|
||||
</Text>
|
||||
}
|
||||
className="mt-2"
|
||||
@ -604,24 +626,30 @@ export const SiteAdminGenerateProductLicenseForSubscriptionForm: React.FunctionC
|
||||
))}
|
||||
</datalist>
|
||||
</Collapsible>
|
||||
<hr className="mb-3" />
|
||||
<H4>Final License Details</H4>
|
||||
<Text>
|
||||
Please double check that the license tags and user count are correct before
|
||||
generating the license. The license cannot be modified once generated.
|
||||
</Text>
|
||||
<div>
|
||||
{hasUnknownTags(tags) && <UnknownTagWarning className="mb-2" />}
|
||||
<Container className="mt-3 mb-3">
|
||||
<H4>Final License Details</H4>
|
||||
<Text>
|
||||
<ProductLicenseTags tags={getTagsFromFormData(formData)} />
|
||||
Please double check that the license tags and user count are correct before
|
||||
generating the license. The license cannot be modified once generated.
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
{hasUnknownTags(tags) && <UnknownTagWarning className="mb-2" />}
|
||||
<Text>
|
||||
<ProductLicenseTags
|
||||
tags={getTagsFromFormData(formData).concat([
|
||||
// Currently added by the backend
|
||||
`customer:${subscription?.displayName}`,
|
||||
])}
|
||||
/>
|
||||
</Text>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="d-flex justify-content-end">
|
||||
<Button
|
||||
disabled={loading}
|
||||
disabled={isLoading}
|
||||
className="mr-2"
|
||||
onClick={onCancel}
|
||||
outline={true}
|
||||
@ -631,9 +659,9 @@ export const SiteAdminGenerateProductLicenseForSubscriptionForm: React.FunctionC
|
||||
</Button>
|
||||
<LoaderButton
|
||||
type="submit"
|
||||
disabled={loading || !selectedPlan}
|
||||
disabled={isLoading || !selectedPlan || !!sfOpportunityIDError}
|
||||
variant="primary"
|
||||
loading={loading}
|
||||
loading={isLoading}
|
||||
alwaysShowLabel={true}
|
||||
label="Generate key"
|
||||
/>
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
import type { PartialMessage } from '@bufbuild/protobuf'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Container, PageHeader } from '@sourcegraph/wildcard'
|
||||
|
||||
import {
|
||||
@ -11,38 +14,118 @@ import {
|
||||
ConnectionForm,
|
||||
ConnectionList,
|
||||
ConnectionLoading,
|
||||
ConnectionSummary,
|
||||
ShowMoreButton,
|
||||
SummaryContainer,
|
||||
} from '../../../../components/FilteredConnection/ui'
|
||||
import { PageTitle } from '../../../../components/PageTitle'
|
||||
|
||||
import { useQueryProductLicensesConnection } from './backend'
|
||||
import {
|
||||
queryClient,
|
||||
useListEnterpriseSubscriptionLicenses,
|
||||
type EnterprisePortalEnvironment,
|
||||
} from './enterpriseportal'
|
||||
import { EnterprisePortalEnvSelector, getDefaultEnterprisePortalEnv } from './EnterprisePortalEnvSelector'
|
||||
import { EnterprisePortalEnvWarning } from './EnterprisePortalEnvWarning'
|
||||
import {
|
||||
type ListEnterpriseSubscriptionLicensesFilter,
|
||||
EnterpriseSubscriptionLicenseType,
|
||||
} from './enterpriseportalgen/subscriptions_pb'
|
||||
import { SiteAdminProductLicenseNode } from './SiteAdminProductLicenseNode'
|
||||
|
||||
interface Props extends TelemetryV2Props {}
|
||||
|
||||
const SEARCH_PARAM_KEY = 'query'
|
||||
|
||||
/**
|
||||
* Displays the product licenses that have been created on Sourcegraph.com.
|
||||
*/
|
||||
export const SiteAdminLicenseKeyLookupPage: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
|
||||
telemetryRecorder,
|
||||
}) => {
|
||||
export const SiteAdminLicenseKeyLookupPage: React.FunctionComponent<React.PropsWithChildren<Props>> = props => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page {...props} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const QUERY_PARAM_KEY = 'query'
|
||||
const QUERY_PARAM_ENV = 'env'
|
||||
const QUERY_PARAM_FILTER = 'filter'
|
||||
|
||||
type FilterType = 'key_substring' | 'sf_opp_id'
|
||||
|
||||
const MAX_RESULTS = 100
|
||||
|
||||
const baseFilters: PartialMessage<ListEnterpriseSubscriptionLicensesFilter>[] = [
|
||||
{
|
||||
filter: {
|
||||
// This UI only manages old-school license keys.
|
||||
case: 'type',
|
||||
value: EnterpriseSubscriptionLicenseType.KEY,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const Page: React.FunctionComponent<React.PropsWithChildren<Props>> = ({ telemetryRecorder }) => {
|
||||
useEffect(() => telemetryRecorder.recordEvent('admin.licenseKeyLookup', 'view'), [telemetryRecorder])
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [env, setEnv] = useState<EnterprisePortalEnvironment>(
|
||||
(searchParams.get(QUERY_PARAM_ENV) as EnterprisePortalEnvironment) || getDefaultEnterprisePortalEnv()
|
||||
)
|
||||
const [query, setQuery] = useState<string>(searchParams.get(QUERY_PARAM_KEY) ?? '')
|
||||
const [debouncedQuery] = useDebounce(query, 200)
|
||||
|
||||
const [search, setSearch] = useState<string>(searchParams.get(SEARCH_PARAM_KEY) ?? '')
|
||||
|
||||
const { loading, hasNextPage, fetchMore, refetchAll, connection, error } = useQueryProductLicensesConnection(search)
|
||||
const [filters, setFilters] = useState<{
|
||||
filter: FilterType
|
||||
}>({
|
||||
filter: (searchParams.get(QUERY_PARAM_FILTER) as FilterType) ?? 'key_substring',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const query = search?.trim() ?? ''
|
||||
searchParams.set(SEARCH_PARAM_KEY, query)
|
||||
const currentEnv = searchParams.get(QUERY_PARAM_ENV) as EnterprisePortalEnvironment
|
||||
|
||||
searchParams.set(QUERY_PARAM_KEY, query?.trim() ?? '')
|
||||
searchParams.set(QUERY_PARAM_FILTER, filters.filter)
|
||||
searchParams.set(QUERY_PARAM_ENV, env)
|
||||
setSearchParams(searchParams)
|
||||
}, [search, searchParams, setSearchParams])
|
||||
|
||||
// HACK: env state doesn't propagate to hooks correctly, so conditionally
|
||||
// reload the page.
|
||||
// Required until we fix https://linear.app/sourcegraph/issue/CORE-245
|
||||
if (env !== currentEnv) {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
}, [query, searchParams, setSearchParams, filters, env])
|
||||
|
||||
let listFilters: PartialMessage<ListEnterpriseSubscriptionLicensesFilter>[] = []
|
||||
switch (filters.filter) {
|
||||
case 'key_substring': {
|
||||
listFilters = [
|
||||
{
|
||||
filter: {
|
||||
case: 'licenseKeySubstring',
|
||||
value: debouncedQuery,
|
||||
},
|
||||
},
|
||||
]
|
||||
break
|
||||
}
|
||||
case 'sf_opp_id': {
|
||||
listFilters = [
|
||||
{
|
||||
filter: {
|
||||
case: 'salesforceOpportunityId',
|
||||
value: debouncedQuery,
|
||||
},
|
||||
},
|
||||
]
|
||||
break
|
||||
}
|
||||
}
|
||||
const { error, isFetching, data, refetch } = useListEnterpriseSubscriptionLicenses(
|
||||
env,
|
||||
baseFilters.concat(listFilters),
|
||||
{
|
||||
limit: MAX_RESULTS,
|
||||
// Only load when we have a query, and at least one filter
|
||||
shouldLoad: !!(debouncedQuery && listFilters.length > 0),
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="site-admin-product-subscriptions-page">
|
||||
@ -50,60 +133,77 @@ export const SiteAdminLicenseKeyLookupPage: React.FunctionComponent<React.PropsW
|
||||
<PageHeader
|
||||
path={[{ text: 'License key lookup' }]}
|
||||
headingElement="h2"
|
||||
description="Find matching licenses and their associated enterprise subscriptions"
|
||||
description="Find matching licenses and their associated enterprise subscriptions."
|
||||
className="mb-3"
|
||||
actions={<EnterprisePortalEnvSelector env={env} setEnv={setEnv} />}
|
||||
/>
|
||||
|
||||
<EnterprisePortalEnvWarning env={env} actionText="managing subscription license keys" />
|
||||
|
||||
<ConnectionContainer>
|
||||
<Container className="mb-3">
|
||||
<ConnectionForm
|
||||
inputValue={search}
|
||||
inputValue={query}
|
||||
filterValues={filters}
|
||||
inputClassName="ml-2"
|
||||
filters={[
|
||||
{
|
||||
id: 'filter',
|
||||
type: 'select',
|
||||
label: 'Filter',
|
||||
options: [
|
||||
{
|
||||
args: {},
|
||||
label: 'License key substring',
|
||||
value: 'key_substring',
|
||||
tooltip: 'Partial match on the signed license key',
|
||||
},
|
||||
{
|
||||
args: {},
|
||||
label: 'Salesforce opportunity ID',
|
||||
value: 'sf_opp_id',
|
||||
tooltip: 'Exact match on the Salesforce opportunity ID attached to the license',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
onInputChange={event => {
|
||||
const search = event.target.value
|
||||
setSearch(search)
|
||||
setQuery(event.target.value)
|
||||
}}
|
||||
inputPlaceholder="Enter a partial license key to find matches"
|
||||
inputClassName="mb-0"
|
||||
formClassName="mb-0"
|
||||
onFilterSelect={(filter, value) => {
|
||||
if (value) {
|
||||
setFilters({ ...filters, [filter.id]: value as FilterType })
|
||||
}
|
||||
}}
|
||||
inputPlaceholder="Enter a query to list subscriptions"
|
||||
/>
|
||||
</Container>
|
||||
{search && (
|
||||
|
||||
{debouncedQuery && filters.filter && (
|
||||
<>
|
||||
<Container className="mb-3">
|
||||
{error && <ConnectionError errors={[error.message]} />}
|
||||
{loading && !connection && <ConnectionLoading />}
|
||||
<ConnectionList
|
||||
as="ul"
|
||||
className="list-group list-group-flush mb-0"
|
||||
aria-label="Subscription licenses"
|
||||
>
|
||||
{connection?.nodes?.map(node => (
|
||||
<SiteAdminProductLicenseNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
showSubscription={true}
|
||||
onRevokeCompleted={refetchAll}
|
||||
telemetryRecorder={telemetryRecorder}
|
||||
/>
|
||||
))}
|
||||
</ConnectionList>
|
||||
{connection && (
|
||||
<SummaryContainer className="mt-2 mb-0">
|
||||
<ConnectionSummary
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="product license"
|
||||
pluralNoun="product licenses"
|
||||
hasNextPage={hasNextPage}
|
||||
noSummaryIfAllNodesVisible={true}
|
||||
emptyElement={
|
||||
<div className="w-100 text-center text-muted">
|
||||
No matching license key found
|
||||
</div>
|
||||
}
|
||||
className="mb-0"
|
||||
/>
|
||||
{hasNextPage && <ShowMoreButton centered={true} onClick={fetchMore} />}
|
||||
</SummaryContainer>
|
||||
{isFetching && <ConnectionLoading />}
|
||||
{data?.licenses && data?.licenses.length >= MAX_RESULTS && (
|
||||
<ConnectionError
|
||||
errors={[
|
||||
`Only ${MAX_RESULTS} results are shown at a time - narrow your search for more accurate results.`,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{data && (
|
||||
<ConnectionList as="ul" aria-label="Enterprise subscription licenses">
|
||||
{data?.licenses?.map(node => (
|
||||
<SiteAdminProductLicenseNode
|
||||
key={node.id}
|
||||
env={env}
|
||||
node={node}
|
||||
showSubscription={true}
|
||||
onRevokeCompleted={refetch}
|
||||
telemetryRecorder={telemetryRecorder}
|
||||
/>
|
||||
))}
|
||||
</ConnectionList>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@ -1,95 +0,0 @@
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { noOpTelemetryRecorder } from '@sourcegraph/shared/src/telemetry'
|
||||
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
|
||||
import { renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
|
||||
|
||||
import { SiteAdminProductLicenseNode } from './SiteAdminProductLicenseNode'
|
||||
|
||||
vi.mock('../../../dotcom/productSubscriptions/AccountName', () => ({ AccountName: () => 'AccountName' }))
|
||||
|
||||
describe('SiteAdminProductLicenseNode', () => {
|
||||
test('active', () => {
|
||||
expect(
|
||||
renderWithBrandedContext(
|
||||
<MockedTestProvider>
|
||||
<SiteAdminProductLicenseNode
|
||||
node={{
|
||||
createdAt: '2020-01-01',
|
||||
id: 'l1',
|
||||
licenseKey: 'lk1',
|
||||
version: 1,
|
||||
revokedAt: null,
|
||||
revokeReason: null,
|
||||
siteID: null,
|
||||
info: {
|
||||
__typename: 'ProductLicenseInfo',
|
||||
expiresAt: '2021-01-01',
|
||||
productNameWithBrand: 'NB',
|
||||
tags: ['a'],
|
||||
userCount: 123,
|
||||
salesforceSubscriptionID: null,
|
||||
salesforceOpportunityID: null,
|
||||
},
|
||||
subscription: {
|
||||
uuid: 'uuid',
|
||||
id: 'id1',
|
||||
account: null,
|
||||
name: 's',
|
||||
activeLicense: { id: 'l1' },
|
||||
urlForSiteAdmin: '/s',
|
||||
},
|
||||
}}
|
||||
showSubscription={true}
|
||||
onRevokeCompleted={function (): void {
|
||||
throw new Error('Function not implemented.')
|
||||
}}
|
||||
telemetryRecorder={noOpTelemetryRecorder}
|
||||
/>
|
||||
</MockedTestProvider>
|
||||
).asFragment()
|
||||
).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('inactive', () => {
|
||||
expect(
|
||||
renderWithBrandedContext(
|
||||
<MockedTestProvider>
|
||||
<SiteAdminProductLicenseNode
|
||||
node={{
|
||||
createdAt: '2020-01-01',
|
||||
id: 'l1',
|
||||
licenseKey: 'lk1',
|
||||
version: 1,
|
||||
revokedAt: null,
|
||||
revokeReason: null,
|
||||
siteID: null,
|
||||
info: {
|
||||
__typename: 'ProductLicenseInfo',
|
||||
expiresAt: '2021-01-01',
|
||||
productNameWithBrand: 'NB',
|
||||
tags: ['a'],
|
||||
userCount: 123,
|
||||
salesforceSubscriptionID: null,
|
||||
salesforceOpportunityID: null,
|
||||
},
|
||||
subscription: {
|
||||
uuid: 'uuid',
|
||||
id: 'id1',
|
||||
account: null,
|
||||
name: 's',
|
||||
activeLicense: { id: 'l0' },
|
||||
urlForSiteAdmin: '/s',
|
||||
},
|
||||
}}
|
||||
showSubscription={true}
|
||||
onRevokeCompleted={function (): void {
|
||||
throw new Error('Function not implemented.')
|
||||
}}
|
||||
telemetryRecorder={noOpTelemetryRecorder}
|
||||
/>
|
||||
</MockedTestProvider>
|
||||
).asFragment()
|
||||
).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
@ -1,12 +1,12 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
|
||||
import { mdiChevronDown, mdiChevronUp } from '@mdi/js'
|
||||
|
||||
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
|
||||
import { useMutation } from '@sourcegraph/http-client'
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import {
|
||||
Alert,
|
||||
ErrorMessage,
|
||||
Button,
|
||||
Collapse,
|
||||
CollapseHeader,
|
||||
@ -17,26 +17,29 @@ import {
|
||||
Label,
|
||||
Link,
|
||||
Text,
|
||||
Badge,
|
||||
} from '@sourcegraph/wildcard'
|
||||
|
||||
import { CopyableText } from '../../../../components/CopyableText'
|
||||
import { LoaderButton } from '../../../../components/LoaderButton'
|
||||
import type { ProductLicenseFields, RevokeLicenseResult, RevokeLicenseVariables } from '../../../../graphql-operations'
|
||||
import { isProductLicenseExpired } from '../../../../productSubscription/helpers'
|
||||
import { AccountName } from '../../../dotcom/productSubscriptions/AccountName'
|
||||
import { ProductLicenseValidity } from '../../../dotcom/productSubscriptions/ProductLicenseValidity'
|
||||
import { ProductLicenseInfoDescription } from '../../../productSubscription/ProductLicenseInfoDescription'
|
||||
import { ProductSubscriptionLabel } from '../../../dotcom/productSubscriptions/ProductSubscriptionLabel'
|
||||
import { ProductLicenseTags, UnknownTagWarning, hasUnknownTags } from '../../../productSubscription/ProductLicenseTags'
|
||||
|
||||
import { REVOKE_LICENSE } from './backend'
|
||||
|
||||
const getLicenseUUID = (id: string): string => atob(id).slice('ProductLicense:"'.length, -1)
|
||||
import { useRevokeEnterpriseSubscriptionLicense, type EnterprisePortalEnvironment } from './enterpriseportal'
|
||||
import {
|
||||
EnterpriseSubscriptionLicenseCondition_Status,
|
||||
type EnterpriseSubscriptionLicense,
|
||||
} from './enterpriseportalgen/subscriptions_pb'
|
||||
|
||||
export interface SiteAdminProductLicenseNodeProps extends TelemetryV2Props {
|
||||
node: ProductLicenseFields
|
||||
env: EnterprisePortalEnvironment
|
||||
node: EnterpriseSubscriptionLicense
|
||||
showSubscription: boolean
|
||||
defaultExpanded?: boolean
|
||||
onRevokeCompleted: () => void
|
||||
isActiveLicense?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@ -44,25 +47,35 @@ export interface SiteAdminProductLicenseNodeProps extends TelemetryV2Props {
|
||||
*/
|
||||
export const SiteAdminProductLicenseNode: React.FunctionComponent<
|
||||
React.PropsWithChildren<SiteAdminProductLicenseNodeProps>
|
||||
> = ({ node, showSubscription, onRevokeCompleted, defaultExpanded = false, telemetryRecorder }) => {
|
||||
const [revoke, { loading, error }] = useMutation<RevokeLicenseResult, RevokeLicenseVariables>(REVOKE_LICENSE)
|
||||
> = ({
|
||||
env,
|
||||
node,
|
||||
showSubscription,
|
||||
onRevokeCompleted,
|
||||
defaultExpanded = false,
|
||||
telemetryRecorder,
|
||||
isActiveLicense,
|
||||
}) => {
|
||||
const {
|
||||
mutate: revoke,
|
||||
isPending: isRevokeLoading,
|
||||
error: revokeError,
|
||||
} = useRevokeEnterpriseSubscriptionLicense(env)
|
||||
|
||||
const onRevoke = useCallback(() => {
|
||||
const reason = window.prompt('Reason for revoking the license key:')
|
||||
const reason = window.prompt(
|
||||
'⚠️ This is a PERMANENT operation. Enter the reason for revoking the license key to continue:'
|
||||
)
|
||||
if (reason) {
|
||||
telemetryRecorder.recordEvent('admin.productSubscription.license', 'revoke')
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
revoke({
|
||||
variables: {
|
||||
id: node.id,
|
||||
reason,
|
||||
},
|
||||
onCompleted: () => {
|
||||
if (onRevokeCompleted) {
|
||||
revoke(
|
||||
{ licenseId: node.id, reason },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onRevokeCompleted()
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}, [revoke, node, onRevokeCompleted, telemetryRecorder])
|
||||
|
||||
@ -71,10 +84,25 @@ export const SiteAdminProductLicenseNode: React.FunctionComponent<
|
||||
setOpen(!open)
|
||||
}, [open, setOpen])
|
||||
|
||||
const uuid = useMemo(() => getLicenseUUID(node.id), [node])
|
||||
if (node.license.case !== 'key') {
|
||||
return (
|
||||
<Alert>
|
||||
<ErrorMessage error="Unknown license type" />
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
const licenseKey = node.license.value
|
||||
const info = licenseKey?.info
|
||||
|
||||
const created = node.conditions.find(
|
||||
condition => condition.status === EnterpriseSubscriptionLicenseCondition_Status.CREATED
|
||||
)
|
||||
const revoked = node.conditions.find(
|
||||
condition => condition.status === EnterpriseSubscriptionLicenseCondition_Status.REVOKED
|
||||
)
|
||||
|
||||
return (
|
||||
<li className="list-group-item p-3 mb-3 border">
|
||||
<li className="list-group-item p-3 mb-3 border" id={node.id}>
|
||||
<Collapse isOpen={open} onOpenChange={setOpen}>
|
||||
<Grid columnCount={2} templateColumns="auto 1fr" spacing={0}>
|
||||
<Button variant="icon" onClick={toggleOpen} className="pr-3">
|
||||
@ -89,37 +117,49 @@ export const SiteAdminProductLicenseNode: React.FunctionComponent<
|
||||
<div className="text-truncate d-flex">
|
||||
<H3>
|
||||
License in{' '}
|
||||
<Link to={node.subscription.urlForSiteAdmin!} className="mr-3">
|
||||
{node.subscription.name}
|
||||
<Link
|
||||
to={`/site-admin/dotcom/product/subscriptions/${node.subscriptionId}#${node.id}?env=${env}`}
|
||||
className="mr-3"
|
||||
>
|
||||
{node.subscriptionId}
|
||||
</Link>
|
||||
</H3>
|
||||
<span className="mr-3">
|
||||
<AccountName account={node.subscription.account} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<Alert variant="danger">Error revoking license: {error.message}</Alert>
|
||||
{!isRevokeLoading && revokeError && (
|
||||
<Alert variant="danger">Error revoking license: {revokeError.message}</Alert>
|
||||
)}
|
||||
<div className="mb-1">
|
||||
{node.info && (
|
||||
<ProductLicenseInfoDescription licenseInfo={node.info} className="mb-0" />
|
||||
{info && (
|
||||
<ProductSubscriptionLabel
|
||||
productName={licenseKey.planDisplayName}
|
||||
userCount={info.userCount}
|
||||
className="mb-0"
|
||||
/>
|
||||
)}
|
||||
{isActiveLicense && (
|
||||
<Badge variant="primary" className="ml-2" small={true}>
|
||||
Active license
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Text className="mb-2">
|
||||
<small className="text-muted">
|
||||
Created <Timestamp date={node.createdAt} />
|
||||
</small>
|
||||
</Text>
|
||||
<ProductLicenseValidity license={node} />
|
||||
{created?.lastTransitionTime && (
|
||||
<Text className="mb-2">
|
||||
<small className="text-muted">
|
||||
Created <Timestamp date={created?.lastTransitionTime.toDate()} />
|
||||
</small>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<ProductLicenseValidity licenseInfo={info} licenseConditions={node.conditions} />
|
||||
</div>
|
||||
{!node?.revokedAt && !isProductLicenseExpired(node?.info?.expiresAt ?? 0) && (
|
||||
{!revoked && !isProductLicenseExpired(info?.expireTime?.toDate() ?? 0) && (
|
||||
<LoaderButton
|
||||
className="ml-auto"
|
||||
variant="danger"
|
||||
label="Revoke"
|
||||
onClick={onRevoke}
|
||||
loading={loading}
|
||||
loading={isRevokeLoading}
|
||||
/>
|
||||
)}
|
||||
</CollapseHeader>
|
||||
@ -127,52 +167,46 @@ export const SiteAdminProductLicenseNode: React.FunctionComponent<
|
||||
<CollapsePanel className="mt-4">
|
||||
<div className="d-flex">
|
||||
<Label>License Key ID</Label>
|
||||
<Text className="ml-3">{uuid}</Text>
|
||||
<Text className="ml-3">
|
||||
<span className="text-monospace">{node.id}</span>
|
||||
</Text>
|
||||
</div>
|
||||
<div className="d-flex">
|
||||
<Label>Key Version</Label>
|
||||
<Text className="ml-3">{node.version}</Text>
|
||||
<Text className="ml-3">{licenseKey.infoVersion}</Text>
|
||||
</div>
|
||||
{node.version > 1 && (
|
||||
{licenseKey.infoVersion > 1 && (
|
||||
<>
|
||||
<div className="d-flex">
|
||||
<Label>Site ID</Label>
|
||||
<Text className="ml-3">
|
||||
{node.siteID ?? <span className="text-muted">Unused</span>}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="d-flex">
|
||||
<Label>Salesforce Subscription ID</Label>
|
||||
<Text className="ml-3">
|
||||
{node.info?.salesforceSubscriptionID ?? (
|
||||
<span className="text-muted">Unused</span>
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="d-flex">
|
||||
<Label>Salesforce Opportunity ID</Label>
|
||||
<Text className="ml-3">
|
||||
{node.info?.salesforceOpportunityID ?? (
|
||||
<span className="text-muted">Unused</span>
|
||||
{info?.salesforceOpportunityId ? (
|
||||
<Link
|
||||
to={`https://sourcegraph2020.lightning.force.com/lightning/r/Opportunity/${info.salesforceOpportunityId}/view`}
|
||||
>
|
||||
<span className="text-monospace">{info.salesforceOpportunityId}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted">Not set</span>
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{node.info && node.info.tags.length > 0 && (
|
||||
{info && info.tags.length > 0 && (
|
||||
<>
|
||||
{hasUnknownTags(node.info.tags) && <UnknownTagWarning className="mb-2" />}
|
||||
{hasUnknownTags(info.tags) && <UnknownTagWarning className="mb-2" />}
|
||||
<Label className="w-100">
|
||||
<Text className="mb-2">Tags</Text>
|
||||
<Text className="mb-2">
|
||||
<ProductLicenseTags tags={node.info.tags} />
|
||||
<ProductLicenseTags tags={info.tags} />
|
||||
</Text>
|
||||
</Label>
|
||||
</>
|
||||
)}
|
||||
<Label className="w-100">
|
||||
<Text className="mb-2">License Key</Text>
|
||||
<CopyableText flex={true} text={node.licenseKey} />
|
||||
<CopyableText flex={true} text={licenseKey.licenseKey} secret={true} />
|
||||
</Label>
|
||||
</CollapsePanel>
|
||||
</Grid>
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
.row {
|
||||
background-color: var(--code-bg);
|
||||
border-bottom: 1px solid var(--border-color-2);
|
||||
|
||||
td {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
@ -1,29 +1,30 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
|
||||
import { LinkOrSpan } from '@sourcegraph/wildcard'
|
||||
import { Badge, LinkOrSpan } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { SiteAdminProductSubscriptionFields } from '../../../../graphql-operations'
|
||||
import { AccountName } from '../../../dotcom/productSubscriptions/AccountName'
|
||||
import { ProductSubscriptionLabel } from '../../../dotcom/productSubscriptions/ProductSubscriptionLabel'
|
||||
import { ProductLicenseTags } from '../../../productSubscription/ProductLicenseTags'
|
||||
import type { EnterprisePortalEnvironment } from './enterpriseportal'
|
||||
import {
|
||||
type EnterpriseSubscription,
|
||||
EnterpriseSubscriptionCondition_Status,
|
||||
} from './enterpriseportalgen/subscriptions_pb'
|
||||
import { InstanceTypeBadge } from './InstanceTypeBadge'
|
||||
|
||||
import { enterprisePortalID } from './utils'
|
||||
import styles from './SiteAdminProductSubscriptionNode.module.scss'
|
||||
|
||||
export const SiteAdminProductSubscriptionNodeHeader: React.FunctionComponent<React.PropsWithChildren<unknown>> = () => (
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Customer</th>
|
||||
<th>Plan</th>
|
||||
<th>Expiration</th>
|
||||
<th>Tags</th>
|
||||
<th>Display name</th>
|
||||
<th>Salesforce subscription</th>
|
||||
<th>Instance type</th>
|
||||
<th>Instance domain</th>
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
|
||||
export interface SiteAdminProductSubscriptionNodeProps {
|
||||
node: SiteAdminProductSubscriptionFields
|
||||
env: EnterprisePortalEnvironment
|
||||
node: EnterpriseSubscription
|
||||
}
|
||||
|
||||
/**
|
||||
@ -31,32 +32,44 @@ export interface SiteAdminProductSubscriptionNodeProps {
|
||||
*/
|
||||
export const SiteAdminProductSubscriptionNode: React.FunctionComponent<
|
||||
React.PropsWithChildren<SiteAdminProductSubscriptionNodeProps>
|
||||
> = ({ node }) => (
|
||||
<tr>
|
||||
<td>
|
||||
<LinkOrSpan to={node.urlForSiteAdmin} className="mr-3">
|
||||
{enterprisePortalID(node.uuid)}
|
||||
</LinkOrSpan>
|
||||
</td>
|
||||
<td className="w-100">
|
||||
<AccountName account={node.account} />
|
||||
</td>
|
||||
<td className="text-nowrap">
|
||||
<ProductSubscriptionLabel productSubscription={node} className="mr-3" />
|
||||
</td>
|
||||
<td className="text-nowrap">
|
||||
{node.activeLicense?.info ? (
|
||||
<Timestamp date={node.activeLicense.info.expiresAt} utc={true} />
|
||||
) : (
|
||||
<span className="text-muted font-italic">None</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="w-100">
|
||||
{node.activeLicense?.info && node.activeLicense.info.tags.length > 0 ? (
|
||||
<ProductLicenseTags tags={node.activeLicense.info.tags} />
|
||||
) : (
|
||||
<span className="text-muted font-italic">None</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
> = ({ env, node }) => {
|
||||
const archived = node.conditions.find(
|
||||
condition => condition.status === EnterpriseSubscriptionCondition_Status.ARCHIVED
|
||||
)
|
||||
|
||||
return (
|
||||
<tr className={styles.row}>
|
||||
<td>
|
||||
{archived && (
|
||||
<Badge variant="danger" small={true} className="mr-2">
|
||||
Archived
|
||||
</Badge>
|
||||
)}
|
||||
<LinkOrSpan to={`/site-admin/dotcom/product/subscriptions/${node.id}?env=${env}`} className="mr-2">
|
||||
<strong>{node.displayName}</strong>
|
||||
</LinkOrSpan>
|
||||
</td>
|
||||
<td className="text-nowrap">
|
||||
{node?.salesforce?.subscriptionId ? (
|
||||
<span className="text-monospace mr-2">{node?.salesforce?.subscriptionId}</span>
|
||||
) : (
|
||||
<span className="text-muted mr-2">Not set</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-nowrap">
|
||||
{node?.instanceType ? (
|
||||
<InstanceTypeBadge instanceType={node.instanceType} className="mr-2" />
|
||||
) : (
|
||||
<span className="text-muted mr-2">Not set</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-nowrap">
|
||||
{node?.instanceDomain ? (
|
||||
<small>{node?.instanceDomain}</small>
|
||||
) : (
|
||||
<span className="text-muted mr-2">Not set</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,46 +1,69 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { mdiPlus } from '@mdi/js'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import type { ConnectError } from '@connectrpc/connect'
|
||||
import { mdiInformationOutline, mdiCircle, mdiPlus, mdiPencil } from '@mdi/js'
|
||||
import { QueryClientProvider, type UseQueryResult } from '@tanstack/react-query'
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { Timestamp } from '@sourcegraph/branded/src/components/Timestamp'
|
||||
import { logger } from '@sourcegraph/common'
|
||||
import { useMutation, useQuery } from '@sourcegraph/http-client'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Button, Container, ErrorAlert, H3, Icon, Link, LoadingSpinner, PageHeader, Text } from '@sourcegraph/wildcard'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Container,
|
||||
ErrorAlert,
|
||||
H3,
|
||||
Icon,
|
||||
Link,
|
||||
LoadingSpinner,
|
||||
PageHeader,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@sourcegraph/wildcard'
|
||||
|
||||
import { Collapsible } from '../../../../components/Collapsible'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
ConnectionError,
|
||||
ConnectionList,
|
||||
ConnectionLoading,
|
||||
ConnectionSummary,
|
||||
ShowMoreButton,
|
||||
SummaryContainer,
|
||||
} from '../../../../components/FilteredConnection/ui'
|
||||
import { PageTitle } from '../../../../components/PageTitle'
|
||||
import { Timeline, type TimelineStage } from '../../../../components/Timeline'
|
||||
import { useScrollToLocationHash } from '../../../../components/useScrollToLocationHash'
|
||||
import type {
|
||||
ArchiveProductSubscriptionResult,
|
||||
ArchiveProductSubscriptionVariables,
|
||||
DotComProductSubscriptionResult,
|
||||
DotComProductSubscriptionVariables,
|
||||
} from '../../../../graphql-operations'
|
||||
import { AccountName } from '../../../dotcom/productSubscriptions/AccountName'
|
||||
import { ProductSubscriptionLabel } from '../../../dotcom/productSubscriptions/ProductSubscriptionLabel'
|
||||
import { isProductLicenseExpired } from '../../../../productSubscription/helpers'
|
||||
import {
|
||||
ProductSubscriptionLabel,
|
||||
productSubscriptionLabel,
|
||||
} from '../../../dotcom/productSubscriptions/ProductSubscriptionLabel'
|
||||
import { LicenseGenerationKeyWarning } from '../../../productSubscription/LicenseGenerationKeyWarning'
|
||||
|
||||
import {
|
||||
ARCHIVE_PRODUCT_SUBSCRIPTION,
|
||||
DOTCOM_PRODUCT_SUBSCRIPTION,
|
||||
useProductSubscriptionLicensesConnection,
|
||||
} from './backend'
|
||||
import { CodyServicesSection } from './CodyServicesSection'
|
||||
import { queryClient, type EnterprisePortalEnvironment } from './enterpriseportal'
|
||||
import {
|
||||
queryClient,
|
||||
useArchiveEnterpriseSubscription,
|
||||
useGetEnterpriseSubscription,
|
||||
useListEnterpriseSubscriptionLicenses,
|
||||
useUpdateEnterpriseSubscription,
|
||||
type EnterprisePortalEnvironment,
|
||||
} from './enterpriseportal'
|
||||
import { EnterprisePortalEnvSelector, getDefaultEnterprisePortalEnv } from './EnterprisePortalEnvSelector'
|
||||
import { EnterprisePortalEnvWarning } from './EnterprisePortalEnvWarning'
|
||||
import {
|
||||
type EnterpriseSubscriptionCondition,
|
||||
type EnterpriseSubscriptionLicenseCondition,
|
||||
EnterpriseSubscriptionCondition_Status,
|
||||
EnterpriseSubscriptionLicenseType,
|
||||
type ListEnterpriseSubscriptionLicensesResponse,
|
||||
EnterpriseSubscriptionLicenseCondition_Status,
|
||||
type EnterpriseSubscriptionLicenseKey,
|
||||
EnterpriseSubscriptionInstanceType,
|
||||
type EnterpriseSubscriptionLicense,
|
||||
} from './enterpriseportalgen/subscriptions_pb'
|
||||
import { InstanceTypeBadge } from './InstanceTypeBadge'
|
||||
import { SiteAdminGenerateProductLicenseForSubscriptionForm } from './SiteAdminGenerateProductLicenseForSubscriptionForm'
|
||||
import { SiteAdminProductLicenseNode } from './SiteAdminProductLicenseNode'
|
||||
import { enterprisePortalID } from './utils'
|
||||
|
||||
interface Props extends TelemetryV2Props {}
|
||||
|
||||
@ -50,224 +73,488 @@ export const SiteAdminProductSubscriptionPage: React.FunctionComponent<React.Pro
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const QUERY_PARAM_ENV = 'env'
|
||||
|
||||
/**
|
||||
* Displays a product subscription in the site admin area.
|
||||
*/
|
||||
const Page: React.FunctionComponent<React.PropsWithChildren<Props>> = ({ telemetryRecorder }) => {
|
||||
const navigate = useNavigate()
|
||||
const { subscriptionUUID = '' } = useParams<{ subscriptionUUID: string }>()
|
||||
const { subscriptionUUID: paramSubscriptionUUID = '' } = useParams<{ subscriptionUUID: string }>()
|
||||
useEffect(() => telemetryRecorder.recordEvent('admin.productSubscription', 'view'), [telemetryRecorder])
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [env, setEnv] = useState<EnterprisePortalEnvironment>(
|
||||
(searchParams.get(QUERY_PARAM_ENV) as EnterprisePortalEnvironment) || getDefaultEnterprisePortalEnv()
|
||||
)
|
||||
useEffect(() => {
|
||||
const currentEnv = searchParams.get(QUERY_PARAM_ENV) as EnterprisePortalEnvironment
|
||||
|
||||
searchParams.set(QUERY_PARAM_ENV, env)
|
||||
setSearchParams(searchParams)
|
||||
|
||||
// HACK: env state doesn't propagate to hooks correctly, so conditionally
|
||||
// reload the page.
|
||||
// Required until we fix https://linear.app/sourcegraph/issue/CORE-245
|
||||
if (env !== currentEnv) {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
}, [env, setSearchParams, searchParams])
|
||||
|
||||
const { data, isFetching: isLoading, error, refetch } = useGetEnterpriseSubscription(env, paramSubscriptionUUID)
|
||||
|
||||
const [showGenerate, setShowGenerate] = useState<boolean>(false)
|
||||
|
||||
const { data, loading, error, refetch } = useQuery<
|
||||
DotComProductSubscriptionResult,
|
||||
DotComProductSubscriptionVariables
|
||||
>(DOTCOM_PRODUCT_SUBSCRIPTION, {
|
||||
variables: { uuid: subscriptionUUID },
|
||||
errorPolicy: 'all',
|
||||
})
|
||||
const licenses = useListEnterpriseSubscriptionLicenses(
|
||||
env,
|
||||
[
|
||||
{
|
||||
filter: {
|
||||
case: 'subscriptionId',
|
||||
value: paramSubscriptionUUID,
|
||||
},
|
||||
},
|
||||
{
|
||||
filter: {
|
||||
// This UI only manages old-school license keys.
|
||||
case: 'type',
|
||||
value: EnterpriseSubscriptionLicenseType.KEY,
|
||||
},
|
||||
},
|
||||
],
|
||||
{ limit: 100, shouldLoad: !!data }
|
||||
)
|
||||
|
||||
const [archiveProductSubscription, { loading: archiveLoading, error: archiveError }] = useMutation<
|
||||
ArchiveProductSubscriptionResult,
|
||||
ArchiveProductSubscriptionVariables
|
||||
>(ARCHIVE_PRODUCT_SUBSCRIPTION)
|
||||
const {
|
||||
mutateAsync: archiveProductSubscription,
|
||||
isPending: archiveLoading,
|
||||
error: archiveError,
|
||||
} = useArchiveEnterpriseSubscription(env)
|
||||
|
||||
const subscription = data?.subscription
|
||||
|
||||
const onArchive = useCallback(async () => {
|
||||
if (!data) {
|
||||
if (!subscription) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!window.confirm(
|
||||
'Do you really want to archive this product subscription? This will hide it from site admins and users.\n\nHowever, it does NOT:\n\n- invalidate the license key\n- refund payment or cancel billing\n\nYou must manually do those things.'
|
||||
)
|
||||
) {
|
||||
const reason = window.prompt(
|
||||
'Do you really want to PERMANENTLY archive this subscription? All licenses associated with this subscription will be PERMANENTLY revoked, it will no longer be available for various Sourcegraph services, and changes can no longer be made to this subscription.\n\nHowever, it does NOT refund payment or cancel billing for you.\n\nEnter a revocation reason to continue.'
|
||||
)
|
||||
if (!reason || reason.length <= 3) {
|
||||
window.alert('Aborting.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
telemetryRecorder.recordEvent('admin.productSubscription', 'archive')
|
||||
await archiveProductSubscription({ variables: { id: data.dotcom.productSubscription.id } })
|
||||
await archiveProductSubscription({
|
||||
reason,
|
||||
subscriptionId: subscription.id,
|
||||
})
|
||||
navigate('/site-admin/dotcom/product/subscriptions')
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
}
|
||||
}, [data, archiveProductSubscription, navigate, telemetryRecorder])
|
||||
}, [subscription, archiveProductSubscription, navigate, telemetryRecorder])
|
||||
|
||||
const toggleShowGenerate = useCallback((): void => setShowGenerate(previousValue => !previousValue), [])
|
||||
|
||||
const refetchRef = useRef<(() => void) | null>(null)
|
||||
const setRefetchRef = useCallback(
|
||||
(refetch: (() => void) | null) => {
|
||||
refetchRef.current = refetch
|
||||
},
|
||||
[refetchRef]
|
||||
)
|
||||
const {
|
||||
mutateAsync: updateEnterpriseSubscription,
|
||||
isPending: subscriptionUpdating,
|
||||
error: subscriptionUpdateError,
|
||||
} = useUpdateEnterpriseSubscription(env)
|
||||
|
||||
const onLicenseUpdate = useCallback(async () => {
|
||||
await refetch()
|
||||
if (refetchRef.current) {
|
||||
refetchRef.current()
|
||||
}
|
||||
await licenses.refetch()
|
||||
setShowGenerate(false)
|
||||
}, [refetch, refetchRef])
|
||||
}, [licenses])
|
||||
|
||||
if (loading && !data) {
|
||||
return <LoadingSpinner />
|
||||
}
|
||||
const isAnythingLoading = isLoading || licenses.isLoading || subscriptionUpdating || archiveLoading
|
||||
|
||||
// If there's an error, simply render an error page.
|
||||
if (error) {
|
||||
return <ErrorAlert className="my-2" error={error} />
|
||||
}
|
||||
const created = subscription?.conditions?.find(
|
||||
condition => condition.status === EnterpriseSubscriptionCondition_Status.CREATED
|
||||
)
|
||||
const archived = subscription?.conditions?.find(
|
||||
condition => condition.status === EnterpriseSubscriptionCondition_Status.ARCHIVED
|
||||
)
|
||||
|
||||
const productSubscription = data!.dotcom.productSubscription
|
||||
|
||||
/**
|
||||
* TODO(@robert): As part of https://linear.app/sourcegraph/issue/CORE-100,
|
||||
* eventually dev subscriptions will only live on Enterprise Portal dev and
|
||||
* prod subscriptions will only live on Enterprise Portal prod. Until we
|
||||
* cut over, we use license tags to determine what Enterprise Portal
|
||||
* environment to target.
|
||||
*/
|
||||
const enterprisePortalEnvironment: EnterprisePortalEnvironment =
|
||||
window.context.deployType === 'dev'
|
||||
? 'local'
|
||||
: productSubscription.activeLicense?.info?.tags?.includes('dev')
|
||||
? 'dev'
|
||||
: 'prod'
|
||||
const activeLicense = getActiveLicense(licenses?.data?.licenses)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="site-admin-product-subscription-page">
|
||||
<PageTitle title="Enterprise subscription" />
|
||||
<PageHeader
|
||||
headingElement="h2"
|
||||
path={[
|
||||
{ text: 'Enterprise subscriptions', to: '/site-admin/dotcom/product/subscriptions' },
|
||||
{ text: enterprisePortalID(subscriptionUUID) },
|
||||
]}
|
||||
description={
|
||||
<div className="site-admin-product-subscription-page">
|
||||
<PageTitle title="Enterprise subscription" />
|
||||
<PageHeader
|
||||
headingElement="h2"
|
||||
path={[
|
||||
{ text: 'Enterprise subscriptions', to: `/site-admin/dotcom/product/subscriptions?env=${env}` },
|
||||
{ text: subscription?.displayName || subscription?.id || paramSubscriptionUUID },
|
||||
]}
|
||||
description="This subscription tracks a single Enterprise Sourcegraph instance."
|
||||
byline={
|
||||
subscription &&
|
||||
created?.lastTransitionTime && (
|
||||
<span className="text-muted">
|
||||
Created <Timestamp date={productSubscription.createdAt} />
|
||||
Created <Timestamp date={created.lastTransitionTime.toDate()} />
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<Button onClick={onArchive} disabled={archiveLoading} variant="danger">
|
||||
Archive
|
||||
</Button>
|
||||
}
|
||||
className="mb-3"
|
||||
/>
|
||||
{archiveError && <ErrorAlert className="mt-2" error={archiveError} />}
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<div className="align-items-end d-flex">
|
||||
<EnterprisePortalEnvSelector env={env} setEnv={setEnv} />
|
||||
<div>
|
||||
<Button
|
||||
onClick={onArchive}
|
||||
disabled={archiveLoading || !!archived}
|
||||
variant="danger"
|
||||
display="block"
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className="mb-3"
|
||||
/>
|
||||
{isAnythingLoading && <LoadingSpinner />}
|
||||
|
||||
<H3>Details</H3>
|
||||
<Container className="mb-3">
|
||||
<table className="table mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="text-nowrap">ID</th>
|
||||
<td className="w-100">{enterprisePortalID(subscriptionUUID)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-nowrap">Current Plan</th>
|
||||
<td className="w-100">
|
||||
<ProductSubscriptionLabel productSubscription={productSubscription} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-nowrap">Account</th>
|
||||
<td className="w-100">
|
||||
<AccountName account={productSubscription.account} /> —{' '}
|
||||
<Link to={productSubscription.url}>View as user</Link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-nowrap">Salesforce Opportunity</th>
|
||||
<td className="w-100">
|
||||
{(!productSubscription.activeLicense ||
|
||||
productSubscription.activeLicense.info?.salesforceOpportunityID === null) && (
|
||||
<span className="text-muted">None</span>
|
||||
)}
|
||||
{productSubscription.activeLicense &&
|
||||
productSubscription.activeLicense.info?.salesforceOpportunityID !== null && (
|
||||
<>{productSubscription.activeLicense.info?.salesforceOpportunityID}</>
|
||||
{archiveError && <ErrorAlert className="mt-2" error={archiveError} />}
|
||||
{subscriptionUpdateError && <ErrorAlert className="mt-2" error={subscriptionUpdateError} />}
|
||||
{error && <ErrorAlert className="mt-2" error={error} />}
|
||||
|
||||
{archived && <Alert variant="danger">This subscription has been permanently archived.</Alert>}
|
||||
|
||||
{subscription && (
|
||||
<>
|
||||
<H3 className="mt-2">Details</H3>
|
||||
<Container className="mb-3">
|
||||
<EnterprisePortalEnvWarning env={env} actionText="managing a subscription" />
|
||||
<table className="table mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="text-nowrap">
|
||||
Subscription ID{' '}
|
||||
<Tooltip content="This immutable identifier represents a subscription for a single Enterprise Sourcegraph instance tracked by the Enterprise Portal service.">
|
||||
<Icon aria-label="Show help text" svgPath={mdiInformationOutline} />
|
||||
</Tooltip>
|
||||
</th>
|
||||
<td className="w-100">
|
||||
<span className="text-monospace">{subscription?.id}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-nowrap">
|
||||
Display name{' '}
|
||||
<Tooltip content="Brief, human-friendly, globally unique name for this subscription.">
|
||||
<Icon aria-label="Show help text" svgPath={mdiInformationOutline} />
|
||||
</Tooltip>
|
||||
</th>
|
||||
<td className="w-100">
|
||||
{subscription?.displayName ? (
|
||||
<>{subscription?.displayName}</>
|
||||
) : (
|
||||
<span className="text-muted">Not set</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-nowrap">Salesforce Subscription</th>
|
||||
<td className="w-100">
|
||||
{(!productSubscription.activeLicense ||
|
||||
productSubscription.activeLicense.info?.salesforceSubscriptionID === null) && (
|
||||
<span className="text-muted">None</span>
|
||||
)}
|
||||
{productSubscription.activeLicense &&
|
||||
productSubscription.activeLicense.info?.salesforceSubscriptionID !== null && (
|
||||
<>{productSubscription.activeLicense.info?.salesforceSubscriptionID}</>
|
||||
{!archived && (
|
||||
<EditAttributeButton
|
||||
label="Edit display name"
|
||||
refetch={refetch}
|
||||
disabled={isAnythingLoading}
|
||||
onClick={async () => {
|
||||
const displayName = window.prompt(
|
||||
'Enter instance display name to assign:',
|
||||
subscription?.displayName
|
||||
)
|
||||
if (displayName === null) {
|
||||
return
|
||||
}
|
||||
await updateEnterpriseSubscription({
|
||||
subscription: { id: subscription?.id, displayName },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Container>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-nowrap">
|
||||
Salesforce subscription{' '}
|
||||
<Tooltip content="The ID of the corresponding Salesforce subscription, of the format 'a1a...'.">
|
||||
<Icon aria-label="Show help text" svgPath={mdiInformationOutline} />
|
||||
</Tooltip>
|
||||
</th>
|
||||
<td className="w-100">
|
||||
{subscription?.salesforce?.subscriptionId ? (
|
||||
<span className="text-monospace">
|
||||
{subscription?.salesforce?.subscriptionId}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted">Not set</span>
|
||||
)}
|
||||
{!archived && (
|
||||
<EditAttributeButton
|
||||
label="Edit Salesforce subscription ID"
|
||||
refetch={refetch}
|
||||
disabled={isAnythingLoading}
|
||||
onClick={async () => {
|
||||
const salesforceSubscriptionID = window.prompt(
|
||||
'Enter the Salesforce subscription ID to assign:',
|
||||
subscription?.salesforce?.subscriptionId
|
||||
)
|
||||
if (salesforceSubscriptionID === null) {
|
||||
return
|
||||
}
|
||||
if (salesforceSubscriptionID === '') {
|
||||
await updateEnterpriseSubscription({
|
||||
subscription: {
|
||||
id: subscription?.id,
|
||||
},
|
||||
updateMask: {
|
||||
paths: ['salesforce.subscription_id'],
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await updateEnterpriseSubscription({
|
||||
subscription: {
|
||||
id: subscription?.id,
|
||||
salesforce: {
|
||||
subscriptionId: salesforceSubscriptionID,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-nowrap">
|
||||
Instance domain{' '}
|
||||
<Tooltip content="The known 'external URL' of this Sourcegraph instance. This must be set manually, and is required for Cody Analytics.">
|
||||
<Icon aria-label="Show help text" svgPath={mdiInformationOutline} />
|
||||
</Tooltip>
|
||||
</th>
|
||||
<td className="w-100">
|
||||
{subscription?.instanceDomain ? (
|
||||
<Link to={`https://${subscription?.instanceDomain}`}>
|
||||
{subscription?.instanceDomain}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted">Not set</span>
|
||||
)}
|
||||
{!archived && (
|
||||
<EditAttributeButton
|
||||
label="Edit instance domain"
|
||||
refetch={refetch}
|
||||
disabled={isAnythingLoading}
|
||||
onClick={async () => {
|
||||
const instanceDomain = window.prompt(
|
||||
'Enter instance domain to assign (leave empty to unassign):',
|
||||
subscription?.instanceDomain
|
||||
)
|
||||
if (instanceDomain === null) {
|
||||
return
|
||||
}
|
||||
if (instanceDomain === '') {
|
||||
await updateEnterpriseSubscription({
|
||||
subscription: {
|
||||
id: subscription?.id,
|
||||
},
|
||||
updateMask: {
|
||||
paths: ['instance_domain'],
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await updateEnterpriseSubscription({
|
||||
subscription: { id: subscription?.id, instanceDomain },
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-nowrap">
|
||||
Instance type{' '}
|
||||
<Tooltip content="This indicates what this subscription's instance is used for.">
|
||||
<Icon aria-label="Show help text" svgPath={mdiInformationOutline} />
|
||||
</Tooltip>
|
||||
</th>
|
||||
<td className="w-100">
|
||||
{subscription?.instanceType ? (
|
||||
<InstanceTypeBadge instanceType={subscription.instanceType} />
|
||||
) : (
|
||||
<span className="text-muted">Not set</span>
|
||||
)}
|
||||
{!archived && (
|
||||
<EditAttributeButton
|
||||
label="Edit instance type"
|
||||
refetch={refetch}
|
||||
disabled={isAnythingLoading}
|
||||
onClick={async () => {
|
||||
const types = [
|
||||
EnterpriseSubscriptionInstanceType.PRIMARY,
|
||||
EnterpriseSubscriptionInstanceType.SECONDARY,
|
||||
EnterpriseSubscriptionInstanceType.INTERNAL,
|
||||
]
|
||||
const instanceType = window.prompt(
|
||||
`Enter an instance type to assign (one of: ${types
|
||||
.map(type => EnterpriseSubscriptionInstanceType[type])
|
||||
.join(', ')}). Leave empty to unassign.`
|
||||
)
|
||||
if (instanceType === null) {
|
||||
return
|
||||
}
|
||||
if (instanceType === '') {
|
||||
await updateEnterpriseSubscription({
|
||||
subscription: {
|
||||
id: subscription?.id,
|
||||
},
|
||||
updateMask: { paths: ['instance_type'] },
|
||||
})
|
||||
return
|
||||
}
|
||||
const type = types.find(
|
||||
type =>
|
||||
EnterpriseSubscriptionInstanceType[type].toLowerCase() ===
|
||||
instanceType.toLowerCase()
|
||||
)
|
||||
if (!type) {
|
||||
window.alert(`Invalid instance type ${instanceType}`)
|
||||
return
|
||||
}
|
||||
await updateEnterpriseSubscription({
|
||||
subscription: {
|
||||
id: subscription?.id,
|
||||
instanceType: type,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-nowrap">
|
||||
Active license{' '}
|
||||
<Tooltip content="The most recently created, non-expired, non-revoked license is considered the 'active license'.">
|
||||
<Icon aria-label="Show help text" svgPath={mdiInformationOutline} />
|
||||
</Tooltip>
|
||||
</th>
|
||||
<td className="w-100">
|
||||
{activeLicense ? (
|
||||
<>
|
||||
<ProductSubscriptionLabel
|
||||
productName={activeLicense.license.value?.planDisplayName}
|
||||
userCount={activeLicense.license.value?.info?.userCount}
|
||||
/>{' '}
|
||||
- <Link to={`#${activeLicense.id}`}>view license</Link>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted">No active license</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Container>
|
||||
|
||||
<CodyServicesSection
|
||||
enterprisePortalEnvironment={enterprisePortalEnvironment}
|
||||
viewerCanAdminister={true}
|
||||
productSubscriptionUUID={subscriptionUUID}
|
||||
telemetryRecorder={telemetryRecorder}
|
||||
/>
|
||||
<Collapsible
|
||||
title={<H3>History</H3>}
|
||||
titleAtStart={true}
|
||||
defaultExpanded={!!archived}
|
||||
className="mb-3"
|
||||
>
|
||||
<Container className="mb-3">
|
||||
{subscription && licenses.data ? (
|
||||
<ConditionsTimeline
|
||||
subscriptionConditions={subscription.conditions}
|
||||
licensesConditions={licenses.data.licenses.flatMap(({ id, conditions, license }) =>
|
||||
conditions.map(condition => ({
|
||||
licenseID: id,
|
||||
license: license.value,
|
||||
condition,
|
||||
}))
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
</Container>
|
||||
</Collapsible>
|
||||
|
||||
<H3 className="d-flex align-items-start">
|
||||
Licenses
|
||||
<Button className="ml-auto" onClick={toggleShowGenerate} variant="primary">
|
||||
<Icon aria-hidden={true} svgPath={mdiPlus} /> New license key
|
||||
</Button>
|
||||
</H3>
|
||||
<LicenseGenerationKeyWarning className="mb-2" />
|
||||
<Container className="mb-2">
|
||||
<ProductSubscriptionLicensesConnection
|
||||
subscriptionUUID={subscriptionUUID}
|
||||
toggleShowGenerate={toggleShowGenerate}
|
||||
setRefetch={setRefetchRef}
|
||||
<CodyServicesSection
|
||||
enterprisePortalEnvironment={env}
|
||||
viewerCanAdminister={true}
|
||||
productSubscriptionUUID={subscription?.id}
|
||||
telemetryRecorder={telemetryRecorder}
|
||||
/>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
{showGenerate && (
|
||||
<H3 className="d-flex align-items-start">
|
||||
Licenses
|
||||
<Button
|
||||
className="ml-auto"
|
||||
onClick={toggleShowGenerate}
|
||||
variant="primary"
|
||||
disabled={!!archived || archiveLoading}
|
||||
>
|
||||
<Icon aria-hidden={true} svgPath={mdiPlus} /> New license key
|
||||
</Button>
|
||||
</H3>
|
||||
<EnterprisePortalEnvWarning env={env} actionText="managing licenses" />
|
||||
<LicenseGenerationKeyWarning className="mb-2" />
|
||||
<Container className="mb-2">
|
||||
<ProductSubscriptionLicensesConnection
|
||||
env={env}
|
||||
licenses={licenses}
|
||||
toggleShowGenerate={toggleShowGenerate}
|
||||
telemetryRecorder={telemetryRecorder}
|
||||
/>
|
||||
</Container>
|
||||
</>
|
||||
)}
|
||||
{subscription && showGenerate && (
|
||||
<SiteAdminGenerateProductLicenseForSubscriptionForm
|
||||
subscriptionID={productSubscription.id}
|
||||
subscriptionAccount={productSubscription.account?.username || ''}
|
||||
latestLicense={productSubscription.productLicenses?.nodes[0] ?? undefined}
|
||||
env={env}
|
||||
subscription={subscription}
|
||||
latestLicense={licenses.data?.licenses[0] ?? undefined}
|
||||
onGenerate={onLicenseUpdate}
|
||||
onCancel={() => setShowGenerate(false)}
|
||||
telemetryRecorder={telemetryRecorder}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getActiveLicense(
|
||||
licenses: EnterpriseSubscriptionLicense[] | undefined
|
||||
): EnterpriseSubscriptionLicense | undefined {
|
||||
return licenses?.find(
|
||||
// Exists if it is the first license, has an expiry, and expiry is before
|
||||
// now
|
||||
({ license, conditions }, idx) =>
|
||||
idx === 0 &&
|
||||
license?.value?.info?.expireTime &&
|
||||
!isProductLicenseExpired(license?.value?.info?.expireTime?.toDate()) &&
|
||||
!conditions.find(condition => condition.status === EnterpriseSubscriptionLicenseCondition_Status.REVOKED)
|
||||
)
|
||||
}
|
||||
|
||||
interface ProductSubscriptionLicensesConnectionProps extends TelemetryV2Props {
|
||||
subscriptionUUID: string
|
||||
env: EnterprisePortalEnvironment
|
||||
licenses: UseQueryResult<ListEnterpriseSubscriptionLicensesResponse, ConnectError>
|
||||
toggleShowGenerate: () => void
|
||||
setRefetch: (refetch: () => void) => void
|
||||
}
|
||||
|
||||
const ProductSubscriptionLicensesConnection: React.FunctionComponent<ProductSubscriptionLicensesConnectionProps> = ({
|
||||
subscriptionUUID,
|
||||
setRefetch,
|
||||
env,
|
||||
licenses: { data, refetch, error, isLoading },
|
||||
toggleShowGenerate,
|
||||
telemetryRecorder,
|
||||
}) => {
|
||||
const { loading, hasNextPage, fetchMore, refetchAll, connection, error } =
|
||||
useProductSubscriptionLicensesConnection(subscriptionUUID)
|
||||
|
||||
useEffect(() => {
|
||||
setRefetch(refetchAll)
|
||||
}, [setRefetch, refetchAll])
|
||||
|
||||
const location = useLocation()
|
||||
const licenseIDFromLocationHash = useMemo(() => {
|
||||
if (location.hash.length > 1) {
|
||||
@ -280,32 +567,22 @@ const ProductSubscriptionLicensesConnection: React.FunctionComponent<ProductSubs
|
||||
return (
|
||||
<ConnectionContainer>
|
||||
{error && <ConnectionError errors={[error.message]} />}
|
||||
{loading && !connection && <ConnectionLoading />}
|
||||
{isLoading && !data && <ConnectionLoading />}
|
||||
<ConnectionList as="ul" className="list-group list-group-flush mb-0" aria-label="Subscription licenses">
|
||||
{connection?.nodes?.map(node => (
|
||||
{data?.licenses?.map(node => (
|
||||
<SiteAdminProductLicenseNode
|
||||
env={env}
|
||||
key={node.id}
|
||||
node={node}
|
||||
defaultExpanded={node.id === licenseIDFromLocationHash}
|
||||
showSubscription={false}
|
||||
onRevokeCompleted={refetchAll}
|
||||
onRevokeCompleted={refetch}
|
||||
telemetryRecorder={telemetryRecorder}
|
||||
isActiveLicense={node.id === getActiveLicense(data?.licenses)?.id}
|
||||
/>
|
||||
))}
|
||||
</ConnectionList>
|
||||
{connection && (
|
||||
<SummaryContainer centered={true}>
|
||||
<ConnectionSummary
|
||||
centered={true}
|
||||
connection={connection}
|
||||
noun="product license"
|
||||
pluralNoun="product licenses"
|
||||
hasNextPage={hasNextPage}
|
||||
emptyElement={<NoProductLicense toggleShowGenerate={toggleShowGenerate} />}
|
||||
/>
|
||||
{hasNextPage && <ShowMoreButton centered={true} onClick={fetchMore} />}
|
||||
</SummaryContainer>
|
||||
)}
|
||||
{data?.licenses?.length === 0 && <NoProductLicense toggleShowGenerate={toggleShowGenerate} />}
|
||||
</ConnectionContainer>
|
||||
)
|
||||
}
|
||||
@ -320,3 +597,136 @@ const NoProductLicense: React.FunctionComponent<{
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
interface ConditionsTimelineProps {
|
||||
subscriptionConditions: EnterpriseSubscriptionCondition[]
|
||||
/**
|
||||
* Combined conditions of all licenses found.
|
||||
*/
|
||||
licensesConditions: {
|
||||
licenseID: string
|
||||
license: EnterpriseSubscriptionLicenseKey | undefined
|
||||
condition: EnterpriseSubscriptionLicenseCondition
|
||||
}[]
|
||||
}
|
||||
|
||||
const ConditionsTimeline: React.FunctionComponent<ConditionsTimelineProps> = ({
|
||||
subscriptionConditions,
|
||||
licensesConditions,
|
||||
}) => {
|
||||
const getSubscriptionConditionBackgroundClassName = (status: EnterpriseSubscriptionCondition_Status): string => {
|
||||
switch (status) {
|
||||
case EnterpriseSubscriptionCondition_Status.CREATED: {
|
||||
return 'bg-success'
|
||||
}
|
||||
case EnterpriseSubscriptionCondition_Status.ARCHIVED: {
|
||||
return 'bg-danger'
|
||||
}
|
||||
default: {
|
||||
return 'bg-info'
|
||||
}
|
||||
}
|
||||
}
|
||||
const getLicenseConditionBackgroundClassName = (status: EnterpriseSubscriptionLicenseCondition_Status): string => {
|
||||
switch (status) {
|
||||
case EnterpriseSubscriptionLicenseCondition_Status.CREATED: {
|
||||
return 'bg-success'
|
||||
}
|
||||
case EnterpriseSubscriptionLicenseCondition_Status.REVOKED: {
|
||||
return 'bg-danger'
|
||||
}
|
||||
default: {
|
||||
return 'bg-info'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allConditions: {
|
||||
lastTransitionTime: Date
|
||||
summary: string
|
||||
details: React.ReactNode
|
||||
className: string
|
||||
}[] = subscriptionConditions
|
||||
.map(condition => ({
|
||||
lastTransitionTime: condition.lastTransitionTime!.toDate(),
|
||||
summary: `Subscription ${EnterpriseSubscriptionCondition_Status[condition.status].toLowerCase()}`,
|
||||
details: (
|
||||
<>
|
||||
{condition.message ? (
|
||||
<>{condition.message}</>
|
||||
) : (
|
||||
<span className="text-muted">No details provided.</span>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
className: getSubscriptionConditionBackgroundClassName(condition.status),
|
||||
}))
|
||||
.concat(
|
||||
...licensesConditions.map(({ licenseID, license, condition }) => ({
|
||||
lastTransitionTime: condition.lastTransitionTime!.toDate(),
|
||||
summary: `License ${EnterpriseSubscriptionLicenseCondition_Status[condition.status]
|
||||
.toLowerCase()
|
||||
.replaceAll('_', ' ')}: ${productSubscriptionLabel(
|
||||
license?.planDisplayName,
|
||||
license?.info?.userCount
|
||||
)}`,
|
||||
details: (
|
||||
<>
|
||||
{condition.message ? (
|
||||
<>{condition.message}</>
|
||||
) : (
|
||||
<span className="text-muted">No details provided.</span>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Link to={`#${licenseID}`}>
|
||||
View license <span className="text-monospace">{licenseID}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
className: getLicenseConditionBackgroundClassName(condition.status),
|
||||
}))
|
||||
)
|
||||
.sort((a, b) => (a.lastTransitionTime > b.lastTransitionTime ? -1 : 1))
|
||||
|
||||
const stages = allConditions?.map(
|
||||
(condition): TimelineStage => ({
|
||||
icon: <Icon aria-label="event" svgPath={mdiCircle} />,
|
||||
date: condition.lastTransitionTime.toISOString(),
|
||||
className: condition.className,
|
||||
|
||||
text: condition.summary,
|
||||
details: <Container>{condition.details}</Container>,
|
||||
})
|
||||
)
|
||||
|
||||
return <Timeline showDurations={false} stages={stages} />
|
||||
}
|
||||
|
||||
interface EditAttributeButtonProps {
|
||||
label: string
|
||||
onClick: () => Promise<void>
|
||||
refetch: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const EditAttributeButton: React.FunctionComponent<EditAttributeButtonProps> = ({
|
||||
label,
|
||||
onClick,
|
||||
refetch,
|
||||
disabled,
|
||||
}) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
aria-label={label}
|
||||
className="ml-1"
|
||||
disabled={disabled}
|
||||
onClick={async () => {
|
||||
await onClick()
|
||||
refetch()
|
||||
}}
|
||||
>
|
||||
<Icon aria-hidden={true} svgPath={mdiPencil} />
|
||||
</Button>
|
||||
)
|
||||
|
||||
@ -1,69 +1,211 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
import type { PartialMessage } from '@bufbuild/protobuf'
|
||||
import { mdiPlus } from '@mdi/js'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
|
||||
import { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Button, Container, Icon, Link, PageHeader } from '@sourcegraph/wildcard'
|
||||
|
||||
import type { AuthenticatedUser } from '../../../../auth'
|
||||
import { FilteredConnection } from '../../../../components/FilteredConnection'
|
||||
import {
|
||||
ConnectionContainer,
|
||||
ConnectionError,
|
||||
ConnectionForm,
|
||||
ConnectionList,
|
||||
ConnectionLoading,
|
||||
SummaryContainer,
|
||||
} from '../../../../components/FilteredConnection/ui'
|
||||
import { PageTitle } from '../../../../components/PageTitle'
|
||||
import type { SiteAdminProductSubscriptionFields } from '../../../../graphql-operations'
|
||||
|
||||
import { queryProductSubscriptions } from './backend'
|
||||
import { queryClient, useListEnterpriseSubscriptions, type EnterprisePortalEnvironment } from './enterpriseportal'
|
||||
import { EnterprisePortalEnvSelector, getDefaultEnterprisePortalEnv } from './EnterprisePortalEnvSelector'
|
||||
import { EnterprisePortalEnvWarning } from './EnterprisePortalEnvWarning'
|
||||
import type { ListEnterpriseSubscriptionsFilter } from './enterpriseportalgen/subscriptions_pb'
|
||||
import {
|
||||
SiteAdminProductSubscriptionNode,
|
||||
SiteAdminProductSubscriptionNodeHeader,
|
||||
type SiteAdminProductSubscriptionNodeProps,
|
||||
} from './SiteAdminProductSubscriptionNode'
|
||||
|
||||
import styles from './SiteAdminCreateProductSubscriptionPage.module.scss'
|
||||
|
||||
interface Props extends TelemetryV2Props {
|
||||
authenticatedUser: AuthenticatedUser
|
||||
}
|
||||
interface Props extends TelemetryV2Props {}
|
||||
|
||||
/**
|
||||
* Displays the enterprise subscriptions (formerly known as "product subscriptions") that have been
|
||||
* created on Sourcegraph.com.
|
||||
*/
|
||||
export const SiteAdminProductSubscriptionsPage: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
|
||||
authenticatedUser,
|
||||
telemetryRecorder,
|
||||
}) => {
|
||||
export const SiteAdminProductSubscriptionsPage: React.FunctionComponent<React.PropsWithChildren<Props>> = props => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Page {...props} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const QUERY_PARAM_KEY = 'query'
|
||||
const QUERY_PARAM_ENV = 'env'
|
||||
const QUERY_PARAM_FILTER = 'filter'
|
||||
|
||||
type FilterType = 'display_name' | 'sf_sub_id'
|
||||
|
||||
const MAX_RESULTS = 50
|
||||
|
||||
const Page: React.FunctionComponent<React.PropsWithChildren<Props>> = ({ telemetryRecorder }) => {
|
||||
useEffect(() => telemetryRecorder.recordEvent('admin.productSubscriptions', 'view'), [telemetryRecorder])
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [env, setEnv] = useState<EnterprisePortalEnvironment>(
|
||||
(searchParams.get(QUERY_PARAM_ENV) as EnterprisePortalEnvironment) || getDefaultEnterprisePortalEnv()
|
||||
)
|
||||
const [query, setQuery] = useState<string>(searchParams.get(QUERY_PARAM_KEY) ?? '')
|
||||
const [filters, setFilters] = useState<{ filter: FilterType }>({
|
||||
filter: (searchParams.get(QUERY_PARAM_FILTER) as FilterType) ?? 'display_name',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const currentEnv = searchParams.get(QUERY_PARAM_ENV) as EnterprisePortalEnvironment
|
||||
|
||||
searchParams.set(QUERY_PARAM_KEY, query?.trim() ?? '')
|
||||
searchParams.set(QUERY_PARAM_FILTER, filters.filter)
|
||||
searchParams.set(QUERY_PARAM_ENV, env)
|
||||
setSearchParams(searchParams)
|
||||
|
||||
// HACK: env state doesn't propagate to hooks correctly, so conditionally
|
||||
// reload the page.
|
||||
// Required until we fix https://linear.app/sourcegraph/issue/CORE-245
|
||||
if (env !== currentEnv) {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
}, [query, searchParams, setSearchParams, filters, env])
|
||||
|
||||
const [debouncedQuery] = useDebounce(query, 200)
|
||||
|
||||
let listFilters: PartialMessage<ListEnterpriseSubscriptionsFilter>[] = []
|
||||
// no filter without a query
|
||||
if (debouncedQuery) {
|
||||
switch (filters.filter) {
|
||||
case 'display_name': {
|
||||
listFilters = [
|
||||
{
|
||||
filter: {
|
||||
case: 'displayName',
|
||||
value: debouncedQuery,
|
||||
},
|
||||
},
|
||||
]
|
||||
break
|
||||
}
|
||||
case 'sf_sub_id': {
|
||||
listFilters = [
|
||||
{
|
||||
filter: {
|
||||
case: 'salesforce',
|
||||
value: { subscriptionId: debouncedQuery },
|
||||
},
|
||||
},
|
||||
]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
const { error, isFetching, data } = useListEnterpriseSubscriptions(env, listFilters, {
|
||||
limit: MAX_RESULTS,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="site-admin-product-subscriptions-page">
|
||||
<PageTitle title="Enterprise subscriptions" />
|
||||
<PageHeader
|
||||
headingElement="h2"
|
||||
path={[{ text: 'Enterprise subscriptions' }]}
|
||||
description="Manage subscriptions for Sourcegraph Enterprise instances."
|
||||
actions={
|
||||
<Button to="./new" variant="primary" as={Link}>
|
||||
<Icon aria-hidden={true} svgPath={mdiPlus} />
|
||||
Create Enterprise subscription
|
||||
</Button>
|
||||
<div className="align-items-end d-flex">
|
||||
<EnterprisePortalEnvSelector env={env} setEnv={setEnv} />
|
||||
<div>
|
||||
<Button to={`./new?env=${env}`} variant="primary" as={Link} display="block">
|
||||
<Icon aria-hidden={true} svgPath={mdiPlus} />
|
||||
Create subscription
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className="mb-3"
|
||||
/>
|
||||
|
||||
<Container>
|
||||
<FilteredConnection<SiteAdminProductSubscriptionFields, SiteAdminProductSubscriptionNodeProps>
|
||||
listComponent="table"
|
||||
listClassName="table"
|
||||
contentWrapperComponent={ListContentWrapper}
|
||||
noun="Enterprise subscription"
|
||||
pluralNoun="Enterprise subscriptions"
|
||||
queryConnection={queryProductSubscriptions}
|
||||
headComponent={SiteAdminProductSubscriptionNodeHeader}
|
||||
nodeComponent={SiteAdminProductSubscriptionNode}
|
||||
/>
|
||||
</Container>
|
||||
<EnterprisePortalEnvWarning env={env} actionText="managing subscriptions" />
|
||||
|
||||
<ConnectionContainer>
|
||||
<Container className="mb-3">
|
||||
<ConnectionForm
|
||||
inputValue={query}
|
||||
filterValues={filters}
|
||||
inputClassName="ml-2"
|
||||
filters={[
|
||||
{
|
||||
id: 'filter',
|
||||
type: 'select',
|
||||
label: 'Filter',
|
||||
options: [
|
||||
{
|
||||
args: {},
|
||||
label: 'Display name',
|
||||
value: 'display_name',
|
||||
tooltip: 'Partial, case-insensitive match on the subscription display name',
|
||||
},
|
||||
{
|
||||
args: {},
|
||||
label: 'Salesforce subscription ID',
|
||||
value: 'sf_sub_id',
|
||||
tooltip:
|
||||
'Exact match on the Salesforce subscription ID associated with the subscription',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
onInputChange={event => {
|
||||
setQuery(event.target.value)
|
||||
}}
|
||||
onFilterSelect={(filter, value) => {
|
||||
if (value) {
|
||||
setFilters({ ...filters, [filter.id]: value as FilterType })
|
||||
}
|
||||
}}
|
||||
inputPlaceholder="Enter a query to find subscriptions"
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Container className="mb-3">
|
||||
{error && <ConnectionError errors={[error.message]} />}
|
||||
|
||||
{isFetching && <ConnectionLoading />}
|
||||
{data && (
|
||||
<ConnectionList as="table" aria-label="Enterprise subscriptions">
|
||||
<SiteAdminProductSubscriptionNodeHeader />
|
||||
<tbody>
|
||||
{data?.subscriptions?.map(node => (
|
||||
<SiteAdminProductSubscriptionNode key={node.id} node={node} env={env} />
|
||||
))}
|
||||
</tbody>
|
||||
</ConnectionList>
|
||||
)}
|
||||
<SummaryContainer className="mt-4" centered={true}>
|
||||
{data && data.subscriptions.length > 0 && (
|
||||
<span className="text-muted">Showing {data.subscriptions.length} subscriptions.</span>
|
||||
)}
|
||||
{data && data.subscriptions.length === 0 && (
|
||||
<span className="text-muted">No subscriptions found.</span>
|
||||
)}
|
||||
{data && data.subscriptions.length >= MAX_RESULTS && (
|
||||
<ConnectionError
|
||||
className="mt-2"
|
||||
errors={[
|
||||
`Only ${MAX_RESULTS} results are shown at a time - narrow your search for more accurate results.`,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</SummaryContainer>
|
||||
</Container>
|
||||
</ConnectionContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ListContentWrapper: React.FunctionComponent<React.PropsWithChildren<{}>> = ({ children }) => (
|
||||
<div className={styles.contentWrapper}>{children}</div>
|
||||
)
|
||||
|
||||
@ -1,449 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SiteAdminProductLicenseNode > active 1`] = `
|
||||
<DocumentFragment>
|
||||
<li
|
||||
class="list-group-item p-3 mb-3 border"
|
||||
>
|
||||
<div
|
||||
style="display: grid; gap: 0rem; grid-template-columns: auto 1fr; margin-bottom: 0rem;"
|
||||
>
|
||||
<button
|
||||
class="btn btnIcon pr-3"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-label="collapse closed"
|
||||
class="mdi-icon iconInline"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
>
|
||||
<path
|
||||
d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
class="d-flex align-items-start"
|
||||
role="button"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="text-truncate d-flex"
|
||||
>
|
||||
<h3
|
||||
class="h3"
|
||||
>
|
||||
License in
|
||||
<a
|
||||
class="anchorLink mr-3"
|
||||
href="/s"
|
||||
>
|
||||
s
|
||||
</a>
|
||||
</h3>
|
||||
<span
|
||||
class="mr-3"
|
||||
>
|
||||
AccountName
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mb-1"
|
||||
>
|
||||
<h3
|
||||
class="h3 mb-0"
|
||||
>
|
||||
NB (123 users)
|
||||
</h3>
|
||||
</div>
|
||||
<p
|
||||
class="mb-2"
|
||||
>
|
||||
<small
|
||||
class="text-muted"
|
||||
>
|
||||
Created
|
||||
<span
|
||||
class="timestamp"
|
||||
>
|
||||
in almost 14 years
|
||||
</span>
|
||||
</small>
|
||||
</p>
|
||||
<div
|
||||
class=""
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="mdi-icon iconInline mr-1 text-success"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
>
|
||||
<path
|
||||
d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z"
|
||||
/>
|
||||
</svg>
|
||||
Valid,
|
||||
<span
|
||||
class="timestamp"
|
||||
>
|
||||
almost 15 years
|
||||
</span>
|
||||
remaining
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btnDanger ml-auto d-flex justify-content-center align-items-center"
|
||||
type="button"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
<div />
|
||||
<div
|
||||
class="collapse mt-4"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
>
|
||||
License Key ID
|
||||
</label>
|
||||
<p
|
||||
class="ml-3"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
>
|
||||
Key Version
|
||||
</label>
|
||||
<p
|
||||
class="ml-3"
|
||||
>
|
||||
1
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
aria-live="assertive"
|
||||
class="alert alertDanger mb-2"
|
||||
role="alert"
|
||||
>
|
||||
License tags contain unknown values (marked red), please check if the tags are correct.
|
||||
</div>
|
||||
<label
|
||||
class="label w-100"
|
||||
>
|
||||
<p
|
||||
class="mb-2"
|
||||
>
|
||||
Tags
|
||||
</p>
|
||||
<p
|
||||
class="mb-2"
|
||||
>
|
||||
<div
|
||||
class="tagsWrapper"
|
||||
>
|
||||
<span
|
||||
class="badge danger"
|
||||
>
|
||||
a
|
||||
</span>
|
||||
</div>
|
||||
</p>
|
||||
</label>
|
||||
<label
|
||||
class="label w-100"
|
||||
>
|
||||
<p
|
||||
class="mb-2"
|
||||
>
|
||||
License Key
|
||||
</p>
|
||||
<div
|
||||
class="form-inline"
|
||||
>
|
||||
<div
|
||||
class="input-group flex-1"
|
||||
>
|
||||
<div
|
||||
class="container loader-input loaderInput"
|
||||
>
|
||||
<input
|
||||
class="input form-control with-invalid-icon"
|
||||
readonly=""
|
||||
type="text"
|
||||
value="lk1"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="input-group-append flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-label="Copy"
|
||||
class="btn btnSecondary"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="mdi-icon iconInline"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
>
|
||||
<path
|
||||
d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"
|
||||
/>
|
||||
</svg>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`SiteAdminProductLicenseNode > inactive 1`] = `
|
||||
<DocumentFragment>
|
||||
<li
|
||||
class="list-group-item p-3 mb-3 border"
|
||||
>
|
||||
<div
|
||||
style="display: grid; gap: 0rem; grid-template-columns: auto 1fr; margin-bottom: 0rem;"
|
||||
>
|
||||
<button
|
||||
class="btn btnIcon pr-3"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-label="collapse closed"
|
||||
class="mdi-icon iconInline"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
>
|
||||
<path
|
||||
d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
class="d-flex align-items-start"
|
||||
role="button"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="text-truncate d-flex"
|
||||
>
|
||||
<h3
|
||||
class="h3"
|
||||
>
|
||||
License in
|
||||
<a
|
||||
class="anchorLink mr-3"
|
||||
href="/s"
|
||||
>
|
||||
s
|
||||
</a>
|
||||
</h3>
|
||||
<span
|
||||
class="mr-3"
|
||||
>
|
||||
AccountName
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mb-1"
|
||||
>
|
||||
<h3
|
||||
class="h3 mb-0"
|
||||
>
|
||||
NB (123 users)
|
||||
</h3>
|
||||
</div>
|
||||
<p
|
||||
class="mb-2"
|
||||
>
|
||||
<small
|
||||
class="text-muted"
|
||||
>
|
||||
Created
|
||||
<span
|
||||
class="timestamp"
|
||||
>
|
||||
in almost 14 years
|
||||
</span>
|
||||
</small>
|
||||
</p>
|
||||
<div
|
||||
class=""
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="mdi-icon iconInline mr-1 text-success"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
>
|
||||
<path
|
||||
d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z"
|
||||
/>
|
||||
</svg>
|
||||
Valid,
|
||||
<span
|
||||
class="timestamp"
|
||||
>
|
||||
almost 15 years
|
||||
</span>
|
||||
remaining
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btnDanger ml-auto d-flex justify-content-center align-items-center"
|
||||
type="button"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
<div />
|
||||
<div
|
||||
class="collapse mt-4"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
>
|
||||
License Key ID
|
||||
</label>
|
||||
<p
|
||||
class="ml-3"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<label
|
||||
class="label"
|
||||
>
|
||||
Key Version
|
||||
</label>
|
||||
<p
|
||||
class="ml-3"
|
||||
>
|
||||
1
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
aria-live="assertive"
|
||||
class="alert alertDanger mb-2"
|
||||
role="alert"
|
||||
>
|
||||
License tags contain unknown values (marked red), please check if the tags are correct.
|
||||
</div>
|
||||
<label
|
||||
class="label w-100"
|
||||
>
|
||||
<p
|
||||
class="mb-2"
|
||||
>
|
||||
Tags
|
||||
</p>
|
||||
<p
|
||||
class="mb-2"
|
||||
>
|
||||
<div
|
||||
class="tagsWrapper"
|
||||
>
|
||||
<span
|
||||
class="badge danger"
|
||||
>
|
||||
a
|
||||
</span>
|
||||
</div>
|
||||
</p>
|
||||
</label>
|
||||
<label
|
||||
class="label w-100"
|
||||
>
|
||||
<p
|
||||
class="mb-2"
|
||||
>
|
||||
License Key
|
||||
</p>
|
||||
<div
|
||||
class="form-inline"
|
||||
>
|
||||
<div
|
||||
class="input-group flex-1"
|
||||
>
|
||||
<div
|
||||
class="container loader-input loaderInput"
|
||||
>
|
||||
<input
|
||||
class="input form-control with-invalid-icon"
|
||||
readonly=""
|
||||
type="text"
|
||||
value="lk1"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="input-group-append flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-label="Copy"
|
||||
class="btn btnSecondary"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="mdi-icon iconInline"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
>
|
||||
<path
|
||||
d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"
|
||||
/>
|
||||
</svg>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@ -1,275 +0,0 @@
|
||||
import type { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
|
||||
import { queryGraphQL } from '../../../../backend/graphql'
|
||||
import {
|
||||
useShowMorePagination,
|
||||
type UseShowMorePaginationResult,
|
||||
} from '../../../../components/FilteredConnection/hooks/useShowMorePagination'
|
||||
import type {
|
||||
DotComProductLicensesResult,
|
||||
DotComProductLicensesVariables,
|
||||
ProductLicenseFields,
|
||||
ProductLicensesResult,
|
||||
ProductLicensesVariables,
|
||||
ProductSubscriptionsDotComResult,
|
||||
ProductSubscriptionsDotComVariables,
|
||||
} from '../../../../graphql-operations'
|
||||
|
||||
const siteAdminProductSubscriptionFragment = gql`
|
||||
fragment SiteAdminProductSubscriptionFields on ProductSubscription {
|
||||
id
|
||||
name
|
||||
uuid
|
||||
account {
|
||||
id
|
||||
username
|
||||
displayName
|
||||
}
|
||||
activeLicense {
|
||||
id
|
||||
info {
|
||||
productNameWithBrand
|
||||
tags
|
||||
userCount
|
||||
expiresAt
|
||||
}
|
||||
licenseKey
|
||||
createdAt
|
||||
}
|
||||
createdAt
|
||||
isArchived
|
||||
urlForSiteAdmin
|
||||
}
|
||||
`
|
||||
|
||||
export const DOTCOM_PRODUCT_SUBSCRIPTION = gql`
|
||||
query DotComProductSubscription($uuid: String!) {
|
||||
dotcom {
|
||||
productSubscription(uuid: $uuid) {
|
||||
id
|
||||
name
|
||||
account {
|
||||
id
|
||||
username
|
||||
displayName
|
||||
}
|
||||
productLicenses {
|
||||
nodes {
|
||||
id
|
||||
info {
|
||||
tags
|
||||
userCount
|
||||
expiresAt
|
||||
salesforceSubscriptionID
|
||||
salesforceOpportunityID
|
||||
}
|
||||
licenseKey
|
||||
createdAt
|
||||
revokedAt
|
||||
revokeReason
|
||||
siteID
|
||||
version
|
||||
}
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
activeLicense {
|
||||
id
|
||||
info {
|
||||
productNameWithBrand
|
||||
tags
|
||||
userCount
|
||||
expiresAt
|
||||
salesforceSubscriptionID
|
||||
salesforceOpportunityID
|
||||
}
|
||||
licenseKey
|
||||
createdAt
|
||||
}
|
||||
currentSourcegraphAccessToken
|
||||
createdAt
|
||||
isArchived
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ARCHIVE_PRODUCT_SUBSCRIPTION = gql`
|
||||
mutation ArchiveProductSubscription($id: ID!) {
|
||||
dotcom {
|
||||
archiveProductSubscription(id: $id) {
|
||||
alwaysNil
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GENERATE_PRODUCT_LICENSE = gql`
|
||||
mutation GenerateProductLicenseForSubscription($productSubscriptionID: ID!, $license: ProductLicenseInput!) {
|
||||
dotcom {
|
||||
generateProductLicenseForSubscription(productSubscriptionID: $productSubscriptionID, license: $license) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const siteAdminProductLicenseFragment = gql`
|
||||
fragment ProductLicenseFields on ProductLicense {
|
||||
id
|
||||
subscription {
|
||||
id
|
||||
uuid
|
||||
name
|
||||
account {
|
||||
...ProductLicenseSubscriptionAccount
|
||||
}
|
||||
activeLicense {
|
||||
id
|
||||
}
|
||||
urlForSiteAdmin
|
||||
}
|
||||
licenseKey
|
||||
siteID
|
||||
info {
|
||||
...ProductLicenseInfoFields
|
||||
}
|
||||
createdAt
|
||||
revokedAt
|
||||
revokeReason
|
||||
version
|
||||
}
|
||||
|
||||
fragment ProductLicenseInfoFields on ProductLicenseInfo {
|
||||
productNameWithBrand
|
||||
tags
|
||||
userCount
|
||||
expiresAt
|
||||
salesforceSubscriptionID
|
||||
salesforceOpportunityID
|
||||
}
|
||||
|
||||
fragment ProductLicenseSubscriptionAccount on User {
|
||||
id
|
||||
username
|
||||
displayName
|
||||
}
|
||||
`
|
||||
|
||||
export const PRODUCT_LICENSES = gql`
|
||||
query ProductLicenses($first: Int, $subscriptionUUID: String!) {
|
||||
dotcom {
|
||||
productSubscription(uuid: $subscriptionUUID) {
|
||||
productLicenses(first: $first) {
|
||||
nodes {
|
||||
...ProductLicenseFields
|
||||
}
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${siteAdminProductLicenseFragment}
|
||||
`
|
||||
|
||||
export const useProductSubscriptionLicensesConnection = (
|
||||
subscriptionUUID: string
|
||||
): UseShowMorePaginationResult<ProductLicensesResult, ProductLicenseFields> =>
|
||||
useShowMorePagination<ProductLicensesResult, ProductLicensesVariables, ProductLicenseFields>({
|
||||
query: PRODUCT_LICENSES,
|
||||
variables: {
|
||||
subscriptionUUID,
|
||||
},
|
||||
getConnection: result => {
|
||||
const { dotcom } = dataOrThrowErrors(result)
|
||||
return dotcom.productSubscription.productLicenses
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'cache-and-network',
|
||||
},
|
||||
})
|
||||
|
||||
export function queryProductSubscriptions(args: {
|
||||
first?: number | null
|
||||
query?: string
|
||||
}): Observable<ProductSubscriptionsDotComResult['dotcom']['productSubscriptions']> {
|
||||
return queryGraphQL<ProductSubscriptionsDotComResult>(
|
||||
gql`
|
||||
query ProductSubscriptionsDotCom($first: Int, $account: ID, $query: String) {
|
||||
dotcom {
|
||||
productSubscriptions(first: $first, account: $account, query: $query) {
|
||||
nodes {
|
||||
...SiteAdminProductSubscriptionFields
|
||||
}
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${siteAdminProductSubscriptionFragment}
|
||||
`,
|
||||
{
|
||||
first: args.first,
|
||||
query: args.query,
|
||||
} as Partial<ProductSubscriptionsDotComVariables>
|
||||
).pipe(
|
||||
map(dataOrThrowErrors),
|
||||
map(data => data.dotcom.productSubscriptions)
|
||||
)
|
||||
}
|
||||
|
||||
const QUERY_PRODUCT_LICENSES = gql`
|
||||
query DotComProductLicenses($first: Int, $licenseKeySubstring: String) {
|
||||
dotcom {
|
||||
productLicenses(first: $first, licenseKeySubstring: $licenseKeySubstring) {
|
||||
nodes {
|
||||
...ProductLicenseFields
|
||||
}
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${siteAdminProductLicenseFragment}
|
||||
`
|
||||
|
||||
export const useQueryProductLicensesConnection = (
|
||||
licenseKeySubstring: string
|
||||
): UseShowMorePaginationResult<DotComProductLicensesResult, ProductLicenseFields> =>
|
||||
useShowMorePagination<DotComProductLicensesResult, DotComProductLicensesVariables, ProductLicenseFields>({
|
||||
query: QUERY_PRODUCT_LICENSES,
|
||||
variables: {
|
||||
licenseKeySubstring,
|
||||
},
|
||||
getConnection: result => {
|
||||
const { dotcom } = dataOrThrowErrors(result)
|
||||
return dotcom.productLicenses
|
||||
},
|
||||
options: {
|
||||
fetchPolicy: 'cache-and-network',
|
||||
skip: !licenseKeySubstring,
|
||||
},
|
||||
})
|
||||
|
||||
export const REVOKE_LICENSE = gql`
|
||||
mutation RevokeLicense($id: ID!, $reason: String!) {
|
||||
dotcom {
|
||||
revokeLicense(id: $id, reason: $reason) {
|
||||
alwaysNil
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@ -2,7 +2,7 @@ import type { PartialMessage } from '@bufbuild/protobuf'
|
||||
import type { ConnectError, Transport } from '@connectrpc/connect'
|
||||
import { defaultOptions, useMutation, useQuery } from '@connectrpc/connect-query'
|
||||
import { createConnectTransport } from '@connectrpc/connect-web'
|
||||
import { QueryClient, type UseMutationResult, type UseQueryResult } from '@tanstack/react-query'
|
||||
import { QueryClient, type UseMutationResult, type UseQueryResult, keepPreviousData } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
getCodyGatewayAccess,
|
||||
@ -15,6 +15,33 @@ import type {
|
||||
UpdateCodyGatewayAccessRequest,
|
||||
UpdateCodyGatewayAccessResponse,
|
||||
} from './enterpriseportalgen/codyaccess_pb'
|
||||
import {
|
||||
listEnterpriseSubscriptions,
|
||||
listEnterpriseSubscriptionLicenses,
|
||||
revokeEnterpriseSubscriptionLicense,
|
||||
getEnterpriseSubscription,
|
||||
createEnterpriseSubscriptionLicense,
|
||||
createEnterpriseSubscription,
|
||||
archiveEnterpriseSubscription,
|
||||
updateEnterpriseSubscription,
|
||||
} from './enterpriseportalgen/subscriptions-SubscriptionsService_connectquery'
|
||||
import type {
|
||||
ListEnterpriseSubscriptionsResponse,
|
||||
ListEnterpriseSubscriptionsFilter,
|
||||
ListEnterpriseSubscriptionLicensesFilter,
|
||||
ListEnterpriseSubscriptionLicensesResponse,
|
||||
RevokeEnterpriseSubscriptionLicenseResponse,
|
||||
RevokeEnterpriseSubscriptionLicenseRequest,
|
||||
GetEnterpriseSubscriptionResponse,
|
||||
CreateEnterpriseSubscriptionLicenseResponse,
|
||||
CreateEnterpriseSubscriptionLicenseRequest,
|
||||
CreateEnterpriseSubscriptionRequest,
|
||||
CreateEnterpriseSubscriptionResponse,
|
||||
ArchiveEnterpriseSubscriptionResponse,
|
||||
ArchiveEnterpriseSubscriptionRequest,
|
||||
UpdateEnterpriseSubscriptionRequest,
|
||||
UpdateEnterpriseSubscriptionResponse,
|
||||
} from './enterpriseportalgen/subscriptions_pb'
|
||||
|
||||
/**
|
||||
* Use a shared QueryClient defined here and explicitly provided via
|
||||
@ -28,7 +55,17 @@ import type {
|
||||
* Portal gets its own dedicated UI:
|
||||
* https://linear.app/sourcegraph/project/kr-p-enterprise-portal-user-interface-dadd5ff28bd8
|
||||
*/
|
||||
export const queryClient = new QueryClient({ defaultOptions })
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
...defaultOptions,
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Use proxy that routes to a locally running Enterprise Portal at localhost:6081
|
||||
@ -130,3 +167,122 @@ export function useUpdateCodyGatewayAccess(
|
||||
transport: mustGetEnvironment(env),
|
||||
})
|
||||
}
|
||||
|
||||
export function useListEnterpriseSubscriptions(
|
||||
env: EnterprisePortalEnvironment,
|
||||
filters: PartialMessage<ListEnterpriseSubscriptionsFilter>[],
|
||||
options: {
|
||||
limit: number
|
||||
}
|
||||
): UseQueryResult<ListEnterpriseSubscriptionsResponse, ConnectError> {
|
||||
return useQuery(
|
||||
listEnterpriseSubscriptions,
|
||||
{
|
||||
filters,
|
||||
pageSize: options.limit,
|
||||
},
|
||||
{
|
||||
transport: mustGetEnvironment(env),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function useListEnterpriseSubscriptionLicenses(
|
||||
env: EnterprisePortalEnvironment,
|
||||
filters: PartialMessage<ListEnterpriseSubscriptionLicensesFilter>[],
|
||||
options: {
|
||||
limit: number
|
||||
shouldLoad: boolean
|
||||
}
|
||||
): UseQueryResult<ListEnterpriseSubscriptionLicensesResponse, ConnectError> {
|
||||
return useQuery(
|
||||
listEnterpriseSubscriptionLicenses,
|
||||
{
|
||||
filters,
|
||||
},
|
||||
{
|
||||
transport: mustGetEnvironment(env),
|
||||
enabled: options.shouldLoad,
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function useRevokeEnterpriseSubscriptionLicense(
|
||||
env: EnterprisePortalEnvironment
|
||||
): UseMutationResult<
|
||||
RevokeEnterpriseSubscriptionLicenseResponse,
|
||||
ConnectError,
|
||||
PartialMessage<RevokeEnterpriseSubscriptionLicenseRequest>,
|
||||
unknown
|
||||
> {
|
||||
return useMutation(revokeEnterpriseSubscriptionLicense, {
|
||||
transport: mustGetEnvironment(env),
|
||||
})
|
||||
}
|
||||
|
||||
export function useGetEnterpriseSubscription(
|
||||
env: EnterprisePortalEnvironment,
|
||||
subscriptionUUID: string
|
||||
): UseQueryResult<GetEnterpriseSubscriptionResponse, ConnectError> {
|
||||
return useQuery(
|
||||
getEnterpriseSubscription,
|
||||
{
|
||||
query: { value: subscriptionUUID, case: 'id' },
|
||||
},
|
||||
{ transport: mustGetEnvironment(env) }
|
||||
)
|
||||
}
|
||||
|
||||
export function useCreateEnterpriseSubscriptionLicense(
|
||||
env: EnterprisePortalEnvironment
|
||||
): UseMutationResult<
|
||||
CreateEnterpriseSubscriptionLicenseResponse,
|
||||
ConnectError,
|
||||
PartialMessage<CreateEnterpriseSubscriptionLicenseRequest>,
|
||||
unknown
|
||||
> {
|
||||
return useMutation(createEnterpriseSubscriptionLicense, {
|
||||
transport: mustGetEnvironment(env),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateEnterpriseSubscription(
|
||||
env: EnterprisePortalEnvironment
|
||||
): UseMutationResult<
|
||||
CreateEnterpriseSubscriptionResponse,
|
||||
ConnectError,
|
||||
PartialMessage<CreateEnterpriseSubscriptionRequest>,
|
||||
unknown
|
||||
> {
|
||||
return useMutation(createEnterpriseSubscription, {
|
||||
transport: mustGetEnvironment(env),
|
||||
})
|
||||
}
|
||||
|
||||
export function useArchiveEnterpriseSubscription(
|
||||
env: EnterprisePortalEnvironment
|
||||
): UseMutationResult<
|
||||
ArchiveEnterpriseSubscriptionResponse,
|
||||
ConnectError,
|
||||
PartialMessage<ArchiveEnterpriseSubscriptionRequest>,
|
||||
unknown
|
||||
> {
|
||||
return useMutation(archiveEnterpriseSubscription, {
|
||||
transport: mustGetEnvironment(env),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateEnterpriseSubscription(
|
||||
env: EnterprisePortalEnvironment
|
||||
): UseMutationResult<
|
||||
UpdateEnterpriseSubscriptionResponse,
|
||||
ConnectError,
|
||||
PartialMessage<UpdateEnterpriseSubscriptionRequest>,
|
||||
unknown
|
||||
> {
|
||||
return useMutation(updateEnterpriseSubscription, {
|
||||
transport: mustGetEnvironment(env),
|
||||
})
|
||||
}
|
||||
|
||||
@ -0,0 +1,176 @@
|
||||
// @generated by protoc-gen-connect-query v1.4.1 with parameter "target=ts"
|
||||
// @generated from file subscriptions.proto (package enterpriseportal.subscriptions.v1, syntax proto3)
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { MethodIdempotency, MethodKind } from "@bufbuild/protobuf";
|
||||
import { ArchiveEnterpriseSubscriptionRequest, ArchiveEnterpriseSubscriptionResponse, CreateEnterpriseSubscriptionLicenseRequest, CreateEnterpriseSubscriptionLicenseResponse, CreateEnterpriseSubscriptionRequest, CreateEnterpriseSubscriptionResponse, GetEnterpriseSubscriptionRequest, GetEnterpriseSubscriptionResponse, ListEnterpriseSubscriptionLicensesRequest, ListEnterpriseSubscriptionLicensesResponse, ListEnterpriseSubscriptionsRequest, ListEnterpriseSubscriptionsResponse, RevokeEnterpriseSubscriptionLicenseRequest, RevokeEnterpriseSubscriptionLicenseResponse, UpdateEnterpriseSubscriptionMembershipRequest, UpdateEnterpriseSubscriptionMembershipResponse, UpdateEnterpriseSubscriptionRequest, UpdateEnterpriseSubscriptionResponse } from "./subscriptions_pb.js";
|
||||
|
||||
/**
|
||||
* GetEnterpriseSubscription retrieves an exact match on an Enterprise subscription.
|
||||
*
|
||||
* @generated from rpc enterpriseportal.subscriptions.v1.SubscriptionsService.GetEnterpriseSubscription
|
||||
*/
|
||||
export const getEnterpriseSubscription = {
|
||||
localName: "getEnterpriseSubscription",
|
||||
name: "GetEnterpriseSubscription",
|
||||
kind: MethodKind.Unary,
|
||||
I: GetEnterpriseSubscriptionRequest,
|
||||
O: GetEnterpriseSubscriptionResponse,
|
||||
idempotency: MethodIdempotency.NoSideEffects,
|
||||
service: {
|
||||
typeName: "enterpriseportal.subscriptions.v1.SubscriptionsService"
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* ListEnterpriseSubscriptions queries for Enterprise subscriptions.
|
||||
*
|
||||
* @generated from rpc enterpriseportal.subscriptions.v1.SubscriptionsService.ListEnterpriseSubscriptions
|
||||
*/
|
||||
export const listEnterpriseSubscriptions = {
|
||||
localName: "listEnterpriseSubscriptions",
|
||||
name: "ListEnterpriseSubscriptions",
|
||||
kind: MethodKind.Unary,
|
||||
I: ListEnterpriseSubscriptionsRequest,
|
||||
O: ListEnterpriseSubscriptionsResponse,
|
||||
idempotency: MethodIdempotency.NoSideEffects,
|
||||
service: {
|
||||
typeName: "enterpriseportal.subscriptions.v1.SubscriptionsService"
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* ListEnterpriseSubscriptionLicenses queries for licenses associated with
|
||||
* Enterprise subscription licenses, with the ability to list licenses across
|
||||
* all subscriptions, or just a specific subscription.
|
||||
*
|
||||
* Each subscription owns a collection of licenses, typically a series of
|
||||
* licenses with the most recent one being a subscription's active license.
|
||||
*
|
||||
* @generated from rpc enterpriseportal.subscriptions.v1.SubscriptionsService.ListEnterpriseSubscriptionLicenses
|
||||
*/
|
||||
export const listEnterpriseSubscriptionLicenses = {
|
||||
localName: "listEnterpriseSubscriptionLicenses",
|
||||
name: "ListEnterpriseSubscriptionLicenses",
|
||||
kind: MethodKind.Unary,
|
||||
I: ListEnterpriseSubscriptionLicensesRequest,
|
||||
O: ListEnterpriseSubscriptionLicensesResponse,
|
||||
idempotency: MethodIdempotency.NoSideEffects,
|
||||
service: {
|
||||
typeName: "enterpriseportal.subscriptions.v1.SubscriptionsService"
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* CreateEnterpriseSubscription creates license for an Enterprise subscription.
|
||||
*
|
||||
* Not idempotent - we could implement https://google.aip.dev/155 for
|
||||
* optional idempotency in the future.
|
||||
*
|
||||
* @generated from rpc enterpriseportal.subscriptions.v1.SubscriptionsService.CreateEnterpriseSubscriptionLicense
|
||||
*/
|
||||
export const createEnterpriseSubscriptionLicense = {
|
||||
localName: "createEnterpriseSubscriptionLicense",
|
||||
name: "CreateEnterpriseSubscriptionLicense",
|
||||
kind: MethodKind.Unary,
|
||||
I: CreateEnterpriseSubscriptionLicenseRequest,
|
||||
O: CreateEnterpriseSubscriptionLicenseResponse,
|
||||
service: {
|
||||
typeName: "enterpriseportal.subscriptions.v1.SubscriptionsService"
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* RevokeEnterpriseSubscriptionLicense revokes an existing license for an
|
||||
* Enterprise subscription, permanently disabling its use for features
|
||||
* managed by Sourcegraph. Revocation cannot be undone.
|
||||
*
|
||||
* @generated from rpc enterpriseportal.subscriptions.v1.SubscriptionsService.RevokeEnterpriseSubscriptionLicense
|
||||
*/
|
||||
export const revokeEnterpriseSubscriptionLicense = {
|
||||
localName: "revokeEnterpriseSubscriptionLicense",
|
||||
name: "RevokeEnterpriseSubscriptionLicense",
|
||||
kind: MethodKind.Unary,
|
||||
I: RevokeEnterpriseSubscriptionLicenseRequest,
|
||||
O: RevokeEnterpriseSubscriptionLicenseResponse,
|
||||
idempotency: MethodIdempotency.Idempotent,
|
||||
service: {
|
||||
typeName: "enterpriseportal.subscriptions.v1.SubscriptionsService"
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* UpdateEnterpriseSubscription updates an existing enterprise subscription.
|
||||
*
|
||||
* @generated from rpc enterpriseportal.subscriptions.v1.SubscriptionsService.UpdateEnterpriseSubscription
|
||||
*/
|
||||
export const updateEnterpriseSubscription = {
|
||||
localName: "updateEnterpriseSubscription",
|
||||
name: "UpdateEnterpriseSubscription",
|
||||
kind: MethodKind.Unary,
|
||||
I: UpdateEnterpriseSubscriptionRequest,
|
||||
O: UpdateEnterpriseSubscriptionResponse,
|
||||
idempotency: MethodIdempotency.Idempotent,
|
||||
service: {
|
||||
typeName: "enterpriseportal.subscriptions.v1.SubscriptionsService"
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* ArchiveEnterpriseSubscriptionRequest archives an existing Enterprise
|
||||
* subscription. This is a permanent operation, and cannot be undone.
|
||||
*
|
||||
* Archiving a subscription also immediately and permanently revokes all
|
||||
* associated licenses.
|
||||
*
|
||||
* @generated from rpc enterpriseportal.subscriptions.v1.SubscriptionsService.ArchiveEnterpriseSubscription
|
||||
*/
|
||||
export const archiveEnterpriseSubscription = {
|
||||
localName: "archiveEnterpriseSubscription",
|
||||
name: "ArchiveEnterpriseSubscription",
|
||||
kind: MethodKind.Unary,
|
||||
I: ArchiveEnterpriseSubscriptionRequest,
|
||||
O: ArchiveEnterpriseSubscriptionResponse,
|
||||
idempotency: MethodIdempotency.Idempotent,
|
||||
service: {
|
||||
typeName: "enterpriseportal.subscriptions.v1.SubscriptionsService"
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* CreateEnterpriseSubscription creates an Enterprise subscription.
|
||||
*
|
||||
* Not idempotent - we could implement https://google.aip.dev/155 for
|
||||
* optional idempotency in the future.
|
||||
*
|
||||
* @generated from rpc enterpriseportal.subscriptions.v1.SubscriptionsService.CreateEnterpriseSubscription
|
||||
*/
|
||||
export const createEnterpriseSubscription = {
|
||||
localName: "createEnterpriseSubscription",
|
||||
name: "CreateEnterpriseSubscription",
|
||||
kind: MethodKind.Unary,
|
||||
I: CreateEnterpriseSubscriptionRequest,
|
||||
O: CreateEnterpriseSubscriptionResponse,
|
||||
service: {
|
||||
typeName: "enterpriseportal.subscriptions.v1.SubscriptionsService"
|
||||
}
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* UpdateEnterpriseSubscriptionMembership updates an enterprise subscription
|
||||
* membership in an authoritative manner.
|
||||
*
|
||||
* @generated from rpc enterpriseportal.subscriptions.v1.SubscriptionsService.UpdateEnterpriseSubscriptionMembership
|
||||
*/
|
||||
export const updateEnterpriseSubscriptionMembership = {
|
||||
localName: "updateEnterpriseSubscriptionMembership",
|
||||
name: "UpdateEnterpriseSubscriptionMembership",
|
||||
kind: MethodKind.Unary,
|
||||
I: UpdateEnterpriseSubscriptionMembershipRequest,
|
||||
O: UpdateEnterpriseSubscriptionMembershipResponse,
|
||||
idempotency: MethodIdempotency.Idempotent,
|
||||
service: {
|
||||
typeName: "enterpriseportal.subscriptions.v1.SubscriptionsService"
|
||||
}
|
||||
} as const;
|
||||
File diff suppressed because it is too large
Load Diff
@ -40,11 +40,3 @@ export function errorForPath(error: ApolloError | undefined, path: (string | num
|
||||
}
|
||||
|
||||
export const numberFormatter = new Intl.NumberFormat()
|
||||
|
||||
/**
|
||||
* Prefixes the ID with subscriptionsv1.EnterpriseSubscriptionIDPrefix to get
|
||||
* the Enterprise Portal external subscription UUID format.
|
||||
*/
|
||||
export function enterprisePortalID(id: string): string {
|
||||
return `es_${id}`
|
||||
}
|
||||
|
||||
@ -114,10 +114,6 @@ const SiteAdminProductSubscriptionPage = lazyComponent(
|
||||
() => import('../enterprise/site-admin/productSubscription/SiteAdminProductSubscriptionPage'),
|
||||
'SiteAdminProductSubscriptionPage'
|
||||
)
|
||||
const SiteAdminProductCustomersPage = lazyComponent(
|
||||
() => import('../enterprise/site-admin/dotcom/customers/SiteAdminCustomersPage'),
|
||||
'SiteAdminProductCustomersPage'
|
||||
)
|
||||
const SiteAdminCreateProductSubscriptionPage = lazyComponent(
|
||||
() => import('../enterprise/site-admin/dotcom/productSubscriptions/SiteAdminCreateProductSubscriptionPage'),
|
||||
'SiteAdminCreateProductSubscriptionPage'
|
||||
@ -367,11 +363,6 @@ export const otherSiteAdminRoutes: readonly SiteAdminAreaRoute[] = [
|
||||
<SiteAdminProductSubscriptionPage telemetryRecorder={props.platformContext.telemetryRecorder} />
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/dotcom/customers',
|
||||
render: props => <SiteAdminProductCustomersPage telemetryRecorder={props.platformContext.telemetryRecorder} />,
|
||||
condition: () => SHOW_BUSINESS_FEATURES,
|
||||
},
|
||||
{
|
||||
path: '/dotcom/product/subscriptions/new',
|
||||
render: props => <SiteAdminCreateProductSubscriptionPage {...props} />,
|
||||
|
||||
@ -234,11 +234,6 @@ export const batchChangesGroup: SiteAdminSideBarGroup = {
|
||||
const businessGroup: SiteAdminSideBarGroup = {
|
||||
header: { label: 'Business', icon: BriefcaseIcon },
|
||||
items: [
|
||||
{
|
||||
label: 'Enterprise customers',
|
||||
to: '/site-admin/dotcom/customers',
|
||||
condition: () => SHOW_BUSINESS_FEATURES,
|
||||
},
|
||||
{
|
||||
label: 'Enterprise subscriptions',
|
||||
to: '/site-admin/dotcom/product/subscriptions',
|
||||
|
||||
@ -71,7 +71,7 @@ func NewPeriodicImporter(
|
||||
interval time.Duration,
|
||||
) background.Routine {
|
||||
if interval == 0 {
|
||||
logger.Warn("importer disabled")
|
||||
logger.Info("importer disabled")
|
||||
return background.NoopRoutine("dotcom.importer.disabled")
|
||||
}
|
||||
return goroutine.NewPeriodicGoroutine(
|
||||
|
||||
@ -65,7 +65,7 @@ func (c *Config) Load(env *runtime.Env) {
|
||||
"For local dev: custom PostgreSQL DSN, overrides DOTCOM_CLOUDSQL_* options")
|
||||
c.DotComDB.IncludeProductionLicenses = env.GetBool("DOTCOM_INCLUDE_PRODUCTION_LICENSES", "false",
|
||||
"Include production licenses in API results")
|
||||
c.DotComDB.ImportInterval = env.GetInterval("DOTCOM_IMPORT_INTERVAL", "10m",
|
||||
c.DotComDB.ImportInterval = env.GetInterval("DOTCOM_IMPORT_INTERVAL", "0s", // disable by default
|
||||
"Interval at which to import data from Sourcegraph.com")
|
||||
|
||||
c.SAMS.ConnConfig = sams.NewConnConfigFromEnv(env)
|
||||
|
||||
@ -15,3 +15,11 @@ plugins:
|
||||
out: .
|
||||
opt:
|
||||
- paths=source_relative
|
||||
|
||||
# Generate connectrpc bindings in Typescript: https://buf.build/connectrpc/query-es?version=v1.4.1
|
||||
- plugin: buf.build/connectrpc/query-es:v1.4.1
|
||||
out: ../../../../client/web/src/enterprise/site-admin/dotcom/productSubscriptions/enterpriseportalgen
|
||||
opt: target=ts
|
||||
- plugin: buf.build/bufbuild/es:v1.10.0
|
||||
out: ../../../../client/web/src/enterprise/site-admin/dotcom/productSubscriptions/enterpriseportalgen
|
||||
opt: target=ts
|
||||
|
||||
Loading…
Reference in New Issue
Block a user