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


![image](https://github.com/user-attachments/assets/542c5b64-5729-4a6a-91b2-e0373abb35fa)

![image](https://github.com/user-attachments/assets/9cc41a80-2ff1-4845-a290-823df242cd2e)

![image](https://github.com/user-attachments/assets/47c9a400-d091-416f-a85c-f1dcc93d880d)

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:
Robert Lin 2024-08-14 08:32:22 -07:00 committed by GitHub
parent fef7af964b
commit 6e828b0a14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 3846 additions and 1802 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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} /> &mdash;{' '}
<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>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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