mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 15:51:43 +00:00
feat(plg): Use react-query for team management (#63267)
This commit is contained in:
parent
db7a268c34
commit
76a1c65d8b
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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)
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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() }),
|
||||
})
|
||||
}
|
||||
|
||||
@ -11,9 +11,9 @@ export interface TeamInvite {
|
||||
status: TeamInviteStatus
|
||||
error?: string
|
||||
|
||||
sentAt: Date
|
||||
sentAt: string
|
||||
sentBy: string
|
||||
acceptedAt?: Date
|
||||
acceptedAt?: string
|
||||
}
|
||||
|
||||
export interface CreateTeamInviteRequest {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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')
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@ -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]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user