mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 15:51:43 +00:00
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:
parent
919f64b3af
commit
50471a67b1
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
172
client/web/src/cody/invites/AcceptInviteBanner.tsx
Normal file
172
client/web/src/cody/invites/AcceptInviteBanner.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
41
client/web/src/cody/invites/useInviteParams.ts
Normal file
41
client/web/src/cody/invites/useInviteParams.ts
Normal 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 }
|
||||
}
|
||||
114
client/web/src/cody/invites/useInviteState.ts
Normal file
114
client/web/src/cody/invites/useInviteState.ts
Normal 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
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
50
client/web/src/cody/management/api/react-query/invites.ts
Normal file
50
client/web/src/cody/management/api/react-query/invites.ts
Normal 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) }),
|
||||
})
|
||||
}
|
||||
17
client/web/src/cody/management/api/react-query/queryKeys.ts
Normal file
17
client/web/src/cody/management/api/react-query/queryKeys.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
@ -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 }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
16
client/web/src/cody/management/api/react-query/teams.ts
Normal file
16
client/web/src/cody/management/api/react-query/teams.ts
Normal 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()
|
||||
},
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user