SSC: Refactor team management (#62694)

This commit is contained in:
David Veszelovszki 2024-05-16 14:46:12 +02:00 committed by GitHub
parent d1404951eb
commit aae97ad078
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 124 additions and 196 deletions

View File

@ -247,10 +247,11 @@ ts_project(
"src/cody/sidebar/useSidebarSize.tsx",
"src/cody/subscription/CodySubscriptionPage.tsx",
"src/cody/subscription/queries.tsx",
"src/cody/subscription/subscriptionSummary.ts",
"src/cody/switch-account/CodySwitchAccountPage.tsx",
"src/cody/team/CodyManageTeamPage.tsx",
"src/cody/team/InviteUsers.tsx",
"src/cody/team/TeamMembers.tsx",
"src/cody/team/TeamMemberList.tsx",
"src/cody/team/WhiteIcon.tsx",
"src/cody/upsell/ChatBrandIcon.tsx",
"src/cody/upsell/CodyUpsellPage.tsx",

View File

@ -6,6 +6,8 @@ import { useSearchParams } from 'react-router-dom'
import { H3, Text } from '@sourcegraph/wildcard'
import { requestSSC } from '../../../util'
/**
* CodyProCheckoutForm is essentially an iframe that the Stripe Elements library will
* render an iframe into, that will host a Stripe Checkout-hosted form.
@ -71,30 +73,19 @@ async function createCheckoutSession(
const origin = window.location.origin
try {
// So the request is kinda made to 2x backends. dotcom's .api/ssc/proxy endpoint will
// take care of exchanging the Sourcegraph session credentials for a SAMS access token.
// And then proxy the request onto the SSC backend, which will actually create the
// checkout session.
// TODO: Use fetchThroughSSCProxy instead of fetch.
const response = await fetch(`${origin}/.api/ssc/proxy/checkout/session`, {
// Pass along the "sgs" session cookie to identify the caller.
credentials: 'same-origin',
method: 'POST',
// Object sent to the backend. See `createCheckoutSessionRequest`.
body: JSON.stringify({
interval: billingInterval,
seats: 1,
customerEmail,
showPromoCodeField,
const response = await requestSSC('/checkout/session', 'POST', {
interval: billingInterval,
seats: 1,
customerEmail,
showPromoCodeField,
// URL the user is redirected to when the checkout process is complete.
//
// BUG: Due to the race conditions between Stripe, the SSC backend,
// and Sourcegraph.com, immediately loading the Dashboard page isn't
// going to show the right data reliably. We will need to instead show
// some interstitial or welcome prompt, to give various things to sync.
returnUrl: `${origin}/cody/manage?session_id={CHECKOUT_SESSION_ID}`,
}),
// URL the user is redirected to when the checkout process is complete.
//
// BUG: Due to the race conditions between Stripe, the SSC backend,
// and Sourcegraph.com, immediately loading the Dashboard page isn't
// going to show the right data reliably. We will need to instead show
// some interstitial or welcome prompt, to give various things to sync.
returnUrl: `${origin}/cody/manage?session_id={CHECKOUT_SESSION_ID}`,
})
const responseBody = await response.text()

View File

@ -0,0 +1,11 @@
import { useSSCQuery } from '../util'
type TeamRole = 'member' | 'admin'
interface CodySubscriptionSummary {
teamId: string
userRole: TeamRole
}
export const useCodySubscriptionSummaryData = (): [CodySubscriptionSummary | null, Error | null] =>
useSSCQuery<CodySubscriptionSummary>('/team/current/subscription/summary')

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react'
import React, { useEffect, useMemo } from 'react'
import { mdiPlusThick, mdiOpenInNew } from '@mdi/js'
import classNames from 'classnames'
@ -11,10 +11,11 @@ import type { AuthenticatedUser } from '../../auth'
import { withAuthenticatedUser } from '../../auth/withAuthenticatedUser'
import { Page } from '../../components/Page'
import { PageTitle } from '../../components/PageTitle'
import { fetchThroughSSCProxy } from '../util'
import { useCodySubscriptionSummaryData } from '../subscription/subscriptionSummary'
import { useSSCQuery } from '../util'
import { InviteUsers } from './InviteUsers'
import { type TeamInvite, TeamMemberList, type TeamMember } from './TeamMembers'
import { TeamMemberList, type TeamMember, type TeamInvite } from './TeamMemberList'
import { WhiteIcon } from './WhiteIcon'
import styles from './CodyManageTeamPage.module.scss'
@ -23,51 +24,14 @@ interface CodyManageTeamPageProps extends TelemetryV2Props {
authenticatedUser: AuthenticatedUser
}
// TODO: Remove this mock data
const mockTeamMembers: TeamMember[] = [
{
accountId: '1',
displayName: 'daniel.marques.pt',
email: 'daniel.marques@sourcegraph.com',
avatarUrl: null,
role: 'member',
},
]
type CodySubscriptionStatus = 'active' | 'past_due' | 'unpaid' | 'canceled' | 'trialing' | 'other'
const mockInvites: TeamInvite[] = [
{
id: '1',
email: 'rob.rhyne@sourcegraph.com',
role: 'member',
status: 'sent',
error: null,
sentAt: '2021-09-01T00:00:00Z',
acceptedAt: null,
},
{
id: '2',
email: 'kevin.chen@sourcegraph.com',
role: 'admin',
status: 'sent',
error: null,
sentAt: '2021-09-01T00:00:00Z',
acceptedAt: null,
},
{
id: '3',
email: 'test@test.com',
role: 'member',
status: 'accepted',
error: null,
sentAt: '2021-09-01T00:00:00Z',
acceptedAt: '2021-09-01T00:00:00Z',
},
]
interface CodySubscription {
subscriptionStatus: CodySubscriptionStatus
maxSeats: number
}
const AuthenticatedCodyManageTeamPage: React.FunctionComponent<CodyManageTeamPageProps> = ({
authenticatedUser,
telemetryRecorder,
}) => {
const AuthenticatedCodyManageTeamPage: React.FunctionComponent<CodyManageTeamPageProps> = ({ telemetryRecorder }) => {
useEffect(() => {
telemetryRecorder.recordEvent('cody.team.management', 'view')
}, [telemetryRecorder])
@ -80,89 +44,31 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent<CodyManageTeamPag
const newSeatsPurchased: number | null = newSeatsPurchasedParam ? parseInt(newSeatsPurchasedParam, 10) : null
// Load data
const [subscriptionData, setSubscriptionData] = useState<{
subscriptionSeatCount: number | null
isProUser: boolean | null
} | null>(null)
const subscriptionSeatCount = subscriptionData?.subscriptionSeatCount
const isProUser = subscriptionData?.isProUser
const [subscriptionDataError, setSubscriptionDataError] = useState<null | Error>(null)
const [subscriptionSummaryData, setSubscriptionSummaryData] = useState<{
teamId: string | null
isAdmin: boolean | null
} | null>(null)
const [subscriptionSummaryDataError, setSubscriptionSummaryDataError] = useState<null | Error>(null)
const [teamMembers, setTeamMembers] = useState<TeamMember[] | null>(null)
const [membersDataError, setMembersDataError] = useState<null | Error>(null)
const [teamInvites, setTeamInvites] = useState<TeamInvite[] | null>(null)
const [invitesDataError, setInvitesDataError] = useState<null | Error>(null)
useEffect(() => {
async function loadSubscriptionData(): Promise<void> {
try {
const response = await fetchThroughSSCProxy('/team/current/subscription', 'GET')
const responseJson = (await response.json()) as {
subscriptionStatus: 'active' | 'past_due' | 'unpaid' | 'canceled' | 'trialing' | 'other'
maxSeats: number
} | null
setSubscriptionData({
subscriptionSeatCount: responseJson?.maxSeats ?? null,
isProUser: responseJson && responseJson.subscriptionStatus !== 'canceled',
})
} catch (error) {
setSubscriptionDataError(error)
}
}
async function loadSubscriptionSummaryData(): Promise<void> {
try {
const response = await fetchThroughSSCProxy('/team/current/subscription/summary', 'GET')
const responseJson = (await response.json()) as {
teamId: string
userRole: 'none' | 'member' | 'admin'
} | null
setSubscriptionSummaryData({
teamId: responseJson?.teamId ?? null,
isAdmin: responseJson && responseJson.userRole === 'admin',
})
} catch (error) {
setSubscriptionSummaryDataError(error)
}
}
async function loadMemberData(): Promise<void> {
try {
const response = await fetchThroughSSCProxy('/team/current/members', 'GET')
const responseJson = await response.json()
setTeamMembers((responseJson as { members: TeamMember[] }).members.concat(mockTeamMembers))
} catch (error) {
setMembersDataError(error)
}
}
async function loadInviteData(): Promise<void> {
try {
const response = await fetchThroughSSCProxy('/team/current/invites', 'GET')
const responseJson = await response.json()
setTeamInvites((responseJson as { invites: TeamInvite[] }).invites.concat(mockInvites))
} catch (error) {
setInvitesDataError(error)
}
}
void loadSubscriptionData()
void loadSubscriptionSummaryData()
void loadMemberData()
void loadInviteData()
}, [authenticatedUser])
const [codySubscription, codySubscriptionError] = useSSCQuery<CodySubscription>('/team/current/subscription')
const isPro = codySubscription?.subscriptionStatus !== 'canceled'
const [codySubscriptionSummary, codySubscriptionSummaryError] = useCodySubscriptionSummaryData()
const isAdmin = codySubscriptionSummary?.userRole === 'admin'
const [memberResponse, membersDataError] = useSSCQuery<{ members: TeamMember[] }>('/team/current/members')
const teamMembers = memberResponse?.members
const [invitesResponse, invitesDataError] = useSSCQuery<{ invites: TeamInvite[] }>('/team/current/invites')
const teamInvites = invitesResponse?.invites
const errorMessage =
codySubscriptionError?.message ||
codySubscriptionSummaryError?.message ||
membersDataError?.message ||
invitesDataError?.message
useEffect(() => {
if (isProUser === false) {
if (!isPro) {
navigate('/cody/subscription')
}
}, [isProUser, navigate])
}, [isPro, navigate])
const remainingInviteCount = useMemo(() => {
const memberCount = teamMembers?.length ?? 0
const invitesUsed = (teamInvites ?? []).filter(invite => invite.status === 'sent').length
return Math.max((subscriptionSeatCount ?? 0) - (memberCount + invitesUsed), 0)
}, [subscriptionSeatCount, teamMembers, teamInvites])
return Math.max((codySubscription?.maxSeats ?? 0) - (memberCount + invitesUsed), 0)
}, [codySubscription?.maxSeats, teamMembers, teamInvites])
return (
<>
@ -171,14 +77,16 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent<CodyManageTeamPag
<PageHeader
className="mb-4 mt-4"
actions={
subscriptionSummaryData?.isAdmin && (
codySubscriptionSummary?.userRole === 'admin' && (
<div className="d-flex">
<Link
to="/cody/manage"
className="d-inline-flex align-items-center mr-3"
onClick={() =>
telemetryRecorder.recordEvent('cody.team.manage.subscription', 'click', {
metadata: { tier: isProUser ? 1 : 0 },
metadata: {
tier: codySubscription?.subscriptionStatus !== 'canceled' ? 1 : 0,
},
})
}
>
@ -211,27 +119,12 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent<CodyManageTeamPag
</PageHeader.Heading>
</PageHeader>
{subscriptionDataError || subscriptionSummaryDataError || membersDataError || invitesDataError ? (
{codySubscriptionError || codySubscriptionSummaryError || membersDataError || invitesDataError ? (
<div className={classNames('mb-4', styles.alert, styles.errorAlert)}>
<H3>Failed to load team data.</H3>
{subscriptionDataError?.message && (
<H3>We couldn't load team data this time. Please try a bit later.</H3>
{errorMessage ?? (
<Text size="small" className="text-muted mb-0">
{subscriptionDataError?.message}
</Text>
)}
{subscriptionSummaryDataError?.message && (
<Text size="small" className="text-muted mb-0">
{subscriptionDataError?.message}
</Text>
)}
{membersDataError?.message && (
<Text size="small" className="text-muted mb-0">
{membersDataError?.message}
</Text>
)}
{invitesDataError?.message && (
<Text size="small" className="text-muted mb-0">
{invitesDataError?.message}
{errorMessage}
</Text>
)}
</div>
@ -246,18 +139,18 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent<CodyManageTeamPag
</div>
)}
{subscriptionSummaryData?.isAdmin && !!remainingInviteCount && (
{isAdmin && !!remainingInviteCount && (
<InviteUsers
teamId={subscriptionSummaryData?.teamId}
teamId={codySubscriptionSummary?.teamId}
remainingInviteCount={remainingInviteCount}
telemetryRecorder={telemetryRecorder}
/>
)}
<TeamMemberList
teamId={subscriptionSummaryData?.teamId ?? null}
teamId={codySubscriptionSummary?.teamId ?? null}
teamMembers={teamMembers || []}
invites={teamInvites || []}
isAdmin={subscriptionSummaryData?.isAdmin ?? false}
isAdmin={isAdmin}
telemetryRecorder={telemetryRecorder}
/>
</Page>

View File

@ -6,7 +6,7 @@ import { pluralize } from '@sourcegraph/common'
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import { ButtonLink, H2, Link, Text, H3, TextArea } from '@sourcegraph/wildcard'
import { isValidEmailAddress, fetchThroughSSCProxy } from '../util'
import { isValidEmailAddress, requestSSC } from '../util'
import styles from './CodyManageTeamPage.module.scss'
@ -43,7 +43,7 @@ export const InviteUsers: React.FunctionComponent<InviteUsersProps> = ({
try {
const responses = await Promise.all(
emailAddresses.map(emailAddress =>
fetchThroughSSCProxy('/team/current/invites', 'POST', { email: emailAddress, role: 'member' })
requestSSC('/team/current/invites', 'POST', { email: emailAddress, role: 'member' })
)
)
if (responses.some(response => response.status !== 200)) {

View File

@ -5,7 +5,7 @@ import classNames from 'classnames'
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import { H2, Text, Badge, Link, ButtonLink } from '@sourcegraph/wildcard'
import { fetchThroughSSCProxy } from '../util'
import { requestSSC } from '../util'
import styles from './CodyManageTeamPage.module.scss'
@ -17,6 +17,13 @@ export interface TeamMember {
role: 'admin' | 'member'
}
interface TeamMemberListProps extends TelemetryV2Props {
teamId: string | null
teamMembers: TeamMember[]
invites: TeamInvite[]
isAdmin: boolean
}
export interface TeamInvite {
id: string
email: string
@ -27,13 +34,6 @@ export interface TeamInvite {
acceptedAt: string | null
}
interface TeamMemberListProps extends TelemetryV2Props {
teamId: string | null
teamMembers: TeamMember[]
invites: TeamInvite[]
isAdmin: boolean
}
export const TeamMemberList: FunctionComponent<TeamMemberListProps> = ({
teamId,
teamMembers,
@ -43,7 +43,7 @@ export const TeamMemberList: FunctionComponent<TeamMemberListProps> = ({
}) => {
const [loading, setLoading] = useState(false)
const [actionResult, setActionResult] = useState<{ message: string; isError: boolean } | null>(null)
const setRole = useCallback(
const updateRole = useCallback(
async (accountId: string, newRole: 'member' | 'admin'): Promise<void> => {
if (!loading) {
// Avoids sending multiple requests at once
@ -52,19 +52,24 @@ export const TeamMemberList: FunctionComponent<TeamMemberListProps> = ({
privateMetadata: { teamId, accountId },
})
const response = await fetchThroughSSCProxy(
`/team/current/members/${accountId}?newRole=${newRole}`,
'PATCH'
)
if (!response.ok) {
try {
const response = await requestSSC(`/team/current/members/${accountId}?newRole=${newRole}`, 'PATCH')
if (!response.ok) {
setLoading(false)
setActionResult({
message: `We couldn't modify the user's role (${response.status}). Please try again later.`,
isError: true,
})
} else {
setLoading(false)
setActionResult({ message: 'Team role updated.', isError: false })
}
} catch (error) {
setLoading(false)
setActionResult({
message: `We couldn't modify the user's role (${response.status}). Please try again later.`,
message: `We couldn't modify the user's role. The error was: "${error}". Please try again later.`,
isError: true,
})
} else {
setLoading(false)
setActionResult({ message: 'Team role updated.', isError: false })
}
}
},
@ -78,7 +83,7 @@ export const TeamMemberList: FunctionComponent<TeamMemberListProps> = ({
setLoading(true)
telemetryRecorder.recordEvent('cody.team.revokeInvite', 'click', { privateMetadata: { teamId } })
const response = await fetchThroughSSCProxy(`/team/current/invites/${inviteId}/cancel`, 'POST')
const response = await requestSSC(`/team/current/invites/${inviteId}/cancel`, 'POST')
if (!response.ok) {
setLoading(false)
setActionResult({
@ -101,7 +106,7 @@ export const TeamMemberList: FunctionComponent<TeamMemberListProps> = ({
setLoading(true)
telemetryRecorder.recordEvent('cody.team.revokeInvite', 'click', { privateMetadata: { teamId } })
const response = await fetchThroughSSCProxy(`/team/current/invites/${inviteId}/resend`, 'POST')
const response = await requestSSC(`/team/current/invites/${inviteId}/resend`, 'POST')
if (!response.ok) {
setLoading(false)
setActionResult({
@ -127,7 +132,7 @@ export const TeamMemberList: FunctionComponent<TeamMemberListProps> = ({
setLoading(true)
telemetryRecorder.recordEvent('cody.team.revokeInvite', 'click', { privateMetadata: { teamId } })
const response = await fetchThroughSSCProxy(`/team/current/members/${accountId}`, 'DELETE')
const response = await requestSSC(`/team/current/members/${accountId}`, 'DELETE')
if (!response.ok) {
setLoading(false)
setActionResult({
@ -196,7 +201,7 @@ export const TeamMemberList: FunctionComponent<TeamMemberListProps> = ({
<div className="d-flex flex-column justify-content-center ml-2">
<Link
to="#"
onClick={() => setRole(member.accountId, 'member')}
onClick={() => updateRole(member.accountId, 'member')}
className="ml-2"
aria-disabled={adminCount < 2}
>
@ -208,7 +213,7 @@ export const TeamMemberList: FunctionComponent<TeamMemberListProps> = ({
<div className="d-flex flex-column justify-content-center ml-2">
<Link
to="#"
onClick={() => setRole(member.accountId, 'admin')}
onClick={() => updateRole(member.accountId, 'admin')}
className="ml-2"
>
Make admin

View File

@ -1,4 +1,6 @@
// The URL to direct users in order to manage their Cody Pro subscription.
import { useState, useEffect } from 'react'
export const manageSubscriptionRedirectURL = 'https://accounts.sourcegraph.com/cody/subscription'
/**
@ -34,11 +36,14 @@ export function isValidEmailAddress(emailAddress: string): boolean {
const emailRegex = /^[^@]+@[^@]+\.[^@]+$/
/**
* So the request is kinda made to two backends. Dotcom's `.api/ssc/proxy` endpoint
* exchanges the Sourcegraph session credentials for a SAMS access token
* and then proxy the request to the SSC backend.
* @param sscUrl The SSC API URL to call. Example: "/checkout/session".
* @param method E.g. "POST".
* @param params The body to send to the SSC API. Will be JSON-encoded.
*/
export function fetchThroughSSCProxy(sscUrl: string, method: string, params?: object): Promise<Response> {
export function requestSSC(sscUrl: string, method: string, params?: object): Promise<Response> {
// /.api/ssc/proxy endpoint exchanges the Sourcegraph session credentials for a SAMS access token.
// And then proxy the request onto the SSC backend, which will actually create the
// checkout session.
@ -53,3 +58,25 @@ export function fetchThroughSSCProxy(sscUrl: string, method: string, params?: ob
...(!['GET', 'HEAD'].includes(method) && params ? { body: JSON.stringify(params) } : null),
})
}
// React hook to fetch data through the SSC proxy and convert the response to a more usable format.
// This is a low-level hook that is meant to be used by other hooks that need to fetch data from the SSC API.
export const useSSCQuery = <T extends object>(endpoint: string): [T | null, Error | null] => {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
async function loadData(): Promise<void> {
try {
const response = await requestSSC(endpoint, 'GET')
const responseJson = await response.json()
setData(responseJson)
} catch (error) {
setError(error)
}
}
void loadData()
}, [endpoint])
return [data, error]
}