diff --git a/client/web/BUILD.bazel b/client/web/BUILD.bazel index 35b2a6a06f8..c026bd76aef 100644 --- a/client/web/BUILD.bazel +++ b/client/web/BUILD.bazel @@ -247,10 +247,11 @@ ts_project( "src/cody/sidebar/useSidebarSize.tsx", "src/cody/subscription/CodySubscriptionPage.tsx", "src/cody/subscription/queries.tsx", + "src/cody/subscription/subscriptionSummary.ts", "src/cody/switch-account/CodySwitchAccountPage.tsx", "src/cody/team/CodyManageTeamPage.tsx", "src/cody/team/InviteUsers.tsx", - "src/cody/team/TeamMembers.tsx", + "src/cody/team/TeamMemberList.tsx", "src/cody/team/WhiteIcon.tsx", "src/cody/upsell/ChatBrandIcon.tsx", "src/cody/upsell/CodyUpsellPage.tsx", diff --git a/client/web/src/cody/management/subscription/new/CodyProCheckoutForm.tsx b/client/web/src/cody/management/subscription/new/CodyProCheckoutForm.tsx index e1810499031..94ca419fb53 100644 --- a/client/web/src/cody/management/subscription/new/CodyProCheckoutForm.tsx +++ b/client/web/src/cody/management/subscription/new/CodyProCheckoutForm.tsx @@ -6,6 +6,8 @@ import { useSearchParams } from 'react-router-dom' import { H3, Text } from '@sourcegraph/wildcard' +import { requestSSC } from '../../../util' + /** * CodyProCheckoutForm is essentially an iframe that the Stripe Elements library will * render an iframe into, that will host a Stripe Checkout-hosted form. @@ -71,30 +73,19 @@ async function createCheckoutSession( const origin = window.location.origin try { - // So the request is kinda made to 2x backends. dotcom's .api/ssc/proxy endpoint will - // take care of exchanging the Sourcegraph session credentials for a SAMS access token. - // And then proxy the request onto the SSC backend, which will actually create the - // checkout session. - // TODO: Use fetchThroughSSCProxy instead of fetch. - const response = await fetch(`${origin}/.api/ssc/proxy/checkout/session`, { - // Pass along the "sgs" session cookie to identify the caller. - credentials: 'same-origin', - method: 'POST', - // Object sent to the backend. See `createCheckoutSessionRequest`. - body: JSON.stringify({ - interval: billingInterval, - seats: 1, - customerEmail, - showPromoCodeField, + const response = await requestSSC('/checkout/session', 'POST', { + interval: billingInterval, + seats: 1, + customerEmail, + showPromoCodeField, - // URL the user is redirected to when the checkout process is complete. - // - // BUG: Due to the race conditions between Stripe, the SSC backend, - // and Sourcegraph.com, immediately loading the Dashboard page isn't - // going to show the right data reliably. We will need to instead show - // some interstitial or welcome prompt, to give various things to sync. - returnUrl: `${origin}/cody/manage?session_id={CHECKOUT_SESSION_ID}`, - }), + // URL the user is redirected to when the checkout process is complete. + // + // BUG: Due to the race conditions between Stripe, the SSC backend, + // and Sourcegraph.com, immediately loading the Dashboard page isn't + // going to show the right data reliably. We will need to instead show + // some interstitial or welcome prompt, to give various things to sync. + returnUrl: `${origin}/cody/manage?session_id={CHECKOUT_SESSION_ID}`, }) const responseBody = await response.text() diff --git a/client/web/src/cody/subscription/subscriptionSummary.ts b/client/web/src/cody/subscription/subscriptionSummary.ts new file mode 100644 index 00000000000..c8b3fe1440a --- /dev/null +++ b/client/web/src/cody/subscription/subscriptionSummary.ts @@ -0,0 +1,11 @@ +import { useSSCQuery } from '../util' + +type TeamRole = 'member' | 'admin' + +interface CodySubscriptionSummary { + teamId: string + userRole: TeamRole +} + +export const useCodySubscriptionSummaryData = (): [CodySubscriptionSummary | null, Error | null] => + useSSCQuery('/team/current/subscription/summary') diff --git a/client/web/src/cody/team/CodyManageTeamPage.tsx b/client/web/src/cody/team/CodyManageTeamPage.tsx index d68b0e74fdc..76084e6e5d1 100644 --- a/client/web/src/cody/team/CodyManageTeamPage.tsx +++ b/client/web/src/cody/team/CodyManageTeamPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react' +import React, { useEffect, useMemo } from 'react' import { mdiPlusThick, mdiOpenInNew } from '@mdi/js' import classNames from 'classnames' @@ -11,10 +11,11 @@ import type { AuthenticatedUser } from '../../auth' import { withAuthenticatedUser } from '../../auth/withAuthenticatedUser' import { Page } from '../../components/Page' import { PageTitle } from '../../components/PageTitle' -import { fetchThroughSSCProxy } from '../util' +import { useCodySubscriptionSummaryData } from '../subscription/subscriptionSummary' +import { useSSCQuery } from '../util' import { InviteUsers } from './InviteUsers' -import { type TeamInvite, TeamMemberList, type TeamMember } from './TeamMembers' +import { TeamMemberList, type TeamMember, type TeamInvite } from './TeamMemberList' import { WhiteIcon } from './WhiteIcon' import styles from './CodyManageTeamPage.module.scss' @@ -23,51 +24,14 @@ interface CodyManageTeamPageProps extends TelemetryV2Props { authenticatedUser: AuthenticatedUser } -// TODO: Remove this mock data -const mockTeamMembers: TeamMember[] = [ - { - accountId: '1', - displayName: 'daniel.marques.pt', - email: 'daniel.marques@sourcegraph.com', - avatarUrl: null, - role: 'member', - }, -] +type CodySubscriptionStatus = 'active' | 'past_due' | 'unpaid' | 'canceled' | 'trialing' | 'other' -const mockInvites: TeamInvite[] = [ - { - id: '1', - email: 'rob.rhyne@sourcegraph.com', - role: 'member', - status: 'sent', - error: null, - sentAt: '2021-09-01T00:00:00Z', - acceptedAt: null, - }, - { - id: '2', - email: 'kevin.chen@sourcegraph.com', - role: 'admin', - status: 'sent', - error: null, - sentAt: '2021-09-01T00:00:00Z', - acceptedAt: null, - }, - { - id: '3', - email: 'test@test.com', - role: 'member', - status: 'accepted', - error: null, - sentAt: '2021-09-01T00:00:00Z', - acceptedAt: '2021-09-01T00:00:00Z', - }, -] +interface CodySubscription { + subscriptionStatus: CodySubscriptionStatus + maxSeats: number +} -const AuthenticatedCodyManageTeamPage: React.FunctionComponent = ({ - authenticatedUser, - telemetryRecorder, -}) => { +const AuthenticatedCodyManageTeamPage: React.FunctionComponent = ({ telemetryRecorder }) => { useEffect(() => { telemetryRecorder.recordEvent('cody.team.management', 'view') }, [telemetryRecorder]) @@ -80,89 +44,31 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent(null) - const subscriptionSeatCount = subscriptionData?.subscriptionSeatCount - const isProUser = subscriptionData?.isProUser - const [subscriptionDataError, setSubscriptionDataError] = useState(null) - const [subscriptionSummaryData, setSubscriptionSummaryData] = useState<{ - teamId: string | null - isAdmin: boolean | null - } | null>(null) - const [subscriptionSummaryDataError, setSubscriptionSummaryDataError] = useState(null) - const [teamMembers, setTeamMembers] = useState(null) - const [membersDataError, setMembersDataError] = useState(null) - const [teamInvites, setTeamInvites] = useState(null) - const [invitesDataError, setInvitesDataError] = useState(null) - useEffect(() => { - async function loadSubscriptionData(): Promise { - try { - const response = await fetchThroughSSCProxy('/team/current/subscription', 'GET') - const responseJson = (await response.json()) as { - subscriptionStatus: 'active' | 'past_due' | 'unpaid' | 'canceled' | 'trialing' | 'other' - maxSeats: number - } | null - setSubscriptionData({ - subscriptionSeatCount: responseJson?.maxSeats ?? null, - isProUser: responseJson && responseJson.subscriptionStatus !== 'canceled', - }) - } catch (error) { - setSubscriptionDataError(error) - } - } - async function loadSubscriptionSummaryData(): Promise { - try { - const response = await fetchThroughSSCProxy('/team/current/subscription/summary', 'GET') - const responseJson = (await response.json()) as { - teamId: string - userRole: 'none' | 'member' | 'admin' - } | null - setSubscriptionSummaryData({ - teamId: responseJson?.teamId ?? null, - isAdmin: responseJson && responseJson.userRole === 'admin', - }) - } catch (error) { - setSubscriptionSummaryDataError(error) - } - } - async function loadMemberData(): Promise { - try { - const response = await fetchThroughSSCProxy('/team/current/members', 'GET') - const responseJson = await response.json() - setTeamMembers((responseJson as { members: TeamMember[] }).members.concat(mockTeamMembers)) - } catch (error) { - setMembersDataError(error) - } - } - async function loadInviteData(): Promise { - try { - const response = await fetchThroughSSCProxy('/team/current/invites', 'GET') - const responseJson = await response.json() - setTeamInvites((responseJson as { invites: TeamInvite[] }).invites.concat(mockInvites)) - } catch (error) { - setInvitesDataError(error) - } - } - - void loadSubscriptionData() - void loadSubscriptionSummaryData() - void loadMemberData() - void loadInviteData() - }, [authenticatedUser]) + const [codySubscription, codySubscriptionError] = useSSCQuery('/team/current/subscription') + const isPro = codySubscription?.subscriptionStatus !== 'canceled' + const [codySubscriptionSummary, codySubscriptionSummaryError] = useCodySubscriptionSummaryData() + const isAdmin = codySubscriptionSummary?.userRole === 'admin' + const [memberResponse, membersDataError] = useSSCQuery<{ members: TeamMember[] }>('/team/current/members') + const teamMembers = memberResponse?.members + const [invitesResponse, invitesDataError] = useSSCQuery<{ invites: TeamInvite[] }>('/team/current/invites') + const teamInvites = invitesResponse?.invites + const errorMessage = + codySubscriptionError?.message || + codySubscriptionSummaryError?.message || + membersDataError?.message || + invitesDataError?.message useEffect(() => { - if (isProUser === false) { + if (!isPro) { navigate('/cody/subscription') } - }, [isProUser, navigate]) + }, [isPro, navigate]) const remainingInviteCount = useMemo(() => { const memberCount = teamMembers?.length ?? 0 const invitesUsed = (teamInvites ?? []).filter(invite => invite.status === 'sent').length - return Math.max((subscriptionSeatCount ?? 0) - (memberCount + invitesUsed), 0) - }, [subscriptionSeatCount, teamMembers, teamInvites]) + return Math.max((codySubscription?.maxSeats ?? 0) - (memberCount + invitesUsed), 0) + }, [codySubscription?.maxSeats, teamMembers, teamInvites]) return ( <> @@ -171,14 +77,16 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent telemetryRecorder.recordEvent('cody.team.manage.subscription', 'click', { - metadata: { tier: isProUser ? 1 : 0 }, + metadata: { + tier: codySubscription?.subscriptionStatus !== 'canceled' ? 1 : 0, + }, }) } > @@ -211,27 +119,12 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent - {subscriptionDataError || subscriptionSummaryDataError || membersDataError || invitesDataError ? ( + {codySubscriptionError || codySubscriptionSummaryError || membersDataError || invitesDataError ? (
-

Failed to load team data.

- {subscriptionDataError?.message && ( +

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

+ {errorMessage ?? ( - {subscriptionDataError?.message} - - )} - {subscriptionSummaryDataError?.message && ( - - {subscriptionDataError?.message} - - )} - {membersDataError?.message && ( - - {membersDataError?.message} - - )} - {invitesDataError?.message && ( - - {invitesDataError?.message} + {errorMessage} )}
@@ -246,18 +139,18 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent )} - {subscriptionSummaryData?.isAdmin && !!remainingInviteCount && ( + {isAdmin && !!remainingInviteCount && ( )} diff --git a/client/web/src/cody/team/InviteUsers.tsx b/client/web/src/cody/team/InviteUsers.tsx index 871dc918f78..4b8066a3bb5 100644 --- a/client/web/src/cody/team/InviteUsers.tsx +++ b/client/web/src/cody/team/InviteUsers.tsx @@ -6,7 +6,7 @@ import { pluralize } from '@sourcegraph/common' import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry' import { ButtonLink, H2, Link, Text, H3, TextArea } from '@sourcegraph/wildcard' -import { isValidEmailAddress, fetchThroughSSCProxy } from '../util' +import { isValidEmailAddress, requestSSC } from '../util' import styles from './CodyManageTeamPage.module.scss' @@ -43,7 +43,7 @@ export const InviteUsers: React.FunctionComponent = ({ try { const responses = await Promise.all( emailAddresses.map(emailAddress => - fetchThroughSSCProxy('/team/current/invites', 'POST', { email: emailAddress, role: 'member' }) + requestSSC('/team/current/invites', 'POST', { email: emailAddress, role: 'member' }) ) ) if (responses.some(response => response.status !== 200)) { diff --git a/client/web/src/cody/team/TeamMembers.tsx b/client/web/src/cody/team/TeamMemberList.tsx similarity index 90% rename from client/web/src/cody/team/TeamMembers.tsx rename to client/web/src/cody/team/TeamMemberList.tsx index fb6b7f33727..f25b1ab4a17 100644 --- a/client/web/src/cody/team/TeamMembers.tsx +++ b/client/web/src/cody/team/TeamMemberList.tsx @@ -5,7 +5,7 @@ import classNames from 'classnames' import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry' import { H2, Text, Badge, Link, ButtonLink } from '@sourcegraph/wildcard' -import { fetchThroughSSCProxy } from '../util' +import { requestSSC } from '../util' import styles from './CodyManageTeamPage.module.scss' @@ -17,6 +17,13 @@ export interface TeamMember { role: 'admin' | 'member' } +interface TeamMemberListProps extends TelemetryV2Props { + teamId: string | null + teamMembers: TeamMember[] + invites: TeamInvite[] + isAdmin: boolean +} + export interface TeamInvite { id: string email: string @@ -27,13 +34,6 @@ export interface TeamInvite { acceptedAt: string | null } -interface TeamMemberListProps extends TelemetryV2Props { - teamId: string | null - teamMembers: TeamMember[] - invites: TeamInvite[] - isAdmin: boolean -} - export const TeamMemberList: FunctionComponent = ({ teamId, teamMembers, @@ -43,7 +43,7 @@ export const TeamMemberList: FunctionComponent = ({ }) => { const [loading, setLoading] = useState(false) const [actionResult, setActionResult] = useState<{ message: string; isError: boolean } | null>(null) - const setRole = useCallback( + const updateRole = useCallback( async (accountId: string, newRole: 'member' | 'admin'): Promise => { if (!loading) { // Avoids sending multiple requests at once @@ -52,19 +52,24 @@ export const TeamMemberList: FunctionComponent = ({ privateMetadata: { teamId, accountId }, }) - const response = await fetchThroughSSCProxy( - `/team/current/members/${accountId}?newRole=${newRole}`, - 'PATCH' - ) - if (!response.ok) { + try { + const response = await requestSSC(`/team/current/members/${accountId}?newRole=${newRole}`, 'PATCH') + if (!response.ok) { + setLoading(false) + setActionResult({ + message: `We couldn't modify the user's role (${response.status}). Please try again later.`, + isError: true, + }) + } else { + setLoading(false) + setActionResult({ message: 'Team role updated.', isError: false }) + } + } catch (error) { setLoading(false) setActionResult({ - message: `We couldn't modify the user's role (${response.status}). Please try again later.`, + message: `We couldn't modify the user's role. The error was: "${error}". Please try again later.`, isError: true, }) - } else { - setLoading(false) - setActionResult({ message: 'Team role updated.', isError: false }) } } }, @@ -78,7 +83,7 @@ export const TeamMemberList: FunctionComponent = ({ setLoading(true) telemetryRecorder.recordEvent('cody.team.revokeInvite', 'click', { privateMetadata: { teamId } }) - const response = await fetchThroughSSCProxy(`/team/current/invites/${inviteId}/cancel`, 'POST') + const response = await requestSSC(`/team/current/invites/${inviteId}/cancel`, 'POST') if (!response.ok) { setLoading(false) setActionResult({ @@ -101,7 +106,7 @@ export const TeamMemberList: FunctionComponent = ({ setLoading(true) telemetryRecorder.recordEvent('cody.team.revokeInvite', 'click', { privateMetadata: { teamId } }) - const response = await fetchThroughSSCProxy(`/team/current/invites/${inviteId}/resend`, 'POST') + const response = await requestSSC(`/team/current/invites/${inviteId}/resend`, 'POST') if (!response.ok) { setLoading(false) setActionResult({ @@ -127,7 +132,7 @@ export const TeamMemberList: FunctionComponent = ({ setLoading(true) telemetryRecorder.recordEvent('cody.team.revokeInvite', 'click', { privateMetadata: { teamId } }) - const response = await fetchThroughSSCProxy(`/team/current/members/${accountId}`, 'DELETE') + const response = await requestSSC(`/team/current/members/${accountId}`, 'DELETE') if (!response.ok) { setLoading(false) setActionResult({ @@ -196,7 +201,7 @@ export const TeamMemberList: FunctionComponent = ({
setRole(member.accountId, 'member')} + onClick={() => updateRole(member.accountId, 'member')} className="ml-2" aria-disabled={adminCount < 2} > @@ -208,7 +213,7 @@ export const TeamMemberList: FunctionComponent = ({
setRole(member.accountId, 'admin')} + onClick={() => updateRole(member.accountId, 'admin')} className="ml-2" > Make admin diff --git a/client/web/src/cody/util.ts b/client/web/src/cody/util.ts index 9fd4391541a..0ba03e35c8b 100644 --- a/client/web/src/cody/util.ts +++ b/client/web/src/cody/util.ts @@ -1,4 +1,6 @@ // The URL to direct users in order to manage their Cody Pro subscription. +import { useState, useEffect } from 'react' + export const manageSubscriptionRedirectURL = 'https://accounts.sourcegraph.com/cody/subscription' /** @@ -34,11 +36,14 @@ export function isValidEmailAddress(emailAddress: string): boolean { const emailRegex = /^[^@]+@[^@]+\.[^@]+$/ /** + * So the request is kinda made to two backends. Dotcom's `.api/ssc/proxy` endpoint + * exchanges the Sourcegraph session credentials for a SAMS access token + * and then proxy the request to the SSC backend. * @param sscUrl The SSC API URL to call. Example: "/checkout/session". * @param method E.g. "POST". * @param params The body to send to the SSC API. Will be JSON-encoded. */ -export function fetchThroughSSCProxy(sscUrl: string, method: string, params?: object): Promise { +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. @@ -53,3 +58,25 @@ export function fetchThroughSSCProxy(sscUrl: string, method: string, params?: ob ...(!['GET', 'HEAD'].includes(method) && params ? { body: JSON.stringify(params) } : null), }) } + +// React hook to fetch data through the SSC proxy and convert the response to a more usable format. +// This is a low-level hook that is meant to be used by other hooks that need to fetch data from the SSC API. +export const useSSCQuery = (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] +}