PLG: Allow users who are already on a team accept invites (#63231)

Closes https://github.com/sourcegraph/sourcegraph/issues/63078
Part of https://github.com/sourcegraph/self-serve-cody/issues/804 (see
also its backend counterpart
https://github.com/sourcegraph/self-serve-cody/pull/858)

[Figma](https://www.figma.com/design/FMSdn1oKccJRHQPgf7053o/Cody-PLG-GA?node-id=4042-4927&t=Ob2UbemEkft4ofJZ-0)

### Summary

1. Moved the accept invite UI to the "/cody/manage" page.
2. Handled cases where the invited user is already a Cody Pro user.
3. Fixed styles in the CodyAlert component to ensure images are visible.

### Implementation Details

1. Added the `useInviteState` hook, which returns `initialInviteStatus`
and `initialUserStatus`.

We track the initial statuses to determine the appropriate UI variant to
display. For example, if the initial invite status is "sent" (indicating
the invite can be accepted) and the initial user status is
`UserInviteStatus.NoCurrentTeam` (indicating the user is not a member of
any team), a confirmation banner is shown for the user to accept or
decline the invite. After the user responds, the invite query is
invalidated, updating the status to "accepted", "canceled", or "errored"
based on their action. Depending on the result, a success or error
banner is displayed, or the banner is hidden if the invite is canceled.
More configuration examples can be found in the Screenshots section. All
possible states are detailed in the `AcceptInviteBannerContent`.

2. For users who are the sole admin of their current team, the banner is
shown on the "/cody/manage" page.

The design requires showing a banner on the "/cody/team/manage" page to
suggest transferring the admin role to another team member. However,
this page is not yet ready. To sync the banner state with user role
changes or deletion actions, the members list query must be invalidated
after each action. The current implementation of the "cody/manage/team"
page does not support refetching with the `useSSCQuery` hook. To resolve
this, we need to migrate the "cody/manage/team" page to use React Query
to allow query invalidation after each action. For now, users who are
sole admins see a banner on the "cody/manage" page suggesting
transferring the admin role, with a link to the "/cody/manage/team"
page.
<!-- 💡 To write a useful PR description, make sure that your description
covers:
- WHAT this PR is changing:
    - How was it PREVIOUSLY.
    - How it will be from NOW on.
- WHY this PR is needed.
- CONTEXT, i.e. to which initiative, project or RFC it belongs.

The structure of the description doesn't matter as much as covering
these points, so use
your best judgement based on your context.
Learn how to write good pull request description:
https://www.notion.so/sourcegraph/Write-a-good-pull-request-description-610a7fd3e613496eb76f450db5a49b6e?pvs=4
-->


### Screenshots

| Description | Screenshot |
|--|--|
| Failed to define user state OR invite status is not "sent" (thus can't
be accepted) | <img width="1516" alt="Screenshot 2024-06-14 at 14 39 41"
src="https://github.com/sourcegraph/sourcegraph/assets/25318659/cf712239-6a8e-4a66-a4a2-c1932ba70ffd">
|
| User is not on a Cody Pro team | <img width="1516" alt="Screenshot
2024-06-14 at 14 34 30"
src="https://github.com/sourcegraph/sourcegraph/assets/25318659/d6b53dc4-c7df-45eb-a743-9baddfdd8aa3"><img
width="1516" alt="Screenshot 2024-06-14 at 14 34 40"
src="https://github.com/sourcegraph/sourcegraph/assets/25318659/617957cf-8259-4056-a117-8b806ece6efe">|
|on the team they've been invited to|<img width="1516" alt="Screenshot
2024-06-14 at 14 35 41"
src="https://github.com/sourcegraph/sourcegraph/assets/25318659/eaa871ce-acd3-4a7e-a25c-74011a42af58">|
| User is the sole admin of another team |<img width="1516"
alt="Screenshot 2024-06-14 at 14 38 42"
src="https://github.com/sourcegraph/sourcegraph/assets/25318659/1382e5a0-4375-4002-93a4-ec25d354317f">|
| User is on another team |<img width="1516" alt="Screenshot 2024-06-14
at 14 36 38"
src="https://github.com/sourcegraph/sourcegraph/assets/25318659/2bf20073-f49b-4fb1-9996-6143671c1727"><img
width="1516" alt="Screenshot 2024-06-14 at 14 36 43"
src="https://github.com/sourcegraph/sourcegraph/assets/25318659/349e9445-b2ec-402d-ac0a-6b2517abde9c">|




## Test plan

- Checkout the branch from
https://github.com/sourcegraph/self-serve-cody/pull/858 and run SSC
locally
- Run Sourcegraph in dotcom mode
- As a Cody Pro team admin send invites to users that have different
statuses (are not on a team, are members of the team they were invited
to, are members of another team, are sole admins of their teams)
- As the invited user:
  - click the invite link from the email
- modify the hostname in the URL so that it points to the local
Sourcegraph instance
  - ensure the correct banner is displayed
- ensure user can accept/decline the invite (if applicable for the
banner type)
<!-- All pull requests REQUIRE a test plan:
https://docs-legacy.sourcegraph.com/dev/background-information/testing_principles
-->


## Changelog

<!--
1. Ensure your pull request title is formatted as: $type($domain): $what
5. Add bullet list items for each additional detail you want to cover
(see example below)
6. You can edit this after the pull request was merged, as long as
release shipping it hasn't been promoted to the public.
7. For more information, please see this how-to
https://www.notion.so/sourcegraph/Writing-a-changelog-entry-dd997f411d524caabf0d8d38a24a878c?

Audience: TS/CSE > Customers > Teammates (in that order).

Cheat sheet: $type = chore|fix|feat $domain:
source|search|ci|release|plg|cody|local|...
-->

<!--
Example:

Title: fix(search): parse quotes with the appropriate context
Changelog section:

## Changelog

- When a quote is used with regexp pattern type, then ...
- Refactored underlying code.
-->
This commit is contained in:
Taras Yemets 2024-06-14 19:56:49 +03:00 committed by GitHub
parent 919f64b3af
commit 50471a67b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 506 additions and 99 deletions

View File

@ -229,7 +229,10 @@ ts_project(
"src/cody/dashboard/CodyDashboardPage.tsx",
"src/cody/dashboard/UpsellImage.tsx",
"src/cody/editorGroups.ts",
"src/cody/invites/AcceptInviteBanner.tsx",
"src/cody/invites/AcceptInvitePage.tsx",
"src/cody/invites/useInviteParams.ts",
"src/cody/invites/useInviteState.ts",
"src/cody/isCodyEnabled.tsx",
"src/cody/management/CodyManagementPage.tsx",
"src/cody/management/SubscriptionStats.tsx",
@ -239,7 +242,10 @@ ts_project(
"src/cody/management/api/hooks/useApiClient.tsx",
"src/cody/management/api/react-query/QueryClientProvider.tsx",
"src/cody/management/api/react-query/callCodyProApi.ts",
"src/cody/management/api/react-query/invites.ts",
"src/cody/management/api/react-query/queryKeys.ts",
"src/cody/management/api/react-query/subscriptions.ts",
"src/cody/management/api/react-query/teams.ts",
"src/cody/management/api/stripeCheckout.ts",
"src/cody/management/api/teamInvites.ts",
"src/cody/management/api/teamMembers.ts",

View File

@ -11,7 +11,7 @@
.purple-no-icon {
padding-left: 1.5rem;
background-color: var(--violet-02);
background-color: var(--violet-01);
color: var(--gray-08);
:global(.theme-dark) & {
background-color: var(--pink);
@ -20,7 +20,7 @@
}
.purple-success {
background-color: var(--violet-02);
background-color: var(--violet-01);
color: var(--violet-06);
:global(.theme-dark) & {
background-color: var(--pink);
@ -47,7 +47,7 @@
.purple-cody-pro {
/* stylelint-disable-next-line declaration-property-unit-allowed-list */
padding-left: calc(1.5rem + 135px + 1.5rem);
background-color: var(--violet-02);
background-color: var(--violet-01);
color: var(--gray-11);
:global(.theme-dark) & {
background-color: var(--pink);
@ -65,19 +65,43 @@
}
}
.green-cody-pro {
/* stylelint-disable-next-line declaration-property-unit-allowed-list */
padding-left: calc(1.5rem + 135px + 1.5rem);
background-color: #e0f9d5;
color: #054410;
:global(.theme-dark) & {
background-color: #51cf66;
}
&::after {
position: absolute;
bottom: 0;
left: 1.5rem;
/* stylelint-disable-next-line declaration-property-unit-allowed-list */
width: 135px;
/* stylelint-disable-next-line declaration-property-unit-allowed-list */
height: 95px;
background-image: url('https://storage.googleapis.com/sourcegraph-assets/cody-pro-card.png');
background-size: cover;
}
}
.error {
/* stylelint-disable-next-line declaration-property-unit-allowed-list */
padding-left: calc(1.5rem + 124px + 1.5rem);
overflow: hidden;
background-color: var(--oc-orange-1);
color: var(--gray-08);
:global(.theme-dark) & {
background-color: var(--oc-orange-4);
}
&::after {
top: 1.5rem;
left: 2rem;
bottom: 0;
/* stylelint-disable-next-line declaration-property-unit-allowed-list */
width: 124px;
/* stylelint-disable-next-line declaration-property-unit-allowed-list */
height: 95px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 124 99"><g filter="url(%23a)"><path fill="%23D9D9D9" fill-rule="evenodd" d="M10.86 5c-3.32 0-6 3.22-6 7.19v124.62c0 3.97 2.68 7.19 6 7.19h102c3.31 0 6-3.22 6-7.19V12.19c0-3.97-2.69-7.19-6-7.19h-102Zm44.82 11.17c-1.65 0-3 1.6-3 3.59 0 1.98 1.35 3.58 3 3.58h12.36c1.65 0 2.99-1.6 2.99-3.58s-1.34-3.59-3-3.59H55.69Z" clip-rule="evenodd"/><path fill="%23EFF2F5" fill-rule="evenodd" d="M10.86 5c-3.32 0-6 3.22-6 7.19v124.62c0 3.97 2.68 7.19 6 7.19h102c3.31 0 6-3.22 6-7.19V12.19c0-3.97-2.69-7.19-6-7.19h-102Zm44.82 11.17c-1.65 0-3 1.6-3 3.59 0 1.98 1.35 3.58 3 3.58h12.36c1.65 0 2.99-1.6 2.99-3.58s-1.34-3.59-3-3.59H55.69Z" clip-rule="evenodd"/><path fill="%23fff" fill-opacity=".2" fill-rule="evenodd" d="M10.86 5c-3.32 0-6 3.22-6 7.19v124.62c0 3.97 2.68 7.19 6 7.19h102c3.31 0 6-3.22 6-7.19V12.19c0-3.97-2.69-7.19-6-7.19h-102Zm44.82 11.17c-1.65 0-3 1.6-3 3.59 0 1.98 1.35 3.58 3 3.58h12.36c1.65 0 2.99-1.6 2.99-3.58s-1.34-3.59-3-3.59H55.69Z" clip-rule="evenodd"/><path fill="url(%23b)" fill-rule="evenodd" d="M10.86 5c-3.32 0-6 3.22-6 7.19v124.62c0 3.97 2.68 7.19 6 7.19h102c3.31 0 6-3.22 6-7.19V12.19c0-3.97-2.69-7.19-6-7.19h-102Zm44.82 11.17c-1.65 0-3 1.6-3 3.59 0 1.98 1.35 3.58 3 3.58h12.36c1.65 0 2.99-1.6 2.99-3.58s-1.34-3.59-3-3.59H55.69Z" clip-rule="evenodd"/><path stroke="%23000" stroke-opacity=".16" stroke-width=".4" d="M5.06 12.19c0-3.9 2.63-6.99 5.8-6.99h102c3.17 0 5.8 3.1 5.8 6.99v124.62c0 3.9-2.63 6.99-5.8 6.99h-102c-3.17 0-5.8-3.1-5.8-6.99V12.19Zm50.62 3.78c-1.8 0-3.2 1.73-3.2 3.79 0 2.05 1.4 3.78 3.2 3.78h12.36c1.8 0 3.19-1.73 3.19-3.78 0-2.06-1.4-3.79-3.2-3.79H55.69Z"/><path fill="%23DBE2F0" d="M63.67 69.33h-3.34v-6.66h3.34v6.66Zm0 6.67h-3.34v-3.33h3.34V76Zm-20 5h36.66L62 49.33 43.67 81Z"/></g><defs><linearGradient id="b" x1="62.09" x2="62.09" y1="137.07" y2="5" gradientUnits="userSpaceOnUse"><stop offset=".43" stop-color="%23fff"/><stop offset="1" stop-color="%23fff" stop-opacity="0"/></linearGradient><filter id="a" width="123" height="148" x=".36" y=".5" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feMorphology in="SourceAlpha" operator="dilate" radius="1" result="effect1_dropShadow_5081_15909"/><feOffset/><feGaussianBlur stdDeviation="1.75"/><feColorMatrix values="0 0 0 0 0.141522 0 0 0 0 0.159783 0 0 0 0 0.21 0 0 0 0.31 0"/><feBlend in2="BackgroundImageFix" result="effect1_dropShadow_5081_15909"/><feBlend in="SourceGraphic" in2="effect1_dropShadow_5081_15909" result="shape"/></filter></defs></svg>');
background-repeat: no-repeat;
}
}

View File

@ -5,7 +5,7 @@ import classNames from 'classnames'
import styles from './CodyAlert.module.scss'
interface CodyAlertProps extends React.HTMLAttributes<HTMLDivElement> {
variant: 'purple' | 'greenSuccess' | 'purpleSuccess' | 'purpleCodyPro' | 'error'
variant: 'purple' | 'greenSuccess' | 'purpleSuccess' | 'purpleCodyPro' | 'greenCodyPro' | 'error'
className?: string
children: React.ReactNode
}
@ -19,6 +19,7 @@ export const CodyAlert: React.FunctionComponent<CodyAlertProps> = ({ variant, cl
[styles.greenSuccess]: variant === 'greenSuccess',
[styles.purpleSuccess]: variant === 'purpleSuccess',
[styles.purpleCodyPro]: variant === 'purpleCodyPro',
[styles.greenCodyPro]: variant === 'greenCodyPro',
[styles.error]: variant === 'error',
},
className

View File

@ -0,0 +1,172 @@
import { Button, ButtonLink, H1, Text } from '@sourcegraph/wildcard'
import { CodyProRoutes } from '../codyProRoutes'
import { CodyAlert } from '../components/CodyAlert'
import { useAcceptInvite, useCancelInvite } from '../management/api/react-query/invites'
import { useInviteParams } from './useInviteParams'
import { UserInviteStatus, useInviteState } from './useInviteState'
export const AcceptInviteBanner: React.FC<{ onSuccess: () => unknown }> = ({ onSuccess }) => {
const { inviteParams, clearInviteParams } = useInviteParams()
if (!inviteParams) {
return null
}
return (
<AcceptInviteBannerContent
teamId={inviteParams.teamId}
inviteId={inviteParams.inviteId}
onSuccess={onSuccess}
clearInviteParams={clearInviteParams}
/>
)
}
const AcceptInviteBannerContent: React.FC<{
teamId: string
inviteId: string
onSuccess: () => unknown
clearInviteParams: () => void
}> = ({ teamId, inviteId, onSuccess, clearInviteParams }) => {
const inviteState = useInviteState(teamId, inviteId)
const acceptInviteMutation = useAcceptInvite()
const cancelInviteMutation = useCancelInvite()
if (inviteState.status === 'loading') {
return null
}
if (
inviteState.status === 'error' ||
inviteState.initialInviteStatus !== 'sent' ||
inviteState.initialUserStatus === UserInviteStatus.Error
) {
return (
<CodyAlert variant="error">
<H1 as="p" className="mb-2">
Issue with invite
</H1>
<Text className="mb-0">The invitation is no longer valid. Contact your team admin.</Text>
</CodyAlert>
)
}
switch (inviteState.initialUserStatus) {
case UserInviteStatus.NoCurrentTeam:
case UserInviteStatus.AnotherTeamMember: {
// Invite has been canceled. Remove the banner.
if (cancelInviteMutation.isSuccess || cancelInviteMutation.isError) {
return null
}
switch (acceptInviteMutation.status) {
case 'error': {
return (
<CodyAlert variant="error">
<H1 as="p" className="mb-2">
Issue with invite
</H1>
<Text className="mb-0">
Accepting invite failed with error: {acceptInviteMutation.error.message}.
</Text>
</CodyAlert>
)
}
case 'success': {
return (
<CodyAlert variant="greenCodyPro">
<H1 as="p" className="mb-2">
Pro team change complete!
</H1>
<Text>
{inviteState.initialUserStatus === UserInviteStatus.NoCurrentTeam
? 'You successfully joined the new Cody Pro team.'
: 'Your pro team has been successfully changed.'}
</Text>
</CodyAlert>
)
}
case 'idle':
case 'pending':
default: {
return (
<CodyAlert variant="purple">
<H1 as="p" className="mb-2">
Join new Cody Pro team?
</H1>
<Text>You've been invited to a new Cody Pro team by {inviteState.sentBy}.</Text>
<Text>
{inviteState.initialUserStatus === UserInviteStatus.NoCurrentTeam
? 'You will get unlimited autocompletions, chat messages and commands.'
: 'This will terminate your current Cody Pro plan, and place you on the new Cody Pro team. You will not lose access to your Cody Pro benefits.'}
</Text>
<div>
<Button
variant="primary"
disabled={acceptInviteMutation.isPending || cancelInviteMutation.isPending}
className="mr-3"
onClick={() =>
acceptInviteMutation.mutate(
{ teamId, inviteId },
{ onSuccess, onSettled: clearInviteParams }
)
}
>
Accept
</Button>
<Button
variant="secondary"
disabled={acceptInviteMutation.isPending || cancelInviteMutation.isPending}
onClick={() =>
cancelInviteMutation.mutate(
{ teamId, inviteId },
{ onSettled: clearInviteParams }
)
}
>
Decline
</Button>
</div>
</CodyAlert>
)
}
}
}
case UserInviteStatus.InvitedTeamMember: {
if (cancelInviteMutation.isIdle) {
void cancelInviteMutation.mutate({ teamId, inviteId }, { onSettled: clearInviteParams })
}
return (
<CodyAlert variant="error">
<H1 as="p" className="mb-2">
Issue with invite
</H1>
<Text>You've been invited to a Cody Pro team by {inviteState.sentBy}.</Text>
<Text className="mb-0">You cannot accept this invite as as you are already on this team.</Text>
</CodyAlert>
)
}
case UserInviteStatus.AnotherTeamSoleAdmin: {
return (
<CodyAlert variant="error">
<H1 as="p" className="mb-2">
Issue with invite
</H1>
<Text className="mb-0">You've been invited to a new Cody Pro team by {inviteState.sentBy}.</Text>
<Text>
To accept this invite you need to transfer your administrative role to another member of your
team and click the invite link again.
</Text>
<div>
<ButtonLink variant="primary" to={CodyProRoutes.ManageTeam}>
Manage team
</ButtonLink>
</div>
</CodyAlert>
)
}
default: {
return null
}
}
}

View File

@ -1,18 +1,12 @@
import React, { useEffect } from 'react'
import classNames from 'classnames'
import { useNavigate } from 'react-router-dom'
import { Navigate, useLocation } from 'react-router-dom'
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
import { PageHeader, Text, H3, useSearchParameters } from '@sourcegraph/wildcard'
import type { AuthenticatedUser } from '../../auth'
import { withAuthenticatedUser } from '../../auth/withAuthenticatedUser'
import { Page } from '../../components/Page'
import { PageTitle } from '../../components/PageTitle'
import { CodyAlert } from '../components/CodyAlert'
import { WhiteIcon } from '../components/WhiteIcon'
import { requestSSC } from '../util'
import { CodyProRoutes } from '../codyProRoutes'
interface CodyAcceptInvitePageProps extends TelemetryV2Props {
authenticatedUser: AuthenticatedUser
@ -21,71 +15,14 @@ interface CodyAcceptInvitePageProps extends TelemetryV2Props {
const AuthenticatedCodyAcceptInvitePage: React.FunctionComponent<CodyAcceptInvitePageProps> = ({
telemetryRecorder,
}) => {
const location = useLocation()
useEffect(() => {
telemetryRecorder.recordEvent('cody.invites.accept', 'view')
}, [telemetryRecorder])
const navigate = useNavigate()
// Process query params
const parameters = useSearchParameters()
const teamId = parameters.get('teamID')
const inviteId = parameters.get('inviteID')
const [loading, setLoading] = React.useState(true)
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
useEffect(() => {
async function postAcceptInvite(): Promise<void> {
if (!teamId || !inviteId) {
setErrorMessage('Invalid invite ID or team ID')
setLoading(false)
return
}
const response = await requestSSC(`/team/${teamId}/invites/${inviteId}/accept`, 'POST')
setLoading(false)
if (response.ok) {
// Wait a second before navigating to the manage team page so that the user sees the success alert.
await new Promise(resolve => setTimeout(resolve, 1000))
navigate('/cody/manage?welcome=1')
} else {
setErrorMessage(await response.text())
}
}
void postAcceptInvite()
}, [inviteId, navigate, teamId])
return (
<Page className={classNames('d-flex flex-column')}>
<PageTitle title="Manage Cody team" />
<PageHeader className="mb-4 mt-4">
<PageHeader.Heading as="h2" styleAs="h1">
<div className="d-inline-flex align-items-center">
<WhiteIcon name="mdi-account-multiple-plus-gradient" className="mr-3" />
Join Cody Pro team
</div>
</PageHeader.Heading>
</PageHeader>
{errorMessage ? (
<CodyAlert variant="error">
<H3>We couldn't accept the invite.</H3>
<Text size="small" className="text-muted mb-0">
{errorMessage}
</Text>
</CodyAlert>
) : null}
{!loading && !errorMessage ? (
<CodyAlert variant="greenSuccess">
<H3>Invite accepted!</H3>
<Text size="small" className="mb-0">
You are now a member of the team. We'll now redirect you to the team page.
</Text>
</CodyAlert>
) : null}
</Page>
)
// navigate to the manage page and passthrough the search params
return <Navigate to={CodyProRoutes.Manage + location.search} replace={true} />
}
export const CodyAcceptInvitePage = withAuthenticatedUser(AuthenticatedCodyAcceptInvitePage)

View File

@ -0,0 +1,41 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
type UseInviteParamsHook = () => {
inviteParams: { teamId: string; inviteId: string } | undefined
clearInviteParams: () => void
}
export const useInviteParams: UseInviteParamsHook = () => {
const [searchParams, setSearchParams] = useSearchParams()
const [inviteParams, setInviteParams] = useState<{ teamId: string; inviteId: string }>()
useEffect(() => {
setInviteParams(s => {
if (s) {
return s
}
const teamId = searchParams.get('teamID')
const inviteId = searchParams.get('inviteID')
if (teamId && inviteId) {
return { teamId, inviteId }
}
return undefined
})
}, [searchParams])
const clearInviteParams = useCallback(
() =>
setSearchParams(params => {
params.delete('teamID')
params.delete('inviteID')
return params
}),
[setSearchParams]
)
return { inviteParams, clearInviteParams }
}

View File

@ -0,0 +1,114 @@
import { useEffect, useMemo, useState } from 'react'
import { CodyProApiError } from '../management/api/react-query/callCodyProApi'
import { useInvite } from '../management/api/react-query/invites'
import { useSubscriptionSummary } from '../management/api/react-query/subscriptions'
import { useTeamMembers } from '../management/api/react-query/teams'
import type { TeamInvite } from '../management/api/teamInvites'
export enum UserInviteStatus {
Error = 'error',
NoCurrentTeam = 'no_current_team',
InvitedTeamMember = 'invited_team_member',
AnotherTeamMember = 'another_team_member',
AnotherTeamSoleAdmin = 'another_team_sole_admin',
}
type UseInviteStateHook = (
teamId: string,
inviteId: string
) =>
| { status: 'loading' }
| { status: 'error' }
| {
status: 'success'
initialInviteStatus: TeamInvite['status']
sentBy: TeamInvite['sentBy']
initialUserStatus: UserInviteStatus
}
export const useInviteState: UseInviteStateHook = (teamId, inviteId) => {
const inviteQuery = useInvite({ teamId, inviteId })
const subscriptionSummaryQuery = useSubscriptionSummary()
const teamMembersQuery = useTeamMembers()
const [initialInviteStatus, setInitialInviteStatus] = useState<TeamInvite['status']>()
const [initialUserStatus, setInitialUserStatus] = useState<UserInviteStatus>()
useEffect(() => {
setInitialUserStatus(prevStatus => {
// If user status is already defined, use it.
if (prevStatus !== undefined) {
return prevStatus
}
if (subscriptionSummaryQuery.isPending) {
return undefined
}
// There are two distinct cases when subscription summary query may fail:
// 1. 404 error indicating that user is not on a team yet. Return the no current team status.
// 2. Other kind of error indicating that we failed to get subscription data for other reason.
// We can't define user status without subscription summary data so return the error status.
if (subscriptionSummaryQuery.isError || !subscriptionSummaryQuery.data) {
return subscriptionSummaryQuery.error instanceof CodyProApiError &&
subscriptionSummaryQuery.error.status === 404
? UserInviteStatus.NoCurrentTeam
: UserInviteStatus.Error
}
// User is already on the team they have been invited to.
if (subscriptionSummaryQuery.data.teamId === teamId) {
return UserInviteStatus.InvitedTeamMember
}
// User is on another team.
// If user is admin, check if they are a sole admin on a team.
if (subscriptionSummaryQuery.data.userRole === 'admin') {
if (teamMembersQuery.isPending) {
return undefined
}
if (teamMembersQuery.isError || !teamMembersQuery.data) {
// We can't define whether the user is a sole admin on a team.
// Return error status.
return UserInviteStatus.Error
}
const currentTeamAdminsCount = teamMembersQuery.data.members.filter(
member => member.role === 'admin'
).length
if (currentTeamAdminsCount === 1) {
return UserInviteStatus.AnotherTeamSoleAdmin
}
}
// User is either a member or one of several admins (not the sole admin) of another team.
return UserInviteStatus.AnotherTeamMember
})
}, [teamId, subscriptionSummaryQuery, teamMembersQuery, setInitialUserStatus])
useEffect(() => {
setInitialInviteStatus(s => s || inviteQuery.data?.status)
}, [inviteQuery, setInitialInviteStatus])
const state: ReturnType<UseInviteStateHook> = useMemo(() => {
if (inviteQuery.isError || (inviteQuery.isSuccess && !inviteQuery.data)) {
return { status: 'error' }
}
if (!inviteQuery.data || !initialInviteStatus || !initialUserStatus) {
return { status: 'loading' }
}
return {
status: 'success',
initialInviteStatus,
initialUserStatus,
sentBy: inviteQuery.data.sentBy,
}
}, [inviteQuery.isError, inviteQuery.isSuccess, inviteQuery.data, initialInviteStatus, initialUserStatus])
return state
}

View File

@ -20,6 +20,7 @@ import {
} from '../../graphql-operations'
import { CodyAlert } from '../components/CodyAlert'
import { CodyProIcon, DashboardIcon } from '../components/CodyIcon'
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'
@ -64,7 +65,7 @@ export const CodyManagementPage: React.FunctionComponent<CodyManagementPageProps
const welcomeToPro = parameters.get('welcome') === '1'
const { data, error: dataError } = useQuery<UserCodyPlanResult, UserCodyPlanVariables>(USER_CODY_PLAN, {})
const { data, error: dataError, refetch } = useQuery<UserCodyPlanResult, UserCodyPlanVariables>(USER_CODY_PLAN, {})
const { data: usageData, error: usageDateError } = useQuery<UserCodyUsageResult, UserCodyUsageVariables>(
USER_CODY_USAGE,
@ -104,8 +105,9 @@ export const CodyManagementPage: React.FunctionComponent<CodyManagementPageProps
<>
<Page className={classNames('d-flex flex-column')}>
<PageTitle title="Dashboard" />
<AcceptInviteBanner onSuccess={refetch} />
{welcomeToPro && (
<CodyAlert variant="purpleCodyPro">
<CodyAlert variant="greenCodyPro">
<H2 className="mt-4">Welcome to Cody Pro</H2>
<Text size="small" className="mb-0">
You now have Cody Pro with access to unlimited autocomplete, chats, and commands.

View File

@ -46,6 +46,26 @@ export module Client {
return { method: 'POST', urlSuffix: '/team/preview', requestBody }
}
// Team members
export function getCurrentTeamMembers(): Call<types.ListTeamMembersResponse> {
return { method: 'GET', urlSuffix: '/team/current/members' }
}
// Invites
export function getInvite(teamId: string, inviteId: string): Call<types.TeamInvite> {
return { method: 'GET', urlSuffix: `/team/${teamId}/invites/${inviteId}` }
}
export function acceptInvite(teamId: string, inviteId: string): Call<unknown> {
return { method: 'POST', urlSuffix: `/team/${teamId}/invites/${inviteId}/accept` }
}
export function cancelInvite(teamId: string, inviteId: string): Call<unknown> {
return { method: 'POST', urlSuffix: `/team/${teamId}/invites/${inviteId}/cancel` }
}
// Stripe Checkout
export function createStripeCheckoutSession(

View File

@ -31,7 +31,7 @@ const buildRequestInit = ({ headers = {}, ...init }: RequestInit): RequestInit =
const signOutAndRedirectToSignIn = async (): Promise<void> => {
const response = await fetch('/-/sign-out', buildRequestInit({ method: 'GET' }))
if (response.ok) {
window.location.href = `/sign-in?returnTo=${window.location.pathname}`
window.location.href = `/sign-in?returnTo=${window.location.pathname + window.location.search}`
}
}
@ -55,7 +55,7 @@ export const callCodyProApi = async (call: Call<unknown>): Promise<Response> =>
// Throw errors for unsuccessful HTTP calls so that `callCodyProApi` callers don't need to check whether the response is OK.
// Motivation taken from here: https://tanstack.com/query/latest/docs/framework/react/guides/query-functions#usage-with-fetch-and-other-clients-that-do-not-throw-by-default
throw new CodyProApiError(`Request to Cody Pro API failed: ${await response.text()}`, response.status)
throw new CodyProApiError(await response.text(), response.status)
}
return response

View File

@ -0,0 +1,50 @@
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult,
} from '@tanstack/react-query'
import { Client } from '../client'
import type { TeamInvite } from '../teamInvites'
import { callCodyProApi } from './callCodyProApi'
import { queryKeys } from './queryKeys'
export const useInvite = ({
teamId,
inviteId,
}: {
teamId: string
inviteId: string
}): UseQueryResult<TeamInvite | undefined> =>
useQuery({
queryKey: queryKeys.invites.invite(teamId, inviteId),
queryFn: async () => {
const response = await callCodyProApi(Client.getInvite(teamId, inviteId))
return response?.json()
},
})
export const useAcceptInvite = (): UseMutationResult<unknown, Error, { teamId: string; inviteId: string }> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ teamId, inviteId }) => callCodyProApi(Client.acceptInvite(teamId, inviteId)),
onSuccess: (_, { teamId, inviteId }) =>
Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }),
queryClient.invalidateQueries({ queryKey: queryKeys.teams.all }),
queryClient.invalidateQueries({ queryKey: queryKeys.invites.invite(teamId, inviteId) }),
]),
})
}
export const useCancelInvite = (): UseMutationResult<unknown, Error, { teamId: string; inviteId: string }> => {
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) }),
})
}

