feat(plg): Use react-query for team management (#63267)

This commit is contained in:
David Veszelovszki 2024-06-19 19:05:14 +02:00 committed by GitHub
parent db7a268c34
commit 76a1c65d8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 272 additions and 294 deletions

View File

@ -279,7 +279,6 @@ 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",

View File

@ -25,9 +25,9 @@ import { AcceptInviteBanner } from '../invites/AcceptInviteBanner'
import { isCodyEnabled } from '../isCodyEnabled'
import { CodyOnboarding, type IEditor } from '../onboarding/CodyOnboarding'
import { USER_CODY_PLAN, USER_CODY_USAGE } from '../subscription/queries'
import { useCodySubscriptionSummaryData } from '../subscription/subscriptionSummary'
import { getManageSubscriptionPageURL } from '../util'
import { useSubscriptionSummary } from './api/react-query/subscriptions'
import { SubscriptionStats } from './SubscriptionStats'
import { UseCodyInEditorSection } from './UseCodyInEditorSection'
@ -74,8 +74,8 @@ export const CodyManagementPage: React.FunctionComponent<CodyManagementPageProps
{}
)
const [codySubscriptionSummary] = useCodySubscriptionSummaryData()
const isAdmin = codySubscriptionSummary?.userRole === 'admin'
const subscriptionSummaryQueryResult = useSubscriptionSummary()
const isAdmin = subscriptionSummaryQueryResult?.data?.userRole === 'admin'
const [selectedEditor, setSelectedEditor] = React.useState<IEditor | null>(null)
const [selectedEditorStep, setSelectedEditorStep] = React.useState<EditorStep | null>(null)

View File

@ -52,12 +52,28 @@ export module Client {
return { method: 'GET', urlSuffix: '/team/current/members' }
}
export function updateTeamMember(requestBody: types.UpdateTeamMembersRequest): Call<unknown> {
return { method: 'PATCH', urlSuffix: '/team/current/members', requestBody }
}
// Invites
export function getInvite(teamId: string, inviteId: string): Call<types.TeamInvite> {
return { method: 'GET', urlSuffix: `/team/${teamId}/invites/${inviteId}` }
}
export function getTeamInvites(): Call<types.ListTeamInvitesResponse> {
return { method: 'GET', urlSuffix: '/team/current/invites' }
}
export function sendInvite(requestBody: types.CreateTeamInviteRequest): Call<types.ListTeamInvitesResponse> {
return { method: 'POST', urlSuffix: '/team/current/invites', requestBody }
}
export function resendInvite(inviteId: string): Call<unknown> {
return { method: 'POST', urlSuffix: `/team/current/invites/${inviteId}/resend` }
}
export function acceptInvite(teamId: string, inviteId: string): Call<unknown> {
return { method: 'POST', urlSuffix: `/team/${teamId}/invites/${inviteId}/accept` }
}
@ -76,7 +92,7 @@ export module Client {
}
// Call is the bundle of data necessary for making an API request.
// This is a sort of "meta request" in the same veign as the `gql`
// This is a sort of "meta request" in the same vein as the `gql`
// template tag, see: https://github.com/apollographql/graphql-tag
export interface Call<Resp> {
method: 'GET' | 'POST' | 'PATCH' | 'DELETE'

View File

@ -7,7 +7,7 @@ import {
} from '@tanstack/react-query'
import { Client } from '../client'
import type { TeamInvite } from '../teamInvites'
import type { TeamInvite, ListTeamInvitesResponse, CreateTeamInviteRequest } from '../types'
import { callCodyProApi } from './callCodyProApi'
import { queryKeys } from './queryKeys'
@ -27,6 +27,36 @@ export const useInvite = ({
},
})
export const useTeamInvites = (): UseQueryResult<Omit<TeamInvite, 'sentBy'>[] | undefined> =>
useQuery({
queryKey: queryKeys.invites.teamInvites(),
queryFn: async () => {
const response = await callCodyProApi(Client.getTeamInvites())
return ((await response.json()) as ListTeamInvitesResponse).invites
},
})
export const useSendInvite = (): UseMutationResult<Omit<TeamInvite, 'sentBy'>, Error, CreateTeamInviteRequest> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async requestBody => (await callCodyProApi(Client.sendInvite(requestBody))).json(),
onSuccess: (newInvite: Omit<TeamInvite, 'sentBy'>) => {
queryClient.setQueryData(queryKeys.invites.teamInvites(), (prevInvites: Omit<TeamInvite, 'sentBy'>[]) => [
...prevInvites,
newInvite,
])
},
})
}
export const useResendInvite = (): UseMutationResult<unknown, Error, { inviteId: string }> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ inviteId }) => callCodyProApi(Client.resendInvite(inviteId)),
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.invites.teamInvites() }),
})
}
export const useAcceptInvite = (): UseMutationResult<unknown, Error, { teamId: string; inviteId: string }> => {
const queryClient = useQueryClient()
return useMutation({
@ -44,7 +74,9 @@ export const useCancelInvite = (): UseMutationResult<unknown, Error, { teamId: s
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ teamId, inviteId }) => callCodyProApi(Client.cancelInvite(teamId, inviteId)),
onSuccess: (_, { teamId, inviteId }) =>
queryClient.invalidateQueries({ queryKey: queryKeys.invites.invite(teamId, inviteId) }),
onSuccess: (_, { inviteId }) =>
queryClient.setQueryData(queryKeys.invites.teamInvites(), (prevInvites: TeamInvite[]) =>
prevInvites.filter(invite => invite.id !== inviteId)
),
})
}

