From f9e607cfc92cc43b0a065060e4446e043bb0fcaf Mon Sep 17 00:00:00 2001 From: Om Date: Thu, 13 Aug 2026 16:04:47 +0530 Subject: [PATCH] fix(frontend): give a real error when the viewer has no environment key Eight call sites read the viewer's environment key as `data.environmentKeys[0]` and destructure it immediately. That list is a queryset filtered by environment and member, so an empty result is a normal outcome rather than an error state, and destructuring it throws "Cannot destructure property 'wrappedSeed' of 'undefined'" from inside an async callback. The user is told nothing, and at the sites that do not catch, the dialog just stops responding. An empty list is reachable because the resolver guards on `user_can_access_app` while filtering keys by environment and member: - CreateEnvironmentMutation provisions `admin_keys` only, so a non-admin member with app access holds no key for an environment created after they were added. - AddAppMemberMutation takes a client-supplied `env_keys` list, so a member can be granted app access for a subset of environments. Add `requireEnvironmentKey` next to the other environment crypto helpers and use it at each site, so the failure names the environment and says what to do about it. Behaviour is otherwise unchanged: the sites that threw still throw, with a message that means something. --- .../members/_components/RoleSelector.tsx | 8 +++-- .../members/_components/AddMemberDialog.tsx | 8 +++-- .../_components/ManageUserAccessDialog.tsx | 9 ++++-- .../_components/AddAccountDialog.tsx | 8 +++-- .../_components/ManageAccountAccessDialog.tsx | 9 ++++-- frontend/components/apps/EnableSSEDialog.tsx | 8 +++-- .../apps/tokens/CreateServiceTokenDialog.tsx | 3 +- .../environments/secrets/HistoryDialog.tsx | 6 +++- frontend/utils/crypto/environments.ts | 32 +++++++++++++++++++ 9 files changed, 77 insertions(+), 14 deletions(-) diff --git a/frontend/app/[team]/access/members/_components/RoleSelector.tsx b/frontend/app/[team]/access/members/_components/RoleSelector.tsx index d8df5ad19..202f662ee 100644 --- a/frontend/app/[team]/access/members/_components/RoleSelector.tsx +++ b/frontend/app/[team]/access/members/_components/RoleSelector.tsx @@ -11,7 +11,11 @@ import { toast } from 'react-toastify' import { PermissionPolicy, isRoleCryptoSafe, userHasGlobalAccess } from '@/utils/access/permissions' import { RoleLabel } from '@/components/users/RoleLabel' import { KeyringContext } from '@/contexts/keyringContext' -import { unwrapEnvSecretsForUser, wrapEnvSecretsForAccount } from '@/utils/crypto' +import { + requireEnvironmentKey, + unwrapEnvSecretsForUser, + wrapEnvSecretsForAccount, +} from '@/utils/crypto' import { userHasPermission } from '@/utils/access/permissions' import { updateServiceAccountHandlers } from '@/utils/crypto/service-accounts' import GetOrganisationMembers from '@/graphql/queries/organisation/getOrganisationMembers.gql' @@ -101,7 +105,7 @@ export const RoleSelector = (props: { wrappedSeed: userWrappedSeed, wrappedSalt: userWrappedSalt, identityKey, - } = data.environmentKeys[0] + } = requireEnvironmentKey(data.environmentKeys, env.name) // Unwrap env keys for current logged in user const { seed, salt } = await unwrapEnvSecretsForUser( diff --git a/frontend/app/[team]/apps/[app]/access/members/_components/AddMemberDialog.tsx b/frontend/app/[team]/apps/[app]/access/members/_components/AddMemberDialog.tsx index a8714f506..4b3ec65dc 100644 --- a/frontend/app/[team]/apps/[app]/access/members/_components/AddMemberDialog.tsx +++ b/frontend/app/[team]/apps/[app]/access/members/_components/AddMemberDialog.tsx @@ -26,7 +26,11 @@ import { toast } from 'react-toastify' import { Avatar } from '@/components/common/Avatar' import { Alert } from '@/components/common/Alert' import Link from 'next/link' -import { unwrapEnvSecretsForUser, wrapEnvSecretsForAccount } from '@/utils/crypto' +import { + requireEnvironmentKey, + unwrapEnvSecretsForUser, + wrapEnvSecretsForAccount, +} from '@/utils/crypto' import GenericDialog from '@/components/common/GenericDialog' import { organisationContext } from '@/contexts/organisationContext' import { useAppPermissions } from '@/hooks/useAppPermissions' @@ -239,7 +243,7 @@ export const AddMemberDialog = ({ appId }: { appId: string }) => { wrappedSeed: userWrappedSeed, wrappedSalt: userWrappedSalt, identityKey, - } = data.environmentKeys[0] + } = requireEnvironmentKey(data.environmentKeys, env.name) const { seed, salt } = await unwrapEnvSecretsForUser( userWrappedSeed, diff --git a/frontend/app/[team]/apps/[app]/access/members/_components/ManageUserAccessDialog.tsx b/frontend/app/[team]/apps/[app]/access/members/_components/ManageUserAccessDialog.tsx index 834c4c833..8417ec6f9 100644 --- a/frontend/app/[team]/apps/[app]/access/members/_components/ManageUserAccessDialog.tsx +++ b/frontend/app/[team]/apps/[app]/access/members/_components/ManageUserAccessDialog.tsx @@ -21,7 +21,12 @@ import { userHasGlobalAccess } from '@/utils/access/permissions' import { useAppPermissions } from '@/hooks/useAppPermissions' import { Alert } from '@/components/common/Alert' import Link from 'next/link' -import { arraysEqual, unwrapEnvSecretsForUser, wrapEnvSecretsForAccount } from '@/utils/crypto' +import { + arraysEqual, + requireEnvironmentKey, + unwrapEnvSecretsForUser, + wrapEnvSecretsForAccount, +} from '@/utils/crypto' import GenericDialog from '@/components/common/GenericDialog' import { sortEnvs } from '@/utils/secrets' import { useSearchParams } from 'next/navigation' @@ -167,7 +172,7 @@ export const ManageUserAccessDialog = ({ wrappedSeed: userWrappedSeed, wrappedSalt: userWrappedSalt, identityKey, - } = data.environmentKeys[0] + } = requireEnvironmentKey(data.environmentKeys, env.name) const { seed, salt } = await unwrapEnvSecretsForUser( userWrappedSeed, diff --git a/frontend/app/[team]/apps/[app]/access/service-accounts/_components/AddAccountDialog.tsx b/frontend/app/[team]/apps/[app]/access/service-accounts/_components/AddAccountDialog.tsx index 3da658339..fcb9ebe51 100644 --- a/frontend/app/[team]/apps/[app]/access/service-accounts/_components/AddAccountDialog.tsx +++ b/frontend/app/[team]/apps/[app]/access/service-accounts/_components/AddAccountDialog.tsx @@ -28,7 +28,11 @@ import { KeyringContext } from '@/contexts/keyringContext' import { useAppPermissions } from '@/hooks/useAppPermissions' import { Alert } from '@/components/common/Alert' import Link from 'next/link' -import { unwrapEnvSecretsForUser, wrapEnvSecretsForAccount } from '@/utils/crypto' +import { + requireEnvironmentKey, + unwrapEnvSecretsForUser, + wrapEnvSecretsForAccount, +} from '@/utils/crypto' import { useSearchParams, useParams } from 'next/navigation' import GenericDialog from '@/components/common/GenericDialog' import { EmptyState } from '@/components/common/EmptyState' @@ -242,7 +246,7 @@ export const AddAccountDialog = ({ appId }: { appId: string }) => { wrappedSeed: userWrappedSeed, wrappedSalt: userWrappedSalt, identityKey, - } = data.environmentKeys[0] + } = requireEnvironmentKey(data.environmentKeys, env.name) const { seed, salt } = await unwrapEnvSecretsForUser( userWrappedSeed, diff --git a/frontend/app/[team]/apps/[app]/access/service-accounts/_components/ManageAccountAccessDialog.tsx b/frontend/app/[team]/apps/[app]/access/service-accounts/_components/ManageAccountAccessDialog.tsx index 04659ce63..82d9dbf97 100644 --- a/frontend/app/[team]/apps/[app]/access/service-accounts/_components/ManageAccountAccessDialog.tsx +++ b/frontend/app/[team]/apps/[app]/access/service-accounts/_components/ManageAccountAccessDialog.tsx @@ -23,7 +23,12 @@ import { userHasGlobalAccess } from '@/utils/access/permissions' import { useAppPermissions } from '@/hooks/useAppPermissions' import { Alert } from '@/components/common/Alert' import Link from 'next/link' -import { arraysEqual, unwrapEnvSecretsForUser, wrapEnvSecretsForAccount } from '@/utils/crypto' +import { + arraysEqual, + requireEnvironmentKey, + unwrapEnvSecretsForUser, + wrapEnvSecretsForAccount, +} from '@/utils/crypto' import GenericDialog from '@/components/common/GenericDialog' import { sortEnvs } from '@/utils/secrets' import { useSearchParams } from 'next/navigation' @@ -171,7 +176,7 @@ export const ManageAccountAccessDialog = ({ wrappedSeed: userWrappedSeed, wrappedSalt: userWrappedSalt, identityKey, - } = data.environmentKeys[0] + } = requireEnvironmentKey(data.environmentKeys, env.name) const { seed, salt } = await unwrapEnvSecretsForUser( userWrappedSeed, diff --git a/frontend/components/apps/EnableSSEDialog.tsx b/frontend/components/apps/EnableSSEDialog.tsx index 8f34fd288..c3406e1c2 100644 --- a/frontend/components/apps/EnableSSEDialog.tsx +++ b/frontend/components/apps/EnableSSEDialog.tsx @@ -15,7 +15,11 @@ import GetAppSyncStatus from '@/graphql/queries/syncing/getAppSyncStatus.gql' import { GetAppDetail } from '@/graphql/queries/getAppDetail.gql' import { FaServer } from 'react-icons/fa6' import { organisationContext } from '@/contexts/organisationContext' -import { unwrapEnvSecretsForUser, wrapEnvSecretsForServer } from '@/utils/crypto' +import { + requireEnvironmentKey, + unwrapEnvSecretsForUser, + wrapEnvSecretsForServer, +} from '@/utils/crypto' import { useAppPermissions } from '@/hooks/useAppPermissions' import Link from 'next/link' @@ -66,7 +70,7 @@ export const EnableSSEDialog = (props: { appId: string }) => { wrappedSeed: userWrappedSeed, wrappedSalt: userWrappedSalt, identityKey, - } = envKeyData.environmentKeys[0] + } = requireEnvironmentKey(envKeyData.environmentKeys, env.name) const { seed, salt } = await unwrapEnvSecretsForUser( userWrappedSeed, diff --git a/frontend/components/apps/tokens/CreateServiceTokenDialog.tsx b/frontend/components/apps/tokens/CreateServiceTokenDialog.tsx index 0807aaef2..9a37c1e1c 100644 --- a/frontend/components/apps/tokens/CreateServiceTokenDialog.tsx +++ b/frontend/components/apps/tokens/CreateServiceTokenDialog.tsx @@ -25,6 +25,7 @@ import { newServiceTokenKeys, splitSecret, getWrappedKeyShare, + requireEnvironmentKey, unwrapEnvSecretsForUser, wrapEnvSecretsForServiceToken, } from '@/utils/crypto' @@ -123,7 +124,7 @@ export const CreateServiceTokenDialog = (props: { organisationId: string; appId: wrappedSeed: userWrappedSeed, wrappedSalt: userWrappedSalt, identityKey, - } = data.environmentKeys[0] + } = requireEnvironmentKey(data.environmentKeys, env.name) const { seed, salt } = await unwrapEnvSecretsForUser( userWrappedSeed, diff --git a/frontend/components/environments/secrets/HistoryDialog.tsx b/frontend/components/environments/secrets/HistoryDialog.tsx index 9d14c4213..efd846b0b 100644 --- a/frontend/components/environments/secrets/HistoryDialog.tsx +++ b/frontend/components/environments/secrets/HistoryDialog.tsx @@ -16,6 +16,7 @@ import { getUserKxPrivateKey, decryptAsymmetric, envKeyring, + requireEnvironmentKey, } from '@/utils/crypto' import { KeyringContext } from '@/contexts/keyringContext' import Spinner from '@/components/common/Spinner' @@ -77,7 +78,10 @@ export const HistoryDialog = ({ }) if (data && keyring) { - const wrappedSeed = data.environmentKeys[0].wrappedSeed + const wrappedSeed = requireEnvironmentKey( + data.environmentKeys, + secret.environment.name + ).wrappedSeed const userKxKeys = { publicKey: await getUserKxPublicKey(keyring.publicKey), diff --git a/frontend/utils/crypto/environments.ts b/frontend/utils/crypto/environments.ts index 79c3839b4..09b5e991d 100644 --- a/frontend/utils/crypto/environments.ts +++ b/frontend/utils/crypto/environments.ts @@ -284,6 +284,38 @@ export const wrapEnvSecretsForServiceToken = async ( } } +/** + * Read the viewer's own environment key out of a `GetEnvironmentKey` result. + * + * `environmentKeys` resolves to a queryset filtered by member, so an empty list + * is a normal outcome rather than an error state: it means the acting member + * holds no key for this environment, e.g. they were given app access without + * that environment, or the environment was created after their keys were + * provisioned. Indexing `[0]` and destructuring it turns that case into + * "Cannot destructure property 'wrappedSeed' of 'undefined'" thrown from inside + * an async callback, which tells the user nothing and, at the call sites that + * do not catch, leaves the dialog looking inert. + * + * @param {EnvironmentKeyType[] | null | undefined} environmentKeys - `data.environmentKeys` from the query. + * @param {string} [envName] - Environment name, used to make the error actionable. + * @returns {EnvironmentKeyType} - The viewer's environment key. + * @throws {Error} - If the viewer holds no key for this environment. + */ +export const requireEnvironmentKey = ( + environmentKeys: EnvironmentKeyType[] | null | undefined, + envName?: string +): EnvironmentKeyType => { + const environmentKey = environmentKeys?.[0] + + if (!environmentKey) { + throw new Error( + `You don't have a key for the ${envName ?? 'selected'} environment, so it can't be shared. Ask an admin to grant you access to it and try again.` + ) + } + + return environmentKey +} + /** * Unwraps environment secrets for a user. *