View File

@ -0,0 +1,17 @@
// Use query key factories to re-use produced query keys in queries and mutations.
// Motivation taken from here: https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories
export const queryKeys = {
subscriptions: {
all: ['subscription'] as const,
subscription: () => [...queryKeys.subscriptions.all, 'current-subscription'] as const,
subscriptionSummary: () => [...queryKeys.subscriptions.all, 'current-subscription-summary'] as const,
},
teams: {
all: ['team'] as const,
teamMembers: () => [...queryKeys.teams.all, 'members'] as const,
},
invites: {
all: ['invite'] as const,
invite: (teamId: string, inviteId: string) => [...queryKeys.invites.all, teamId, inviteId] as const,
},
}

View File

@ -10,27 +10,30 @@ import { Client } from '../client'
import type {
UpdateSubscriptionRequest,
Subscription,
SubscriptionSummary,
CreateTeamRequest,
PreviewResult,
PreviewCreateTeamRequest,
} from '../types'
} from '../teamSubscriptions'
import { callCodyProApi } from './callCodyProApi'
// Use query key factories to re-use produced query keys in queries and mutations.
// Motivation taken from here: https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories
const queryKeys = {
all: ['subscription'] as const,
subscription: () => [...queryKeys.all, 'current-subscription'] as const,
subscriptionSummary: () => [...queryKeys.all, 'current-subscription-summary'] as const,
}
import { queryKeys } from './queryKeys'
export const useCurrentSubscription = (): UseQueryResult<Subscription | undefined> =>
useQuery({
queryKey: queryKeys.subscription(),
queryKey: queryKeys.subscriptions.subscription(),
queryFn: async () => {
const response = await callCodyProApi(Client.getCurrentSubscription())
return response.ok ? response.json() : undefined
return response?.json()
},
})
export const useSubscriptionSummary = (): UseQueryResult<SubscriptionSummary | undefined> =>
useQuery({
queryKey: queryKeys.subscriptions.subscriptionSummary(),
queryFn: async () => {
const response = await callCodyProApi(Client.getCurrentSubscriptionSummary())
return response?.json()
},
})
@ -43,17 +46,17 @@ export const useUpdateCurrentSubscription = (): UseMutationResult<
return useMutation({
mutationFn: async requestBody => {
const response = await callCodyProApi(Client.updateCurrentSubscription(requestBody))
return (await response.json()) as Subscription
return response?.json()
},
onSuccess: data => {
// We get updated subscription data in response - no need to refetch subscription.
// All the `queryKeys.subscription()` subscribers (`useCurrentSubscription` callers) will get the updated value automatically.
queryClient.setQueryData(queryKeys.subscription(), data)
queryClient.setQueryData(queryKeys.subscriptions.subscription(), data)
// Invalidate `queryKeys.subscriptionSummary()` queries. If the subscription summary is a subset of subscription, we can
// derive the updated subscription summary from the subscription response eliminating the need in subscription summary query invalidation
// causing data refetching.
return queryClient.invalidateQueries({ queryKey: queryKeys.subscriptionSummary() })
return queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.subscriptionSummary() })
},
})
}
@ -64,7 +67,7 @@ export const useCreateTeam = (): UseMutationResult<void, Error, CreateTeamReques
mutationFn: async requestBody => {
await callCodyProApi(Client.createTeam(requestBody))
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.all }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }),
})
}