View File

@ -14,5 +14,6 @@ export const queryKeys = {
invites: {
all: ['invite'] as const,
invite: (teamId: string, inviteId: string) => [...queryKeys.invites.all, teamId, inviteId] as const,
teamInvites: () => [...queryKeys.invites.all, 'team-invites'] as const,
},
}

View File

@ -16,7 +16,9 @@ import type {
SubscriptionSummary,
UpdateSubscriptionRequest,
GetSubscriptionInvoicesResponse,
} from '../teamSubscriptions'
ListTeamMembersResponse,
ListTeamInvitesResponse,
} from '../types'
import { callCodyProApi } from './callCodyProApi'
import { queryKeys } from './queryKeys'
@ -26,7 +28,7 @@ export const useCurrentSubscription = (): UseQueryResult<Subscription | undefine
queryKey: queryKeys.subscriptions.subscription(),
queryFn: async () => {
const response = await callCodyProApi(Client.getCurrentSubscription())
return response?.json()
return response.json()
},
})
@ -35,7 +37,7 @@ export const useSubscriptionSummary = (): UseQueryResult<SubscriptionSummary | u
queryKey: queryKeys.subscriptions.subscriptionSummary(),
queryFn: async () => {
const response = await callCodyProApi(Client.getCurrentSubscriptionSummary())
return response?.json()
return response.json()
},
})
@ -48,6 +50,24 @@ export const useSubscriptionInvoices = (): UseQueryResult<GetSubscriptionInvoice
},
})
export const useTeamMembers = (): UseQueryResult<ListTeamMembersResponse | undefined> =>
useQuery({
queryKey: queryKeys.teams.teamMembers(),
queryFn: async () => {
const response = await callCodyProApi(Client.getCurrentTeamMembers())
return response.ok ? response.json() : undefined
},
})
export const useTeamInvites = (): UseQueryResult<ListTeamInvitesResponse | undefined> =>
useQuery({
queryKey: queryKeys.invites.teamInvites(),
queryFn: async () => {
const response = await callCodyProApi(Client.getTeamInvites())
return response.ok ? response.json() : undefined
},
})
export const useUpdateCurrentSubscription = (): UseMutationResult<
Subscription | undefined,
Error,
@ -57,7 +77,7 @@ export const useUpdateCurrentSubscription = (): UseMutationResult<
return useMutation({
mutationFn: async requestBody => {
const response = await callCodyProApi(Client.updateCurrentSubscription(requestBody))
return response?.json()
return response.json()
},
onSuccess: data => {
// We get updated subscription data in response - no need to refetch subscription.

View File

@ -1,7 +1,13 @@
import { useQuery, type UseQueryResult } from '@tanstack/react-query'
import {
useMutation,
useQuery,
useQueryClient,
type UseQueryResult,
type UseMutationResult,
} from '@tanstack/react-query'
import { Client } from '../client'
import type { ListTeamMembersResponse } from '../teamMembers'
import type { ListTeamMembersResponse, UpdateTeamMembersRequest } from '../types'
import { callCodyProApi } from './callCodyProApi'
import { queryKeys } from './queryKeys'
@ -14,3 +20,11 @@ export const useTeamMembers = (): UseQueryResult<ListTeamMembersResponse | undef
return response?.json()
},
})
export const useUpdateTeamMember = (): UseMutationResult<unknown, Error, UpdateTeamMembersRequest> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async requestBody => callCodyProApi(Client.updateTeamMember(requestBody)),
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.teams.teamMembers() }),
})
}

