From e3db938d684f4c4b397267ab1bdc26e0c61323e4 Mon Sep 17 00:00:00 2001 From: Rohan Chaturvedi Date: Fri, 26 Jan 2024 11:45:50 +0530 Subject: [PATCH] feat: integrations ux improvements (#159) * fix: button loading states for delete actions * refactor: split create creds comonent from dialog * fix: service id for github * feat: misc improvements to empty states * fix: misc fixes to handle various ui states --- frontend/app/[team]/integrations/page.tsx | 123 ++++++-- .../syncing/CreateProviderCredentials.tsx | 219 ++++++++++++++ .../CreateProviderCredentialsDialog.tsx | 266 ++---------------- .../components/syncing/CreateSyncDialog.tsx | 2 +- .../DeleteProviderCredentialDialog.tsx | 7 +- .../components/syncing/DeleteSyncDialog.tsx | 7 +- 6 files changed, 350 insertions(+), 274 deletions(-) create mode 100644 frontend/components/syncing/CreateProviderCredentials.tsx diff --git a/frontend/app/[team]/integrations/page.tsx b/frontend/app/[team]/integrations/page.tsx index bc474e24..1c0bb502 100644 --- a/frontend/app/[team]/integrations/page.tsx +++ b/frontend/app/[team]/integrations/page.tsx @@ -1,24 +1,31 @@ 'use client' import { organisationContext } from '@/contexts/organisationContext' -import { Fragment, useContext, useEffect } from 'react' +import { Fragment, useContext, useEffect, useState } from 'react' import GetSavedCredentials from '@/graphql/queries/syncing/getSavedCredentials.gql' import GetOrganisationSyncs from '@/graphql/queries/syncing/GetOrgSyncs.gql' -import { useLazyQuery } from '@apollo/client' -import { AppType, EnvironmentSyncType, ProviderCredentialsType } from '@/apollo/graphql' +import GetProviderList from '@/graphql/queries/syncing/getProviders.gql' +import { useLazyQuery, useQuery } from '@apollo/client' +import { + AppType, + EnvironmentSyncType, + ProviderCredentialsType, + ProviderType, +} from '@/apollo/graphql' import { CreateProviderCredentialsDialog } from '@/components/syncing/CreateProviderCredentialsDialog' import { SyncCard } from '@/components/syncing/SyncCard' import { ProviderCredentialCard } from '@/components/syncing/ProviderCredentialCard' import { GetApps } from '@/graphql/queries/getApps.gql' import { Button } from '@/components/common/Button' -import { SyncOptions } from '@/components/syncing/SyncOptions' import { Menu, Transition } from '@headlessui/react' -import { FaArrowRight, FaPlus } from 'react-icons/fa' +import { FaArrowRight, FaCubes, FaPlus } from 'react-icons/fa' import clsx from 'clsx' import Link from 'next/link' import { userIsAdmin } from '@/utils/permissions' import { useSearchParams } from 'next/navigation' import { FrameworkIntegrations } from '@/components/syncing/FrameworkIntegrations' +import { ProviderCard } from '@/components/syncing/CreateProviderCredentials' +import { AppCard } from '@/components/apps/AppCard' export default function Integrations({ params }: { params: { team: string } }) { const { activeOrganisation: organisation } = useContext(organisationContext) @@ -27,6 +34,12 @@ export default function Integrations({ params }: { params: { team: string } }) { const openCreateCredentialDialog = searchParams.get('newCredential') + const { data: providersData } = useQuery(GetProviderList) + + const providers: ProviderType[] = providersData?.providers ?? [] + + const [provider, setProvider] = useState(null) + const [getApps, { data: appsData }] = useLazyQuery(GetApps) const [getSavedCredentials, { data: credentialsData }] = useLazyQuery(GetSavedCredentials) const [getOrgSyncs, { data: syncsData }] = useLazyQuery(GetOrganisationSyncs) @@ -46,6 +59,10 @@ export default function Integrations({ params }: { params: { team: string } }) { const noCredentials = credentialsData?.savedCredentials.length === 0 + const noSyncs = syncsData?.syncs.length === 0 + + const noApps = appsData?.apps.length === 0 + const NewSyncMenu = () => { return ( @@ -106,28 +123,49 @@ export default function Integrations({ params }: { params: { team: string } }) {

Manage syncs

- {syncsData?.syncs.length > 0 && activeUserIsAdmin && ( + {!noSyncs && activeUserIsAdmin && (
)} - {syncsData?.syncs.length > 0 ? ( - syncsData?.syncs.map((sync: EnvironmentSyncType) => ( - - )) - ) : ( -
-
No syncs
-
- Create a sync from the "Syncing" tab of an App + {noApps && ( +
+
+
No Apps
+
+ You don't have access to any Apps. Create a new app, or contact your + organistion admin for access to start syncing. +
- {activeUserIsAdmin && ( -
- + + + +
+ )} + + {noSyncs && !noApps ? ( +
+
+
No syncs
+
+ You don't have any syncs at the moment. Choose an App below to create a sync.
- )} +
+
+ {apps?.map((app: AppType) => ( + + + + ))} +
+ ) : ( + syncsData?.syncs.map((sync: EnvironmentSyncType) => ( + + )) )}
@@ -135,29 +173,52 @@ export default function Integrations({ params }: { params: { team: string } }) {
-

Service credentials

+

Service credentials

Manage stored credentials for third party services

{noCredentials && ( -
- No service credentials -
- )} - {noCredentials && ( -
- Set up a new authentication method to start syncing with third party services. +
+
+ No service credentials +
+
+ {activeUserIsAdmin + ? 'Set up a new authentication method to start syncing with third party services.' + : 'Contact your organisation admin or owner to create credentials.'} +
)} + {activeUserIsAdmin && ( -
- -
+ <> +
+ setProvider(null)} + /> +
+ {noCredentials ? ( +
+
+ {providers.map((provider) => ( + + ))} +
+
+ ) : ( +
+ )} + )}
diff --git a/frontend/components/syncing/CreateProviderCredentials.tsx b/frontend/components/syncing/CreateProviderCredentials.tsx new file mode 100644 index 00000000..467625f1 --- /dev/null +++ b/frontend/components/syncing/CreateProviderCredentials.tsx @@ -0,0 +1,219 @@ +import { ProviderType } from '@/apollo/graphql' +import GetProviderList from '@/graphql/queries/syncing/getProviders.gql' +import GetSavedCredentials from '@/graphql/queries/syncing/getSavedCredentials.gql' +import SaveNewProviderCreds from '@/graphql/mutations/syncing/saveNewProviderCreds.gql' +import { useState, useEffect, useContext } from 'react' +import { FaArrowRight, FaQuestionCircle } from 'react-icons/fa' +import { Button } from '../common/Button' +import { useMutation, useQuery } from '@apollo/client' +import { Input } from '../common/Input' +import { organisationContext } from '@/contexts/organisationContext' +import { toast } from 'react-toastify' +import { encryptProviderCredentials } from '@/utils/syncing/general' +import { Card } from '../common/Card' +import { ProviderIcon } from './ProviderIcon' +import { AWSRegionPicker } from './AWS/AWSRegionPicker' +import { awsRegions } from '@/utils/syncing/aws' +import Link from 'next/link' +import { SetupGhAuth } from './GitHub/SetupGhAuth' + +interface CredentialState { + [key: string]: string +} + +export const ProviderCard = (props: { provider: ProviderType }) => { + const { provider } = props + + return ( + +
+
+ +
+
+
+
{provider.name}
+
+ Set up authentication credentials to sync with {provider.name}. +
+
+
+ Create +
+
+
+
+ ) +} + +export const CreateProviderCredentials = (props: { + provider: ProviderType | null + onComplete: () => void +}) => { + const { activeOrganisation: organisation } = useContext(organisationContext) + + const [provider, setProvider] = useState(props.provider || null) + const [name, setName] = useState('') + const [credentials, setCredentials] = useState({}) + + const { data: providersData } = useQuery(GetProviderList) + const [saveNewCreds] = useMutation(SaveNewProviderCreds) + + const providers: ProviderType[] = providersData?.providers ?? [] + + useEffect(() => { + const handleProviderChange = (provider: ProviderType) => { + setProvider(provider) + const initialCredentials: CredentialState = {} + provider.expectedCredentials!.forEach((cred) => { + initialCredentials[cred] = '' // Initialize each credential with an empty string + }) + if (provider.optionalCredentials) { + provider.optionalCredentials!.forEach((cred) => { + initialCredentials[cred] = '' + }) + } + if (provider.id === 'aws') initialCredentials['region'] = awsRegions[0].region + setCredentials(initialCredentials) + + if (name.length === 0) setName(`${provider.name} credentials`) + } + if (provider) handleProviderChange(provider) + }, [provider]) + + const handleCredentialChange = (key: string, value: string) => { + setCredentials({ ...credentials, [key]: value }) + } + + const reset = () => { + setProvider(null) + setName('') + } + + const docsLink = (provider: ProviderType) => { + if (provider.id === 'cloudflare') + return 'https://docs.phase.dev/integrations/platforms/cloudflare-pages' + else if (provider.id === 'aws') + return 'https://docs.phase.dev/integrations/platforms/aws-secrets-manager' + else if (provider.id === 'hashicorp_vault') + return 'https://docs.phase.dev/integrations/platforms/hashicorp-vault' + else return 'https://docs.phase.dev/integrations' + } + + const handleClickBack = () => { + setProvider(null) + setName('') + } + + const handleSubmit = async (e: { preventDefault: () => void }) => { + e.preventDefault() + + if (provider === null) { + return false + } + + const encryptedCredentials = JSON.stringify( + await encryptProviderCredentials(provider, credentials, providersData.serverPublicKey) + ) + + await saveNewCreds({ + variables: { + orgId: organisation!.id, + provider: provider?.id, + name, + credentials: encryptedCredentials, + }, + refetchQueries: [ + { + query: GetSavedCredentials, + variables: { + orgId: organisation!.id, + }, + }, + ], + }) + + toast.success(`Saved ${name}`) + props.onComplete() + } + + return ( + <> +
+ {provider && ( +
+
+ + {provider.name} +
+ + + +
+ )} + + {provider === null && ( +
+ {providers.map((provider) => ( + + ))} +
+ )} + + {provider?.authScheme === 'token' && + provider?.expectedCredentials + .filter((credential) => credential !== 'region') + .map((credential) => ( + handleCredentialChange(credential, value)} + label={credential.replace(/_/g, ' ').toUpperCase()} + required + secret={true} + /> + ))} + + {provider?.authScheme === 'token' && + provider?.optionalCredentials + .filter((credential) => credential !== 'region') + .map((credential) => ( + handleCredentialChange(credential, value)} + label={`${credential.replace(/_/g, ' ').toUpperCase()} (Optional)`} + secret={true} + /> + ))} + + {provider?.id === 'aws' && ( + handleCredentialChange('region', region)} /> + )} + + {provider?.id === 'github' && } + + {provider && provider?.authScheme === 'token' && ( + setName(value)} label="Name" /> + )} + + {provider && ( +
+ + + +
+ )} + + + ) +} diff --git a/frontend/components/syncing/CreateProviderCredentialsDialog.tsx b/frontend/components/syncing/CreateProviderCredentialsDialog.tsx index 0e5c4499..c2c4c308 100644 --- a/frontend/components/syncing/CreateProviderCredentialsDialog.tsx +++ b/frontend/components/syncing/CreateProviderCredentialsDialog.tsx @@ -1,104 +1,21 @@ -import { OrganisationMemberType, ProviderType } from '@/apollo/graphql' -import GetProviderList from '@/graphql/queries/syncing/getProviders.gql' -import GetSavedCredentials from '@/graphql/queries/syncing/getSavedCredentials.gql' -import SaveNewProviderCreds from '@/graphql/mutations/syncing/saveNewProviderCreds.gql' -import { Dialog, Combobox, Transition } from '@headlessui/react' -import clsx from 'clsx' -import { useState, Fragment, ChangeEvent, useEffect, useContext } from 'react' -import { FaArrowRight, FaChevronDown, FaPlus, FaQuestionCircle, FaTimes } from 'react-icons/fa' -import { Avatar } from '../common/Avatar' +import { Dialog, Transition } from '@headlessui/react' +import { useState, Fragment, useEffect } from 'react' +import { FaPlus, FaTimes } from 'react-icons/fa' import { Button } from '../common/Button' -import { useMutation, useQuery } from '@apollo/client' -import { Input } from '../common/Input' -import { encryptAsymmetric } from '@/utils/crypto' -import { organisationContext } from '@/contexts/organisationContext' -import { toast } from 'react-toastify' -import { encryptProviderCredentials } from '@/utils/syncing/general' -import { Card } from '../common/Card' -import { ProviderIcon } from './ProviderIcon' -import { AWSRegionPicker } from './AWS/AWSRegionPicker' -import { awsRegions } from '@/utils/syncing/aws' -import Link from 'next/link' -import { SetupGhAuth } from './GitHub/SetupGhAuth' - -interface CredentialState { - [key: string]: string -} - -const ProviderCard = (props: { provider: ProviderType }) => { - const { provider } = props - - return ( - -
-
- -
-
-
-
{provider.name}
-
- Set up authentication credentials to sync with {provider.name}. -
-
-
- -
-
-
-
- ) -} +import { CreateProviderCredentials } from './CreateProviderCredentials' +import { ProviderType } from '@/apollo/graphql' export const CreateProviderCredentialsDialog = (props: { buttonVariant?: 'primary' | 'secondary' defaultOpen?: boolean + provider: ProviderType | null + closeDialogCallback: () => void + showButton: boolean }) => { - const { activeOrganisation: organisation } = useContext(organisationContext) - const [isOpen, setIsOpen] = useState(props.defaultOpen || false) - const [provider, setProvider] = useState(null) - const [name, setName] = useState('') - const [credentials, setCredentials] = useState({}) - - const { data: providersData } = useQuery(GetProviderList) - const [saveNewCreds] = useMutation(SaveNewProviderCreds) - - const providers: ProviderType[] = providersData?.providers ?? [] - - useEffect(() => { - const handleProviderChange = (provider: ProviderType) => { - setProvider(provider) - const initialCredentials: CredentialState = {} - provider.expectedCredentials!.forEach((cred) => { - initialCredentials[cred] = '' // Initialize each credential with an empty string - }) - if (provider.optionalCredentials) { - provider.optionalCredentials!.forEach((cred) => { - initialCredentials[cred] = '' - }) - } - if (provider.id === 'aws') initialCredentials['region'] = awsRegions[0].region - setCredentials(initialCredentials) - - if (name.length === 0) setName(`${provider.name} credentials`) - } - if (provider) handleProviderChange(provider) - }, [provider]) - - const handleCredentialChange = (key: string, value: string) => { - setCredentials({ ...credentials, [key]: value }) - } - - const reset = () => { - setProvider(null) - setName('') - } const closeModal = () => { - reset() + props.closeDialogCallback() setIsOpen(false) } @@ -110,65 +27,24 @@ export const CreateProviderCredentialsDialog = (props: { if (props.defaultOpen) openModal() }, [props.defaultOpen]) - const docsLink = (provider: ProviderType) => { - if (provider.id === 'cloudflare') - return 'https://docs.phase.dev/integrations/platforms/cloudflare-pages' - else if (provider.id === 'aws') - return 'https://docs.phase.dev/integrations/platforms/aws-secrets-manager' - else if (provider.id === 'hashicorp_vault') - return 'https://docs.phase.dev/integrations/platforms/hashicorp-vault' - else return 'https://docs.phase.dev/integrations' - } - - const handleClickBack = () => { - setProvider(null) - setName('') - } - - const handleSubmit = async (e: { preventDefault: () => void }) => { - e.preventDefault() - - if (provider === null) { - return false - } - - const encryptedCredentials = JSON.stringify( - await encryptProviderCredentials(provider, credentials, providersData.serverPublicKey) - ) - - await saveNewCreds({ - variables: { - orgId: organisation!.id, - provider: provider?.id, - name, - credentials: encryptedCredentials, - }, - refetchQueries: [ - { - query: GetSavedCredentials, - variables: { - orgId: organisation!.id, - }, - }, - ], - }) - - toast.success(`Saved ${name}`) - closeModal() - } + useEffect(() => { + if (props.provider) openModal() + }, [props.provider]) return ( <> -
- -
+ {props.showButton && ( +
+ +
+ )} @@ -198,7 +74,8 @@ export const CreateProviderCredentialsDialog = (props: {

- Create new {provider && {provider.name}} service credentials + {/* Create new {provider && {provider.name}} service credentials */} + Create new service credentials

-
+

Add a new set of credentials for third party integrations.

-
- {provider && ( -
-
- - - {provider.name} - -
- - - -
- )} - - {provider === null && ( -
- {providers.map((provider) => ( - - ))} -
- )} - - {provider?.authScheme === 'token' && - provider?.expectedCredentials - .filter((credential) => credential !== 'region') - .map((credential) => ( - handleCredentialChange(credential, value)} - label={credential.replace(/_/g, ' ').toUpperCase()} - required - secret={true} - /> - ))} - - {provider?.authScheme === 'token' && - provider?.optionalCredentials - .filter((credential) => credential !== 'region') - .map((credential) => ( - handleCredentialChange(credential, value)} - label={`${credential.replace(/_/g, ' ').toUpperCase()} (Optional)`} - secret={true} - /> - ))} - - {provider?.id === 'aws' && ( - handleCredentialChange('region', region)} - /> - )} - - {provider?.id === 'github' && } - - {provider && provider?.authScheme === 'token' && ( - setName(value)} - label="Name" - /> - )} - - {provider && ( -
- - - -
- )} - +
diff --git a/frontend/components/syncing/CreateSyncDialog.tsx b/frontend/components/syncing/CreateSyncDialog.tsx index 894c2419..5456e3b1 100644 --- a/frontend/components/syncing/CreateSyncDialog.tsx +++ b/frontend/components/syncing/CreateSyncDialog.tsx @@ -30,7 +30,7 @@ export const CreateSyncDialog = (props: { return case 'cloudflare_pages': return - case 'gh_actions': + case 'github_actions': return case 'hashicorp_vault': return diff --git a/frontend/components/syncing/DeleteProviderCredentialDialog.tsx b/frontend/components/syncing/DeleteProviderCredentialDialog.tsx index 46f43237..763c43eb 100644 --- a/frontend/components/syncing/DeleteProviderCredentialDialog.tsx +++ b/frontend/components/syncing/DeleteProviderCredentialDialog.tsx @@ -15,7 +15,9 @@ export const DeleteProviderCredentialDialog = (props: { const [isOpen, setIsOpen] = useState(false) - const [deleteCredential, { loading: deleteLoading }] = useMutation(DeleteProviderCreds) + const [deleteLoading, setDeleteLoading] = useState(false) + + const [deleteCredential] = useMutation(DeleteProviderCreds) const closeModal = () => { setIsOpen(false) @@ -26,6 +28,7 @@ export const DeleteProviderCredentialDialog = (props: { } const handleDelete = async () => { + setDeleteLoading(true) await deleteCredential({ variables: { credentialId: credential.id }, refetchQueries: [ @@ -97,7 +100,7 @@ export const DeleteProviderCredentialDialog = (props: { -
diff --git a/frontend/components/syncing/DeleteSyncDialog.tsx b/frontend/components/syncing/DeleteSyncDialog.tsx index 33eaa9f2..6ef28252 100644 --- a/frontend/components/syncing/DeleteSyncDialog.tsx +++ b/frontend/components/syncing/DeleteSyncDialog.tsx @@ -12,7 +12,9 @@ export const DeleteSyncDialog = (props: { sync: EnvironmentSyncType }) => { const [isOpen, setIsOpen] = useState(false) - const [deleteSync, { loading: deleteLoading }] = useMutation(DeleteSync) + const [deleteLoading, setDeleteLoading] = useState(false) + + const [deleteSync] = useMutation(DeleteSync) const closeModal = () => { setIsOpen(false) @@ -23,6 +25,7 @@ export const DeleteSyncDialog = (props: { sync: EnvironmentSyncType }) => { } const handleDelete = async () => { + setDeleteLoading(true) await deleteSync({ variables: { syncId: sync.id }, refetchQueries: [ @@ -86,7 +89,7 @@ export const DeleteSyncDialog = (props: { sync: EnvironmentSyncType }) => { -