mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 17:31:43 +00:00
SSC: Teams and Invites: Create "Manage team" page (#62453)
This commit is contained in:
parent
066cb1e7b2
commit
99ebb4f89a
@ -248,6 +248,10 @@ ts_project(
|
||||
"src/cody/subscription/CodySubscriptionPage.tsx",
|
||||
"src/cody/subscription/queries.tsx",
|
||||
"src/cody/switch-account/CodySwitchAccountPage.tsx",
|
||||
"src/cody/team/CodyManageTeamPage.tsx",
|
||||
"src/cody/team/InviteUsers.tsx",
|
||||
"src/cody/team/TeamMembers.tsx",
|
||||
"src/cody/team/WhiteIcon.tsx",
|
||||
"src/cody/upsell/ChatBrandIcon.tsx",
|
||||
"src/cody/upsell/CodyUpsellPage.tsx",
|
||||
"src/cody/upsell/CompletionsBrandIcon.tsx",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js'
|
||||
import * as stripeJs from '@stripe/stripe-js'
|
||||
import type { Stripe } from '@stripe/stripe-js'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { H3, Text } from '@sourcegraph/wildcard'
|
||||
@ -11,14 +11,14 @@ import { H3, Text } from '@sourcegraph/wildcard'
|
||||
* render an iframe into, that will host a Stripe Checkout-hosted form.
|
||||
*/
|
||||
export const CodyProCheckoutForm: React.FunctionComponent<{
|
||||
stripeHandle: Promise<stripeJs.Stripe | null>
|
||||
stripePromise: Promise<Stripe | null>
|
||||
customerEmail: string | undefined
|
||||
}> = ({ stripeHandle, customerEmail }) => {
|
||||
}> = ({ stripePromise, customerEmail }) => {
|
||||
const [clientSecret, setClientSecret] = useState('')
|
||||
const [errorDetails, setErrorDetails] = useState('')
|
||||
const [urlSearchParams] = useSearchParams()
|
||||
|
||||
// Optionally support the "showCouponCodeAtCheckout" URL query parameter, which if present
|
||||
// Optionally support the "showCouponCodeAtCheckout" URL query parameter, which is present
|
||||
// will display a "promotional code" element in the Stripe Checkout UI.
|
||||
const showPromoCodeField = urlSearchParams.get('showCouponCodeAtCheckout') !== null
|
||||
|
||||
@ -44,7 +44,7 @@ export const CodyProCheckoutForm: React.FunctionComponent<{
|
||||
)}
|
||||
|
||||
{clientSecret && (
|
||||
<EmbeddedCheckoutProvider stripe={stripeHandle} options={options}>
|
||||
<EmbeddedCheckoutProvider stripe={stripePromise} options={options}>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
)}
|
||||
@ -75,6 +75,7 @@ async function createCheckoutSession(
|
||||
// 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',
|
||||
@ -91,7 +92,7 @@ async function createCheckoutSession(
|
||||
// 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 intersitular or welcome prompt, to give various things to sync.
|
||||
// some interstitial or welcome prompt, to give various things to sync.
|
||||
returnUrl: `${origin}/cody/manage?session_id={CHECKOUT_SESSION_ID}`,
|
||||
}),
|
||||
})
|
||||
@ -102,7 +103,7 @@ async function createCheckoutSession(
|
||||
setClientSecret(typedResp.clientSecret)
|
||||
} else {
|
||||
// Pass any 4xx or 5xx directly to the user. We expect the
|
||||
// server to have properly redcated any sensive information.
|
||||
// server to have properly redacted any sensitive information.
|
||||
setErrorDetails(responseBody)
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, type FunctionComponent } from 'react'
|
||||
|
||||
import { Elements } from '@stripe/react-stripe-js'
|
||||
// NOTE: A side effect of loading this library will update the DOM and
|
||||
@ -17,7 +17,11 @@ import type { AuthenticatedUser } from '../../../../auth'
|
||||
import { withAuthenticatedUser } from '../../../../auth/withAuthenticatedUser'
|
||||
import { Page } from '../../../../components/Page'
|
||||
import { PageTitle } from '../../../../components/PageTitle'
|
||||
import { type UserCodyPlanResult, type UserCodyPlanVariables } from '../../../../graphql-operations'
|
||||
import {
|
||||
type UserCodyPlanResult,
|
||||
type UserCodyPlanVariables,
|
||||
CodySubscriptionPlan,
|
||||
} from '../../../../graphql-operations'
|
||||
import { CodyProIcon } from '../../../components/CodyIcon'
|
||||
import { USER_CODY_PLAN } from '../../../subscription/queries'
|
||||
|
||||
@ -33,7 +37,7 @@ interface NewCodyProSubscriptionPageProps extends TelemetryV2Props {
|
||||
authenticatedUser: AuthenticatedUser
|
||||
}
|
||||
|
||||
const AuthenticatedNewCodyProSubscriptionPage: React.FunctionComponent<NewCodyProSubscriptionPageProps> = ({
|
||||
const AuthenticatedNewCodyProSubscriptionPage: FunctionComponent<NewCodyProSubscriptionPageProps> = ({
|
||||
authenticatedUser,
|
||||
telemetryRecorder,
|
||||
}) => {
|
||||
@ -46,7 +50,7 @@ const AuthenticatedNewCodyProSubscriptionPage: React.FunctionComponent<NewCodyPr
|
||||
if (dataLoadError) {
|
||||
throw dataLoadError
|
||||
}
|
||||
if (data?.currentUser?.codySubscription?.plan === 'PRO') {
|
||||
if (data?.currentUser?.codySubscription?.plan === CodySubscriptionPlan.PRO) {
|
||||
return <Navigate to="/cody/manage" replace={true} />
|
||||
}
|
||||
|
||||
@ -70,7 +74,7 @@ const AuthenticatedNewCodyProSubscriptionPage: React.FunctionComponent<NewCodyPr
|
||||
<Container>
|
||||
<Elements stripe={stripePromise} options={{ appearance: stripeElementsAppearance }}>
|
||||
<CodyProCheckoutForm
|
||||
stripeHandle={stripePromise}
|
||||
stripePromise={stripePromise}
|
||||
customerEmail={authenticatedUser?.emails[0].email || ''}
|
||||
/>
|
||||
</Elements>
|
||||
|
||||
@ -64,3 +64,15 @@
|
||||
background-color: #eff2f5;
|
||||
background-image: linear-gradient(90deg, #7048e8, #4ac1e8 32.21%, #a112ff 65.39%, #ff5543 104.43%), none;
|
||||
}
|
||||
|
||||
.pro-badge {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
// stylelint-disable-next-line declaration-property-unit-allowed-list
|
||||
width: 22px;
|
||||
// stylelint-disable-next-line declaration-property-unit-allowed-list
|
||||
height: 16px;
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 17"><path fill="%23fff" fill-opacity=".46" d="M16.3 11.04a.96.96 0 0 1-.64.22 1 1 0 0 1-.65-.22 1.4 1.4 0 0 1-.4-.6 2.63 2.63 0 0 1-.13-.85c0-.32.05-.61.13-.86.1-.26.23-.45.4-.6.18-.15.4-.22.65-.22.26 0 .47.07.64.22.17.15.3.34.39.6.09.25.13.54.13.86 0 .32-.04.6-.13.86-.09.25-.22.45-.39.6zM5.83 8.7H6.9c.3 0 .55-.05.74-.15a1 1 0 0 0 .45-.44c.1-.19.14-.4.14-.64 0-.25-.05-.46-.14-.64a.99.99 0 0 0-.45-.43c-.2-.1-.44-.16-.75-.16H5.83V8.7z"/><path fill="%23fff" fill-opacity=".46" fill-rule="evenodd" d="M3.76.87A3.74 3.74 0 0 0 0 4.61v8.53a3.74 3.74 0 0 0 3.76 3.73h14.48A3.74 3.74 0 0 0 22 13.14V4.61A3.74 3.74 0 0 0 18.24.87H3.76zM14.2 12.1c.4.24.9.36 1.45.36s1.05-.12 1.46-.36c.41-.24.73-.57.95-1 .22-.43.33-.93.33-1.5a3.2 3.2 0 0 0-.33-1.49 2.4 2.4 0 0 0-.95-1c-.4-.24-.9-.36-1.46-.36s-1.04.12-1.45.36a2.4 2.4 0 0 0-.8.73V6.8a1.77 1.77 0 0 0-.48-.06 1.35 1.35 0 0 0-1.33 1.05h-.06V6.8h-1.5v5.53h1.55V9.2c0-.22.05-.42.15-.6.1-.17.24-.3.42-.4.18-.1.38-.15.6-.15a2.71 2.71 0 0 1 .5.05 3.2 3.2 0 0 0-.33 1.49c0 .56.1 1.06.33 1.5.22.42.54.75.95 1zM4.27 4.97v7.37h1.56V9.95h1.32c.57 0 1.06-.1 1.46-.31.4-.21.7-.5.92-.88s.32-.8.32-1.3c0-.48-.1-.91-.32-1.29-.2-.38-.5-.67-.9-.88a3 3 0 0 0-1.44-.32H4.27z" clip-rule="evenodd"/></svg>');
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { type ReactElement, useEffect } from 'react'
|
||||
import React, { type ReactElement, useEffect, useMemo } from 'react'
|
||||
|
||||
import { mdiArrowLeft, mdiInformationOutline, mdiTrendingUp, mdiCreditCardOutline } from '@mdi/js'
|
||||
import classNames from 'classnames'
|
||||
@ -29,7 +29,7 @@ import type { UserCodyPlanResult, UserCodyPlanVariables } from '../../graphql-op
|
||||
import { EventName } from '../../util/constants'
|
||||
import { CodyColorIcon } from '../chat/CodyPageIcon'
|
||||
import { isCodyEnabled } from '../isCodyEnabled'
|
||||
import { manageSubscriptionRedirectURL } from '../util'
|
||||
import { manageSubscriptionRedirectURL, isEmbeddedCodyProUIEnabled } from '../util'
|
||||
|
||||
import { USER_CODY_PLAN } from './queries'
|
||||
|
||||
@ -56,6 +56,7 @@ export const CodySubscriptionPage: React.FunctionComponent<CodySubscriptionPageP
|
||||
const { data, error: dataError } = useQuery<UserCodyPlanResult, UserCodyPlanVariables>(USER_CODY_PLAN, {})
|
||||
|
||||
const navigate = useNavigate()
|
||||
const useEmbeddedCodyUI = useMemo(() => isEmbeddedCodyProUIEnabled(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!!data && !data?.currentUser) {
|
||||
@ -71,7 +72,7 @@ export const CodySubscriptionPage: React.FunctionComponent<CodySubscriptionPageP
|
||||
return null
|
||||
}
|
||||
|
||||
const isProUser = data.currentUser.codySubscription?.plan === CodySubscriptionPlan.PRO
|
||||
const isProUser = data.currentUser.codySubscription?.plan !== CodySubscriptionPlan.PRO
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -217,16 +218,41 @@ export const CodySubscriptionPage: React.FunctionComponent<CodySubscriptionPageP
|
||||
>
|
||||
Manage subscription
|
||||
</Text>
|
||||
) : useEmbeddedCodyUI ? (
|
||||
<>
|
||||
<Button
|
||||
className="mb-3 d-flex align-items-center justify-content-center"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
telemetryRecorder.recordEvent('cody.planSelection', 'click', {
|
||||
metadata: { tier: 1, team: 1 },
|
||||
})
|
||||
window.location.href = manageSubscriptionRedirectURL // TODO: Use team link or argument
|
||||
}}
|
||||
>
|
||||
<span className={classNames(styles.proBadge, 'mr-1')} />
|
||||
<span>Create a Cody Pro team</span>
|
||||
</Button>
|
||||
<Link
|
||||
className="text-center"
|
||||
to="https://sourcegraph.com/contact/request-info?utm_source=cody_subscription_page"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={() => {
|
||||
telemetryRecorder.recordEvent('cody.planSelection', 'click', {
|
||||
metadata: { tier: 1, team: 0 },
|
||||
})
|
||||
window.location.href = manageSubscriptionRedirectURL
|
||||
}}
|
||||
>
|
||||
Upgrade yourself to Pro
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
EVENT_LOGGER.log(
|
||||
EventName.CODY_SUBSCRIPTION_PLAN_CLICKED,
|
||||
{ tier: 'pro' },
|
||||
{ tier: 'pro' }
|
||||
)
|
||||
telemetryRecorder.recordEvent('cody.planSelection', 'click', {
|
||||
metadata: { tier: 1 },
|
||||
})
|
||||
|
||||
69
client/web/src/cody/team/CodyManageTeamPage.module.scss
Normal file
69
client/web/src/cody/team/CodyManageTeamPage.module.scss
Normal file
@ -0,0 +1,69 @@
|
||||
.container {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 1.5rem 2.5rem;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.alert {
|
||||
position: relative;
|
||||
padding: 1.5rem 1.5rem 1.5rem 5rem;
|
||||
color: #181b26;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 1.5rem;
|
||||
width: 2.5rem;
|
||||
height: 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
.purple-success-alert {
|
||||
background-color: #f2e7fb;
|
||||
&::after {
|
||||
// Purple checkmark SVG
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 31 25'><path fill='%23a405e1' d='M27 .4a3.4 3.4 0 0 0-2.4 1.1L12 15.6 7 9.3a3.4 3.4 0 0 0-5-.6 3.4 3.4 0 0 0-.5 4.9L9.2 23a3.4 3.4 0 0 0 5.3.1l15.2-17a3.4 3.4 0 0 0-.3-4.9 3.4 3.4 0 0 0-2.5-.8Z'/></svg>");
|
||||
}
|
||||
}
|
||||
|
||||
.blue-success-alert {
|
||||
background-color: #e8f7ff;
|
||||
&::after {
|
||||
// Blue checkmark SVG
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 31 25'><path fill='%23339af0' d='M27 .4a3.4 3.4 0 0 0-2.4 1.1L12 15.6 7 9.3a3.4 3.4 0 0 0-5-.6 3.4 3.4 0 0 0-.5 4.9L9.2 23a3.4 3.4 0 0 0 5.3.1l15.2-17a3.4 3.4 0 0 0-.3-4.9 3.4 3.4 0 0 0-2.5-.8Z'/></svg>");
|
||||
}
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
background-color: #ffe8e8;
|
||||
&::after {
|
||||
// Ugly red X SVG – TODO: Replace it with a design-approved one.
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 122.88 122.88'><path fill='%23f5222d' d='M6,6H6a20.53,20.53,0,0,1,29,0l26.5,26.49L87.93,6a20.54,20.54,0,0,1,29,0h0a20.53,20.53,0,0,1,0,29L90.41,61.44,116.9,87.93a20.54,20.54,0,0,1,0,29h0a20.54,20.54,0,0,1-29,0L61.44,90.41,35,116.9a20.54,20.54,0,0,1-29,0H6a20.54,20.54,0,0,1,0-29L32.47,61.44,6,34.94A20.53,20.53,0,0,1,6,6Z'/></svg>");
|
||||
}
|
||||
}
|
||||
|
||||
.user-badges {
|
||||
position: relative;
|
||||
left: -31px;
|
||||
border: 1px solid #e6ebf2;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.invite-users-header {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
/* stylelint-disable-next-line declaration-property-unit-allowed-list */
|
||||
width: 42px;
|
||||
/* stylelint-disable-next-line declaration-property-unit-allowed-list */
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
268
client/web/src/cody/team/CodyManageTeamPage.tsx
Normal file
268
client/web/src/cody/team/CodyManageTeamPage.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
|
||||
import { mdiPlusThick, mdiOpenInNew } from '@mdi/js'
|
||||
import classNames from 'classnames'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry'
|
||||
import { Icon, PageHeader, Button, Link, 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 { fetchThroughSSCProxy } from '../util'
|
||||
|
||||
import { InviteUsers } from './InviteUsers'
|
||||
import { type TeamInvite, TeamMemberList, type TeamMember } from './TeamMembers'
|
||||
import { WhiteIcon } from './WhiteIcon'
|
||||
|
||||
import styles from './CodyManageTeamPage.module.scss'
|
||||
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
const AuthenticatedCodyManageTeamPage: React.FunctionComponent<CodyManageTeamPageProps> = ({
|
||||
authenticatedUser,
|
||||
telemetryRecorder,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
telemetryRecorder.recordEvent('cody.team.management', 'view')
|
||||
}, [telemetryRecorder])
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Process query params
|
||||
const parameters = useSearchParameters()
|
||||
const newSeatsPurchasedParam = parameters.get('newSeatsPurchased')
|
||||
const newSeatsPurchased: number | null = newSeatsPurchasedParam ? parseInt(newSeatsPurchasedParam, 10) : null
|
||||
|
||||
// Load data
|
||||
const [subscriptionData, setSubscriptionData] = useState<{
|
||||
subscriptionSeatCount: number | null
|
||||
isProUser: boolean | null
|
||||
} | null>(null)
|
||||
const subscriptionSeatCount = subscriptionData?.subscriptionSeatCount
|
||||
const isProUser = subscriptionData?.isProUser
|
||||
const [subscriptionDataError, setSubscriptionDataError] = useState<null | Error>(null)
|
||||
const [subscriptionSummaryData, setSubscriptionSummaryData] = useState<{
|
||||
teamId: string | null
|
||||
isAdmin: boolean | null
|
||||
} | null>(null)
|
||||
const [subscriptionSummaryDataError, setSubscriptionSummaryDataError] = useState<null | Error>(null)
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMember[] | null>(null)
|
||||
const [membersDataError, setMembersDataError] = useState<null | Error>(null)
|
||||
const [teamInvites, setTeamInvites] = useState<TeamInvite[] | null>(null)
|
||||
const [invitesDataError, setInvitesDataError] = useState<null | Error>(null)
|
||||
useEffect(() => {
|
||||
async function loadSubscriptionData(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (isProUser === false) {
|
||||
navigate('/cody/subscription')
|
||||
}
|
||||
}, [isProUser, 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 (
|
||||
<>
|
||||
<Page className={classNames('d-flex flex-column')}>
|
||||
<PageTitle title="Manage Cody team" />
|
||||
<PageHeader
|
||||
className="mb-4 mt-4"
|
||||
actions={
|
||||
subscriptionSummaryData?.isAdmin && (
|
||||
<div className="d-flex">
|
||||
<Link
|
||||
to="/cody/manage"
|
||||
className="d-inline-flex align-items-center mr-3"
|
||||
onClick={() =>
|
||||
telemetryRecorder.recordEvent('cody.team.manage.subscription', 'click', {
|
||||
metadata: { tier: isProUser ? 1 : 0 },
|
||||
})
|
||||
}
|
||||
>
|
||||
Manage subscription
|
||||
<Icon
|
||||
svgPath={mdiOpenInNew}
|
||||
inline={false}
|
||||
aria-hidden={true}
|
||||
height="1rem"
|
||||
width="1rem"
|
||||
className="ml-2"
|
||||
/>
|
||||
</Link>
|
||||
<Button
|
||||
as={Link}
|
||||
to="/cody/manage/subscription/new"
|
||||
variant="primary"
|
||||
className="text-nowrap"
|
||||
>
|
||||
<Icon aria-hidden={true} svgPath={mdiPlusThick} /> Add seats
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<PageHeader.Heading as="h2" styleAs="h1">
|
||||
<div className="d-inline-flex align-items-center">
|
||||
<WhiteIcon name="mdi-account-multiple-plus-gradient" />
|
||||
</div>
|
||||
</PageHeader.Heading>
|
||||
</PageHeader>
|
||||
|
||||
{subscriptionDataError || subscriptionSummaryDataError || membersDataError || invitesDataError ? (
|
||||
<div className={classNames('mb-4', styles.alert, styles.errorAlert)}>
|
||||
<H3>Failed to load team data.</H3>
|
||||
{subscriptionDataError?.message && (
|
||||
<Text size="small" className="text-muted mb-0">
|
||||
{subscriptionDataError?.message}
|
||||
</Text>
|
||||
)}
|
||||
{subscriptionSummaryDataError?.message && (
|
||||
<Text size="small" className="text-muted mb-0">
|
||||
{subscriptionDataError?.message}
|
||||
</Text>
|
||||
)}
|
||||
{membersDataError?.message && (
|
||||
<Text size="small" className="text-muted mb-0">
|
||||
{membersDataError?.message}
|
||||
</Text>
|
||||
)}
|
||||
{invitesDataError?.message && (
|
||||
<Text size="small" className="text-muted mb-0">
|
||||
{invitesDataError?.message}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{newSeatsPurchased && (
|
||||
<div className={classNames('mb-4', styles.alert, styles.purpleSuccessAlert)}>
|
||||
<H3>{newSeatsPurchased} Cody teams seats purchased!</H3>
|
||||
<Text size="small" className="mb-0">
|
||||
Invited users will receive unlimited autocompletions and unlimited chat messages.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subscriptionSummaryData?.isAdmin && !!remainingInviteCount && (
|
||||
<InviteUsers
|
||||
teamId={subscriptionSummaryData?.teamId}
|
||||
remainingInviteCount={remainingInviteCount}
|
||||
telemetryRecorder={telemetryRecorder}
|
||||
/>
|
||||
)}
|
||||
<TeamMemberList
|
||||
teamId={subscriptionSummaryData?.teamId ?? null}
|
||||
teamMembers={teamMembers || []}
|
||||
invites={teamInvites || []}
|
||||
isAdmin={subscriptionSummaryData?.isAdmin ?? false}
|
||||
telemetryRecorder={telemetryRecorder}
|
||||
/>
|
||||
</Page>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const CodyManageTeamPage = withAuthenticatedUser(AuthenticatedCodyManageTeamPage)
|
||||
165
client/web/src/cody/team/InviteUsers.tsx
Normal file
165
client/web/src/cody/team/InviteUsers.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
|
||||
import classNames from 'classnames'
|
||||
|
||||
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 styles from './CodyManageTeamPage.module.scss'
|
||||
|
||||
interface InviteUsersProps extends TelemetryV2Props {
|
||||
teamId: string | null
|
||||
remainingInviteCount: number
|
||||
}
|
||||
|
||||
export const InviteUsers: React.FunctionComponent<InviteUsersProps> = ({
|
||||
teamId,
|
||||
remainingInviteCount,
|
||||
telemetryRecorder,
|
||||
}) => {
|
||||
const [emailAddressesString, setEmailAddressesString] = useState<string>('')
|
||||
const [emailAddressErrorMessage, setEmailAddressErrorMessage] = useState<string | null>(null)
|
||||
const [invitesSendingStatus, setInvitesSendingStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle')
|
||||
const [invitesSendingErrorMessage, setInvitesSendingErrorMessage] = useState<string | null>(null)
|
||||
|
||||
const onSendInvitesClicked = useCallback(async () => {
|
||||
const { emails: emailAddresses, error: emailParsingError } = parseEmailList(
|
||||
emailAddressesString,
|
||||
remainingInviteCount
|
||||
)
|
||||
if (emailParsingError) {
|
||||
setEmailAddressErrorMessage(emailParsingError)
|
||||
return
|
||||
}
|
||||
telemetryRecorder.recordEvent('cody.team.sendInvites', 'click', {
|
||||
metadata: { count: emailAddresses.length },
|
||||
privateMetadata: { teamId, emailAddresses },
|
||||
})
|
||||
|
||||
setInvitesSendingStatus('sending')
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
emailAddresses.map(emailAddress =>
|
||||
fetchThroughSSCProxy('/team/current/invites', 'POST', { email: emailAddress, role: 'member' })
|
||||
)
|
||||
)
|
||||
if (responses.some(response => response.status !== 200)) {
|
||||
const responsesText = await Promise.all(responses.map(response => response.text()))
|
||||
setInvitesSendingStatus('error')
|
||||
setInvitesSendingErrorMessage(`Error sending invites: ${responsesText.join(', ')}`)
|
||||
telemetryRecorder.recordEvent('cody.team.sendInvites', 'error', {
|
||||
metadata: { count: emailAddresses.length, softError: 1 },
|
||||
privateMetadata: { teamId, emailAddresses },
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
setInvitesSendingStatus('success')
|
||||
telemetryRecorder.recordEvent('cody.team.sendInvites', 'success', {
|
||||
metadata: { count: emailAddresses.length },
|
||||
privateMetadata: { teamId, emailAddresses },
|
||||
})
|
||||
} catch (error) {
|
||||
setInvitesSendingStatus('error')
|
||||
setInvitesSendingErrorMessage(`Error sending invites: ${error}`)
|
||||
telemetryRecorder.recordEvent('cody.team.sendInvites', 'error', {
|
||||
metadata: { count: emailAddresses.length, softError: 0 },
|
||||
privateMetadata: { teamId, emailAddresses },
|
||||
})
|
||||
}
|
||||
}, [emailAddressesString, remainingInviteCount, teamId, telemetryRecorder])
|
||||
|
||||
return (
|
||||
<>
|
||||
{invitesSendingStatus === 'success' && (
|
||||
<div className={classNames('mb-4', styles.alert, styles.blueSuccessAlert)}>
|
||||
<H3>4 invites sent!</H3>
|
||||
<Text size="small" className="mb-0">
|
||||
Invitees will receive an email from cody@sourcegraph.com.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{invitesSendingStatus === 'error' && (
|
||||
<div className={classNames('mb-4', styles.alert, styles.errorAlert)}>
|
||||
<H3>Invites not sent.</H3>
|
||||
<Text size="small" className="text-muted mb-0">
|
||||
{invitesSendingErrorMessage}
|
||||
</Text>
|
||||
<Text size="small">
|
||||
If you encounter this issue repeatedly, please contact support at{' '}
|
||||
<Link to="mailto:support@sourcegraph.com">support@sourcegraph.com</Link>.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!remainingInviteCount && (
|
||||
<div className={classNames('p-4 border bg-1 mb-4 d-flex flex-row', styles.container)}>
|
||||
<div className="d-flex justify-content-between align-items-center w-100">
|
||||
<div>
|
||||
<img
|
||||
src="https://storage.googleapis.com/sourcegraph-assets/cody/user-badges.png"
|
||||
alt="User badges"
|
||||
width="230"
|
||||
height="202"
|
||||
className={classNames('mr-3')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 d-flex flex-column">
|
||||
<H2 className={classNames('mb-4', styles.inviteUsersHeader)}>
|
||||
<strong>Invite users</strong> – You have {remainingInviteCount} free{' '}
|
||||
{pluralize('seat', remainingInviteCount)}
|
||||
</H2>
|
||||
<TextArea
|
||||
className={classNames('mb-2')}
|
||||
placeholder="Example: someone@sourcegraph.com, another.user@sourcegraph.com"
|
||||
rows={4}
|
||||
onChange={event => {
|
||||
setEmailAddressErrorMessage(null)
|
||||
setEmailAddressesString(event.target.value)
|
||||
}}
|
||||
/>
|
||||
<Text className="text-muted mb-2">Enter email addresses separated by a comma.</Text>
|
||||
<Text className="text-danger mb-2">{emailAddressErrorMessage}</Text>
|
||||
<div className="d-flex justify-content-end">
|
||||
<ButtonLink variant="success" size="sm" onSelect={onSendInvitesClicked}>
|
||||
Send
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function parseEmailList(
|
||||
emailAddressesString: string,
|
||||
remainingInviteCount: number
|
||||
): { emails: string[]; error: string | null } {
|
||||
const emails = emailAddressesString.split(',').map(email => email.trim())
|
||||
if (emails.length === 0) {
|
||||
return { emails, error: 'Please enter at least one email address.' }
|
||||
}
|
||||
|
||||
if (emails.length > remainingInviteCount) {
|
||||
return {
|
||||
emails,
|
||||
error: `${emails.length} email addresses entered, but you only have ${remainingInviteCount} seats.`,
|
||||
}
|
||||
}
|
||||
|
||||
const invalidEmails = emails.filter(email => !isValidEmailAddress(email))
|
||||
|
||||
if (invalidEmails.length > 0) {
|
||||
return {
|
||||
emails,
|
||||
error: `Invalid email address${invalidEmails.length > 1 ? 'es' : ''}: ${invalidEmails.join(', ')}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { emails, error: null }
|
||||
}
|
||||
285
client/web/src/cody/team/TeamMembers.tsx
Normal file
285
client/web/src/cody/team/TeamMembers.tsx
Normal file
@ -0,0 +1,285 @@
|
||||
import { type FunctionComponent, useMemo, useCallback, useState } from 'react'
|
||||
|
||||
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 styles from './CodyManageTeamPage.module.scss'
|
||||
|
||||
export interface TeamMember {
|
||||
accountId: string
|
||||
displayName: string | null
|
||||
email: string
|
||||
avatarUrl: string | null
|
||||
role: 'admin' | 'member'
|
||||
}
|
||||
|
||||
export interface TeamInvite {
|
||||
id: string
|
||||
email: string
|
||||
role: 'admin' | 'member' | 'none'
|
||||
status: 'sent' | 'errored' | 'accepted' | 'canceled'
|
||||
error: string | null
|
||||
sentAt: string | null
|
||||
acceptedAt: string | null
|
||||
}
|
||||
|
||||
interface TeamMemberListProps extends TelemetryV2Props {
|
||||
teamId: string | null
|
||||
teamMembers: TeamMember[]
|
||||
invites: TeamInvite[]
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export const TeamMemberList: FunctionComponent<TeamMemberListProps> = ({
|
||||
teamId,
|
||||
teamMembers,
|
||||
invites,
|
||||
isAdmin,
|
||||
telemetryRecorder,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actionResult, setActionResult] = useState<{ message: string; isError: boolean } | null>(null)
|
||||
const setRole = useCallback(
|
||||
async (accountId: string, newRole: 'member' | 'admin'): Promise<void> => {
|
||||
if (!loading) {
|
||||
// Avoids sending multiple requests at once
|
||||
setLoading(true)
|
||||
telemetryRecorder.recordEvent('cody.team.revokeAdmin', 'click', {
|
||||
privateMetadata: { teamId, accountId },
|
||||
})
|
||||
|
||||
const response = await fetchThroughSSCProxy(
|
||||
`/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 })
|
||||
}
|
||||
}
|
||||
},
|
||||
[loading, telemetryRecorder, teamId]
|
||||
)
|
||||
|
||||
const revokeInvite = useCallback(
|
||||
async (inviteId: string): Promise<void> => {
|
||||
if (!loading) {
|
||||
// Avoids sending multiple requests at once
|
||||
setLoading(true)
|
||||
telemetryRecorder.recordEvent('cody.team.revokeInvite', 'click', { privateMetadata: { teamId } })
|
||||
|
||||
const response = await fetchThroughSSCProxy(`/team/current/invites/${inviteId}/cancel`, 'POST')
|
||||
if (!response.ok) {
|
||||
setLoading(false)
|
||||
setActionResult({
|
||||
message: `We couldn't revoke the invite (${response.status}). Please try again later.`,
|
||||
isError: true,
|
||||
})
|
||||
} else {
|
||||
setLoading(false)
|
||||
setActionResult({ message: 'Invite revoked.', isError: false })
|
||||
}
|
||||
}
|
||||
},
|
||||
[loading, telemetryRecorder, teamId]
|
||||
)
|
||||
|
||||
const resendInvite = useCallback(
|
||||
async (inviteId: string): Promise<void> => {
|
||||
if (!loading) {
|
||||
// Avoids sending multiple requests at once
|
||||
setLoading(true)
|
||||
telemetryRecorder.recordEvent('cody.team.revokeInvite', 'click', { privateMetadata: { teamId } })
|
||||
|
||||
const response = await fetchThroughSSCProxy(`/team/current/invites/${inviteId}/resend`, 'POST')
|
||||
if (!response.ok) {
|
||||
setLoading(false)
|
||||
setActionResult({
|
||||
message: `We couldn't resend the invite (${response.status}). Please try again later.`,
|
||||
isError: true,
|
||||
})
|
||||
} else {
|
||||
setLoading(false)
|
||||
setActionResult({ message: 'Invite resent.', isError: false })
|
||||
}
|
||||
}
|
||||
|
||||
telemetryRecorder.recordEvent('cody.team.resendInvite', 'click', { privateMetadata: { teamId } })
|
||||
},
|
||||
[loading, telemetryRecorder, teamId]
|
||||
)
|
||||
|
||||
const removeMember = useCallback(
|
||||
async (accountId: string): Promise<void> => {
|
||||
telemetryRecorder.recordEvent('cody.team.removeMember', 'click', { privateMetadata: { teamId, accountId } })
|
||||
|
||||
if (!loading) {
|
||||
setLoading(true)
|
||||
telemetryRecorder.recordEvent('cody.team.revokeInvite', 'click', { privateMetadata: { teamId } })
|
||||
|
||||
const response = await fetchThroughSSCProxy(`/team/current/members/${accountId}`, 'DELETE')
|
||||
if (!response.ok) {
|
||||
setLoading(false)
|
||||
setActionResult({
|
||||
message: `We couldn't remove the team member. (${response.status}). Please try again later.`,
|
||||
isError: true,
|
||||
})
|
||||
} else {
|
||||
setLoading(false)
|
||||
setActionResult({ message: 'Team member removed.', isError: false })
|
||||
}
|
||||
}
|
||||
},
|
||||
[telemetryRecorder, teamId, loading]
|
||||
)
|
||||
|
||||
const adminCount = useMemo(() => teamMembers?.filter(member => member.role === 'admin').length ?? 0, [teamMembers])
|
||||
|
||||
if (!teamMembers) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{actionResult && (
|
||||
<div
|
||||
className={classNames(
|
||||
'mb-4',
|
||||
styles.alert,
|
||||
actionResult.isError ? styles.errorAlert : styles.blueSuccessAlert
|
||||
)}
|
||||
>
|
||||
{actionResult.message}
|
||||
</div>
|
||||
)}
|
||||
<div className={classNames('p-4 border bg-1 d-flex flex-column', styles.container)}>
|
||||
<H2 className="text-lg font-semibold mb-2">Team members</H2>
|
||||
<Text className="text-sm text-gray-500 mb-4">Manage invited and active users</Text>
|
||||
<ul className="space-y-4 d-flex flex-column list-none pl-0">
|
||||
{teamMembers.map(member => (
|
||||
<li key={member.accountId} className="d-flex flex-row justify-between mb-4">
|
||||
<div className="flex-1 d-flex flex-row">
|
||||
{member.avatarUrl ? (
|
||||
<img
|
||||
src={member.avatarUrl}
|
||||
alt="avatar"
|
||||
width="40"
|
||||
height="40"
|
||||
className={classNames(styles.avatar)}
|
||||
/>
|
||||
) : (
|
||||
<div className={classNames(styles.avatar, styles.avatarPlaceholder)} />
|
||||
)}
|
||||
<div className="d-flex flex-column justify-content-center ml-2">
|
||||
{member.displayName && <strong>{member.displayName}</strong>}
|
||||
<Text className="mb-0">{member.email}</Text>
|
||||
</div>
|
||||
{member.role === 'admin' && (
|
||||
<div className="d-flex flex-column justify-content-center ml-2">
|
||||
<Badge variant="primary">ADMIN</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="d-flex">
|
||||
{member.role === 'admin' ? (
|
||||
<div className="d-flex flex-column justify-content-center ml-2">
|
||||
<Link
|
||||
to="#"
|
||||
onClick={() => setRole(member.accountId, 'member')}
|
||||
className="ml-2"
|
||||
aria-disabled={adminCount < 2}
|
||||
>
|
||||
Revoke admin
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="d-flex flex-column justify-content-center ml-2">
|
||||
<Link
|
||||
to="#"
|
||||
onClick={() => setRole(member.accountId, 'admin')}
|
||||
className="ml-2"
|
||||
>
|
||||
Make admin
|
||||
</Link>
|
||||
</div>
|
||||
<div className="d-flex flex-column justify-content-center ml-2">
|
||||
<ButtonLink
|
||||
to="#"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => removeMember(member.accountId)}
|
||||
className="ml-2"
|
||||
>
|
||||
Remove
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{invites
|
||||
.filter(invite => invite.status === 'sent')
|
||||
.map(invite => (
|
||||
<li key={invite.id} className="d-flex flex-row justify-between mb-4">
|
||||
<div className="flex-1 d-flex flex-row">
|
||||
<div className={classNames(styles.avatar, styles.avatarPlaceholder)} />
|
||||
<div className="d-flex flex-column justify-content-center ml-2">
|
||||
<Text className="mb-0">{invite.email}</Text>
|
||||
</div>
|
||||
{invite.role === 'admin' && (
|
||||
<div className="d-flex flex-column justify-content-center ml-2">
|
||||
<Badge variant="primary">ADMIN</Badge>
|
||||
</div>
|
||||
)}
|
||||
<div className="d-flex flex-column justify-content-center ml-2">
|
||||
<div className="d-flex flex-row">
|
||||
<div className="d-flex flex-column justify-content-center ml-2">
|
||||
<Badge variant="secondary">INVITED</Badge>
|
||||
</div>
|
||||
<em className="ml-4">Invite sent {invite.sentAt /* TODO format this */}</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="d-flex">
|
||||
<div className="d-flex row justify-content-center ml-2">
|
||||
<div className="d-flex flex-column justify-content-center ml-2">
|
||||
<Link to="#" onClick={() => revokeInvite(invite.id)} className="ml-2">
|
||||
Revoke
|
||||
</Link>
|
||||
</div>
|
||||
<div className="d-flex flex-column justify-content-center ml-2">
|
||||
<ButtonLink
|
||||
to="#"
|
||||
variant="success"
|
||||
size="sm"
|
||||
onClick={() => resendInvite(invite.id)}
|
||||
className="ml-2"
|
||||
>
|
||||
Resend invite
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
21
client/web/src/cody/team/WhiteIcon.module.scss
Normal file
21
client/web/src/cody/team/WhiteIcon.module.scss
Normal file
@ -0,0 +1,21 @@
|
||||
.white-box {
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
/* stylelint-disable-next-line declaration-property-unit-allowed-list */
|
||||
width: 60px;
|
||||
/* stylelint-disable-next-line declaration-property-unit-allowed-list */
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.white-box-background {
|
||||
position: absolute;
|
||||
left: -16px;
|
||||
top: -9px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.white-box-content {
|
||||
z-index: 20;
|
||||
margin: 0 auto;
|
||||
}
|
||||
95
client/web/src/cody/team/WhiteIcon.tsx
Normal file
95
client/web/src/cody/team/WhiteIcon.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import React from 'react'
|
||||
|
||||
import styles from './WhiteIcon.module.scss'
|
||||
|
||||
export const ICON_NAMES = ['mdi-account-multiple-plus-gradient'] as const
|
||||
|
||||
interface WhiteIconProps {
|
||||
name: typeof ICON_NAMES[number]
|
||||
}
|
||||
|
||||
const nameToUrl = {
|
||||
'mdi-account-multiple-plus-gradient':
|
||||
'data:image/png;base64,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',
|
||||
}
|
||||
|
||||
export const WhiteIcon: React.FunctionComponent<WhiteIconProps> = ({ name, ...attributes }) => {
|
||||
if (!name || !ICON_NAMES.includes(name)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.whiteBox} {...attributes}>
|
||||
<img className={styles.whiteBoxContent} src={nameToUrl[name]} alt={name} />
|
||||
<svg
|
||||
className={styles.whiteBoxBackground}
|
||||
width="92"
|
||||
height="92"
|
||||
viewBox="0 0 92 92"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g filter="url(#filter0_dd_4521_4269)">
|
||||
<path
|
||||
d="M16.6973 39.3422C16.6973 29.6443 16.6973 24.7954 18.4087 21.0182C20.3311 16.7753 23.7304 13.376 27.9733 11.4536C31.7505 9.74219 36.5994 9.74219 46.2973 9.74219C55.9951 9.74219 60.8441 9.74219 64.6212 11.4536C68.8641 13.376 72.2634 16.7753 74.1859 21.0182C75.8973 24.7954 75.8973 29.6443 75.8973 39.3422C75.8973 49.0401 75.8973 53.889 74.1859 57.6662C72.2634 61.909 68.8641 65.3084 64.6212 67.2308C60.8441 68.9422 55.9951 68.9422 46.2973 68.9422C36.5994 68.9422 31.7505 68.9422 27.9733 67.2308C23.7304 65.3084 20.3311 61.909 18.4087 57.6662C16.6973 53.889 16.6973 49.0401 16.6973 39.3422Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M16.6973 39.3422C16.6973 29.6443 16.6973 24.7954 18.4087 21.0182C20.3311 16.7753 23.7304 13.376 27.9733 11.4536C31.7505 9.74219 36.5994 9.74219 46.2973 9.74219C55.9951 9.74219 60.8441 9.74219 64.6212 11.4536C68.8641 13.376 72.2634 16.7753 74.1859 21.0182C75.8973 24.7954 75.8973 29.6443 75.8973 39.3422C75.8973 49.0401 75.8973 53.889 74.1859 57.6662C72.2634 61.909 68.8641 65.3084 64.6212 67.2308C60.8441 68.9422 55.9951 68.9422 46.2973 68.9422C36.5994 68.9422 31.7505 68.9422 27.9733 67.2308C23.7304 65.3084 20.3311 61.909 18.4087 57.6662C16.6973 53.889 16.6973 49.0401 16.6973 39.3422Z"
|
||||
fill="url(#paint0_radial_4521_4269)"
|
||||
fillOpacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M17.4973 39.3422C17.4973 34.4814 17.4978 30.8792 17.709 28.0189C17.9197 25.1666 18.3367 23.1155 19.1374 21.3484C20.9797 17.2823 24.2374 14.0246 28.3035 12.1823C30.0706 11.3816 32.1217 10.9646 34.9739 10.7539C37.8343 10.5427 41.4365 10.5422 46.2973 10.5422C51.1581 10.5422 54.7603 10.5427 57.6206 10.7539C60.4729 10.9646 62.5239 11.3816 64.2911 12.1823C68.3572 14.0246 71.6148 17.2823 73.4572 21.3484C74.2578 23.1155 74.6749 25.1666 74.8855 28.0189C75.0968 30.8792 75.0973 34.4814 75.0973 39.3422C75.0973 44.203 75.0968 47.8052 74.8855 50.6655C74.6749 53.5178 74.2578 55.5689 73.4572 57.336C71.6148 61.4021 68.3572 64.6598 64.2911 66.5021C62.5239 67.3028 60.4729 67.7198 57.6206 67.9304C54.7603 68.1417 51.1581 68.1422 46.2973 68.1422C41.4365 68.1422 37.8343 68.1417 34.9739 67.9304C32.1217 67.7198 30.0706 67.3028 28.3035 66.5021C24.2374 64.6598 20.9797 61.4021 19.1374 57.336C18.3367 55.5689 17.9197 53.5178 17.709 50.6655C17.4978 47.8052 17.4973 44.203 17.4973 39.3422Z"
|
||||
stroke="black"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="1.6"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_dd_4521_4269"
|
||||
x="0.697266"
|
||||
y="0.142188"
|
||||
width="91.2"
|
||||
height="91.1992"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="6.4" />
|
||||
<feGaussianBlur stdDeviation="8" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0.891257 0 0 0 0 0.907635 0 0 0 0 0.956771 0 0 0 1 0"
|
||||
/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4521_4269" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset dy="3.2" />
|
||||
<feGaussianBlur stdDeviation="1.6" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="effect1_dropShadow_4521_4269"
|
||||
result="effect2_dropShadow_4521_4269"
|
||||
/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_4521_4269" result="shape" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -8,10 +8,48 @@ export const manageSubscriptionRedirectURL = 'https://accounts.sourcegraph.com/c
|
||||
* If false, we rely on the current behavior. Where users are directed to https://accounts.sourcegraph.com/cody
|
||||
* for managing their Cody Pro subscription information.
|
||||
*/
|
||||
export function useEmbeddedCodyProUi(): boolean {
|
||||
const codyProConfig = window.context.frontendCodyProConfig
|
||||
if (codyProConfig && codyProConfig.stripePublishableKey) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
export function isEmbeddedCodyProUIEnabled(): boolean {
|
||||
return !!(window.context.frontendCodyProConfig as { stripePublishableKey: string } | undefined)
|
||||
?.stripePublishableKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Note that this is a very simplistic approach.
|
||||
* "doesThisStringRoughlyResembleAnEmailAddress" would be a more accurate name.
|
||||
* And it is definitely not meant to replace the backend validation.
|
||||
*/
|
||||
export function isValidEmailAddress(emailAddress: string): boolean {
|
||||
return emailRegex.test(emailAddress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Regular expression to validate whether a string looks like an email address:
|
||||
* - Contains a single "@" that is not at the beginning or at the end.
|
||||
* - Contains at least one "." after the "@" that is not at the end.
|
||||
*
|
||||
* NOTE: Keep this in sync with `emailRegex` in the backend
|
||||
* (https://sourcegraph.sourcegraph.com/search?q=context:global+r:github.com/sourcegraph/sourcegraph-accounts+f:backend/internal/graph/*+%22var+emailRegex+%3D+regexp.%22&patternType=newStandardRC1&sm=1),
|
||||
* and keep in mind that the backend validation has the final say, validation in the web app is only for UX improvement.
|
||||
*/
|
||||
const emailRegex = /^[^@]+@[^@]+\.[^@]+$/
|
||||
|
||||
/**
|
||||
* @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<Response> {
|
||||
// /.api/ssc/proxy endpoint exchanges the Sourcegraph session credentials for a SAMS access token.
|
||||
// And then proxy the request onto the SSC backend, which will actually create the
|
||||
// checkout session.
|
||||
return fetch(`/.api/ssc/proxy${sscUrl}`, {
|
||||
// Pass along the "sgs" session cookie to identify the caller.
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
...window.context.xhrHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method,
|
||||
...(!['GET', 'HEAD'].includes(method) && params ? { body: JSON.stringify(params) } : null),
|
||||
})
|
||||
}
|
||||
|
||||
@ -47,6 +47,8 @@ export enum PageRoutes {
|
||||
// The CodySubscriptions page is a comparison of different Cody product tiers.
|
||||
CodySubscription = '/cody/subscription',
|
||||
|
||||
CodyManageTeam = '/cody/team/manage',
|
||||
|
||||
CodySwitchAccount = '/cody/switch-account/:username',
|
||||
Own = '/own',
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import { Navigate, useNavigate, type RouteObject } from 'react-router-dom'
|
||||
import { useExperimentalFeatures } from '@sourcegraph/shared/src/settings/settings'
|
||||
import { lazyComponent } from '@sourcegraph/shared/src/util/lazyComponent'
|
||||
|
||||
import { useEmbeddedCodyProUi } from './cody/util'
|
||||
import { isEmbeddedCodyProUIEnabled } from './cody/util'
|
||||
import { communitySearchContextsRoutes } from './communitySearchContexts/routes'
|
||||
import { type LegacyLayoutRouteContext, LegacyRoute } from './LegacyRouteContext'
|
||||
import { PageRoutes } from './routes.constants'
|
||||
@ -66,6 +66,7 @@ const SearchPageWrapper = lazyComponent(() => import('./search/SearchPageWrapper
|
||||
const CodySearchPage = lazyComponent(() => import('./cody/search/CodySearchPage'), 'CodySearchPage')
|
||||
const CodyChatPage = lazyComponent(() => import('./cody/chat/CodyChatPage'), 'CodyChatPage')
|
||||
const CodyManagementPage = lazyComponent(() => import('./cody/management/CodyManagementPage'), 'CodyManagementPage')
|
||||
const CodyManageTeamPage = lazyComponent(() => import('./cody/team/CodyManageTeamPage'), 'CodyManageTeamPage')
|
||||
const CodySwitchAccountPage = lazyComponent(
|
||||
() => import('./cody/switch-account/CodySwitchAccountPage'),
|
||||
'CodySwitchAccountPage'
|
||||
@ -428,6 +429,19 @@ export const routes: RouteObject[] = [
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: PageRoutes.CodyManageTeam,
|
||||
element: (
|
||||
<LegacyRoute
|
||||
render={props => (
|
||||
<CodyManageTeamPage {...props} telemetryRecorder={props.platformContext.telemetryRecorder} />
|
||||
)}
|
||||
condition={({ isSourcegraphDotCom, licenseFeatures }) =>
|
||||
isSourcegraphDotCom && licenseFeatures.isCodyEnabled && isEmbeddedCodyProUIEnabled()
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: PageRoutes.CodyNewProSubscription,
|
||||
element: (
|
||||
@ -438,7 +452,9 @@ export const routes: RouteObject[] = [
|
||||
telemetryRecorder={props.platformContext.telemetryRecorder}
|
||||
/>
|
||||
)}
|
||||
condition={({ isSourcegraphDotCom }) => isSourcegraphDotCom && useEmbeddedCodyProUi()}
|
||||
condition={({ isSourcegraphDotCom, licenseFeatures }) =>
|
||||
isSourcegraphDotCom && licenseFeatures.isCodyEnabled && isEmbeddedCodyProUIEnabled()
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user