diff --git a/package-lock.json b/package-lock.json index 686d1bcf..590c6695 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "axios": "^0.27.2", "js-sha1": "^0.6.0", "mini-css-extract-plugin": "^2.6.1", + "moment": "^2.29.4", "null-loader": "^4.0.1", "react": "^17.0.2", "react-router-dom": "^6.3.0", @@ -18867,6 +18868,14 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "engines": { + "node": "*" + } + }, "node_modules/move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -43373,6 +43382,11 @@ "minimist": "^1.2.6" } }, + "moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", diff --git a/package.json b/package.json index 0c627058..76582d07 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "axios": "^0.27.2", "js-sha1": "^0.6.0", "mini-css-extract-plugin": "^2.6.1", + "moment": "^2.29.4", "null-loader": "^4.0.1", "react": "^17.0.2", "react-router-dom": "^6.3.0", diff --git a/src/components/modals/GenerateEncryptedPasswordModal.tsx b/src/components/modals/GenerateEncryptedPasswordModal.tsx new file mode 100644 index 00000000..70d926b3 --- /dev/null +++ b/src/components/modals/GenerateEncryptedPasswordModal.tsx @@ -0,0 +1,81 @@ +import {Modal, ModalVariant, Button, TextInput} from '@patternfly/react-core'; +import {useState} from 'react'; +import FormError from 'src/components/errors/FormError'; +import {addDisplayError} from 'src/resources/ErrorHandling'; +import {useCreateClientKey} from 'src/hooks/UseCreateClientKey'; + +export function GenerateEncryptedPassword(props: ConfirmationModalProps) { + const [err, setErr] = useState(); + + const [password, setPassword] = useState(''); + const [step, setStep] = useState(1); + const {createClientKey, clientKey} = useCreateClientKey({ + onError: (error) => { + console.error(error); + setErr(addDisplayError('Error', error)); + }, + onSuccess: () => { + setStep(step + 1); + }, + }); + + const handleModalConfirm = async () => { + createClientKey(password); + }; + + return ( + + {props.buttonText} + , + , + ] + : [ + , + ] + } + > + {step == 1 && ( + <> + + setPassword(value)} + aria-label="text input example" + label="Password" + /> + Please enter your password in order to generate + + )} + {step == 2 && ( + <> + Your encrypted password is:
{clientKey} + + )} +
+ ); +} + +type ConfirmationModalProps = { + title: string; + modalOpen: boolean; + buttonText: string; + toggleModal: () => void; +}; diff --git a/src/components/modals/UserConvertConflictsModal.tsx b/src/components/modals/UserConvertConflictsModal.tsx new file mode 100644 index 00000000..eed9935f --- /dev/null +++ b/src/components/modals/UserConvertConflictsModal.tsx @@ -0,0 +1,142 @@ +import { + Button, + Modal, + ModalVariant, + PageSection, + PageSectionVariants, + TextInput, + Toolbar, + ToolbarContent, + ToolbarItem, +} from '@patternfly/react-core'; +import { + TableComposable, + Tbody, + Td, + Th, + Thead, + Tr, +} from '@patternfly/react-table'; +import {useState} from 'react'; +import {ToolbarPagination} from 'src/components/toolbar/ToolbarPagination'; +import {IOrganization} from 'src/resources/OrganizationResource'; + +export const UserConvertConflictsModal = ( + props: UserConvertConflictsModal, +): JSX.Element => { + const [itemsMarkedForDelete, setItemsMarkedForDelete] = useState< + IOrganization[] + >(props.items); + + const [searchInput, setSearchInput] = useState(''); + + const [bulkModalPerPage, setBulkModalPerPage] = useState(10); + const [bulkModalPage, setBulkModalPage] = useState(1); + + const paginatedBulkItemsList = itemsMarkedForDelete.slice( + bulkModalPage * bulkModalPerPage - bulkModalPerPage, + bulkModalPage * bulkModalPerPage - bulkModalPerPage + bulkModalPerPage, + ); + + const onSearch = (value: string) => { + setSearchInput(value); + if (value === '') { + setItemsMarkedForDelete(props.items); + } else { + /* Note: This search filter assumes that the search is always based on the 1st column, + hence we do "colNames[0]" */ + const filteredTableRow = props.items.filter((item) => + item.name?.toLowerCase().includes(value.toLowerCase()), + ); + setItemsMarkedForDelete(filteredTableRow); + } + }; + + return ( + + Close + , + ]} + > + + This account cannot be converted into an organization, as it is a member + of another organization. Please leave the following organization(s) + first: + + + + + + + + + + + + + + Organization + Role + + + + {paginatedBulkItemsList.map((item, idx) => ( + + {item.name} + {item.is_org_admin ? 'Admin' : 'User'} + + ))} + + + + + + + + ); +}; + +type UserConvertConflictsModal = { + mapOfColNamesToTableData: { + [key: string]: {label?: string; transformFunc?: (value) => any}; + }; + isModalOpen: boolean; + handleModalToggle?: () => void; + items: IOrganization[]; +}; diff --git a/src/hooks/UseConvertAccount.ts b/src/hooks/UseConvertAccount.ts new file mode 100644 index 00000000..5667ae79 --- /dev/null +++ b/src/hooks/UseConvertAccount.ts @@ -0,0 +1,30 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {convert, ConvertUserRequest} from 'src/resources/UserResource'; + +export function useConvertAccount({onSuccess, onError}) { + const queryClient = useQueryClient(); + + const convertAccountMutator = useMutation( + async ({adminUser, adminPassword}: ConvertUserRequest) => { + return convert({adminUser, adminPassword}); + }, + { + onSuccess: () => { + onSuccess(); + queryClient.invalidateQueries(['user']); + queryClient.invalidateQueries(['organization']); + }, + onError: (err) => { + onError(err); + }, + }, + ); + + return { + convert: async (convertUserRequest: ConvertUserRequest) => + convertAccountMutator.mutate(convertUserRequest), + loading: convertAccountMutator.isLoading, + error: convertAccountMutator.error, + clientKey: convertAccountMutator.data, + }; +} diff --git a/src/hooks/UseCreateClientKey.ts b/src/hooks/UseCreateClientKey.ts new file mode 100644 index 00000000..43ce6239 --- /dev/null +++ b/src/hooks/UseCreateClientKey.ts @@ -0,0 +1,30 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {createClientKey} from 'src/resources/UserResource'; + +export function useCreateClientKey({onSuccess, onError}) { + const queryClient = useQueryClient(); + + const createClientKeyMutator = useMutation( + async ({password}: {password: string}) => { + return createClientKey(password); + }, + { + onSuccess: () => { + onSuccess(); + queryClient.invalidateQueries(['user']); + queryClient.invalidateQueries(['organization']); + }, + onError: (err) => { + onError(err); + }, + }, + ); + + return { + createClientKey: async (password: string) => + createClientKeyMutator.mutate({password}), + loading: createClientKeyMutator.isLoading, + error: createClientKeyMutator.error, + clientKey: createClientKeyMutator.data, + }; +} diff --git a/src/hooks/UseOrganization.ts b/src/hooks/UseOrganization.ts index 8f1d06c0..57e09f98 100644 --- a/src/hooks/UseOrganization.ts +++ b/src/hooks/UseOrganization.ts @@ -13,6 +13,7 @@ export function useOrganization(name: string) { data: organization, isLoading, error, + isPlaceholderData, } = useQuery(['organization', name], ({signal}) => fetchOrg(name, signal), { enabled: !isUserOrganization, placeholderData: (): IOrganization[] => new Array(10).fill({}), @@ -21,7 +22,7 @@ export function useOrganization(name: string) { return { isUserOrganization, error, - loading: isLoading, + loading: isLoading || isPlaceholderData, organization, }; } diff --git a/src/hooks/UsePlan.ts b/src/hooks/UsePlan.ts new file mode 100644 index 00000000..b6860390 --- /dev/null +++ b/src/hooks/UsePlan.ts @@ -0,0 +1,24 @@ +import {fetchPlan} from 'src/resources/PlanResource'; +import {useQuery} from '@tanstack/react-query'; +import {useOrganization} from './UseOrganization'; + +export function usePlan(name: string) { + // Get usernames + const {isUserOrganization} = useOrganization(name); + + // Get organization plan + const { + data: plan, + isLoading, + error, + isPlaceholderData, + } = useQuery(['organization', name, 'plan'], () => { + return fetchPlan(name, isUserOrganization); + }); + + return { + error, + loading: isLoading || isPlaceholderData, + plan, + }; +} diff --git a/src/hooks/UseUpdateOrganization.ts b/src/hooks/UseUpdateOrganization.ts new file mode 100644 index 00000000..6c6acaba --- /dev/null +++ b/src/hooks/UseUpdateOrganization.ts @@ -0,0 +1,36 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {updateOrg, UpdateOrgRequest} from 'src/resources/OrganizationResource'; +export function useUpdateOrganization({onSuccess, onError}) { + const queryClient = useQueryClient(); + + const updateOrganizationMutator = useMutation( + async ({ + name, + updateOrgRequest, + }: { + name: string; + updateOrgRequest: UpdateOrgRequest; + }) => { + return updateOrg(name, updateOrgRequest); + }, + { + onSuccess: () => { + onSuccess(); + queryClient.invalidateQueries(['user']); + queryClient.invalidateQueries(['organization']); + }, + onError: (err) => { + onError(err); + }, + }, + ); + + return { + updateOrganization: async ( + name: string, + updateOrgRequest: UpdateOrgRequest, + ) => updateOrganizationMutator.mutate({name, updateOrgRequest}), + loading: updateOrganizationMutator.isLoading, + error: updateOrganizationMutator.error, + }; +} diff --git a/src/hooks/UseUpdateUser.ts b/src/hooks/UseUpdateUser.ts new file mode 100644 index 00000000..6235625f --- /dev/null +++ b/src/hooks/UseUpdateUser.ts @@ -0,0 +1,35 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {UpdateUserRequest, updateUser} from 'src/resources/UserResource'; + +export function useUpdateUser({onSuccess, onError}) { + const queryClient = useQueryClient(); + + const updateUserMutator = useMutation( + async ({ + name, + updateUserRequest, + }: { + name: string; + updateUserRequest: UpdateUserRequest; + }) => { + return updateUser(name, updateUserRequest); + }, + { + onSuccess: () => { + onSuccess(); + queryClient.invalidateQueries(['user']); + queryClient.invalidateQueries(['organization']); + }, + onError: (err) => { + onError(err); + }, + }, + ); + + return { + updateUser: async (name: string, updateUserRequest: UpdateUserRequest) => + updateUserMutator.mutate({name, updateUserRequest}), + loading: updateUserMutator.isLoading, + error: updateUserMutator.error, + }; +} diff --git a/src/resources/OrganizationResource.ts b/src/resources/OrganizationResource.ts index e5a64b7c..0ec60522 100644 --- a/src/resources/OrganizationResource.ts +++ b/src/resources/OrganizationResource.ts @@ -10,6 +10,8 @@ export interface IAvatar { } export interface IOrganization { + invoice_email?: boolean; + invoice_email_address?: string; name: string; avatar?: IAvatar; can_create_repo?: boolean; @@ -17,6 +19,8 @@ export interface IOrganization { is_org_admin?: boolean; preferred_namespace?: boolean; teams?: string[]; + email?: string; + tag_expiration_s?: number; } export async function fetchOrg(orgname: string, signal: AbortSignal) { @@ -108,3 +112,20 @@ export async function createOrg(name: string, email?: string) { assertHttpCode(response.status, 201); return response.data; } + +export interface UpdateOrgRequest { + invoice_email?: boolean; + invoice_email_address?: string; + tag_expiration_s?: string; + email?: string; +} + +export async function updateOrg( + name: string, + updateOrgRequest: UpdateOrgRequest, +) { + const updateOrgUrl = `/api/v1/organization/${name}`; + const response = await axios.put(updateOrgUrl, updateOrgRequest); + assertHttpCode(response.status, 200); + return response.data; +} diff --git a/src/resources/PlanResource.ts b/src/resources/PlanResource.ts new file mode 100644 index 00000000..2586e46f --- /dev/null +++ b/src/resources/PlanResource.ts @@ -0,0 +1,32 @@ +import {AxiosResponse} from 'axios'; +import axios from 'src/libs/axios'; +import {assertHttpCode} from './ErrorHandling'; + +export interface IPlan { + hasSubscription: boolean; + isExistingCustomer: boolean; + plan: string; + usedPrivateRepos: number; +} + +// FIXME we have to mock this for now + +export async function fetchPlan(name: string, isUserOrganization: boolean) { + // let fetchPlanUrl: string; + // if (isUserOrganization) { + // fetchPlanUrl = `/api/v1/user/${name}/plan`; + // } else { + // fetchPlanUrl = `/api/v1/organization/${name}/plan`; + // } + + // // TODO: Add return type + // const response: AxiosResponse = await axios.get(fetchPlanUrl); + // assertHttpCode(response.status, 200); + + return { + hasSubscription: true, + isExistingCustomer: true, + plan: 'free', + usedPrivateRepos: 10, + } as IPlan; +} diff --git a/src/resources/UserResource.ts b/src/resources/UserResource.ts index b9724a67..71024ba5 100644 --- a/src/resources/UserResource.ts +++ b/src/resources/UserResource.ts @@ -89,3 +89,45 @@ export async function fetchEntities(org: string, search: string) { assertHttpCode(response.status, 200); return response.data?.results; } + +export interface UpdateUserRequest { + invoice_email?: boolean; + family_name?: string; + location?: string; + company?: string; + password?: string; + invoice_email_address?: string; + tag_expiration_s?: string; + email?: string; +} + +export async function updateUser( + name: string, + updateUserRequest: UpdateUserRequest, +) { + const updateUserUrl = `/api/v1/user/`; + const response = await axios.put(updateUserUrl, updateUserRequest); + assertHttpCode(response.status, 200); + return response.data; +} + +export async function createClientKey(password: string): Promise { + const updateUserUrl = `/api/v1/user/clientkey`; + const response = await axios.post(updateUserUrl, {password}); + assertHttpCode(response.status, 200); + return response.data.key; +} + +export interface ConvertUserRequest { + plan?: string; + adminUser: string; + adminPassword: string; +} + +export async function convert( + convertUserRequest: ConvertUserRequest, +): Promise { + const updateUserUrl = `/api/v1/user/convert`; + const response = await axios.post(updateUserUrl, convertUserRequest); + assertHttpCode(response.status, 200); +} diff --git a/src/routes/OrganizationsList/Organization/Tabs/Settings/BillingInformation.tsx b/src/routes/OrganizationsList/Organization/Tabs/Settings/BillingInformation.tsx new file mode 100644 index 00000000..f664ad10 --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/Settings/BillingInformation.tsx @@ -0,0 +1,364 @@ +import {useEffect, useState} from 'react'; +import { + Flex, + FlexItem, + Form, + TextInput, + ActionGroup, + Button, + Title, + Checkbox, + FormAlert, + Alert, + Radio, + FormGroup, + AlertActionLink, + HelperText, +} from '@patternfly/react-core'; +import {useLocation} from 'react-router-dom'; +import {useCurrentUser} from 'src/hooks/UseCurrentUser'; +import {useOrganization} from 'src/hooks/UseOrganization'; +import {useUpdateOrganization} from 'src/hooks/UseUpdateOrganization'; + +import {usePlan} from 'src/hooks/UsePlan'; +import {ExclamationCircleIcon} from '@patternfly/react-icons'; +import {AxiosError} from 'axios'; +import {useUpdateUser} from 'src/hooks/UseUpdateUser'; +import {UserConvertConflictsModal} from 'src/components/modals/UserConvertConflictsModal'; +import {useConvertAccount} from 'src/hooks/UseConvertAccount'; + +export const BillingInformation = () => { + const location = useLocation(); + const organizationName = location.pathname.split('/')[2]; + const {plan} = usePlan(organizationName); + const {user} = useCurrentUser(); + + const [touched, setTouched] = useState(false); + const [invoiceEmail, setInvoiceEmail] = useState(false); + const [invoiceEmailAddress, setInvoiceEmailAddress] = useState(''); + const [convertConflictModalOpen, setConvertConflictModalOpen] = + useState(false); + + const [adminUser, setAdminUser] = useState(''); + const [adminPassword, setAdminPassword] = useState(''); + + type validate = 'success' | 'warning' | 'error' | 'default'; + const [validated, setValidated] = useState('success'); + + const {organization, isUserOrganization, loading} = + useOrganization(organizationName); + + const [accountType, setAccountType] = useState( + isUserOrganization ? 'individual' : 'organization', + ); + useEffect(() => { + setAccountType(isUserOrganization ? 'individual' : 'organization'); + }, [loading]); + + const { + updateOrganization, + loading: organizationUpdateLoading, + error: organizationUpdateError, + } = useUpdateOrganization({ + onSuccess: () => { + setTouched(false); + }, + onError: (err) => { + console.log(err); + }, + }); + + const { + updateUser, + loading: userUpdateLoading, + error: userUpdateError, + } = useUpdateUser({ + onSuccess: () => { + setTouched(false); + }, + onError: (err) => { + console.log(err); + }, + }); + + const { + convert, + loading: convertAccountLoading, + error: convertAccountError, + } = useConvertAccount({ + onSuccess: () => { + setTouched(false); + }, + onError: (err) => { + console.log(err); + }, + }); + + const error = + userUpdateError || organizationUpdateError || convertAccountError; + + const updateLoading = userUpdateLoading || organizationUpdateLoading; + useEffect(() => { + resetFields(); + }, [loading]); + + const resetFields = () => { + if (!loading && organization) { + setInvoiceEmail(organization.invoice_email || false); + setInvoiceEmailAddress(organization.invoice_email_address || ''); + if (organization.invoice_email_address) { + setValidated('success'); + } else { + setValidated('default'); + } + } else if (isUserOrganization) { + setInvoiceEmail(user.invoice_email || false); + setInvoiceEmailAddress(user.invoice_email_address || ''); + if (user.invoice_email_address) { + setValidated('success'); + } else { + setValidated('default'); + } + } + setTouched(false); + }; + + return ( +
+ {error && ( + + + + )} + + + + {plan?.plan.toUpperCase()} organization's plan + + + {`100 of 125 private repositories used`} + {`20 of unlimited public repositories used`} + + + + + + + + { + setTouched(true); + setInvoiceEmail(!invoiceEmail); + }} + /> + + } + helperText="Invoices will be sent to this e-mail address." + > + { + setTouched(true); + setInvoiceEmailAddress(val); + if (/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(val)) { + setValidated('success'); + } else { + setValidated('error'); + } + }} + /> + + + + {isUserOrganization && ( + <> + Account Type + setAccountType('individual')} + description="Single account with multiple repositories" + /> + setAccountType('organization')} + isChecked={accountType == 'organization'} + description="Multiple users and teams that share access and billing under a single namespace" + /> + {user.organizations.length > 0 && accountType == 'organization' && ( + + setConvertConflictModalOpen(true)} + > + View details + + + } + > +

+ This account cannot be converted into an organization, as it is + already a member of one or many organizations. +

+
+ )} + {!user.organizations.length && accountType == 'organization' && ( + <> + + setConvertConflictModalOpen(true)} + > + View details + + + } + > +

+ Fill out the form below to convert your current user account + into an organization. Your existing repositories will be + maintained under the namespace. All direct permissions + delegated to quayusername will be deleted. +

+
+ + Admin User + + The username and password for the account that will become an + administrator of the organization. Note that this account must + be a seperate registered account from the account that you are + trying to convert, and must already exist + + + { + setAdminUser(val); + }} + value={adminUser} + /> + + + { + setAdminPassword(val); + }} + /> + + + + + )} + + )} + + + + + + + + setConvertConflictModalOpen(false)} + mapOfColNamesToTableData={{}} + /> + + ); +}; diff --git a/src/routes/OrganizationsList/Organization/Tabs/Settings/CLIConfiguration.tsx b/src/routes/OrganizationsList/Organization/Tabs/Settings/CLIConfiguration.tsx new file mode 100644 index 00000000..fc276aa2 --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/Settings/CLIConfiguration.tsx @@ -0,0 +1,33 @@ +import {Grid, GridItem, Text, Title} from '@patternfly/react-core'; +import {Button} from '@patternfly/react-core'; +import {GenerateEncryptedPassword} from 'src/components/modals/GenerateEncryptedPasswordModal'; +import {useState} from 'react'; + +export const CliConfiguration = () => { + const [open, toggleOpen] = useState(false); + return ( + + + Docker CLI Password + + + + The Docker CLI stores passwords entered on the command line in + plaintext. It is therefore highly recommended to generate an encrypted + version of your password for use with docker login. + + + + + + toggleOpen(false)} + /> + + ); +}; diff --git a/src/routes/OrganizationsList/Organization/Tabs/Settings/GeneralSettings.tsx b/src/routes/OrganizationsList/Organization/Tabs/Settings/GeneralSettings.tsx new file mode 100644 index 00000000..cac228fa --- /dev/null +++ b/src/routes/OrganizationsList/Organization/Tabs/Settings/GeneralSettings.tsx @@ -0,0 +1,280 @@ +import {useEffect, useState} from 'react'; +import { + Flex, + FormGroup, + Form, + TextInput, + FormSelect, + FormSelectOption, + ActionGroup, + Button, + FormAlert, + Alert, + Grid, + GridItem, +} from '@patternfly/react-core'; +import {useLocation} from 'react-router-dom'; +import {useCurrentUser} from 'src/hooks/UseCurrentUser'; +import {useOrganization} from 'src/hooks/UseOrganization'; +import {useUpdateOrganization} from 'src/hooks/UseUpdateOrganization'; +import {useQuayConfig} from 'src/hooks/UseQuayConfig'; +import moment from 'moment'; +import {ExclamationCircleIcon} from '@patternfly/react-icons'; +import {AxiosError} from 'axios'; +import {useUpdateUser} from 'src/hooks/UseUpdateUser'; + +export const GeneralSettings = () => { + const location = useLocation(); + const organizationName = location.pathname.split('/')[2]; + + const {user} = useCurrentUser(); + const {config} = useQuayConfig(); + + const tagExpirationOptions = config.TAG_EXPIRATION_OPTIONS.map((option) => { + const number = option.substring(0, option.length - 1); + const suffix = option.substring(option.length - 1); + return moment.duration(number, suffix).asSeconds(); + }); + + type validate = 'success' | 'warning' | 'error' | 'default'; + const [validated, setValidated] = useState('success'); + + const {organization, isUserOrganization, loading} = + useOrganization(organizationName); + + const { + updateOrganization, + loading: organizationUpdateLoading, + error: organizationUpdateError, + } = useUpdateOrganization({ + onSuccess: () => { + setTouched(false); + }, + onError: (err) => { + console.log(err); + }, + }); + + const { + updateUser, + loading: userUpdateLoading, + error: userUpdateError, + } = useUpdateUser({ + onSuccess: () => { + setTouched(false); + }, + onError: (err) => { + console.log(err); + }, + }); + + const error = userUpdateError || organizationUpdateError; + + const updateLoading = userUpdateLoading || organizationUpdateLoading; + + // Time Machine + const timeMachineOptions = tagExpirationOptions; + const [timeMachineFormValue, setTimeMachineFormValue] = useState( + timeMachineOptions[0], + ); + + const [touched, setTouched] = useState(false); + + // Email + const [emailFormValue, setEmailFormValue] = useState(''); + const [fullName, setFullName] = useState(''); + const [userLocation, setUserLocation] = useState(''); + const [company, setCompany] = useState(''); + + useEffect(() => { + resetFields(); + }, [loading, isUserOrganization]); + + const resetFields = () => { + if (!loading && organization) { + setEmailFormValue(organization.email || ''); + setTimeMachineFormValue(organization.tag_expiration_s || 0); + } else if (isUserOrganization) { + setEmailFormValue(user.email || ''); + setTimeMachineFormValue(user.tag_expiration_s || 0); + setFullName(user.family_name || ''); + setCompany(user.company || ''); + setUserLocation(user.location || ''); + } + setTouched(false); + }; + + return ( +
+ {error && touched && ( + + + + )} + + + {!isUserOrganization && ( + + + + + + )} + + + } + > + { + setTouched(true); + setEmailFormValue(val); + if (/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(val)) { + setValidated('success'); + } else { + setValidated('error'); + } + }} + /> + + + + {isUserOrganization && ( + <> + + + { + setTouched(true); + setFullName(val); + }} + /> + + + + + { + setTouched(true); + setUserLocation(val); + }} + /> + + + + + { + setTouched(true); + setCompany(val); + }} + /> + + + + )} + + + + { + setTouched(true); + setTimeMachineFormValue(parseInt(val)); + }} + > + {timeMachineOptions.map((option, index) => ( + + ))} + + + + + + + + + + + +
+ ); +}; diff --git a/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx b/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx index f55701b2..f9fcaa85 100644 --- a/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx +++ b/src/routes/OrganizationsList/Organization/Tabs/Settings/Settings.tsx @@ -16,6 +16,8 @@ import { import {useLocation} from 'react-router-dom'; import {useCurrentUser} from 'src/hooks/UseCurrentUser'; import {useOrganization} from 'src/hooks/UseOrganization'; +import {BillingInformation} from './BillingInformation'; +import {CliConfiguration} from './CLIConfiguration'; const GeneralSettings = () => { const location = useLocation(); @@ -120,11 +122,11 @@ export default function Settings() { id: 'generalsettings', content: , }, - // { - // name: 'Billing Information', - // id: 'billinginformation', - // content: , - // }, + { + name: 'Billing Information', + id: 'billinginformation', + content: , + }, ]; return ( @@ -141,14 +143,16 @@ export default function Settings() { {tab.name}} + title={{tab.name}} /> ))} {tabs.at(activeTabIndex).content}