diff --git a/client/web/BUILD.bazel b/client/web/BUILD.bazel index d3615b8db6d..3766a94b1a3 100644 --- a/client/web/BUILD.bazel +++ b/client/web/BUILD.bazel @@ -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", diff --git a/client/web/src/cody/management/CodyManagementPage.tsx b/client/web/src/cody/management/CodyManagementPage.tsx index 5dca0194f32..fe0280e3912 100644 --- a/client/web/src/cody/management/CodyManagementPage.tsx +++ b/client/web/src/cody/management/CodyManagementPage.tsx @@ -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(null) const [selectedEditorStep, setSelectedEditorStep] = React.useState(null) diff --git a/client/web/src/cody/management/api/client.ts b/client/web/src/cody/management/api/client.ts index 96e854b159a..fc8ed5e2d3d 100644 --- a/client/web/src/cody/management/api/client.ts +++ b/client/web/src/cody/management/api/client.ts @@ -52,12 +52,28 @@ export module Client { return { method: 'GET', urlSuffix: '/team/current/members' } } + export function updateTeamMember(requestBody: types.UpdateTeamMembersRequest): Call { + return { method: 'PATCH', urlSuffix: '/team/current/members', requestBody } + } + // Invites export function getInvite(teamId: string, inviteId: string): Call { return { method: 'GET', urlSuffix: `/team/${teamId}/invites/${inviteId}` } } + export function getTeamInvites(): Call { + return { method: 'GET', urlSuffix: '/team/current/invites' } + } + + export function sendInvite(requestBody: types.CreateTeamInviteRequest): Call { + return { method: 'POST', urlSuffix: '/team/current/invites', requestBody } + } + + export function resendInvite(inviteId: string): Call { + return { method: 'POST', urlSuffix: `/team/current/invites/${inviteId}/resend` } + } + export function acceptInvite(teamId: string, inviteId: string): Call { 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 { method: 'GET' | 'POST' | 'PATCH' | 'DELETE' diff --git a/client/web/src/cody/management/api/react-query/invites.ts b/client/web/src/cody/management/api/react-query/invites.ts index 37be4c6383a..3917a0a7a2d 100644 --- a/client/web/src/cody/management/api/react-query/invites.ts +++ b/client/web/src/cody/management/api/react-query/invites.ts @@ -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[] | 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, Error, CreateTeamInviteRequest> => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async requestBody => (await callCodyProApi(Client.sendInvite(requestBody))).json(), + onSuccess: (newInvite: Omit) => { + queryClient.setQueryData(queryKeys.invites.teamInvites(), (prevInvites: Omit[]) => [ + ...prevInvites, + newInvite, + ]) + }, + }) +} + +export const useResendInvite = (): UseMutationResult => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ inviteId }) => callCodyProApi(Client.resendInvite(inviteId)), + onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.invites.teamInvites() }), + }) +} + export const useAcceptInvite = (): UseMutationResult => { const queryClient = useQueryClient() return useMutation({ @@ -44,7 +74,9 @@ export const useCancelInvite = (): UseMutationResult 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) + ), }) } diff --git a/client/web/src/cody/management/api/react-query/queryKeys.ts b/client/web/src/cody/management/api/react-query/queryKeys.ts index f1949ca3e7d..be4e009d375 100644 --- a/client/web/src/cody/management/api/react-query/queryKeys.ts +++ b/client/web/src/cody/management/api/react-query/queryKeys.ts @@ -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, }, } diff --git a/client/web/src/cody/management/api/react-query/subscriptions.ts b/client/web/src/cody/management/api/react-query/subscriptions.ts index ae5f0f769ab..3da2c9429be 100644 --- a/client/web/src/cody/management/api/react-query/subscriptions.ts +++ b/client/web/src/cody/management/api/react-query/subscriptions.ts @@ -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 { const response = await callCodyProApi(Client.getCurrentSubscription()) - return response?.json() + return response.json() }, }) @@ -35,7 +37,7 @@ export const useSubscriptionSummary = (): UseQueryResult { const response = await callCodyProApi(Client.getCurrentSubscriptionSummary()) - return response?.json() + return response.json() }, }) @@ -48,6 +50,24 @@ export const useSubscriptionInvoices = (): UseQueryResult => + useQuery({ + queryKey: queryKeys.teams.teamMembers(), + queryFn: async () => { + const response = await callCodyProApi(Client.getCurrentTeamMembers()) + return response.ok ? response.json() : undefined + }, + }) + +export const useTeamInvites = (): UseQueryResult => + 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. diff --git a/client/web/src/cody/management/api/react-query/teams.ts b/client/web/src/cody/management/api/react-query/teams.ts index b2b65cc69b0..17dd84b017a 100644 --- a/client/web/src/cody/management/api/react-query/teams.ts +++ b/client/web/src/cody/management/api/react-query/teams.ts @@ -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 => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async requestBody => callCodyProApi(Client.updateTeamMember(requestBody)), + onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.teams.teamMembers() }), + }) +} diff --git a/client/web/src/cody/management/api/teamInvites.ts b/client/web/src/cody/management/api/teamInvites.ts index a6b2fd06e94..cfde66bad87 100644 --- a/client/web/src/cody/management/api/teamInvites.ts +++ b/client/web/src/cody/management/api/teamInvites.ts @@ -11,9 +11,9 @@ export interface TeamInvite { status: TeamInviteStatus error?: string - sentAt: Date + sentAt: string sentBy: string - acceptedAt?: Date + acceptedAt?: string } export interface CreateTeamInviteRequest { diff --git a/client/web/src/cody/management/api/teamMembers.ts b/client/web/src/cody/management/api/teamMembers.ts index 6c7f83a5030..0f482d7bacd 100644 --- a/client/web/src/cody/management/api/teamMembers.ts +++ b/client/web/src/cody/management/api/teamMembers.ts @@ -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 } diff --git a/client/web/src/cody/subscription/subscriptionSummary.ts b/client/web/src/cody/subscription/subscriptionSummary.ts deleted file mode 100644 index c8b3fe1440a..00000000000 --- a/client/web/src/cody/subscription/subscriptionSummary.ts +++ /dev/null @@ -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('/team/current/subscription/summary') diff --git a/client/web/src/cody/team/CodyManageTeamPage.tsx b/client/web/src/cody/team/CodyManageTeamPage.tsx index 8614cd53a3d..a00b0c7885d 100644 --- a/client/web/src/cody/team/CodyManageTeamPage.tsx +++ b/client/web/src/cody/team/CodyManageTeamPage.tsx @@ -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 = ({ telemetryRecorder }) => { useEffect(() => { telemetryRecorder.recordEvent('cody.team.management', 'view') @@ -44,31 +38,30 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent('/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 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 - {codySubscriptionError || codySubscriptionSummaryError || membersDataError || invitesDataError ? ( + {errorMessage ? (