View File

@ -11,9 +11,9 @@ export interface TeamInvite {
status: TeamInviteStatus
error?: string
sentAt: Date
sentAt: string
sentBy: string
acceptedAt?: Date
acceptedAt?: string
}
export interface CreateTeamInviteRequest {

View File

@ -3,8 +3,8 @@ export type TeamRole = 'member' | 'admin'
export interface TeamMember {
accountId: string
displayName: string
email: string
avatarUrl: string
role: TeamRole
}
@ -19,7 +19,7 @@ export interface ListTeamMembersResponse {
}
export interface UpdateTeamMembersRequest {
addMembver?: TeamMemberRef
addMember?: TeamMemberRef
removeMember?: TeamMemberRef
updateMemberRole?: TeamMemberRef
}

View File

@ -1,11 +0,0 @@
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

@ -14,23 +14,17 @@ import { PageTitle } from '../../components/PageTitle'
import { CodyProRoutes } from '../codyProRoutes'
import { CodyAlert } from '../components/CodyAlert'
import { PageHeaderIcon } from '../components/PageHeaderIcon'
import { useCodySubscriptionSummaryData } from '../subscription/subscriptionSummary'
import { useSSCQuery } from '../util'
import { useTeamInvites } from '../management/api/react-query/invites'
import { useCurrentSubscription, useSubscriptionSummary } from '../management/api/react-query/subscriptions'
import { useTeamMembers } from '../management/api/react-query/teams'
import { InviteUsers } from './InviteUsers'
import { TeamMemberList, type TeamMember, type TeamInvite } from './TeamMemberList'
import { TeamMemberList } from './TeamMemberList'
interface CodyManageTeamPageProps extends TelemetryV2Props {
authenticatedUser: AuthenticatedUser
}
type CodySubscriptionStatus = 'active' | 'past_due' | 'unpaid' | 'canceled' | 'trialing' | 'other'
interface CodySubscription {
subscriptionStatus: CodySubscriptionStatus
maxSeats: number
}
const AuthenticatedCodyManageTeamPage: React.FunctionComponent<CodyManageTeamPageProps> = ({ telemetryRecorder }) => {
useEffect(() => {
telemetryRecorder.recordEvent('cody.team.management', 'view')
@ -44,31 +38,30 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent<CodyManageTeamPag
const newSeatsPurchased: number | null = newSeatsPurchasedParam ? parseInt(newSeatsPurchasedParam, 10) : null
// Load data
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 subscriptionQueryResult = useCurrentSubscription()
const subscriptionSummaryQueryResult = useSubscriptionSummary()
const isAdmin = subscriptionSummaryQueryResult.data?.userRole === 'admin'
const teamMembersQueryResult = useTeamMembers()
const teamMembers = teamMembersQueryResult.data?.members
const teamInvitesQueryResult = useTeamInvites()
const teamInvites = teamInvitesQueryResult.data
const errorMessage =
codySubscriptionError?.message ||
codySubscriptionSummaryError?.message ||
membersDataError?.message ||
invitesDataError?.message
subscriptionQueryResult.error?.message ||
subscriptionSummaryQueryResult.error?.message ||
teamMembersQueryResult.error?.message ||
teamInvitesQueryResult.error?.message
useEffect(() => {
if (!isPro) {
if (subscriptionQueryResult.data?.subscriptionStatus === 'canceled') {
navigate('/cody/subscription')
}
}, [isPro, navigate])
}, [navigate, subscriptionQueryResult.data])
const remainingInviteCount = useMemo(() => {
const memberCount = teamMembers?.length ?? 0
const invitesUsed = (teamInvites ?? []).filter(invite => invite.status === 'sent').length
return Math.max((codySubscription?.maxSeats ?? 0) - (memberCount + invitesUsed), 0)
}, [codySubscription?.maxSeats, teamMembers, teamInvites])
return Math.max((subscriptionQueryResult.data?.maxSeats ?? 0) - (memberCount + invitesUsed), 0)
}, [subscriptionQueryResult.data?.maxSeats, teamMembers, teamInvites])
return (
<>
@ -85,7 +78,10 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent<CodyManageTeamPag
onClick={() =>
telemetryRecorder.recordEvent('cody.team.manage.subscription', 'click', {
metadata: {
tier: codySubscription?.subscriptionStatus !== 'canceled' ? 1 : 0,
tier:
subscriptionQueryResult.data?.subscriptionStatus !== 'canceled'
? 1
: 0,
},
})
}
@ -112,14 +108,12 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent<CodyManageTeamPag
</PageHeader.Heading>
</PageHeader>
{codySubscriptionError || codySubscriptionSummaryError || membersDataError || invitesDataError ? (
{errorMessage ? (
<CodyAlert variant="error">
<H3>We couldn't load team data this time. Please try a bit later.</H3>
{!!errorMessage && (
<Text size="small" className="text-muted mb-0">
{errorMessage}
</Text>
)}
<Text size="small" className="text-muted mb-0">
{errorMessage}
</Text>
</CodyAlert>
) : null}
@ -132,20 +126,22 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent<CodyManageTeamPag
</CodyAlert>
)}
{isAdmin && !!remainingInviteCount && (
{isAdmin && !!remainingInviteCount && !!subscriptionSummaryQueryResult.data && (
<InviteUsers
teamId={codySubscriptionSummary?.teamId}
teamId={subscriptionSummaryQueryResult.data.teamId}
remainingInviteCount={remainingInviteCount}
telemetryRecorder={telemetryRecorder}
/>
)}
<TeamMemberList
teamId={codySubscriptionSummary?.teamId ?? null}
teamMembers={teamMembers || []}
invites={teamInvites || []}
isAdmin={isAdmin}
telemetryRecorder={telemetryRecorder}
/>
{!!subscriptionSummaryQueryResult.data && (
<TeamMemberList
teamId={subscriptionSummaryQueryResult.data.teamId}
teamMembers={teamMembers || []}
invites={teamInvites || []}
isAdmin={isAdmin}
telemetryRecorder={telemetryRecorder}
/>
)}
</Page>
</>
)

View File

@ -6,10 +6,11 @@ import { ButtonLink, H2, Link, Text, H3, TextArea } from '@sourcegraph/wildcard'
import { CodyAlert } from '../components/CodyAlert'
import { CodyContainer } from '../components/CodyContainer'
import { isValidEmailAddress, requestSSC } from '../util'
import { useSendInvite } from '../management/api/react-query/invites'
import { isValidEmailAddress } from '../util'
interface InviteUsersProps extends TelemetryV2Props {
teamId: string | null
teamId: string
remainingInviteCount: number
}
@ -19,18 +20,35 @@ export const InviteUsers: React.FunctionComponent<InviteUsersProps> = ({
telemetryRecorder,
}) => {
const [emailAddressesString, setEmailAddressesString] = useState<string>('')
const emailAddresses = emailAddressesString.split(',').map(email => email.trim())
const [emailAddressErrorMessage, setEmailAddressErrorMessage] = useState<string | null>(null)
const [invitesSendingStatus, setInvitesSendingStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle')
const [invitesSentCount, setInvitesSentCount] = useState(0)
const [invitesSendingErrorMessage, setInvitesSendingErrorMessage] = useState<string | null>(null)
const sendInviteMutation = useSendInvite()
const verifyEmailList = useCallback((): Error | void => {
if (emailAddresses.length === 0) {
return new Error('Please enter at least one email address.')
}
if (emailAddresses.length > remainingInviteCount) {
return new Error(
`${emailAddresses.length} email addresses entered, but you only have ${remainingInviteCount} seats.`
)
}
const invalidEmails = emailAddresses.filter(email => !isValidEmailAddress(email))
if (invalidEmails.length > 0) {
return new Error(
`Invalid email address${invalidEmails.length > 1 ? 'es' : ''}: ${invalidEmails.join(', ')}`
)
}
}, [emailAddresses, remainingInviteCount])
const onSendInvitesClicked = useCallback(async () => {
const { emails: emailAddresses, error: emailParsingError } = parseEmailList(
emailAddressesString,
remainingInviteCount
)
if (emailParsingError) {
setEmailAddressErrorMessage(emailParsingError)
const emailListError = verifyEmailList()
if (emailListError) {
setEmailAddressErrorMessage(emailListError.message)
return
}
telemetryRecorder.recordEvent('cody.team.sendInvites', 'click', {
@ -38,57 +56,56 @@ export const InviteUsers: React.FunctionComponent<InviteUsersProps> = ({
privateMetadata: { teamId, emailAddresses },
})
setInvitesSendingStatus('sending')
try {
const responses = await Promise.all(
emailAddresses.map(emailAddress =>
requestSSC('/team/current/invites', 'POST', { email: emailAddress, role: 'member' })
)
const results = await Promise.allSettled(
emailAddresses.map(emailAddress =>
sendInviteMutation.mutateAsync.call(undefined, { email: emailAddress, role: 'member' })
)
if (responses.some(response => response.status !== 200)) {
const responsesText = await Promise.all(responses.map(response => response.text()))
setInvitesSendingStatus('error')
setInvitesSendingErrorMessage(`Error sending invites: ${responsesText.join(', ')}`)
telemetryRecorder.recordEvent('cody.team.sendInvites', 'error', {
metadata: { count: emailAddresses.length, softError: 1 },
privateMetadata: { teamId, emailAddresses },
})
)
return
}
setInvitesSendingStatus('success')
setInvitesSentCount(emailAddresses.length)
telemetryRecorder.recordEvent('cody.team.sendInvites', 'success', {
metadata: { count: emailAddresses.length },
privateMetadata: { teamId, emailAddresses },
})
} catch (error) {
setInvitesSendingStatus('error')
setInvitesSendingErrorMessage(`Error sending invites: ${error}`)
const failures = results
.map((result, index) => ({
emailAddress: emailAddresses[index],
errorMessage: result.status === 'rejected' ? (result.reason as Error).message : null,
}))
.filter(({ errorMessage }) => errorMessage)
if (failures.length) {
const failureList = failures
.map(({ emailAddress, errorMessage }) => `"${emailAddress}": ${errorMessage}`)
.join(', ')
const errorMessage = `We couldn't send${
failures.length < emailAddresses.length ? ` ${failures.length} of` : ''
} the ${pluralize('invite', emailAddresses.length)}. This is what we got: ${failureList}`
telemetryRecorder.recordEvent('cody.team.sendInvites', 'error', {
metadata: { count: emailAddresses.length, softError: 0 },
privateMetadata: { teamId, emailAddresses },
privateMetadata: { teamId, emailAddresses, error: errorMessage },
})
setEmailAddressErrorMessage(errorMessage)
return
}
}, [emailAddressesString, remainingInviteCount, teamId, telemetryRecorder])
telemetryRecorder.recordEvent('cody.team.sendInvites', 'success', {
metadata: { count: emailAddresses.length },
privateMetadata: { teamId, emailAddresses },
})
}, [emailAddresses, sendInviteMutation.mutateAsync, teamId, telemetryRecorder, verifyEmailList])
return (
<>
{invitesSendingStatus === 'success' && (
{sendInviteMutation.status === 'success' && (
<CodyAlert variant="greenSuccess">
<H3>
{invitesSentCount} {pluralize('invite', invitesSentCount)} sent!
{emailAddresses.length} {pluralize('invite', emailAddresses.length)} sent!
</H3>
<Text size="small" className="mb-0">
Invitees will receive an email from cody@sourcegraph.com.
</Text>
</CodyAlert>
)}
{invitesSendingStatus === 'error' && (
{sendInviteMutation.status === 'error' && (
<CodyAlert variant="error">
<H3>Invites not sent.</H3>
<Text size="small" className="text-muted mb-0">
{invitesSendingErrorMessage}
Error sending invites: {sendInviteMutation.error?.message}
</Text>
<Text size="small" className="mb-0">
If you encounter this issue repeatedly, please contact support at{' '}
@ -120,6 +137,7 @@ export const InviteUsers: React.FunctionComponent<InviteUsersProps> = ({
onChange={event => {
setEmailAddressErrorMessage(null)
setEmailAddressesString(event.target.value)
sendInviteMutation.reset()
}}
isValid={emailAddressErrorMessage ? false : undefined}
/>
@ -140,31 +158,3 @@ export const InviteUsers: React.FunctionComponent<InviteUsersProps> = ({
</>
)
}
function parseEmailList(
emailAddressesString: string,
remainingInviteCount: number
): { emails: string[]; error: string | null } {
const emails = emailAddressesString.split(',').map(email => email.trim())
if (emails.length === 0) {
return { emails, error: 'Please enter at least one email address.' }
}
if (emails.length > remainingInviteCount) {
return {
emails,
error: `${emails.length} email addresses entered, but you only have ${remainingInviteCount} seats.`,
}
}
const invalidEmails = emails.filter(email => !isValidEmailAddress(email))
if (invalidEmails.length > 0) {
return {
emails,
error: `Invalid email address${invalidEmails.length > 1 ? 'es' : ''}: ${invalidEmails.join(', ')}`,
}
}
return { emails, error: null }
}

View File

@ -4,39 +4,23 @@ import classNames from 'classnames'
import { intlFormatDistance } from 'date-fns'
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import { H2, Text, Badge, Link, ButtonLink } from '@sourcegraph/wildcard'
import { H2, Text, Badge, Link, ButtonLink, Button } from '@sourcegraph/wildcard'
import { CodyAlert } from '../components/CodyAlert'
import { CodyContainer } from '../components/CodyContainer'
import { requestSSC } from '../util'
import { useCancelInvite, useResendInvite } from '../management/api/react-query/invites'
import { useUpdateTeamMember } from '../management/api/react-query/teams'
import type { TeamMember, TeamInvite } from '../management/api/types'
import styles from './TeamMemberList.module.scss'
export interface TeamMember {
accountId: string
displayName: string | null
email: string
avatarUrl: string | null
role: 'admin' | 'member'
}
interface TeamMemberListProps extends TelemetryV2Props {
teamId: string | null
teamId: string
teamMembers: TeamMember[]
invites: TeamInvite[]
invites: Omit<TeamInvite, 'sentBy'>[]
isAdmin: boolean
}
export interface TeamInvite {
id: string
email: string
role: 'admin' | 'member' | 'none'
status: 'sent' | 'errored' | 'accepted' | 'canceled'
error: string | null
sentAt: string | null
acceptedAt: string | null
}
// This tiny function is extracted to make it testable. Same for the "now" parameter.
export const formatInviteDate = (sentAt: string | null, now?: Date): string => {
try {
@ -56,113 +40,100 @@ export const TeamMemberList: FunctionComponent<TeamMemberListProps> = ({
isAdmin,
telemetryRecorder,
}) => {
const [loading, setLoading] = useState(false)
const [actionResult, setActionResult] = useState<{ message: string; isError: boolean } | null>(null)
const updateTeamMemberMutation = useUpdateTeamMember()
const cancelInviteMutation = useCancelInvite()
const resendInviteMutation = useResendInvite()
const isLoading =
updateTeamMemberMutation.status === 'pending' ||
cancelInviteMutation.status === 'pending' ||
resendInviteMutation.status === 'pending'
const updateRole = useCallback(
async (accountId: string, newRole: 'member' | 'admin'): Promise<void> => {
if (!loading) {
// Avoids sending multiple requests at once
setLoading(true)
telemetryRecorder.recordEvent('cody.team.revokeAdmin', 'click', {
privateMetadata: { teamId, accountId },
})
if (isLoading) {
return
}
try {
const response = await requestSSC('/team/current/members', 'PATCH', {
updateMemberRole: { accountId, teamRole: newRole },
})
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. The error was: "${error}". Please try again later.`,
isError: true,
})
}
telemetryRecorder.recordEvent('cody.team.revokeAdmin', 'click', {
privateMetadata: { teamId, accountId },
})
try {
await updateTeamMemberMutation.mutateAsync.call(undefined, {
updateMemberRole: { accountId, teamRole: newRole },
})
setActionResult({ message: 'Team role updated.', isError: false })
} catch (error) {
setActionResult({
message: `We couldn't modify the user's role. The error was: "${error}". Please try again later.`,
isError: true,
})
}
},
[loading, telemetryRecorder, teamId]
[isLoading, updateTeamMemberMutation.mutateAsync, telemetryRecorder, teamId]
)
const revokeInvite = useCallback(
async (inviteId: string): Promise<void> => {
if (!loading) {
// Avoids sending multiple requests at once
setLoading(true)
telemetryRecorder.recordEvent('cody.team.revokeInvite', 'click', { privateMetadata: { teamId } })
const response = await requestSSC(`/team/current/invites/${inviteId}/cancel`, 'POST')
if (!response.ok) {
setLoading(false)
setActionResult({
message: `We couldn't revoke the invite (${response.status}). Please try again later.`,
isError: true,
})
} else {
setLoading(false)
setActionResult({ message: 'Invite revoked.', isError: false })
}
if (isLoading) {
return
}
telemetryRecorder.recordEvent('cody.team.revokeInvite', 'click', { privateMetadata: { teamId } })
try {
await cancelInviteMutation.mutateAsync.call(undefined, { teamId, inviteId })
setActionResult({ message: 'Invite revoked.', isError: false })
} catch (error) {
setActionResult({
message: `We couldn't revoke the invite. The error was: "${error}". Please try again later.`,
isError: true,
})
}
},
[loading, telemetryRecorder, teamId]
[isLoading, cancelInviteMutation.mutateAsync, telemetryRecorder, teamId]
)
const resendInvite = useCallback(
async (inviteId: string): Promise<void> => {
if (!loading) {
// Avoids sending multiple requests at once
setLoading(true)
telemetryRecorder.recordEvent('cody.team.resendInvite', 'click', { privateMetadata: { teamId } })
if (isLoading) {
return
}
telemetryRecorder.recordEvent('cody.team.resendInvite', 'click', { privateMetadata: { teamId } })
const response = await requestSSC(`/team/current/invites/${inviteId}/resend`, 'POST')
if (!response.ok) {
setLoading(false)
setActionResult({
message: `We couldn't resend the invite (${response.status}). Please try again later.`,
isError: true,
})
} else {
setLoading(false)
setActionResult({ message: 'Invite resent.', isError: false })
}
try {
await resendInviteMutation.mutateAsync.call(undefined, { inviteId })
setActionResult({ message: 'Invite resent.', isError: false })
} catch (error) {
setActionResult({
message: `We couldn't resend the invite (${error}). Please try again later.`,
isError: true,
})
}
telemetryRecorder.recordEvent('cody.team.resendInvite', 'click', { privateMetadata: { teamId } })
},
[loading, telemetryRecorder, teamId]
[isLoading, resendInviteMutation.mutateAsync, telemetryRecorder, teamId]
)
const removeMember = useCallback(
async (accountId: string): Promise<void> => {
if (!loading) {
setLoading(true)
telemetryRecorder.recordEvent('cody.team.removeMember', 'click', { privateMetadata: { teamId } })
if (isLoading) {
return
}
telemetryRecorder.recordEvent('cody.team.removeMember', 'click', { privateMetadata: { teamId } })
const response = await requestSSC('/team/current/members', 'PATCH', {
try {
await updateTeamMemberMutation.mutateAsync.call(undefined, {
removeMember: { accountId, teamRole: 'member' },
})
if (!response.ok) {
setLoading(false)
setActionResult({
message: `We couldn't remove the team member. (${response.status}). Please try again later.`,
isError: true,
})
} else {
setLoading(false)
setActionResult({ message: 'Team member removed.', isError: false })
}
setActionResult({ message: 'Team member removed.', isError: false })
} catch (error) {
setActionResult({
message: `We couldn't remove the team member. (${error}). Please try again later.`,
isError: true,
})
}
},
[telemetryRecorder, teamId, loading]
[isLoading, updateTeamMemberMutation.mutateAsync, telemetryRecorder, teamId]
)
const adminCount = useMemo(() => teamMembers?.filter(member => member.role === 'admin').length ?? 0, [teamMembers])
@ -214,14 +185,14 @@ export const TeamMemberList: FunctionComponent<TeamMemberListProps> = ({
<>
<div />
<div className="align-content-center text-center">
<Link
to="#"
<Button
variant="link"
onClick={() => updateRole(member.accountId, 'member')}
className="ml-2"
aria-disabled={adminCount < 2}
disabled={adminCount < 2}
>
Revoke admin
</Link>
</Button>
</div>
</>
) : (

View File

@ -1,6 +1,3 @@
// The URL to direct users in order to manage their Cody Pro subscription.
import { useState, useEffect } from 'react'
import { CodyProRoutes } from './codyProRoutes'
// URL the user needs to navigate to in order to modify their Cody Pro subscription.
@ -46,50 +43,3 @@ export function isValidEmailAddress(emailAddress: string): boolean {
* and keep in mind that the backend validation has the final say, validation in the web app is only for UX improvement.
*/
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.
* In the case of GET and HEAD, use the query string instead.
*/
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.
return fetch(`/.api/ssc/proxy${sscUrl}`, {
// Pass along the "sgs" session cookie to identify the caller.
credentials: 'same-origin',
headers: {
...window.context.xhrHeaders,
'Content-Type': 'application/json',
},
method,
...(!['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]
}