diff --git a/ditto/base.json b/ditto/base.json index 6cbd9ebfb..864c8ff39 100644 --- a/ditto/base.json +++ b/ditto/base.json @@ -262,6 +262,44 @@ "text_649c54823c9089006247625a": "Can’t be prorated due to {{chargeModel}} charge model defined above.", "text_649c49bcebd91c0082d84446": "Full charge", "text_649c54823c90890062476259": "Based on the event timestamp and end date of the billing period.", + "text_664c732c264d7eed1c74fd96": "Authentication", + "text_664c732c264d7eed1c74fd9c": "Manage how to authenticate to your Lago organization.", + "text_664c732c264d7eed1c74fda2": "Okta", + "text_664c732c264d7eed1c74fda8": "Allows logins using SSO by connecting Lago and Okta.", + "text_664c732c264d7eed1c74fdb4": "{{integration}} integration successfully deleted", + "text_664c732c264d7eed1c74fd88": "Connect Lago and Okta", + "text_664c732c264d7eed1c74fd8e": "To connect to Okta, please enter the following information", + "text_664c732c264d7eed1c74fd94": "Domain name", + "text_664c732c264d7eed1c74fd9a": "Type your domain name", + "text_664c732c264d7eed1c74fda0": "e.g. acme.com", + "text_664c732c264d7eed1c74fda6": "Okta public key", + "text_664c732c264d7eed1c74fdac": "Type your Okta public key", + "text_664c732c264d7eed1c74fdb2": "Okta private key", + "text_664c732c264d7eed1c74fdb7": "Type your Okta private key", + "text_664c732c264d7eed1c74fdbb": "Okta organization name", + "text_664c732c264d7eed1c74fdbf": "Type your organization name", + "text_664c732c264d7eed1c74fdc3": ".okta.com", + "text_664c732c264d7eed1c74fdcb": "Connect to Okta", + "text_664c732c264d7eed1c74fdaa": "Edit connection", + "text_664c732c264d7eed1c74fdb0": "Delete connection", + "text_664c732c264d7eed1c74fdbd": "Identity provider", + "text_664c732c264d7eed1c74fdc5": "Connection details", + "text_664c732c264d7eed1c74fde6": "{{integration}} integration successfully connected", + "text_664c732c264d7eed1c74fde8": "{{integration}} integration successfully edited", + "text_664c732c264d7eed1c74fe03": "Please fill this input with a domain format to move forward", + "text_664c8fa719b5e7ad81c86018": "Edit Okta connection", + "text_664c8fa719b5e7ad81c86019": "To edit Okta connection, please edit the following information", + "text_664c900d2d312a01546bd84b": "Delete connection to Okta", + "text_664c900d2d312a01546bd84c": "By deleting the connection, it will not be used anymore and you’ll be not able to access to your Lago organization via Okta SSO. Are you sure?", + "text_664c90c9b2b6c2012aa50bce": "Log In with Okta", + "text_664c90c9b2b6c2012aa50bd0": "Please log in with your enterprise account", + "text_664c90c9b2b6c2012aa50bd6": "Okta provider was not assigned for this domain, please contact an admin.", + "text_664c90c9b2b6c2012aa50bda": "Log In with another method? -", + "text_664c90c9b2b6c2012aa50bcd": "Join {{orgnisationName}}", + "text_664c90c9b2b6c2012aa50bd1": "Oops! Looks like your Okta provider was not assigned for this domain, please contact an admin.", + "text_664c90c9b2b6c2012aa50bd3": "Join with Google", + "text_664c90c9b2b6c2012aa50bd5": "Join with Okta", + "text_664c98989d08a3f733357f73": "There was an error while fetching user info from Okta. Please try again.", "text_66141e30699a0631f0b2ec7f": "There is no invoices with dispute lost", "text_66141e30699a0631f0b2ec87": "Disputed lost invoices occur when a customer chooses not to pay, requests a dispute, and demands a chargeback.", "text_66141e30699a0631f0b2ec59": "Mark this invoice as disputed", @@ -946,7 +984,7 @@ "text_63208bfc99e69a28211ec7fd": "Show more invitations", "text_63208c711ce25db78140755d": "Member successfully removed", "text_63208c701ce25db78140748f": "Invite members", - "text_63208c701ce25db78140749b": "To generate a link for inviting a member to this organization, please enter the new member’s email.", + "text_63208c701ce25db78140749b": "To generate a link for inviting a member to this organization, please enter the member’s email and select a role.", "text_63208c701ce25db7814074ab": "Email address", "text_63208c711ce25db7814074c1": "jane@banco.com", "text_63208c711ce25db7814074cd": "Cancel", @@ -968,7 +1006,6 @@ "text_6321a076b94bd1b32494e9ee": "Something went wrong", "text_6321a076b94bd1b32494e9f0": "We can’t fetch the members list, please refresh the section or contact us if the error persists.", "text_6321a076b94bd1b32494e9f2": "Refresh the section", - "text_63246f875e2228ab7b63dcd0": "Sign up to {{orgnisationName}}", "text_63246f875e2228ab7b63dcd4": "Create your account and access to this organization.", "text_63246f875e2228ab7b63dcdc": "Email address", "text_63246f875e2228ab7b63dce9": "Set your password", @@ -991,7 +1028,6 @@ "text_660bf95c75dd928ced0ecb21": "Continue with Google", "text_660bf95c75dd928ced0ecb33": "Type your organization name", "text_660bf95c75dd928ced0ecb2b": "Oops! Looks like your Google account email doesn't match the invitation. Give it another shot!", - "text_660bf95c75dd928ced0ecb37": "Sign up with Google", "text_660bfaa2cbc95800a63f48b1": "Sorry, it seems there are no accounts associated with the provided credentials.", "text_642707b0da1753a9bb6672b5": "Forgot password", "text_642707b0da1753a9bb66728e": "Password reset email sent!", diff --git a/ditto/config.yml b/ditto/config.yml index bdb87b773..22351df1d 100644 --- a/ditto/config.yml +++ b/ditto/config.yml @@ -358,5 +358,8 @@ sources: fileName: 👍 [Ready for dev] - Wallets - Real time prepaid credit improvements - name: 👍 [Ready for dev] - B.Metrics | Plans - Custom aggregation | price id: 663dea541d89e5df98fbc05b + fileName: 👍 [Ready for dev] - B.Metrics | Plans - Custom aggregation | price + - name: ⚙️ [WIP] - Onboarding - Log In | Join Lago via OKTA SSO + id: 664c7329c182a2ec5807ffec format: flat variants: true diff --git a/ditto/index.js b/ditto/index.js index 197e21895..e15e7bd48 100644 --- a/ditto/index.js +++ b/ditto/index.js @@ -106,6 +106,7 @@ const wip___customers___subscription_on_anniversary_date = require('./wip---cust const wip___general___fe_environment_infos = require('./wip---general---fe-environment-infos__base.json'); const wip___integration___lago_eu_tax_integration = require('./wip---integration---lago-eu-tax-integration__base.json'); const wip___invoices___dispute_payment_intent = require('./wip---invoices---dispute-payment-intent__base.json'); +const wip___onboarding___log_in_join_lago_via_okta_sso = require('./wip---onboarding---log-in-join-lago-via-okta-sso__base.json'); const wip___plan___charges_paid_in_advance = require('./wip---plan---charges-paid-in-advance__base.json'); const wip___settings___create_tax_rate_object_apply_on_org_cus = require('./wip---settings---create-tax-rate-object-apply-on-org-cus__base.json'); const wip___settings___define_preferred_doc_language_generation = require('./wip---settings---define-preferred-doc-language-generation__base.json'); @@ -430,6 +431,9 @@ module.exports = { "project_66141e2ffa16c75cb553dbc1": { "base": {...wip___invoices___dispute_payment_intent} }, + "project_664c7329c182a2ec5807ffec": { + "base": {...wip___onboarding___log_in_join_lago_via_okta_sso} + }, "project_646e2d05cf47b79ad4b5ccf5": { "base": {...wip___plan___charges_paid_in_advance} }, diff --git a/src/components/auth/GoogleAuthButton.tsx b/src/components/auth/GoogleAuthButton.tsx index 8d21c51ee..46ce4a0b0 100644 --- a/src/components/auth/GoogleAuthButton.tsx +++ b/src/components/auth/GoogleAuthButton.tsx @@ -103,9 +103,13 @@ const GoogleAuthButton = ({ } if (data?.googleAuthUrl?.url) { - window.location.href = addValuesToUrlState(data.googleAuthUrl.url, { - mode, - ...(!!invitationToken && { invitationToken }), + window.location.href = addValuesToUrlState({ + url: data.googleAuthUrl.url, + stateType: 'object', + values: { + mode, + ...(!!invitationToken && { invitationToken }), + }, }) } }} diff --git a/src/components/designSystem/Icon/mapping.tsx b/src/components/designSystem/Icon/mapping.tsx index a2371a42e..9ac5efe01 100644 --- a/src/components/designSystem/Icon/mapping.tsx +++ b/src/components/designSystem/Icon/mapping.tsx @@ -67,6 +67,7 @@ import Mail from '~/public/icons/mail.svg' import Map from '~/public/icons/map.svg' import Micro from '~/public/icons/micro.svg' import Minus from '~/public/icons/minus.svg' +import Okta from '~/public/icons/okta.svg' import Outside from '~/public/icons/outside.svg' import Paperclip from '~/public/icons/paperclip.svg' import PauseCircleFilled from '~/public/icons/pause-circle-filled.svg' @@ -184,6 +185,7 @@ export const ALL_ICONS = { map: Map, micro: Micro, minus: Minus, + okta: Okta, outside: Outside, paperclip: Paperclip, 'pause-circle-filled': PauseCircleFilled, diff --git a/src/components/settings/authentication/AddOktaDialog.tsx b/src/components/settings/authentication/AddOktaDialog.tsx new file mode 100644 index 000000000..fe0086efd --- /dev/null +++ b/src/components/settings/authentication/AddOktaDialog.tsx @@ -0,0 +1,154 @@ +import { InputAdornment, Stack } from '@mui/material' +import { forwardRef, RefObject, useImperativeHandle, useRef, useState } from 'react' +import styled from 'styled-components' + +import { Button, Dialog, DialogRef } from '~/components/designSystem' +import { TextInputField } from '~/components/form' +import { DeleteOktaIntegrationDialogRef } from '~/components/settings/authentication/DeleteOktaIntegrationDialog' +import { + useOktaIntegration, + UseOktaIntegrationProps, +} from '~/components/settings/authentication/useOktaIntegration' +import { AddOktaIntegrationDialogFragment } from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' +import { theme } from '~/styles' + +type AddOktaDialogProps = Partial<{ + integration: AddOktaIntegrationDialogFragment + deleteModalRef: RefObject + deleteDialogCallback: Function + callback?: UseOktaIntegrationProps['onSubmit'] +}> + +export interface AddOktaDialogRef { + openDialog: (props?: AddOktaDialogProps) => unknown + closeDialog: () => unknown +} + +export const AddOktaDialog = forwardRef((_, ref) => { + const { translate } = useInternationalization() + + const dialogRef = useRef(null) + const [localData, setLocalData] = useState() + + const integration = localData?.integration + const isEdition = !!integration + + const { formikProps } = useOktaIntegration({ + initialValues: integration, + onSubmit: (id) => { + localData?.callback?.(id) + dialogRef.current?.closeDialog() + }, + }) + + useImperativeHandle(ref, () => ({ + openDialog: (data) => { + setLocalData(data) + dialogRef.current?.openDialog() + }, + closeDialog: () => dialogRef.current?.closeDialog(), + })) + + return ( + <> + ( + + {isEdition && localData?.deleteDialogCallback && ( + + )} + + + + + + + )} + > + + + + + + {translate('text_664c732c264d7eed1c74fdc3')} + + ), + }} + /> + + + + ) +}) + +const Content = styled.div` + margin-bottom: ${theme.spacing(8)}; + + > *:not(:last-child) { + margin-bottom: ${theme.spacing(6)}; + } +` + +AddOktaDialog.displayName = 'AddOktaDialog' diff --git a/src/components/settings/authentication/DeleteOktaIntegrationDialog.tsx b/src/components/settings/authentication/DeleteOktaIntegrationDialog.tsx new file mode 100644 index 000000000..a9ce79676 --- /dev/null +++ b/src/components/settings/authentication/DeleteOktaIntegrationDialog.tsx @@ -0,0 +1,89 @@ +import { gql } from '@apollo/client' +import { forwardRef, useImperativeHandle, useRef, useState } from 'react' + +import { WarningDialog, WarningDialogRef } from '~/components/WarningDialog' +import { addToast } from '~/core/apolloClient' +import { + DeleteOktaIntegrationDialogFragment, + useDestroyIntegrationMutation, +} from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' + +gql` + fragment DeleteOktaIntegrationDialog on OktaIntegration { + id + name + } + + mutation DestroyIntegration($input: DestroyIntegrationInput!) { + destroyIntegration(input: $input) { + id + } + } +` + +type DeleteOktaIntegrationDialogProps = { + integration: DeleteOktaIntegrationDialogFragment | undefined + callback?: Function +} + +export interface DeleteOktaIntegrationDialogRef { + openDialog: ({ integration, callback }: DeleteOktaIntegrationDialogProps) => unknown + closeDialog: () => unknown +} + +export const DeleteOktaIntegrationDialog = forwardRef((_, ref) => { + const { translate } = useInternationalization() + + const dialogRef = useRef(null) + const [localData, setLocalData] = useState() + + const integration = localData?.integration + + const [deleteIntegration] = useDestroyIntegrationMutation({ + onCompleted(data) { + if (data && data.destroyIntegration) { + dialogRef.current?.closeDialog() + localData?.callback?.() + + addToast({ + message: translate('text_664c732c264d7eed1c74fdb4', { + integration: translate('text_664c732c264d7eed1c74fda2'), + }), + severity: 'success', + }) + } + }, + update(cache) { + cache.evict({ id: `OktaIntegration:${integration?.id}` }) + }, + }) + + useImperativeHandle(ref, () => ({ + openDialog: (data) => { + setLocalData(data) + dialogRef.current?.openDialog() + }, + closeDialog: () => dialogRef.current?.closeDialog(), + })) + + return ( + + await deleteIntegration({ + variables: { + input: { + id: integration?.id ?? '', + }, + }, + }) + } + continueText={translate('text_645d071272418a14c1c76a81')} + /> + ) +}) + +DeleteOktaIntegrationDialog.displayName = 'DeleteOktaIntegrationDialog' diff --git a/src/components/settings/authentication/useOktaIntegration.ts b/src/components/settings/authentication/useOktaIntegration.ts new file mode 100644 index 000000000..b15d76352 --- /dev/null +++ b/src/components/settings/authentication/useOktaIntegration.ts @@ -0,0 +1,113 @@ +import { gql } from '@apollo/client' +import { useFormik } from 'formik' +import { object, string } from 'yup' + +import { addToast } from '~/core/apolloClient' +import { + AddOktaIntegrationDialogFragment, + CreateOktaIntegrationInput, + DeleteOktaIntegrationDialogFragmentDoc, + useCreateOktaIntegrationMutation, + useUpdateOktaIntegrationMutation, +} from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' + +gql` + fragment AddOktaIntegrationDialog on OktaIntegration { + id + domain + clientId + clientSecret + organizationName + ...DeleteOktaIntegrationDialog + } + + mutation createOktaIntegration($input: CreateOktaIntegrationInput!) { + createOktaIntegration(input: $input) { + id + } + } + + mutation updateOktaIntegration($input: UpdateOktaIntegrationInput!) { + updateOktaIntegration(input: $input) { + id + } + } + + ${DeleteOktaIntegrationDialogFragmentDoc} +` + +const oktaIntegrationSchema = object().shape({ + domain: string().domain('text_664c732c264d7eed1c74fe03').required(''), + clientId: string().required(''), + clientSecret: string().required(''), + organizationName: string().required(''), +}) + +export interface UseOktaIntegrationProps { + initialValues?: AddOktaIntegrationDialogFragment + onSubmit?: (id: string) => void +} + +export const useOktaIntegration = ({ initialValues, onSubmit }: UseOktaIntegrationProps) => { + const { translate } = useInternationalization() + const isEdition = !!initialValues + + const [createIntegration] = useCreateOktaIntegrationMutation({ + onCompleted: (res) => { + if (!res.createOktaIntegration) return + + onSubmit?.(res.createOktaIntegration?.id) + addToast({ + severity: 'success', + message: translate('text_664c732c264d7eed1c74fde6', { + integration: translate('text_664c732c264d7eed1c74fda2'), + }), + }) + }, + }) + + const [updateIntegration] = useUpdateOktaIntegrationMutation({ + onCompleted: (res) => { + if (!res.updateOktaIntegration) return + + onSubmit?.(res.updateOktaIntegration?.id) + addToast({ + severity: 'success', + message: translate('text_664c732c264d7eed1c74fde8', { + integration: translate('text_664c732c264d7eed1c74fda2'), + }), + }) + }, + }) + + const formikProps = useFormik({ + initialValues: { + domain: initialValues?.domain || '', + clientId: initialValues?.clientId || '', + clientSecret: initialValues?.clientSecret || '', + organizationName: initialValues?.organizationName || '', + }, + validationSchema: oktaIntegrationSchema, + onSubmit: async (values) => { + if (isEdition) { + await updateIntegration({ + variables: { + input: { + ...values, + id: initialValues?.id || '', + }, + }, + }) + } else { + await createIntegration({ variables: { input: values } }) + } + }, + validateOnMount: true, + enableReinitialize: true, + }) + + return { + formikProps, + } +} diff --git a/src/core/apolloClient/graphqlResolvers.tsx b/src/core/apolloClient/graphqlResolvers.tsx index e6f68c8c6..2adb3bcea 100644 --- a/src/core/apolloClient/graphqlResolvers.tsx +++ b/src/core/apolloClient/graphqlResolvers.tsx @@ -38,6 +38,8 @@ export const typeDefs = gql` invalid_google_code invalid_google_token google_auth_missing_setup + domain_not_configured + okta_userinfo_error } ` diff --git a/src/core/router/AuthRoutes.tsx b/src/core/router/AuthRoutes.tsx index f1cca3c96..66327f943 100644 --- a/src/core/router/AuthRoutes.tsx +++ b/src/core/router/AuthRoutes.tsx @@ -20,17 +20,25 @@ const InvitationInit = lazyLoad( () => import(/* webpackChunkName: 'invitation-init' */ '~/pages/InvitationInit'), ) const GoogleAuthCallback = lazyLoad( - () => import(/* webpackChunkName: 'invitation-init' */ '~/pages/auth/GoogleAuthCallback'), + () => import(/* webpackChunkName: 'google-auth-callback' */ '~/pages/auth/GoogleAuthCallback'), +) +const LoginOkta = lazyLoad( + () => import(/* webpackChunkName: 'login-okta' */ '~/pages/auth/LoginOkta'), +) +const OktaAuthCallback = lazyLoad( + () => import(/* webpackChunkName: 'okta-auth-callback' */ '~/pages/auth/OktaAuthCallback'), ) // ----------- Routes ----------- export const LOGIN_ROUTE = '/login' +export const LOGIN_OKTA = `${LOGIN_ROUTE}/okta` export const FORGOT_PASSWORD_ROUTE = '/forgot-password' export const RESET_PASSWORD_ROUTE = '/reset-password/:token' export const SIGN_UP_ROUTE = '/sign-up' export const INVITATION_ROUTE = '/invitation/:token' export const INVITATION_ROUTE_FORM = '/invitation/:token/form' export const GOOGLE_AUTH_CALLBACK = '/auth/google/callback' +export const OKTA_AUTH_CALLBACK = '/auth/okta/callback' export const authRoutes: CustomRouteObject[] = [ ...(!disableSignUp @@ -47,6 +55,11 @@ export const authRoutes: CustomRouteObject[] = [ element: , onlyPublic: true, }, + { + path: LOGIN_OKTA, + element: , + onlyPublic: true, + }, { path: FORGOT_PASSWORD_ROUTE, element: , @@ -57,6 +70,11 @@ export const authRoutes: CustomRouteObject[] = [ element: , onlyPublic: true, }, + { + path: OKTA_AUTH_CALLBACK, + element: , + onlyPublic: true, + }, { path: RESET_PASSWORD_ROUTE, element: , diff --git a/src/core/router/SettingRoutes.tsx b/src/core/router/SettingRoutes.tsx index 080623a63..db2bab703 100644 --- a/src/core/router/SettingRoutes.tsx +++ b/src/core/router/SettingRoutes.tsx @@ -21,6 +21,18 @@ const Members = lazyLoad(() => import(/* webpackChunkName: 'members' */ '~/pages const Integrations = lazyLoad( () => import(/* webpackChunkName: 'integrations' */ '~/pages/settings/Integrations'), ) +const Authentication = lazyLoad( + () => + import( + /* webpackChunkName: 'authentication' */ '~/pages/settings/Authentication/Authentication' + ), +) +const OktaAuthenticationDetails = lazyLoad( + () => + import( + /* webpackChunkName: 'okta-authentication-details' */ '~/pages/settings/Authentication/OktaAuthenticationDetails' + ), +) const AdyenIntegrations = lazyLoad( () => import(/* webpackChunkName: 'adyen-integrations' */ '~/pages/settings/AdyenIntegrations'), ) @@ -77,6 +89,8 @@ export const INVOICE_SETTINGS_ROUTE = `${SETTINGS_ROUTE}/invoice` export const TAXES_SETTINGS_ROUTE = `${SETTINGS_ROUTE}/taxes` export const ORGANIZATION_INFORMATIONS_ROUTE = `${SETTINGS_ROUTE}/organization-informations` export const INTEGRATIONS_ROUTE = `${SETTINGS_ROUTE}/integrations` +export const AUTHENTICATION_ROUTE = `${SETTINGS_ROUTE}/authentication` +export const OKTA_AUTHENTICATION_ROUTE = `${AUTHENTICATION_ROUTE}/okta/:integrationId` export const ADYEN_INTEGRATION_ROUTE = `${INTEGRATIONS_ROUTE}/adyen` export const ADYEN_INTEGRATION_DETAILS_ROUTE = `${INTEGRATIONS_ROUTE}/adyen/:integrationId` export const STRIPE_INTEGRATION_ROUTE = `${INTEGRATIONS_ROUTE}/stripe` @@ -124,6 +138,18 @@ export const settingRoutes: CustomRouteObject[] = [ element: , permissions: ['organizationIntegrationsView'], }, + { + path: AUTHENTICATION_ROUTE, + private: true, + element: , + permissions: ['organizationIntegrationsView'], + }, + { + path: OKTA_AUTHENTICATION_ROUTE, + private: true, + element: , + permissions: ['organizationIntegrationsView'], + }, { path: MEMBERS_ROUTE, private: true, diff --git a/src/core/utils/__tests__/urlUtils.test.ts b/src/core/utils/__tests__/urlUtils.test.ts index 47e4c7381..96a1f5de6 100644 --- a/src/core/utils/__tests__/urlUtils.test.ts +++ b/src/core/utils/__tests__/urlUtils.test.ts @@ -2,34 +2,114 @@ import { addValuesToUrlState } from '../urlUtils' describe('urlUtils', () => { describe('addValuesToUrlState', () => { - it('should create state with value if not existing', () => { - const url = 'http://localhost:3000' - const mode = 'login' - const result = addValuesToUrlState(url, { mode, test: 'value' }) - - expect(result).toEqual( - 'http://localhost:3000/?state=%7B%22mode%22%3A%22login%22%2C%22test%22%3A%22value%22%7D', - ) - }) + describe('stateType: object', () => { + it('should create state with value if not existing', () => { + const url = 'http://localhost:3000' + const mode = 'login' + const result = addValuesToUrlState({ + url, + stateType: 'object', + values: { mode, test: 'value' }, + }) + + const expectedOutput = '%7B%22mode%22%3A%22login%22%2C%22test%22%3A%22value%22%7D' + + expect(result).toEqual(`http://localhost:3000/?state=${expectedOutput}`) + expect(decodeURIComponent(expectedOutput)).toEqual( + JSON.stringify({ + mode: 'login', + test: 'value', + }), + ) + }) + + it('should add mode to url state', () => { + const url = 'http://localhost:3000?state={}' + const mode = 'login' + const result = addValuesToUrlState({ + url, + stateType: 'object', + values: { mode, test: 'value' }, + }) + + const expectedOutput = '%7B%22mode%22%3A%22login%22%2C%22test%22%3A%22value%22%7D' + + expect(result).toEqual(`http://localhost:3000/?state=${expectedOutput}`) + expect(decodeURIComponent(expectedOutput)).toEqual( + JSON.stringify({ + mode: 'login', + test: 'value', + }), + ) + }) - it('should add mode to url state', () => { - const url = 'http://localhost:3000?state={}' - const mode = 'login' - const result = addValuesToUrlState(url, { mode, test: 'value' }) + it('should add happen mode to existing state values', () => { + const url = 'http://localhost:3000?state={"other":"value"}' + const mode = 'login' + const result = addValuesToUrlState({ + url, + stateType: 'object', + values: { mode, test: 'value' }, + }) - expect(result).toEqual( - 'http://localhost:3000/?state=%7B%22mode%22%3A%22login%22%2C%22test%22%3A%22value%22%7D', - ) + const expectedOutput = + '%7B%22other%22%3A%22value%22%2C%22mode%22%3A%22login%22%2C%22test%22%3A%22value%22%7D' + + expect(result).toEqual(`http://localhost:3000/?state=${expectedOutput}`) + expect(decodeURIComponent(expectedOutput)).toEqual( + JSON.stringify({ + other: 'value', + mode: 'login', + test: 'value', + }), + ) + }) }) - it('should add happen mode to existing state values', () => { - const url = 'http://localhost:3000?state={"other":"value"}' - const mode = 'login' - const result = addValuesToUrlState(url, { mode, test: 'value' }) + describe('stateType: string', () => { + it('should create state with value if not existing', () => { + const url = 'http://localhost:3000/?state' + const mode = 'login' + const result = addValuesToUrlState({ + url, + stateType: 'string', + values: { mode, test: 'value' }, + }) + + const expectedOutput = + '%7B%22state%22%3A%22%7B%7D%22%2C%22mode%22%3A%22login%22%2C%22test%22%3A%22value%22%7D' + + expect(result).toEqual(`http://localhost:3000/?state=${expectedOutput}`) + expect(decodeURIComponent(expectedOutput)).toEqual( + JSON.stringify({ + state: '{}', + mode: 'login', + test: 'value', + }), + ) + }) + + it('should add mode to url state', () => { + const url = 'http://localhost:3000/?state=id' + const mode = 'login' + const result = addValuesToUrlState({ + url, + stateType: 'string', + values: { mode, test: 'value' }, + }) + + const expectedOutput = + '%7B%22state%22%3A%22id%22%2C%22mode%22%3A%22login%22%2C%22test%22%3A%22value%22%7D' - expect(result).toEqual( - 'http://localhost:3000/?state=%7B%22other%22%3A%22value%22%2C%22mode%22%3A%22login%22%2C%22test%22%3A%22value%22%7D', - ) + expect(result).toEqual(`http://localhost:3000/?state=${expectedOutput}`) + expect(decodeURIComponent(expectedOutput)).toEqual( + JSON.stringify({ + state: 'id', + mode: 'login', + test: 'value', + }), + ) + }) }) }) }) diff --git a/src/core/utils/urlUtils.ts b/src/core/utils/urlUtils.ts index 23f6e5319..e23f5ed8d 100644 --- a/src/core/utils/urlUtils.ts +++ b/src/core/utils/urlUtils.ts @@ -1,9 +1,26 @@ -export const addValuesToUrlState = (url: string, values: Record) => { +export const addValuesToUrlState = ({ + url, + values, + stateType, +}: { + url: string + values: Record + stateType: 'string' | 'object' +}) => { let urlObj = new URL(url) let urlSearchParams = urlObj.searchParams - let state = JSON.parse(urlSearchParams.get('state') || ('{}' as string)) - state = { ...state, ...values } + const oldState = urlSearchParams.get('state') || ('{}' as string) + + let state = {} + + if (stateType === 'string') { + state = { state: oldState, ...values } + } else if (stateType === 'object') { + const parsedState = JSON.parse(oldState) + + state = { ...parsedState, ...values } + } urlSearchParams.set('state', decodeURI(JSON.stringify(state))) urlObj.search = urlSearchParams.toString() diff --git a/src/formValidation/initializeYup.ts b/src/formValidation/initializeYup.ts index c2ac55ad5..6cd55fd1c 100644 --- a/src/formValidation/initializeYup.ts +++ b/src/formValidation/initializeYup.ts @@ -3,6 +3,9 @@ import { addMethod, string } from 'yup' const EMAIL_REGEX: RegExp = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/g +const DOMAIN_REGEX: RegExp = + /^((?!-))(xn--)?[a-z0-9][a-z0-9-_]{0,61}[a-z0-9]{0,1}\.(xn--)?([a-z0-9\-]{1,61}|[a-z0-9-]{1,30}\.[a-z]{2,})$/ + export const initializeYup = () => { addMethod(string, 'email', function validateEmail(message) { return this.matches(EMAIL_REGEX, { @@ -11,4 +14,12 @@ export const initializeYup = () => { excludeEmptyString: true, }) }) + + addMethod(string, 'domain', function validateDomain(message) { + return this.matches(DOMAIN_REGEX, { + message, + name: 'string.domain', + excludeEmptyString: true, + }) + }) } diff --git a/src/formValidation/schema.d.ts b/src/formValidation/schema.d.ts new file mode 100644 index 000000000..ba92b456c --- /dev/null +++ b/src/formValidation/schema.d.ts @@ -0,0 +1,14 @@ +import { AnyObject, Flags, Maybe, Schema } from 'yup' + +declare module 'yup' { + interface StringSchema< + TType extends Maybe = string | undefined, + TContext extends AnyObject = AnyObject, + TDefault = undefined, + TFlags extends Flags = '', + > extends Schema { + domain(message: string): this + } +} + +export {} diff --git a/src/generated/graphql.tsx b/src/generated/graphql.tsx index 865733d3f..ece1efb55 100644 --- a/src/generated/graphql.tsx +++ b/src/generated/graphql.tsx @@ -1100,7 +1100,7 @@ export type CreateNetsuiteIntegrationInput = { code: Scalars['String']['input']; connectionId: Scalars['String']['input']; name: Scalars['String']['input']; - scriptEndpointUrl: Scalars['String']['input']; + scriptEndpointUrl?: InputMaybe; syncCreditNotes?: InputMaybe; syncInvoices?: InputMaybe; syncPayments?: InputMaybe; @@ -2430,6 +2430,7 @@ export enum LagoApiError { CouponIsNotReusable = 'coupon_is_not_reusable', CurrenciesDoesNotMatch = 'currencies_does_not_match', DoesNotMatchItemAmounts = 'does_not_match_item_amounts', + DomainNotConfigured = 'domain_not_configured', EmailAlreadyUsed = 'email_already_used', ExpiredJwtToken = 'expired_jwt_token', Forbidden = 'forbidden', @@ -2443,6 +2444,7 @@ export enum LagoApiError { InviteNotFound = 'invite_not_found', NotFound = 'not_found', NotOrganizationMember = 'not_organization_member', + OktaUserinfoError = 'okta_userinfo_error', PaymentProcessorIsCurrentlyHandlingPayment = 'payment_processor_is_currently_handling_payment', PlanNotFound = 'plan_not_found', PlanOverlapping = 'plan_overlapping', @@ -2522,7 +2524,7 @@ export type Membership = { organization: Organization; permissions: Permissions; revokedAt: Scalars['ISO8601DateTime']['output']; - role: MembershipRole; + role?: Maybe; status: MembershipStatus; updatedAt: Scalars['ISO8601DateTime']['output']; user: User; @@ -3175,7 +3177,7 @@ export type NetsuiteIntegration = { hasMappingsConfigured?: Maybe; id: Scalars['ID']['output']; name: Scalars['String']['output']; - scriptEndpointUrl: Scalars['String']['output']; + scriptEndpointUrl?: Maybe; syncCreditNotes?: Maybe; syncInvoices?: Maybe; syncPayments?: Maybe; @@ -4836,7 +4838,7 @@ export enum WeightedIntervalEnum { export type UserIdentifierQueryVariables = Exact<{ [key: string]: never; }>; -export type UserIdentifierQuery = { __typename?: 'Query', me: { __typename?: 'User', id: string, email?: string | null, premium: boolean, memberships: Array<{ __typename?: 'Membership', id: string, organization: { __typename?: 'Organization', id: string, name: string, logoUrl?: string | null }, permissions: { __typename?: 'Permissions', addonsCreate: boolean, addonsDelete: boolean, addonsUpdate: boolean, addonsView: boolean, analyticsView: boolean, billableMetricsCreate: boolean, billableMetricsDelete: boolean, billableMetricsUpdate: boolean, billableMetricsView: boolean, couponsAttach: boolean, couponsCreate: boolean, couponsDelete: boolean, couponsDetach: boolean, couponsUpdate: boolean, couponsView: boolean, creditNotesCreate: boolean, creditNotesUpdate: boolean, creditNotesView: boolean, creditNotesVoid: boolean, customerSettingsUpdateGracePeriod: boolean, customerSettingsUpdateLang: boolean, customerSettingsUpdatePaymentTerms: boolean, customerSettingsUpdateTaxRates: boolean, customerSettingsView: boolean, customersCreate: boolean, customersDelete: boolean, customersUpdate: boolean, customersView: boolean, developersKeysManage: boolean, developersManage: boolean, draftInvoicesUpdate: boolean, invoicesCreate: boolean, invoicesSend: boolean, invoicesUpdate: boolean, invoicesView: boolean, invoicesVoid: boolean, organizationEmailsUpdate: boolean, organizationEmailsView: boolean, organizationIntegrationsCreate: boolean, organizationIntegrationsDelete: boolean, organizationIntegrationsUpdate: boolean, organizationIntegrationsView: boolean, organizationInvoicesUpdate: boolean, organizationInvoicesView: boolean, organizationMembersCreate: boolean, organizationMembersDelete: boolean, organizationMembersUpdate: boolean, organizationMembersView: boolean, organizationTaxesUpdate: boolean, organizationTaxesView: boolean, organizationUpdate: boolean, organizationView: boolean, plansCreate: boolean, plansDelete: boolean, plansUpdate: boolean, plansView: boolean, subscriptionsCreate: boolean, subscriptionsDelete: boolean, subscriptionsUpdate: boolean, subscriptionsView: boolean, walletsCreate: boolean, walletsTerminate: boolean, walletsTopUp: boolean, walletsUpdate: boolean } }> }, organization?: { __typename?: 'CurrentOrganization', id: string, name: string, logoUrl?: string | null, timezone?: TimezoneEnum | null, defaultCurrency: CurrencyEnum } | null }; +export type UserIdentifierQuery = { __typename?: 'Query', me: { __typename?: 'User', id: string, email?: string | null, premium: boolean, memberships: Array<{ __typename?: 'Membership', id: string, organization: { __typename?: 'Organization', id: string, name: string, logoUrl?: string | null }, permissions: { __typename?: 'Permissions', addonsCreate: boolean, addonsDelete: boolean, addonsUpdate: boolean, addonsView: boolean, analyticsView: boolean, billableMetricsCreate: boolean, billableMetricsDelete: boolean, billableMetricsUpdate: boolean, billableMetricsView: boolean, couponsAttach: boolean, couponsCreate: boolean, couponsDelete: boolean, couponsDetach: boolean, couponsUpdate: boolean, couponsView: boolean, creditNotesCreate: boolean, creditNotesView: boolean, creditNotesVoid: boolean, customerSettingsUpdateGracePeriod: boolean, customerSettingsUpdateLang: boolean, customerSettingsUpdatePaymentTerms: boolean, customerSettingsUpdateTaxRates: boolean, customerSettingsView: boolean, customersCreate: boolean, customersDelete: boolean, customersUpdate: boolean, customersView: boolean, developersKeysManage: boolean, developersManage: boolean, draftInvoicesUpdate: boolean, invoicesCreate: boolean, invoicesSend: boolean, invoicesUpdate: boolean, invoicesView: boolean, invoicesVoid: boolean, organizationEmailsUpdate: boolean, organizationEmailsView: boolean, organizationIntegrationsCreate: boolean, organizationIntegrationsDelete: boolean, organizationIntegrationsUpdate: boolean, organizationIntegrationsView: boolean, organizationInvoicesUpdate: boolean, organizationInvoicesView: boolean, organizationMembersCreate: boolean, organizationMembersDelete: boolean, organizationMembersUpdate: boolean, organizationMembersView: boolean, organizationTaxesUpdate: boolean, organizationTaxesView: boolean, organizationUpdate: boolean, organizationView: boolean, plansCreate: boolean, plansDelete: boolean, plansUpdate: boolean, plansView: boolean, subscriptionsCreate: boolean, subscriptionsDelete: boolean, subscriptionsUpdate: boolean, subscriptionsView: boolean, walletsCreate: boolean, walletsTerminate: boolean, walletsTopUp: boolean, walletsUpdate: boolean } }> }, organization?: { __typename?: 'CurrentOrganization', id: string, name: string, logoUrl?: string | null, timezone?: TimezoneEnum | null, defaultCurrency: CurrencyEnum } | null }; export type AddOnItemFragment = { __typename?: 'AddOn', id: string, name: string, amountCurrency: CurrencyEnum, amountCents: any, customersCount: number, createdAt: any }; @@ -5553,6 +5555,31 @@ export type UpdateOrganizationTimezoneMutationVariables = Exact<{ export type UpdateOrganizationTimezoneMutation = { __typename?: 'Mutation', updateOrganization?: { __typename?: 'CurrentOrganization', id: string, timezone?: TimezoneEnum | null } | null }; +export type DeleteOktaIntegrationDialogFragment = { __typename?: 'OktaIntegration', id: string, name: string }; + +export type DestroyIntegrationMutationVariables = Exact<{ + input: DestroyIntegrationInput; +}>; + + +export type DestroyIntegrationMutation = { __typename?: 'Mutation', destroyIntegration?: { __typename?: 'DestroyIntegrationPayload', id?: string | null } | null }; + +export type AddOktaIntegrationDialogFragment = { __typename?: 'OktaIntegration', id: string, domain: string, clientId?: string | null, clientSecret?: string | null, organizationName: string, name: string }; + +export type CreateOktaIntegrationMutationVariables = Exact<{ + input: CreateOktaIntegrationInput; +}>; + + +export type CreateOktaIntegrationMutation = { __typename?: 'Mutation', createOktaIntegration?: { __typename?: 'OktaIntegration', id: string } | null }; + +export type UpdateOktaIntegrationMutationVariables = Exact<{ + input: UpdateOktaIntegrationInput; +}>; + + +export type UpdateOktaIntegrationMutation = { __typename?: 'Mutation', updateOktaIntegration?: { __typename?: 'OktaIntegration', id: string } | null }; + export type UpdateOrganizationLogoMutationVariables = Exact<{ input: UpdateOrganizationInput; }>; @@ -5946,12 +5973,12 @@ export type UpdateTaxMutationVariables = Exact<{ export type UpdateTaxMutation = { __typename?: 'Mutation', updateTax?: { __typename?: 'Tax', id: string, code: string, description?: string | null, name: string, rate: number, customersCount: number } | null }; -export type CurrentUserInfosFragment = { __typename?: 'User', id: string, email?: string | null, premium: boolean, memberships: Array<{ __typename?: 'Membership', id: string, organization: { __typename?: 'Organization', id: string, name: string, logoUrl?: string | null }, permissions: { __typename?: 'Permissions', addonsCreate: boolean, addonsDelete: boolean, addonsUpdate: boolean, addonsView: boolean, analyticsView: boolean, billableMetricsCreate: boolean, billableMetricsDelete: boolean, billableMetricsUpdate: boolean, billableMetricsView: boolean, couponsAttach: boolean, couponsCreate: boolean, couponsDelete: boolean, couponsDetach: boolean, couponsUpdate: boolean, couponsView: boolean, creditNotesCreate: boolean, creditNotesUpdate: boolean, creditNotesView: boolean, creditNotesVoid: boolean, customerSettingsUpdateGracePeriod: boolean, customerSettingsUpdateLang: boolean, customerSettingsUpdatePaymentTerms: boolean, customerSettingsUpdateTaxRates: boolean, customerSettingsView: boolean, customersCreate: boolean, customersDelete: boolean, customersUpdate: boolean, customersView: boolean, developersKeysManage: boolean, developersManage: boolean, draftInvoicesUpdate: boolean, invoicesCreate: boolean, invoicesSend: boolean, invoicesUpdate: boolean, invoicesView: boolean, invoicesVoid: boolean, organizationEmailsUpdate: boolean, organizationEmailsView: boolean, organizationIntegrationsCreate: boolean, organizationIntegrationsDelete: boolean, organizationIntegrationsUpdate: boolean, organizationIntegrationsView: boolean, organizationInvoicesUpdate: boolean, organizationInvoicesView: boolean, organizationMembersCreate: boolean, organizationMembersDelete: boolean, organizationMembersUpdate: boolean, organizationMembersView: boolean, organizationTaxesUpdate: boolean, organizationTaxesView: boolean, organizationUpdate: boolean, organizationView: boolean, plansCreate: boolean, plansDelete: boolean, plansUpdate: boolean, plansView: boolean, subscriptionsCreate: boolean, subscriptionsDelete: boolean, subscriptionsUpdate: boolean, subscriptionsView: boolean, walletsCreate: boolean, walletsTerminate: boolean, walletsTopUp: boolean, walletsUpdate: boolean } }> }; +export type CurrentUserInfosFragment = { __typename?: 'User', id: string, email?: string | null, premium: boolean, memberships: Array<{ __typename?: 'Membership', id: string, organization: { __typename?: 'Organization', id: string, name: string, logoUrl?: string | null }, permissions: { __typename?: 'Permissions', addonsCreate: boolean, addonsDelete: boolean, addonsUpdate: boolean, addonsView: boolean, analyticsView: boolean, billableMetricsCreate: boolean, billableMetricsDelete: boolean, billableMetricsUpdate: boolean, billableMetricsView: boolean, couponsAttach: boolean, couponsCreate: boolean, couponsDelete: boolean, couponsDetach: boolean, couponsUpdate: boolean, couponsView: boolean, creditNotesCreate: boolean, creditNotesView: boolean, creditNotesVoid: boolean, customerSettingsUpdateGracePeriod: boolean, customerSettingsUpdateLang: boolean, customerSettingsUpdatePaymentTerms: boolean, customerSettingsUpdateTaxRates: boolean, customerSettingsView: boolean, customersCreate: boolean, customersDelete: boolean, customersUpdate: boolean, customersView: boolean, developersKeysManage: boolean, developersManage: boolean, draftInvoicesUpdate: boolean, invoicesCreate: boolean, invoicesSend: boolean, invoicesUpdate: boolean, invoicesView: boolean, invoicesVoid: boolean, organizationEmailsUpdate: boolean, organizationEmailsView: boolean, organizationIntegrationsCreate: boolean, organizationIntegrationsDelete: boolean, organizationIntegrationsUpdate: boolean, organizationIntegrationsView: boolean, organizationInvoicesUpdate: boolean, organizationInvoicesView: boolean, organizationMembersCreate: boolean, organizationMembersDelete: boolean, organizationMembersUpdate: boolean, organizationMembersView: boolean, organizationTaxesUpdate: boolean, organizationTaxesView: boolean, organizationUpdate: boolean, organizationView: boolean, plansCreate: boolean, plansDelete: boolean, plansUpdate: boolean, plansView: boolean, subscriptionsCreate: boolean, subscriptionsDelete: boolean, subscriptionsUpdate: boolean, subscriptionsView: boolean, walletsCreate: boolean, walletsTerminate: boolean, walletsTopUp: boolean, walletsUpdate: boolean } }> }; export type GetCurrentUserInfosQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserInfosQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, email?: string | null, premium: boolean, memberships: Array<{ __typename?: 'Membership', id: string, organization: { __typename?: 'Organization', id: string, name: string, logoUrl?: string | null }, permissions: { __typename?: 'Permissions', addonsCreate: boolean, addonsDelete: boolean, addonsUpdate: boolean, addonsView: boolean, analyticsView: boolean, billableMetricsCreate: boolean, billableMetricsDelete: boolean, billableMetricsUpdate: boolean, billableMetricsView: boolean, couponsAttach: boolean, couponsCreate: boolean, couponsDelete: boolean, couponsDetach: boolean, couponsUpdate: boolean, couponsView: boolean, creditNotesCreate: boolean, creditNotesUpdate: boolean, creditNotesView: boolean, creditNotesVoid: boolean, customerSettingsUpdateGracePeriod: boolean, customerSettingsUpdateLang: boolean, customerSettingsUpdatePaymentTerms: boolean, customerSettingsUpdateTaxRates: boolean, customerSettingsView: boolean, customersCreate: boolean, customersDelete: boolean, customersUpdate: boolean, customersView: boolean, developersKeysManage: boolean, developersManage: boolean, draftInvoicesUpdate: boolean, invoicesCreate: boolean, invoicesSend: boolean, invoicesUpdate: boolean, invoicesView: boolean, invoicesVoid: boolean, organizationEmailsUpdate: boolean, organizationEmailsView: boolean, organizationIntegrationsCreate: boolean, organizationIntegrationsDelete: boolean, organizationIntegrationsUpdate: boolean, organizationIntegrationsView: boolean, organizationInvoicesUpdate: boolean, organizationInvoicesView: boolean, organizationMembersCreate: boolean, organizationMembersDelete: boolean, organizationMembersUpdate: boolean, organizationMembersView: boolean, organizationTaxesUpdate: boolean, organizationTaxesView: boolean, organizationUpdate: boolean, organizationView: boolean, plansCreate: boolean, plansDelete: boolean, plansUpdate: boolean, plansView: boolean, subscriptionsCreate: boolean, subscriptionsDelete: boolean, subscriptionsUpdate: boolean, subscriptionsView: boolean, walletsCreate: boolean, walletsTerminate: boolean, walletsTopUp: boolean, walletsUpdate: boolean } }> } }; +export type GetCurrentUserInfosQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, email?: string | null, premium: boolean, memberships: Array<{ __typename?: 'Membership', id: string, organization: { __typename?: 'Organization', id: string, name: string, logoUrl?: string | null }, permissions: { __typename?: 'Permissions', addonsCreate: boolean, addonsDelete: boolean, addonsUpdate: boolean, addonsView: boolean, analyticsView: boolean, billableMetricsCreate: boolean, billableMetricsDelete: boolean, billableMetricsUpdate: boolean, billableMetricsView: boolean, couponsAttach: boolean, couponsCreate: boolean, couponsDelete: boolean, couponsDetach: boolean, couponsUpdate: boolean, couponsView: boolean, creditNotesCreate: boolean, creditNotesView: boolean, creditNotesVoid: boolean, customerSettingsUpdateGracePeriod: boolean, customerSettingsUpdateLang: boolean, customerSettingsUpdatePaymentTerms: boolean, customerSettingsUpdateTaxRates: boolean, customerSettingsView: boolean, customersCreate: boolean, customersDelete: boolean, customersUpdate: boolean, customersView: boolean, developersKeysManage: boolean, developersManage: boolean, draftInvoicesUpdate: boolean, invoicesCreate: boolean, invoicesSend: boolean, invoicesUpdate: boolean, invoicesView: boolean, invoicesVoid: boolean, organizationEmailsUpdate: boolean, organizationEmailsView: boolean, organizationIntegrationsCreate: boolean, organizationIntegrationsDelete: boolean, organizationIntegrationsUpdate: boolean, organizationIntegrationsView: boolean, organizationInvoicesUpdate: boolean, organizationInvoicesView: boolean, organizationMembersCreate: boolean, organizationMembersDelete: boolean, organizationMembersUpdate: boolean, organizationMembersView: boolean, organizationTaxesUpdate: boolean, organizationTaxesView: boolean, organizationUpdate: boolean, organizationView: boolean, plansCreate: boolean, plansDelete: boolean, plansUpdate: boolean, plansView: boolean, subscriptionsCreate: boolean, subscriptionsDelete: boolean, subscriptionsUpdate: boolean, subscriptionsView: boolean, walletsCreate: boolean, walletsTerminate: boolean, walletsTopUp: boolean, walletsUpdate: boolean } }> } }; export type GetEmailSettingsQueryVariables = Exact<{ [key: string]: never; }>; @@ -5972,7 +5999,7 @@ export type GetOrganizationInfosQueryVariables = Exact<{ [key: string]: never; } export type GetOrganizationInfosQuery = { __typename?: 'Query', organization?: { __typename?: 'CurrentOrganization', id: string, name: string, logoUrl?: string | null, timezone?: TimezoneEnum | null, defaultCurrency: CurrencyEnum } | null }; -export type MembershipPermissionsFragment = { __typename?: 'Membership', id: string, permissions: { __typename?: 'Permissions', addonsCreate: boolean, addonsDelete: boolean, addonsUpdate: boolean, addonsView: boolean, analyticsView: boolean, billableMetricsCreate: boolean, billableMetricsDelete: boolean, billableMetricsUpdate: boolean, billableMetricsView: boolean, couponsAttach: boolean, couponsCreate: boolean, couponsDelete: boolean, couponsDetach: boolean, couponsUpdate: boolean, couponsView: boolean, creditNotesCreate: boolean, creditNotesUpdate: boolean, creditNotesView: boolean, creditNotesVoid: boolean, customerSettingsUpdateGracePeriod: boolean, customerSettingsUpdateLang: boolean, customerSettingsUpdatePaymentTerms: boolean, customerSettingsUpdateTaxRates: boolean, customerSettingsView: boolean, customersCreate: boolean, customersDelete: boolean, customersUpdate: boolean, customersView: boolean, developersKeysManage: boolean, developersManage: boolean, draftInvoicesUpdate: boolean, invoicesCreate: boolean, invoicesSend: boolean, invoicesUpdate: boolean, invoicesView: boolean, invoicesVoid: boolean, organizationEmailsUpdate: boolean, organizationEmailsView: boolean, organizationIntegrationsCreate: boolean, organizationIntegrationsDelete: boolean, organizationIntegrationsUpdate: boolean, organizationIntegrationsView: boolean, organizationInvoicesUpdate: boolean, organizationInvoicesView: boolean, organizationMembersCreate: boolean, organizationMembersDelete: boolean, organizationMembersUpdate: boolean, organizationMembersView: boolean, organizationTaxesUpdate: boolean, organizationTaxesView: boolean, organizationUpdate: boolean, organizationView: boolean, plansCreate: boolean, plansDelete: boolean, plansUpdate: boolean, plansView: boolean, subscriptionsCreate: boolean, subscriptionsDelete: boolean, subscriptionsUpdate: boolean, subscriptionsView: boolean, walletsCreate: boolean, walletsTerminate: boolean, walletsTopUp: boolean, walletsUpdate: boolean } }; +export type MembershipPermissionsFragment = { __typename?: 'Membership', id: string, permissions: { __typename?: 'Permissions', addonsCreate: boolean, addonsDelete: boolean, addonsUpdate: boolean, addonsView: boolean, analyticsView: boolean, billableMetricsCreate: boolean, billableMetricsDelete: boolean, billableMetricsUpdate: boolean, billableMetricsView: boolean, couponsAttach: boolean, couponsCreate: boolean, couponsDelete: boolean, couponsDetach: boolean, couponsUpdate: boolean, couponsView: boolean, creditNotesCreate: boolean, creditNotesView: boolean, creditNotesVoid: boolean, customerSettingsUpdateGracePeriod: boolean, customerSettingsUpdateLang: boolean, customerSettingsUpdatePaymentTerms: boolean, customerSettingsUpdateTaxRates: boolean, customerSettingsView: boolean, customersCreate: boolean, customersDelete: boolean, customersUpdate: boolean, customersView: boolean, developersKeysManage: boolean, developersManage: boolean, draftInvoicesUpdate: boolean, invoicesCreate: boolean, invoicesSend: boolean, invoicesUpdate: boolean, invoicesView: boolean, invoicesVoid: boolean, organizationEmailsUpdate: boolean, organizationEmailsView: boolean, organizationIntegrationsCreate: boolean, organizationIntegrationsDelete: boolean, organizationIntegrationsUpdate: boolean, organizationIntegrationsView: boolean, organizationInvoicesUpdate: boolean, organizationInvoicesView: boolean, organizationMembersCreate: boolean, organizationMembersDelete: boolean, organizationMembersUpdate: boolean, organizationMembersView: boolean, organizationTaxesUpdate: boolean, organizationTaxesView: boolean, organizationUpdate: boolean, organizationView: boolean, plansCreate: boolean, plansDelete: boolean, plansUpdate: boolean, plansView: boolean, subscriptionsCreate: boolean, subscriptionsDelete: boolean, subscriptionsUpdate: boolean, subscriptionsView: boolean, walletsCreate: boolean, walletsTerminate: boolean, walletsTopUp: boolean, walletsUpdate: boolean } }; export type AllInvoiceDetailsForCustomerInvoiceDetailsFragment = { __typename?: 'Invoice', id: string, invoiceType: InvoiceTypeEnum, number: string, paymentStatus: InvoicePaymentStatusTypeEnum, status: InvoiceStatusTypeEnum, totalAmountCents: any, currency?: CurrencyEnum | null, refundableAmountCents: any, creditableAmountCents: any, voidable: boolean, paymentDisputeLostAt?: any | null, issuingDate: any, subTotalExcludingTaxesAmountCents: any, subTotalIncludingTaxesAmountCents: any, versionNumber: number, paymentDueDate: any, couponsAmountCents: any, creditNotesAmountCents: any, prepaidCreditAmountCents: any, customer: { __typename?: 'Customer', id: string, applicableTimezone: TimezoneEnum, currency?: CurrencyEnum | null, name?: string | null, legalNumber?: string | null, legalName?: string | null, taxIdentificationNumber?: string | null, email?: string | null, addressLine1?: string | null, addressLine2?: string | null, state?: string | null, country?: CountryCode | null, city?: string | null, zipcode?: string | null, deletedAt?: any | null, metadata?: Array<{ __typename?: 'CustomerMetadata', id: string, displayInInvoice: boolean, key: string, value: string }> | null }, creditNotes?: Array<{ __typename?: 'CreditNote', id: string, couponsAdjustmentAmountCents: any, number: string, subTotalExcludingTaxesAmountCents: any, currency: CurrencyEnum, totalAmountCents: any, appliedTaxes?: Array<{ __typename?: 'CreditNoteAppliedTax', id: string, amountCents: any, baseAmountCents: any, taxRate: number, taxName: string }> | null, items: Array<{ __typename?: 'CreditNoteItem', amountCents: any, amountCurrency: CurrencyEnum, fee: { __typename?: 'Fee', id: string, amountCents: any, eventsCount?: any | null, units: number, feeType: FeeTypesEnum, groupedBy: any, itemName: string, invoiceName?: string | null, appliedTaxes?: Array<{ __typename?: 'FeeAppliedTax', id: string, tax: { __typename?: 'Tax', id: string, rate: number } }> | null, trueUpParentFee?: { __typename?: 'Fee', id: string } | null, charge?: { __typename?: 'Charge', id: string, billableMetric: { __typename?: 'BillableMetric', id: string, name: string, aggregationType: AggregationTypeEnum } } | null, subscription?: { __typename?: 'Subscription', id: string, name?: string | null, plan: { __typename?: 'Plan', id: string, name: string, invoiceDisplayName?: string | null } } | null, chargeFilter?: { __typename?: 'ChargeFilter', invoiceDisplayName?: string | null, values: any } | null } }> }> | null, fees?: Array<{ __typename?: 'Fee', id: string, amountCents: any, description?: string | null, feeType: FeeTypesEnum, invoiceDisplayName?: string | null, invoiceName?: string | null, itemName: string, units: number, preciseUnitAmount: number, eventsCount?: any | null, adjustedFee: boolean, adjustedFeeType?: AdjustedFeeTypeEnum | null, currency: CurrencyEnum, appliedTaxes?: Array<{ __typename?: 'FeeAppliedTax', id: string, taxRate: number }> | null, trueUpFee?: { __typename?: 'Fee', id: string } | null, trueUpParentFee?: { __typename?: 'Fee', id: string } | null, charge?: { __typename?: 'Charge', id: string, payInAdvance: boolean, invoiceDisplayName?: string | null, chargeModel: ChargeModelEnum, minAmountCents: any, prorated: boolean, billableMetric: { __typename?: 'BillableMetric', id: string, name: string, aggregationType: AggregationTypeEnum, recurring: boolean } } | null, chargeFilter?: { __typename?: 'ChargeFilter', invoiceDisplayName?: string | null, values: any } | null, amountDetails?: { __typename?: 'FeeAmountDetails', freeUnits?: string | null, fixedFeeUnitAmount?: string | null, flatUnitAmount?: string | null, perUnitAmount?: string | null, perUnitTotalAmount?: string | null, paidUnits?: string | null, perPackageSize?: number | null, perPackageUnitAmount?: string | null, fixedFeeTotalAmount?: string | null, freeEvents?: number | null, minMaxAdjustmentTotalAmount?: string | null, paidEvents?: number | null, rate?: string | null, units?: string | null, graduatedRanges?: Array<{ __typename?: 'FeeAmountDetailsGraduatedRange', toValue?: any | null, flatUnitAmount?: string | null, fromValue?: any | null, perUnitAmount?: string | null, perUnitTotalAmount?: string | null, totalWithFlatAmount?: string | null, units?: string | null }> | null, graduatedPercentageRanges?: Array<{ __typename?: 'FeeAmountDetailsGraduatedPercentageRange', toValue?: any | null, flatUnitAmount?: string | null, fromValue?: any | null, perUnitTotalAmount?: string | null, rate?: string | null, totalWithFlatAmount?: string | null, units?: string | null }> | null } | null }> | null, invoiceSubscriptions?: Array<{ __typename?: 'InvoiceSubscription', fromDatetime?: any | null, toDatetime?: any | null, chargesFromDatetime?: any | null, chargesToDatetime?: any | null, inAdvanceChargesFromDatetime?: any | null, inAdvanceChargesToDatetime?: any | null, subscription: { __typename?: 'Subscription', id: string, name?: string | null, plan: { __typename?: 'Plan', id: string, name: string, interval: PlanInterval, amountCents: any, amountCurrency: CurrencyEnum, invoiceDisplayName?: string | null } }, fees?: Array<{ __typename?: 'Fee', id: string, amountCents: any, invoiceName?: string | null, invoiceDisplayName?: string | null, units: number, groupedBy: any, description?: string | null, feeType: FeeTypesEnum, itemName: string, preciseUnitAmount: number, eventsCount?: any | null, adjustedFee: boolean, adjustedFeeType?: AdjustedFeeTypeEnum | null, currency: CurrencyEnum, subscription?: { __typename?: 'Subscription', id: string, name?: string | null, plan: { __typename?: 'Plan', id: string, name: string, invoiceDisplayName?: string | null, interval: PlanInterval } } | null, charge?: { __typename?: 'Charge', id: string, payInAdvance: boolean, minAmountCents: any, invoiceDisplayName?: string | null, chargeModel: ChargeModelEnum, prorated: boolean, billableMetric: { __typename?: 'BillableMetric', id: string, name: string, aggregationType: AggregationTypeEnum, recurring: boolean } } | null, chargeFilter?: { __typename?: 'ChargeFilter', invoiceDisplayName?: string | null, values: any } | null, appliedTaxes?: Array<{ __typename?: 'FeeAppliedTax', id: string, taxRate: number }> | null, trueUpFee?: { __typename?: 'Fee', id: string } | null, trueUpParentFee?: { __typename?: 'Fee', id: string } | null, amountDetails?: { __typename?: 'FeeAmountDetails', freeUnits?: string | null, fixedFeeUnitAmount?: string | null, flatUnitAmount?: string | null, perUnitAmount?: string | null, perUnitTotalAmount?: string | null, paidUnits?: string | null, perPackageSize?: number | null, perPackageUnitAmount?: string | null, fixedFeeTotalAmount?: string | null, freeEvents?: number | null, minMaxAdjustmentTotalAmount?: string | null, paidEvents?: number | null, rate?: string | null, units?: string | null, graduatedRanges?: Array<{ __typename?: 'FeeAmountDetailsGraduatedRange', toValue?: any | null, flatUnitAmount?: string | null, fromValue?: any | null, perUnitAmount?: string | null, perUnitTotalAmount?: string | null, totalWithFlatAmount?: string | null, units?: string | null }> | null, graduatedPercentageRanges?: Array<{ __typename?: 'FeeAmountDetailsGraduatedPercentageRange', toValue?: any | null, flatUnitAmount?: string | null, fromValue?: any | null, perUnitTotalAmount?: string | null, rate?: string | null, totalWithFlatAmount?: string | null, units?: string | null }> | null } | null }> | null, invoice: { __typename?: 'Invoice', id: string, status: InvoiceStatusTypeEnum } }> | null, metadata?: Array<{ __typename?: 'InvoiceMetadata', id: string, key: string, value: string }> | null, appliedTaxes?: Array<{ __typename?: 'InvoiceAppliedTax', id: string, amountCents: any, feesAmountCents: any, taxRate: number, taxName: string }> | null }; @@ -6183,6 +6210,20 @@ export type GoogleAcceptInviteMutationVariables = Exact<{ export type GoogleAcceptInviteMutation = { __typename?: 'Mutation', googleAcceptInvite?: { __typename?: 'RegisterUser', token: string, user: { __typename?: 'User', id: string, organizations: Array<{ __typename?: 'Organization', id: string, name: string, timezone?: TimezoneEnum | null }> } } | null }; +export type FetchOktaAuthorizeUrlMutationVariables = Exact<{ + input: OktaAuthorizeInput; +}>; + + +export type FetchOktaAuthorizeUrlMutation = { __typename?: 'Mutation', oktaAuthorize?: { __typename?: 'Authorize', url: string } | null }; + +export type OktaAcceptInviteMutationVariables = Exact<{ + input: OktaAcceptInviteInput; +}>; + + +export type OktaAcceptInviteMutation = { __typename?: 'Mutation', oktaAcceptInvite?: { __typename?: 'LoginUser', token: string, user: { __typename?: 'User', id: string, organizations: Array<{ __typename?: 'Organization', id: string, name: string, timezone?: TimezoneEnum | null }> } } | null }; + export type GetInvoiceCreditNotesQueryVariables = Exact<{ invoiceId: Scalars['ID']['input']; page?: InputMaybe; @@ -6287,6 +6328,13 @@ export type LoginUserMutationVariables = Exact<{ export type LoginUserMutation = { __typename?: 'Mutation', loginUser?: { __typename?: 'LoginUser', token: string, user: { __typename?: 'User', id: string, organizations: Array<{ __typename?: 'Organization', id: string, name: string, timezone?: TimezoneEnum | null }> } } | null }; +export type OktaLoginUserMutationVariables = Exact<{ + input: OktaLoginInput; +}>; + + +export type OktaLoginUserMutation = { __typename?: 'Mutation', oktaLogin?: { __typename?: 'LoginUser', token: string, user: { __typename?: 'User', id: string, organizations: Array<{ __typename?: 'Organization', id: string, name: string, timezone?: TimezoneEnum | null }> } } | null }; + export type GetPortalLocaleQueryVariables = Exact<{ [key: string]: never; }>; @@ -6388,6 +6436,22 @@ export type GetAdyenIntegrationsListQueryVariables = Exact<{ export type GetAdyenIntegrationsListQuery = { __typename?: 'Query', paymentProviders?: { __typename?: 'PaymentProviderCollection', collection: Array<{ __typename?: 'AdyenProvider', id: string, name: string, code: string, apiKey?: string | null, hmacKey?: string | null, livePrefix?: string | null, merchantAccount: string } | { __typename?: 'GocardlessProvider' } | { __typename?: 'StripeProvider' }> } | null }; +export type GetAuthIntegrationsQueryVariables = Exact<{ + limit: Scalars['Int']['input']; +}>; + + +export type GetAuthIntegrationsQuery = { __typename?: 'Query', organization?: { __typename?: 'CurrentOrganization', id: string, premiumIntegrations: Array } | null, integrations?: { __typename?: 'IntegrationCollection', collection: Array<{ __typename?: 'NetsuiteIntegration' } | { __typename?: 'OktaIntegration', id: string, domain: string, clientId?: string | null, clientSecret?: string | null, organizationName: string, name: string }> } | null }; + +export type OktaIntegrationDetailsFragment = { __typename?: 'OktaIntegration', id: string, clientId?: string | null, clientSecret?: string | null, code: string, organizationName: string, domain: string, name: string }; + +export type GetOktaIntegrationQueryVariables = Exact<{ + id?: InputMaybe; +}>; + + +export type GetOktaIntegrationQuery = { __typename?: 'Query', integration?: { __typename?: 'NetsuiteIntegration' } | { __typename?: 'OktaIntegration', id: string, clientId?: string | null, clientSecret?: string | null, code: string, organizationName: string, domain: string, name: string } | null }; + export type GocardlessIntegrationDetailsFragment = { __typename?: 'GocardlessProvider', id: string, code: string, name: string, successRedirectUrl?: string | null, webhookSecret?: string | null }; export type GetGocardlessIntegrationsDetailsQueryVariables = Exact<{ @@ -7045,6 +7109,22 @@ export const EditOrganizationInvoiceTemplateDialogFragmentDoc = gql` } } `; +export const DeleteOktaIntegrationDialogFragmentDoc = gql` + fragment DeleteOktaIntegrationDialog on OktaIntegration { + id + name +} + `; +export const AddOktaIntegrationDialogFragmentDoc = gql` + fragment AddOktaIntegrationDialog on OktaIntegration { + id + domain + clientId + clientSecret + organizationName + ...DeleteOktaIntegrationDialog +} + ${DeleteOktaIntegrationDialogFragmentDoc}`; export const AddAdyenProviderDialogFragmentDoc = gql` fragment AddAdyenProviderDialog on AdyenProvider { id @@ -7482,7 +7562,6 @@ export const MembershipPermissionsFragmentDoc = gql` couponsUpdate couponsView creditNotesCreate - creditNotesUpdate creditNotesView creditNotesVoid customerSettingsUpdateGracePeriod @@ -8501,6 +8580,17 @@ export const AdyenIntegrationsFragmentDoc = gql` code } `; +export const OktaIntegrationDetailsFragmentDoc = gql` + fragment OktaIntegrationDetails on OktaIntegration { + id + clientId + clientSecret + code + organizationName + domain + name +} + `; export const GocardlessIntegrationDetailsFragmentDoc = gql` fragment GocardlessIntegrationDetails on GocardlessProvider { id @@ -11528,6 +11618,105 @@ export function useUpdateOrganizationTimezoneMutation(baseOptions?: Apollo.Mutat export type UpdateOrganizationTimezoneMutationHookResult = ReturnType; export type UpdateOrganizationTimezoneMutationResult = Apollo.MutationResult; export type UpdateOrganizationTimezoneMutationOptions = Apollo.BaseMutationOptions; +export const DestroyIntegrationDocument = gql` + mutation DestroyIntegration($input: DestroyIntegrationInput!) { + destroyIntegration(input: $input) { + id + } +} + `; +export type DestroyIntegrationMutationFn = Apollo.MutationFunction; + +/** + * __useDestroyIntegrationMutation__ + * + * To run a mutation, you first call `useDestroyIntegrationMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDestroyIntegrationMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [destroyIntegrationMutation, { data, loading, error }] = useDestroyIntegrationMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useDestroyIntegrationMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DestroyIntegrationDocument, options); + } +export type DestroyIntegrationMutationHookResult = ReturnType; +export type DestroyIntegrationMutationResult = Apollo.MutationResult; +export type DestroyIntegrationMutationOptions = Apollo.BaseMutationOptions; +export const CreateOktaIntegrationDocument = gql` + mutation createOktaIntegration($input: CreateOktaIntegrationInput!) { + createOktaIntegration(input: $input) { + id + } +} + `; +export type CreateOktaIntegrationMutationFn = Apollo.MutationFunction; + +/** + * __useCreateOktaIntegrationMutation__ + * + * To run a mutation, you first call `useCreateOktaIntegrationMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateOktaIntegrationMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createOktaIntegrationMutation, { data, loading, error }] = useCreateOktaIntegrationMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateOktaIntegrationMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateOktaIntegrationDocument, options); + } +export type CreateOktaIntegrationMutationHookResult = ReturnType; +export type CreateOktaIntegrationMutationResult = Apollo.MutationResult; +export type CreateOktaIntegrationMutationOptions = Apollo.BaseMutationOptions; +export const UpdateOktaIntegrationDocument = gql` + mutation updateOktaIntegration($input: UpdateOktaIntegrationInput!) { + updateOktaIntegration(input: $input) { + id + } +} + `; +export type UpdateOktaIntegrationMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateOktaIntegrationMutation__ + * + * To run a mutation, you first call `useUpdateOktaIntegrationMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateOktaIntegrationMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateOktaIntegrationMutation, { data, loading, error }] = useUpdateOktaIntegrationMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useUpdateOktaIntegrationMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateOktaIntegrationDocument, options); + } +export type UpdateOktaIntegrationMutationHookResult = ReturnType; +export type UpdateOktaIntegrationMutationResult = Apollo.MutationResult; +export type UpdateOktaIntegrationMutationOptions = Apollo.BaseMutationOptions; export const UpdateOrganizationLogoDocument = gql` mutation updateOrganizationLogo($input: UpdateOrganizationInput!) { updateOrganization(input: $input) { @@ -14658,6 +14847,76 @@ export function useGoogleAcceptInviteMutation(baseOptions?: Apollo.MutationHookO export type GoogleAcceptInviteMutationHookResult = ReturnType; export type GoogleAcceptInviteMutationResult = Apollo.MutationResult; export type GoogleAcceptInviteMutationOptions = Apollo.BaseMutationOptions; +export const FetchOktaAuthorizeUrlDocument = gql` + mutation fetchOktaAuthorizeUrl($input: OktaAuthorizeInput!) { + oktaAuthorize(input: $input) { + url + } +} + `; +export type FetchOktaAuthorizeUrlMutationFn = Apollo.MutationFunction; + +/** + * __useFetchOktaAuthorizeUrlMutation__ + * + * To run a mutation, you first call `useFetchOktaAuthorizeUrlMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useFetchOktaAuthorizeUrlMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [fetchOktaAuthorizeUrlMutation, { data, loading, error }] = useFetchOktaAuthorizeUrlMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useFetchOktaAuthorizeUrlMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(FetchOktaAuthorizeUrlDocument, options); + } +export type FetchOktaAuthorizeUrlMutationHookResult = ReturnType; +export type FetchOktaAuthorizeUrlMutationResult = Apollo.MutationResult; +export type FetchOktaAuthorizeUrlMutationOptions = Apollo.BaseMutationOptions; +export const OktaAcceptInviteDocument = gql` + mutation oktaAcceptInvite($input: OktaAcceptInviteInput!) { + oktaAcceptInvite(input: $input) { + token + user { + id + ...CurrentUser + } + } +} + ${CurrentUserFragmentDoc}`; +export type OktaAcceptInviteMutationFn = Apollo.MutationFunction; + +/** + * __useOktaAcceptInviteMutation__ + * + * To run a mutation, you first call `useOktaAcceptInviteMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useOktaAcceptInviteMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [oktaAcceptInviteMutation, { data, loading, error }] = useOktaAcceptInviteMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useOktaAcceptInviteMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(OktaAcceptInviteDocument, options); + } +export type OktaAcceptInviteMutationHookResult = ReturnType; +export type OktaAcceptInviteMutationResult = Apollo.MutationResult; +export type OktaAcceptInviteMutationOptions = Apollo.BaseMutationOptions; export const GetInvoiceCreditNotesDocument = gql` query getInvoiceCreditNotes($invoiceId: ID!, $page: Int, $limit: Int) { invoiceCreditNotes(invoiceId: $invoiceId, page: $page, limit: $limit) { @@ -15215,6 +15474,43 @@ export function useLoginUserMutation(baseOptions?: Apollo.MutationHookOptions; export type LoginUserMutationResult = Apollo.MutationResult; export type LoginUserMutationOptions = Apollo.BaseMutationOptions; +export const OktaLoginUserDocument = gql` + mutation oktaLoginUser($input: OktaLoginInput!) { + oktaLogin(input: $input) { + user { + id + ...CurrentUser + } + token + } +} + ${CurrentUserFragmentDoc}`; +export type OktaLoginUserMutationFn = Apollo.MutationFunction; + +/** + * __useOktaLoginUserMutation__ + * + * To run a mutation, you first call `useOktaLoginUserMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useOktaLoginUserMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [oktaLoginUserMutation, { data, loading, error }] = useOktaLoginUserMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useOktaLoginUserMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(OktaLoginUserDocument, options); + } +export type OktaLoginUserMutationHookResult = ReturnType; +export type OktaLoginUserMutationResult = Apollo.MutationResult; +export type OktaLoginUserMutationOptions = Apollo.BaseMutationOptions; export const GetPortalLocaleDocument = gql` query getPortalLocale { customerPortalOrganization { @@ -15798,6 +16094,103 @@ export type GetAdyenIntegrationsListQueryHookResult = ReturnType; export type GetAdyenIntegrationsListSuspenseQueryHookResult = ReturnType; export type GetAdyenIntegrationsListQueryResult = Apollo.QueryResult; +export const GetAuthIntegrationsDocument = gql` + query GetAuthIntegrations($limit: Int!) { + organization { + id + premiumIntegrations + } + integrations(limit: $limit) { + collection { + ... on OktaIntegration { + id + ...AddOktaIntegrationDialog + ...DeleteOktaIntegrationDialog + } + } + } +} + ${AddOktaIntegrationDialogFragmentDoc} +${DeleteOktaIntegrationDialogFragmentDoc}`; + +/** + * __useGetAuthIntegrationsQuery__ + * + * To run a query within a React component, call `useGetAuthIntegrationsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetAuthIntegrationsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetAuthIntegrationsQuery({ + * variables: { + * limit: // value for 'limit' + * }, + * }); + */ +export function useGetAuthIntegrationsQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetAuthIntegrationsDocument, options); + } +export function useGetAuthIntegrationsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetAuthIntegrationsDocument, options); + } +export function useGetAuthIntegrationsSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(GetAuthIntegrationsDocument, options); + } +export type GetAuthIntegrationsQueryHookResult = ReturnType; +export type GetAuthIntegrationsLazyQueryHookResult = ReturnType; +export type GetAuthIntegrationsSuspenseQueryHookResult = ReturnType; +export type GetAuthIntegrationsQueryResult = Apollo.QueryResult; +export const GetOktaIntegrationDocument = gql` + query GetOktaIntegration($id: ID) { + integration(id: $id) { + ... on OktaIntegration { + ...OktaIntegrationDetails + ...AddOktaIntegrationDialog + ...DeleteOktaIntegrationDialog + } + } +} + ${OktaIntegrationDetailsFragmentDoc} +${AddOktaIntegrationDialogFragmentDoc} +${DeleteOktaIntegrationDialogFragmentDoc}`; + +/** + * __useGetOktaIntegrationQuery__ + * + * To run a query within a React component, call `useGetOktaIntegrationQuery` and pass it any options that fit your needs. + * When your component renders, `useGetOktaIntegrationQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetOktaIntegrationQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useGetOktaIntegrationQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetOktaIntegrationDocument, options); + } +export function useGetOktaIntegrationLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetOktaIntegrationDocument, options); + } +export function useGetOktaIntegrationSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(GetOktaIntegrationDocument, options); + } +export type GetOktaIntegrationQueryHookResult = ReturnType; +export type GetOktaIntegrationLazyQueryHookResult = ReturnType; +export type GetOktaIntegrationSuspenseQueryHookResult = ReturnType; +export type GetOktaIntegrationQueryResult = Apollo.QueryResult; export const GetGocardlessIntegrationsDetailsDocument = gql` query getGocardlessIntegrationsDetails($id: ID!, $limit: Int, $type: ProviderTypeEnum) { paymentProvider(id: $id) { diff --git a/src/layouts/Settings.tsx b/src/layouts/Settings.tsx index cbcd9848f..0b7e71f20 100644 --- a/src/layouts/Settings.tsx +++ b/src/layouts/Settings.tsx @@ -5,6 +5,7 @@ import styled from 'styled-components' import { Button, NavigationTab, Typography } from '~/components/designSystem' import { + AUTHENTICATION_ROUTE, CREATE_TAX_ROUTE, EMAILS_SETTINGS_ROUTE, HOME_ROUTE, @@ -110,6 +111,11 @@ const Settings = () => { link: INTEGRATIONS_ROUTE, hidden: !hasPermissions(['organizationIntegrationsView']), }, + { + title: translate('text_664c732c264d7eed1c74fd96'), + link: AUTHENTICATION_ROUTE, + hidden: !hasPermissions(['organizationIntegrationsView']), + }, { title: translate('text_63208b630aaf8df6bbfb2655'), link: MEMBERS_ROUTE, diff --git a/src/pages/Invitation.tsx b/src/pages/Invitation.tsx index 5644eb567..08c35088b 100644 --- a/src/pages/Invitation.tsx +++ b/src/pages/Invitation.tsx @@ -12,12 +12,15 @@ import { TextInput } from '~/components/form' import { hasDefinedGQLError, onLogIn } from '~/core/apolloClient' import { DOCUMENTATION_ENV_VARS } from '~/core/constants/externalUrls' import { LOGIN_ROUTE } from '~/core/router' +import { addValuesToUrlState } from '~/core/utils/urlUtils' import { CurrentUserFragmentDoc, LagoApiError, useAcceptInviteMutation, + useFetchOktaAuthorizeUrlMutation, useGetinviteQuery, useGoogleAcceptInviteMutation, + useOktaAcceptInviteMutation, } from '~/generated/graphql' import { useIsAuthenticated } from '~/hooks/auth/useIsAuthenticated' import { useInternationalization } from '~/hooks/core/useInternationalization' @@ -57,6 +60,22 @@ gql` } } + mutation fetchOktaAuthorizeUrl($input: OktaAuthorizeInput!) { + oktaAuthorize(input: $input) { + url + } + } + + mutation oktaAcceptInvite($input: OktaAcceptInviteInput!) { + oktaAcceptInvite(input: $input) { + token + user { + id + ...CurrentUser + } + } + } + ${CurrentUserFragmentDoc} ` @@ -84,20 +103,26 @@ const Invitation = () => { const { token } = useParams() let [searchParams] = useSearchParams() const googleCode = searchParams.get('code') || '' + const oktaCode = searchParams.get('oktaCode') || '' + const oktaState = searchParams.get('oktaState') || '' + const { data, error, loading } = useGetinviteQuery({ context: { silentErrorCodes: [LagoApiError.InviteNotFound] }, variables: { token: token || '' }, skip: !token || isAuthenticated, // We need to skip when authenticated to prevent an error flash on the form after submit }) const email = data?.invite?.email - const [acceptInvite, { error: acceptInviteError }] = useAcceptInviteMutation({ - context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, - onCompleted(res) { - if (!!res?.acceptInvite) { - onLogIn(res?.acceptInvite.token, res?.acceptInvite?.user) - } - }, - }) + + const [acceptInvite, { error: acceptInviteError, loading: acceptInviteLoading }] = + useAcceptInviteMutation({ + context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, + onCompleted(res) { + if (!!res?.acceptInvite) { + onLogIn(res?.acceptInvite.token, res?.acceptInvite?.user) + } + }, + }) + const [googleAcceptInvite, { error: googleAcceptInviteError }] = useGoogleAcceptInviteMutation({ context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, onCompleted(res) { @@ -106,6 +131,25 @@ const Invitation = () => { } }, }) + + const [ + fetchOktaAuthorizeUrl, + { error: oktaAuthorizeUrlError, loading: oktaAuthorizeUrlLoading }, + ] = useFetchOktaAuthorizeUrlMutation({ + context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, + fetchPolicy: 'network-only', + }) + + const [oktaAcceptInvite, { error: oktaAcceptInviteError, loading: oktaAcceptInviteLoading }] = + useOktaAcceptInviteMutation({ + context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, + onCompleted(res) { + if (!!res?.oktaAcceptInvite) { + onLogIn(res?.oktaAcceptInvite.token, res?.oktaAcceptInvite?.user) + } + }, + }) + const [formFields, setFormFields] = useState({ password: '', }) @@ -136,6 +180,26 @@ const Invitation = () => { }) } + const onOktaLogin = async () => { + const { data: oktaAuthorizeData } = await fetchOktaAuthorizeUrl({ + variables: { + input: { + email: email || '', + }, + }, + }) + + if (oktaAuthorizeData?.oktaAuthorize?.url) { + window.location.href = addValuesToUrlState({ + url: oktaAuthorizeData.oktaAuthorize.url, + values: { + invitationToken: token || '', + }, + stateType: 'string', + }) + } + } + useEffect(() => { validationSchema .validate(formFields, { abortEarly: false }) @@ -163,8 +227,29 @@ const Invitation = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [googleCode, token]) + useEffect(() => { + if (!!oktaCode && !!oktaState && !!token) { + oktaAcceptInvite({ + variables: { + input: { + code: oktaCode, + state: oktaState, + inviteToken: token || '', + }, + }, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [oktaCode, oktaState, token]) + const errorTranslation: string | undefined = useMemo(() => { - if (!acceptInviteError && !googleAcceptInviteError) return + if ( + !acceptInviteError && + !googleAcceptInviteError && + !oktaAcceptInviteError && + !oktaAuthorizeUrlError + ) + return // If any error occur, we need to remove the code from the URL history.replaceState({}, '', window.location.pathname) @@ -182,10 +267,18 @@ const Invitation = () => { return translate('text_660bf95c75dd928ced0ecb2b') } + if (hasDefinedGQLError('DomainNotConfigured', oktaAuthorizeUrlError)) { + return translate('text_664c90c9b2b6c2012aa50bd1') + } + + if (hasDefinedGQLError('OktaUserinfoError', oktaAcceptInviteError)) { + return translate('text_664c98989d08a3f733357f73') + } + return // eslint-disable-next-line react-hooks/exhaustive-deps - }, [acceptInviteError, googleAcceptInviteError]) + }, [acceptInviteError, googleAcceptInviteError, oktaAcceptInviteError, oktaAuthorizeUrlError]) useShortcuts([ { @@ -214,7 +307,7 @@ const Invitation = () => { - {translate('text_63246f875e2228ab7b63dcd0', { + {translate('text_664c90c9b2b6c2012aa50bcd', { orgnisationName: data?.invite?.organization.name, })} @@ -227,11 +320,24 @@ const Invitation = () => { )} - + + + + + diff --git a/src/pages/auth/Login.tsx b/src/pages/auth/Login.tsx index ace23ca9d..cf51c2c13 100644 --- a/src/pages/auth/Login.tsx +++ b/src/pages/auth/Login.tsx @@ -1,7 +1,7 @@ import { gql } from '@apollo/client' import { Stack } from '@mui/material' import { useFormik } from 'formik' -import { generatePath, Link } from 'react-router-dom' +import { generatePath, Link, useNavigate } from 'react-router-dom' import styled from 'styled-components' import { object, string } from 'yup' @@ -9,7 +9,7 @@ import GoogleAuthButton from '~/components/auth/GoogleAuthButton' import { Alert, Button, Typography } from '~/components/designSystem' import { TextInputField } from '~/components/form' import { envGlobalVar, hasDefinedGQLError, onLogIn } from '~/core/apolloClient' -import { FORGOT_PASSWORD_ROUTE, SIGN_UP_ROUTE } from '~/core/router' +import { FORGOT_PASSWORD_ROUTE, LOGIN_OKTA, SIGN_UP_ROUTE } from '~/core/router' import { CurrentUserFragmentDoc, LagoApiError, useLoginUserMutation } from '~/generated/graphql' import { useInternationalization } from '~/hooks/core/useInternationalization' import { useShortcuts } from '~/hooks/ui/useShortcuts' @@ -34,6 +34,8 @@ gql` const Login = () => { const { translate } = useInternationalization() + const navigate = useNavigate() + const [loginUser, { error: loginError }] = useLoginUserMutation({ context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, onCompleted(res) { @@ -93,11 +95,22 @@ const Login = () => { )} - + + + + diff --git a/src/pages/auth/LoginOkta.tsx b/src/pages/auth/LoginOkta.tsx new file mode 100644 index 000000000..c2135d042 --- /dev/null +++ b/src/pages/auth/LoginOkta.tsx @@ -0,0 +1,175 @@ +import { gql } from '@apollo/client' +import { Stack } from '@mui/material' +import { useFormik } from 'formik' +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import styled from 'styled-components' +import { object, string } from 'yup' + +import { Alert, Button, Typography } from '~/components/designSystem' +import { TextInputField } from '~/components/form' +import { hasDefinedGQLError } from '~/core/apolloClient' +import { LOGIN_ROUTE } from '~/core/router' +import { addValuesToUrlState } from '~/core/utils/urlUtils' +import { LagoApiError, useFetchOktaAuthorizeUrlMutation } from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' +import { useShortcuts } from '~/hooks/ui/useShortcuts' +import { Card, Page, StyledLogo } from '~/styles/auth' + +const getErrorKey = (code: LagoApiError): string => { + switch (code) { + case LagoApiError.OktaUserinfoError: + return 'text_664c98989d08a3f733357f73' + case LagoApiError.DomainNotConfigured: + return 'text_664c90c9b2b6c2012aa50bd6' + default: + return 'text_62b31e1f6a5b8b1b745ece48' + } +} + +gql` + mutation fetchOktaAuthorizeUrl($input: OktaAuthorizeInput!) { + oktaAuthorize(input: $input) { + url + } + } +` + +const LoginOkta = () => { + const { translate } = useInternationalization() + let [searchParams] = useSearchParams() + const [errorAlert, setErrorAlert] = useState() + const [errorField, setErrorField] = useState() + + const lagoErrorCode = searchParams.get('lago_error_code') + + const [fetchOktaAuthorizeUrl, { error: fetchOktaAuthorizeUrlError, loading }] = + useFetchOktaAuthorizeUrlMutation({ + context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, + fetchPolicy: 'network-only', + }) + + useEffect(() => { + if (lagoErrorCode) { + setErrorAlert(lagoErrorCode as LagoApiError) + + // Remove the error code from the URL, so it disappears on page reload + history.replaceState({}, '', window.location.pathname) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (fetchOktaAuthorizeUrlError) { + if (hasDefinedGQLError('DomainNotConfigured', fetchOktaAuthorizeUrlError)) { + setErrorField(LagoApiError.DomainNotConfigured) + } else { + setErrorAlert(LagoApiError.UnprocessableEntity) + } + } + }, [fetchOktaAuthorizeUrlError]) + + const formikProps = useFormik({ + initialValues: { + email: '', + }, + validationSchema: object().shape({ + email: string() + .email('text_620bc4d4269a55014d493fc3') + .required('text_620bc4d4269a55014d493f98'), + }), + validateOnChange: false, + validateOnBlur: false, + onSubmit: async (values) => { + const { data } = await fetchOktaAuthorizeUrl({ + variables: { + input: { + email: values.email, + }, + }, + }) + + if (data?.oktaAuthorize?.url) { + setErrorField(undefined) + setErrorAlert(undefined) + window.location.href = addValuesToUrlState({ + url: data.oktaAuthorize.url, + stateType: 'string', + values: {}, + }) + } + }, + }) + + useShortcuts([ + { + keys: ['Enter'], + action: formikProps.submitForm, + }, + ]) + + return ( + + + + + + + {translate('text_664c90c9b2b6c2012aa50bce')} + {translate('text_664c90c9b2b6c2012aa50bd0')} + + + {/* This error is displayed in the input */} + {!!errorAlert && ( + + {translate(getErrorKey(errorAlert))} + + )} + + + + + + + + + + ) +} + +export default LoginOkta + +const UsefulLink = styled(Typography)` + margin-left: auto; + margin-right: auto; + text-align: center; +` diff --git a/src/pages/auth/OktaAuthCallback.tsx b/src/pages/auth/OktaAuthCallback.tsx new file mode 100644 index 000000000..5e82fea69 --- /dev/null +++ b/src/pages/auth/OktaAuthCallback.tsx @@ -0,0 +1,96 @@ +import { gql } from '@apollo/client' +import { useEffect } from 'react' +import { generatePath, useNavigate, useSearchParams } from 'react-router-dom' +import styled from 'styled-components' + +import { Icon } from '~/components/designSystem' +import { hasDefinedGQLError, LagoGQLError, onLogIn } from '~/core/apolloClient' +import { INVITATION_ROUTE_FORM, LOGIN_OKTA, LOGIN_ROUTE } from '~/core/router' +import { CurrentUserFragmentDoc, LagoApiError, useOktaLoginUserMutation } from '~/generated/graphql' + +gql` + mutation oktaLoginUser($input: OktaLoginInput!) { + oktaLogin(input: $input) { + user { + id + ...CurrentUser + } + token + } + } + + ${CurrentUserFragmentDoc} +` + +const OktaAuthCallback = () => { + const navigate = useNavigate() + const [oktaLoginUser] = useOktaLoginUserMutation({ + context: { silentErrorCodes: [LagoApiError.UnprocessableEntity] }, + fetchPolicy: 'network-only', + }) + + const [searchParams] = useSearchParams() + const code = searchParams.get('code') || '' + const state = JSON.parse(searchParams.get('state') || '{}') + + const oktaState = state.state || '' + const invitationToken = state.invitationToken || undefined + + if (!code) { + navigate(LOGIN_ROUTE) + } + + useEffect(() => { + const oktaCallback = async () => { + if (invitationToken) { + navigate({ + pathname: generatePath(INVITATION_ROUTE_FORM, { + token: invitationToken as string, + }), + search: `?oktaCode=${code}&oktaState=${oktaState}`, + }) + } else { + const res = await oktaLoginUser({ variables: { input: { code, state: oktaState } } }) + + if (res.errors) { + if (hasDefinedGQLError('OktaUserinfoError', res.errors)) { + navigate({ + pathname: LOGIN_OKTA, + search: `?lago_error_code=${LagoApiError.OktaUserinfoError}`, + }) + } else { + navigate({ + pathname: LOGIN_ROUTE, + search: `?lago_error_code=${ + (res.errors[0].extensions as LagoGQLError['extensions']).code + }`, + }) + } + } else if (!!res.data?.oktaLogin) { + onLogIn(res.data?.oktaLogin?.token, res.data?.oktaLogin?.user) + } + } + } + + oktaCallback() + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + + + ) +} + +export default OktaAuthCallback + +const Loader = styled.div` + height: 160px; + width: 100%; + margin: auto; + display: flex; + align-items: center; + justify-content: center; +` diff --git a/src/pages/settings/Authentication/Authentication.tsx b/src/pages/settings/Authentication/Authentication.tsx new file mode 100644 index 000000000..82c44e613 --- /dev/null +++ b/src/pages/settings/Authentication/Authentication.tsx @@ -0,0 +1,150 @@ +import { gql } from '@apollo/client' +import { useRef } from 'react' +import { generatePath, useNavigate } from 'react-router-dom' +import styled from 'styled-components' + +import { Avatar, Chip, Selector, SelectorSkeleton, Typography } from '~/components/designSystem' +import { PremiumWarningDialog, PremiumWarningDialogRef } from '~/components/PremiumWarningDialog' +import { AddOktaDialog, AddOktaDialogRef } from '~/components/settings/authentication/AddOktaDialog' +import { + DeleteOktaIntegrationDialog, + DeleteOktaIntegrationDialogRef, +} from '~/components/settings/authentication/DeleteOktaIntegrationDialog' +import { OKTA_AUTHENTICATION_ROUTE } from '~/core/router' +import { + AddOktaIntegrationDialogFragmentDoc, + DeleteOktaIntegrationDialogFragmentDoc, + IntegrationTypeEnum, + OktaIntegration, + useGetAuthIntegrationsQuery, +} from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' +import { useCurrentUser } from '~/hooks/useCurrentUser' +import Okta from '~/public/images/okta.svg' +import { theme } from '~/styles' +import { SettingsHeaderNameWrapper, SettingsPageContentWrapper } from '~/styles/settingsPage' + +gql` + query GetAuthIntegrations($limit: Int!) { + organization { + id + premiumIntegrations + } + + integrations(limit: $limit) { + collection { + ... on OktaIntegration { + id + ...AddOktaIntegrationDialog + ...DeleteOktaIntegrationDialog + } + } + } + } + + ${AddOktaIntegrationDialogFragmentDoc} + ${DeleteOktaIntegrationDialogFragmentDoc} +` + +const Authentication = () => { + const { isPremium } = useCurrentUser() + const { translate } = useInternationalization() + const navigate = useNavigate() + + const premiumWarningDialogRef = useRef(null) + const addOktaDialogRef = useRef(null) + const deleteOktaDialogRef = useRef(null) + + const { data, loading } = useGetAuthIntegrationsQuery({ variables: { limit: 10 } }) + + const hasAccessTOktaPremiumIntegration = data?.organization?.premiumIntegrations?.includes( + IntegrationTypeEnum.Okta, + ) + + const oktaIntegration = data?.integrations?.collection.find( + (integration) => integration.__typename === 'OktaIntegration', + ) as OktaIntegration | undefined + + const shouldSeeOktaIntegration = hasAccessTOktaPremiumIntegration && isPremium + + return ( + <> + + + {translate('text_664c732c264d7eed1c74fd96')} + + + + + {translate('text_664c732c264d7eed1c74fd96')} + {translate('text_664c732c264d7eed1c74fd9c')} + + {loading ? ( + + {[0].map((i) => ( + + ))} + + ) : ( + + + + } + endIcon={ + shouldSeeOktaIntegration ? ( + oktaIntegration?.id ? ( + + ) : undefined + ) : ( + 'sparkles' + ) + } + onClick={() => { + if (!shouldSeeOktaIntegration) { + return premiumWarningDialogRef.current?.openDialog() + } + + if (oktaIntegration?.id) { + return navigate( + generatePath(OKTA_AUTHENTICATION_ROUTE, { + integrationId: oktaIntegration.id, + }), + ) + } + + return addOktaDialogRef.current?.openDialog({ + integration: oktaIntegration, + callback: (id) => + navigate(generatePath(OKTA_AUTHENTICATION_ROUTE, { integrationId: id })), + }) + }} + /> + )} + + + + + + + ) +} + +const Title = styled(Typography)` + margin-bottom: ${theme.spacing(2)}; +` + +const Subtitle = styled(Typography)` + margin-bottom: ${theme.spacing(8)}; +` + +const LoadingContainer = styled.div` + > * { + margin-bottom: ${theme.spacing(4)}; + } +` + +export default Authentication diff --git a/src/pages/settings/Authentication/OktaAuthenticationDetails.tsx b/src/pages/settings/Authentication/OktaAuthenticationDetails.tsx new file mode 100644 index 000000000..c9fb15fe5 --- /dev/null +++ b/src/pages/settings/Authentication/OktaAuthenticationDetails.tsx @@ -0,0 +1,289 @@ +import { gql } from '@apollo/client' +import { useRef } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import styled from 'styled-components' + +import { + Avatar, + Button, + ButtonLink, + Chip, + Popper, + Skeleton, + Typography, +} from '~/components/designSystem' +import { AddOktaDialog, AddOktaDialogRef } from '~/components/settings/authentication/AddOktaDialog' +import { + DeleteOktaIntegrationDialog, + DeleteOktaIntegrationDialogRef, +} from '~/components/settings/authentication/DeleteOktaIntegrationDialog' +import { AUTHENTICATION_ROUTE } from '~/core/router' +import { + AddOktaIntegrationDialogFragmentDoc, + DeleteOktaIntegrationDialogFragmentDoc, + OktaIntegration, + useGetOktaIntegrationQuery, +} from '~/generated/graphql' +import { useInternationalization } from '~/hooks/core/useInternationalization' +import { + PropertyListItem, + SkeletonPropertyListItem, +} from '~/pages/settings/Authentication/components/PropertyListItem' +import Okta from '~/public/images/okta.svg' +import { MenuPopper, NAV_HEIGHT, PageHeader, theme } from '~/styles' + +gql` + fragment OktaIntegrationDetails on OktaIntegration { + id + clientId + clientSecret + code + organizationName + domain + name + } + + query GetOktaIntegration($id: ID) { + integration(id: $id) { + ... on OktaIntegration { + ...OktaIntegrationDetails + ...AddOktaIntegrationDialog + ...DeleteOktaIntegrationDialog + } + } + } + + ${AddOktaIntegrationDialogFragmentDoc} + ${DeleteOktaIntegrationDialogFragmentDoc} +` + +const OktaAuthenticationDetails = () => { + const { translate } = useInternationalization() + const { integrationId } = useParams() + const navigate = useNavigate() + + const addOktaDialogRef = useRef(null) + const deleteOktaDialogRef = useRef(null) + + const { data, loading, refetch } = useGetOktaIntegrationQuery({ + variables: { id: integrationId }, + }) + + const integration = data?.integration as OktaIntegration | null + + const onDeleteCallback = () => { + navigate(AUTHENTICATION_ROUTE) + } + + const onEditCallback = () => { + refetch() + } + + if (!integration) { + navigate(AUTHENTICATION_ROUTE) + return null + } + + return ( + <> + + + + {loading ? ( + + ) : ( + + {translate('text_664c732c264d7eed1c74fda2')} + + )} + + {translate('text_626162c62f790600f850b6fe')} + } + > + {({ closePopper }) => ( + + + + + )} + + + + + {loading ? ( + <> + + + + + + + ) : ( + <> + + + +
+ + + {translate('text_664c732c264d7eed1c74fda2')} + + + + {translate('text_664c732c264d7eed1c74fdbd')} +
+ + )} +
+ + +
+ + {translate('text_664c732c264d7eed1c74fdc5')} + + + + <> + {loading ? ( + [0, 1, 2, 3].map((i) => ) + ) : ( + <> + + + + + + )} + +
+
+ + + + + ) +} + +const HeaderBlock = styled.div` + display: flex; + align-items: center; + + > *:first-child  { + margin-right: ${theme.spacing(3)}; + } +` + +const MainInfos = styled.div` + display: flex; + align-items: center; + padding: ${theme.spacing(8)} ${theme.spacing(12)}; + + ${theme.breakpoints.down('md')} { + padding: ${theme.spacing(8)} ${theme.spacing(4)}; + } +` + +const Settings = styled.div` + display: flex; + flex-direction: column; + gap: ${theme.spacing(8)}; + padding: 0 ${theme.spacing(12)}; + box-sizing: border-box; + max-width: ${theme.spacing(168)}; + + ${theme.breakpoints.down('md')} { + padding: 0 ${theme.spacing(4)}; + } +` + +const InlineTitle = styled.div` + position: relative; + height: ${NAV_HEIGHT}px; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; +` + +const SkeletonText = styled.div` + width: 100%; +` + +const StyledAvatar = styled(Avatar)` + margin-right: ${theme.spacing(4)}; +` + +const Line = styled.div` + display: flex; + align-items: center; + + > *:first-child { + margin-right: ${theme.spacing(2)}; + } +` + +export default OktaAuthenticationDetails diff --git a/src/pages/settings/Authentication/components/PropertyListItem.tsx b/src/pages/settings/Authentication/components/PropertyListItem.tsx new file mode 100644 index 000000000..ac6051558 --- /dev/null +++ b/src/pages/settings/Authentication/components/PropertyListItem.tsx @@ -0,0 +1,56 @@ +import { FC } from 'react' +import styled from 'styled-components' + +import { Avatar, Icon, IconName, Skeleton, Typography } from '~/components/designSystem' +import { NAV_HEIGHT, theme } from '~/styles' + +interface PropertyListItemProps { + label: string + value: string + icon: IconName +} + +export const PropertyListItem: FC = ({ label, value, icon }) => { + return ( + + + + +
+ + {label} + + + {value} + +
+
+ ) +} + +export const SkeletonPropertyListItem: FC = () => { + return ( + + + + + + + + ) +} + +const Container = styled.div` + height: ${NAV_HEIGHT}px; + box-shadow: ${theme.shadows[7]}; + display: flex; + align-items: center; + + > *:first-child { + margin-right: ${theme.spacing(3)}; + } +` + +const SkeletonText = styled.div` + width: 100%; +` diff --git a/src/public/icons/okta.svg b/src/public/icons/okta.svg new file mode 100644 index 000000000..5cd9e8980 --- /dev/null +++ b/src/public/icons/okta.svg @@ -0,0 +1,8 @@ + + + diff --git a/src/public/images/okta.svg b/src/public/images/okta.svg new file mode 100644 index 000000000..304c4cc73 --- /dev/null +++ b/src/public/images/okta.svg @@ -0,0 +1,9 @@ + + + + diff --git a/tsconfig.json b/tsconfig.json index 2c6f61227..a0ae888e9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["**/*.tsx", "**/*.ts", "cypress/cypress.config.js"], + "include": ["**/*.tsx", "**/*.ts", "cypress/cypress.config.js", "**/*.d.ts"], "compilerOptions": { "lib": ["es6", "dom", "esnext"], "outDir": "./dist",