We couldn't load team data this time. Please try a bit later.

- {!!errorMessage && ( - - {errorMessage} - - )} + + {errorMessage} +
) : null} @@ -132,20 +126,22 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent )} - {isAdmin && !!remainingInviteCount && ( + {isAdmin && !!remainingInviteCount && !!subscriptionSummaryQueryResult.data && ( )} - + {!!subscriptionSummaryQueryResult.data && ( + + )} ) diff --git a/client/web/src/cody/team/InviteUsers.tsx b/client/web/src/cody/team/InviteUsers.tsx index 3ed09788ec9..2c273c76781 100644 --- a/client/web/src/cody/team/InviteUsers.tsx +++ b/client/web/src/cody/team/InviteUsers.tsx @@ -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 = ({ telemetryRecorder, }) => { const [emailAddressesString, setEmailAddressesString] = useState('') + const emailAddresses = emailAddressesString.split(',').map(email => email.trim()) const [emailAddressErrorMessage, setEmailAddressErrorMessage] = useState(null) - const [invitesSendingStatus, setInvitesSendingStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle') - const [invitesSentCount, setInvitesSentCount] = useState(0) - const [invitesSendingErrorMessage, setInvitesSendingErrorMessage] = useState(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 = ({ 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' && (

- {invitesSentCount} {pluralize('invite', invitesSentCount)} sent! + {emailAddresses.length} {pluralize('invite', emailAddresses.length)} sent!

Invitees will receive an email from cody@sourcegraph.com.
)} - {invitesSendingStatus === 'error' && ( + {sendInviteMutation.status === 'error' && (

Invites not sent.

- {invitesSendingErrorMessage} + Error sending invites: {sendInviteMutation.error?.message} If you encounter this issue repeatedly, please contact support at{' '} @@ -120,6 +137,7 @@ export const InviteUsers: React.FunctionComponent = ({ onChange={event => { setEmailAddressErrorMessage(null) setEmailAddressesString(event.target.value) + sendInviteMutation.reset() }} isValid={emailAddressErrorMessage ? false : undefined} /> @@ -140,31 +158,3 @@ export const InviteUsers: React.FunctionComponent = ({ ) } - -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 } -} diff --git a/client/web/src/cody/team/TeamMemberList.tsx b/client/web/src/cody/team/TeamMemberList.tsx index 2b37956181d..391b4c3d0d6 100644 --- a/client/web/src/cody/team/TeamMemberList.tsx +++ b/client/web/src/cody/team/TeamMemberList.tsx @@ -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[] 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 = ({ 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 => { - 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 => { - 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 => { - 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 => { - 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 = ({ <>
- updateRole(member.accountId, 'member')} className="ml-2" - aria-disabled={adminCount < 2} + disabled={adminCount < 2} > Revoke admin - +
) : ( diff --git a/client/web/src/cody/util.ts b/client/web/src/cody/util.ts index c58c31e560e..6a1e135af9e 100644 --- a/client/web/src/cody/util.ts +++ b/client/web/src/cody/util.ts @@ -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 { - // /.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 = (endpoint: string): [T | null, Error | null] => { - const [data, setData] = useState(null) - const [error, setError] = useState(null) - useEffect(() => { - async function loadData(): Promise { - try { - const response = await requestSSC(endpoint, 'GET') - const responseJson = await response.json() - setData(responseJson) - } catch (error) { - setError(error) - } - } - - void loadData() - }, [endpoint]) - - return [data, error] -}