View File

@ -0,0 +1,16 @@
import { useQuery, type UseQueryResult } from '@tanstack/react-query'
import { Client } from '../client'
import type { ListTeamMembersResponse } from '../teamMembers'
import { callCodyProApi } from './callCodyProApi'
import { queryKeys } from './queryKeys'
export const useTeamMembers = (): UseQueryResult<ListTeamMembersResponse | undefined> =>
useQuery({
queryKey: queryKeys.teams.teamMembers(),
queryFn: async () => {
const response = await callCodyProApi(Client.getCurrentTeamMembers())
return response?.json()
},
})

View File

@ -1,4 +1,4 @@
import { TeamRole } from './teamMembers'
import type { TeamRole } from './teamMembers'
export type TeamInviteStatus = 'sent' | 'errored' | 'accepted' | 'canceled'
@ -12,6 +12,7 @@ export interface TeamInvite {
error?: string
sentAt: Date
sentBy: string
acceptedAt?: Date
}
@ -21,6 +22,6 @@ export interface CreateTeamInviteRequest {
}
export interface ListTeamInvitesResponse {
invites: TeamInvite[]
invites: Omit<TeamInvite, 'sentBy'>[]
continuationToken?: string
}

View File

@ -63,7 +63,7 @@ const AuthenticatedCodySubscriptionManagePage: React.FC<Props> = ({ telemetryRec
// This page only applies to users who have a Cody Pro subscription to manage.
// Otherwise, direct them to the ./new page to sign up.
if (subscriptionData.plan !== CodySubscriptionPlan.PRO) {
return <Navigate to="/cody/manage/subscription/new" replace={true} />
return <Navigate to={CodyProRoutes.NewProSubscription} replace={true} />
}
return (

View File

@ -22,6 +22,7 @@ import {
type UserCodyPlanVariables,
CodySubscriptionPlan,
} from '../../../../graphql-operations'
import { CodyProRoutes } from '../../../codyProRoutes'
import { WhiteIcon } from '../../../components/WhiteIcon'
import { USER_CODY_PLAN } from '../../../subscription/queries'
import { defaultCodyProApiClientContext, CodyProApiClientContext } from '../../api/components/CodyProApiClient'
@ -65,7 +66,7 @@ const AuthenticatedNewCodyProSubscriptionPage: FunctionComponent<NewCodyProSubsc
throw dataLoadError
}
if (data?.currentUser?.codySubscription?.plan === CodySubscriptionPlan.PRO) {
return <Navigate to="/cody/manage" replace={true} />
return <Navigate to={CodyProRoutes.Manage} replace={true} />
}
return (

View File

@ -11,6 +11,7 @@ import type { AuthenticatedUser } from '../../auth'
import { withAuthenticatedUser } from '../../auth/withAuthenticatedUser'
import { Page } from '../../components/Page'
import { PageTitle } from '../../components/PageTitle'
import { CodyProRoutes } from '../codyProRoutes'
import { CodyAlert } from '../components/CodyAlert'
import { WhiteIcon } from '../components/WhiteIcon'
import { useCodySubscriptionSummaryData } from '../subscription/subscriptionSummary'
@ -73,6 +74,7 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent<CodyManageTeamPag
<>
<Page className={classNames('d-flex flex-column')}>
<PageTitle title="Manage Cody team" />
{/* {isAdmin ? <AcceptInviteBanner /> : null} */}
<PageHeader
className="mb-4 mt-4"
actions={
@ -93,7 +95,7 @@ const AuthenticatedCodyManageTeamPage: React.FunctionComponent<CodyManageTeamPag
</Link>
<Button
as={Link}
to="/cody/manage/subscription/new"
to={CodyProRoutes.NewProSubscription}
variant="success"
className="text-nowrap"
>