-
Notifications
You must be signed in to change notification settings - Fork 28
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add service account page, create and delete dialogs
- Loading branch information
1 parent
619a191
commit 3e46b57
Showing
4 changed files
with
367 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
200 changes: 200 additions & 0 deletions
200
frontend/app/[team]/access/service-accounts/_components/CreateServiceAccountDialog.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
54 changes: 54 additions & 0 deletions
54
frontend/app/[team]/access/service-accounts/_components/DeleteServiceAccountDialog.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |