diff --git a/static/js/directives/ui/usage-chart.js b/static/js/directives/ui/usage-chart.js index e0a4e62163..ec0df13de5 100644 --- a/static/js/directives/ui/usage-chart.js +++ b/static/js/directives/ui/usage-chart.js @@ -49,6 +49,7 @@ angular.module('quay').directive('usageChart', function () { } var finalAmount = $scope.total + $scope.marketplaceTotal; + if(finalAmount >= 9223372036854775807) { finalAmount = "unlimited"; } chart.update($scope.current, finalAmount); }; diff --git a/util/marketplace.py b/util/marketplace.py index 0fe987a9c5..efe66b19ba 100644 --- a/util/marketplace.py +++ b/util/marketplace.py @@ -293,6 +293,8 @@ def get_list_of_subscriptions( "username": "free_user", } +E2E_TEST_USER_EMAIL = "user1@redhat.com" + class FakeUserApi(RedHatUserApi): """ @@ -300,7 +302,7 @@ class FakeUserApi(RedHatUserApi): """ def lookup_customer_id(self, email): - if email == TEST_USER["email"]: + if email == TEST_USER["email"] or email == E2E_TEST_USER_EMAIL: return TEST_USER["account_number"] if email == FREE_USER["email"]: return FREE_USER["account_number"] diff --git a/web/cypress/e2e/marketplace.cy.ts b/web/cypress/e2e/marketplace.cy.ts new file mode 100644 index 0000000000..e49318b8ed --- /dev/null +++ b/web/cypress/e2e/marketplace.cy.ts @@ -0,0 +1,58 @@ +describe('Marketplace Section', () => { + beforeEach(() => { + cy.exec('npm run quay:seed'); + cy.request('GET', `${Cypress.env('REACT_QUAY_APP_API_URL')}/csrf_token`) + .then((response) => response.body.csrf_token) + .then((token) => { + cy.loginByCSRF(token); + }); + }); + + it('ListSubscriptions', () => { + cy.visit('/organization/user1?tab=Settings'); + cy.get('#pf-tab-1-billinginformation').click(); + cy.get('#user-subscription-list').contains( + '2x MW02701 belonging to user namespace', + ); + cy.get('#user-subscription-list').contains( + '1x MW02701 belonging to user namespace', + ); + }); + + it('ManageSubscription', () => { + cy.visit('/organization/projectquay?tab=Settings'); + cy.get('#pf-tab-1-billinginformation').click(); + cy.get('#attach-subscription-button').click(); + cy.get('#subscription-select-toggle').click(); + cy.get('#subscription-select-list').contains('2x MW02701').click(); + cy.get('#confirm-subscription-select').click(); + cy.get('#org-subscription-list') + .contains('2x MW02701 attached') + .should('exist'); + + cy.get('#remove-subscription-button').click(); + cy.get('#subscription-select-toggle').click(); + cy.get('#subscription-select-list').contains('2x MW02701').click(); + cy.get('#confirm-subscription-select').click(); + cy.get('#org-subscription-list') + .contains('2x MW02701 attached') + .should('not.exist'); + }); + + it('ViewUnlimitedSubscriptions', () => { + cy.intercept('GET', '/api/v1/organization/projectquay/marketplace', { + fixture: 'marketplace-org.json', + }); + cy.visit('/organization/projectquay?tab=Settings'); + cy.get('#pf-tab-1-billinginformation').click(); + cy.get('#attach-subscription-button').click(); + cy.get('#subscription-select-toggle').click(); + cy.get('#subscription-select-list').contains('2x MW02701').click(); + cy.get('#confirm-subscription-select').click(); + cy.get('#pf-tab-0-generalsettings').click(); + cy.get('#pf-tab-1-billinginformation').click(); + cy.get('#form-form') + .contains('0 of unlimited private repositories used') + .should('exist'); + }); +}); diff --git a/web/cypress/fixtures/marketplace-org.json b/web/cypress/fixtures/marketplace-org.json new file mode 100644 index 0000000000..de07a7e4c0 --- /dev/null +++ b/web/cypress/fixtures/marketplace-org.json @@ -0,0 +1,18 @@ +[ + { + "id": 1, + "subscription_id": 12345678, + "user_id": 36, + "org_id": 37, + "quantity": 2, + "sku": "MW02702", + "metadata": { + "title": "premium", + "privateRepos": 9007199254740991, + "stripeId": "not_a_stripe_plan", + "rh_sku": "MW02702", + "sku_billing": true, + "plans_page_hidden": true + } + } +] diff --git a/web/src/components/modals/OrgSubscriptionModal.tsx b/web/src/components/modals/OrgSubscriptionModal.tsx new file mode 100644 index 0000000000..e4abf2cb28 --- /dev/null +++ b/web/src/components/modals/OrgSubscriptionModal.tsx @@ -0,0 +1,133 @@ +import { + Button, + Flex, + FlexItem, + HelperText, + MenuToggle, + MenuToggleElement, + Modal, + ModalVariant, + Select, + SelectList, + SelectOption, +} from '@patternfly/react-core'; +import React from 'react'; +import {AlertVariant} from 'src/atoms/AlertState'; +import {useAlerts} from 'src/hooks/UseAlerts'; +import {useManageOrgSubscriptions} from 'src/hooks/UseMarketplaceSubscriptions'; + +interface OrgSubscriptionModalProps { + modalType: string; + org: string; + subscriptions: Array>; + isOpen: boolean; + handleModalToggle: any; +} + +export default function OrgSubscriptionModal(props: OrgSubscriptionModalProps) { + const [selectedSku, setSelectedSku] = React.useState(''); + const [menuIsOpen, setMenuIsOpen] = React.useState(false); + const {addAlert} = useAlerts(); + const onSelect = ( + _event: React.MouseEvent | undefined, + value: string | number | undefined, + ) => { + setSelectedSku(value as string); + setMenuIsOpen(false); + }; + + const { + manageSubscription, + errorManageSubscription, + successManageSubscription, + } = useManageOrgSubscriptions(props.org, { + onSuccess: () => { + addAlert({ + variant: AlertVariant.Success, + title: `Successfully ${ + props.modalType === 'attach' ? 'attached' : 'removed' + } subscription`, + }); + }, + onError: () => { + addAlert({ + variant: AlertVariant.Failure, + title: `Failed to ${ + props.modalType === 'attach' ? 'attach' : 'remove' + } subscription`, + }); + }, + }); + + return ( + + + + + + + + + + + + + + ); +} diff --git a/web/src/hooks/UseMarketplaceSubscriptions.ts b/web/src/hooks/UseMarketplaceSubscriptions.ts new file mode 100644 index 0000000000..73463e60b4 --- /dev/null +++ b/web/src/hooks/UseMarketplaceSubscriptions.ts @@ -0,0 +1,93 @@ +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import { + fetchMarketplaceSubscriptions, + setMarketplaceOrgAttachment, + setMarketplaceOrgRemoval, +} from 'src/resources/BillingResource'; +import {useCurrentUser} from './UseCurrentUser'; +import {useQuayConfig} from './UseQuayConfig'; + +export function useMarketplaceSubscriptions( + organizationName: string = null, + userName: string = null, +) { + const config = useQuayConfig(); + const { + isLoading: loadingUserSubs, + isError: errorFetchingUserSubs, + data: userSubscriptions, + } = useQuery( + ['subscriptions', {type: 'user'}], + () => fetchMarketplaceSubscriptions(), + { + enabled: config?.features?.RH_MARKETPLACE, + }, + ); + + const { + isLoading: loadingOrgSubs, + error: errorFetchingOrgSubs, + data: orgSubscriptions, + } = useQuery( + ['subscriptions', {type: 'org'}], + () => fetchMarketplaceSubscriptions(organizationName), + { + enabled: config?.features?.RH_MARKETPLACE && organizationName != userName, + }, + ); + + const loading = + organizationName != userName + ? loadingUserSubs || loadingOrgSubs + : loadingUserSubs; + + return { + userSubscriptions: userSubscriptions, + orgSubscriptions: orgSubscriptions, + loading: loading, + error: errorFetchingUserSubs, + }; +} + +export function useManageOrgSubscriptions(org: string, {onSuccess, onError}) { + const queryClient = useQueryClient(); + + const { + mutate: manageSubscription, + isError: errorManageSubscription, + isSuccess: successManageSubscription, + } = useMutation( + async ({ + subscription, + manageType, + }: { + subscription: Dict; + manageType: string; + }) => { + const reqBody = []; + reqBody.push({ + subscription_id: + manageType === 'attach' + ? subscription['id'] + : subscription['subscription_id'], + }); + if (manageType === 'attach') { + setMarketplaceOrgAttachment(org, reqBody); + } else { + setMarketplaceOrgRemoval(org, reqBody); + } + }, + { + onSuccess: () => { + onSuccess(); + return queryClient.invalidateQueries(['subscriptions']); + }, + onError: onError, + }, + ); + return { + manageSubscription, + errorManageSubscription, + successManageSubscription, + }; +} diff --git a/web/src/resources/BillingResource.ts b/web/src/resources/BillingResource.ts index a0b6a7bfbf..d2d8bf2a5c 100644 --- a/web/src/resources/BillingResource.ts +++ b/web/src/resources/BillingResource.ts @@ -1,5 +1,6 @@ import {AxiosResponse} from 'axios'; import axios from 'src/libs/axios'; +import {ResourceError} from './ErrorHandling'; export interface Subscription { hasSubscription: boolean; @@ -47,9 +48,8 @@ export interface Plan { } export async function fetchPlans() { - const response: AxiosResponse = await axios.get( - '/api/v1/plans/', - ); + const response: AxiosResponse = + await axios.get('/api/v1/plans/'); return response.data.plans; } @@ -85,3 +85,48 @@ export async function fetchCard(org: string = null) { const response: AxiosResponse = await axios.get(url); return response.data.card; } + +export async function fetchMarketplaceSubscriptions(org: string = null) { + const url: string = + org != null + ? `/api/v1/organization/${org}/marketplace` + : '/api/v1/user/marketplace'; + const response: AxiosResponse = await axios.get(url); + return response.data; +} + +export async function setMarketplaceOrgAttachment( + org: string, + subscriptions: Array, +) { + try { + await axios.post(`/api/v1/organization/${org}/marketplace`, { + subscriptions: subscriptions, + }); + } catch (error) { + throw new ResourceError( + 'Unable to bind subscriptions to org', + `${org}`, + error, + ); + } + return; +} + +export async function setMarketplaceOrgRemoval( + org: string, + subscriptions: Array, +) { + try { + await axios.post(`/api/v1/organization/${org}/marketplace/batchremove`, { + subscriptions: subscriptions, + }); + } catch (error) { + throw new ResourceError( + 'Unable to remove subscriptions from org', + `${org}`, + error, + ); + } + return; +} diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/Settings/BillingInformation.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/Settings/BillingInformation.tsx index af411ce438..3f1ef12c10 100644 --- a/web/src/routes/OrganizationsList/Organization/Tabs/Settings/BillingInformation.tsx +++ b/web/src/routes/OrganizationsList/Organization/Tabs/Settings/BillingInformation.tsx @@ -16,6 +16,10 @@ import { HelperText, AlertGroup, FormHelperText, + NumberInput, + Select, + MenuToggleElement, + MenuToggle, } from '@patternfly/react-core'; import {useRepositories} from 'src/hooks/UseRepositories'; import {useCurrentUser, useUpdateUser} from 'src/hooks/UseCurrentUser'; @@ -28,6 +32,7 @@ import {useOrganizationSettings} from 'src/hooks/UseOrganizationSettings'; import {useAlerts} from 'src/hooks/UseAlerts'; import {AlertVariant} from 'src/atoms/AlertState'; import Alerts from 'src/routes/Alerts'; +import MarketplaceDetails from './MarketplaceDetails'; type BillingInformationProps = { organizationName: string; @@ -58,6 +63,8 @@ export const BillingInformation = (props: BillingInformationProps) => { }, }); + const maxPrivate = BigInt(Number.MAX_SAFE_INTEGER); + const [touched, setTouched] = useState(false); const [invoiceEmail, setInvoiceEmail] = useState(false); const [invoiceEmailAddress, setInvoiceEmailAddress] = useState(''); @@ -102,6 +109,16 @@ export const BillingInformation = (props: BillingInformationProps) => { accountType == 'organization', ); + // total number of private repos allowed (stripe subscription + RH subscription watch) + const [totalPrivate, setTotalPrivate] = useState( + currentPlan?.privateRepos || 0, + ); + + const addMarketplacePrivate = (marketplacePrivate: number) => { + const sum = marketplacePrivate + (currentPlan?.privateRepos || 0); + setTotalPrivate(sum); + }; + const { convert, loading: convertAccountLoading, @@ -175,7 +192,9 @@ export const BillingInformation = (props: BillingInformationProps) => { {privateAllowed ? ( - {`${privateCount} of ${privateAllowed} private repositories used`} + {`${privateCount} of ${ + totalPrivate >= maxPrivate ? 'unlimited' : totalPrivate + } private repositories used`} ) : null} {`${totalResults} of unlimited public repositories used`} @@ -234,6 +253,11 @@ export const BillingInformation = (props: BillingInformationProps) => { View Invoices + + {isUserOrganization && ( <> Account Type diff --git a/web/src/routes/OrganizationsList/Organization/Tabs/Settings/MarketplaceDetails.tsx b/web/src/routes/OrganizationsList/Organization/Tabs/Settings/MarketplaceDetails.tsx new file mode 100644 index 0000000000..ef5b197b7a --- /dev/null +++ b/web/src/routes/OrganizationsList/Organization/Tabs/Settings/MarketplaceDetails.tsx @@ -0,0 +1,149 @@ +import { + Button, + Flex, + FlexItem, + HelperText, + List, + ListItem, + Spinner, + Title, +} from '@patternfly/react-core'; +import React, {useEffect} from 'react'; +import RequestError from 'src/components/errors/RequestError'; +import OrgSubscriptionModal from 'src/components/modals/OrgSubscriptionModal'; +import {useCurrentUser} from 'src/hooks/UseCurrentUser'; +import {useMarketplaceSubscriptions} from 'src/hooks/UseMarketplaceSubscriptions'; + +type MarketplaceDetailsProps = { + organizationName: string; + updateTotalPrivate: (total: number) => void; +}; + +export default function MarketplaceDetails(props: MarketplaceDetailsProps) { + const organizationName = props.organizationName; + const {user} = useCurrentUser(); + const {userSubscriptions, orgSubscriptions, loading, error} = + useMarketplaceSubscriptions(organizationName, user.username); + + const [isModalOpen, setIsModalOpen] = React.useState(false); + const [modalType, setModalType] = React.useState('attach'); + + useEffect(() => { + if (loading) return; + if (error) return; + + let marketplaceTotalPrivate = 0; + if (organizationName != user.username) { + for (let i = 0; i < orgSubscriptions.length; i++) { + marketplaceTotalPrivate += + orgSubscriptions[i]['quantity'] * + orgSubscriptions[i]['metadata']['privateRepos']; + } + } else { + for (let i = 0; i < userSubscriptions.length; i++) { + if (userSubscriptions[i]['assigned_to_org'] === null) { + marketplaceTotalPrivate += + userSubscriptions[i]['quantity'] * + userSubscriptions[i]['metadata']['privateRepos']; + } + } + } + props.updateTotalPrivate(marketplaceTotalPrivate); + }); + + const handleModalToggle = (modalType: string) => { + setModalType(modalType); + setIsModalOpen(!isModalOpen); + }; + + if (loading) { + return ; + } + + if (error) { + return ( + <> + + Subscriptions From Red Hat Customer Portal + + + + ); + } + + if (user.username == organizationName) { + return ( + <> + + Subscriptions From Red Hat Customer Portal + + + + {userSubscriptions.map((subscription: Dict) => ( + + {subscription.quantity}x {subscription.sku} + {subscription.assigned_to_org + ? ` attached to ${subscription.assigned_to_org}` + : ' belonging to user namespace'} + + ))} + + + + ); + } + + return ( + <> + + Subscriptions From Red Hat Customer Portal + + + + {orgSubscriptions.map((subscription: Dict) => ( + + {subscription.quantity}x {subscription.sku} attached + + ))} + + + + + + + + + + + ) => sub.assigned_to_org === null, + ) + : orgSubscriptions + } + isOpen={isModalOpen} + handleModalToggle={handleModalToggle} + /> + + ); +}