Skip to content

Commit

Permalink
feat: add service account page, create and delete dialogs
Browse files Browse the repository at this point in the history
  • Loading branch information
rohan-chaturvedi committed Oct 21, 2024
1 parent 619a191 commit 3e46b57
Show file tree
Hide file tree
Showing 4 changed files with 367 additions and 0 deletions.
4 changes: 4 additions & 0 deletions frontend/app/[team]/access/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export default function AccessLayout({
name: 'Members',
link: 'members',
},
{
name: 'Service Accounts',
link: 'service-accounts',
},
{
name: 'Roles',
link: 'roles',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RoleType | null>(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<boolean>((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 (
<GenericDialog
title="Create a new Service Account"
buttonContent={
<>
<FaPlus /> Create Service Account
</>
}
buttonVariant="primary"
size="lg"
ref={dialogRef}
>
<form onSubmit={handleCreateServiceAccount}>
<div className="grid grid-cols-2 gap-8 max-h-[85vh] overflow-y-auto">
<Input value={name} setValue={setName} label="Account name" required maxLength={32} />
<div className="space-y-1 w-full">
<label className="block text-neutral-500 text-sm mb-2" htmlFor="role">
Role
</label>
<Listbox value={role} onChange={setRole} name="role">
{({ open }) => (
<>
<Listbox.Button as={Fragment} aria-required>
<div
className={clsx(
'py-2 flex items-center justify-between w-full rounded-md h-10'
)}
>
{role ? <RoleLabel role={role} /> : <></>}
<FaChevronDown
className={clsx(
'transition-transform ease duration-300 text-neutral-500',
open ? 'rotate-180' : 'rotate-0'
)}
/>
</div>
</Listbox.Button>
<Listbox.Options className="bg-zinc-200 dark:bg-zinc-800 p-2 rounded-md shadow-2xl absolute z-10 w-max focus:outline-none">
{roleOptions.map((role: RoleType) => (
<Listbox.Option key={role.name} value={role} as={Fragment}>
{({ active, selected }) => (
<div
className={clsx(
'flex items-center gap-2 p-2 cursor-pointer rounded-full',
active && 'bg-zinc-300 dark:bg-zinc-700'
)}
>
<RoleLabel role={role} />
</div>
)}
</Listbox.Option>
))}
</Listbox.Options>
</>
)}
</Listbox>
</div>
<div>
<label className="block text-neutral-500 text-sm mb-2" htmlFor="role">
Enable third-party authentication
</label>
<ToggleSwitch value={thirdParty} onToggle={() => setThirdParty(!thirdParty)} />
</div>
</div>
<div className="flex justify-end items-center gap-2 pt-8">
<Button type="submit" variant="primary" isLoading={createPending}>
Create Service Account
</Button>
</div>
</form>
</GenericDialog>
)
}
Original file line number Diff line number Diff line change
@@ -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 (
<GenericDialog
title={`Delete ${account.name}`}
buttonContent={
<>
<FaTrash /> Delete
</>
}
buttonVariant="danger"
ref={dialogRef}
>
<div className="space-y-4">
<div className="text-neutral-500 py-4">
Are you sure you want to delete this service account? This will delete all service tokens
associated with this account.
</div>
<div className="flex justify-end">
<Button variant="danger" onClick={handleDelete}>
<FaTrash /> Delete
</Button>
</div>
</div>
</GenericDialog>
)
}
109 changes: 109 additions & 0 deletions frontend/app/[team]/access/service-accounts/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="overflow-y-auto">
<div className="w-full space-y-4 text-black dark:text-white">
<div className="space-y-1">
<h2 className="text-xl font-semibold">{params.team} Service Accounts</h2>
<p className="text-neutral-500">Manage service accounts.</p>
</div>
<div className="space-y-4">
{userCanCreateSA && (
<div className="flex justify-end">
<CreateServiceAccountDialog />
</div>
)}

{userCanReadSA ? (
<table className="table-auto min-w-full divide-y divide-zinc-500/40 ">
<thead>
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Account name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>

<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-500/20">
{data?.serviceAccounts.map((account: ServiceAccountType) => (
<tr key={account.id} className="group">
<td className="flex items-center gap-2 py-4">
<div className="rounded-full flex items-center bg-neutral-500/40 justify-center size-10">
<FaRobot className="shrink-0 text-zinc-900 dark:text-zinc-100 text-xl" />
</div>
{account.name}
</td>

<td className="px-6 py-4">
<RoleLabel role={account.role!} />
</td>

<td className="px-6 py-4">
{relativeTimeFromDates(new Date(account.createdAt))}
</td>

<td className="px-6 py-4 flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition ease">
{userCanDeleteSA && <DeleteServiceAccountDialog account={account} />}
</td>
</tr>
))}
</tbody>
</table>
) : (
<EmptyState
title="Access restricted"
subtitle="You don't have the permissions required to view service accounts in this organisation."
graphic={
<div className="text-neutral-300 dark:text-neutral-700 text-7xl text-center">
<FaBan />
</div>
}
>
<></>
</EmptyState>
)}
</div>
</div>
</section>
)
}

0 comments on commit 3e46b57

Please sign in to comment.