diff --git a/frontend/app/[team]/access/layout.tsx b/frontend/app/[team]/access/layout.tsx index c3e169c7..09d74d9d 100644 --- a/frontend/app/[team]/access/layout.tsx +++ b/frontend/app/[team]/access/layout.tsx @@ -29,6 +29,10 @@ export default function AccessLayout({ name: 'Members', link: 'members', }, + { + name: 'Service Accounts', + link: 'service-accounts', + }, { name: 'Roles', link: 'roles', diff --git a/frontend/app/[team]/access/service-accounts/_components/CreateServiceAccountDialog.tsx b/frontend/app/[team]/access/service-accounts/_components/CreateServiceAccountDialog.tsx new file mode 100644 index 00000000..f6b30824 --- /dev/null +++ b/frontend/app/[team]/access/service-accounts/_components/CreateServiceAccountDialog.tsx @@ -0,0 +1,200 @@ +import { OrganisationMemberType, RoleType } from '@/apollo/graphql' +import GenericDialog from '@/components/common/GenericDialog' +import { Fragment, useContext, useRef, useState } from 'react' +import { FaChevronDown, FaPlus } from 'react-icons/fa' +import { GetServiceAccounts } from '@/graphql/queries/service-accounts/getServiceAccounts.gql' +import { GetServiceAccountHandlers } from '@/graphql/queries/service-accounts/getServiceAccountHandlers.gql' +import { GetRoles } from '@/graphql/queries/organisation/getRoles.gql' +import { GetServerKey } from '@/graphql/queries/syncing/getServerKey.gql' +import { CreateServiceAccount } from '@/graphql/mutations/service-accounts/createServiceAccount.gql' +import { organisationContext } from '@/contexts/organisationContext' +import { useMutation, useQuery } from '@apollo/client' +import { + organisationSeed, + organisationKeyring, + deviceVaultKey, + encryptAccountKeyring, + encryptAccountRecovery, + OrganisationKeyring, + getUserKxPublicKey, +} from '@/utils/crypto' +import { Input } from '@/components/common/Input' +import { RoleLabel } from '@/components/users/RoleLabel' +import { Listbox } from '@headlessui/react' +import clsx from 'clsx' +import { ToggleSwitch } from '@/components/common/ToggleSwitch' +import { Button } from '@/components/common/Button' +import { identity } from 'lodash' +import { toast } from 'react-toastify' + +const bip39 = require('bip39') + +export const CreateServiceAccountDialog = () => { + const { activeOrganisation: organisation } = useContext(organisationContext) + + const { data: roleData, loading: roleDataPending } = useQuery(GetRoles, { + variables: { orgId: organisation?.id }, + skip: !organisation, + }) + + const { data: serviceAccountHandlerData } = useQuery(GetServiceAccountHandlers, { + variables: { orgId: organisation?.id }, + skip: !organisation, + }) + + const [createServiceAccount] = useMutation(CreateServiceAccount) + + const { data: serverKeyData } = useQuery(GetServerKey) + + const dialogRef = useRef<{ closeModal: () => void }>(null) + + const [name, setName] = useState('') + const [role, setRole] = useState(null) + const [thirdParty, setThirdParty] = useState(false) + const [createPending, setCreatePending] = useState(false) + + const roleOptions = roleData?.roles.filter((option: RoleType) => option.name !== 'Owner') || [] + + const handleCreateServiceAccount = (e: { preventDefault: () => void }) => { + return new Promise((resolve) => { + e.preventDefault() + setCreatePending(true) + setTimeout(async () => { + // Compute new keys for service account + const mnemonic = bip39.generateMnemonic(256) + const accountSeed = await organisationSeed(mnemonic, organisation!.id) + const keyring = await organisationKeyring(accountSeed) + + // Wrap keys for server if required + let serverKeys = undefined + if (thirdParty) { + const serverKey = serverKeyData.serverPublicKey + + const serverEncryptedKeyring = await encryptAccountKeyring(keyring, serverKey) + + const serverEncryptedMnemonic = await encryptAccountRecovery(mnemonic, serverKey) + + serverKeys = { + serverEncryptedKeyring, + serverEncryptedMnemonic, + } + } + + // Wrap keys for service account handlers + const handlers: OrganisationMemberType[] = serviceAccountHandlerData.serviceAccountHandlers + + const handlerWrappingPromises = handlers.map(async (handler) => { + const kxKey = await getUserKxPublicKey(handler.identityKey!) + const wrappedKeyring = await encryptAccountKeyring(keyring, kxKey) + const wrappedRecovery = await encryptAccountRecovery(mnemonic, kxKey) + return { + memberId: handler.id, + wrappedKeyring, + wrappedRecovery, + } + }) + + const handlerKeys = await Promise.all(handlerWrappingPromises) + + await createServiceAccount({ + variables: { + name, + orgId: organisation!.id, + roleId: role!.id, + identityKey: keyring.publicKey, + serverWrappedKeyring: serverKeys?.serverEncryptedKeyring || null, + serverWrappedRecovery: serverKeys?.serverEncryptedMnemonic || null, + handlers: handlerKeys, + }, + refetchQueries: [ + { + query: GetServiceAccounts, + variables: { orgId: organisation!.id }, + }, + ], + }) + + setCreatePending(false) + + if (dialogRef.current) dialogRef.current.closeModal() + + toast.success('Created new service account!') + + resolve(true) + }, 500) + }) + } + + return ( + + Create Service Account + + } + buttonVariant="primary" + size="lg" + ref={dialogRef} + > +
+
+ +
+ + + {({ open }) => ( + <> + +
+ {role ? : <>} + +
+
+ + {roleOptions.map((role: RoleType) => ( + + {({ active, selected }) => ( +
+ +
+ )} +
+ ))} +
+ + )} +
+
+
+ + setThirdParty(!thirdParty)} /> +
+
+
+ +
+
+
+ ) +} diff --git a/frontend/app/[team]/access/service-accounts/_components/DeleteServiceAccountDialog.tsx b/frontend/app/[team]/access/service-accounts/_components/DeleteServiceAccountDialog.tsx new file mode 100644 index 00000000..34362eac --- /dev/null +++ b/frontend/app/[team]/access/service-accounts/_components/DeleteServiceAccountDialog.tsx @@ -0,0 +1,54 @@ +import { FaTrash } from 'react-icons/fa' +import { DeleteServiceAccount } from '@/graphql/mutations/service-accounts/deleteServiceAccount.gql' +import { GetServiceAccounts } from '@/graphql/queries/service-accounts/getServiceAccounts.gql' +import { useMutation } from '@apollo/client' +import { toast } from 'react-toastify' +import { useContext, useRef } from 'react' +import { organisationContext } from '@/contexts/organisationContext' +import { ServiceAccountType } from '@/apollo/graphql' +import { Button } from '@/components/common/Button' +import GenericDialog from '@/components/common/GenericDialog' + +export const DeleteServiceAccountDialog = ({ account }: { account: ServiceAccountType }) => { + const { activeOrganisation: organisation } = useContext(organisationContext) + + const dialogRef = useRef<{ closeModal: () => void }>(null) + + const [deleteAccount] = useMutation(DeleteServiceAccount) + + const handleDelete = async () => { + const deleted = await deleteAccount({ + variables: { id: account.id }, + refetchQueries: [{ query: GetServiceAccounts, variables: { orgId: organisation!.id } }], + }) + if (deleted.data.deleteServiceAccount.ok) { + toast.success('Deleted service account!') + if (dialogRef.current) dialogRef.current.closeModal() + } + } + + return ( + + Delete + + } + buttonVariant="danger" + ref={dialogRef} + > +
+
+ Are you sure you want to delete this service account? This will delete all service tokens + associated with this account. +
+
+ +
+
+
+ ) +} diff --git a/frontend/app/[team]/access/service-accounts/page.tsx b/frontend/app/[team]/access/service-accounts/page.tsx new file mode 100644 index 00000000..cc75de44 --- /dev/null +++ b/frontend/app/[team]/access/service-accounts/page.tsx @@ -0,0 +1,109 @@ +'use client' + +import { ServiceAccountType } from '@/apollo/graphql' +import { EmptyState } from '@/components/common/EmptyState' +import { RoleLabel } from '@/components/users/RoleLabel' +import { organisationContext } from '@/contexts/organisationContext' +import { GetServiceAccounts } from '@/graphql/queries/service-accounts/getServiceAccounts.gql' +import { userHasPermission } from '@/utils/access/permissions' +import { useQuery } from '@apollo/client' +import { useContext } from 'react' +import { FaBan } from 'react-icons/fa' +import { CreateServiceAccountDialog } from './_components/CreateServiceAccountDialog' +import { FaRobot } from 'react-icons/fa6' +import { relativeTimeFromDates } from '@/utils/time' +import { DeleteServiceAccountDialog } from './_components/DeleteServiceAccountDialog' + +export default function ServiceAccounts({ params }: { params: { team: string } }) { + const { activeOrganisation: organisation } = useContext(organisationContext) + + const userCanReadSA = organisation + ? userHasPermission(organisation?.role?.permissions, 'ServiceAccounts', 'read') + : false + + const userCanCreateSA = organisation + ? userHasPermission(organisation?.role?.permissions, 'ServiceAccounts', 'create') + : false + + const userCanDeleteSA = organisation + ? userHasPermission(organisation?.role?.permissions, 'ServiceAccounts', 'delete') + : false + + const { data, loading } = useQuery(GetServiceAccounts, { + variables: { orgId: organisation?.id }, + skip: !organisation || !userCanReadSA, + }) + + return ( +
+
+
+

{params.team} Service Accounts

+

Manage service accounts.

+
+
+ {userCanCreateSA && ( +
+ +
+ )} + + {userCanReadSA ? ( + + + + + + + + + + + {data?.serviceAccounts.map((account: ServiceAccountType) => ( + + + + + + + + + + ))} + +
+ Account name + + Role + + Created +
+
+ +
+ {account.name} +
+ + + {relativeTimeFromDates(new Date(account.createdAt))} + + {userCanDeleteSA && } +
+ ) : ( + + +
+ } + > + <> + + )} +
+ +
+ ) +}