From 33b3ac60ae278e45d75d4bd8bc4b5ee9c495a92f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 Sep 2023 20:48:29 +0000 Subject: [PATCH 01/33] Build(deps): bump cryptography from 41.0.2 to 41.0.4 in /backend Bumps [cryptography](https://github.com/pyca/cryptography) from 41.0.2 to 41.0.4. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/41.0.2...41.0.4) --- updated-dependencies: - dependency-name: cryptography dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 646126e7..c36f0b58 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,7 +9,7 @@ certifi==2023.7.22 cffi==1.15.1 charset-normalizer==3.0.1 constantly==15.1.0 -cryptography==41.0.2 +cryptography==41.0.4 defusedxml==0.7.1 dj-rest-auth==3.0.0 Django==4.2.3 From aac3f2c5fb48ff27a78ab712087509c8f709988d Mon Sep 17 00:00:00 2001 From: Nimish <85357445+nimish-ks@users.noreply.github.com> Date: Mon, 9 Oct 2023 12:21:11 +0530 Subject: [PATCH 02/33] Environments (#51) * fix: misc fixes to schema * feat: update graphql schema and types * chore: update graphql schema export command * fix: add identity key to env model * fix: misc fixes and updates to schema * chore: regenerate frontend schema and types * feat: add frontend queries and mutations * feat: crypto utils for asymmetric encryption * feat: env key utils * feat: add string encrypt and decrypt utils * feat: add secrets page to app layout * fix: env secret format * fix: pss_env format * feat: add secrets serializer * feat: add util to infer env from token * feat: add rest api for secrets * fix: misc cleanup and fixes * feat: pass requests on /secrets to backend * fix: prefix and version ciphertexts * fix: update wrapped value max length * chore: format on save * fix: rename env secret model to env token, add user token model * chore: update types and schema to use environmen token * chore: regenerate types and schema * fix: update queries, mutations and utils for environment tokens * feat: add util to generate user token * feat: add schema, types and resolvers for user tokens * chore: regenerate schema.graphql and types * feat: query and mutation for user tokens * feat: test user tokens * feat: add user token kms api, misc fixes * fix: add app context to env keys in token auth * fix: use salt when hashing keys, encode hashes in hex * fix: secrets api POST * fix: secrets api DELETE * fix: don't return deleted secrets in graphql resolver * fix: secrets api PUT * feat: modified user token response * changed userId to snaky snakes * refactor: frontend graphql setup * feat: create input type for environment mutation * feat: setup all three envs for app * fix: default env names * fix: reset version * fix: dont return keys for deleted apps * feat: user keyring context * fix: check that all secrets to be deleted exist * fix: order of checks * fix: allow querying specific environment * fix: misc fixes for secret mutations * chore: regenerate types and schema * fix: update default input style * feat: add some utils * feat: add queries and mutations * feat: update secrets overvew page, add environment page * fix: encrypting string util * fix: create org operation name * fix: update models and schema for tags * feat: update secrets query for new tags schema * feat: mutation and query to create and fetch tags * fix: misc fixes for tags * feat: allow querying email and username for org members * fix: get secret history users * feat: added encryption key to user token response * feat: add warning button variant * feat: misc ui improvements to keyring unlock dialog * feat: misc ui improvements to secrets screen * fix: use full screen width for secrets * fix: misc improvements to secret row ui * fix: active search query causes secret to appear modified * fix: focus styles * fix: misc fixes for light theme * fix: correctly infer common secret names accross envs * fix: col gap * feat: add danger button variant * feat: use danger button variant for deletes * fix: use danger button variant for inline delete button * fix: delete button light theme * fix: input buttons overlapping text * feat: create new secrets client side only, add polling * fix: keep tags in single line * fix: handle deleting new secrets that aren't deployed yet * feat: secrets breadcrumbs * fix: replace orange shades with amber * feat: decrypt history * feat: show changed properties in history dialog * fix: input bg * fix: input change handler, tag selector ui * fix: remove unused util * feat: service tokens * fix: allow service tokens to fetch secrets * fix: only check user access for user tokens * fix: handle secret events for service tokens * refactor: tokens tab * fix: reset dialog state when closed * fix: history log for service tokens * fix: return 403 for revoked tokens * feat: token expiry * feat: update copy and text color for expired tokens * fix: radiogroup comparison function * test: print timestamps to debug * fix: check for expired tokens in kms views * fix: expiry check for tokens with no expiry * fix: future timestamp math * refactor: use single endpoint for secret tokens * fix: misc ui / ux improvements to tokens screen * fix: calculate token expiry when creating tokens * fix: remove 2min expiry option * feat: add invite model, schema, types and operations * chore: regenerate schema and types * feat: add invites query and mutation * feat: add organisation context * feat: increase free app limit to 5 * feat: add role to invite model * feat: add invite validation query * chore: regenerate schema and types * feat: members page * feat: invite page * fix: misc tweaks to organisation context * fix: redirect to callback url on signin * fix: button titles * feat: add apps, custom delete method to org member model * feat: add create and delete org member mutations * chore: regenerate graphql schema * feat: create and delete org member mutations * fix: input ring style * fix: account for repeated words in list index * fix: remove redundant border * fix: restyle password strength bar * feat: restyle dark mode toggle * feat: new user onboarding flow * feat: add delete handler and ui to members page * fix: org member queries to account for soft deletes * fix: org member queries * feat: improve mode toggle styling * fix: misc styling fixes to account password input * fix: restyle sidebar * fix: add icons to mode toggle in onboarding navbar * feat: allow copying invite link from history, light theme fixes * feat: add success pane * fix: only return apps that a user has access too * fix: icon button color * fix: disable deploy button while save operation is in progress * fix: update role mutation * feat: update org member resolvers, add full name and avatar url * chore: regenerate types * feat: update role mutation * feat: query user full name and avatar * feat: add referrer to head to fix google avatar 403s * feat: allow updating user roles inline, restyle user row * feat: avatar component * feat: show user fullname and avatar in history * feat: user app management * chore: remove commented code * feat: simplified nginx config * feat: only allow admins to update member access * fix: only allow admins to delete users * fix: set identity key for new env keys * fix: hide app member form when all org memebrs are added * fix: only show service tokens to admins * fix: create keys for all admins when creating envs * fix: disable action buttons for org owner * refactor: move invites to members screen * fix: vertical overflow * fix: disable word wrapping for key names * fix: misc tweaks to invites * fix: remove member table bg * fix: conditional logic to show app user action buttons * fix: clean up console.log * feat: grant user access to all envs when given admin role * feat: better colors for alert * feat: disallow changing env scope for admins * feat: misc improvements to alert style * fix: disable save button when user is admin * fix: dialog layout * fix: misc ui improvements * fix: ui fixes to tokens dialog * fix: misc ui fixes and cleanup * feat: user tokens page, misc styling improvements * fix: wire app new app dialog with keyring context * feat: added SMTP config * feat: added login alert email template * feat: dispatch emails on sso based logins * fix: graphql mutation operation name * fix: operation name * fix: veryify invite in useEffect hook * Multi user (#57) * fix: clean up console.log * feat: grant user access to all envs when given admin role * feat: better colors for alert * feat: disallow changing env scope for admins * feat: misc improvements to alert style * fix: disable save button when user is admin * fix: dialog layout * fix: misc ui improvements * fix: ui fixes to tokens dialog * fix: misc ui fixes and cleanup * feat: user tokens page, misc styling improvements * fix: wire app new app dialog with keyring context * fix: graphql mutation operation name * fix: operation name * fix: veryify invite in useEffect hook --------- Co-authored-by: rohan-chaturvedi * refactor: emails * feat: grab client user agent and ip during login * fix: replace ph-backend with service * fix: typos * chore: removed applications from the license * fix: get encrypted keyring from backend * refactor: use org context to route user post login, correctly check local keyrings * fix: re-arrange providers * fix: validate org route param * feat: add organisation list and loading state to org context * feat: allow users with keyring on backend to login on new device with sudo pass * fix: allow all user roles to invite new members * fix: check local orgs by id and email * fix: misc bugfixes * feat: allow switching orgs * fix: styling fixes to user menu * fix: allow all users to revoke invites * fix: invite dialog max width * feat: encrypted recovery for new users * feat: restyle org selection screen * fix: invite dialog max width * feat: allow skipping recovery step for invited users * refactor: misc fixes, prevent duplicate local keyrings * fix: refactor settings page, allow viewing account recovery * fix: don't render account panel till activeOrg is truthy * fix: move service tokens above user tokens * feat: handle updating wrapped keyring and recovery * fix: validate localkeyring before mutation * fix: replace ad hoc queries with organisation context * fix: invite link * fix: check that apps is truthy before render * feat: increased nginx proxy buffer sizes * fix: not routing to onboard when logging in with no orgs * fix: misc bug fixes and UI for account recovery * fix: rename onboarding page to /signup * fix: misc ui fixes to sidebar * updated: signup and invite link recovery phase copy * fix: user token sorting * fix: user email for fallback when fullName is not available * fix: add app to creator app list * fix: wipe keyring context when switching between orgs * fix: remove user tokens from app tokens tab * feat: init app envs when creating app, correctly catch bad sudo pw exc * feat: add self property to org member type * fix: don't allow changing your own role when admin * fix: don't allow non admins to delete apps * fix: handle null selection in combobox * fix: hide settings tab for devs * fix: allow devs to view and create service tokens * fix: rename tokens in sidebar to user tokens * fix: render env listbox options in row * fix: workspace selection light theme * fix: horizontal attr for listbox * feat: secrets ui improvements (#61) * refactor: secrets overview page * chore: regenerate apollo types * feat: misc updates to secrets ui * fix: remove shadows * fix: bug when creating new secrets * fix: correctly update comment state when clicking out of dialog * fix: misc ui fixes * fix: value field z-index * fix: text area size * fix: only show envs menu when multiple envs are available * fix: add back logic to setup envs for legacy apps * feat: create example secrets by default for new apps * feat: add danger variant to alert * feat: update delete app dialog copy and ui * fix: misc cleanup * fix: only show invalid input ring when group-focused * feat: use secrets overview as app home * feat: show active env in breadcrumbs * fix: set starters switch default on only for first app * fix: upsell copy * feat: log read events with ip and user agent * refactor: app logs * fix: secret log paging * fix: env key query params * fix: only show KMS logs to org owners * fix: remove secret fields from log, link to secret in env * fix: bugs with app tabs * fix: scroll highlighted secret into view * fix: require one of envId or appId to query env keys * fix: link to highlight secret in env from overview page * fix: remove undefined query param * refactor: app card * fix: log when copying a value * fix: rest api secret reads * test: remove timestamp filter and sort from logs * test: print exceptions * test: print for debug * fix: logs null return in cloud hosted mode * fix: logs * feat: invite emails * feat: poll members and invites * fix: hostname * fix: invite link * fix: email invite link * fix: update email template * fix: invite link button style * feat: update starter secrets * fix: don't show reads in secret history * fix: rename user tokens to PATs --------- Co-authored-by: rohan-chaturvedi --- .env.dev.example | 2 +- .vscode/settings.json | 8 +- backend/README.md | 4 +- backend/api/emails.py | 78 + ...t_secret_secrettag_secretevent_and_more.py | 106 + ...t_token_environmentsecret_name_and_more.py | 60 + ..._remove_secretevent_collection_and_more.py | 27 + ...0020_remove_organisation_owner_and_more.py | 56 + .../0021_remove_secretevent_timestamp.py | 17 + .../migrations/0022_secretevent_timestamp.py | 20 + .../0023_environment_identity_key.py | 19 + ...alter_environment_wrapped_salt_and_more.py | 33 + ...onmentsecret_environmenttoken_usertoken.py | 33 + ...er_color_remove_secret_tags_secret_tags.py | 28 + ...move_secretfolder_color_secrettag_color.py | 23 + ...emove_secretevent_tags_secretevent_tags.py | 22 + backend/api/migrations/0029_servicetoken.py | 32 + .../migrations/0030_usertoken_expires_at.py | 18 + .../0031_organisationmemberinvite.py | 28 + .../0032_organisationmemberinvite_apps.py | 18 + .../0033_organisationmemberinvite_role.py | 18 + .../0034_organisationmember_apps.py | 29 + ...035_alter_organisationmember_deleted_at.py | 27 + .../0036_alter_organisationmember_apps.py | 18 + ...037_organisationmember_wrapped_recovery.py | 18 + ...event_ip_address_secretevent_user_agent.py | 23 + backend/api/models.py | 231 ++- backend/api/serializers.py | 113 +- .../backend/api/email_templates/invite.html | 149 ++ .../backend/api/email_templates/login.html | 90 + backend/api/utils.py | 68 +- backend/api/views.py | 338 ++- backend/backend/exceptions.py | 1 + backend/backend/graphene/mutations/app.py | 181 ++ .../backend/graphene/mutations/environment.py | 490 +++++ .../graphene/mutations/organisation.py | 209 ++ backend/backend/graphene/types.py | 206 ++ backend/backend/graphene/utils/permissions.py | 31 + backend/backend/schema.py | 468 +++-- backend/backend/settings.py | 12 +- backend/backend/urls.py | 4 +- backend/ee/LICENSE | 4 +- backend/ee/feature_flags.py | 9 +- docker-compose.yml | 2 +- frontend/.vscode/settings.json | 4 + frontend/apollo/gql.ts | 251 ++- frontend/apollo/graphql.ts | 1249 ++++++++++- .../apollo/mutations/createOrganisation.gql | 9 - frontend/apollo/queries/getAppLogCount.gql | 3 - frontend/apollo/queries/getAppLogs.gql | 13 - frontend/apollo/schema.graphql | 342 ++- .../[app]/environments/[environment]/page.tsx | 618 ++++++ frontend/app/[team]/apps/[app]/keys/page.tsx | 287 --- frontend/app/[team]/apps/[app]/layout.tsx | 77 +- frontend/app/[team]/apps/[app]/logs/page.tsx | 373 +--- .../app/[team]/apps/[app]/members/page.tsx | 930 +++++++++ .../[team]/apps/[app]/old-home.tsx.archive | 120 ++ frontend/app/[team]/apps/[app]/page.tsx | 544 ++++- .../app/[team]/apps/[app]/settings/page.tsx | 20 +- .../app/[team]/apps/[app]/tokens/page.tsx | 285 +++ frontend/app/[team]/apps/page.tsx | 26 +- frontend/app/[team]/layout.tsx | 29 +- frontend/app/[team]/members/page.tsx | 802 +++++++ frontend/app/[team]/newdevice/page.tsx | 251 ++- frontend/app/[team]/page.tsx | 14 +- frontend/app/[team]/settings/page.tsx | 225 +- frontend/app/[team]/tokens/page.tsx | 507 +++++ frontend/app/globals.css | 14 +- frontend/app/head.tsx | 1 + frontend/app/invite/[invite]/layout.tsx | 11 + frontend/app/invite/[invite]/page.tsx | 322 +++ frontend/app/page.tsx | 116 +- frontend/app/providers.tsx | 8 +- frontend/app/{onboard => signup}/head.tsx | 0 frontend/app/{onboard => signup}/layout.tsx | 0 frontend/app/{onboard => signup}/page.tsx | 12 +- frontend/codegen.ts | 4 +- frontend/components/UserMenu.tsx | 8 +- frontend/components/apps/AppActivityChart.tsx | 2 +- frontend/components/apps/AppCard.tsx | 106 +- frontend/components/apps/AppsHomeCard.tsx | 2 +- frontend/components/apps/DeleteAppDialog.tsx | 30 +- frontend/components/apps/NewAppDialog.tsx | 354 +++- .../components/apps/tokens/SecretTokens.tsx | 627 ++++++ frontend/components/auth/SignInButtons.tsx | 11 +- .../components/auth/UnlockKeyringDialog.tsx | 140 ++ frontend/components/common/Alert.tsx | 37 +- frontend/components/common/Avatar.tsx | 19 + frontend/components/common/Button.tsx | 4 + frontend/components/common/ModeToggle.tsx | 12 +- .../environments/SecretPropertyDiffs.tsx | 77 + .../components/environments/SecretRow.tsx | 674 ++++++ frontend/components/layout/Navbar.tsx | 70 +- .../components/layout/OnboardingNavbar.tsx | 7 +- frontend/components/layout/Sidebar.tsx | 158 +- frontend/components/logs/KmsLogs.tsx | 339 +++ frontend/components/logs/SecretLogs.tsx | 431 ++++ .../components/onboarding/AccountPassword.tsx | 126 +- .../onboarding/AccountSeedChecker.tsx | 2 +- .../components/onboarding/AccountSeedGen.tsx | 4 +- frontend/components/users/RoleLabel.tsx | 25 + frontend/contexts/keyringContext.tsx | 31 + frontend/contexts/organisationContext.tsx | 85 + frontend/ee/LICENSE | 4 +- .../graphql/mutations/apps/addAppMember.gql | 7 + .../mutations/apps/removeAppMember.gql | 7 + .../graphql/mutations/apps/updateEnvScope.gql | 7 + .../mutations/createApp.gql | 2 +- .../graphql/mutations/createOrganisation.gql | 9 + .../mutations/deleteApp.gql | 2 +- .../environments/createEnvironment.gql | 11 + .../environments/createEnvironmentKey.gql | 8 + .../environments/createEnvironmentToken.gql | 8 + .../mutations/environments/createSecret.gql | 10 + .../environments/createSecretTag.gql | 7 + .../environments/createServiceToken.gql | 9 + .../mutations/environments/deleteSecret.gql | 7 + .../environments/deleteServiceToken.gql | 5 + .../mutations/environments/editSecret.gql | 8 + .../environments/initAppEnvironments.gql | 33 + .../mutations/environments/readSecret.gql | 5 + .../mutations/organisation/acceptInvite.gql | 10 + .../mutations/organisation/deleteInvite.gql | 5 + .../organisation/deleteOrgMember.gql | 5 + .../organisation/inviteNewMember.gql | 7 + .../organisation/updateOrgMemberRole.gql | 8 + .../organisation/updateUserWrappedSecrets.gql | 7 + .../mutations/rotateAppKeys.gql | 2 +- .../mutations/users/createUserToken.gql | 5 + .../mutations/users/deleteUserToken.gql | 5 + .../graphql/queries/apps/getAppMembers.gql | 11 + .../queries/getAppActivityChart.gql | 0 .../queries/getAppDetail.gql | 0 frontend/graphql/queries/getAppKmsLogs.gql | 15 + .../{apollo => graphql}/queries/getApps.gql | 0 .../queries/getOrganisations.gql | 4 + .../queries/organisation/getInvites.gql | 13 + .../getOrganisationAdminsAndSelf.gql | 8 + .../organisation/getOrganisationMembers.gql | 12 + .../validateOrganisationInvite.gql | 18 + .../queries/secrets/getAppEnvironments.gql | 11 + .../queries/secrets/getAppSecretsLogs.gql | 45 + .../queries/secrets/getEnvironmentKey.gql | 8 + .../queries/secrets/getEnvironmentTokens.gql | 8 + .../graphql/queries/secrets/getSecretKVs.gql | 13 + .../graphql/queries/secrets/getSecretTags.gql | 7 + .../graphql/queries/secrets/getSecrets.gql | 48 + .../queries/secrets/getServiceTokens.gql | 17 + .../graphql/queries/users/getUserTokens.gql | 9 + frontend/package.json | 1 - frontend/pages/api/auth/[...nextauth].ts | 14 +- frontend/utils/auth.ts | 110 +- frontend/utils/crypto.ts | 172 ++ frontend/utils/environments.ts | 433 ++++ frontend/utils/localStorage.ts | 40 +- frontend/utils/permissions.ts | 2 + frontend/utils/tags.ts | 14 + frontend/utils/time.ts | 36 + frontend/yarn.lock | 1846 +++++++++++------ nginx/default.conf | 30 +- staging-docker-compose.yml | 2 +- 161 files changed, 15110 insertions(+), 2259 deletions(-) create mode 100644 backend/api/emails.py create mode 100644 backend/api/migrations/0017_environment_secret_secrettag_secretevent_and_more.py create mode 100644 backend/api/migrations/0018_rename_environment_token_environmentsecret_name_and_more.py create mode 100644 backend/api/migrations/0019_remove_secret_user_remove_secretevent_collection_and_more.py create mode 100644 backend/api/migrations/0020_remove_organisation_owner_and_more.py create mode 100644 backend/api/migrations/0021_remove_secretevent_timestamp.py create mode 100644 backend/api/migrations/0022_secretevent_timestamp.py create mode 100644 backend/api/migrations/0023_environment_identity_key.py create mode 100644 backend/api/migrations/0024_alter_environment_wrapped_salt_and_more.py create mode 100644 backend/api/migrations/0025_rename_environmentsecret_environmenttoken_usertoken.py create mode 100644 backend/api/migrations/0026_secretfolder_color_remove_secret_tags_secret_tags.py create mode 100644 backend/api/migrations/0027_remove_secretfolder_color_secrettag_color.py create mode 100644 backend/api/migrations/0028_remove_secretevent_tags_secretevent_tags.py create mode 100644 backend/api/migrations/0029_servicetoken.py create mode 100644 backend/api/migrations/0030_usertoken_expires_at.py create mode 100644 backend/api/migrations/0031_organisationmemberinvite.py create mode 100644 backend/api/migrations/0032_organisationmemberinvite_apps.py create mode 100644 backend/api/migrations/0033_organisationmemberinvite_role.py create mode 100644 backend/api/migrations/0034_organisationmember_apps.py create mode 100644 backend/api/migrations/0035_alter_organisationmember_deleted_at.py create mode 100644 backend/api/migrations/0036_alter_organisationmember_apps.py create mode 100644 backend/api/migrations/0037_organisationmember_wrapped_recovery.py create mode 100644 backend/api/migrations/0038_secretevent_ip_address_secretevent_user_agent.py create mode 100644 backend/api/templates/backend/api/email_templates/invite.html create mode 100644 backend/api/templates/backend/api/email_templates/login.html create mode 100644 backend/backend/graphene/mutations/app.py create mode 100644 backend/backend/graphene/mutations/environment.py create mode 100644 backend/backend/graphene/mutations/organisation.py create mode 100644 backend/backend/graphene/types.py create mode 100644 backend/backend/graphene/utils/permissions.py create mode 100644 frontend/.vscode/settings.json delete mode 100644 frontend/apollo/mutations/createOrganisation.gql delete mode 100644 frontend/apollo/queries/getAppLogCount.gql delete mode 100644 frontend/apollo/queries/getAppLogs.gql create mode 100644 frontend/app/[team]/apps/[app]/environments/[environment]/page.tsx delete mode 100644 frontend/app/[team]/apps/[app]/keys/page.tsx create mode 100644 frontend/app/[team]/apps/[app]/members/page.tsx create mode 100644 frontend/app/[team]/apps/[app]/old-home.tsx.archive create mode 100644 frontend/app/[team]/apps/[app]/tokens/page.tsx create mode 100644 frontend/app/[team]/members/page.tsx create mode 100644 frontend/app/[team]/tokens/page.tsx create mode 100644 frontend/app/invite/[invite]/layout.tsx create mode 100644 frontend/app/invite/[invite]/page.tsx rename frontend/app/{onboard => signup}/head.tsx (100%) rename frontend/app/{onboard => signup}/layout.tsx (100%) rename frontend/app/{onboard => signup}/page.tsx (94%) create mode 100644 frontend/components/apps/tokens/SecretTokens.tsx create mode 100644 frontend/components/auth/UnlockKeyringDialog.tsx create mode 100644 frontend/components/common/Avatar.tsx create mode 100644 frontend/components/environments/SecretPropertyDiffs.tsx create mode 100644 frontend/components/environments/SecretRow.tsx create mode 100644 frontend/components/logs/KmsLogs.tsx create mode 100644 frontend/components/logs/SecretLogs.tsx create mode 100644 frontend/components/users/RoleLabel.tsx create mode 100644 frontend/contexts/keyringContext.tsx create mode 100644 frontend/contexts/organisationContext.tsx create mode 100644 frontend/graphql/mutations/apps/addAppMember.gql create mode 100644 frontend/graphql/mutations/apps/removeAppMember.gql create mode 100644 frontend/graphql/mutations/apps/updateEnvScope.gql rename frontend/{apollo => graphql}/mutations/createApp.gql (94%) create mode 100644 frontend/graphql/mutations/createOrganisation.gql rename frontend/{apollo => graphql}/mutations/deleteApp.gql (58%) create mode 100644 frontend/graphql/mutations/environments/createEnvironment.gql create mode 100644 frontend/graphql/mutations/environments/createEnvironmentKey.gql create mode 100644 frontend/graphql/mutations/environments/createEnvironmentToken.gql create mode 100644 frontend/graphql/mutations/environments/createSecret.gql create mode 100644 frontend/graphql/mutations/environments/createSecretTag.gql create mode 100644 frontend/graphql/mutations/environments/createServiceToken.gql create mode 100644 frontend/graphql/mutations/environments/deleteSecret.gql create mode 100644 frontend/graphql/mutations/environments/deleteServiceToken.gql create mode 100644 frontend/graphql/mutations/environments/editSecret.gql create mode 100644 frontend/graphql/mutations/environments/initAppEnvironments.gql create mode 100644 frontend/graphql/mutations/environments/readSecret.gql create mode 100644 frontend/graphql/mutations/organisation/acceptInvite.gql create mode 100644 frontend/graphql/mutations/organisation/deleteInvite.gql create mode 100644 frontend/graphql/mutations/organisation/deleteOrgMember.gql create mode 100644 frontend/graphql/mutations/organisation/inviteNewMember.gql create mode 100644 frontend/graphql/mutations/organisation/updateOrgMemberRole.gql create mode 100644 frontend/graphql/mutations/organisation/updateUserWrappedSecrets.gql rename frontend/{apollo => graphql}/mutations/rotateAppKeys.gql (58%) create mode 100644 frontend/graphql/mutations/users/createUserToken.gql create mode 100644 frontend/graphql/mutations/users/deleteUserToken.gql create mode 100644 frontend/graphql/queries/apps/getAppMembers.gql rename frontend/{apollo => graphql}/queries/getAppActivityChart.gql (100%) rename frontend/{apollo => graphql}/queries/getAppDetail.gql (100%) create mode 100644 frontend/graphql/queries/getAppKmsLogs.gql rename frontend/{apollo => graphql}/queries/getApps.gql (100%) rename frontend/{apollo => graphql}/queries/getOrganisations.gql (68%) create mode 100644 frontend/graphql/queries/organisation/getInvites.gql create mode 100644 frontend/graphql/queries/organisation/getOrganisationAdminsAndSelf.gql create mode 100644 frontend/graphql/queries/organisation/getOrganisationMembers.gql create mode 100644 frontend/graphql/queries/organisation/validateOrganisationInvite.gql create mode 100644 frontend/graphql/queries/secrets/getAppEnvironments.gql create mode 100644 frontend/graphql/queries/secrets/getAppSecretsLogs.gql create mode 100644 frontend/graphql/queries/secrets/getEnvironmentKey.gql create mode 100644 frontend/graphql/queries/secrets/getEnvironmentTokens.gql create mode 100644 frontend/graphql/queries/secrets/getSecretKVs.gql create mode 100644 frontend/graphql/queries/secrets/getSecretTags.gql create mode 100644 frontend/graphql/queries/secrets/getSecrets.gql create mode 100644 frontend/graphql/queries/secrets/getServiceTokens.gql create mode 100644 frontend/graphql/queries/users/getUserTokens.gql create mode 100644 frontend/utils/crypto.ts create mode 100644 frontend/utils/environments.ts create mode 100644 frontend/utils/permissions.ts create mode 100644 frontend/utils/tags.ts diff --git a/.env.dev.example b/.env.dev.example index 1555b9e9..0a08184b 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -15,7 +15,7 @@ HTTP_PROTOCOL=https:// NEXTAUTH_URL=https://localhost OAUTH_REDIRECT_URI=https://localhost BACKEND_API_BASE=http://backend:8000 -NEXT_PUBLIC_BACKEND_API_BASE=https://localhost/ph-backend +NEXT_PUBLIC_BACKEND_API_BASE=https://localhost/service NEXT_PUBLIC_NEXTAUTH_PROVIDERS=google,github,gitlab # WARNING: Replace this with a cryptographically strong random value. You can use `openssl rand -hex 32` to generate this. diff --git a/.vscode/settings.json b/.vscode/settings.json index 1a821336..765df626 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,7 @@ "package-lock.json": true }, "editor.defaultFormatter": "dbaeumer.vscode-eslint", - "editor.formatOnSave": false, + "editor.formatOnSave": true, "editor.codeActionsOnSave": [ "source.addMissingImports", "source.fixAll.eslint" @@ -21,5 +21,9 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "prettier.ignorePath": ".gitignore" // Don't run prettier for files listed in .gitignore + "prettier.ignorePath": ".gitignore", + "[python]": { + "editor.defaultFormatter": "ms-python.autopep8", + "editor.formatOnSave": true + } // Don't run prettier for files listed in .gitignore } diff --git a/backend/README.md b/backend/README.md index 24bfad39..99d9b9be 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,9 +1,9 @@ # Phase Console - Backend -Python Django REST api + Postgres +Django + Graphene + DRF ### Generate graphql schema for frontend ```bash -./manage.py graphql_schema --schema backend.schema.schema --out ../dashboard/apollo/schema.graphql +./manage.py graphql_schema --schema backend.schema.schema --out ../frontend/apollo/schema.graphql ``` diff --git a/backend/api/emails.py b/backend/api/emails.py new file mode 100644 index 00000000..e68c0180 --- /dev/null +++ b/backend/api/emails.py @@ -0,0 +1,78 @@ +from django.conf import settings +from django.core.mail import send_mail +from django.template.loader import render_to_string +from datetime import datetime +import os +from api.utils import encode_string_to_base64, get_client_ip + + +def send_email(subject, recipient_list, template_name, context): + """ + Send email via SMTP gateway through Django's email backend. + """ + # Load the template + email_html_message = render_to_string(template_name, context) + + # Get the DEFAULT_FROM_EMAIL from settings + default_from_email = getattr(settings, "DEFAULT_FROM_EMAIL") + + # Send the email + send_mail( + subject, + '', # plain text content can be empty as we're sending HTML + default_from_email, + recipient_list, + html_message=email_html_message + ) + + +def send_login_email(request, email): + user_agent = request.META.get('HTTP_USER_AGENT', 'Unknown') + ip_address = get_client_ip(request) + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + # Creating context dictionary + context = { + 'auth': 'GitHub', + 'email': email, + 'ip': ip_address, + 'user_agent': user_agent, + 'timestamp': timestamp + } + + send_email( + 'New Login Alert - Phase Console', + [email], + 'backend/api/email_templates/login.html', + context + ) + + +def send_inite_email(invite): + organisation = invite.organisation.name + + invited_by_social_acc = invite.invited_by.user.socialaccount_set.first() + + name = invited_by_social_acc.extra_data.get('name') + + if name is not None: + invited_by_name = name + else: + invited_by_name = invite.invited_by.user.email + + invite_code = encode_string_to_base64(str(invite.id)) + + invite_link = f"{os.getenv('ALLOWED_ORIGINS')}/invite/{invite_code}" + + context = { + 'organisation': organisation, + 'invited_by': invited_by_name, + 'invite_link': invite_link + } + + send_email( + f"Invite - {organisation} on Phase", + [invite.invitee_email], + 'backend/api/email_templates/invite.html', + context + ) diff --git a/backend/api/migrations/0017_environment_secret_secrettag_secretevent_and_more.py b/backend/api/migrations/0017_environment_secret_secrettag_secretevent_and_more.py new file mode 100644 index 00000000..f9a0c114 --- /dev/null +++ b/backend/api/migrations/0017_environment_secret_secrettag_secretevent_and_more.py @@ -0,0 +1,106 @@ +# Generated by Django 4.2.3 on 2023-07-31 10:52 + +from django.conf import settings +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0016_organisation_plan'), + ] + + operations = [ + migrations.CreateModel( + name='Environment', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('env_type', models.CharField(choices=[('dev', 'Development'), ('staging', 'Staging'), ('prod', 'Production')], default='dev', max_length=7)), + ('wrapped_seed', models.CharField(max_length=208)), + ('wrapped_salt', models.CharField(max_length=208)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('is_deleted', models.BooleanField(default=False)), + ('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.app')), + ], + ), + migrations.CreateModel( + name='Secret', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('collection', models.TextField(blank=True, null=True)), + ('key', models.TextField()), + ('key_digest', models.TextField()), + ('value', models.TextField()), + ('version', models.IntegerField(default=1)), + ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=64), size=10)), + ('comment', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('environment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.environment')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='SecretTag', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.organisation')), + ], + ), + migrations.CreateModel( + name='SecretEvent', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('collection', models.TextField(blank=True, null=True)), + ('key', models.TextField()), + ('key_digest', models.TextField()), + ('value', models.TextField()), + ('version', models.IntegerField(default=1)), + ('tags', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=64), size=10)), + ('comment', models.TextField()), + ('event_type', models.CharField(choices=[('C', 'Create'), ('R', 'Read'), ('U', 'Update'), ('D', 'Delete')], default='C', max_length=1)), + ('timestamp', models.BigIntegerField()), + ('environment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.environment')), + ('secret', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.secret')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='EnvironmentSecret', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('identity_key', models.CharField(max_length=256)), + ('environment_token', models.CharField(max_length=64)), + ('wrapped_key_share', models.CharField(max_length=406)), + ('token', models.CharField(max_length=64)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('environment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.environment')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='EnvironmentKey', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('identity_key', models.CharField(max_length=256)), + ('environment_token', models.CharField(max_length=64)), + ('wrapped_seed', models.CharField(max_length=208)), + ('wrapped_salt', models.CharField(max_length=208)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('environment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.environment')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/api/migrations/0018_rename_environment_token_environmentsecret_name_and_more.py b/backend/api/migrations/0018_rename_environment_token_environmentsecret_name_and_more.py new file mode 100644 index 00000000..1f098d87 --- /dev/null +++ b/backend/api/migrations/0018_rename_environment_token_environmentsecret_name_and_more.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.3 on 2023-08-01 07:57 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0017_environment_secret_secrettag_secretevent_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='environmentsecret', + old_name='environment_token', + new_name='name', + ), + migrations.RemoveField( + model_name='environmentkey', + name='environment_token', + ), + migrations.RemoveField( + model_name='secret', + name='collection', + ), + migrations.AddField( + model_name='secrettag', + name='created_at', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='secrettag', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='secrettag', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + migrations.CreateModel( + name='SecretFolder', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('environment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.environment')), + ('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.secretfolder')), + ], + ), + migrations.AddField( + model_name='secret', + name='folder', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.secretfolder'), + ), + ] diff --git a/backend/api/migrations/0019_remove_secret_user_remove_secretevent_collection_and_more.py b/backend/api/migrations/0019_remove_secret_user_remove_secretevent_collection_and_more.py new file mode 100644 index 00000000..cc4af583 --- /dev/null +++ b/backend/api/migrations/0019_remove_secret_user_remove_secretevent_collection_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.3 on 2023-08-02 07:03 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0018_rename_environment_token_environmentsecret_name_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='secret', + name='user', + ), + migrations.RemoveField( + model_name='secretevent', + name='collection', + ), + migrations.AddField( + model_name='secretevent', + name='folder', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='api.secretfolder'), + ), + ] diff --git a/backend/api/migrations/0020_remove_organisation_owner_and_more.py b/backend/api/migrations/0020_remove_organisation_owner_and_more.py new file mode 100644 index 00000000..f0a16b88 --- /dev/null +++ b/backend/api/migrations/0020_remove_organisation_owner_and_more.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.3 on 2023-08-04 09:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + +def migrate_org_owners(apps, schema_editor): + OrgModel = apps.get_model('api', 'Organisation') + OrgMemberModel = apps.get_model('api', 'OrganisationMember') + + for org in OrgModel.objects.all(): + OrgMemberModel.objects.create(user=org.owner, organisation=org, role='owner', identity_key=org.identity_key, created_at=org.created_at) + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0019_remove_secret_user_remove_secretevent_collection_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='OrganisationMember', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('dev', 'Developer')], default='dev', max_length=5)), + ('identity_key', models.CharField(blank=True, max_length=256, null=True)), + ('wrapped_keyring', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(auto_now=True)), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='users', to='api.organisation')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organisation', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterField( + model_name='environmentkey', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.organisationmember'), + ), + migrations.AlterField( + model_name='environmentsecret', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.organisationmember'), + ), + migrations.AlterField( + model_name='secretevent', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.organisationmember'), + ), + migrations.RunPython(migrate_org_owners), + migrations.RemoveField( + model_name='organisation', + name='owner', + ), + ] diff --git a/backend/api/migrations/0021_remove_secretevent_timestamp.py b/backend/api/migrations/0021_remove_secretevent_timestamp.py new file mode 100644 index 00000000..936f7281 --- /dev/null +++ b/backend/api/migrations/0021_remove_secretevent_timestamp.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.3 on 2023-08-04 09:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0020_remove_organisation_owner_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='secretevent', + name='timestamp', + ), + ] diff --git a/backend/api/migrations/0022_secretevent_timestamp.py b/backend/api/migrations/0022_secretevent_timestamp.py new file mode 100644 index 00000000..ca51b369 --- /dev/null +++ b/backend/api/migrations/0022_secretevent_timestamp.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.3 on 2023-08-04 09:54 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0021_remove_secretevent_timestamp'), + ] + + operations = [ + migrations.AddField( + model_name='secretevent', + name='timestamp', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + ] diff --git a/backend/api/migrations/0023_environment_identity_key.py b/backend/api/migrations/0023_environment_identity_key.py new file mode 100644 index 00000000..9724ddf2 --- /dev/null +++ b/backend/api/migrations/0023_environment_identity_key.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.3 on 2023-08-09 13:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0022_secretevent_timestamp'), + ] + + operations = [ + migrations.AddField( + model_name='environment', + name='identity_key', + field=models.CharField(default='', max_length=256), + preserve_default=False, + ), + ] diff --git a/backend/api/migrations/0024_alter_environment_wrapped_salt_and_more.py b/backend/api/migrations/0024_alter_environment_wrapped_salt_and_more.py new file mode 100644 index 00000000..346581de --- /dev/null +++ b/backend/api/migrations/0024_alter_environment_wrapped_salt_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.3 on 2023-08-12 09:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0023_environment_identity_key'), + ] + + operations = [ + migrations.AlterField( + model_name='environment', + name='wrapped_salt', + field=models.CharField(max_length=256), + ), + migrations.AlterField( + model_name='environment', + name='wrapped_seed', + field=models.CharField(max_length=256), + ), + migrations.AlterField( + model_name='environmentkey', + name='wrapped_salt', + field=models.CharField(max_length=256), + ), + migrations.AlterField( + model_name='environmentkey', + name='wrapped_seed', + field=models.CharField(max_length=256), + ), + ] diff --git a/backend/api/migrations/0025_rename_environmentsecret_environmenttoken_usertoken.py b/backend/api/migrations/0025_rename_environmentsecret_environmenttoken_usertoken.py new file mode 100644 index 00000000..7b70a0e3 --- /dev/null +++ b/backend/api/migrations/0025_rename_environmentsecret_environmenttoken_usertoken.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.3 on 2023-08-12 10:28 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0024_alter_environment_wrapped_salt_and_more'), + ] + + operations = [ + migrations.RenameModel( + old_name='EnvironmentSecret', + new_name='EnvironmentToken', + ), + migrations.CreateModel( + name='UserToken', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('identity_key', models.CharField(max_length=256)), + ('token', models.CharField(max_length=64)), + ('wrapped_key_share', models.CharField(max_length=406)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.organisationmember')), + ], + ), + ] diff --git a/backend/api/migrations/0026_secretfolder_color_remove_secret_tags_secret_tags.py b/backend/api/migrations/0026_secretfolder_color_remove_secret_tags_secret_tags.py new file mode 100644 index 00000000..85430e62 --- /dev/null +++ b/backend/api/migrations/0026_secretfolder_color_remove_secret_tags_secret_tags.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.3 on 2023-08-29 08:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0025_rename_environmentsecret_environmenttoken_usertoken'), + ] + + operations = [ + migrations.AddField( + model_name='secretfolder', + name='color', + field=models.CharField(default='', max_length=64), + preserve_default=False, + ), + migrations.RemoveField( + model_name='secret', + name='tags', + ), + migrations.AddField( + model_name='secret', + name='tags', + field=models.ManyToManyField(to='api.secrettag'), + ), + ] diff --git a/backend/api/migrations/0027_remove_secretfolder_color_secrettag_color.py b/backend/api/migrations/0027_remove_secretfolder_color_secrettag_color.py new file mode 100644 index 00000000..b2fc0597 --- /dev/null +++ b/backend/api/migrations/0027_remove_secretfolder_color_secrettag_color.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.3 on 2023-08-29 08:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0026_secretfolder_color_remove_secret_tags_secret_tags'), + ] + + operations = [ + migrations.RemoveField( + model_name='secretfolder', + name='color', + ), + migrations.AddField( + model_name='secrettag', + name='color', + field=models.CharField(default='', max_length=64), + preserve_default=False, + ), + ] diff --git a/backend/api/migrations/0028_remove_secretevent_tags_secretevent_tags.py b/backend/api/migrations/0028_remove_secretevent_tags_secretevent_tags.py new file mode 100644 index 00000000..0b20cd75 --- /dev/null +++ b/backend/api/migrations/0028_remove_secretevent_tags_secretevent_tags.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.3 on 2023-08-29 08:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0027_remove_secretfolder_color_secrettag_color'), + ] + + operations = [ + migrations.RemoveField( + model_name='secretevent', + name='tags', + ), + migrations.AddField( + model_name='secretevent', + name='tags', + field=models.ManyToManyField(to='api.secrettag'), + ), + ] diff --git a/backend/api/migrations/0029_servicetoken.py b/backend/api/migrations/0029_servicetoken.py new file mode 100644 index 00000000..19e103a4 --- /dev/null +++ b/backend/api/migrations/0029_servicetoken.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.3 on 2023-09-06 08:34 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0028_remove_secretevent_tags_secretevent_tags'), + ] + + operations = [ + migrations.CreateModel( + name='ServiceToken', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('identity_key', models.CharField(max_length=256)), + ('token', models.CharField(max_length=64)), + ('wrapped_key_share', models.CharField(max_length=406)), + ('name', models.CharField(max_length=64)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('expires_at', models.DateTimeField(null=True)), + ('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.app')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.organisationmember')), + ('keys', models.ManyToManyField(to='api.environmentkey')), + ], + ), + ] diff --git a/backend/api/migrations/0030_usertoken_expires_at.py b/backend/api/migrations/0030_usertoken_expires_at.py new file mode 100644 index 00000000..96558b80 --- /dev/null +++ b/backend/api/migrations/0030_usertoken_expires_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.3 on 2023-09-09 09:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0029_servicetoken'), + ] + + operations = [ + migrations.AddField( + model_name='usertoken', + name='expires_at', + field=models.DateTimeField(null=True), + ), + ] diff --git a/backend/api/migrations/0031_organisationmemberinvite.py b/backend/api/migrations/0031_organisationmemberinvite.py new file mode 100644 index 00000000..98b901bb --- /dev/null +++ b/backend/api/migrations/0031_organisationmemberinvite.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.3 on 2023-09-12 08:18 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0030_usertoken_expires_at'), + ] + + operations = [ + migrations.CreateModel( + name='OrganisationMemberInvite', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('invitee_email', models.EmailField(max_length=254)), + ('valid', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('expires_at', models.DateTimeField()), + ('invited_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.organisationmember')), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='api.organisation')), + ], + ), + ] diff --git a/backend/api/migrations/0032_organisationmemberinvite_apps.py b/backend/api/migrations/0032_organisationmemberinvite_apps.py new file mode 100644 index 00000000..68f7a410 --- /dev/null +++ b/backend/api/migrations/0032_organisationmemberinvite_apps.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.3 on 2023-09-12 13:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0031_organisationmemberinvite'), + ] + + operations = [ + migrations.AddField( + model_name='organisationmemberinvite', + name='apps', + field=models.ManyToManyField(to='api.app'), + ), + ] diff --git a/backend/api/migrations/0033_organisationmemberinvite_role.py b/backend/api/migrations/0033_organisationmemberinvite_role.py new file mode 100644 index 00000000..31b73a6a --- /dev/null +++ b/backend/api/migrations/0033_organisationmemberinvite_role.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.3 on 2023-09-14 08:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0032_organisationmemberinvite_apps'), + ] + + operations = [ + migrations.AddField( + model_name='organisationmemberinvite', + name='role', + field=models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('dev', 'Developer')], default='dev', max_length=5), + ), + ] diff --git a/backend/api/migrations/0034_organisationmember_apps.py b/backend/api/migrations/0034_organisationmember_apps.py new file mode 100644 index 00000000..fbbd2642 --- /dev/null +++ b/backend/api/migrations/0034_organisationmember_apps.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.3 on 2023-09-15 13:19 + +from django.db import migrations, models + + +def set_org_member_apps(apps, schema_editor): + OrgMemberModel = apps.get_model('api', 'OrganisationMember') + AppModel = apps.get_model('api', 'App') + + for org_member in OrgMemberModel.objects.all(): + org_apps = AppModel.objects.filter( + organisation=org_member.organisation, is_deleted=False) + org_member.apps.set(org_apps) + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0033_organisationmemberinvite_role'), + ] + + operations = [ + migrations.AddField( + model_name='organisationmember', + name='apps', + field=models.ManyToManyField(to='api.app'), + ), + migrations.RunPython(set_org_member_apps) + ] diff --git a/backend/api/migrations/0035_alter_organisationmember_deleted_at.py b/backend/api/migrations/0035_alter_organisationmember_deleted_at.py new file mode 100644 index 00000000..222284eb --- /dev/null +++ b/backend/api/migrations/0035_alter_organisationmember_deleted_at.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.3 on 2023-09-16 08:50 + +from django.db import migrations, models + + +def reset_deleted_field(apps, schema_editor): + OrgMemberModel = apps.get_model('api', 'OrganisationMember') + + for org_member in OrgMemberModel.objects.all(): + org_member.deleted_at = None + org_member.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0034_organisationmember_apps'), + ] + + operations = [ + migrations.AlterField( + model_name='organisationmember', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.RunPython(reset_deleted_field) + ] diff --git a/backend/api/migrations/0036_alter_organisationmember_apps.py b/backend/api/migrations/0036_alter_organisationmember_apps.py new file mode 100644 index 00000000..e666c0f3 --- /dev/null +++ b/backend/api/migrations/0036_alter_organisationmember_apps.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.3 on 2023-09-20 06:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0035_alter_organisationmember_deleted_at'), + ] + + operations = [ + migrations.AlterField( + model_name='organisationmember', + name='apps', + field=models.ManyToManyField(related_name='members', to='api.app'), + ), + ] diff --git a/backend/api/migrations/0037_organisationmember_wrapped_recovery.py b/backend/api/migrations/0037_organisationmember_wrapped_recovery.py new file mode 100644 index 00000000..22f22588 --- /dev/null +++ b/backend/api/migrations/0037_organisationmember_wrapped_recovery.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.3 on 2023-09-27 08:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0036_alter_organisationmember_apps'), + ] + + operations = [ + migrations.AddField( + model_name='organisationmember', + name='wrapped_recovery', + field=models.TextField(blank=True), + ), + ] diff --git a/backend/api/migrations/0038_secretevent_ip_address_secretevent_user_agent.py b/backend/api/migrations/0038_secretevent_ip_address_secretevent_user_agent.py new file mode 100644 index 00000000..8c4d6242 --- /dev/null +++ b/backend/api/migrations/0038_secretevent_ip_address_secretevent_user_agent.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.3 on 2023-10-05 15:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0037_organisationmember_wrapped_recovery'), + ] + + operations = [ + migrations.AddField( + model_name='secretevent', + name='ip_address', + field=models.GenericIPAddressField(blank=True, null=True), + ), + migrations.AddField( + model_name='secretevent', + name='user_agent', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index c5f50c7d..f0d05a3c 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -1,13 +1,15 @@ from django.db import models +from django.contrib.postgres.fields import ArrayField from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin from uuid import uuid4 from backend.api.kv import write import json - +from django.utils import timezone from django.conf import settings CLOUD_HOSTED = settings.APP_HOST == 'cloud' + class CustomUserManager(BaseUserManager): def create_user(self, username, email, password=None): """ @@ -72,10 +74,8 @@ class Organisation(models.Model): (PRO_PLAN, 'Pro'), (ENTERPRISE_PLAN, 'Enterprise') ] - + id = models.TextField(default=uuid4, primary_key=True, editable=False) - owner = models.ForeignKey( - CustomUser, related_name='organisation', on_delete=models.CASCADE) name = models.CharField(max_length=64, unique=True) identity_key = models.CharField(max_length=256) created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) @@ -85,7 +85,7 @@ class Organisation(models.Model): choices=PLAN_TIERS, default=FREE_PLAN, ) - list_display = ('owner', 'name', 'identity_key', 'id') + list_display = ('name', 'identity_key', 'id') def __str__(self): return self.name @@ -94,7 +94,7 @@ def __str__(self): class App(models.Model): id = models.TextField(default=uuid4, primary_key=True, editable=False) organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE) - name = name = models.CharField(max_length=64) + name = models.CharField(max_length=64) identity_key = models.CharField(max_length=256) app_version = models.IntegerField(null=False, blank=False, default=1) app_token = models.CharField(max_length=64) @@ -122,3 +122,222 @@ def save(self, *args, **kwargs): def __str__(self): return self.name + + +class OrganisationMember(models.Model): + OWNER = 'owner' + ADMIN = 'admin' + DEVELOPER = 'dev' + + USER_ROLES = [ + (OWNER, 'Owner'), + (ADMIN, 'Admin'), + (DEVELOPER, 'Developer') + ] + + id = models.TextField(default=uuid4, primary_key=True, editable=False) + user = models.ForeignKey( + CustomUser, related_name='organisation', on_delete=models.CASCADE) + organisation = models.ForeignKey( + Organisation, related_name='users', on_delete=models.CASCADE) + role = models.CharField( + max_length=5, + choices=USER_ROLES, + default=DEVELOPER, + ) + apps = models.ManyToManyField(App, related_name='members') + identity_key = models.CharField(max_length=256, null=True, blank=True) + wrapped_keyring = models.TextField(blank=True) + wrapped_recovery = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(null=True, blank=True) + + def delete(self, *args, **kwargs): + """ + Soft delete the object by setting the 'deleted_at' field. + """ + self.deleted_at = timezone.now() + self.save() + + +class OrganisationMemberInvite(models.Model): + id = models.TextField(default=uuid4, primary_key=True, editable=False) + organisation = models.ForeignKey( + Organisation, related_name='invites', on_delete=models.CASCADE) + apps = models.ManyToManyField(App) + role = models.CharField( + max_length=5, + choices=OrganisationMember.USER_ROLES, + default=OrganisationMember.DEVELOPER, + ) + invited_by = models.ForeignKey( + OrganisationMember, on_delete=models.CASCADE) + invitee_email = models.EmailField() + valid = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + expires_at = models.DateTimeField() + + +class Environment(models.Model): + + DEVELOPMENT = "dev" + STAGING = "staging" + PRODUCTION = "prod" + + ENV_TYPES = [ + (DEVELOPMENT, 'Development'), + (STAGING, 'Staging'), + (PRODUCTION, 'Production') + ] + + id = models.TextField(default=uuid4, primary_key=True, editable=False) + app = models.ForeignKey(App, on_delete=models.CASCADE) + name = models.CharField(max_length=64) + env_type = models.CharField( + max_length=7, + choices=ENV_TYPES, + default=DEVELOPMENT, + ) + identity_key = models.CharField(max_length=256) + wrapped_seed = models.CharField(max_length=256) + wrapped_salt = models.CharField(max_length=256) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(blank=True, null=True) + is_deleted = models.BooleanField(default=False) + + +class EnvironmentKey(models.Model): + id = models.TextField(default=uuid4, primary_key=True, editable=False) + environment = models.ForeignKey(Environment, on_delete=models.CASCADE) + user = models.ForeignKey( + OrganisationMember, on_delete=models.CASCADE, blank=True, null=True) + identity_key = models.CharField(max_length=256) + wrapped_seed = models.CharField(max_length=256) + wrapped_salt = models.CharField(max_length=256) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(blank=True, null=True) + + def delete(self, *args, **kwargs): + self.deleted_at = timezone.now() + self.save() + + +class EnvironmentToken(models.Model): + id = models.TextField(default=uuid4, primary_key=True, editable=False) + environment = models.ForeignKey(Environment, on_delete=models.CASCADE) + user = models.ForeignKey( + OrganisationMember, on_delete=models.CASCADE, blank=True, null=True) + name = models.CharField(max_length=64) + identity_key = models.CharField(max_length=256) + token = models.CharField(max_length=64) + wrapped_key_share = models.CharField(max_length=406) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(blank=True, null=True) + + +class ServiceToken(models.Model): + id = models.TextField(default=uuid4, primary_key=True, editable=False) + app = models.ForeignKey(App, on_delete=models.CASCADE) + keys = models.ManyToManyField(EnvironmentKey) + identity_key = models.CharField(max_length=256) + token = models.CharField(max_length=64) + wrapped_key_share = models.CharField(max_length=406) + name = models.CharField(max_length=64) + created_by = models.ForeignKey( + OrganisationMember, on_delete=models.CASCADE, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(blank=True, null=True) + expires_at = models.DateTimeField(null=True) + + +class UserToken(models.Model): + id = models.TextField(default=uuid4, primary_key=True, editable=False) + user = models.ForeignKey( + OrganisationMember, on_delete=models.CASCADE, blank=True, null=True) + name = models.CharField(max_length=64) + identity_key = models.CharField(max_length=256) + token = models.CharField(max_length=64) + wrapped_key_share = models.CharField(max_length=406) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(blank=True, null=True) + expires_at = models.DateTimeField(null=True) + + +class SecretFolder(models.Model): + id = models.TextField(default=uuid4, primary_key=True, editable=False) + environment = models.ForeignKey(Environment, on_delete=models.CASCADE) + parent = models.ForeignKey('self', on_delete=models.CASCADE) + name = models.CharField(max_length=64) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(blank=True, null=True) + + +class SecretTag(models.Model): + id = models.TextField(default=uuid4, primary_key=True, editable=False) + organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE) + name = models.CharField(max_length=64) + color = models.CharField(max_length=64) + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(blank=True, null=True) + + +class Secret(models.Model): + id = models.TextField(default=uuid4, primary_key=True, editable=False) + environment = models.ForeignKey(Environment, on_delete=models.CASCADE) + folder = models.ForeignKey( + SecretFolder, on_delete=models.CASCADE, null=True) + key = models.TextField() + key_digest = models.TextField() + value = models.TextField() + version = models.IntegerField(default=1) + tags = models.ManyToManyField(SecretTag) + comment = models.TextField() + created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(blank=True, null=True) + + +class SecretEvent(models.Model): + + CREATE = "C" + READ = "R" + UPDATE = "U" + DELETE = "D" + + EVENT_TYPES = [ + (CREATE, 'Create'), + (READ, 'Read'), + (UPDATE, 'Update'), + (DELETE, 'Delete') + ] + + id = models.TextField(default=uuid4, primary_key=True, editable=False) + secret = models.ForeignKey(Secret, on_delete=models.CASCADE) + environment = models.ForeignKey(Environment, on_delete=models.CASCADE) + folder = models.ForeignKey( + SecretFolder, on_delete=models.CASCADE, null=True) + user = models.ForeignKey( + OrganisationMember, on_delete=models.SET_NULL, blank=True, null=True) + key = models.TextField() + key_digest = models.TextField() + value = models.TextField() + version = models.IntegerField(default=1) + tags = models.ManyToManyField(SecretTag) + comment = models.TextField() + event_type = models.CharField( + max_length=1, + choices=EVENT_TYPES, + default=CREATE, + ) + timestamp = models.DateTimeField(auto_now_add=True) + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.TextField(null=True, blank=True) diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 365174f9..a3e44c36 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -1,8 +1,15 @@ -from rest_framework.serializers import ModelSerializer -from .models import CustomUser, Organisation +from rest_framework import serializers +from .models import CustomUser, Environment, EnvironmentKey, Organisation, Secret, ServiceToken, UserToken -class CustomUserSerializer(ModelSerializer): +def find_index_by_id(dictionaries, target_id): + for index, dictionary in enumerate(dictionaries): + if dictionary.get('id') == target_id: + return index + return -1 + + +class CustomUserSerializer(serializers.ModelSerializer): class Meta: model = CustomUser fields = [ @@ -22,10 +29,108 @@ def create(self, validated_data): return user -class OrganisationSerializer(ModelSerializer): +class OrganisationSerializer(serializers.ModelSerializer): class Meta: model = Organisation fields = ['id', 'name', 'identity_key', 'created_at'] def create(self, validated_data): return Organisation(**validated_data) + + +class SecretSerializer(serializers.ModelSerializer): + class Meta: + model = Secret + fields = '__all__' + + +class EnvironmentSerializer(serializers.ModelSerializer): + class Meta: + model = Environment + fields = ['id', 'name', 'env_type'] + + +class EnvironmentKeySerializer(serializers.ModelSerializer): + environment = EnvironmentSerializer() + + class Meta: + model = EnvironmentKey + fields = '__all__' + + +class UserTokenSerializer(serializers.ModelSerializer): + apps = EnvironmentKeySerializer(many=True, read_only=True) + + # New field 'userId' + user_id = serializers.UUIDField(source='user.id', read_only=True) + + # New field 'offline_enabled' with default value False + offline_enabled = serializers.BooleanField(default=False, read_only=True) + + class Meta: + model = UserToken + fields = ['wrapped_key_share', 'user_id', 'offline_enabled', 'apps'] + + def to_representation(self, instance): + representation = super().to_representation(instance) + + # Filter environment_keys to include only those associated with the same user + user = instance.user + + if user is not None: + environment_keys = EnvironmentKey.objects.filter( + user=user, environment__app__deleted_at=None) + apps = [] + for key in environment_keys: + + serializer = EnvironmentKeySerializer(key) + index = find_index_by_id(apps, key.environment.app.id) + + app_data = { + 'id': key.environment.app.id, + 'name': key.environment.app.name, + 'encryption': 'E2E', # Adding encryption to each app + } + + if index == -1: + app_data['environment_keys'] = [serializer.data] + apps.append(app_data) + else: + apps[index]['environment_keys'].append(serializer.data) + + representation['apps'] = apps + + return representation + + +class ServiceTokenSerializer(serializers.ModelSerializer): + apps = EnvironmentKeySerializer(many=True, read_only=True) + + class Meta: + model = ServiceToken + fields = ['wrapped_key_share', 'apps'] + + def to_representation(self, instance): + representation = super().to_representation(instance) + + environment_keys = instance.keys.all() + apps = [] + for key in environment_keys: + serializer = EnvironmentKeySerializer(key) + index = find_index_by_id(apps, key.environment.app.id) + + app_data = { + 'id': key.environment.app.id, + 'name': key.environment.app.name, + 'encryption': 'E2E', # Adding encryption to each app + } + + if index == -1: + app_data['environment_keys'] = [serializer.data] + apps.append(app_data) + else: + apps[index]['environment_keys'].append(serializer.data) + + representation['apps'] = apps + + return representation diff --git a/backend/api/templates/backend/api/email_templates/invite.html b/backend/api/templates/backend/api/email_templates/invite.html new file mode 100644 index 00000000..591bec38 --- /dev/null +++ b/backend/api/templates/backend/api/email_templates/invite.html @@ -0,0 +1,149 @@ + + + + Phase Invite + + + +
+ + + + + +
+ + +

Phase Invite

+
+
+

+ You have been invited to join {{ organisation }} on + Phase by {{ invited_by }}. +

+
+ +

Click the link below to accept the invite

+

Join

+
+
+ +
+ + diff --git a/backend/api/templates/backend/api/email_templates/login.html b/backend/api/templates/backend/api/email_templates/login.html new file mode 100644 index 00000000..62a82109 --- /dev/null +++ b/backend/api/templates/backend/api/email_templates/login.html @@ -0,0 +1,90 @@ + + + + New Login Alert + + + + + + + + +
+ + +

Login alert

+
+
+

You have logged into the Phase Console via {{ auth }} on {{ email }}.

+
+ + + + + + + + + + + + + +
IP address:{{ ip }}
User Agent:{{ user_agent }}
Timestamp:{{ timestamp }}
+
+

If this wasn't you, please check your account security or contact support.

+
+ + + diff --git a/backend/api/utils.py b/backend/api/utils.py index 29a2fa52..7fc5cbcc 100644 --- a/backend/api/utils.py +++ b/backend/api/utils.py @@ -1,7 +1,73 @@ +from api.models import EnvironmentToken, ServiceToken, UserToken +from django.utils import timezone +import base64 + + def get_client_ip(request): x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') if x_forwarded_for: ip = x_forwarded_for.split(',')[0] else: ip = request.META.get('REMOTE_ADDR') - return ip \ No newline at end of file + return ip + + +def get_resolver_request_meta(request): + user_agent = request.META.get('HTTP_USER_AGENT', 'Unknown') + ip_address = get_client_ip(request) + + return ip_address, user_agent + + +def get_token_type(auth_token): + return auth_token.split(" ")[1] + + +def get_env_from_service_token(auth_token): + token = auth_token.split(" ")[2] + + if not token: + return False + + try: + env_token = EnvironmentToken.objects.get(token=token) + return env_token.environment, env_token.user + except Exception as ex: + return False + + +def get_org_member_from_user_token(auth_token): + token = auth_token.split(" ")[2] + + if not token: + return False + + try: + user_token = UserToken.objects.get(token=token) + return user_token.user + except Exception as ex: + return False + + +def token_is_expired_or_deleted(auth_token): + prefix, token_type, token_value = auth_token.split(" ") + + if token_type == 'User': + token = UserToken.objects.get(token=token_value) + else: + token = ServiceToken.objects.get(token=token_value) + + return token.deleted_at is not None or (token.expires_at is not None and token.expires_at < timezone.now()) + + +def encode_string_to_base64(s): + # Convert string to bytes + byte_representation = s.encode('utf-8') + + # Base64 encode the bytes + base64_bytes = base64.b64encode(byte_representation) + + # Convert the encoded bytes back to a string + base64_string = base64_bytes.decode('utf-8') + + return base64_string diff --git a/backend/api/views.py b/backend/api/views.py index 3389bfd7..84db5a21 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -1,5 +1,9 @@ from datetime import datetime +import json +from api.serializers import EnvironmentKeySerializer, SecretSerializer, ServiceTokenSerializer, UserTokenSerializer +from api.emails import send_login_email +from backend.graphene.utils.permissions import user_can_access_environment from dj_rest_auth.registration.views import SocialLoginView from django.contrib.auth.mixins import LoginRequiredMixin from graphene_django.views import GraphQLView @@ -8,9 +12,9 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response from django.http import JsonResponse, HttpResponse -from api.utils import get_client_ip +from api.utils import get_client_ip, get_env_from_service_token, get_org_member_from_user_token, get_resolver_request_meta, get_token_type, token_is_expired_or_deleted from logs.models import KMSDBLog -from .models import App +from .models import App, Environment, EnvironmentKey, EnvironmentToken, Secret, SecretEvent, SecretTag, ServiceToken, UserToken import jwt import requests from django.contrib.auth import logout @@ -23,10 +27,17 @@ from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter +from rest_framework.views import APIView +from rest_framework import status +from django.views.decorators.csrf import csrf_exempt +from django.utils import timezone + CLOUD_HOSTED = settings.APP_HOST == 'cloud' # for custom gitlab adapter class + + def _check_errors(response): # 403 error's are presented as user-facing errors if response.status_code == 403: @@ -76,9 +87,18 @@ def complete_login(self, request, app, token, response, **kwargs): raise OAuth2Error("Invalid id_token") from e login = self.get_provider().sociallogin_from_response(request, identity_data) email = login.email_addresses[0] + if CLOUD_HOSTED and not CustomUser.objects.filter(email=email).exists(): - # new user - notify_slack(f"New user signup: {email}") + try: + # Notify Slack + notify_slack(f"New user signup: {email}") + except Exception as e: + print(f"Error notifying Slack: {e}") + + try: + send_login_email(request, email) + except Exception as e: + print(f"Error sending email: {e}") return login @@ -108,9 +128,19 @@ def complete_login(self, request, app, token, **kwargs): extra_data["email"] = self.get_email(headers) email = extra_data["email"] + if CLOUD_HOSTED and not CustomUser.objects.filter(email=email).exists(): - # new user - notify_slack(f"New user signup: {email}") + try: + # Notify Slack + notify_slack(f"New user signup: {email}") + except Exception as e: + print(f"Error notifying Slack: {e}") + + try: + send_login_email(request, email) + except Exception as e: + print(f"Error sending email: {e}") + return self.get_provider().sociallogin_from_response(request, extra_data) @@ -136,9 +166,19 @@ def complete_login(self, request, app, token, response): email = login.email_addresses[0] - if CLOUD_HOSTED and not CustomUser.objects.filter(email=email).exists(): - # new user - notify_slack(f"New user signup: {email}") + if CLOUD_HOSTED: + # Check if user exists and notify Slack for new user signup + if not CustomUser.objects.filter(email=email).exists(): + try: + notify_slack(f"New user signup: {email}") + except Exception as e: + print(f"Error notifying Slack: {e}") + + try: + send_login_email(request, email) + except Exception as e: + print(f"Error sending email: {e}") + return login @@ -172,8 +212,8 @@ def logout_view(request): @api_view(['GET']) @permission_classes([AllowAny]) -def health_check(request): - return JsonResponse({ +def health_check(request): + return JsonResponse({ 'status': 'alive' }) @@ -194,7 +234,8 @@ def kms(request, app_id): app = App.objects.get(app_token=app_token) try: timestamp = datetime.now().timestamp() * 1000 - KMSDBLog.objects.create(app_id=app_id, event_type=event_type, phase_node=phase_node, ph_size=float(ph_size), ip_address=ip_address, timestamp=timestamp) + KMSDBLog.objects.create(app_id=app_id, event_type=event_type, phase_node=phase_node, ph_size=float( + ph_size), ip_address=ip_address, timestamp=timestamp) except: pass return JsonResponse({ @@ -204,6 +245,279 @@ def kms(request, app_id): return HttpResponse(status=404) +def user_token_kms(request): + auth_token = request.headers['authorization'] + + token = auth_token.split(' ')[2] + + user_token = UserToken.objects.get(token=token) + + serializer = UserTokenSerializer(user_token) + + return Response(serializer.data, status=status.HTTP_200_OK) + + +def service_token_kms(request): + auth_token = request.headers['authorization'] + + token = auth_token.split(' ')[2] + + service_token = ServiceToken.objects.get(token=token) + + serializer = ServiceTokenSerializer(service_token) + + return Response(serializer.data, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([AllowAny]) +def secrets_tokens(request): + auth_token = request.headers['authorization'] + + if token_is_expired_or_deleted(auth_token): + return HttpResponse(status=403) + + token_type = get_token_type(auth_token) + + if token_type == 'Service': + return service_token_kms(request) + elif token_type == 'User': + return user_token_kms(request) + else: + return HttpResponse(status=403) + + class PrivateGraphQLView(LoginRequiredMixin, GraphQLView): raise_exception = True pass + + +class SecretsView(APIView): + permission_classes = [AllowAny, ] + + @csrf_exempt + def dispatch(self, request, *args): + return super(SecretsView, self).dispatch(request, *args) + + def get(self, request): + auth_token = request.headers['authorization'] + + if token_is_expired_or_deleted(auth_token): + return HttpResponse(status=403) + + token_type = get_token_type(auth_token) + + env_id = request.headers['environment'] + env = Environment.objects.get(id=env_id) + + ip_address, user_agent = get_resolver_request_meta(request) + + if token_type == 'User': + try: + org_member = get_org_member_from_user_token(auth_token) + + if not user_can_access_environment(org_member.user.userId, env_id): + return HttpResponse(status=403) + except Exception as ex: + print('EX:', ex) + return HttpResponse(status=404) + + if not env.id: + return HttpResponse(status=404) + + secrets_filter = { + 'environment': env, + 'deleted_at': None + } + + try: + key_digest = request.headers['keydigest'] + if key_digest: + secrets_filter['key_digest'] = key_digest + except: + pass + + secrets = Secret.objects.filter(**secrets_filter) + + for secret in secrets: + read_event = SecretEvent.objects.create(secret=secret, environment=secret.environment, user=org_member, key=secret.key, key_digest=secret.key_digest, + value=secret.value, comment=secret.comment, event_type=SecretEvent.READ, ip_address=ip_address, user_agent=user_agent) + read_event.tags.set(secret.tags.all()) + + serializer = SecretSerializer(secrets, many=True) + + return Response(serializer.data, status=status.HTTP_200_OK) + + def post(self, request): + auth_token = request.headers['authorization'] + + if token_is_expired_or_deleted(auth_token): + return HttpResponse(status=403) + + token_type = get_token_type(auth_token) + + env_id = request.headers['environment'] + env = Environment.objects.get(id=env_id) + + if token_type == 'User': + try: + user = get_org_member_from_user_token(auth_token) + + if not user_can_access_environment(user.user.userId, env_id): + return HttpResponse(status=403) + except: + return HttpResponse(status=404) + else: + user = None + + if not env: + return HttpResponse(status=404) + + request_body = json.loads(request.body) + + ip_address, user_agent = get_resolver_request_meta(request) + + for secret in request_body['secrets']: + + tags = SecretTag.objects.filter( + id__in=secret['tags']) + + secret_data = { + 'environment': env, + 'key': secret['key'], + 'key_digest': secret['keyDigest'], + 'value': secret['value'], + 'folder_id': secret['folderId'], + 'version': 1, + 'comment': secret['comment'], + } + + secret_obj = Secret.objects.create(**secret_data) + secret_obj.tags.set(tags) + + event = SecretEvent.objects.create( + **{**secret_data, **{ + 'user': user, + 'secret': secret_obj, + 'event_type': SecretEvent.CREATE, + 'ip_address': ip_address, + 'user_agent': user_agent + }}) + event.tags.set(tags) + + return Response(status=status.HTTP_200_OK) + + def put(self, request): + auth_token = request.headers['authorization'] + + if token_is_expired_or_deleted(auth_token): + return HttpResponse(status=403) + + token_type = get_token_type(auth_token) + + env_id = request.headers['environment'] + env = Environment.objects.get(id=env_id) + + if token_type == 'User': + try: + user = get_org_member_from_user_token(auth_token) + + if not user_can_access_environment(user.user.userId, env_id): + return HttpResponse(status=403) + except: + return HttpResponse(status=404) + + else: + user = None + + request_body = json.loads(request.body) + + ip_address, user_agent = get_resolver_request_meta(request) + + for secret in request_body['secrets']: + secret_obj = Secret.objects.get(id=secret['id']) + + tags = SecretTag.objects.filter( + id__in=secret['tags']) + + secret_data = { + 'environment': env, + 'key': secret['key'], + 'key_digest': secret['keyDigest'], + 'value': secret['value'], + 'folder_id': secret['folderId'], + 'version': secret_obj.version + 1, + 'comment': secret['comment'], + } + + for key, value in secret_data.items(): + setattr(secret_obj, key, value) + + secret_obj.updated_at = timezone.now() + secret_obj.tags.set(tags) + secret_obj.save() + + event = SecretEvent.objects.create( + **{**secret_data, **{ + 'user': user, + 'secret': secret_obj, + 'event_type': SecretEvent.UPDATE, + 'ip_address': ip_address, + 'user_agent': user_agent + }}) + event.tags.set(tags) + + return Response(status=status.HTTP_200_OK) + + def delete(self, request): + auth_token = request.headers['authorization'] + + if token_is_expired_or_deleted(auth_token): + return HttpResponse(status=403) + + token_type = get_token_type(auth_token) + + env_id = request.headers['environment'] + + if token_type == 'User': + try: + user = get_org_member_from_user_token(auth_token) + + if not user_can_access_environment(user.user.userId, env_id): + return HttpResponse(status=403) + except: + return HttpResponse(status=404) + + else: + user = None + + request_body = json.loads(request.body) + + ip_address, user_agent = get_resolver_request_meta(request) + + secrets_to_delete = Secret.objects.filter( + id__in=request_body['secrets']) + + for secret in secrets_to_delete: + if not Secret.objects.filter(id=secret.id).exists(): + return HttpResponse(status=404) + + if user is not None and not user_can_access_environment(user.user.userId, secret.environment.id): + return HttpResponse(status=403) + + for secret in secrets_to_delete: + secret.updated_at = timezone.now() + secret.deleted_at = timezone.now() + secret.save() + + most_recent_event_copy = SecretEvent.objects.filter( + secret=secret).order_by('version').last() + + # setting the pk to None and then saving it creates a copy of the instance with updated fields + most_recent_event_copy.id = None + most_recent_event_copy.event_type = SecretEvent.DELETE + most_recent_event_copy.ip_address = ip_address + most_recent_event_copy.user_agent = user_agent + most_recent_event_copy.save() + + return Response(status=status.HTTP_200_OK) diff --git a/backend/backend/exceptions.py b/backend/backend/exceptions.py index 1b8f2263..61e138ce 100644 --- a/backend/backend/exceptions.py +++ b/backend/backend/exceptions.py @@ -6,6 +6,7 @@ def custom_exception_handler(exc, context): # Call REST framework's default exception handler first, # to get the standard error response. response = exception_handler(exc, context) + print("EXCEPTION", exc) # set 404 as default response code status_code = 404 diff --git a/backend/backend/graphene/mutations/app.py b/backend/backend/graphene/mutations/app.py new file mode 100644 index 00000000..0aa01726 --- /dev/null +++ b/backend/backend/graphene/mutations/app.py @@ -0,0 +1,181 @@ +from backend.api.kv import delete, purge +from backend.graphene.mutations.environment import EnvironmentKeyInput +from backend.graphene.utils.permissions import user_can_access_app, user_is_admin, user_is_org_member +from ee.feature_flags import allow_new_app +import graphene +from django.utils import timezone +from graphql import GraphQLError +from api.models import App, EnvironmentKey, Organisation, OrganisationMember +from backend.graphene.types import AppType +from django.conf import settings + +CLOUD_HOSTED = settings.APP_HOST == 'cloud' + + +class CreateAppMutation(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) + organisation_id = graphene.ID(required=True) + name = graphene.String(required=True) + identity_key = graphene.String(required=True) + app_token = graphene.String(required=True) + app_seed = graphene.String(required=True) + wrapped_key_share = graphene.String(required=True) + app_version = graphene.Int(required=True) + + app = graphene.Field(AppType) + + @classmethod + def mutate(cls, root, info, id, organisation_id, name, identity_key, app_token, app_seed, wrapped_key_share, app_version): + user = info.context.user + org = Organisation.objects.get(id=organisation_id) + if not user_is_org_member(user.userId, organisation_id): + raise GraphQLError("You don't have access to this organisation") + + if allow_new_app(org) == False: + raise GraphQLError( + 'You have reached the App limit for your current plan. Please upgrade your account to add more.') + + if App.objects.filter(identity_key=identity_key).exists(): + raise GraphQLError("This app already exists") + + app = App.objects.create(id=id, organisation=org, name=name, identity_key=identity_key, + app_token=app_token, app_seed=app_seed, wrapped_key_share=wrapped_key_share, app_version=app_version) + + org_member = OrganisationMember.objects.get( + organisation=org, user=info.context.user, deleted_at=None) + org_member.apps.add(app) + + admin_roles = [OrganisationMember.ADMIN, OrganisationMember.OWNER] + + org_admins = org.users.filter(role__in=admin_roles) + for admin in org_admins: + admin.apps.add(app) + + return CreateAppMutation(app=app) + + +class RotateAppKeysMutation(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) + app_token = graphene.String(required=True) + wrapped_key_share = graphene.String(required=True) + + app = graphene.Field(AppType) + + @classmethod + def mutate(cls, root, info, id, app_token, wrapped_key_share): + user = info.context.user + app = App.objects.get(id=id) + + if not user_can_access_app(user.userId, app.id): + raise GraphQLError("You don't have access to this app") + + if CLOUD_HOSTED: + # delete current keys from cloudflare KV + deleted = delete(app.app_token) + + # purge keys from cloudflare cache + purged = purge( + f"phApp:v{app.app_version}:{app.identity_key}/{app.app_token}") + + if not deleted or not purged: + raise GraphQLError( + "Failed to delete app keys. Please try again.") + + app.app_token = app_token + app.wrapped_key_share = wrapped_key_share + app.save() + + return RotateAppKeysMutation(app=app) + + +class DeleteAppMutation(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) + + app = graphene.Field(AppType) + + @classmethod + def mutate(cls, root, info, id): + user = info.context.user + app = App.objects.get(id=id) + + if not user_can_access_app(user.userId, app.id): + raise GraphQLError("You don't have access to this app") + if not user_is_admin(user.userId, app.organisation.id): + raise GraphQLError( + "You don't have permission to perform that action.") + + if CLOUD_HOSTED: + # delete current keys from cloudflare KV + deleted = delete(app.app_token) + + # purge keys from cloudflare cache + purged = purge( + f"phApp:v{app.app_version}:{app.identity_key}/{app.app_token}") + + if not deleted or not purged: + raise GraphQLError( + "Failed to delete app keys. Please try again.") + + app.wrapped_key_share = "" + app.is_deleted = True + app.deleted_at = timezone.now() + app.save() + + return DeleteAppMutation(app=app) + + +class AddAppMemberMutation(graphene.Mutation): + class Arguments: + member_id = graphene.ID() + app_id = graphene.ID() + env_keys = graphene.List(EnvironmentKeyInput) + + app = graphene.Field(AppType) + + @classmethod + def mutate(cls, root, info, member_id, app_id, env_keys): + user = info.context.user + app = App.objects.get(id=app_id) + + if not user_can_access_app(user.userId, app.id): + raise GraphQLError("You don't have access to this app") + + org_member = OrganisationMember.objects.get( + id=member_id, deleted_at=None) + + app.members.add(org_member) + for key in env_keys: + EnvironmentKey.objects.create( + environment_id=key.env_id, user_id=key.user_id, wrapped_seed=key.wrapped_seed, wrapped_salt=key.wrapped_salt, identity_key=key.identity_key) + + return AddAppMemberMutation(app=app) + + +class RemoveAppMemberMutation(graphene.Mutation): + class Arguments: + member_id = graphene.ID() + app_id = graphene.ID() + + app = graphene.Field(AppType) + + @classmethod + def mutate(cls, root, info, member_id, app_id): + user = info.context.user + app = App.objects.get(id=app_id) + + if not user_can_access_app(user.userId, app.id): + raise GraphQLError("You don't have access to this app") + + org_member = OrganisationMember.objects.get( + id=member_id, deleted_at=None) + if org_member not in app.members.all(): + raise GraphQLError("This user is not a member of this app") + else: + app.members.remove(org_member) + EnvironmentKey.objects.filter( + environment__app=app, user_id=member_id).delete() + + return RemoveAppMemberMutation(app=app) diff --git a/backend/backend/graphene/mutations/environment.py b/backend/backend/graphene/mutations/environment.py new file mode 100644 index 00000000..47e7a9ec --- /dev/null +++ b/backend/backend/graphene/mutations/environment.py @@ -0,0 +1,490 @@ +from django.utils import timezone +from api.utils import get_resolver_request_meta +from backend.graphene.utils.permissions import member_can_access_org, user_can_access_app, user_can_access_environment, user_is_org_member +import graphene +from graphql import GraphQLError +from api.models import App, Environment, EnvironmentKey, EnvironmentToken, Organisation, OrganisationMember, Secret, SecretEvent, SecretFolder, SecretTag, UserToken, ServiceToken +from backend.graphene.types import AppType, EnvironmentKeyType, EnvironmentTokenType, EnvironmentType, SecretFolderType, SecretTagType, SecretType, ServiceTokenType, UserTokenType +from datetime import datetime + + +class EnvironmentInput(graphene.InputObjectType): + app_id = graphene.ID(required=True) + name = graphene.String(required=True) + env_type = graphene.String(required=True) + wrapped_seed = graphene.String(required=True) + wrapped_salt = graphene.String(required=True) + identity_key = graphene.String(required=True) + + +class EnvironmentKeyInput(graphene.InputObjectType): + env_id = graphene.ID(required=True) + user_id = graphene.ID(required=False) + identity_key = graphene.String(required=True) + wrapped_seed = graphene.String(required=True) + wrapped_salt = graphene.String(required=True) + + +class SecretInput(graphene.InputObjectType): + env_id = graphene.ID(required=False) + folder_id = graphene.ID(required=False) + key = graphene.String(required=True) + key_digest = graphene.String(required=True) + value = graphene.String(required=True) + tags = graphene.List(graphene.String) + comment = graphene.String() + + +class CreateEnvironmentMutation(graphene.Mutation): + class Arguments: + environment_data = EnvironmentInput(required=True) + admin_keys = graphene.List(EnvironmentKeyInput) + + environment = graphene.Field(EnvironmentType) + + @classmethod + def mutate(cls, root, info, environment_data, admin_keys): + user_id = info.context.user.userId + + if not user_can_access_app(user_id, environment_data.app_id): + raise GraphQLError("You don't have access to this app") + + app = App.objects.get(id=environment_data.app_id) + + environment = Environment.objects.create(app=app, name=environment_data.name, env_type=environment_data.env_type, + identity_key=environment_data.identity_key, wrapped_seed=environment_data.wrapped_seed, wrapped_salt=environment_data.wrapped_salt) + + org_owner = OrganisationMember.objects.get( + organisation=environment.app.organisation, role=OrganisationMember.OWNER, deleted_at=None) + + EnvironmentKey.objects.create(environment=environment, user=org_owner, + identity_key=environment_data.identity_key, wrapped_seed=environment_data.wrapped_seed, wrapped_salt=environment_data.wrapped_salt) + for key in admin_keys: + EnvironmentKey.objects.create( + environment=environment, user_id=key.user_id, wrapped_seed=key.wrapped_seed, wrapped_salt=key.wrapped_salt, identity_key=key.identity_key) + + return CreateEnvironmentMutation(environment=environment) + + +class CreateEnvironmentKeyMutation(graphene.Mutation): + class Arguments: + # id = graphene.ID(required=True) + env_id = graphene.ID(required=True) + user_id = graphene.ID(required=False) + identity_key = graphene.String(required=True) + wrapped_seed = graphene.String(required=True) + wrapped_salt = graphene.String(required=True) + + environment_key = graphene.Field(EnvironmentKeyType) + + @classmethod + def mutate(cls, root, info, env_id, identity_key, wrapped_seed, wrapped_salt, user_id=None,): + + env = Environment.objects.get(id=env_id) + + # check that the user attempting the mutation has access + if not user_can_access_app(info.context.user.userId, env.app.id): + raise GraphQLError("You don't have access to this app") + + # check that the user for whom we are adding a key has access + if not user_id is not None and member_can_access_org(user_id, env.app.organisation.id): + raise GraphQLError("This user doesn't have access to this app") + + if user_id is not None: + org_member = OrganisationMember.objects.get(id=user_id) + + if EnvironmentKey.objects.filter(environment=env, user_id=org_member).exists(): + raise GraphQLError( + "This user already has access to this environment") + + environment_key = EnvironmentKey.objects.create( + environment=env, user_id=user_id, identity_key=identity_key, wrapped_seed=wrapped_seed, wrapped_salt=wrapped_salt) + + return CreateEnvironmentKeyMutation(environment_key=environment_key) + + +class UpdateMemberEnvScopeMutation(graphene.Mutation): + class Arguments: + member_id = graphene.ID() + app_id = graphene.ID() + env_keys = graphene.List(EnvironmentKeyInput) + + app = graphene.Field(AppType) + + @classmethod + def mutate(cls, root, info, member_id, app_id, env_keys): + user = info.context.user + app = App.objects.get(id=app_id) + + if not user_can_access_app(user.userId, app.id): + raise GraphQLError("You don't have access to this app") + + org_member = OrganisationMember.objects.get( + id=member_id, deleted_at=None) + if org_member not in app.members.all(): + raise GraphQLError("This user does not have access to this app") + else: + # delete all existing keys + EnvironmentKey.objects.filter( + environment__app=app, user_id=member_id).delete() + + # set new keys + for key in env_keys: + EnvironmentKey.objects.create( + environment_id=key.env_id, user_id=key.user_id, wrapped_seed=key.wrapped_seed, wrapped_salt=key.wrapped_salt, identity_key=key.identity_key) + + return UpdateMemberEnvScopeMutation(app=app) + + +class CreateEnvironmentTokenMutation(graphene.Mutation): + class Arguments: + env_id = graphene.ID(required=True) + name = graphene.String(required=True) + identity_key = graphene.String(required=True) + token = graphene.String(required=True) + wrapped_key_share = graphene.String(required=True) + + environment_token = graphene.Field(EnvironmentTokenType) + + @classmethod + def mutate(cls, root, info, env_id, name, identity_key, token, wrapped_key_share): + user = info.context.user + if user_can_access_environment(user.userId, env_id): + + env = Environment.objects.get(id=env_id) + org_member = OrganisationMember.objects.get( + organisation=env.app.organisation, user_id=user.userId, deleted_at=None) + + environment_token = EnvironmentToken.objects.create( + environment_id=env_id, user=org_member, name=name, identity_key=identity_key, token=token, wrapped_key_share=wrapped_key_share) + + return CreateEnvironmentTokenMutation(environment_token=environment_token) + + +class CreateUserTokenMutation(graphene.Mutation): + class Arguments: + org_id = graphene.ID(required=True) + name = graphene.String(required=True) + identity_key = graphene.String(required=True) + token = graphene.String(required=True) + wrapped_key_share = graphene.String(required=True) + expiry = graphene.BigInt(required=False) + + ok = graphene.Boolean() + user_token = graphene.Field(UserTokenType) + + @classmethod + def mutate(cls, root, info, org_id, name, identity_key, token, wrapped_key_share, expiry): + user = info.context.user + if user_is_org_member(user.userId, org_id): + + org_member = OrganisationMember.objects.get( + organisation_id=org_id, user_id=user.userId, deleted_at=None) + + if expiry is not None: + expires_at = datetime.fromtimestamp(expiry / 1000) + else: + expires_at = None + + user_token = UserToken.objects.create( + user=org_member, name=name, identity_key=identity_key, token=token, wrapped_key_share=wrapped_key_share, expires_at=expires_at) + + return CreateUserTokenMutation(user_token=user_token, ok=True) + + else: + raise GraphQLError( + "You don't have permission to perform this action") + + +class DeleteUserTokenMutation(graphene.Mutation): + class Arguments: + token_id = graphene.ID(required=True) + + ok = graphene.Boolean() + + @classmethod + def mutate(cls, root, info, token_id): + user = info.context.user + token = UserToken.objects.get(id=token_id) + org = token.user.organisation + + if user_is_org_member(user.userId, org.id): + token.deleted_at = timezone.now() + token.save() + + return DeleteUserTokenMutation(ok=True) + else: + raise GraphQLError( + "You don't have permission to perform this action") + + +class CreateServiceTokenMutation(graphene.Mutation): + class Arguments: + app_id = graphene.ID(required=True) + environment_keys = graphene.List(EnvironmentKeyInput) + identity_key = graphene.String(required=True) + token = graphene.String(required=True) + wrapped_key_share = graphene.String(required=True) + name = graphene.String(required=True) + expiry = graphene.BigInt(required=False) + + service_token = graphene.Field(ServiceTokenType) + + @classmethod + def mutate(cls, root, info, app_id, environment_keys, identity_key, token, wrapped_key_share, name, expiry): + user = info.context.user + app = App.objects.get(id=app_id) + + if user_is_org_member(user.userId, app.organisation.id): + + org_member = OrganisationMember.objects.get( + organisation_id=app.organisation.id, user_id=user.userId, deleted_at=None) + + env_keys = EnvironmentKey.objects.bulk_create([EnvironmentKey( + environment_id=key.env_id, identity_key=key.identity_key, wrapped_seed=key.wrapped_seed, wrapped_salt=key.wrapped_salt) for key in environment_keys]) + + if expiry is not None: + expires_at = datetime.fromtimestamp(expiry / 1000) + else: + expires_at = None + + service_token = ServiceToken.objects.create( + app=app, identity_key=identity_key, token=token, wrapped_key_share=wrapped_key_share, name=name, created_by=org_member, expires_at=expires_at) + + service_token.keys.set(env_keys) + + return CreateServiceTokenMutation(service_token=service_token) + + +class DeleteServiceTokenMutation(graphene.Mutation): + class Arguments: + token_id = graphene.ID(required=True) + + ok = graphene.Boolean() + + @classmethod + def mutate(cls, root, info, token_id): + user = info.context.user + token = ServiceToken.objects.get(id=token_id) + org = token.app.organisation + + if user_is_org_member(user.userId, org.id): + token.deleted_at = timezone.now() + token.save() + + return DeleteServiceTokenMutation(ok=True) + else: + raise GraphQLError( + "You don't have permission to perform this action") + + +class CreateSecretFolderMutation(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) + env_id = graphene.ID(required=True) + parent_folder_id = graphene.ID(required=False) + name = graphene.String(required=True) + + folder = graphene.Field(SecretFolderType) + + @classmethod + def mutate(cls, root, info, id, env_id, name, parent_folder_id=None): + user = info.context.user + if user_can_access_environment(user.id, env_id): + folder = SecretFolder.objects.create( + id=id, environment_id=env_id, parent_id=parent_folder_id, name=name) + + return CreateSecretFolderMutation(folder=folder) + + +class CreateSecretTagMutation(graphene.Mutation): + class Arguments: + org_id = graphene.ID(required=True) + name = graphene.String(required=True) + color = graphene.String(required=True) + + tag = graphene.Field(SecretTagType) + + @classmethod + def mutate(cls, root, info, org_id, name, color): + + if not user_is_org_member(info.context.user.userId, org_id): + raise GraphQLError( + "You don't have permission to perform this action") + + org = Organisation.objects.get(id=org_id) + + if SecretTag.objects.filter(organisation=org, name=name).exists(): + raise GraphQLError('This tag already exists!') + + tag = SecretTag.objects.create( + organisation=org, name=name, color=color) + + return CreateSecretTagMutation(tag=tag) + + +class CreateSecretMutation(graphene.Mutation): + class Arguments: + secret_data = SecretInput(SecretInput) + + secret = graphene.Field(SecretType) + + @classmethod + def mutate(cls, root, info, secret_data): + env = Environment.objects.get(id=secret_data.env_id) + org = env.app.organisation + if not user_is_org_member(info.context.user.userId, org.id): + raise GraphQLError( + "You don't have permission to perform this action") + + tags = SecretTag.objects.filter( + id__in=secret_data.tags) + + secret_obj_data = { + 'environment_id': env.id, + 'folder_id': secret_data.folder_id, + 'key': secret_data.key, + 'key_digest': secret_data.key_digest, + 'value': secret_data.value, + 'version': 1, + 'comment': secret_data.comment + } + + secret = Secret.objects.create(**secret_obj_data) + secret.tags.set(tags) + + ip_address, user_agent = get_resolver_request_meta(info.context) + + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=org, deleted_at=None) + + event = SecretEvent.objects.create( + **{**secret_obj_data, **{ + 'user': org_member, + 'secret': secret, + 'event_type': SecretEvent.CREATE, + 'ip_address': ip_address, + 'user_agent': user_agent + }}) + event.tags.set(tags) + + return CreateSecretMutation(secret=secret) + + +class EditSecretMutation(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) + secret_data = SecretInput(SecretInput) + + secret = graphene.Field(SecretType) + + @classmethod + def mutate(cls, root, info, id, secret_data): + secret = Secret.objects.get(id=id) + env = secret.environment + org = env.app.organisation + if not user_is_org_member(info.context.user.userId, org.id): + raise GraphQLError( + "You don't have permission to perform this action") + + tags = SecretTag.objects.filter( + id__in=secret_data.tags) + + secret_obj_data = { + 'folder_id': secret_data.folder_id, + 'key': secret_data.key, + 'key_digest': secret_data.key_digest, + 'value': secret_data.value, + 'version': secret.version + 1, + 'comment': secret_data.comment + } + + for key, value in secret_obj_data.items(): + setattr(secret, key, value) + + secret.updated_at = timezone.now() + secret.tags.set(tags) + secret.save() + + ip_address, user_agent = get_resolver_request_meta(info.context) + + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=org, deleted_at=None) + + event = SecretEvent.objects.create( + **{**secret_obj_data, **{ + 'user': org_member, + 'environment': env, + 'secret': secret, + 'event_type': SecretEvent.UPDATE, + 'ip_address': ip_address, + 'user_agent': user_agent + }}) + event.tags.set(tags) + + return EditSecretMutation(secret=secret) + + +class DeleteSecretMutation(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) + + secret = graphene.Field(SecretType) + + @classmethod + def mutate(cls, root, info, id): + secret = Secret.objects.get(id=id) + env = secret.environment + org = env.app.organisation + + if not user_is_org_member(info.context.user.userId, org.id): + raise GraphQLError( + "You don't have permission to perform this action") + + secret.updated_at = timezone.now() + secret.deleted_at = timezone.now() + secret.save() + + ip_address, user_agent = get_resolver_request_meta(info.context) + + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=org, deleted_at=None) + + most_recent_event_copy = SecretEvent.objects.filter( + secret=secret).order_by('version').last() + + # setting the pk to None and then saving it creates a copy of the instance with updated fields + most_recent_event_copy.id = None + most_recent_event_copy.event_type = SecretEvent.DELETE + most_recent_event_copy.user = org_member + most_recent_event_copy.ip_address = ip_address + most_recent_event_copy.user_agent = user_agent + most_recent_event_copy.save() + + return DeleteSecretMutation(secret=secret) + + +class ReadSecretMutation(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) + + ok = graphene.Boolean() + + @classmethod + def mutate(cls, root, info, id): + secret = Secret.objects.get(id=id) + env = secret.environment + org = env.app.organisation + if not user_is_org_member(info.context.user.userId, org.id): + raise GraphQLError( + "You don't have permission to perform this action") + else: + ip_address, user_agent = get_resolver_request_meta(info.context) + + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=org, deleted_at=None) + + read_event = SecretEvent.objects.create(secret=secret, environment=secret.environment, user=org_member, key=secret.key, key_digest=secret.key_digest, + value=secret.value, comment=secret.comment, event_type=SecretEvent.READ, ip_address=ip_address, user_agent=user_agent) + read_event.tags.set(secret.tags.all()) + return ReadSecretMutation(ok=True) diff --git a/backend/backend/graphene/mutations/organisation.py b/backend/backend/graphene/mutations/organisation.py new file mode 100644 index 00000000..3145e30b --- /dev/null +++ b/backend/backend/graphene/mutations/organisation.py @@ -0,0 +1,209 @@ +from api.emails import send_inite_email +from backend.graphene.utils.permissions import user_is_admin, user_is_org_member +import graphene +from graphql import GraphQLError +from api.models import App, Organisation, CustomUser, OrganisationMember, OrganisationMemberInvite +from backend.graphene.types import OrganisationMemberInviteType, OrganisationMemberType, OrganisationType +from datetime import datetime, timedelta +from django.utils import timezone + + +class CreateOrganisationMutation(graphene.Mutation): + class Arguments: + id = graphene.ID(required=True) + name = graphene.String(required=True) + identity_key = graphene.String(required=True) + wrapped_keyring = graphene.String(required=True) + wrapped_recovery = graphene.String(required=True) + + organisation = graphene.Field(OrganisationType) + + @classmethod + def mutate(cls, root, info, id, name, identity_key, wrapped_keyring, wrapped_recovery): + if Organisation.objects.filter(name__iexact=name).exists(): + raise GraphQLError('This organisation name is not available.') + if OrganisationMember.objects.filter(user_id=info.context.user.userId, role=OrganisationMember.OWNER).exists(): + raise GraphQLError( + 'Your current plan only supports one organisation.') + + owner = CustomUser.objects.get(userId=info.context.user.userId) + org = Organisation.objects.create( + id=id, name=name, identity_key=identity_key) + OrganisationMember.objects.create( + user=owner, organisation=org, role=OrganisationMember.OWNER, identity_key=identity_key, wrapped_keyring=wrapped_keyring, wrapped_recovery=wrapped_recovery) + + return CreateOrganisationMutation(organisation=org) + + +class UpdateUserWrappedSecretsMutation(graphene.Mutation): + class Arguments: + org_id = graphene.ID(required=True) + wrapped_keyring = graphene.String(required=True) + wrapped_recovery = graphene.String(required=True) + + org_member = graphene.Field(OrganisationMemberType) + + @classmethod + def mutate(cls, root, info, org_id, wrapped_keyring, wrapped_recovery): + + org_member = OrganisationMember.objects.get( + organisation_id=org_id, user=info.context.user, deleted_at=None) + + org_member.wrapped_keyring = wrapped_keyring + org_member.wrapped_recovery = wrapped_recovery + org_member.save() + + return UpdateUserWrappedSecretsMutation(org_member=org_member) + + +class InviteOrganisationMemberMutation(graphene.Mutation): + class Arguments: + org_id = graphene.ID(required=True) + email = graphene.String(required=True) + apps = graphene.List(graphene.String) + role = graphene.String() + + invite = graphene.Field(OrganisationMemberInviteType) + + @classmethod + def mutate(cls, root, info, org_id, email, apps, role): + if user_is_org_member(info.context.user, org_id): + user_already_exists = OrganisationMember.objects.filter( + organisation_id=org_id, user__email=email, deleted_at=None).exists() + if user_already_exists: + raise GraphQLError( + "This user is already a member if your organisation") + + if OrganisationMemberInvite.objects.filter(organisation_id=org_id, invitee_email=email, valid=True, expires_at__gte=timezone.now()).exists(): + raise GraphQLError( + "An active invitiation already exists for this user.") + + invited_by = OrganisationMember.objects.get( + user=info.context.user, organisation_id=org_id, deleted_at=None) + + expiry = datetime.now() + timedelta(days=3) + + app_scope = App.objects.filter(id__in=apps) + + invite = OrganisationMemberInvite.objects.create( + organisation_id=org_id, invited_by=invited_by, role=role.lower(), invitee_email=email, expires_at=expiry) + + invite.apps.set(app_scope) + + try: + send_inite_email(invite) + except Exception as e: + print(f"Error sending invite email: {e}") + + return InviteOrganisationMemberMutation(invite=invite) + else: + raise GraphQLError( + "You don't have permission to perform this action") + + +class DeleteInviteMutation(graphene.Mutation): + class Arguments: + invite_id = graphene.ID(required=True) + + ok = graphene.Boolean() + + @classmethod + def mutate(cls, rooot, info, invite_id): + invite = OrganisationMemberInvite.objects.get(id=invite_id) + + if user_is_org_member(info.context.user, invite.organisation.id): + invite.delete() + + return DeleteInviteMutation(ok=True) + + else: + raise GraphQLError( + "You don't have permission to perform this action") + + +class CreateOrganisationMemberMutation(graphene.Mutation): + class Arguments: + org_id = graphene.ID(required=True) + identity_key = graphene.String(required=True) + wrapped_keyring = graphene.String(required=False) + wrapped_recovery = graphene.String(required=False) + invite_id = graphene.ID(required=True) + + org_member = graphene.Field(OrganisationMemberType) + + @classmethod + def mutate(cls, root, info, org_id, identity_key, wrapped_keyring, wrapped_recovery, invite_id): + if user_is_org_member(info.context.user.userId, org_id): + raise GraphQLError( + "You are already a member of this organisation") + + if OrganisationMemberInvite.objects.filter(id=invite_id, valid=True, expires_at__gte=timezone.now()).exists(): + + invite = OrganisationMemberInvite.objects.get( + id=invite_id, valid=True, expires_at__gte=timezone.now()) + + org = Organisation.objects.get(id=org_id) + + org_member = OrganisationMember.objects.create( + user_id=info.context.user.userId, organisation=org, role=invite.role, identity_key=identity_key, wrapped_keyring=wrapped_keyring, wrapped_recovery=wrapped_recovery) + + org_member.apps.set(invite.apps.all()) # broken + + invite.valid = False + invite.save() + + return CreateOrganisationMemberMutation(org_member=org_member) + else: + raise GraphQLError( + "You need a valid invite to join this organisation") + + +class DeleteOrganisationMemberMutation(graphene.Mutation): + class Arguments: + member_id = graphene.ID(required=True) + + ok = graphene.Boolean() + + @classmethod + def mutate(cls, root, info, member_id): + org_member = OrganisationMember.objects.get( + id=member_id, deleted_at=None) + + if org_member.user == info.context.user: + raise GraphQLError( + "You can't remove yourself from an organisation") + + if user_is_admin(info.context.user.userId, org_member.organisation.id): + org_member.delete() + + return DeleteOrganisationMemberMutation(ok=True) + else: + raise GraphQLError( + "You don't have permission to perform that action") + + +class UpdateOrganisationMemberRole(graphene.Mutation): + class Arguments: + member_id = graphene.ID(required=True) + role = graphene.String(required=True) + + org_member = graphene.Field(OrganisationMemberType) + + @classmethod + def mutate(cls, root, info, member_id, role): + + org_member = OrganisationMember.objects.get( + id=member_id, deleted_at=None) + + if user_is_admin(info.context.user.userId, org_member.organisation.id): + if role.lower() == OrganisationMember.OWNER.lower(): + raise GraphQLError( + 'You cannot set this user as the organisation owner') + + org_member.role = role.lower() + org_member.save() + + return UpdateOrganisationMemberRole(org_member=org_member) + else: + raise GraphQLError( + "You don't have permission to perform this action") diff --git a/backend/backend/graphene/types.py b/backend/backend/graphene/types.py new file mode 100644 index 00000000..bcd865c1 --- /dev/null +++ b/backend/backend/graphene/types.py @@ -0,0 +1,206 @@ +import graphene +from enum import Enum +from graphene import ObjectType, relay +from graphene_django import DjangoObjectType +from api.models import CustomUser, Environment, EnvironmentKey, EnvironmentToken, Organisation, App, OrganisationMember, OrganisationMemberInvite, Secret, SecretEvent, SecretFolder, SecretTag, ServiceToken, UserToken +from logs.dynamodb_models import KMSLog +from allauth.socialaccount.models import SocialAccount + + +class OrganisationType(DjangoObjectType): + role = graphene.String() + member_id = graphene.ID() + keyring = graphene.String() + recovery = graphene.String() + + class Meta: + model = Organisation + fields = ('id', 'name', 'identity_key', + 'created_at', 'plan', 'role', 'member_id', 'keyring', 'recovery') + + def resolve_role(self, info): + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=self, deleted_at=None) + return org_member.role + + def resolve_member_id(self, info): + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=self, deleted_at=None) + return org_member.id + + def resolve_keyring(self, info): + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=self, deleted_at=None) + return org_member.wrapped_keyring + + def resolve_recovery(self, info): + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=self, deleted_at=None) + return org_member.wrapped_recovery + + def resolve_idenity_key(self, info): + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=self, deleted_at=None) + return org_member.identity_key + + +class OrganisationMemberType(DjangoObjectType): + email = graphene.String() + username = graphene.String() + full_name = graphene.String() + avatar_url = graphene.String() + self = graphene.Boolean() + + class Meta: + model = OrganisationMember + fields = ('id', 'email', 'username', 'full_name', 'avatar_url', 'role', + 'identity_key', 'wrapped_keyring', 'created_at', 'updated_at') + + def resolve_email(self, info): + return self.user.email + + def resolve_username(self, info): + return self.user.username + + def resolve_full_name(self, info): + social_acc = self.user.socialaccount_set.first() + if social_acc: + return social_acc.extra_data.get('name') + return None + + def resolve_avatar_url(self, info): + social_acc = self.user.socialaccount_set.first() + if social_acc: + if social_acc.provider == 'google': + return social_acc.extra_data.get('picture') + return social_acc.extra_data.get('avatar_url') + return None + + def resolve_self(self, info): + return self.user == info.context.user + + +class OrganisationMemberInviteType(DjangoObjectType): + class Meta: + model = OrganisationMemberInvite + fields = ('id', 'invited_by', 'invitee_email', 'valid', 'organisation', 'apps', 'role', + 'created_at', 'updated_at', 'expires_at') + + +class AppType(DjangoObjectType): + class Meta: + model = App + fields = ('id', 'name', 'identity_key', + 'wrapped_key_share', 'created_at', 'app_token', 'app_seed', 'app_version') + + +class EnvironmentType(DjangoObjectType): + class Meta: + model = Environment + fields = ('id', 'name', 'env_type', 'identity_key', + 'wrapped_seed', 'wrapped_salt', 'created_at', 'updated_at') + + +class EnvironmentKeyType(DjangoObjectType): + class Meta: + model = EnvironmentKey + fields = ('id', 'identity_key', 'wrapped_seed', + 'wrapped_salt', 'created_at', 'updated_at', 'environment') + + +class EnvironmentTokenType(DjangoObjectType): + class Meta: + model = EnvironmentToken + fields = ('id', 'name', 'identity_key', 'token', + 'wrapped_key_share', 'created_at', 'updated_at') + + +class UserTokenType(DjangoObjectType): + class Meta: + model = UserToken + fields = ('id', 'name', 'identity_key', 'token', + 'wrapped_key_share', 'created_at', 'updated_at', 'expires_at') + + +class ServiceTokenType(DjangoObjectType): + class Meta: + model = ServiceToken + fields = ('id', 'keys', 'identity_key', + 'token', 'wrapped_key_share', 'name', 'created_by', 'created_at', 'updated_at', 'expires_at') + + +class SecretFolderType(DjangoObjectType): + class Meta: + model = SecretFolder + fields = ('id', 'environment_id', 'parent_folder_id', + 'name', 'created_at', 'updated_at') + + +class SecretTagType(DjangoObjectType): + class Meta: + model = SecretTag + fields = ('id', 'name', 'color') + + +class SecretEventType(DjangoObjectType): + class Meta: + model = SecretEvent + fields = ('id', 'secret', 'key', 'value', + 'version', 'tags', 'comment', 'event_type', 'timestamp', 'user', 'ip_address', 'user_agent', 'environment') + + +class SecretType(DjangoObjectType): + + history = graphene.List(SecretEventType) + + class Meta: + model = Secret + fields = ('id', 'key', 'value', 'folder', 'version', 'tags', + 'comment', 'created_at', 'updated_at', 'history') + # interfaces = (relay.Node, ) + + def resolve_history(self, info): + return SecretEvent.objects.filter(secret_id=self.id).order_by('timestamp') + + +class KMSLogType(ObjectType): + class Meta: + model = KMSLog + fields = ('id', 'app_id', 'timestamp', 'phase_node', + 'event_type', 'ip_address', 'ph_size', 'edge_location', 'country', 'city', 'latitude', 'longitude') + interfaces = (relay.Node, ) + + id = graphene.ID(required=True) + timestamp = graphene.BigInt() + app_id = graphene.String() + phase_node = graphene.String() + event_type = graphene.String() + ip_address = graphene.String() + ph_size = graphene.Int() + asn = graphene.Int() + isp = graphene.String() + edge_location = graphene.String() + country = graphene.String() + city = graphene.String() + latitude = graphene.Float() + longitude = graphene.Float() + + +class ChartDataPointType(graphene.ObjectType): + index = graphene.Int() + date = graphene.BigInt() + data = graphene.Int() + + +class TimeRange(Enum): + HOUR = 'hour' + DAY = 'day' + WEEK = 'week' + MONTH = 'month' + YEAR = 'year' + ALL_TIME = 'allTime' + + +class LogsResponseType(ObjectType): + kms = graphene.List(KMSLogType) + secrets = graphene.List(SecretEventType) diff --git a/backend/backend/graphene/utils/permissions.py b/backend/backend/graphene/utils/permissions.py new file mode 100644 index 00000000..12a8a43a --- /dev/null +++ b/backend/backend/graphene/utils/permissions.py @@ -0,0 +1,31 @@ +from api.models import App, Environment, EnvironmentKey, Organisation, OrganisationMember + +admin_roles = [OrganisationMember.OWNER, OrganisationMember.ADMIN] + + +def user_is_admin(user_id, org_id): + member = OrganisationMember.objects.get( + user_id=user_id, organisation_id=org_id, deleted_at=None) + return member.role in admin_roles + + +def user_is_org_member(user_id, org_id): + return OrganisationMember.objects.filter(user_id=user_id, organisation_id=org_id, deleted_at=None).exists() + + +def user_can_access_app(user_id, app_id): + app = App.objects.get(id=app_id) + org_member = OrganisationMember.objects.get( + user_id=user_id, organisation=app.organisation, deleted_at=None) + return org_member in app.members.all() + + +def user_can_access_environment(user_id, env_id): + env = Environment.objects.get(id=env_id) + org_member = OrganisationMember.objects.get( + organisation=env.app.organisation, user_id=user_id, deleted_at=None) + return EnvironmentKey.objects.filter(user_id=org_member, environment_id=env_id).exists() + + +def member_can_access_org(member_id, org_id): + return OrganisationMember.objects.filter(id=member_id, organisation_id=org_id, deleted_at=None).exists() diff --git a/backend/backend/schema.py b/backend/backend/schema.py index cc17a572..e7f16695 100644 --- a/backend/backend/schema.py +++ b/backend/backend/schema.py @@ -1,246 +1,295 @@ -from enum import Enum +from .graphene.mutations.environment import CreateEnvironmentKeyMutation, CreateEnvironmentMutation, CreateEnvironmentTokenMutation, CreateSecretFolderMutation, CreateSecretMutation, CreateSecretTagMutation, CreateServiceTokenMutation, CreateUserTokenMutation, DeleteSecretMutation, DeleteServiceTokenMutation, DeleteUserTokenMutation, EditSecretMutation, ReadSecretMutation, UpdateMemberEnvScopeMutation +from .graphene.utils.permissions import user_can_access_app, user_can_access_environment, user_is_admin, user_is_org_member +from .graphene.mutations.app import AddAppMemberMutation, CreateAppMutation, DeleteAppMutation, RemoveAppMemberMutation, RotateAppKeysMutation +from .graphene.mutations.organisation import CreateOrganisationMemberMutation, CreateOrganisationMutation, DeleteInviteMutation, DeleteOrganisationMemberMutation, InviteOrganisationMemberMutation, UpdateOrganisationMemberRole, UpdateUserWrappedSecretsMutation +from .graphene.types import AppType, ChartDataPointType, EnvironmentKeyType, EnvironmentTokenType, EnvironmentType, KMSLogType, LogsResponseType, OrganisationMemberInviteType, OrganisationMemberType, OrganisationType, SecretEventType, SecretTagType, SecretType, ServiceTokenType, TimeRange, UserTokenType import graphene -from django.utils import timezone -from graphene import ObjectType, relay -from graphene_django import DjangoObjectType from graphql import GraphQLError -from api.models import CustomUser, Organisation, App -from backend.api.kv import delete, purge -from ee.feature_flags import allow_new_app -from logs.dynamodb_models import KMSLog +from api.models import Environment, EnvironmentKey, EnvironmentToken, Organisation, App, OrganisationMember, OrganisationMemberInvite, Secret, SecretEvent, SecretTag, ServiceToken, UserToken from logs.queries import get_app_log_count, get_app_log_count_range, get_app_logs from datetime import datetime, timedelta from django.conf import settings from logs.models import KMSDBLog +from itertools import chain +from django.utils import timezone CLOUD_HOSTED = settings.APP_HOST == 'cloud' -class OrganisationType(DjangoObjectType): - class Meta: - model = Organisation - fields = ('id', 'name', 'identity_key', 'created_at', 'plan') - - -class AppType(DjangoObjectType): - class Meta: - model = App - fields = ('id', 'name', 'identity_key', - 'wrapped_key_share', 'created_at', 'app_token', 'app_seed', 'app_version') - - -class KMSLogType(ObjectType): - class Meta: - model = KMSLog - fields = ('id', 'app_id', 'timestamp', 'phase_node', - 'event_type', 'ip_address', 'ph_size', 'edge_location', 'country', 'city', 'latitude', 'longitude') - interfaces = (relay.Node, ) - - id = graphene.ID(required=True) - timestamp = graphene.BigInt() - app_id = graphene.String() - phase_node = graphene.String() - event_type = graphene.String() - ip_address = graphene.String() - ph_size = graphene.Int() - asn = graphene.Int() - isp = graphene.String() - edge_location = graphene.String() - country = graphene.String() - city = graphene.String() - latitude = graphene.Float() - longitude = graphene.Float() - - -class ChartDataPointType(graphene.ObjectType): - index = graphene.Int() - date = graphene.BigInt() - data = graphene.Int() - - -class TimeRange(Enum): - HOUR = 'hour' - DAY = 'day' - WEEK = 'week' - MONTH = 'month' - YEAR = 'year' - ALL_TIME = 'allTime' - - -class CreateOrganisationMutation(graphene.Mutation): - class Arguments: - id = graphene.ID(required=True) - name = graphene.String(required=True) - identity_key = graphene.String(required=True) - - organisation = graphene.Field(OrganisationType) - - @classmethod - def mutate(cls, root, info, id, name, identity_key): - if Organisation.objects.filter(name__iexact=name).exists(): - raise GraphQLError('This organisation name is not available.') - if Organisation.objects.filter(owner__userId=info.context.user.userId).exists(): - raise GraphQLError( - 'Your current plan only supports one organisation.') - - owner = CustomUser.objects.get(userId=info.context.user.userId) - org = Organisation.objects.create( - id=id, name=name, identity_key=identity_key, owner=owner) - - return CreateOrganisationMutation(organisation=org) - - -class CreateAppMutation(graphene.Mutation): - class Arguments: - id = graphene.ID(required=True) - organisation_id = graphene.ID(required=True) - name = graphene.String(required=True) - identity_key = graphene.String(required=True) - app_token = graphene.String(required=True) - app_seed = graphene.String(required=True) - wrapped_key_share = graphene.String(required=True) - app_version = graphene.Int(required=True) - - app = graphene.Field(AppType) - - @classmethod - def mutate(cls, root, info, id, organisation_id, name, identity_key, app_token, app_seed, wrapped_key_share, app_version): - owner = info.context.user - org = Organisation.objects.get(id=organisation_id) - if not Organisation.objects.filter(id=organisation_id, owner__userId=owner.userId).exists(): - raise GraphQLError("You don't have access to this organisation") - - if allow_new_app(org) == False: - raise GraphQLError( - 'You have reached the App limit for your current plan. Please upgrade your account to add more.') - if App.objects.filter(identity_key=identity_key).exists(): - raise GraphQLError("This app already exists") - - app = App.objects.create(id=id, organisation=org, name=name, identity_key=identity_key, - app_token=app_token, app_seed=app_seed, wrapped_key_share=wrapped_key_share, app_version=app_version) +class Query(graphene.ObjectType): + organisations = graphene.List(OrganisationType) + organisation_members = graphene.List(OrganisationMemberType, organisation_id=graphene.ID( + ), user_id=graphene.ID(), role=graphene.List(graphene.String)) + organisation_admins_and_self = graphene.List( + OrganisationMemberType, organisation_id=graphene.ID()) + organisation_invites = graphene.List( + OrganisationMemberInviteType, org_id=graphene.ID()) + validate_invite = graphene.Field( + OrganisationMemberInviteType, invite_id=graphene.ID()) + apps = graphene.List( + AppType, organisation_id=graphene.ID(), app_id=graphene.ID()) - return CreateAppMutation(app=app) + logs = graphene.Field(LogsResponseType, app_id=graphene.ID(), + start=graphene.BigInt(), end=graphene.BigInt()) + kms_logs_count = graphene.Int(app_id=graphene.ID(), + this_month=graphene.Boolean()) -class RotateAppKeysMutation(graphene.Mutation): - class Arguments: - id = graphene.ID(required=True) - app_token = graphene.String(required=True) - wrapped_key_share = graphene.String(required=True) + secrets_logs_count = graphene.Int(app_id=graphene.ID()) - app = graphene.Field(AppType) + app_activity_chart = graphene.List(ChartDataPointType, app_id=graphene.ID( + ), period=graphene.Argument(graphene.Enum.from_enum(TimeRange))) - @classmethod - def mutate(cls, root, info, id, app_token, wrapped_key_share): - owner = info.context.user - org = Organisation.objects.filter( - owner__userId=owner.userId).first() - app = App.objects.get(id=id) - if not app.organisation.id == org.id: - raise GraphQLError("You don't have access to this app") + app_environments = graphene.List(EnvironmentType, app_id=graphene.ID( + ), environment_id=graphene.ID(required=False), member_id=graphene.ID(required=False)) + app_users = graphene.List(OrganisationMemberType, app_id=graphene.ID()) + secrets = graphene.List(SecretType, env_id=graphene.ID()) + secret_history = graphene.List(SecretEventType, secret_id=graphene.ID()) + secret_tags = graphene.List(SecretTagType, org_id=graphene.ID()) + environment_keys = graphene.List( + EnvironmentKeyType, app_id=graphene.ID(required=False), environment_id=graphene.ID(required=False), member_id=graphene.ID(required=False)) + environment_tokens = graphene.List( + EnvironmentTokenType, environment_id=graphene.ID()) + user_tokens = graphene.List(UserTokenType, organisation_id=graphene.ID()) + service_tokens = graphene.List(ServiceTokenType, app_id=graphene.ID()) - if CLOUD_HOSTED: - # delete current keys from cloudflare KV - deleted = delete(app.app_token) + def resolve_organisations(root, info): + memberships = OrganisationMember.objects.filter( + user=info.context.user, deleted_at=None) - # purge keys from cloudflare cache - purged = purge( - f"phApp:v{app.app_version}:{app.identity_key}/{app.app_token}") + return [membership.organisation for membership in memberships] - if not deleted or not purged: - raise GraphQLError("Failed to delete app keys. Please try again.") + def resolve_organisation_members(root, info, organisation_id, role, user_id=None): + if not user_is_org_member(info.context.user.userId, organisation_id): + raise GraphQLError("You don't have access to this organisation") - app.app_token = app_token - app.wrapped_key_share = wrapped_key_share - app.save() + filter = { + "organisation_id": organisation_id, + "deleted_at": None + } - return RotateAppKeysMutation(app=app) + if role: + roles = [user_role.lower() for user_role in role] + filter["roles__in"] = roles + return OrganisationMember.objects.filter(**filter) -class DeleteAppMutation(graphene.Mutation): - class Arguments: - id = graphene.ID(required=True) + def resolve_organisation_admins_and_self(root, info, organisation_id): + if not user_is_org_member(info.context.user.userId, organisation_id): + raise GraphQLError("You don't have access to this organisation") - app = graphene.Field(AppType) + roles = ['owner', 'admin'] - @classmethod - def mutate(cls, root, info, id): - owner = info.context.user - org = Organisation.objects.filter( - owner__userId=owner.userId).first() - app = App.objects.get(id=id) - if not app.organisation.id == org.id: - raise GraphQLError("You don't have access to this app") + members = OrganisationMember.objects.filter( + organisation_id=organisation_id, role__in=roles, deleted_at=None) - if CLOUD_HOSTED: - # delete current keys from cloudflare KV - deleted = delete(app.app_token) + if not info.context.user.userId in [member.user_id for member in members]: + self_member = OrganisationMember.objects.filter( + organisation_id=organisation_id, user_id=info.context.user.userId, deleted_at=None) + members = list(chain(members, self_member)) - # purge keys from cloudflare cache - purged = purge( - f"phApp:v{app.app_version}:{app.identity_key}/{app.app_token}") + return members - if not deleted or not purged: - raise GraphQLError("Failed to delete app keys. Please try again.") + def resolve_organisation_invites(root, info, org_id): + if not user_is_org_member(info.context.user.userId, org_id): + raise GraphQLError("You don't have access to this organisation") - app.wrapped_key_share = "" - app.is_deleted = True - app.deleted_at = timezone.now() - app.save() + invites = OrganisationMemberInvite.objects.filter( + organisation_id=org_id, valid=True) - return DeleteAppMutation(app=app) + return invites + def resolve_validate_invite(root, info, invite_id): + try: + invite = OrganisationMemberInvite.objects.get( + id=invite_id, valid=True) + except: + raise GraphQLError("This invite is invalid") -class Query(graphene.ObjectType): - organisations = graphene.List(OrganisationType) - apps = graphene.List( - AppType, organisation_id=graphene.ID(), app_id=graphene.ID()) - logs = graphene.List(KMSLogType, app_id=graphene.ID(), + if invite.expires_at < timezone.now(): + raise GraphQLError("This invite has expired") - start=graphene.BigInt(), end=graphene.BigInt()) - logs_count = graphene.Int(app_id=graphene.ID(), - this_month=graphene.Boolean()) - - app_activity_chart = graphene.List(ChartDataPointType, app_id=graphene.ID( - ), period=graphene.Argument(graphene.Enum.from_enum(TimeRange))) - - def resolve_organisations(root, info): - return Organisation.objects.filter(owner__userId=info.context.user.userId) + if invite.invitee_email == info.context.user.email: + return invite + else: + raise GraphQLError("This invite is for another user") def resolve_apps(root, info, organisation_id, app_id): + org_member = OrganisationMember.objects.get( + organisation_id=organisation_id, user_id=info.context.user.userId, deleted_at=None) + filter = { 'organisation_id': organisation_id, + 'id__in': org_member.apps.all(), 'is_deleted': False } + if app_id != '': filter['id'] = app_id return App.objects.filter(**filter) - def resolve_logs(root, info, app_id, start=0, end=0): - owner = info.context.user - org = Organisation.objects.filter( - owner__userId=owner.userId).first() + def resolve_app_environments(root, info, app_id, environment_id, member_id=None): + if not user_can_access_app(info.context.user.userId, app_id): + raise GraphQLError("You don't have access to this app") + + app = App.objects.get(id=app_id) + + if member_id is not None: + org_member = OrganisationMember.objects.get(id=member_id) + else: + org_member = OrganisationMember.objects.get( + organisation=app.organisation, user_id=info.context.user.userId, deleted_at=None) + + filter = { + 'app_id': app_id + } + + if environment_id: + filter['id'] = environment_id + + app_environments = Environment.objects.filter(**filter) + return [app_env for app_env in app_environments if EnvironmentKey.objects.filter(user=org_member, environment_id=app_env.id).exists()] + + def resolve_app_users(root, info, app_id): + if not user_can_access_app(info.context.user.userId, app_id): + raise GraphQLError("You don't have access to this app") + + app = App.objects.get(id=app_id) + return app.members.filter(deleted_at=None) + + def resolve_secrets(root, info, env_id): + if not user_can_access_environment(info.context.user.userId, env_id): + raise GraphQLError("You don't have access to this environment") + + return Secret.objects.filter(environment_id=env_id, deleted_at=None).order_by('created_at') + + def resolve_secret_history(root, info, secret_id): + secret = Secret.objects.get(id=secret_id) + if not user_can_access_environment(info.context.user.userId, secret.environment.id): + raise GraphQLError("You don't have access to this secret") + return SecretEvent.objects.filter(secret_id=secret_id) + + def resolve_secret_tags(root, info, org_id): + if not user_is_org_member(info.context.user.userId, org_id): + raise GraphQLError("You don't have access to this Organisation") + + return SecretTag.objects.filter(organisation_id=org_id) + + def resolve_environment_keys(root, info, app_id=None, environment_id=None, member_id=None): + if app_id is None and environment_id is None: + return None + elif app_id is not None: + app = App.objects.get(id=app_id) + else: + app = Environment.objects.get(id=environment_id).app + + if not user_can_access_app(info.context.user.userId, app.id): + raise GraphQLError("You don't have access to this app") + + filter = { + 'environment__app': app, + 'deleted_at': None + } + + if environment_id: + filter['environment_id'] = environment_id + + if member_id is not None: + org_member = OrganisationMember.objects.get( + id=member_id, deleted_at=None) + else: + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=app.organisation, deleted_at=None) + + filter['user'] = org_member + + return EnvironmentKey.objects.filter(**filter) + + def resolve_environment_tokens(root, info, environment_id): + if not user_can_access_environment(info.context.user.userId, environment_id): + raise GraphQLError("You don't have access to this secret") + + env = Environment.objects.get(id=environment_id) + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=env.app.organisation, deleted_at=None) + return EnvironmentToken.objects.filter(environment=env, user=org_member) + + def resolve_user_tokens(root, info, organisation_id): + if not user_is_org_member(info.context.user.userId, organisation_id): + raise GraphQLError("You don't have access to this organisation") + + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation_id=organisation_id, deleted_at=None) + return UserToken.objects.filter(user=org_member, deleted_at=None) + + def resolve_service_tokens(root, info, app_id): app = App.objects.get(id=app_id) - if not app.organisation.id == org.id: + if not user_is_org_member(info.context.user.userId, app.organisation.id): + raise GraphQLError("You don't have access to this organisation") + + return ServiceToken.objects.filter(app=app, deleted_at=None) + + def resolve_logs(root, info, app_id, start=0, end=0): + if not user_can_access_app(info.context.user.userId, app_id): raise GraphQLError("You don't have access to this app") + + app = App.objects.get(id=app_id) + if end == 0: end = datetime.now().timestamp() * 1000 + if CLOUD_HOSTED: - return get_app_logs(f"phApp:v{app.app_version}:{app.identity_key}", start, end, 25) - logs = KMSDBLog.objects.filter(app_id=f"phApp:v{app.app_version}:{app.identity_key}",timestamp__lte=end, timestamp__gte=start).order_by('-timestamp')[:25] - return list(logs.values()) - - def resolve_logs_count(root, info, app_id): - owner = info.context.user - org = Organisation.objects.filter( - owner__userId=owner.userId).first() - app = App.objects.get(id=app_id) - if not app.organisation.id == org.id: + kms_logs = get_app_logs( + f"phApp:v{app.app_version}:{app.identity_key}", start, end, 25) + + else: + kms_logs = list(KMSDBLog.objects.filter( + app_id=f"phApp:v{app.app_version}:{app.identity_key}", timestamp__lte=end, timestamp__gte=start).order_by('-timestamp')[:25].values()) + + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=app.organisation, deleted_at=None) + + env_keys = EnvironmentKey.objects.filter( + environment__app=app, user=org_member, deleted_at=None + ).select_related('environment') + + envs = [env_key.environment for env_key in env_keys] + + start_dt = datetime.fromtimestamp(start / 1000) + end_dt = datetime.fromtimestamp(end / 1000) + + secret_events = SecretEvent.objects.filter( + environment__in=envs, timestamp__lte=end_dt, timestamp__gte=start_dt).order_by('-timestamp')[:25] + + return LogsResponseType(kms=kms_logs, secrets=secret_events) + + def resolve_kms_logs_count(root, info, app_id): + if not user_can_access_app(info.context.user.userId, app_id): raise GraphQLError("You don't have access to this app") + + app = App.objects.get(id=app_id) + if CLOUD_HOSTED: return get_app_log_count(f"phApp:v{app.app_version}:{app.identity_key}") return KMSDBLog.objects.filter(app_id=f"phApp:v{app.app_version}:{app.identity_key}").count() + def resolve_secrets_logs_count(root, info, app_id): + if not user_can_access_app(info.context.user.userId, app_id): + raise GraphQLError("You don't have access to this app") + + app = App.objects.get(id=app_id) + + org_member = OrganisationMember.objects.get( + user=info.context.user, organisation=app.organisation, deleted_at=None) + + env_keys = EnvironmentKey.objects.filter( + environment__app=app, user=org_member, deleted_at=None + ).select_related('environment') + + envs = [env_key.environment for env_key in env_keys] + + return SecretEvent.objects.filter(environment__in=envs).count() + def resolve_app_activity_chart(root, info, app_id, period=TimeRange.DAY): """ Converts app log activity for the chosen time period into time series data that can be used to draw a chart @@ -255,11 +304,9 @@ def resolve_app_activity_chart(root, info, app_id, period=TimeRange.DAY): Returns: List[ChartDataPointType]: Time series decrypt count data """ - owner = info.context.user - org = Organisation.objects.filter( - owner__userId=owner.userId).first() + app = App.objects.get(id=app_id) - if not app.organisation.id == org.id: + if not user_can_access_app(info.context.user.userId, app_id): raise GraphQLError("You don't have access to this app") end_date = datetime.now() # current time @@ -316,7 +363,8 @@ def resolve_app_activity_chart(root, info, app_id, period=TimeRange.DAY): decrypts = get_app_log_count_range( f"phApp:v{app.app_version}:{app.identity_key}", start_unix, end_unix) else: - decrypts = KMSDBLog.objects.filter(app_id=f"phApp:v{app.app_version}:{app.identity_key}",timestamp__lte=end_unix, timestamp__gte=start_unix).count() + decrypts = KMSDBLog.objects.filter( + app_id=f"phApp:v{app.app_version}:{app.identity_key}", timestamp__lte=end_unix, timestamp__gte=start_unix).count() time_series_logs.append(ChartDataPointType( index=str(index), date=end_unix, data=decrypts)) @@ -330,9 +378,37 @@ def resolve_app_activity_chart(root, info, app_id, period=TimeRange.DAY): class Mutation(graphene.ObjectType): create_organisation = CreateOrganisationMutation.Field() + invite_organisation_member = InviteOrganisationMemberMutation.Field() + create_organisation_member = CreateOrganisationMemberMutation.Field() + delete_organisation_member = DeleteOrganisationMemberMutation.Field() + update_organisation_member_role = UpdateOrganisationMemberRole.Field() + update_member_wrapped_secrets = UpdateUserWrappedSecretsMutation.Field() + + delete_invitation = DeleteInviteMutation.Field() + create_app = CreateAppMutation.Field() rotate_app_keys = RotateAppKeysMutation.Field() delete_app = DeleteAppMutation.Field() + add_app_member = AddAppMemberMutation.Field() + remove_app_member = RemoveAppMemberMutation.Field() + update_member_environment_scope = UpdateMemberEnvScopeMutation.Field() + + create_environment = CreateEnvironmentMutation.Field() + create_environment_key = CreateEnvironmentKeyMutation.Field() + create_environment_token = CreateEnvironmentTokenMutation.Field() + + create_user_token = CreateUserTokenMutation.Field() + delete_user_token = DeleteUserTokenMutation.Field() + + create_service_token = CreateServiceTokenMutation.Field() + delete_service_token = DeleteServiceTokenMutation.Field() + + create_secret_folder = CreateSecretFolderMutation.Field() + create_secret_tag = CreateSecretTagMutation.Field() + create_secret = CreateSecretMutation.Field() + edit_secret = EditSecretMutation.Field() + delete_secret = DeleteSecretMutation.Field() + read_secret = ReadSecretMutation.Field() schema = graphene.Schema(query=Query, mutation=Mutation) diff --git a/backend/backend/settings.py b/backend/backend/settings.py index 32125793..db939fea 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -121,6 +121,17 @@ OAUTH_REDIRECT_URI = os.getenv('OAUTH_REDIRECT_URI') + +# Email configurations +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = os.getenv('SMTP_SERVER') +EMAIL_PORT = int(os.getenv('SMTP_PORT', 587)) +EMAIL_USE_TLS = True +EMAIL_HOST_USER = os.getenv('SMTP_USERNAME') +EMAIL_HOST_PASSWORD = os.getenv('SMTP_PASSWORD') +DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL') + + SITE_ID = 1 MIDDLEWARE = [ @@ -256,4 +267,3 @@ APP_HOST = os.getenv('APP_HOST') except: APP_HOST = 'self' - \ No newline at end of file diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 872d7ed0..413acefe 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -3,7 +3,7 @@ from django.conf import settings from graphene_django.views import GraphQLView from django.views.decorators.csrf import csrf_exempt -from api.views import PrivateGraphQLView, logout_view, health_check, kms +from api.views import PrivateGraphQLView, logout_view, health_check, kms, SecretsView, user_token_kms, service_token_kms, secrets_tokens CLOUD_HOSTED = settings.APP_HOST == 'cloud' @@ -14,6 +14,8 @@ path('logout/', csrf_exempt(logout_view)), path('graphql/', csrf_exempt(PrivateGraphQLView.as_view(graphiql=True))), path('493c5048-99f9-4eac-ad0d-98c3740b491f/health', health_check), + path('secrets/', SecretsView.as_view()), + path('secrets/tokens/', secrets_tokens) ] if not CLOUD_HOSTED: diff --git a/backend/ee/LICENSE b/backend/ee/LICENSE index 5e43b4ed..6343eec5 100644 --- a/backend/ee/LICENSE +++ b/backend/ee/LICENSE @@ -9,13 +9,13 @@ and are in compliance with, the Phase Subscription Terms of Service, available at https://phase.dev/legal/terms, or other agreement governing the use of the Software, as agreed by you and Phase, and otherwise have a valid Phase Console Enterprise License for the -correct number of applications & user seats. Subject to the foregoing sentence, you are free to +correct number of user seats. Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software. You agree that Phase and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid Phase Console Enterprise subscription for the correct number of -applications & user seats. Notwithstanding the foregoing, you may copy and modify +user seats. Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes, without requiring a subscription. You agree that Phase and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications. You are not diff --git a/backend/ee/feature_flags.py b/backend/ee/feature_flags.py index df8d3728..c1ea1107 100644 --- a/backend/ee/feature_flags.py +++ b/backend/ee/feature_flags.py @@ -11,13 +11,14 @@ def allow_new_app(organisation): Returns: bool: Whether or not to allow creating an app for the given org """ - FREE_APP_LIMIT = 1 + FREE_APP_LIMIT = 5 PRO_APP_LIMIT = 10 - current_app_count = App.objects.filter(organisation=organisation, is_deleted=False).count() - + current_app_count = App.objects.filter( + organisation=organisation, is_deleted=False).count() + if organisation.plan == Organisation.FREE_PLAN and current_app_count >= FREE_APP_LIMIT: return False elif organisation.plan == Organisation.PRO_PLAN and current_app_count >= PRO_APP_LIMIT: return False - return True \ No newline at end of file + return True diff --git a/docker-compose.yml b/docker-compose.yml index 04865871..d327cff4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,7 @@ services: NEXTAUTH_URL: "${HTTP_PROTOCOL}${HOST}" OAUTH_REDIRECT_URI: "${HTTP_PROTOCOL}${HOST}" BACKEND_API_BASE: "http://backend:8000" - NEXT_PUBLIC_BACKEND_API_BASE: "${HTTP_PROTOCOL}${HOST}/ph-backend" + NEXT_PUBLIC_BACKEND_API_BASE: "${HTTP_PROTOCOL}${HOST}/service" NEXT_PUBLIC_NEXTAUTH_PROVIDERS: "${SSO_PROVIDERS}" networks: - phase-net diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 00000000..d0679104 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} \ No newline at end of file diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index b04b6771..497ecb2e 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -1,8 +1,65 @@ /* eslint-disable */ -import * as types from './graphql' -import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core' +import * as types from './graphql'; +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + +/** + * Map of all GraphQL operations in the project. + * + * This map has several performance disadvantages: + * 1. It is not tree-shakeable, so it will include all operations in the project. + * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. + * 3. It does not support dead code elimination, so it will add unused operations. + * + * Therefore it is highly recommended to use the babel or swc plugin for production. + */ +const documents = { + "mutation AddMemberToApp($memberId: ID!, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n addAppMember(memberId: $memberId, appId: $appId, envKeys: $envKeys) {\n app {\n id\n }\n }\n}": types.AddMemberToAppDocument, + "mutation RemoveMemberFromApp($memberId: ID!, $appId: ID!) {\n removeAppMember(memberId: $memberId, appId: $appId) {\n app {\n id\n }\n }\n}": types.RemoveMemberFromAppDocument, + "mutation UpdateEnvScope($memberId: ID!, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n updateMemberEnvironmentScope(\n memberId: $memberId\n appId: $appId\n envKeys: $envKeys\n ) {\n app {\n id\n }\n }\n}": types.UpdateEnvScopeDocument, + "mutation CreateApplication($id: ID!, $organisationId: ID!, $name: String!, $identityKey: String!, $appToken: String!, $appSeed: String!, $wrappedKeyShare: String!, $appVersion: Int!) {\n createApp(\n id: $id\n organisationId: $organisationId\n name: $name\n identityKey: $identityKey\n appToken: $appToken\n appSeed: $appSeed\n wrappedKeyShare: $wrappedKeyShare\n appVersion: $appVersion\n ) {\n app {\n id\n name\n identityKey\n }\n }\n}": types.CreateApplicationDocument, + "mutation CreateOrg($id: ID!, $name: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n createOrganisation(\n id: $id\n name: $name\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n organisation {\n id\n name\n createdAt\n }\n }\n}": types.CreateOrgDocument, + "mutation DeleteApplication($id: ID!) {\n deleteApp(id: $id) {\n app {\n id\n }\n }\n}": types.DeleteApplicationDocument, + "mutation CreateEnv($input: EnvironmentInput!) {\n createEnvironment(environmentData: $input) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n}": types.CreateEnvDocument, + "mutation CreateEnvKey($envId: ID!, $userId: ID, $wrappedSeed: String!, $wrappedSalt: String!, $identityKey: String!) {\n createEnvironmentKey(\n envId: $envId\n userId: $userId\n wrappedSeed: $wrappedSeed\n wrappedSalt: $wrappedSalt\n identityKey: $identityKey\n ) {\n environmentKey {\n id\n createdAt\n }\n }\n}": types.CreateEnvKeyDocument, + "mutation CreateEnvToken($envId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!) {\n createEnvironmentToken(\n envId: $envId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n ) {\n environmentToken {\n id\n createdAt\n }\n }\n}": types.CreateEnvTokenDocument, + "mutation CreateNewSecret($newSecret: SecretInput!) {\n createSecret(secretData: $newSecret) {\n secret {\n id\n key\n value\n createdAt\n }\n }\n}": types.CreateNewSecretDocument, + "mutation CreateNewSecretTag($orgId: ID!, $name: String!, $color: String!) {\n createSecretTag(orgId: $orgId, name: $name, color: $color) {\n tag {\n id\n }\n }\n}": types.CreateNewSecretTagDocument, + "mutation CreateNewServiceToken($appId: ID!, $environmentKeys: [EnvironmentKeyInput], $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $name: String!, $expiry: BigInt) {\n createServiceToken(\n appId: $appId\n environmentKeys: $environmentKeys\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n name: $name\n expiry: $expiry\n ) {\n serviceToken {\n id\n createdAt\n expiresAt\n }\n }\n}": types.CreateNewServiceTokenDocument, + "mutation DeleteSecretOp($id: ID!) {\n deleteSecret(id: $id) {\n secret {\n id\n }\n }\n}": types.DeleteSecretOpDocument, + "mutation RevokeServiceToken($tokenId: ID!) {\n deleteServiceToken(tokenId: $tokenId) {\n ok\n }\n}": types.RevokeServiceTokenDocument, + "mutation UpdateSecret($id: ID!, $secretData: SecretInput!) {\n editSecret(id: $id, secretData: $secretData) {\n secret {\n id\n updatedAt\n }\n }\n}": types.UpdateSecretDocument, + "mutation InitAppEnvironments($devEnv: EnvironmentInput!, $stagingEnv: EnvironmentInput!, $prodEnv: EnvironmentInput!, $devAdminKeys: [EnvironmentKeyInput], $stagAdminKeys: [EnvironmentKeyInput], $prodAdminKeys: [EnvironmentKeyInput]) {\n devEnvironment: createEnvironment(\n environmentData: $devEnv\n adminKeys: $devAdminKeys\n ) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n stagingEnvironment: createEnvironment(\n environmentData: $stagingEnv\n adminKeys: $stagAdminKeys\n ) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n prodEnvironment: createEnvironment(\n environmentData: $prodEnv\n adminKeys: $prodAdminKeys\n ) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n}": types.InitAppEnvironmentsDocument, + "mutation LogSecretRead($id: ID!) {\n readSecret(id: $id) {\n ok\n }\n}": types.LogSecretReadDocument, + "mutation AcceptOrganisationInvite($orgId: ID!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!, $inviteId: ID!) {\n createOrganisationMember(\n orgId: $orgId\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n inviteId: $inviteId\n ) {\n orgMember {\n id\n email\n createdAt\n role\n }\n }\n}": types.AcceptOrganisationInviteDocument, + "mutation DeleteOrgInvite($inviteId: ID!) {\n deleteInvitation(inviteId: $inviteId) {\n ok\n }\n}": types.DeleteOrgInviteDocument, + "mutation RemoveMember($memberId: ID!) {\n deleteOrganisationMember(memberId: $memberId) {\n ok\n }\n}": types.RemoveMemberDocument, + "mutation InviteMember($orgId: ID!, $email: String!, $apps: [String], $role: String) {\n inviteOrganisationMember(orgId: $orgId, email: $email, apps: $apps, role: $role) {\n invite {\n id\n }\n }\n}": types.InviteMemberDocument, + "mutation UpdateMemberRole($memberId: ID!, $role: String!) {\n updateOrganisationMemberRole(memberId: $memberId, role: $role) {\n orgMember {\n id\n role\n }\n }\n}": types.UpdateMemberRoleDocument, + "mutation UpdateWrappedSecrets($orgId: ID!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": types.UpdateWrappedSecretsDocument, + "mutation RotateAppKey($id: ID!, $appToken: String!, $wrappedKeyShare: String!) {\n rotateAppKeys(id: $id, appToken: $appToken, wrappedKeyShare: $wrappedKeyShare) {\n app {\n id\n }\n }\n}": types.RotateAppKeyDocument, + "mutation CreateNewUserToken($orgId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $expiry: BigInt) {\n createUserToken(\n orgId: $orgId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n expiry: $expiry\n ) {\n ok\n }\n}": types.CreateNewUserTokenDocument, + "mutation RevokeUserToken($tokenId: ID!) {\n deleteUserToken(tokenId: $tokenId) {\n ok\n }\n}": types.RevokeUserTokenDocument, + "query GetAppMembers($appId: ID!) {\n appUsers(appId: $appId) {\n id\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n role\n }\n}": types.GetAppMembersDocument, + "query GetAppActivityChart($appId: ID!, $period: TimeRange) {\n appActivityChart(appId: $appId, period: $period) {\n index\n date\n data\n }\n}": types.GetAppActivityChartDocument, + "query GetAppDetail($organisationId: ID!, $appId: ID!) {\n apps(organisationId: $organisationId, appId: $appId) {\n id\n name\n identityKey\n createdAt\n appToken\n appSeed\n appVersion\n }\n}": types.GetAppDetailDocument, + "query GetAppKmsLogs($appId: ID!, $start: BigInt, $end: BigInt) {\n logs(appId: $appId, start: $start, end: $end) {\n kms {\n id\n timestamp\n phaseNode\n eventType\n ipAddress\n country\n city\n phSize\n }\n }\n kmsLogsCount(appId: $appId)\n}": types.GetAppKmsLogsDocument, + "query GetApps($organisationId: ID!, $appId: ID!) {\n apps(organisationId: $organisationId, appId: $appId) {\n id\n name\n identityKey\n createdAt\n }\n}": types.GetAppsDocument, + "query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n role\n memberId\n keyring\n recovery\n }\n}": types.GetOrganisationsDocument, + "query GetInvites($orgId: ID!) {\n organisationInvites(orgId: $orgId) {\n id\n createdAt\n expiresAt\n invitedBy {\n email\n fullName\n self\n }\n inviteeEmail\n }\n}": types.GetInvitesDocument, + "query GetOrganisationAdminsAndSelf($organisationId: ID!) {\n organisationAdminsAndSelf(organisationId: $organisationId) {\n id\n role\n identityKey\n self\n }\n}": types.GetOrganisationAdminsAndSelfDocument, + "query GetOrganisationMembers($organisationId: ID!, $role: [String]) {\n organisationMembers(organisationId: $organisationId, role: $role) {\n id\n role\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n self\n }\n}": types.GetOrganisationMembersDocument, + "query VerifyInvite($inviteId: ID!) {\n validateInvite(inviteId: $inviteId) {\n id\n organisation {\n id\n name\n }\n inviteeEmail\n invitedBy {\n email\n }\n apps {\n id\n name\n }\n }\n}": types.VerifyInviteDocument, + "query GetAppEnvironments($appId: ID!, $memberId: ID) {\n appEnvironments(appId: $appId, environmentId: null, memberId: $memberId) {\n id\n name\n envType\n identityKey\n wrappedSeed\n wrappedSalt\n createdAt\n }\n}": types.GetAppEnvironmentsDocument, + "query GetAppSecretsLogs($appId: ID!, $start: BigInt, $end: BigInt) {\n logs(appId: $appId, start: $start, end: $end) {\n secrets {\n id\n key\n value\n tags {\n id\n name\n color\n }\n version\n comment\n timestamp\n ipAddress\n userAgent\n user {\n email\n username\n fullName\n avatarUrl\n }\n eventType\n environment {\n id\n envType\n name\n }\n secret {\n id\n }\n }\n }\n secretsLogsCount(appId: $appId)\n environmentKeys(appId: $appId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n environment {\n id\n }\n }\n}": types.GetAppSecretsLogsDocument, + "query GetEnvironmentKey($envId: ID!, $appId: ID!) {\n environmentKeys(environmentId: $envId, appId: $appId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n}": types.GetEnvironmentKeyDocument, + "query GetEnvironmentTokens($envId: ID!) {\n environmentTokens(environmentId: $envId) {\n id\n name\n wrappedKeyShare\n createdAt\n }\n}": types.GetEnvironmentTokensDocument, + "query GetEnvSecretsKV($envId: ID!) {\n secrets(envId: $envId) {\n id\n key\n value\n }\n environmentKeys(environmentId: $envId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n}": types.GetEnvSecretsKvDocument, + "query GetSecretTags($orgId: ID!) {\n secretTags(orgId: $orgId) {\n id\n name\n color\n }\n}": types.GetSecretTagsDocument, + "query GetSecrets($appId: ID!, $envId: ID!) {\n secrets(envId: $envId) {\n id\n key\n value\n tags {\n id\n name\n color\n }\n comment\n createdAt\n history {\n id\n key\n value\n tags {\n id\n name\n color\n }\n version\n comment\n timestamp\n ipAddress\n userAgent\n user {\n email\n username\n fullName\n avatarUrl\n }\n eventType\n }\n }\n appEnvironments(appId: $appId, environmentId: $envId) {\n id\n name\n envType\n identityKey\n }\n environmentKeys(appId: $appId, environmentId: $envId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n}": types.GetSecretsDocument, + "query GetServiceTokens($appId: ID!) {\n serviceTokens(appId: $appId) {\n id\n name\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n expiresAt\n keys {\n id\n }\n }\n}": types.GetServiceTokensDocument, + "query GetUserTokens($organisationId: ID!) {\n userTokens(organisationId: $organisationId) {\n id\n name\n wrappedKeyShare\n createdAt\n expiresAt\n }\n}": types.GetUserTokensDocument, +}; -const documents: never[] = [] /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * @@ -15,11 +72,191 @@ const documents: never[] = [] * The query argument is unknown! * Please regenerate the types. */ -export function graphql(source: string): unknown +export function graphql(source: string): unknown; + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation AddMemberToApp($memberId: ID!, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n addAppMember(memberId: $memberId, appId: $appId, envKeys: $envKeys) {\n app {\n id\n }\n }\n}"): (typeof documents)["mutation AddMemberToApp($memberId: ID!, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n addAppMember(memberId: $memberId, appId: $appId, envKeys: $envKeys) {\n app {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation RemoveMemberFromApp($memberId: ID!, $appId: ID!) {\n removeAppMember(memberId: $memberId, appId: $appId) {\n app {\n id\n }\n }\n}"): (typeof documents)["mutation RemoveMemberFromApp($memberId: ID!, $appId: ID!) {\n removeAppMember(memberId: $memberId, appId: $appId) {\n app {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation UpdateEnvScope($memberId: ID!, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n updateMemberEnvironmentScope(\n memberId: $memberId\n appId: $appId\n envKeys: $envKeys\n ) {\n app {\n id\n }\n }\n}"): (typeof documents)["mutation UpdateEnvScope($memberId: ID!, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n updateMemberEnvironmentScope(\n memberId: $memberId\n appId: $appId\n envKeys: $envKeys\n ) {\n app {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation CreateApplication($id: ID!, $organisationId: ID!, $name: String!, $identityKey: String!, $appToken: String!, $appSeed: String!, $wrappedKeyShare: String!, $appVersion: Int!) {\n createApp(\n id: $id\n organisationId: $organisationId\n name: $name\n identityKey: $identityKey\n appToken: $appToken\n appSeed: $appSeed\n wrappedKeyShare: $wrappedKeyShare\n appVersion: $appVersion\n ) {\n app {\n id\n name\n identityKey\n }\n }\n}"): (typeof documents)["mutation CreateApplication($id: ID!, $organisationId: ID!, $name: String!, $identityKey: String!, $appToken: String!, $appSeed: String!, $wrappedKeyShare: String!, $appVersion: Int!) {\n createApp(\n id: $id\n organisationId: $organisationId\n name: $name\n identityKey: $identityKey\n appToken: $appToken\n appSeed: $appSeed\n wrappedKeyShare: $wrappedKeyShare\n appVersion: $appVersion\n ) {\n app {\n id\n name\n identityKey\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation CreateOrg($id: ID!, $name: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n createOrganisation(\n id: $id\n name: $name\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n organisation {\n id\n name\n createdAt\n }\n }\n}"): (typeof documents)["mutation CreateOrg($id: ID!, $name: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n createOrganisation(\n id: $id\n name: $name\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n organisation {\n id\n name\n createdAt\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation DeleteApplication($id: ID!) {\n deleteApp(id: $id) {\n app {\n id\n }\n }\n}"): (typeof documents)["mutation DeleteApplication($id: ID!) {\n deleteApp(id: $id) {\n app {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation CreateEnv($input: EnvironmentInput!) {\n createEnvironment(environmentData: $input) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n}"): (typeof documents)["mutation CreateEnv($input: EnvironmentInput!) {\n createEnvironment(environmentData: $input) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation CreateEnvKey($envId: ID!, $userId: ID, $wrappedSeed: String!, $wrappedSalt: String!, $identityKey: String!) {\n createEnvironmentKey(\n envId: $envId\n userId: $userId\n wrappedSeed: $wrappedSeed\n wrappedSalt: $wrappedSalt\n identityKey: $identityKey\n ) {\n environmentKey {\n id\n createdAt\n }\n }\n}"): (typeof documents)["mutation CreateEnvKey($envId: ID!, $userId: ID, $wrappedSeed: String!, $wrappedSalt: String!, $identityKey: String!) {\n createEnvironmentKey(\n envId: $envId\n userId: $userId\n wrappedSeed: $wrappedSeed\n wrappedSalt: $wrappedSalt\n identityKey: $identityKey\n ) {\n environmentKey {\n id\n createdAt\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation CreateEnvToken($envId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!) {\n createEnvironmentToken(\n envId: $envId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n ) {\n environmentToken {\n id\n createdAt\n }\n }\n}"): (typeof documents)["mutation CreateEnvToken($envId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!) {\n createEnvironmentToken(\n envId: $envId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n ) {\n environmentToken {\n id\n createdAt\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation CreateNewSecret($newSecret: SecretInput!) {\n createSecret(secretData: $newSecret) {\n secret {\n id\n key\n value\n createdAt\n }\n }\n}"): (typeof documents)["mutation CreateNewSecret($newSecret: SecretInput!) {\n createSecret(secretData: $newSecret) {\n secret {\n id\n key\n value\n createdAt\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation CreateNewSecretTag($orgId: ID!, $name: String!, $color: String!) {\n createSecretTag(orgId: $orgId, name: $name, color: $color) {\n tag {\n id\n }\n }\n}"): (typeof documents)["mutation CreateNewSecretTag($orgId: ID!, $name: String!, $color: String!) {\n createSecretTag(orgId: $orgId, name: $name, color: $color) {\n tag {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation CreateNewServiceToken($appId: ID!, $environmentKeys: [EnvironmentKeyInput], $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $name: String!, $expiry: BigInt) {\n createServiceToken(\n appId: $appId\n environmentKeys: $environmentKeys\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n name: $name\n expiry: $expiry\n ) {\n serviceToken {\n id\n createdAt\n expiresAt\n }\n }\n}"): (typeof documents)["mutation CreateNewServiceToken($appId: ID!, $environmentKeys: [EnvironmentKeyInput], $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $name: String!, $expiry: BigInt) {\n createServiceToken(\n appId: $appId\n environmentKeys: $environmentKeys\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n name: $name\n expiry: $expiry\n ) {\n serviceToken {\n id\n createdAt\n expiresAt\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation DeleteSecretOp($id: ID!) {\n deleteSecret(id: $id) {\n secret {\n id\n }\n }\n}"): (typeof documents)["mutation DeleteSecretOp($id: ID!) {\n deleteSecret(id: $id) {\n secret {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation RevokeServiceToken($tokenId: ID!) {\n deleteServiceToken(tokenId: $tokenId) {\n ok\n }\n}"): (typeof documents)["mutation RevokeServiceToken($tokenId: ID!) {\n deleteServiceToken(tokenId: $tokenId) {\n ok\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation UpdateSecret($id: ID!, $secretData: SecretInput!) {\n editSecret(id: $id, secretData: $secretData) {\n secret {\n id\n updatedAt\n }\n }\n}"): (typeof documents)["mutation UpdateSecret($id: ID!, $secretData: SecretInput!) {\n editSecret(id: $id, secretData: $secretData) {\n secret {\n id\n updatedAt\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation InitAppEnvironments($devEnv: EnvironmentInput!, $stagingEnv: EnvironmentInput!, $prodEnv: EnvironmentInput!, $devAdminKeys: [EnvironmentKeyInput], $stagAdminKeys: [EnvironmentKeyInput], $prodAdminKeys: [EnvironmentKeyInput]) {\n devEnvironment: createEnvironment(\n environmentData: $devEnv\n adminKeys: $devAdminKeys\n ) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n stagingEnvironment: createEnvironment(\n environmentData: $stagingEnv\n adminKeys: $stagAdminKeys\n ) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n prodEnvironment: createEnvironment(\n environmentData: $prodEnv\n adminKeys: $prodAdminKeys\n ) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n}"): (typeof documents)["mutation InitAppEnvironments($devEnv: EnvironmentInput!, $stagingEnv: EnvironmentInput!, $prodEnv: EnvironmentInput!, $devAdminKeys: [EnvironmentKeyInput], $stagAdminKeys: [EnvironmentKeyInput], $prodAdminKeys: [EnvironmentKeyInput]) {\n devEnvironment: createEnvironment(\n environmentData: $devEnv\n adminKeys: $devAdminKeys\n ) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n stagingEnvironment: createEnvironment(\n environmentData: $stagingEnv\n adminKeys: $stagAdminKeys\n ) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n prodEnvironment: createEnvironment(\n environmentData: $prodEnv\n adminKeys: $prodAdminKeys\n ) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation LogSecretRead($id: ID!) {\n readSecret(id: $id) {\n ok\n }\n}"): (typeof documents)["mutation LogSecretRead($id: ID!) {\n readSecret(id: $id) {\n ok\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation AcceptOrganisationInvite($orgId: ID!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!, $inviteId: ID!) {\n createOrganisationMember(\n orgId: $orgId\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n inviteId: $inviteId\n ) {\n orgMember {\n id\n email\n createdAt\n role\n }\n }\n}"): (typeof documents)["mutation AcceptOrganisationInvite($orgId: ID!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!, $inviteId: ID!) {\n createOrganisationMember(\n orgId: $orgId\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n inviteId: $inviteId\n ) {\n orgMember {\n id\n email\n createdAt\n role\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation DeleteOrgInvite($inviteId: ID!) {\n deleteInvitation(inviteId: $inviteId) {\n ok\n }\n}"): (typeof documents)["mutation DeleteOrgInvite($inviteId: ID!) {\n deleteInvitation(inviteId: $inviteId) {\n ok\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation RemoveMember($memberId: ID!) {\n deleteOrganisationMember(memberId: $memberId) {\n ok\n }\n}"): (typeof documents)["mutation RemoveMember($memberId: ID!) {\n deleteOrganisationMember(memberId: $memberId) {\n ok\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation InviteMember($orgId: ID!, $email: String!, $apps: [String], $role: String) {\n inviteOrganisationMember(orgId: $orgId, email: $email, apps: $apps, role: $role) {\n invite {\n id\n }\n }\n}"): (typeof documents)["mutation InviteMember($orgId: ID!, $email: String!, $apps: [String], $role: String) {\n inviteOrganisationMember(orgId: $orgId, email: $email, apps: $apps, role: $role) {\n invite {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation UpdateMemberRole($memberId: ID!, $role: String!) {\n updateOrganisationMemberRole(memberId: $memberId, role: $role) {\n orgMember {\n id\n role\n }\n }\n}"): (typeof documents)["mutation UpdateMemberRole($memberId: ID!, $role: String!) {\n updateOrganisationMemberRole(memberId: $memberId, role: $role) {\n orgMember {\n id\n role\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation UpdateWrappedSecrets($orgId: ID!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}"): (typeof documents)["mutation UpdateWrappedSecrets($orgId: ID!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation RotateAppKey($id: ID!, $appToken: String!, $wrappedKeyShare: String!) {\n rotateAppKeys(id: $id, appToken: $appToken, wrappedKeyShare: $wrappedKeyShare) {\n app {\n id\n }\n }\n}"): (typeof documents)["mutation RotateAppKey($id: ID!, $appToken: String!, $wrappedKeyShare: String!) {\n rotateAppKeys(id: $id, appToken: $appToken, wrappedKeyShare: $wrappedKeyShare) {\n app {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation CreateNewUserToken($orgId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $expiry: BigInt) {\n createUserToken(\n orgId: $orgId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n expiry: $expiry\n ) {\n ok\n }\n}"): (typeof documents)["mutation CreateNewUserToken($orgId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $expiry: BigInt) {\n createUserToken(\n orgId: $orgId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n expiry: $expiry\n ) {\n ok\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation RevokeUserToken($tokenId: ID!) {\n deleteUserToken(tokenId: $tokenId) {\n ok\n }\n}"): (typeof documents)["mutation RevokeUserToken($tokenId: ID!) {\n deleteUserToken(tokenId: $tokenId) {\n ok\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetAppMembers($appId: ID!) {\n appUsers(appId: $appId) {\n id\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n role\n }\n}"): (typeof documents)["query GetAppMembers($appId: ID!) {\n appUsers(appId: $appId) {\n id\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n role\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetAppActivityChart($appId: ID!, $period: TimeRange) {\n appActivityChart(appId: $appId, period: $period) {\n index\n date\n data\n }\n}"): (typeof documents)["query GetAppActivityChart($appId: ID!, $period: TimeRange) {\n appActivityChart(appId: $appId, period: $period) {\n index\n date\n data\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetAppDetail($organisationId: ID!, $appId: ID!) {\n apps(organisationId: $organisationId, appId: $appId) {\n id\n name\n identityKey\n createdAt\n appToken\n appSeed\n appVersion\n }\n}"): (typeof documents)["query GetAppDetail($organisationId: ID!, $appId: ID!) {\n apps(organisationId: $organisationId, appId: $appId) {\n id\n name\n identityKey\n createdAt\n appToken\n appSeed\n appVersion\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetAppKmsLogs($appId: ID!, $start: BigInt, $end: BigInt) {\n logs(appId: $appId, start: $start, end: $end) {\n kms {\n id\n timestamp\n phaseNode\n eventType\n ipAddress\n country\n city\n phSize\n }\n }\n kmsLogsCount(appId: $appId)\n}"): (typeof documents)["query GetAppKmsLogs($appId: ID!, $start: BigInt, $end: BigInt) {\n logs(appId: $appId, start: $start, end: $end) {\n kms {\n id\n timestamp\n phaseNode\n eventType\n ipAddress\n country\n city\n phSize\n }\n }\n kmsLogsCount(appId: $appId)\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetApps($organisationId: ID!, $appId: ID!) {\n apps(organisationId: $organisationId, appId: $appId) {\n id\n name\n identityKey\n createdAt\n }\n}"): (typeof documents)["query GetApps($organisationId: ID!, $appId: ID!) {\n apps(organisationId: $organisationId, appId: $appId) {\n id\n name\n identityKey\n createdAt\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n role\n memberId\n keyring\n recovery\n }\n}"): (typeof documents)["query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n role\n memberId\n keyring\n recovery\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetInvites($orgId: ID!) {\n organisationInvites(orgId: $orgId) {\n id\n createdAt\n expiresAt\n invitedBy {\n email\n fullName\n self\n }\n inviteeEmail\n }\n}"): (typeof documents)["query GetInvites($orgId: ID!) {\n organisationInvites(orgId: $orgId) {\n id\n createdAt\n expiresAt\n invitedBy {\n email\n fullName\n self\n }\n inviteeEmail\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetOrganisationAdminsAndSelf($organisationId: ID!) {\n organisationAdminsAndSelf(organisationId: $organisationId) {\n id\n role\n identityKey\n self\n }\n}"): (typeof documents)["query GetOrganisationAdminsAndSelf($organisationId: ID!) {\n organisationAdminsAndSelf(organisationId: $organisationId) {\n id\n role\n identityKey\n self\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetOrganisationMembers($organisationId: ID!, $role: [String]) {\n organisationMembers(organisationId: $organisationId, role: $role) {\n id\n role\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n self\n }\n}"): (typeof documents)["query GetOrganisationMembers($organisationId: ID!, $role: [String]) {\n organisationMembers(organisationId: $organisationId, role: $role) {\n id\n role\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n self\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query VerifyInvite($inviteId: ID!) {\n validateInvite(inviteId: $inviteId) {\n id\n organisation {\n id\n name\n }\n inviteeEmail\n invitedBy {\n email\n }\n apps {\n id\n name\n }\n }\n}"): (typeof documents)["query VerifyInvite($inviteId: ID!) {\n validateInvite(inviteId: $inviteId) {\n id\n organisation {\n id\n name\n }\n inviteeEmail\n invitedBy {\n email\n }\n apps {\n id\n name\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetAppEnvironments($appId: ID!, $memberId: ID) {\n appEnvironments(appId: $appId, environmentId: null, memberId: $memberId) {\n id\n name\n envType\n identityKey\n wrappedSeed\n wrappedSalt\n createdAt\n }\n}"): (typeof documents)["query GetAppEnvironments($appId: ID!, $memberId: ID) {\n appEnvironments(appId: $appId, environmentId: null, memberId: $memberId) {\n id\n name\n envType\n identityKey\n wrappedSeed\n wrappedSalt\n createdAt\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetAppSecretsLogs($appId: ID!, $start: BigInt, $end: BigInt) {\n logs(appId: $appId, start: $start, end: $end) {\n secrets {\n id\n key\n value\n tags {\n id\n name\n color\n }\n version\n comment\n timestamp\n ipAddress\n userAgent\n user {\n email\n username\n fullName\n avatarUrl\n }\n eventType\n environment {\n id\n envType\n name\n }\n secret {\n id\n }\n }\n }\n secretsLogsCount(appId: $appId)\n environmentKeys(appId: $appId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n environment {\n id\n }\n }\n}"): (typeof documents)["query GetAppSecretsLogs($appId: ID!, $start: BigInt, $end: BigInt) {\n logs(appId: $appId, start: $start, end: $end) {\n secrets {\n id\n key\n value\n tags {\n id\n name\n color\n }\n version\n comment\n timestamp\n ipAddress\n userAgent\n user {\n email\n username\n fullName\n avatarUrl\n }\n eventType\n environment {\n id\n envType\n name\n }\n secret {\n id\n }\n }\n }\n secretsLogsCount(appId: $appId)\n environmentKeys(appId: $appId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n environment {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetEnvironmentKey($envId: ID!, $appId: ID!) {\n environmentKeys(environmentId: $envId, appId: $appId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n}"): (typeof documents)["query GetEnvironmentKey($envId: ID!, $appId: ID!) {\n environmentKeys(environmentId: $envId, appId: $appId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetEnvironmentTokens($envId: ID!) {\n environmentTokens(environmentId: $envId) {\n id\n name\n wrappedKeyShare\n createdAt\n }\n}"): (typeof documents)["query GetEnvironmentTokens($envId: ID!) {\n environmentTokens(environmentId: $envId) {\n id\n name\n wrappedKeyShare\n createdAt\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetEnvSecretsKV($envId: ID!) {\n secrets(envId: $envId) {\n id\n key\n value\n }\n environmentKeys(environmentId: $envId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n}"): (typeof documents)["query GetEnvSecretsKV($envId: ID!) {\n secrets(envId: $envId) {\n id\n key\n value\n }\n environmentKeys(environmentId: $envId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetSecretTags($orgId: ID!) {\n secretTags(orgId: $orgId) {\n id\n name\n color\n }\n}"): (typeof documents)["query GetSecretTags($orgId: ID!) {\n secretTags(orgId: $orgId) {\n id\n name\n color\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetSecrets($appId: ID!, $envId: ID!) {\n secrets(envId: $envId) {\n id\n key\n value\n tags {\n id\n name\n color\n }\n comment\n createdAt\n history {\n id\n key\n value\n tags {\n id\n name\n color\n }\n version\n comment\n timestamp\n ipAddress\n userAgent\n user {\n email\n username\n fullName\n avatarUrl\n }\n eventType\n }\n }\n appEnvironments(appId: $appId, environmentId: $envId) {\n id\n name\n envType\n identityKey\n }\n environmentKeys(appId: $appId, environmentId: $envId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n}"): (typeof documents)["query GetSecrets($appId: ID!, $envId: ID!) {\n secrets(envId: $envId) {\n id\n key\n value\n tags {\n id\n name\n color\n }\n comment\n createdAt\n history {\n id\n key\n value\n tags {\n id\n name\n color\n }\n version\n comment\n timestamp\n ipAddress\n userAgent\n user {\n email\n username\n fullName\n avatarUrl\n }\n eventType\n }\n }\n appEnvironments(appId: $appId, environmentId: $envId) {\n id\n name\n envType\n identityKey\n }\n environmentKeys(appId: $appId, environmentId: $envId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetServiceTokens($appId: ID!) {\n serviceTokens(appId: $appId) {\n id\n name\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n expiresAt\n keys {\n id\n }\n }\n}"): (typeof documents)["query GetServiceTokens($appId: ID!) {\n serviceTokens(appId: $appId) {\n id\n name\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n expiresAt\n keys {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetUserTokens($organisationId: ID!) {\n userTokens(organisationId: $organisationId) {\n id\n name\n wrappedKeyShare\n createdAt\n expiresAt\n }\n}"): (typeof documents)["query GetUserTokens($organisationId: ID!) {\n userTokens(organisationId: $organisationId) {\n id\n name\n wrappedKeyShare\n createdAt\n expiresAt\n }\n}"]; export function graphql(source: string) { - return (documents as any)[source] ?? {} + return (documents as any)[source] ?? {}; } -export type DocumentType> = - TDocumentNode extends DocumentNode ? TType : never +export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never; \ No newline at end of file diff --git a/frontend/apollo/graphql.ts b/frontend/apollo/graphql.ts index ac182286..a6b000c9 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -1,28 +1,64 @@ /* eslint-disable */ -export type Maybe = T | null -export type InputMaybe = Maybe -export type Exact = { [K in keyof T]: T[K] } -export type MakeOptional = Omit & { [SubKey in K]?: Maybe } -export type MakeMaybe = Omit & { [SubKey in K]: Maybe } +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; /** All built-in and custom scalars, mapped to their actual values */ export type Scalars = { - ID: string - String: string - Boolean: boolean - Int: number - Float: number + ID: string; + String: string; + Boolean: boolean; + Int: number; + Float: number; /** * The `BigInt` scalar type represents non-fractional whole numeric values. * `BigInt` is not constrained to 32-bit like the `Int` type and thus is a less * compatible type. */ - BigInt: any + BigInt: any; /** * The `DateTime` scalar type represents a DateTime * value as specified by * [iso8601](https://en.wikipedia.org/wiki/ISO_8601). */ - DateTime: any + DateTime: any; +}; + +export type AddAppMemberMutation = { + __typename?: 'AddAppMemberMutation'; + app?: Maybe; +}; + +/** An enumeration. */ +export enum ApiEnvironmentEnvTypeChoices { + /** Development */ + Dev = 'DEV', + /** Production */ + Prod = 'PROD', + /** Staging */ + Staging = 'STAGING' +} + +/** An enumeration. */ +export enum ApiOrganisationMemberInviteRoleChoices { + /** Admin */ + Admin = 'ADMIN', + /** Developer */ + Dev = 'DEV', + /** Owner */ + Owner = 'OWNER' +} + +/** An enumeration. */ +export enum ApiOrganisationMemberRoleChoices { + /** Admin */ + Admin = 'ADMIN', + /** Developer */ + Dev = 'DEV', + /** Owner */ + Owner = 'OWNER' } /** An enumeration. */ @@ -32,145 +68,681 @@ export enum ApiOrganisationPlanChoices { /** Free */ Fr = 'FR', /** Pro */ - Pr = 'PR', + Pr = 'PR' } -export type AppType = { - __typename?: 'AppType' - appSeed: Scalars['String'] - appToken: Scalars['String'] - appVersion: Scalars['Int'] - createdAt?: Maybe - id: Scalars['String'] - identityKey: Scalars['String'] - name: Scalars['String'] - wrappedKeyShare: Scalars['String'] +/** An enumeration. */ +export enum ApiSecretEventEventTypeChoices { + /** Create */ + C = 'C', + /** Delete */ + D = 'D', + /** Read */ + R = 'R', + /** Update */ + U = 'U' } +export type AppType = { + __typename?: 'AppType'; + appSeed: Scalars['String']; + appToken: Scalars['String']; + appVersion: Scalars['Int']; + createdAt?: Maybe; + id: Scalars['String']; + identityKey: Scalars['String']; + name: Scalars['String']; + wrappedKeyShare: Scalars['String']; +}; + export type ChartDataPointType = { - __typename?: 'ChartDataPointType' - data?: Maybe - date?: Maybe - index?: Maybe -} + __typename?: 'ChartDataPointType'; + data?: Maybe; + date?: Maybe; + index?: Maybe; +}; export type CreateAppMutation = { - __typename?: 'CreateAppMutation' - app?: Maybe -} + __typename?: 'CreateAppMutation'; + app?: Maybe; +}; + +export type CreateEnvironmentKeyMutation = { + __typename?: 'CreateEnvironmentKeyMutation'; + environmentKey?: Maybe; +}; + +export type CreateEnvironmentMutation = { + __typename?: 'CreateEnvironmentMutation'; + environment?: Maybe; +}; + +export type CreateEnvironmentTokenMutation = { + __typename?: 'CreateEnvironmentTokenMutation'; + environmentToken?: Maybe; +}; + +export type CreateOrganisationMemberMutation = { + __typename?: 'CreateOrganisationMemberMutation'; + orgMember?: Maybe; +}; export type CreateOrganisationMutation = { - __typename?: 'CreateOrganisationMutation' - organisation?: Maybe -} + __typename?: 'CreateOrganisationMutation'; + organisation?: Maybe; +}; + +export type CreateSecretFolderMutation = { + __typename?: 'CreateSecretFolderMutation'; + folder?: Maybe; +}; + +export type CreateSecretMutation = { + __typename?: 'CreateSecretMutation'; + secret?: Maybe; +}; + +export type CreateSecretTagMutation = { + __typename?: 'CreateSecretTagMutation'; + tag?: Maybe; +}; + +export type CreateServiceTokenMutation = { + __typename?: 'CreateServiceTokenMutation'; + serviceToken?: Maybe; +}; + +export type CreateUserTokenMutation = { + __typename?: 'CreateUserTokenMutation'; + ok?: Maybe; + userToken?: Maybe; +}; export type DeleteAppMutation = { - __typename?: 'DeleteAppMutation' - app?: Maybe -} + __typename?: 'DeleteAppMutation'; + app?: Maybe; +}; + +export type DeleteInviteMutation = { + __typename?: 'DeleteInviteMutation'; + ok?: Maybe; +}; + +export type DeleteOrganisationMemberMutation = { + __typename?: 'DeleteOrganisationMemberMutation'; + ok?: Maybe; +}; + +export type DeleteSecretMutation = { + __typename?: 'DeleteSecretMutation'; + secret?: Maybe; +}; + +export type DeleteServiceTokenMutation = { + __typename?: 'DeleteServiceTokenMutation'; + ok?: Maybe; +}; + +export type DeleteUserTokenMutation = { + __typename?: 'DeleteUserTokenMutation'; + ok?: Maybe; +}; + +export type EditSecretMutation = { + __typename?: 'EditSecretMutation'; + secret?: Maybe; +}; + +export type EnvironmentInput = { + appId: Scalars['ID']; + envType: Scalars['String']; + identityKey: Scalars['String']; + name: Scalars['String']; + wrappedSalt: Scalars['String']; + wrappedSeed: Scalars['String']; +}; + +export type EnvironmentKeyInput = { + envId: Scalars['ID']; + identityKey: Scalars['String']; + userId?: InputMaybe; + wrappedSalt: Scalars['String']; + wrappedSeed: Scalars['String']; +}; + +export type EnvironmentKeyType = { + __typename?: 'EnvironmentKeyType'; + createdAt?: Maybe; + environment: EnvironmentType; + id: Scalars['String']; + identityKey: Scalars['String']; + updatedAt: Scalars['DateTime']; + wrappedSalt: Scalars['String']; + wrappedSeed: Scalars['String']; +}; + +export type EnvironmentTokenType = { + __typename?: 'EnvironmentTokenType'; + createdAt?: Maybe; + id: Scalars['String']; + identityKey: Scalars['String']; + name: Scalars['String']; + token: Scalars['String']; + updatedAt: Scalars['DateTime']; + wrappedKeyShare: Scalars['String']; +}; + +export type EnvironmentType = { + __typename?: 'EnvironmentType'; + createdAt?: Maybe; + envType: ApiEnvironmentEnvTypeChoices; + id: Scalars['String']; + identityKey: Scalars['String']; + name: Scalars['String']; + updatedAt: Scalars['DateTime']; + wrappedSalt: Scalars['String']; + wrappedSeed: Scalars['String']; +}; + +export type InviteOrganisationMemberMutation = { + __typename?: 'InviteOrganisationMemberMutation'; + invite?: Maybe; +}; export type KmsLogType = Node & { - __typename?: 'KMSLogType' - appId?: Maybe - asn?: Maybe - city?: Maybe - country?: Maybe - edgeLocation?: Maybe - eventType?: Maybe - id: Scalars['ID'] - ipAddress?: Maybe - isp?: Maybe - latitude?: Maybe - longitude?: Maybe - phSize?: Maybe - phaseNode?: Maybe - timestamp?: Maybe -} + __typename?: 'KMSLogType'; + appId?: Maybe; + asn?: Maybe; + city?: Maybe; + country?: Maybe; + edgeLocation?: Maybe; + eventType?: Maybe; + id: Scalars['ID']; + ipAddress?: Maybe; + isp?: Maybe; + latitude?: Maybe; + longitude?: Maybe; + phSize?: Maybe; + phaseNode?: Maybe; + timestamp?: Maybe; +}; + +export type LogsResponseType = { + __typename?: 'LogsResponseType'; + kms?: Maybe>>; + secrets?: Maybe>>; +}; export type Mutation = { - __typename?: 'Mutation' - createApp?: Maybe - createOrganisation?: Maybe - deleteApp?: Maybe - rotateAppKeys?: Maybe -} + __typename?: 'Mutation'; + addAppMember?: Maybe; + createApp?: Maybe; + createEnvironment?: Maybe; + createEnvironmentKey?: Maybe; + createEnvironmentToken?: Maybe; + createOrganisation?: Maybe; + createOrganisationMember?: Maybe; + createSecret?: Maybe; + createSecretFolder?: Maybe; + createSecretTag?: Maybe; + createServiceToken?: Maybe; + createUserToken?: Maybe; + deleteApp?: Maybe; + deleteInvitation?: Maybe; + deleteOrganisationMember?: Maybe; + deleteSecret?: Maybe; + deleteServiceToken?: Maybe; + deleteUserToken?: Maybe; + editSecret?: Maybe; + inviteOrganisationMember?: Maybe; + readSecret?: Maybe; + removeAppMember?: Maybe; + rotateAppKeys?: Maybe; + updateMemberEnvironmentScope?: Maybe; + updateMemberWrappedSecrets?: Maybe; + updateOrganisationMemberRole?: Maybe; +}; + + +export type MutationAddAppMemberArgs = { + appId?: InputMaybe; + envKeys?: InputMaybe>>; + memberId?: InputMaybe; +}; + export type MutationCreateAppArgs = { - appSeed: Scalars['String'] - appToken: Scalars['String'] - appVersion: Scalars['Int'] - id: Scalars['ID'] - identityKey: Scalars['String'] - name: Scalars['String'] - organisationId: Scalars['ID'] - wrappedKeyShare: Scalars['String'] -} + appSeed: Scalars['String']; + appToken: Scalars['String']; + appVersion: Scalars['Int']; + id: Scalars['ID']; + identityKey: Scalars['String']; + name: Scalars['String']; + organisationId: Scalars['ID']; + wrappedKeyShare: Scalars['String']; +}; + + +export type MutationCreateEnvironmentArgs = { + adminKeys?: InputMaybe>>; + environmentData: EnvironmentInput; +}; + + +export type MutationCreateEnvironmentKeyArgs = { + envId: Scalars['ID']; + identityKey: Scalars['String']; + userId?: InputMaybe; + wrappedSalt: Scalars['String']; + wrappedSeed: Scalars['String']; +}; + + +export type MutationCreateEnvironmentTokenArgs = { + envId: Scalars['ID']; + identityKey: Scalars['String']; + name: Scalars['String']; + token: Scalars['String']; + wrappedKeyShare: Scalars['String']; +}; + export type MutationCreateOrganisationArgs = { - id: Scalars['ID'] - identityKey: Scalars['String'] - name: Scalars['String'] -} + id: Scalars['ID']; + identityKey: Scalars['String']; + name: Scalars['String']; + wrappedKeyring: Scalars['String']; + wrappedRecovery: Scalars['String']; +}; + + +export type MutationCreateOrganisationMemberArgs = { + identityKey: Scalars['String']; + inviteId: Scalars['ID']; + orgId: Scalars['ID']; + wrappedKeyring?: InputMaybe; + wrappedRecovery?: InputMaybe; +}; + + +export type MutationCreateSecretArgs = { + secretData?: InputMaybe; +}; + + +export type MutationCreateSecretFolderArgs = { + envId: Scalars['ID']; + id: Scalars['ID']; + name: Scalars['String']; + parentFolderId?: InputMaybe; +}; + + +export type MutationCreateSecretTagArgs = { + color: Scalars['String']; + name: Scalars['String']; + orgId: Scalars['ID']; +}; + + +export type MutationCreateServiceTokenArgs = { + appId: Scalars['ID']; + environmentKeys?: InputMaybe>>; + expiry?: InputMaybe; + identityKey: Scalars['String']; + name: Scalars['String']; + token: Scalars['String']; + wrappedKeyShare: Scalars['String']; +}; + + +export type MutationCreateUserTokenArgs = { + expiry?: InputMaybe; + identityKey: Scalars['String']; + name: Scalars['String']; + orgId: Scalars['ID']; + token: Scalars['String']; + wrappedKeyShare: Scalars['String']; +}; + export type MutationDeleteAppArgs = { - id: Scalars['ID'] -} + id: Scalars['ID']; +}; + + +export type MutationDeleteInvitationArgs = { + inviteId: Scalars['ID']; +}; + + +export type MutationDeleteOrganisationMemberArgs = { + memberId: Scalars['ID']; +}; + + +export type MutationDeleteSecretArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteServiceTokenArgs = { + tokenId: Scalars['ID']; +}; + + +export type MutationDeleteUserTokenArgs = { + tokenId: Scalars['ID']; +}; + + +export type MutationEditSecretArgs = { + id: Scalars['ID']; + secretData?: InputMaybe; +}; + + +export type MutationInviteOrganisationMemberArgs = { + apps?: InputMaybe>>; + email: Scalars['String']; + orgId: Scalars['ID']; + role?: InputMaybe; +}; + + +export type MutationReadSecretArgs = { + id: Scalars['ID']; +}; + + +export type MutationRemoveAppMemberArgs = { + appId?: InputMaybe; + memberId?: InputMaybe; +}; + export type MutationRotateAppKeysArgs = { - appToken: Scalars['String'] - id: Scalars['ID'] - wrappedKeyShare: Scalars['String'] -} + appToken: Scalars['String']; + id: Scalars['ID']; + wrappedKeyShare: Scalars['String']; +}; + + +export type MutationUpdateMemberEnvironmentScopeArgs = { + appId?: InputMaybe; + envKeys?: InputMaybe>>; + memberId?: InputMaybe; +}; + + +export type MutationUpdateMemberWrappedSecretsArgs = { + orgId: Scalars['ID']; + wrappedKeyring: Scalars['String']; + wrappedRecovery: Scalars['String']; +}; + + +export type MutationUpdateOrganisationMemberRoleArgs = { + memberId: Scalars['ID']; + role: Scalars['String']; +}; /** An object with an ID */ export type Node = { /** The ID of the object */ - id: Scalars['ID'] -} + id: Scalars['ID']; +}; + +export type OrganisationMemberInviteType = { + __typename?: 'OrganisationMemberInviteType'; + apps: Array; + createdAt?: Maybe; + expiresAt: Scalars['DateTime']; + id: Scalars['String']; + invitedBy: OrganisationMemberType; + inviteeEmail: Scalars['String']; + organisation: OrganisationType; + role: ApiOrganisationMemberInviteRoleChoices; + updatedAt: Scalars['DateTime']; + valid: Scalars['Boolean']; +}; + +export type OrganisationMemberType = { + __typename?: 'OrganisationMemberType'; + avatarUrl?: Maybe; + createdAt?: Maybe; + email?: Maybe; + fullName?: Maybe; + id: Scalars['String']; + identityKey?: Maybe; + role: ApiOrganisationMemberRoleChoices; + self?: Maybe; + updatedAt: Scalars['DateTime']; + username?: Maybe; + wrappedKeyring: Scalars['String']; +}; export type OrganisationType = { - __typename?: 'OrganisationType' - createdAt?: Maybe - id: Scalars['String'] - identityKey: Scalars['String'] - name: Scalars['String'] - plan: ApiOrganisationPlanChoices -} + __typename?: 'OrganisationType'; + createdAt?: Maybe; + id: Scalars['String']; + identityKey: Scalars['String']; + keyring?: Maybe; + memberId?: Maybe; + name: Scalars['String']; + plan: ApiOrganisationPlanChoices; + recovery?: Maybe; + role?: Maybe; +}; export type Query = { - __typename?: 'Query' - appActivityChart?: Maybe>> - apps?: Maybe>> - logs?: Maybe>> - logsCount?: Maybe - organisations?: Maybe>> -} + __typename?: 'Query'; + appActivityChart?: Maybe>>; + appEnvironments?: Maybe>>; + appUsers?: Maybe>>; + apps?: Maybe>>; + environmentKeys?: Maybe>>; + environmentTokens?: Maybe>>; + kmsLogsCount?: Maybe; + logs?: Maybe; + organisationAdminsAndSelf?: Maybe>>; + organisationInvites?: Maybe>>; + organisationMembers?: Maybe>>; + organisations?: Maybe>>; + secretHistory?: Maybe>>; + secretTags?: Maybe>>; + secrets?: Maybe>>; + secretsLogsCount?: Maybe; + serviceTokens?: Maybe>>; + userTokens?: Maybe>>; + validateInvite?: Maybe; +}; + export type QueryAppActivityChartArgs = { - appId?: InputMaybe - period?: InputMaybe -} + appId?: InputMaybe; + period?: InputMaybe; +}; + + +export type QueryAppEnvironmentsArgs = { + appId?: InputMaybe; + environmentId?: InputMaybe; + memberId?: InputMaybe; +}; + + +export type QueryAppUsersArgs = { + appId?: InputMaybe; +}; + export type QueryAppsArgs = { - appId?: InputMaybe - organisationId?: InputMaybe -} + appId?: InputMaybe; + organisationId?: InputMaybe; +}; + + +export type QueryEnvironmentKeysArgs = { + appId?: InputMaybe; + environmentId?: InputMaybe; + memberId?: InputMaybe; +}; + + +export type QueryEnvironmentTokensArgs = { + environmentId?: InputMaybe; +}; + + +export type QueryKmsLogsCountArgs = { + appId?: InputMaybe; + thisMonth?: InputMaybe; +}; + export type QueryLogsArgs = { - appId?: InputMaybe - end?: InputMaybe - start?: InputMaybe -} + appId?: InputMaybe; + end?: InputMaybe; + start?: InputMaybe; +}; + + +export type QueryOrganisationAdminsAndSelfArgs = { + organisationId?: InputMaybe; +}; + + +export type QueryOrganisationInvitesArgs = { + orgId?: InputMaybe; +}; + + +export type QueryOrganisationMembersArgs = { + organisationId?: InputMaybe; + role?: InputMaybe>>; + userId?: InputMaybe; +}; + + +export type QuerySecretHistoryArgs = { + secretId?: InputMaybe; +}; + + +export type QuerySecretTagsArgs = { + orgId?: InputMaybe; +}; -export type QueryLogsCountArgs = { - appId?: InputMaybe - thisMonth?: InputMaybe -} + +export type QuerySecretsArgs = { + envId?: InputMaybe; +}; + + +export type QuerySecretsLogsCountArgs = { + appId?: InputMaybe; +}; + + +export type QueryServiceTokensArgs = { + appId?: InputMaybe; +}; + + +export type QueryUserTokensArgs = { + organisationId?: InputMaybe; +}; + + +export type QueryValidateInviteArgs = { + inviteId?: InputMaybe; +}; + +export type ReadSecretMutation = { + __typename?: 'ReadSecretMutation'; + ok?: Maybe; +}; + +export type RemoveAppMemberMutation = { + __typename?: 'RemoveAppMemberMutation'; + app?: Maybe; +}; export type RotateAppKeysMutation = { - __typename?: 'RotateAppKeysMutation' - app?: Maybe -} + __typename?: 'RotateAppKeysMutation'; + app?: Maybe; +}; + +export type SecretEventType = { + __typename?: 'SecretEventType'; + comment: Scalars['String']; + environment: EnvironmentType; + eventType: ApiSecretEventEventTypeChoices; + id: Scalars['String']; + ipAddress?: Maybe; + key: Scalars['String']; + secret: SecretType; + tags: Array; + timestamp: Scalars['DateTime']; + user?: Maybe; + userAgent?: Maybe; + value: Scalars['String']; + version: Scalars['Int']; +}; + +export type SecretFolderType = { + __typename?: 'SecretFolderType'; + createdAt?: Maybe; + id: Scalars['String']; + name: Scalars['String']; + updatedAt: Scalars['DateTime']; +}; + +export type SecretInput = { + comment?: InputMaybe; + envId?: InputMaybe; + folderId?: InputMaybe; + key: Scalars['String']; + keyDigest: Scalars['String']; + tags?: InputMaybe>>; + value: Scalars['String']; +}; + +export type SecretTagType = { + __typename?: 'SecretTagType'; + color: Scalars['String']; + id: Scalars['String']; + name: Scalars['String']; +}; + +export type SecretType = { + __typename?: 'SecretType'; + comment: Scalars['String']; + createdAt?: Maybe; + folder?: Maybe; + history?: Maybe>>; + id: Scalars['String']; + key: Scalars['String']; + tags: Array; + updatedAt: Scalars['DateTime']; + value: Scalars['String']; + version: Scalars['Int']; +}; + +export type ServiceTokenType = { + __typename?: 'ServiceTokenType'; + createdAt?: Maybe; + createdBy?: Maybe; + expiresAt?: Maybe; + id: Scalars['String']; + identityKey: Scalars['String']; + keys: Array; + name: Scalars['String']; + token: Scalars['String']; + updatedAt: Scalars['DateTime']; + wrappedKeyShare: Scalars['String']; +}; /** An enumeration. */ export enum TimeRange { @@ -179,5 +751,458 @@ export enum TimeRange { Hour = 'HOUR', Month = 'MONTH', Week = 'WEEK', - Year = 'YEAR', + Year = 'YEAR' } + +export type UpdateMemberEnvScopeMutation = { + __typename?: 'UpdateMemberEnvScopeMutation'; + app?: Maybe; +}; + +export type UpdateOrganisationMemberRole = { + __typename?: 'UpdateOrganisationMemberRole'; + orgMember?: Maybe; +}; + +export type UpdateUserWrappedSecretsMutation = { + __typename?: 'UpdateUserWrappedSecretsMutation'; + orgMember?: Maybe; +}; + +export type UserTokenType = { + __typename?: 'UserTokenType'; + createdAt?: Maybe; + expiresAt?: Maybe; + id: Scalars['String']; + identityKey: Scalars['String']; + name: Scalars['String']; + token: Scalars['String']; + updatedAt: Scalars['DateTime']; + wrappedKeyShare: Scalars['String']; +}; + +export type AddMemberToAppMutationVariables = Exact<{ + memberId: Scalars['ID']; + appId: Scalars['ID']; + envKeys?: InputMaybe> | InputMaybe>; +}>; + + +export type AddMemberToAppMutation = { __typename?: 'Mutation', addAppMember?: { __typename?: 'AddAppMemberMutation', app?: { __typename?: 'AppType', id: string } | null } | null }; + +export type RemoveMemberFromAppMutationVariables = Exact<{ + memberId: Scalars['ID']; + appId: Scalars['ID']; +}>; + + +export type RemoveMemberFromAppMutation = { __typename?: 'Mutation', removeAppMember?: { __typename?: 'RemoveAppMemberMutation', app?: { __typename?: 'AppType', id: string } | null } | null }; + +export type UpdateEnvScopeMutationVariables = Exact<{ + memberId: Scalars['ID']; + appId: Scalars['ID']; + envKeys?: InputMaybe> | InputMaybe>; +}>; + + +export type UpdateEnvScopeMutation = { __typename?: 'Mutation', updateMemberEnvironmentScope?: { __typename?: 'UpdateMemberEnvScopeMutation', app?: { __typename?: 'AppType', id: string } | null } | null }; + +export type CreateApplicationMutationVariables = Exact<{ + id: Scalars['ID']; + organisationId: Scalars['ID']; + name: Scalars['String']; + identityKey: Scalars['String']; + appToken: Scalars['String']; + appSeed: Scalars['String']; + wrappedKeyShare: Scalars['String']; + appVersion: Scalars['Int']; +}>; + + +export type CreateApplicationMutation = { __typename?: 'Mutation', createApp?: { __typename?: 'CreateAppMutation', app?: { __typename?: 'AppType', id: string, name: string, identityKey: string } | null } | null }; + +export type CreateOrgMutationVariables = Exact<{ + id: Scalars['ID']; + name: Scalars['String']; + identityKey: Scalars['String']; + wrappedKeyring: Scalars['String']; + wrappedRecovery: Scalars['String']; +}>; + + +export type CreateOrgMutation = { __typename?: 'Mutation', createOrganisation?: { __typename?: 'CreateOrganisationMutation', organisation?: { __typename?: 'OrganisationType', id: string, name: string, createdAt?: any | null } | null } | null }; + +export type DeleteApplicationMutationVariables = Exact<{ + id: Scalars['ID']; +}>; + + +export type DeleteApplicationMutation = { __typename?: 'Mutation', deleteApp?: { __typename?: 'DeleteAppMutation', app?: { __typename?: 'AppType', id: string } | null } | null }; + +export type CreateEnvMutationVariables = Exact<{ + input: EnvironmentInput; +}>; + + +export type CreateEnvMutation = { __typename?: 'Mutation', createEnvironment?: { __typename?: 'CreateEnvironmentMutation', environment?: { __typename?: 'EnvironmentType', id: string, name: string, createdAt?: any | null, identityKey: string } | null } | null }; + +export type CreateEnvKeyMutationVariables = Exact<{ + envId: Scalars['ID']; + userId?: InputMaybe; + wrappedSeed: Scalars['String']; + wrappedSalt: Scalars['String']; + identityKey: Scalars['String']; +}>; + + +export type CreateEnvKeyMutation = { __typename?: 'Mutation', createEnvironmentKey?: { __typename?: 'CreateEnvironmentKeyMutation', environmentKey?: { __typename?: 'EnvironmentKeyType', id: string, createdAt?: any | null } | null } | null }; + +export type CreateEnvTokenMutationVariables = Exact<{ + envId: Scalars['ID']; + name: Scalars['String']; + identityKey: Scalars['String']; + token: Scalars['String']; + wrappedKeyShare: Scalars['String']; +}>; + + +export type CreateEnvTokenMutation = { __typename?: 'Mutation', createEnvironmentToken?: { __typename?: 'CreateEnvironmentTokenMutation', environmentToken?: { __typename?: 'EnvironmentTokenType', id: string, createdAt?: any | null } | null } | null }; + +export type CreateNewSecretMutationVariables = Exact<{ + newSecret: SecretInput; +}>; + + +export type CreateNewSecretMutation = { __typename?: 'Mutation', createSecret?: { __typename?: 'CreateSecretMutation', secret?: { __typename?: 'SecretType', id: string, key: string, value: string, createdAt?: any | null } | null } | null }; + +export type CreateNewSecretTagMutationVariables = Exact<{ + orgId: Scalars['ID']; + name: Scalars['String']; + color: Scalars['String']; +}>; + + +export type CreateNewSecretTagMutation = { __typename?: 'Mutation', createSecretTag?: { __typename?: 'CreateSecretTagMutation', tag?: { __typename?: 'SecretTagType', id: string } | null } | null }; + +export type CreateNewServiceTokenMutationVariables = Exact<{ + appId: Scalars['ID']; + environmentKeys?: InputMaybe> | InputMaybe>; + identityKey: Scalars['String']; + token: Scalars['String']; + wrappedKeyShare: Scalars['String']; + name: Scalars['String']; + expiry?: InputMaybe; +}>; + + +export type CreateNewServiceTokenMutation = { __typename?: 'Mutation', createServiceToken?: { __typename?: 'CreateServiceTokenMutation', serviceToken?: { __typename?: 'ServiceTokenType', id: string, createdAt?: any | null, expiresAt?: any | null } | null } | null }; + +export type DeleteSecretOpMutationVariables = Exact<{ + id: Scalars['ID']; +}>; + + +export type DeleteSecretOpMutation = { __typename?: 'Mutation', deleteSecret?: { __typename?: 'DeleteSecretMutation', secret?: { __typename?: 'SecretType', id: string } | null } | null }; + +export type RevokeServiceTokenMutationVariables = Exact<{ + tokenId: Scalars['ID']; +}>; + + +export type RevokeServiceTokenMutation = { __typename?: 'Mutation', deleteServiceToken?: { __typename?: 'DeleteServiceTokenMutation', ok?: boolean | null } | null }; + +export type UpdateSecretMutationVariables = Exact<{ + id: Scalars['ID']; + secretData: SecretInput; +}>; + + +export type UpdateSecretMutation = { __typename?: 'Mutation', editSecret?: { __typename?: 'EditSecretMutation', secret?: { __typename?: 'SecretType', id: string, updatedAt: any } | null } | null }; + +export type InitAppEnvironmentsMutationVariables = Exact<{ + devEnv: EnvironmentInput; + stagingEnv: EnvironmentInput; + prodEnv: EnvironmentInput; + devAdminKeys?: InputMaybe> | InputMaybe>; + stagAdminKeys?: InputMaybe> | InputMaybe>; + prodAdminKeys?: InputMaybe> | InputMaybe>; +}>; + + +export type InitAppEnvironmentsMutation = { __typename?: 'Mutation', devEnvironment?: { __typename?: 'CreateEnvironmentMutation', environment?: { __typename?: 'EnvironmentType', id: string, name: string, createdAt?: any | null, identityKey: string } | null } | null, stagingEnvironment?: { __typename?: 'CreateEnvironmentMutation', environment?: { __typename?: 'EnvironmentType', id: string, name: string, createdAt?: any | null, identityKey: string } | null } | null, prodEnvironment?: { __typename?: 'CreateEnvironmentMutation', environment?: { __typename?: 'EnvironmentType', id: string, name: string, createdAt?: any | null, identityKey: string } | null } | null }; + +export type LogSecretReadMutationVariables = Exact<{ + id: Scalars['ID']; +}>; + + +export type LogSecretReadMutation = { __typename?: 'Mutation', readSecret?: { __typename?: 'ReadSecretMutation', ok?: boolean | null } | null }; + +export type AcceptOrganisationInviteMutationVariables = Exact<{ + orgId: Scalars['ID']; + identityKey: Scalars['String']; + wrappedKeyring: Scalars['String']; + wrappedRecovery: Scalars['String']; + inviteId: Scalars['ID']; +}>; + + +export type AcceptOrganisationInviteMutation = { __typename?: 'Mutation', createOrganisationMember?: { __typename?: 'CreateOrganisationMemberMutation', orgMember?: { __typename?: 'OrganisationMemberType', id: string, email?: string | null, createdAt?: any | null, role: ApiOrganisationMemberRoleChoices } | null } | null }; + +export type DeleteOrgInviteMutationVariables = Exact<{ + inviteId: Scalars['ID']; +}>; + + +export type DeleteOrgInviteMutation = { __typename?: 'Mutation', deleteInvitation?: { __typename?: 'DeleteInviteMutation', ok?: boolean | null } | null }; + +export type RemoveMemberMutationVariables = Exact<{ + memberId: Scalars['ID']; +}>; + + +export type RemoveMemberMutation = { __typename?: 'Mutation', deleteOrganisationMember?: { __typename?: 'DeleteOrganisationMemberMutation', ok?: boolean | null } | null }; + +export type InviteMemberMutationVariables = Exact<{ + orgId: Scalars['ID']; + email: Scalars['String']; + apps?: InputMaybe> | InputMaybe>; + role?: InputMaybe; +}>; + + +export type InviteMemberMutation = { __typename?: 'Mutation', inviteOrganisationMember?: { __typename?: 'InviteOrganisationMemberMutation', invite?: { __typename?: 'OrganisationMemberInviteType', id: string } | null } | null }; + +export type UpdateMemberRoleMutationVariables = Exact<{ + memberId: Scalars['ID']; + role: Scalars['String']; +}>; + + +export type UpdateMemberRoleMutation = { __typename?: 'Mutation', updateOrganisationMemberRole?: { __typename?: 'UpdateOrganisationMemberRole', orgMember?: { __typename?: 'OrganisationMemberType', id: string, role: ApiOrganisationMemberRoleChoices } | null } | null }; + +export type UpdateWrappedSecretsMutationVariables = Exact<{ + orgId: Scalars['ID']; + wrappedKeyring: Scalars['String']; + wrappedRecovery: Scalars['String']; +}>; + + +export type UpdateWrappedSecretsMutation = { __typename?: 'Mutation', updateMemberWrappedSecrets?: { __typename?: 'UpdateUserWrappedSecretsMutation', orgMember?: { __typename?: 'OrganisationMemberType', id: string } | null } | null }; + +export type RotateAppKeyMutationVariables = Exact<{ + id: Scalars['ID']; + appToken: Scalars['String']; + wrappedKeyShare: Scalars['String']; +}>; + + +export type RotateAppKeyMutation = { __typename?: 'Mutation', rotateAppKeys?: { __typename?: 'RotateAppKeysMutation', app?: { __typename?: 'AppType', id: string } | null } | null }; + +export type CreateNewUserTokenMutationVariables = Exact<{ + orgId: Scalars['ID']; + name: Scalars['String']; + identityKey: Scalars['String']; + token: Scalars['String']; + wrappedKeyShare: Scalars['String']; + expiry?: InputMaybe; +}>; + + +export type CreateNewUserTokenMutation = { __typename?: 'Mutation', createUserToken?: { __typename?: 'CreateUserTokenMutation', ok?: boolean | null } | null }; + +export type RevokeUserTokenMutationVariables = Exact<{ + tokenId: Scalars['ID']; +}>; + + +export type RevokeUserTokenMutation = { __typename?: 'Mutation', deleteUserToken?: { __typename?: 'DeleteUserTokenMutation', ok?: boolean | null } | null }; + +export type GetAppMembersQueryVariables = Exact<{ + appId: Scalars['ID']; +}>; + + +export type GetAppMembersQuery = { __typename?: 'Query', appUsers?: Array<{ __typename?: 'OrganisationMemberType', id: string, identityKey?: string | null, email?: string | null, fullName?: string | null, avatarUrl?: string | null, createdAt?: any | null, role: ApiOrganisationMemberRoleChoices } | null> | null }; + +export type GetAppActivityChartQueryVariables = Exact<{ + appId: Scalars['ID']; + period?: InputMaybe; +}>; + + +export type GetAppActivityChartQuery = { __typename?: 'Query', appActivityChart?: Array<{ __typename?: 'ChartDataPointType', index?: number | null, date?: any | null, data?: number | null } | null> | null }; + +export type GetAppDetailQueryVariables = Exact<{ + organisationId: Scalars['ID']; + appId: Scalars['ID']; +}>; + + +export type GetAppDetailQuery = { __typename?: 'Query', apps?: Array<{ __typename?: 'AppType', id: string, name: string, identityKey: string, createdAt?: any | null, appToken: string, appSeed: string, appVersion: number } | null> | null }; + +export type GetAppKmsLogsQueryVariables = Exact<{ + appId: Scalars['ID']; + start?: InputMaybe; + end?: InputMaybe; +}>; + + +export type GetAppKmsLogsQuery = { __typename?: 'Query', kmsLogsCount?: number | null, logs?: { __typename?: 'LogsResponseType', kms?: Array<{ __typename?: 'KMSLogType', id: string, timestamp?: any | null, phaseNode?: string | null, eventType?: string | null, ipAddress?: string | null, country?: string | null, city?: string | null, phSize?: number | null } | null> | null } | null }; + +export type GetAppsQueryVariables = Exact<{ + organisationId: Scalars['ID']; + appId: Scalars['ID']; +}>; + + +export type GetAppsQuery = { __typename?: 'Query', apps?: Array<{ __typename?: 'AppType', id: string, name: string, identityKey: string, createdAt?: any | null } | null> | null }; + +export type GetOrganisationsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetOrganisationsQuery = { __typename?: 'Query', organisations?: Array<{ __typename?: 'OrganisationType', id: string, name: string, identityKey: string, createdAt?: any | null, plan: ApiOrganisationPlanChoices, role?: string | null, memberId?: string | null, keyring?: string | null, recovery?: string | null } | null> | null }; + +export type GetInvitesQueryVariables = Exact<{ + orgId: Scalars['ID']; +}>; + + +export type GetInvitesQuery = { __typename?: 'Query', organisationInvites?: Array<{ __typename?: 'OrganisationMemberInviteType', id: string, createdAt?: any | null, expiresAt: any, inviteeEmail: string, invitedBy: { __typename?: 'OrganisationMemberType', email?: string | null, fullName?: string | null, self?: boolean | null } } | null> | null }; + +export type GetOrganisationAdminsAndSelfQueryVariables = Exact<{ + organisationId: Scalars['ID']; +}>; + + +export type GetOrganisationAdminsAndSelfQuery = { __typename?: 'Query', organisationAdminsAndSelf?: Array<{ __typename?: 'OrganisationMemberType', id: string, role: ApiOrganisationMemberRoleChoices, identityKey?: string | null, self?: boolean | null } | null> | null }; + +export type GetOrganisationMembersQueryVariables = Exact<{ + organisationId: Scalars['ID']; + role?: InputMaybe> | InputMaybe>; +}>; + + +export type GetOrganisationMembersQuery = { __typename?: 'Query', organisationMembers?: Array<{ __typename?: 'OrganisationMemberType', id: string, role: ApiOrganisationMemberRoleChoices, identityKey?: string | null, email?: string | null, fullName?: string | null, avatarUrl?: string | null, createdAt?: any | null, self?: boolean | null } | null> | null }; + +export type VerifyInviteQueryVariables = Exact<{ + inviteId: Scalars['ID']; +}>; + + +export type VerifyInviteQuery = { __typename?: 'Query', validateInvite?: { __typename?: 'OrganisationMemberInviteType', id: string, inviteeEmail: string, organisation: { __typename?: 'OrganisationType', id: string, name: string }, invitedBy: { __typename?: 'OrganisationMemberType', email?: string | null }, apps: Array<{ __typename?: 'AppType', id: string, name: string }> } | null }; + +export type GetAppEnvironmentsQueryVariables = Exact<{ + appId: Scalars['ID']; + memberId?: InputMaybe; +}>; + + +export type GetAppEnvironmentsQuery = { __typename?: 'Query', appEnvironments?: Array<{ __typename?: 'EnvironmentType', id: string, name: string, envType: ApiEnvironmentEnvTypeChoices, identityKey: string, wrappedSeed: string, wrappedSalt: string, createdAt?: any | null } | null> | null }; + +export type GetAppSecretsLogsQueryVariables = Exact<{ + appId: Scalars['ID']; + start?: InputMaybe; + end?: InputMaybe; +}>; + + +export type GetAppSecretsLogsQuery = { __typename?: 'Query', secretsLogsCount?: number | null, logs?: { __typename?: 'LogsResponseType', secrets?: Array<{ __typename?: 'SecretEventType', id: string, key: string, value: string, version: number, comment: string, timestamp: any, ipAddress?: string | null, userAgent?: string | null, eventType: ApiSecretEventEventTypeChoices, tags: Array<{ __typename?: 'SecretTagType', id: string, name: string, color: string }>, user?: { __typename?: 'OrganisationMemberType', email?: string | null, username?: string | null, fullName?: string | null, avatarUrl?: string | null } | null, environment: { __typename?: 'EnvironmentType', id: string, envType: ApiEnvironmentEnvTypeChoices, name: string }, secret: { __typename?: 'SecretType', id: string } } | null> | null } | null, environmentKeys?: Array<{ __typename?: 'EnvironmentKeyType', id: string, identityKey: string, wrappedSeed: string, wrappedSalt: string, environment: { __typename?: 'EnvironmentType', id: string } } | null> | null }; + +export type GetEnvironmentKeyQueryVariables = Exact<{ + envId: Scalars['ID']; + appId: Scalars['ID']; +}>; + + +export type GetEnvironmentKeyQuery = { __typename?: 'Query', environmentKeys?: Array<{ __typename?: 'EnvironmentKeyType', id: string, identityKey: string, wrappedSeed: string, wrappedSalt: string } | null> | null }; + +export type GetEnvironmentTokensQueryVariables = Exact<{ + envId: Scalars['ID']; +}>; + + +export type GetEnvironmentTokensQuery = { __typename?: 'Query', environmentTokens?: Array<{ __typename?: 'EnvironmentTokenType', id: string, name: string, wrappedKeyShare: string, createdAt?: any | null } | null> | null }; + +export type GetEnvSecretsKvQueryVariables = Exact<{ + envId: Scalars['ID']; +}>; + + +export type GetEnvSecretsKvQuery = { __typename?: 'Query', secrets?: Array<{ __typename?: 'SecretType', id: string, key: string, value: string } | null> | null, environmentKeys?: Array<{ __typename?: 'EnvironmentKeyType', id: string, identityKey: string, wrappedSeed: string, wrappedSalt: string } | null> | null }; + +export type GetSecretTagsQueryVariables = Exact<{ + orgId: Scalars['ID']; +}>; + + +export type GetSecretTagsQuery = { __typename?: 'Query', secretTags?: Array<{ __typename?: 'SecretTagType', id: string, name: string, color: string } | null> | null }; + +export type GetSecretsQueryVariables = Exact<{ + appId: Scalars['ID']; + envId: Scalars['ID']; +}>; + + +export type GetSecretsQuery = { __typename?: 'Query', secrets?: Array<{ __typename?: 'SecretType', id: string, key: string, value: string, comment: string, createdAt?: any | null, tags: Array<{ __typename?: 'SecretTagType', id: string, name: string, color: string }>, history?: Array<{ __typename?: 'SecretEventType', id: string, key: string, value: string, version: number, comment: string, timestamp: any, ipAddress?: string | null, userAgent?: string | null, eventType: ApiSecretEventEventTypeChoices, tags: Array<{ __typename?: 'SecretTagType', id: string, name: string, color: string }>, user?: { __typename?: 'OrganisationMemberType', email?: string | null, username?: string | null, fullName?: string | null, avatarUrl?: string | null } | null } | null> | null } | null> | null, appEnvironments?: Array<{ __typename?: 'EnvironmentType', id: string, name: string, envType: ApiEnvironmentEnvTypeChoices, identityKey: string } | null> | null, environmentKeys?: Array<{ __typename?: 'EnvironmentKeyType', id: string, identityKey: string, wrappedSeed: string, wrappedSalt: string } | null> | null }; + +export type GetServiceTokensQueryVariables = Exact<{ + appId: Scalars['ID']; +}>; + + +export type GetServiceTokensQuery = { __typename?: 'Query', serviceTokens?: Array<{ __typename?: 'ServiceTokenType', id: string, name: string, createdAt?: any | null, expiresAt?: any | null, createdBy?: { __typename?: 'OrganisationMemberType', fullName?: string | null, avatarUrl?: string | null, self?: boolean | null } | null, keys: Array<{ __typename?: 'EnvironmentKeyType', id: string }> } | null> | null }; + +export type GetUserTokensQueryVariables = Exact<{ + organisationId: Scalars['ID']; +}>; + + +export type GetUserTokensQuery = { __typename?: 'Query', userTokens?: Array<{ __typename?: 'UserTokenType', id: string, name: string, wrappedKeyShare: string, createdAt?: any | null, expiresAt?: any | null } | null> | null }; + + +export const AddMemberToAppDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddMemberToApp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envKeys"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addAppMember"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"envKeys"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envKeys"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const RemoveMemberFromAppDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveMemberFromApp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeAppMember"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const UpdateEnvScopeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateEnvScope"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envKeys"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMemberEnvironmentScope"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"envKeys"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envKeys"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateApplicationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateApplication"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appSeed"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appVersion"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createApp"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"appToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"appSeed"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appSeed"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyShare"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}}},{"kind":"Argument","name":{"kind":"Name","value":"appVersion"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appVersion"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateOrgDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOrg"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOrganisation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyring"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedRecovery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const DeleteApplicationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteApplication"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteApp"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateEnvDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateEnv"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createEnvironment"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"environmentData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateEnvKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateEnvKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedSeed"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedSalt"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createEnvironmentKey"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"envId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}},{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedSeed"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedSeed"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedSalt"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedSalt"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"environmentKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateEnvTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateEnvToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createEnvironmentToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"envId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyShare"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"environmentToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateNewSecretDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateNewSecret"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newSecret"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SecretInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createSecret"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"secretData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newSecret"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"secret"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateNewSecretTagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateNewSecretTag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"color"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createSecretTag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"color"},"value":{"kind":"Variable","name":{"kind":"Name","value":"color"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tag"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateNewServiceTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateNewServiceToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"environmentKeys"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentKeyInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"expiry"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createServiceToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"environmentKeys"},"value":{"kind":"Variable","name":{"kind":"Name","value":"environmentKeys"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyShare"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"expiry"},"value":{"kind":"Variable","name":{"kind":"Name","value":"expiry"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const DeleteSecretOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteSecretOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteSecret"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"secret"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const RevokeServiceTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RevokeServiceToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tokenId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteServiceToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tokenId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tokenId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const UpdateSecretDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSecret"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"secretData"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SecretInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"editSecret"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"secretData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"secretData"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"secret"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]}}]} as unknown as DocumentNode; +export const InitAppEnvironmentsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InitAppEnvironments"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"devEnv"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"stagingEnv"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"prodEnv"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"devAdminKeys"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentKeyInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"stagAdminKeys"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentKeyInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"prodAdminKeys"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"devEnvironment"},"name":{"kind":"Name","value":"createEnvironment"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"environmentData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"devEnv"}}},{"kind":"Argument","name":{"kind":"Name","value":"adminKeys"},"value":{"kind":"Variable","name":{"kind":"Name","value":"devAdminKeys"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"stagingEnvironment"},"name":{"kind":"Name","value":"createEnvironment"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"environmentData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stagingEnv"}}},{"kind":"Argument","name":{"kind":"Name","value":"adminKeys"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stagAdminKeys"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"prodEnvironment"},"name":{"kind":"Name","value":"createEnvironment"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"environmentData"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prodEnv"}}},{"kind":"Argument","name":{"kind":"Name","value":"adminKeys"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prodAdminKeys"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}}]}}]}}]}}]} as unknown as DocumentNode; +export const LogSecretReadDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"LogSecretRead"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readSecret"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const AcceptOrganisationInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AcceptOrganisationInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOrganisationMember"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyring"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedRecovery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}}},{"kind":"Argument","name":{"kind":"Name","value":"inviteId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orgMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}}]} as unknown as DocumentNode; +export const DeleteOrgInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOrgInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteInvitation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inviteId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const RemoveMemberDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveMember"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOrganisationMember"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const InviteMemberDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InviteMember"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"apps"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"role"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteOrganisationMember"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"apps"},"value":{"kind":"Variable","name":{"kind":"Name","value":"apps"}}},{"kind":"Argument","name":{"kind":"Name","value":"role"},"value":{"kind":"Variable","name":{"kind":"Name","value":"role"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invite"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const UpdateMemberRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMemberRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"role"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateOrganisationMemberRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}},{"kind":"Argument","name":{"kind":"Name","value":"role"},"value":{"kind":"Variable","name":{"kind":"Name","value":"role"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orgMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]}}]} as unknown as DocumentNode; +export const UpdateWrappedSecretsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWrappedSecrets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMemberWrappedSecrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyring"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedRecovery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orgMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const RotateAppKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RotateAppKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rotateAppKeys"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"appToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyShare"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateNewUserTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateNewUserToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"expiry"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createUserToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyShare"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}}},{"kind":"Argument","name":{"kind":"Name","value":"expiry"},"value":{"kind":"Variable","name":{"kind":"Name","value":"expiry"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const RevokeUserTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RevokeUserToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"tokenId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteUserToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"tokenId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"tokenId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const GetAppMembersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppMembers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]}}]} as unknown as DocumentNode; +export const GetAppActivityChartDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppActivityChart"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"period"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"TimeRange"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appActivityChart"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"period"},"value":{"kind":"Variable","name":{"kind":"Name","value":"period"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"index"}},{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"data"}}]}}]}}]} as unknown as DocumentNode; +export const GetAppDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apps"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"appToken"}},{"kind":"Field","name":{"kind":"Name","value":"appSeed"}},{"kind":"Field","name":{"kind":"Name","value":"appVersion"}}]}}]}}]} as unknown as DocumentNode; +export const GetAppKmsLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppKmsLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"start"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"end"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"start"},"value":{"kind":"Variable","name":{"kind":"Name","value":"start"}}},{"kind":"Argument","name":{"kind":"Name","value":"end"},"value":{"kind":"Variable","name":{"kind":"Name","value":"end"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"kms"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"phaseNode"}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}},{"kind":"Field","name":{"kind":"Name","value":"ipAddress"}},{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"city"}},{"kind":"Field","name":{"kind":"Name","value":"phSize"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"kmsLogsCount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}]}]}}]} as unknown as DocumentNode; +export const GetAppsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetApps"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apps"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode; +export const GetOrganisationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"memberId"}},{"kind":"Field","name":{"kind":"Name","value":"keyring"}},{"kind":"Field","name":{"kind":"Name","value":"recovery"}}]}}]}}]} as unknown as DocumentNode; +export const GetInvitesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetInvites"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisationInvites"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}},{"kind":"Field","name":{"kind":"Name","value":"inviteeEmail"}}]}}]}}]} as unknown as DocumentNode; +export const GetOrganisationAdminsAndSelfDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisationAdminsAndSelf"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisationAdminsAndSelf"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}}]}}]} as unknown as DocumentNode; +export const GetOrganisationMembersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisationMembers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"role"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisationMembers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"role"},"value":{"kind":"Variable","name":{"kind":"Name","value":"role"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}}]}}]} as unknown as DocumentNode; +export const VerifyInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"VerifyInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validateInvite"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inviteId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inviteId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"organisation"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"inviteeEmail"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"apps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetAppEnvironmentsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppEnvironments"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appEnvironments"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"environmentId"},"value":{"kind":"NullValue"}},{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"envType"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedSeed"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedSalt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode; +export const GetAppSecretsLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppSecretsLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"start"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"end"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"start"},"value":{"kind":"Variable","name":{"kind":"Name","value":"start"}}},{"kind":"Argument","name":{"kind":"Name","value":"end"},"value":{"kind":"Variable","name":{"kind":"Name","value":"end"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"secrets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"color"}}]}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"ipAddress"}},{"kind":"Field","name":{"kind":"Name","value":"userAgent"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}},{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"envType"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"secret"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"secretsLogsCount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}]},{"kind":"Field","name":{"kind":"Name","value":"environmentKeys"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedSeed"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedSalt"}},{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetEnvironmentKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetEnvironmentKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"environmentKeys"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"environmentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedSeed"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedSalt"}}]}}]}}]} as unknown as DocumentNode; +export const GetEnvironmentTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetEnvironmentTokens"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"environmentTokens"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"environmentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedKeyShare"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode; +export const GetEnvSecretsKvDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetEnvSecretsKV"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"secrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"envId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"environmentKeys"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"environmentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedSeed"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedSalt"}}]}}]}}]} as unknown as DocumentNode; +export const GetSecretTagsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSecretTags"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"secretTags"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"color"}}]}}]}}]} as unknown as DocumentNode; +export const GetSecretsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSecrets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"secrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"envId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"color"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"history"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"color"}}]}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"ipAddress"}},{"kind":"Field","name":{"kind":"Name","value":"userAgent"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"appEnvironments"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"environmentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"envType"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}}]}},{"kind":"Field","name":{"kind":"Name","value":"environmentKeys"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"environmentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedSeed"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedSalt"}}]}}]}}]} as unknown as DocumentNode; +export const GetServiceTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServiceTokens"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceTokens"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"keys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetUserTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserTokens"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userTokens"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedKeyShare"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/apollo/mutations/createOrganisation.gql b/frontend/apollo/mutations/createOrganisation.gql deleted file mode 100644 index ab5b27b4..00000000 --- a/frontend/apollo/mutations/createOrganisation.gql +++ /dev/null @@ -1,9 +0,0 @@ -mutation CreateOrganisation($id: ID!, $name: String!, $identityKey: String!) { - createOrganisation(id: $id, name: $name, identityKey: $identityKey) { - organisation { - id - name - createdAt - } - } -} diff --git a/frontend/apollo/queries/getAppLogCount.gql b/frontend/apollo/queries/getAppLogCount.gql deleted file mode 100644 index 43023509..00000000 --- a/frontend/apollo/queries/getAppLogCount.gql +++ /dev/null @@ -1,3 +0,0 @@ -query GetAppLogCount($appId: ID!, $thisMonth: Boolean) { - logsCount(appId: $appId, thisMonth: $thisMonth) -} diff --git a/frontend/apollo/queries/getAppLogs.gql b/frontend/apollo/queries/getAppLogs.gql deleted file mode 100644 index c551a6ea..00000000 --- a/frontend/apollo/queries/getAppLogs.gql +++ /dev/null @@ -1,13 +0,0 @@ -query GetAppLogs($appId: ID!, $start: BigInt, $end: BigInt) { - logs(appId: $appId, start: $start, end: $end) { - id - timestamp - phaseNode - eventType - ipAddress - country - city - phSize - } - logsCount(appId: $appId) -} diff --git a/frontend/apollo/schema.graphql b/frontend/apollo/schema.graphql index 7aaf4a12..620916db 100644 --- a/frontend/apollo/schema.graphql +++ b/frontend/apollo/schema.graphql @@ -1,9 +1,23 @@ type Query { organisations: [OrganisationType] + organisationMembers(organisationId: ID, userId: ID, role: [String]): [OrganisationMemberType] + organisationAdminsAndSelf(organisationId: ID): [OrganisationMemberType] + organisationInvites(orgId: ID): [OrganisationMemberInviteType] + validateInvite(inviteId: ID): OrganisationMemberInviteType apps(organisationId: ID, appId: ID): [AppType] - logs(appId: ID, start: BigInt, end: BigInt): [KMSLogType] - logsCount(appId: ID, thisMonth: Boolean): Int + logs(appId: ID, start: BigInt, end: BigInt): LogsResponseType + kmsLogsCount(appId: ID, thisMonth: Boolean): Int + secretsLogsCount(appId: ID): Int appActivityChart(appId: ID, period: TimeRange): [ChartDataPointType] + appEnvironments(appId: ID, environmentId: ID, memberId: ID): [EnvironmentType] + appUsers(appId: ID): [OrganisationMemberType] + secrets(envId: ID): [SecretType] + secretHistory(secretId: ID): [SecretEventType] + secretTags(orgId: ID): [SecretTagType] + environmentKeys(appId: ID, environmentId: ID, memberId: ID): [EnvironmentKeyType] + environmentTokens(environmentId: ID): [EnvironmentTokenType] + userTokens(organisationId: ID): [UserTokenType] + serviceTokens(appId: ID): [ServiceTokenType] } type OrganisationType { @@ -12,6 +26,10 @@ type OrganisationType { identityKey: String! createdAt: DateTime plan: ApiOrganisationPlanChoices! + role: String + memberId: ID + keyring: String + recovery: String } """ @@ -33,6 +51,45 @@ enum ApiOrganisationPlanChoices { EN } +type OrganisationMemberType { + id: String! + role: ApiOrganisationMemberRoleChoices! + identityKey: String + wrappedKeyring: String! + createdAt: DateTime + updatedAt: DateTime! + email: String + username: String + fullName: String + avatarUrl: String + self: Boolean +} + +"""An enumeration.""" +enum ApiOrganisationMemberRoleChoices { + """Owner""" + OWNER + + """Admin""" + ADMIN + + """Developer""" + DEV +} + +type OrganisationMemberInviteType { + id: String! + organisation: OrganisationType! + apps: [AppType!]! + role: ApiOrganisationMemberInviteRoleChoices! + invitedBy: OrganisationMemberType! + inviteeEmail: String! + valid: Boolean! + createdAt: DateTime + updatedAt: DateTime! + expiresAt: DateTime! +} + type AppType { id: String! name: String! @@ -44,6 +101,23 @@ type AppType { createdAt: DateTime } +"""An enumeration.""" +enum ApiOrganisationMemberInviteRoleChoices { + """Owner""" + OWNER + + """Admin""" + ADMIN + + """Developer""" + DEV +} + +type LogsResponseType { + kms: [KMSLogType] + secrets: [SecretEventType] +} + type KMSLogType implements Node { id: ID! timestamp: BigInt @@ -74,6 +148,86 @@ compatible type. """ scalar BigInt +type SecretEventType { + id: String! + secret: SecretType! + environment: EnvironmentType! + user: OrganisationMemberType + key: String! + value: String! + version: Int! + tags: [SecretTagType!]! + comment: String! + eventType: ApiSecretEventEventTypeChoices! + timestamp: DateTime! + ipAddress: String + userAgent: String +} + +type SecretType { + id: String! + folder: SecretFolderType + key: String! + value: String! + version: Int! + tags: [SecretTagType!]! + comment: String! + createdAt: DateTime + updatedAt: DateTime! + history: [SecretEventType] +} + +type SecretFolderType { + id: String! + name: String! + createdAt: DateTime + updatedAt: DateTime! +} + +type SecretTagType { + id: String! + name: String! + color: String! +} + +type EnvironmentType { + id: String! + name: String! + envType: ApiEnvironmentEnvTypeChoices! + identityKey: String! + wrappedSeed: String! + wrappedSalt: String! + createdAt: DateTime + updatedAt: DateTime! +} + +"""An enumeration.""" +enum ApiEnvironmentEnvTypeChoices { + """Development""" + DEV + + """Staging""" + STAGING + + """Production""" + PROD +} + +"""An enumeration.""" +enum ApiSecretEventEventTypeChoices { + """Create""" + C + + """Read""" + R + + """Update""" + U + + """Delete""" + D +} + type ChartDataPointType { index: Int date: BigInt @@ -90,17 +244,107 @@ enum TimeRange { ALL_TIME } +type EnvironmentKeyType { + id: String! + environment: EnvironmentType! + identityKey: String! + wrappedSeed: String! + wrappedSalt: String! + createdAt: DateTime + updatedAt: DateTime! +} + +type EnvironmentTokenType { + id: String! + name: String! + identityKey: String! + token: String! + wrappedKeyShare: String! + createdAt: DateTime + updatedAt: DateTime! +} + +type UserTokenType { + id: String! + name: String! + identityKey: String! + token: String! + wrappedKeyShare: String! + createdAt: DateTime + updatedAt: DateTime! + expiresAt: DateTime +} + +type ServiceTokenType { + id: String! + keys: [EnvironmentKeyType!]! + identityKey: String! + token: String! + wrappedKeyShare: String! + name: String! + createdBy: OrganisationMemberType + createdAt: DateTime + updatedAt: DateTime! + expiresAt: DateTime +} + type Mutation { - createOrganisation(id: ID!, identityKey: String!, name: String!): CreateOrganisationMutation + createOrganisation(id: ID!, identityKey: String!, name: String!, wrappedKeyring: String!, wrappedRecovery: String!): CreateOrganisationMutation + inviteOrganisationMember(apps: [String], email: String!, orgId: ID!, role: String): InviteOrganisationMemberMutation + createOrganisationMember(identityKey: String!, inviteId: ID!, orgId: ID!, wrappedKeyring: String, wrappedRecovery: String): CreateOrganisationMemberMutation + deleteOrganisationMember(memberId: ID!): DeleteOrganisationMemberMutation + updateOrganisationMemberRole(memberId: ID!, role: String!): UpdateOrganisationMemberRole + updateMemberWrappedSecrets(orgId: ID!, wrappedKeyring: String!, wrappedRecovery: String!): UpdateUserWrappedSecretsMutation + deleteInvitation(inviteId: ID!): DeleteInviteMutation createApp(appSeed: String!, appToken: String!, appVersion: Int!, id: ID!, identityKey: String!, name: String!, organisationId: ID!, wrappedKeyShare: String!): CreateAppMutation rotateAppKeys(appToken: String!, id: ID!, wrappedKeyShare: String!): RotateAppKeysMutation deleteApp(id: ID!): DeleteAppMutation + addAppMember(appId: ID, envKeys: [EnvironmentKeyInput], memberId: ID): AddAppMemberMutation + removeAppMember(appId: ID, memberId: ID): RemoveAppMemberMutation + updateMemberEnvironmentScope(appId: ID, envKeys: [EnvironmentKeyInput], memberId: ID): UpdateMemberEnvScopeMutation + createEnvironment(adminKeys: [EnvironmentKeyInput], environmentData: EnvironmentInput!): CreateEnvironmentMutation + createEnvironmentKey(envId: ID!, identityKey: String!, userId: ID, wrappedSalt: String!, wrappedSeed: String!): CreateEnvironmentKeyMutation + createEnvironmentToken(envId: ID!, identityKey: String!, name: String!, token: String!, wrappedKeyShare: String!): CreateEnvironmentTokenMutation + createUserToken(expiry: BigInt, identityKey: String!, name: String!, orgId: ID!, token: String!, wrappedKeyShare: String!): CreateUserTokenMutation + deleteUserToken(tokenId: ID!): DeleteUserTokenMutation + createServiceToken(appId: ID!, environmentKeys: [EnvironmentKeyInput], expiry: BigInt, identityKey: String!, name: String!, token: String!, wrappedKeyShare: String!): CreateServiceTokenMutation + deleteServiceToken(tokenId: ID!): DeleteServiceTokenMutation + createSecretFolder(envId: ID!, id: ID!, name: String!, parentFolderId: ID): CreateSecretFolderMutation + createSecretTag(color: String!, name: String!, orgId: ID!): CreateSecretTagMutation + createSecret(secretData: SecretInput): CreateSecretMutation + editSecret(id: ID!, secretData: SecretInput): EditSecretMutation + deleteSecret(id: ID!): DeleteSecretMutation + readSecret(id: ID!): ReadSecretMutation } type CreateOrganisationMutation { organisation: OrganisationType } +type InviteOrganisationMemberMutation { + invite: OrganisationMemberInviteType +} + +type CreateOrganisationMemberMutation { + orgMember: OrganisationMemberType +} + +type DeleteOrganisationMemberMutation { + ok: Boolean +} + +type UpdateOrganisationMemberRole { + orgMember: OrganisationMemberType +} + +type UpdateUserWrappedSecretsMutation { + orgMember: OrganisationMemberType +} + +type DeleteInviteMutation { + ok: Boolean +} + type CreateAppMutation { app: AppType } @@ -111,4 +355,96 @@ type RotateAppKeysMutation { type DeleteAppMutation { app: AppType +} + +type AddAppMemberMutation { + app: AppType +} + +input EnvironmentKeyInput { + envId: ID! + userId: ID + identityKey: String! + wrappedSeed: String! + wrappedSalt: String! +} + +type RemoveAppMemberMutation { + app: AppType +} + +type UpdateMemberEnvScopeMutation { + app: AppType +} + +type CreateEnvironmentMutation { + environment: EnvironmentType +} + +input EnvironmentInput { + appId: ID! + name: String! + envType: String! + wrappedSeed: String! + wrappedSalt: String! + identityKey: String! +} + +type CreateEnvironmentKeyMutation { + environmentKey: EnvironmentKeyType +} + +type CreateEnvironmentTokenMutation { + environmentToken: EnvironmentTokenType +} + +type CreateUserTokenMutation { + ok: Boolean + userToken: UserTokenType +} + +type DeleteUserTokenMutation { + ok: Boolean +} + +type CreateServiceTokenMutation { + serviceToken: ServiceTokenType +} + +type DeleteServiceTokenMutation { + ok: Boolean +} + +type CreateSecretFolderMutation { + folder: SecretFolderType +} + +type CreateSecretTagMutation { + tag: SecretTagType +} + +type CreateSecretMutation { + secret: SecretType +} + +input SecretInput { + envId: ID + folderId: ID + key: String! + keyDigest: String! + value: String! + tags: [String] + comment: String +} + +type EditSecretMutation { + secret: SecretType +} + +type DeleteSecretMutation { + secret: SecretType +} + +type ReadSecretMutation { + ok: Boolean } \ No newline at end of file diff --git a/frontend/app/[team]/apps/[app]/environments/[environment]/page.tsx b/frontend/app/[team]/apps/[app]/environments/[environment]/page.tsx new file mode 100644 index 00000000..e9947101 --- /dev/null +++ b/frontend/app/[team]/apps/[app]/environments/[environment]/page.tsx @@ -0,0 +1,618 @@ +'use client' + +import { EnvironmentType, SecretInput, SecretType } from '@/apollo/graphql' +import UnlockKeyringDialog from '@/components/auth/UnlockKeyringDialog' +import { KeyringContext } from '@/contexts/keyringContext' +import { GetSecrets } from '@/graphql/queries/secrets/getSecrets.gql' +import { CreateNewSecret } from '@/graphql/mutations/environments/createSecret.gql' +import { UpdateSecret } from '@/graphql/mutations/environments/editSecret.gql' +import { DeleteSecretOp } from '@/graphql/mutations/environments/deleteSecret.gql' +import { GetAppEnvironments } from '@/graphql/queries/secrets/getAppEnvironments.gql' +import { + getUserKxPublicKey, + getUserKxPrivateKey, + decryptAsymmetric, + digest, + encryptAsymmetric, +} from '@/utils/crypto' +import { arraysEqual, envKeyring } from '@/utils/environments' +import { useMutation, useQuery } from '@apollo/client' +import { Fragment, useContext, useEffect, useRef, useState } from 'react' +import { Button } from '@/components/common/Button' +import { + FaChevronDown, + FaDownload, + FaExchangeAlt, + FaPlus, + FaSearch, + FaTimesCircle, + FaUndo, +} from 'react-icons/fa' +import SecretRow from '@/components/environments/SecretRow' +import clsx from 'clsx' +import { toast } from 'react-toastify' +import { organisationContext } from '@/contexts/organisationContext' +import { Menu, Transition } from '@headlessui/react' +import { usePathname, useSearchParams } from 'next/navigation' +import Link from 'next/link' +import { Alert } from '@/components/common/Alert' + +type EnvKeyring = { + privateKey: string + publicKey: string + salt: string +} + +export default function Environment({ + params, +}: { + params: { team: string; app: string; environment: string } +}) { + const { keyring } = useContext(KeyringContext) + const pathname = usePathname() + const searchParams = useSearchParams() + + const secretToHighlight = searchParams.get('secret') + const highlightedRef = useRef(null) + + const [envKeys, setEnvKeys] = useState(null) + const [secrets, setSecrets] = useState([]) + const [updatedSecrets, updateSecrets] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [isLoading, setIsloading] = useState(false) + + const { activeOrganisation: organisation } = useContext(organisationContext) + + useEffect(() => { + // 2. Scroll into view when secretToHighlight changes + if (highlightedRef.current && secrets.length > 0) { + highlightedRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }) + } + }, [secretToHighlight, secrets]) + + const unsavedChanges = + secrets.length !== updatedSecrets.length || + secrets.some((secret, index) => { + const updatedSecret = updatedSecrets[index] + + // Compare secret properties (comment, key, tags, value) + return ( + secret.comment !== updatedSecret.comment || + secret.key !== updatedSecret.key || + !arraysEqual(secret.tags, updatedSecret.tags) || + secret.value !== updatedSecret.value + ) + }) + + const { data: appEnvsData } = useQuery(GetAppEnvironments, { + variables: { + appId: params.app, + }, + }) + + const { data, loading } = useQuery(GetSecrets, { + variables: { + appId: params.app, + envId: params.environment, + }, + pollInterval: unsavedChanges ? 0 : 5000, + }) + + const savingAndFetching = isLoading || loading + + const [createSecret] = useMutation(CreateNewSecret) + const [updateSecret] = useMutation(UpdateSecret) + const [deleteSecret] = useMutation(DeleteSecretOp) + + const envPath = (env: EnvironmentType) => { + const pathSegments = pathname!.split('/') + pathSegments[pathSegments.length - 1] = env.id + return pathSegments?.join('/') + } + + const environment = data?.appEnvironments[0] as EnvironmentType + + const envLinks = + appEnvsData?.appEnvironments + .filter((env: EnvironmentType) => env.name !== environment?.name) + .map((env: EnvironmentType) => { + return { + label: env.name, + href: envPath(env), + } + }) ?? [] + + const handleAddSecret = (start: boolean = true) => { + const newSecret = { + id: `new-${crypto.randomUUID()}`, + updatedAt: null, + version: 1, + key: '', + value: '', + tags: [], + comment: '', + } as SecretType + start + ? updateSecrets([newSecret, ...updatedSecrets]) + : updateSecrets([...updatedSecrets, newSecret]) + } + + const handleUpdateSecret = async (secret: SecretType) => { + const { id, key, value, comment, tags } = secret + + const encryptedKey = await encryptAsymmetric(key, environment.identityKey) + const encryptedValue = await encryptAsymmetric(value, environment.identityKey) + const keyDigest = await digest(key, envKeys!.salt) + const encryptedComment = await encryptAsymmetric(comment, environment.identityKey) + const tagIds = tags.map((tag) => tag.id) + + if (id.split('-')[0] === 'new') { + await createSecret({ + variables: { + newSecret: { + envId: params.environment, + key: encryptedKey, + keyDigest, + value: encryptedValue, + folderId: null, + comment: encryptedComment, + tags: tagIds, + } as SecretInput, + }, + refetchQueries: [ + { + query: GetSecrets, + variables: { + appId: params.app, + envId: params.environment, + }, + }, + ], + }) + } else { + await updateSecret({ + variables: { + id, + secretData: { + key: encryptedKey, + keyDigest, + value: encryptedValue, + folderId: null, + comment: encryptedComment, + tags: tagIds, + } as SecretInput, + }, + refetchQueries: [ + { + query: GetSecrets, + variables: { + appId: params.app, + envId: params.environment, + }, + }, + ], + }) + } + } + + const handleDeleteSecret = async (id: string) => { + if (id.split('-')[0] === 'new') + updateSecrets(updatedSecrets.filter((secret) => secret.id !== id)) + else { + await deleteSecret({ + variables: { + id, + }, + refetchQueries: [ + { + query: GetSecrets, + variables: { + appId: params.app, + envId: params.environment, + }, + }, + ], + }) + } + toast.success('Secret deleted.') + } + + useEffect(() => { + const initEnvKeys = async () => { + const wrappedSeed = data.environmentKeys[0].wrappedSeed + + const userKxKeys = { + publicKey: await getUserKxPublicKey(keyring!.publicKey), + privateKey: await getUserKxPrivateKey(keyring!.privateKey), + } + const seed = await decryptAsymmetric(wrappedSeed, userKxKeys.privateKey, userKxKeys.publicKey) + + const salt = await decryptAsymmetric( + data.environmentKeys[0].wrappedSalt, + userKxKeys.privateKey, + userKxKeys.publicKey + ) + const { publicKey, privateKey } = await envKeyring(seed) + + setEnvKeys({ + publicKey, + privateKey, + salt, + }) + } + + if (data && keyring) initEnvKeys() + }, [data, keyring]) + + useEffect(() => { + if (data && envKeys) { + const decryptSecrets = async () => { + const decryptedSecrets = await Promise.all( + data.secrets.map(async (secret: SecretType) => { + const decryptedSecret = structuredClone(secret) + + decryptedSecret.key = await decryptAsymmetric( + secret.key, + envKeys?.privateKey, + envKeys?.publicKey + ) + + decryptedSecret.value = await decryptAsymmetric( + secret.value, + envKeys.privateKey, + envKeys.publicKey + ) + + if (decryptedSecret.comment !== '') + decryptedSecret.comment = await decryptAsymmetric( + secret.comment, + envKeys.privateKey, + envKeys.publicKey + ) + + // Decrypt history for each secret + if (secret.history && secret.history.length > 0) { + const decryptedHistory = await Promise.all( + secret.history.map(async (event) => { + const decryptedEvent = structuredClone(event) + + // Decrypt event fields + decryptedEvent!.key = await decryptAsymmetric( + event!.key, + envKeys.privateKey, + envKeys.publicKey + ) + + decryptedEvent!.value = await decryptAsymmetric( + event!.value, + envKeys.privateKey, + envKeys.publicKey + ) + + if (decryptedEvent!.comment !== '') { + decryptedEvent!.comment = await decryptAsymmetric( + event!.comment, + envKeys.privateKey, + envKeys.publicKey + ) + } + + return decryptedEvent + }) + ) + + decryptedSecret.history = decryptedHistory + } + + return decryptedSecret + }) + ) + return decryptedSecrets + } + + decryptSecrets().then((decryptedSecrets) => { + setSecrets(decryptedSecrets) + updateSecrets(decryptedSecrets) + }) + } + }, [envKeys, data]) + + const handleUpdateSecretProperty = (id: string, property: string, value: any) => { + const updatedSecretList = updatedSecrets.map((secret) => { + if (secret.id === id) { + return { ...secret, [property]: value } + } + return secret + }) + + updateSecrets(updatedSecretList) + } + + const getUpdatedSecrets = () => { + const changedElements = [] + + for (let i = 0; i < updatedSecrets.length; i++) { + const updatedSecret = updatedSecrets[i] + const originalSecret = secrets.find((secret) => secret.id === updatedSecret.id) + + // this is a newly created secret that doesn't exist on the server yet + if (!originalSecret) { + changedElements.push(updatedSecret) + } else if ( + originalSecret.comment !== updatedSecret.comment || + originalSecret.key !== updatedSecret.key || + !arraysEqual(originalSecret.tags, updatedSecret.tags) || + originalSecret.value !== updatedSecret.value + ) { + changedElements.push(updatedSecret) + } + } + + return changedElements + } + + const duplicateKeysExist = () => { + const keySet = new Set() + + for (const secret of updatedSecrets) { + if (keySet.has(secret.key)) { + return true // Duplicate key found + } + keySet.add(secret.key) + } + + return false // No duplicate keys found + } + + const handleSaveChanges = async () => { + setIsloading(true) + const changedSecrets = getUpdatedSecrets() + if (changedSecrets.some((secret) => secret.key.length === 0)) { + toast.error('Secret keys cannot be empty!') + setIsloading(false) + return false + } + + if (duplicateKeysExist()) { + toast.error('Secret keys cannot be repeated!') + setIsloading(false) + return false + } + + const updates = changedSecrets.map((secret) => handleUpdateSecret(secret)) + + await Promise.all(updates) + + setTimeout(() => setIsloading(false), 500) + + toast.success('Changes successfully deployed.') + } + + const handleDiscardChanges = () => { + updateSecrets(secrets) + } + + const secretNames = secrets.map((secret) => { + const { id, key } = secret + return { + id, + key, + } + }) + + const filteredSecrets = + searchQuery === '' + ? updatedSecrets + : updatedSecrets.filter((secret) => { + const searchRegex = new RegExp(searchQuery, 'i') + return searchRegex.test(secret.key) + }) + + const cannonicalSecret = (id: string) => secrets.find((secret) => secret.id === id) + + const downloadEnvFile = () => { + const envContent = secrets + .map((secret) => { + const comment = secret.comment ? `#${secret.comment}\n` : '' + return `${comment}${secret.key}=${secret.value}` + }) + .join('\n') + + const blob = new Blob([envContent], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + + const a = document.createElement('a') + a.href = url + a.download = `${environment.name}.env` + + document.body.appendChild(a) + a.click() + + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + + // useEffect(() => { + // const warningText = 'You have unsaved changes - are you sure you wish to leave this page?' + // const handleWindowClose = (e: BeforeUnloadEvent) => { + // if (!unsavedChanges) return + // e.preventDefault() + // return (e.returnValue = warningText) + // } + // const handleBrowseAway = () => { + // if (!unsavedChanges) return + // if (window.confirm(warningText)) return + // router.events.emit('routeChangeError') + // throw 'routeChange aborted.' + // } + // window.addEventListener('beforeunload', handleWindowClose) + // router.events.on('routeChangeStart', handleBrowseAway) + // return () => { + // window.removeEventListener('beforeunload', handleWindowClose) + // router.events.off('routeChangeStart', handleBrowseAway) + // } + // }, [unsavedChanges]) + + return ( +
+ {organisation && } + {keyring !== null && !loading && ( +
+
+ {envLinks.length > 1 ? ( + + {({ open }) => ( + <> + +
+

{environment.name}

+ +
+
+ + +
+ {envLinks.map((link: { label: string; href: string }) => ( + + {({ active }) => ( + +
{link.label}
+ + + )} +
+ ))} +
+
+
+ + )} +
+ ) : ( +

{environment.name}

+ )} + {unsavedChanges && ( + + You have undeployed changes to this environment. + + )} +
+ +
+
+
+ +
+ setSearchQuery(e.target.value)} + /> + setSearchQuery('')} + /> +
+
+ + {unsavedChanges && ( + + )} + +
+
+
+
+
+ key +
+
+ value + +
+
+ {organisation && + filteredSecrets.map((secret, index: number) => ( +
+ {index + 1} + +
+ ))} + +
+ +
+
+
+ )} +
+ ) +} diff --git a/frontend/app/[team]/apps/[app]/keys/page.tsx b/frontend/app/[team]/apps/[app]/keys/page.tsx deleted file mode 100644 index 586ed2a5..00000000 --- a/frontend/app/[team]/apps/[app]/keys/page.tsx +++ /dev/null @@ -1,287 +0,0 @@ -'use client' - -import { GetOrganisations } from '@/apollo/queries/getOrganisations.gql' -import { GetAppDetail } from '@/apollo/queries/getAppDetail.gql' -import { RotateAppKeys } from '@/apollo/mutations/rotateAppKeys.gql' -import { useLazyQuery, useQuery, useMutation } from '@apollo/client' -import { AppType, ChartDataPointType, TimeRange } from '@/apollo/graphql' -import { Fragment, useEffect, useState } from 'react' -import { Button } from '@/components/common/Button' -import { copyToClipBoard } from '@/utils/clipboard' -import { - FaCopy, - FaExclamationTriangle, - FaEye, - FaEyeSlash, - FaInfo, - FaMagic, - FaTimes, -} from 'react-icons/fa' -import { MdContentCopy, MdOutlineRotateLeft } from 'react-icons/md' -import { toast } from 'react-toastify' -import { Dialog, Transition } from '@headlessui/react' -import { cryptoUtils } from '@/utils/auth' -import { useSession } from 'next-auth/react' -import { getLocalKeyring } from '@/utils/localStorage' -import { splitSecret } from '@/utils/keyshares' -import { Alert } from '@/components/common/Alert' - -export default function App({ params }: { params: { team: string; app: string } }) { - const { data: orgsData } = useQuery(GetOrganisations) - const [getApp, { data }] = useLazyQuery(GetAppDetail) - - const app = data?.apps[0] as AppType - - const appId = `phApp:v${app?.appVersion}:${app?.identityKey}` - - const [appSecret, setAppSecret] = useState('') - - const { data: session } = useSession() - - const appSecretPlaceholder = '*'.repeat(295) - - useEffect(() => { - if (orgsData) { - const organisationId = orgsData.organisations[0].id - getApp({ - variables: { - organisationId, - appId: params.app, - }, - }) - } - }, [getApp, orgsData, params.app]) - - const handleCopy = (val: string) => { - copyToClipBoard(val) - toast.info('Copied') - } - - const RotateAppDialog = () => { - const [pw, setPw] = useState('') - const [showPw, setShowPw] = useState(false) - const [loading, setLoading] = useState(false) - const [isOpen, setIsOpen] = useState(false) - const [rotateAppKeys] = useMutation(RotateAppKeys) - - const closeModal = () => { - setPw('') - setIsOpen(false) - } - - const handleGenerateNewAppKey = async () => { - const APP_VERSION = 1 - - return new Promise(async (resolve, reject) => { - setTimeout(async () => { - setLoading(true) - try { - const wrapKey = await cryptoUtils.newAppWrapKey() - const newAppToken = await cryptoUtils.newAppToken() - const deviceKey = await cryptoUtils.deviceVaultKey(pw, session?.user?.email!) - const encryptedKeyring = getLocalKeyring(orgsData.organisations[0].id) - if (!encryptedKeyring) throw 'Error fetching local encrypted keys from browser' - const decryptedKeyring = await cryptoUtils.decryptAccountKeyring( - encryptedKeyring!, - deviceKey - ) - if (!decryptedKeyring) throw 'Failed to decrypt keys' - - const appSeed = await cryptoUtils.decryptedAppSeed( - app.appSeed, - decryptedKeyring.symmetricKey - ) - - const appKeys = await cryptoUtils.appKeyring(appSeed) - const appKeyShares = await splitSecret(appKeys.privateKey) - const wrappedShare = await cryptoUtils.wrappedKeyShare(appKeyShares[1], wrapKey) - await rotateAppKeys({ - variables: { - id: app.id, - appToken: newAppToken, - wrappedKeyShare: wrappedShare, - }, - }) - - setAppSecret(`pss:v${APP_VERSION}:${newAppToken}:${appKeyShares[0]}:${wrapKey}`) - - setLoading(false) - resolve(true) - } catch (error) { - console.log(error) - setLoading(false) - reject() - } - }, 500) - }) - } - - const handleSubmit = async (event: { preventDefault: () => void }) => { - event.preventDefault() - toast - .promise(handleGenerateNewAppKey, { - pending: 'Generating app keys', - success: 'Success!', - error: 'Something went wrong! Please check your password and try again.', - }) - .then(() => closeModal()) - } - - return ( - <> - - - {}}> - -
- - -
-
- - - -

- Genereate new app secret -

- -
- - - Generate a new app secret for {app.name} - - -
-
-
- -
- -
- Warning: This will revoke your current app keys. Your application - won't be able to decrypt data using the current keys. -
-
-
- - -
- -
- Your new keys will be available to use immediately. You will be able - to decrypt any existing data with your new keys. Please allow up to - 60 seconds for your old keys to be revoked. -
-
-
-
- -
- -
- setPw(e.target.value)} - type={showPw ? 'text' : 'password'} - minLength={16} - required - className="w-full " - /> - -
-
-
- - -
-
-
-
-
-
-
-
-
- - ) - } - - return ( -
-
- {/*

keys

*/} - {app && ( -
-
-
- app id - -
- {appId} -
- -
-
- app secret -
- {appSecret && ( -
- -
{"Copy this value. You won't see it again!"}
-
- )} - {appSecret && ( - - )} -
- {!appSecret && } -
- {appSecret || appSecretPlaceholder} -
-
- )} -
-
- ) -} diff --git a/frontend/app/[team]/apps/[app]/layout.tsx b/frontend/app/[team]/apps/[app]/layout.tsx index 74277d1b..2e41160b 100644 --- a/frontend/app/[team]/apps/[app]/layout.tsx +++ b/frontend/app/[team]/apps/[app]/layout.tsx @@ -1,16 +1,14 @@ 'use client' -import { Fragment, useEffect, useState } from 'react' +import { Fragment, useContext, useEffect, useState } from 'react' import { Tab } from '@headlessui/react' import clsx from 'clsx' import Link from 'next/link' -import { useQuery, useLazyQuery } from '@apollo/client' +import { useLazyQuery } from '@apollo/client' import { AppType } from '@/apollo/graphql' -import { GetOrganisations } from '@/apollo/queries/getOrganisations.gql' -import { GetAppDetail } from '@/apollo/queries/getAppDetail.gql' +import { GetAppDetail } from '@/graphql/queries/getAppDetail.gql' import { usePathname } from 'next/navigation' -import { Button } from '@/components/common/Button' -import { FaCopy } from 'react-icons/fa' +import { organisationContext } from '@/contexts/organisationContext' export default function AppLayout({ params, @@ -19,56 +17,63 @@ export default function AppLayout({ params: { team: string; app: string } children: React.ReactNode }) { + const { activeOrganisation: organisation } = useContext(organisationContext) const path = usePathname() const [tabIndex, setTabIndex] = useState(0) - const { data: orgsData } = useQuery(GetOrganisations) const [getApp, { data, loading }] = useLazyQuery(GetAppDetail) const app = data?.apps[0] as AppType + const [tabs, setTabs] = useState([ + { + name: 'Secrets', + link: '', + }, + { + name: 'Service tokens', + link: 'tokens', + }, + { + name: 'Logs', + link: 'logs', + }, + { + name: 'Members', + link: 'members', + }, + ]) + useEffect(() => { - if (orgsData) { - const organisationId = orgsData.organisations[0].id + if (organisation) { getApp({ variables: { - organisationId, + organisationId: organisation.id, appId: params.app, }, }) + + if (organisation.role!.toLowerCase() !== 'dev') { + setTabs((prevTabs) => + prevTabs.some((tab) => tab.name === 'Settings') + ? prevTabs + : [...prevTabs, { name: 'Settings', link: 'settings' }] + ) + } } - }, [getApp, orgsData, params.app]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [organisation, params.app]) useEffect(() => { const activeTabIndex = () => { if (app) { - const currentUrl = path?.split('/')[4] - if (currentUrl === '') return 0 - if (currentUrl === 'logs') return 1 - if (currentUrl === 'keys') return 2 - if (currentUrl === 'settings') return 3 + const currentUrl = path?.split('/')[4] || '' + const index = tabs.findIndex((tab) => tab.link === currentUrl) + return index >= 0 ? index : 0 } return 0 } - setTabIndex(activeTabIndex()) - }, [app, path]) - const tabs = [ - { - name: 'Home', - link: '', - }, - { - name: 'Logs', - link: 'logs', - }, - { - name: 'Keys', - link: 'keys', - }, - { - name: 'Settings', - link: 'settings', - }, - ] + setTabIndex(activeTabIndex()) + }, [app, path, tabs]) return (
(null) - const tableBodyRef = useRef(null) - const [getAppLogs, { data, loading }] = useLazyQuery(GetAppLogs) - const [totalCount, setTotalCount] = useState(0) - const [logList, setLogList] = useState([]) - - const [endofList, setEndofList] = useState(false) - - const getCurrentTimeStamp = () => Date.now() - const getLastLogTimestamp = () => - logList.length > 0 ? logList[logList.length - 1].timestamp : getCurrentTimeStamp() - - /** - * Fetches logs for the app with the given start and end timestamps, - * and then adds the result of the query to the current log list. - * - * @param {number} start - Start datetime as unix timestamp (ms) - * @param {number} end - End datetime as unix timestamp (ms) - * - * @returns {void} - */ - const fetchLogs = (start: number, end: number) => { - getAppLogs({ - variables: { - appId: params.app, - start, - end, - }, - fetchPolicy: 'network-only', - }).then((result) => { - if (result.data?.logs.length) { - setLogList(logList.concat(result.data.logs)) - } - if (result.data?.logs.length < DEFAULT_PAGE_SIZE) setEndofList(true) - }) - } - - const clearLogList = () => setLogList([]) - - /** - * Gets the first page of logs, by resetting the log list and fetching logs using the current unix timestamp. - * - * @returns {void} - */ - const getFirstPage = () => { - setEndofList(false) - fetchLogs(LOGS_START_DATE, getCurrentTimeStamp()) - } - - /** - * Gets the new page of logs by using the last available timestamp from the current log list - * - * @returns {void} - */ - const getNextPage = () => { - fetchLogs(LOGS_START_DATE, getLastLogTimestamp()) - } - - /** - * Hook to get the first page of logs on page load, or when the loglist is reset to empty - */ - useEffect(() => { - if (logList.length === 0) getFirstPage() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [params.app, logList]) - - /** - * Hook to update the log count once its available - */ - useEffect(() => { - if (data?.logsCount) setTotalCount(data.logsCount) - }, [data]) - - // useEffect(() => { - // const options = { - // root: null, - // rootMargin: '0px', - // threshold: 1.0, - // } - // const observer = new IntersectionObserver((entries) => { - // const [entry] = entries - // if (entry.isIntersecting) getNextPage() - // }, options) - - // if (loglistEndRef.current) { - // if (endofList) observer.unobserve(loglistEndRef.current) - // else observer.observe(loglistEndRef.current) - // } - - // return () => { - // if (loglistEndRef.current) observer.unobserve(loglistEndRef.current) - // } - - // // eslint-disable-next-line react-hooks/exhaustive-deps - // }, [loglistEndRef]) - - const LogRow = (props: { log: KmsLogType }) => { - const { log } = props - - const SDKIcon = (sdkName: string) => { - const sdks = [ - { - name: 'node-js', - label: 'Node.js', - icon: , - color: 'bg-[#339933]', - }, - { - name: 'python', - label: 'Python', - icon: , - color: 'bg-[#3776AB]', - }, - ] - - const sdk = sdks.find((sdk) => sdkName.toLowerCase().includes(sdk.name)) || sdks[0] - - return ( -
- {sdk.icon} -
- ) - } - - const relativeTimeStamp = () => { - return relativeTimeFromDates(new Date(log.timestamp)) - } - - const verboseTimeStamp = () => { - const date = new Date(log.timestamp) - return date.toISOString() - } - - const LogField = (props: { label: string; children: ReactNode }) => { - return ( -
- {props.label}: - {props.children} -
- ) - } - - return ( - - {({ open }) => ( - <> - - {/* */} - - - - {SDKIcon(log.phaseNode!)} - {log.eventType} - - {humanFileSize(log.phSize!)} - - - {log.city} {log.country ? getUnicodeFlagIcon(log.country) : 'Not available'} - - - {relativeTimeStamp()} - - {/* */} - - - - -
- Log ID: - {log.id} -
-
- -
- {SDKIcon(log.phaseNode!)} {log.phaseNode} -
-
- - - {log.eventType} - - - {humanFileSize(log.phSize!)} - - {(log.city || log.country) && ( - - {' '} - {log.city}, {log.country}{' '} - {log.country ? getUnicodeFlagIcon(log.country) : 'Not available'} - - )} - - {log.ipAddress} - - {verboseTimeStamp()} -
-
- -
- - )} -
- ) - } - - const SkeletonRow = (props: { rows: number }) => { - const SKELETON_BASE_STYLE = 'dark:bg-neutral-700 bg-neutral-300 animate-pulse' - return ( - <> - {[...Array(props.rows)].map((_, n) => ( - - - - - -
- - -
- - -
- - -
- - -
- - - ))} - - ) - } +export default function Logs({ params }: { params: { team: string; app: string } }) { + const [tabIndex, setTabIndex] = useState(0) + const { activeOrganisation: organisation } = useContext(organisationContext) + + const tabs = [ + { + label: 'Secrets', + component: , + }, + { + label: 'KMS', + component: , + }, + ] return (
-
- - {totalCount && } Events - - -
- - - - - - - - - - - - - {logList.map((log, n) => ( - - ))} - {loading && } - - - - -
SDKEventDataLocationTime
-
- {!endofList && ( - + {organisation?.role?.toLowerCase() === 'owner' ? ( + setTabIndex(index)}> + + {tabs.map((tab) => ( + + {({ selected }) => ( +
+ {tab.label} +
)} - {endofList && `No${logList.length ? ' more ' : ' '}logs to show`} -
-
+ + ))} + + + {tabs.map((tab) => ( + {tab.component} + ))} + + + ) : ( + + )}
) } diff --git a/frontend/app/[team]/apps/[app]/members/page.tsx b/frontend/app/[team]/apps/[app]/members/page.tsx new file mode 100644 index 00000000..d1a63f3c --- /dev/null +++ b/frontend/app/[team]/apps/[app]/members/page.tsx @@ -0,0 +1,930 @@ +'use client' + +import GetOrganisationMembers from '@/graphql/queries/organisation/getOrganisationMembers.gql' +import AddMemberToApp from '@/graphql/mutations/apps/addAppMember.gql' +import RemoveMemberFromApp from '@/graphql/mutations/apps/removeAppMember.gql' +import UpdateEnvScope from '@/graphql/mutations/apps/updateEnvScope.gql' +import GetAppMembers from '@/graphql/queries/apps/getAppMembers.gql' +import { GetAppEnvironments } from '@/graphql/queries/secrets/getAppEnvironments.gql' +import { GetEnvironmentKey } from '@/graphql/queries/secrets/getEnvironmentKey.gql' +import { useLazyQuery, useMutation, useQuery } from '@apollo/client' +import { Fragment, useContext, useEffect, useState } from 'react' +import { OrganisationMemberType, EnvironmentType } from '@/apollo/graphql' +import { Button } from '@/components/common/Button' +import { organisationContext } from '@/contexts/organisationContext' +import { relativeTimeFromDates } from '@/utils/time' +import { Combobox, Dialog, Listbox, Transition } from '@headlessui/react' +import { + FaCheckSquare, + FaChevronDown, + FaEye, + FaEyeSlash, + FaPlus, + FaSquare, + FaTimes, + FaUserCog, + FaUserTimes, +} from 'react-icons/fa' +import clsx from 'clsx' +import { toast } from 'react-toastify' +import { useSession } from 'next-auth/react' +import { Avatar } from '@/components/common/Avatar' +import { KeyringContext } from '@/contexts/keyringContext' +import { unwrapEnvSecretsForUser, wrapEnvSecretsForUser } from '@/utils/environments' +import { OrganisationKeyring, cryptoUtils } from '@/utils/auth' +import { userIsAdmin } from '@/utils/permissions' +import { RoleLabel } from '@/components/users/RoleLabel' +import { Alert } from '@/components/common/Alert' +import Link from 'next/link' + +export default function Members({ params }: { params: { team: string; app: string } }) { + const { data } = useQuery(GetAppMembers, { variables: { appId: params.app } }) + + const { keyring, setKeyring } = useContext(KeyringContext) + const { activeOrganisation: organisation } = useContext(organisationContext) + + const activeUserIsAdmin = organisation ? userIsAdmin(organisation.role!) : false + + const [getEnvKey] = useLazyQuery(GetEnvironmentKey) + + const { data: session } = useSession() + + const validateKeyring = async (password: string) => { + return new Promise(async (resolve) => { + if (keyring) resolve(keyring) + else { + const decryptedKeyring = await cryptoUtils.getKeyring( + session?.user?.email!, + organisation!.id, + password + ) + setKeyring(decryptedKeyring) + resolve(decryptedKeyring) + } + }) + } + + const AddMemberDialog = () => { + const [getMembers, { data: orgMembersData }] = useLazyQuery(GetOrganisationMembers) + + useEffect(() => { + if (organisation) { + getMembers({ + variables: { + organisationId: organisation.id, + role: null, + }, + }) + } + }, [getMembers]) + + const memberOptions = + orgMembersData?.organisationMembers.filter( + (orgMember: OrganisationMemberType) => + !data?.appUsers + .map((appUser: OrganisationMemberType) => appUser.id) + .includes(orgMember.id) + ) ?? [] + + const [addMember] = useMutation(AddMemberToApp) + + const { data: appEnvsData } = useQuery(GetAppEnvironments, { + variables: { + appId: params.app, + }, + }) + + const envOptions = + appEnvsData?.appEnvironments.map((env: EnvironmentType) => { + const { id, name } = env + + return { + id, + name, + } + }) ?? [] + + const [isOpen, setIsOpen] = useState(false) + const [selectedMember, setSelectedMember] = useState(null) + const [query, setQuery] = useState('') + const [envScope, setEnvScope] = useState>>([]) + const [showEnvHint, setShowEnvHint] = useState(false) + const [password, setPassword] = useState('') + const [showPw, setShowPw] = useState(false) + + const filteredPeople = + query === '' + ? memberOptions + : memberOptions.filter((member: OrganisationMemberType) => { + const memberQueryableName = member.fullName || member.email! + return memberQueryableName.toLowerCase().includes(query.toLowerCase()) + }) + + const closeModal = () => { + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + const handleAddMember = async (e: { preventDefault: () => void }) => { + e.preventDefault() + + if (envScope.length === 0) { + setShowEnvHint(true) + return false + } + + const keyring = await validateKeyring(password) + + const appEnvironments = appEnvsData.appEnvironments as EnvironmentType[] + + const envKeyPromises = appEnvironments + .filter((env) => envScope.map((selectedEnv) => selectedEnv.id).includes(env.id)) + .map(async (env: EnvironmentType) => { + const { data } = await getEnvKey({ + variables: { + envId: env.id, + appId: params.app, + }, + }) + + const { + wrappedSeed: userWrappedSeed, + wrappedSalt: userWrappedSalt, + identityKey, + } = data.environmentKeys[0] + + const { seed, salt } = await unwrapEnvSecretsForUser( + userWrappedSeed, + userWrappedSalt, + keyring! + ) + + const { wrappedSeed, wrappedSalt } = await wrapEnvSecretsForUser( + { seed, salt }, + selectedMember! + ) + + return { + envId: env.id, + userId: selectedMember!.id, + identityKey, + wrappedSeed, + wrappedSalt, + } + }) + + const envKeyInputs = await Promise.all(envKeyPromises) + + await addMember({ + variables: { memberId: selectedMember!.id, appId: params.app, envKeys: envKeyInputs }, + refetchQueries: [ + { + query: GetAppMembers, + variables: { appId: params.app }, + }, + ], + }) + + toast.success('Added member to App', { autoClose: 2000 }) + } + + return ( + <> +
+ +
+ + + + +
+ + +
+
+ + + +

+ Add a member +

+ + +
+ + {memberOptions.length === 0 ? ( +
+ +

+ All organisation members are added to this App. You can invite more + users from the{' '} + + organisation members + {' '} + page. +

+
+
+ ) : ( +
+ + {({ open }) => ( + <> +
+ + + +
+ setQuery(event.target.value)} + required + displayValue={(person: OrganisationMemberType) => + person?.fullName || person?.email! + } + /> +
+ + + +
+
+
+ + +
+ {filteredPeople.map((person: OrganisationMemberType) => ( + + {({ active, selected }) => ( +
+ + + {person.fullName || person.email} + +
+ )} +
+ ))} +
+
+
+ + )} +
+ +
+ {envScope.length === 0 && showEnvHint && ( + + Select an environment scope + + )} + + {({ open }) => ( + <> + + + + +
+ + {envScope + .map((env: Partial) => env.name) + .join(' + ')} + + +
+
+ + +
+ {envOptions.map((env: Partial) => ( + + {({ active, selected }) => ( +
+ {selected ? ( + + ) : ( + + )} + + {env.name} + +
+ )} +
+ ))} +
+
+
+ + {!keyring && ( +
+ +
+ setPassword(e.target.value)} + type={showPw ? 'text' : 'password'} + minLength={16} + required + autoFocus + className="custom w-full text-zinc-800 font-mono dark:text-white bg-zinc-100 dark:bg-zinc-800 rounded-md" + /> + +
+
+ )} + + )} +
+
+ +
+ + +
+
+ )} +
+
+
+
+
+
+ + ) + } + + const RemoveMemberConfirmDialog = (props: { member: OrganisationMemberType }) => { + const { member } = props + + const [removeMember] = useMutation(RemoveMemberFromApp) + + const [isOpen, setIsOpen] = useState(false) + + const closeModal = () => { + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + const handleRemoveMember = async () => { + await removeMember({ + variables: { memberId: member.id, appId: params.app }, + refetchQueries: [ + { + query: GetAppMembers, + variables: { appId: params.app }, + }, + ], + }) + toast.success('Removed member from app', { autoClose: 2000 }) + } + + return ( + <> +
+ +
+ + + + +
+ + +
+
+ + + +

+ Remove member +

+ + +
+ +
+

+ Are you sure you want to remove {member.fullName || member.email} from this + app? +

+
+ + +
+
+
+
+
+
+
+
+ + ) + } + + const ManageUserAccessDialog = (props: { member: OrganisationMemberType }) => { + const [updateScope] = useMutation(UpdateEnvScope) + const [getUserEnvScope] = useLazyQuery(GetAppEnvironments) + + const { data: appEnvsData } = useQuery(GetAppEnvironments, { + variables: { + appId: params.app, + }, + }) + + const envOptions = + appEnvsData?.appEnvironments.map((env: EnvironmentType) => { + const { id, name } = env + + return { + id, + name, + } + }) ?? [] + + const [isOpen, setIsOpen] = useState(false) + + const [envScope, setEnvScope] = useState>>([]) + const [showEnvHint, setShowEnvHint] = useState(false) + const [password, setPassword] = useState('') + const [showPw, setShowPw] = useState(false) + + const memberIsAdmin = userIsAdmin(props.member.role) || false + + const closeModal = () => { + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + useEffect(() => { + if (isOpen) { + const handleGetCurrentSCope = async () => { + const { data: currentScope } = await getUserEnvScope({ + variables: { + appId: params.app, + memberId: props.member.id, + }, + fetchPolicy: 'no-cache', + }) + + setEnvScope( + currentScope?.appEnvironments.map((env: EnvironmentType) => { + const { id, name } = env + + return { + id, + name, + } + }) ?? [] + ) + } + + if (isOpen) handleGetCurrentSCope() + } + }, [getUserEnvScope, isOpen, props.member.id]) + + const handleUpdateScope = async (e: { preventDefault: () => void }) => { + e.preventDefault() + + if (envScope.length === 0) { + setShowEnvHint(true) + return false + } + + const keyring = await validateKeyring(password) + + const appEnvironments = appEnvsData.appEnvironments as EnvironmentType[] + + const envKeyPromises = appEnvironments + .filter((env) => envScope.map((selectedEnv) => selectedEnv.id).includes(env.id)) + .map(async (env: EnvironmentType) => { + const { data } = await getEnvKey({ + variables: { + envId: env.id, + appId: params.app, + }, + }) + + const { + wrappedSeed: userWrappedSeed, + wrappedSalt: userWrappedSalt, + identityKey, + } = data.environmentKeys[0] + + const { seed, salt } = await unwrapEnvSecretsForUser( + userWrappedSeed, + userWrappedSalt, + keyring! + ) + + const { wrappedSeed, wrappedSalt } = await wrapEnvSecretsForUser( + { seed, salt }, + props.member! + ) + + return { + envId: env.id, + userId: props.member!.id, + identityKey, + wrappedSeed, + wrappedSalt, + } + }) + + const envKeyInputs = await Promise.all(envKeyPromises) + + await updateScope({ + variables: { memberId: props.member!.id, appId: params.app, envKeys: envKeyInputs }, + refetchQueries: [ + { + query: GetAppMembers, + variables: { appId: params.app }, + }, + ], + }) + + toast.success('Updated user access', { autoClose: 2000 }) + } + + return ( + <> +
+ +
+ + + + +
+ + +
+
+ + + +

+ Manage access for {props.member.fullName || props.member.email} +

+ + +
+ +
+
+ {envScope.length === 0 && showEnvHint && ( + + Select an environment scope + + )} + + {({ open }) => ( + <> + + + + +
+ + {envScope + .map((env: Partial) => env.name) + .join(' + ')} + + +
+
+ + +
+ {envOptions.map((env: Partial) => ( + + {({ active, selected }) => ( +
+ {selected ? ( + + ) : ( + + )} + + {env.name} + +
+ )} +
+ ))} +
+
+
+ + )} +
+
+ {!keyring && !memberIsAdmin && ( +
+ +
+ setPassword(e.target.value)} + type={showPw ? 'text' : 'password'} + minLength={16} + required + autoFocus + className="custom w-full text-zinc-800 font-mono dark:text-white bg-zinc-100 dark:bg-zinc-800 rounded-md" + /> + +
+
+ )} + + {memberIsAdmin && ( + +

+ This user is an , and has access to all + environments in this App. To restrict their access, change their role to{' '} + from the{' '} + + organisation members + {' '} + page. +

+
+ )} + +
+ + +
+
+
+
+
+
+
+
+ + ) + } + + return ( +
+
+
+ +
+ + + + + + + + {activeUserIsAdmin && } + + + + {data?.appUsers.map((member: OrganisationMemberType) => ( + + + + + {activeUserIsAdmin && ( + + )} + + ))} + +
+ User + + Joined +
+ +
+
+ {member.fullName || member.email} + +
+ {member.fullName && ( + {member.email} + )} +
+
+ {relativeTimeFromDates(new Date(member.createdAt))} + + {member.email !== session?.user?.email && + member.role.toLowerCase() !== 'owner' && ( +
+ + +
+ )} +
+
+
+ ) +} diff --git a/frontend/app/[team]/apps/[app]/old-home.tsx.archive b/frontend/app/[team]/apps/[app]/old-home.tsx.archive new file mode 100644 index 00000000..f89afaad --- /dev/null +++ b/frontend/app/[team]/apps/[app]/old-home.tsx.archive @@ -0,0 +1,120 @@ +'use client' + +import { GetAppDetail } from '@/graphql/queries/getAppDetail.gql' +import { GetAppLogCount } from '@/graphql/queries/getAppLogCount.gql' +import { useLazyQuery } from '@apollo/client' +import { AppType } from '@/apollo/graphql' +import { useContext, useEffect } from 'react' +import { AppActivityChart } from '@/components/apps/AppActivityChart' +import { FaArrowRight } from 'react-icons/fa' +import { Button } from '@/components/common/Button' +import Spinner from '@/components/common/Spinner' +import { relativeTimeFromDates } from '@/utils/time' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { Count } from 'reaviz' +import { organisationContext } from '@/contexts/organisationContext' + +export default function App({ params }: { params: { team: string; app: string } }) { + const { activeOrganisation: organisation } = useContext(organisationContext) + + const [getAppLogCount, { data: countData, loading: countLoading }] = useLazyQuery(GetAppLogCount) + const [getApp, { data, loading: appDataLoading }] = useLazyQuery(GetAppDetail) + + const app = data?.apps[0] as AppType + + useEffect(() => { + if (organisation) { + getApp({ + variables: { + organisationId: organisation.id, + appId: params.app, + }, + }) + getAppLogCount({ + variables: { + appId: params.app, + }, + fetchPolicy: 'cache-and-network', + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [organisation, params.app]) + + const totalLogCount = countData?.logsCount ?? 0 + + const showTotalLogCountSpinner = countLoading && totalLogCount === 0 + + return ( +
+ {!app && ( +
+ )} + {app && ( +
+
+ +
+
+ )} + +
+
+ {showTotalLogCountSpinner && } + {!showTotalLogCountSpinner && ( + + + + )} +

Total Decrypts

+
+
+ + + +
+
+ +
+
+
+ 1 +

Active key

+
+ +
+
+ + + +
+
+ +
+
+ {appDataLoading && } + {app && ( + + {relativeTimeFromDates(new Date(app.createdAt))} + + )} +

App created

+
+
+ + + +
+
+
+ ) +} diff --git a/frontend/app/[team]/apps/[app]/page.tsx b/frontend/app/[team]/apps/[app]/page.tsx index 6f6c7b48..994d8766 100644 --- a/frontend/app/[team]/apps/[app]/page.tsx +++ b/frontend/app/[team]/apps/[app]/page.tsx @@ -1,120 +1,474 @@ 'use client' -import { GetOrganisations } from '@/apollo/queries/getOrganisations.gql' -import { GetAppDetail } from '@/apollo/queries/getAppDetail.gql' -import { GetAppLogCount } from '@/apollo/queries/getAppLogCount.gql' -import { useLazyQuery, useQuery } from '@apollo/client' -import { AppType } from '@/apollo/graphql' -import { useEffect } from 'react' -import { AppActivityChart } from '@/components/apps/AppActivityChart' -import { FaArrowRight } from 'react-icons/fa' -import { Button } from '@/components/common/Button' -import Spinner from '@/components/common/Spinner' -import { humanReadableNumber } from '@/utils/dataUnits' -import { relativeTimeFromDates } from '@/utils/time' +import { GetAppEnvironments } from '@/graphql/queries/secrets/getAppEnvironments.gql' +import { GetEnvSecretsKV } from '@/graphql/queries/secrets/getSecretKVs.gql' +import { InitAppEnvironments } from '@/graphql/mutations/environments/initAppEnvironments.gql' +import { GetOrganisationAdminsAndSelf } from '@/graphql/queries/organisation/getOrganisationAdminsAndSelf.gql' +import { LogSecretRead } from '@/graphql/mutations/environments/readSecret.gql' +import { useLazyQuery, useMutation, useQuery } from '@apollo/client' +import { useContext, useEffect, useState } from 'react' +import { createNewEnv, decryptEnvSecretKVs, unwrapEnvSecretsForUser } from '@/utils/environments' +import { ApiEnvironmentEnvTypeChoices, EnvironmentType, SecretType } from '@/apollo/graphql' +import _sodium from 'libsodium-wrappers-sumo' +import { KeyringContext } from '@/contexts/keyringContext' +import UnlockKeyringDialog from '@/components/auth/UnlockKeyringDialog' +import { + FaArrowRight, + FaCheckCircle, + FaChevronRight, + FaCircle, + FaCopy, + FaExternalLinkAlt, + FaRegEye, + FaRegEyeSlash, + FaSearch, + FaTimesCircle, +} from 'react-icons/fa' import Link from 'next/link' import { usePathname } from 'next/navigation' -import { Count } from 'reaviz' +import { organisationContext } from '@/contexts/organisationContext' +import { Button } from '@/components/common/Button' +import clsx from 'clsx' +import { Disclosure, Transition } from '@headlessui/react' +import { copyToClipBoard } from '@/utils/clipboard' +import { toast } from 'react-toastify' +import { userIsAdmin } from '@/utils/permissions' + +type EnvSecrets = { + env: EnvironmentType + secrets: SecretType[] +} + +type AppSecret = { + key: string + envs: Array<{ + env: Partial + secret: SecretType | null + }> +} + +export default function Secrets({ params }: { params: { team: string; app: string } }) { + const { data } = useQuery(GetAppEnvironments, { + variables: { + appId: params.app, + }, + }) + + const pathname = usePathname() -export default function App({ params }: { params: { team: string; app: string } }) { - const { data: orgsData } = useQuery(GetOrganisations) - const [getAppLogCount, { data: countData, loading: countLoading }] = useLazyQuery(GetAppLogCount) - const [getApp, { data, loading: appDataLoading }] = useLazyQuery(GetAppDetail) + const [getEnvSecrets] = useLazyQuery(GetEnvSecretsKV) + const [getOrgAdmins, { data: orgAdminsData }] = useLazyQuery(GetOrganisationAdminsAndSelf) + const [appSecrets, setAppSecrets] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [initAppEnvironments] = useMutation(InitAppEnvironments) - const app = data?.apps[0] as AppType + const { keyring } = useContext(KeyringContext) + const { activeOrganisation: organisation } = useContext(organisationContext) + + const activeUserIsAdmin = organisation ? userIsAdmin(organisation.role!) : false + + const filteredSecrets = + searchQuery === '' + ? appSecrets + : appSecrets.filter((secret) => { + const searchRegex = new RegExp(searchQuery, 'i') + return searchRegex.test(secret.key) + }) useEffect(() => { - if (orgsData) { - const organisationId = orgsData.organisations[0].id - getApp({ + if (organisation) { + getOrgAdmins({ variables: { - organisationId, - appId: params.app, + organisationId: organisation.id, }, }) - getAppLogCount({ - variables: { - appId: params.app, - }, - fetchPolicy: 'cache-and-network', + } + }, [getOrgAdmins, organisation, params.app]) + + useEffect(() => { + const fetchAndDecryptAppEnvs = async (appEnvironments: EnvironmentType[]) => { + const envSecrets = [] as EnvSecrets[] + + for (const env of appEnvironments) { + const { data } = await getEnvSecrets({ + variables: { + envId: env.id, + }, + }) + + const { wrappedSeed, wrappedSalt } = data.environmentKeys[0] + + const { publicKey, privateKey } = await unwrapEnvSecretsForUser( + wrappedSeed, + wrappedSalt, + keyring! + ) + + const decryptedSecrets = await decryptEnvSecretKVs(data.secrets, { + publicKey, + privateKey, + }) + + envSecrets.push({ env, secrets: decryptedSecrets }) + } + + // Create a list of unique secret keys + const secretKeys = Array.from( + new Set(envSecrets.flatMap((envCard) => envCard.secrets.map((secret) => secret.key))) + ) + + // Transform envCards into an array of AppSecret objects + const appSecrets = secretKeys.map((key) => { + const envs = envSecrets.map((envCard) => ({ + env: envCard.env, + secret: envCard.secrets.find((secret) => secret.key === key) || null, + })) + return { key, envs } }) + + setAppSecrets(appSecrets) } - }, [getApp, getAppLogCount, orgsData, params.app]) - const totalLogCount = countData?.logsCount ?? 0 + if (keyring !== null && data?.appEnvironments) fetchAndDecryptAppEnvs(data?.appEnvironments) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data?.appEnvironments, keyring]) - const showTotalLogCountSpinner = countLoading && totalLogCount === 0 + const initAppEnvs = async () => { + const mutationPayload = { + devEnv: await createNewEnv( + params.app, + 'Development', + ApiEnvironmentEnvTypeChoices.Dev, + orgAdminsData.organisationAdminsAndSelf + ), + stagingEnv: await createNewEnv( + params.app, + 'Staging', + ApiEnvironmentEnvTypeChoices.Staging, + orgAdminsData.organisationAdminsAndSelf + ), + prodEnv: await createNewEnv( + params.app, + 'Production', + ApiEnvironmentEnvTypeChoices.Prod, + orgAdminsData.organisationAdminsAndSelf + ), + } - return ( -
- {!app && ( -
- )} - {app && ( -
-
- -
-
- )} - -
-
- {showTotalLogCountSpinner && } - {!showTotalLogCountSpinner && ( - - - - )} -

Total Decrypts

-
-
- - + await initAppEnvironments({ + variables: { + devEnv: mutationPayload.devEnv.createEnvPayload, + stagingEnv: mutationPayload.stagingEnv.createEnvPayload, + prodEnv: mutationPayload.prodEnv.createEnvPayload, + devAdminKeys: mutationPayload.devEnv.adminKeysPayload, + stagAdminKeys: mutationPayload.stagingEnv.adminKeysPayload, + prodAdminKeys: mutationPayload.prodEnv.adminKeysPayload, + }, + refetchQueries: [ + { + query: GetAppEnvironments, + variables: { + appId: params.app, + }, + }, + ], + }) + } + + const setupRequired = data?.appEnvironments.length === 0 ?? true + + const EnvSecret = (props: { + envSecret: { + env: Partial + secret: SecretType | null + } + sameAsProd: boolean + }) => { + const { envSecret, sameAsProd } = props + + const [readSecret] = useMutation(LogSecretRead) + + const [showValue, setShowValue] = useState(false) + + const handleRevealSecret = async () => { + setShowValue(true) + await readSecret({ variables: { id: envSecret.secret!.id } }) + } + + const handleHideSecret = () => setShowValue(false) + + const toggleShowValue = () => { + showValue ? handleHideSecret() : handleRevealSecret() + } + + const handleCopy = async (val: string) => { + copyToClipBoard(val) + toast.info('Copied', { autoClose: 2000 }) + await readSecret({ variables: { id: envSecret.secret!.id } }) + } + + return ( +
+
+ +
{envSecret.env.envType}
+
-
-
-
-
- 1 -

Active key

+ {envSecret.secret === null ? ( + missing + ) : envSecret.secret.value.length === 0 ? ( + blank + ) : ( +
+ + {showValue ? ( +
{envSecret.secret.value}
+ ) : ( + {'*'.repeat(envSecret.secret.value.length)} + )} +
+ + {envSecret.secret !== null && ( +
+ + +
+ )}
- -
-
- - - -
+ )}
+ ) + } -
-
- {appDataLoading && } - {app && ( - - {relativeTimeFromDates(new Date(app.createdAt))} - - )} -

App created

-
-
- - - -
-
+ const AppSecretRow = (props: { appSecret: AppSecret }) => { + const { appSecret } = props + + const prodSecret = appSecret.envs.find( + (env) => env.env.envType?.toLowerCase() === 'prod' + )?.secret + + const secretIsSameAsProd = (env: { + env: Partial + secret: SecretType | null + }) => + prodSecret !== null && + env.secret?.value === prodSecret?.value && + env.env.envType?.toLowerCase() !== 'prod' + + const tooltipText = (env: { env: Partial; secret: SecretType | null }) => { + if (env.secret === null) return `This secret is missing in ${env.env.envType}` + else if (env.secret.value.length === 0) return `This secret is blank in ${env.env.envType}` + else if (secretIsSameAsProd(env)) return `This secret is the same as PROD.` + else return 'This secret is present' + } + + return ( + + {({ open }) => ( + <> + + + {appSecret.key} + + + {appSecret.envs.map((env) => ( + +
+ {env.secret !== null ? ( + env.secret.value.length === 0 ? ( + + ) : ( + + ) + ) : ( + + )} +
+ + ))} +
+ + + +
+ {appSecret.envs.map((envSecret) => ( + + ))} +
+
+ +
+ + )} +
+ ) + } + + return ( +
+ {organisation && } + {keyring !== null && + (setupRequired ? ( +
+

+ {activeUserIsAdmin + ? "There aren't any environments for this app yet" + : "You don't have access to any environments for this app yet. Contact the organisation owner or admins to get access."} +

+ {activeUserIsAdmin && ( + + )} +
+ ) : ( +
+
+
+

Secrets

+

+ An overview of secrets across all environments in this App. +

+
+
+ +
+
+
+ +
+ setSearchQuery(e.target.value)} + /> + setSearchQuery('')} + /> +
+ +
+
+ Secret is present +
+
+ Secret is the same as + Production +
+
+ Secret is blank +
+
+ Secret is missing +
+
+
+ + + + + + {data?.appEnvironments.map((env: EnvironmentType) => ( + + ))} + + + + {filteredSecrets.map((appSecret, index) => ( + + ))} + +
+ key + + + + +
+
+ ))}
) } diff --git a/frontend/app/[team]/apps/[app]/settings/page.tsx b/frontend/app/[team]/apps/[app]/settings/page.tsx index 2473d86f..1bf04bb0 100644 --- a/frontend/app/[team]/apps/[app]/settings/page.tsx +++ b/frontend/app/[team]/apps/[app]/settings/page.tsx @@ -1,28 +1,28 @@ 'use client' -import { GetOrganisations } from '@/apollo/queries/getOrganisations.gql' -import { GetAppDetail } from '@/apollo/queries/getAppDetail.gql' +import { GetOrganisations } from '@/graphql/queries/getOrganisations.gql' +import { GetAppDetail } from '@/graphql/queries/getAppDetail.gql' import { useLazyQuery, useQuery } from '@apollo/client' import { AppType } from '@/apollo/graphql' -import { useEffect } from 'react' +import { useContext, useEffect } from 'react' import DeleteAppDialog from '@/components/apps/DeleteAppDialog' +import { organisationContext } from '@/contexts/organisationContext' export default function AppSettings({ params }: { params: { team: string; app: string } }) { - const { data: orgsData } = useQuery(GetOrganisations) + const { activeOrganisation: organisation } = useContext(organisationContext) const [getApp, { data, loading }] = useLazyQuery(GetAppDetail) useEffect(() => { - if (orgsData) { - const organisationId = orgsData.organisations[0].id + if (organisation) { getApp({ variables: { - organisationId, + organisationId: organisation.id, appId: params.app, }, }) } - }, [getApp, orgsData, params.app]) + }, [getApp, organisation, params.app]) const app = data?.apps[0] as AppType @@ -42,12 +42,12 @@ export default function AppSettings({ params }: { params: { team: string; app: s

Permanently delete this app

- {app && ( + {organisation && app && ( )}
diff --git a/frontend/app/[team]/apps/[app]/tokens/page.tsx b/frontend/app/[team]/apps/[app]/tokens/page.tsx new file mode 100644 index 00000000..613a5dbd --- /dev/null +++ b/frontend/app/[team]/apps/[app]/tokens/page.tsx @@ -0,0 +1,285 @@ +'use client' + +import { GetAppDetail } from '@/graphql/queries/getAppDetail.gql' +import { RotateAppKey } from '@/graphql/mutations/rotateAppKeys.gql' +import { useLazyQuery, useMutation } from '@apollo/client' +import { AppType } from '@/apollo/graphql' +import { Fragment, useContext, useEffect, useState } from 'react' +import { Button } from '@/components/common/Button' +import { copyToClipBoard } from '@/utils/clipboard' +import { FaCopy, FaExclamationTriangle, FaInfo, FaTimes } from 'react-icons/fa' +import { MdContentCopy, MdOutlineRotateLeft } from 'react-icons/md' +import { toast } from 'react-toastify' +import { Dialog, Transition } from '@headlessui/react' +import { cryptoUtils } from '@/utils/auth' +import { splitSecret } from '@/utils/keyshares' +import { Alert } from '@/components/common/Alert' +import UnlockKeyringDialog from '@/components/auth/UnlockKeyringDialog' +import { KeyringContext } from '@/contexts/keyringContext' +import clsx from 'clsx' +import { SecretTokens } from '@/components/apps/tokens/SecretTokens' +import { organisationContext } from '@/contexts/organisationContext' + +export default function Tokens({ params }: { params: { team: string; app: string } }) { + const [getApp, { data }] = useLazyQuery(GetAppDetail) + + const app = data?.apps[0] as AppType + + const [activePanel, setActivePanel] = useState<'secrets' | 'kms'>('secrets') + + const { activeOrganisation: organisation } = useContext(organisationContext) + + const { keyring } = useContext(KeyringContext) + + useEffect(() => { + if (organisation) { + getApp({ + variables: { + organisationId: organisation.id, + appId: params.app, + }, + }) + } + }, [getApp, organisation, params.app]) + + const handleCopy = (val: string) => { + copyToClipBoard(val) + toast.info('Copied') + } + + const KmsPanel = () => { + const appId = `phApp:v${app?.appVersion}:${app?.identityKey}` + + const [appSecret, setAppSecret] = useState('') + + const appSecretPlaceholder = '*'.repeat(295) + + const RotateAppDialog = () => { + const [pw, setPw] = useState('') + const [showPw, setShowPw] = useState(false) + const [loading, setLoading] = useState(false) + const [isOpen, setIsOpen] = useState(false) + const [rotateAppKeys] = useMutation(RotateAppKey) + + const closeModal = () => { + setPw('') + setIsOpen(false) + } + + const handleGenerateNewAppKey = async () => { + const APP_VERSION = 1 + + return new Promise(async (resolve, reject) => { + setTimeout(async () => { + setLoading(true) + try { + const wrapKey = await cryptoUtils.newAppWrapKey() + const newAppToken = await cryptoUtils.newAppToken() + const appSeed = await cryptoUtils.decryptedAppSeed(app.appSeed, keyring!.symmetricKey) + + const appKeys = await cryptoUtils.appKeyring(appSeed) + const appKeyShares = await splitSecret(appKeys.privateKey) + const wrappedShare = await cryptoUtils.wrappedKeyShare(appKeyShares[1], wrapKey) + await rotateAppKeys({ + variables: { + id: app.id, + appToken: newAppToken, + wrappedKeyShare: wrappedShare, + }, + }) + + setAppSecret(`pss:v${APP_VERSION}:${newAppToken}:${appKeyShares[0]}:${wrapKey}`) + + setLoading(false) + resolve(true) + } catch (error) { + console.log(error) + setLoading(false) + reject() + } + }, 500) + }) + } + + const handleSubmit = async (event: { preventDefault: () => void }) => { + event.preventDefault() + toast + .promise(handleGenerateNewAppKey, { + pending: 'Generating app keys', + success: 'Success!', + error: 'Something went wrong! Please check your password and try again.', + }) + .then(() => closeModal()) + } + + return ( + <> + + + {}}> + +
+ + +
+
+ + + +

+ Genereate new app secret +

+ +
+ + + Generate a new app secret for {app.name} + + +
+
+
+ +
+ +
+ Warning: This will revoke your current app keys. Your application + won't be able to decrypt data using the current keys. +
+
+
+ + +
+ +
+ Your new keys will be available to use immediately. You will be + able to decrypt any existing data with your new keys. Please allow + up to 60 seconds for your old keys to be revoked. +
+
+
+
+ +
+ + +
+
+
+
+
+
+
+
+
+ + ) + } + + return ( +
+
+
+ app id + +
+ {appId} +
+ +
+
+ app secret +
+ {appSecret && ( +
+ +
{"Copy this value. You won't see it again!"}
+
+ )} + {appSecret && ( + + )} +
+ {!appSecret && } +
+ {appSecret || appSecretPlaceholder} +
+
+ ) + } + + return ( +
+
+ {organisation && } + {keyring !== null && ( +
+
+
setActivePanel('secrets')} + className={clsx( + 'p-4 cursor-pointer border-l transition ease -ml-px w-60', + activePanel === 'secrets' + ? 'bg-zinc-300 dark:bg-zinc-800 font-semibold border-emerald-500' + : 'bg-zinc-200 dark:bg-zinc-900 hover:font-semibold border-neutral-500/40' + )} + > + Secrets +
+ {organisation?.role?.toLowerCase() === 'owner' && ( +
setActivePanel('kms')} + className={clsx( + 'p-4 cursor-pointer border-l transition ease -ml-px w-60', + activePanel === 'kms' + ? 'bg-zinc-300 dark:bg-zinc-800 font-semibold border-emerald-500' + : 'bg-zinc-200 dark:bg-zinc-900 hover:font-semibold border-neutral-500/40' + )} + > + KMS +
+ )} +
+
+ {app && activePanel === 'secrets' && ( + + )} + {app && activePanel === 'kms' && } +
+
+ )} +
+
+ ) +} diff --git a/frontend/app/[team]/apps/page.tsx b/frontend/app/[team]/apps/page.tsx index a1fcf10f..82c96679 100644 --- a/frontend/app/[team]/apps/page.tsx +++ b/frontend/app/[team]/apps/page.tsx @@ -1,32 +1,27 @@ 'use client' -import { useLazyQuery, useQuery } from '@apollo/client' -import { GetApps } from '@/apollo/queries/getApps.gql' -import { GetOrganisations } from '@/apollo/queries/getOrganisations.gql' -import { AppType, OrganisationType } from '@/apollo/graphql' +import { useLazyQuery } from '@apollo/client' +import { GetApps } from '@/graphql/queries/getApps.gql' +import { AppType } from '@/apollo/graphql' import NewAppDialog from '@/components/apps/NewAppDialog' import { FaPlus } from 'react-icons/fa' -import { useEffect, useState } from 'react' +import { useContext, useEffect } from 'react' import Link from 'next/link' import Spinner from '@/components/common/Spinner' import { AppCard } from '@/components/apps/AppCard' +import { organisationContext } from '@/contexts/organisationContext' export default function AppsHome({ params }: { params: { team: string } }) { - const [organisation, setOrganisation] = useState(undefined) - - const { data: orgsData } = useQuery(GetOrganisations) + const { activeOrganisation: organisation } = useContext(organisationContext) const [getApps, { data, loading }] = useLazyQuery(GetApps) useEffect(() => { - if (orgsData?.organisations) { + if (organisation) { const fetchData = async () => { - const org = orgsData.organisations[0] - setOrganisation(org) - const organisationId = org.id getApps({ variables: { - organisationId, + organisationId: organisation.id, appId: '', }, }) @@ -34,7 +29,8 @@ export default function AppsHome({ params }: { params: { team: string } }) { fetchData() } - }, [getApps, orgsData]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [organisation]) const apps = data?.apps as AppType[] @@ -50,7 +46,7 @@ export default function AppsHome({ params }: { params: { team: string } }) { ))} - {organisation && !loading && ( + {organisation && apps && (
{ + if (!loading && organisations !== null) { + // if there are no organisations for this user, send to onboarding + if (organisations.length === 0) { + router.push('/signup') + } + + // try and get org being access from route params in the list of organisations for this user + const org = organisations.find((org) => org.name === params.team) + + // update active organisation if it exists + if (org) setActiveOrganisation(org) + // else update the route to the active organisation + else router.push(`/${activeOrganisation!.name}`) + } + }, [activeOrganisation, organisations, params.team, router, loading, setActiveOrganisation]) + const path = usePathname() const showNav = !path?.split('/').includes('newdevice') @@ -21,7 +46,7 @@ export default function RootLayout({ return (
diff --git a/frontend/app/[team]/members/page.tsx b/frontend/app/[team]/members/page.tsx new file mode 100644 index 00000000..15c5d191 --- /dev/null +++ b/frontend/app/[team]/members/page.tsx @@ -0,0 +1,802 @@ +'use client' + +import GetOrganisationMembers from '@/graphql/queries/organisation/getOrganisationMembers.gql' +import GetInvites from '@/graphql/queries/organisation/getInvites.gql' +import GetApps from '@/graphql/queries/getApps.gql' +import { GetAppEnvironments } from '@/graphql/queries/secrets/getAppEnvironments.gql' +import { GetEnvironmentKey } from '@/graphql/queries/secrets/getEnvironmentKey.gql' +import InviteMember from '@/graphql/mutations/organisation/inviteNewMember.gql' +import DeleteOrgInvite from '@/graphql/mutations/organisation/deleteInvite.gql' +import RemoveMember from '@/graphql/mutations/organisation/deleteOrgMember.gql' +import UpdateMemberRole from '@/graphql/mutations/organisation/updateOrgMemberRole.gql' +import AddMemberToApp from '@/graphql/mutations/apps/addAppMember.gql' +import { useLazyQuery, useMutation, useQuery } from '@apollo/client' +import { Fragment, useContext, useEffect, useState } from 'react' +import { + OrganisationMemberInviteType, + OrganisationMemberType, + AppType, + ApiOrganisationMemberRoleChoices, + EnvironmentType, +} from '@/apollo/graphql' +import { Button } from '@/components/common/Button' +import { organisationContext } from '@/contexts/organisationContext' +import { relativeTimeFromDates } from '@/utils/time' +import { Dialog, Listbox, Transition } from '@headlessui/react' +import { + FaCheckSquare, + FaChevronDown, + FaCopy, + FaPlus, + FaSquare, + FaTimes, + FaTrashAlt, + FaUserAlt, +} from 'react-icons/fa' +import clsx from 'clsx' +import { cryptoUtils } from '@/utils/auth' +import { copyToClipBoard } from '@/utils/clipboard' +import { toast } from 'react-toastify' +import { useSession } from 'next-auth/react' +import { Avatar } from '@/components/common/Avatar' +import { userIsAdmin } from '@/utils/permissions' +import { RoleLabel } from '@/components/users/RoleLabel' +import { KeyringContext } from '@/contexts/keyringContext' +import { unwrapEnvSecretsForUser, wrapEnvSecretsForUser } from '@/utils/environments' +import UnlockKeyringDialog from '@/components/auth/UnlockKeyringDialog' + +const handleCopy = (val: string) => { + copyToClipBoard(val) + toast.info('Copied', { autoClose: 2000 }) +} + +const inviteIsExpired = (invite: OrganisationMemberInviteType) => { + return new Date(invite.expiresAt) < new Date() +} + +const RoleSelector = (props: { member: OrganisationMemberType }) => { + const { member } = props + + const { activeOrganisation: organisation } = useContext(organisationContext) + const { keyring } = useContext(KeyringContext) + + const { data: appsData, loading: appsLoading } = useQuery(GetApps, { + variables: { organisationId: organisation!.id, appId: '' }, + }) + const [getAppEnvs] = useLazyQuery(GetAppEnvironments) + const [getEnvKey] = useLazyQuery(GetEnvironmentKey) + const [updateRole] = useMutation(UpdateMemberRole) + const [addMemberToApp] = useMutation(AddMemberToApp) + + const [role, setRole] = useState(member.role) + + const isOwner = role.toLowerCase() === 'owner' + + const activeUserIsAdmin = organisation ? userIsAdmin(organisation.role!) : false + + /** + * Handles the upgrade of a user from 'dev' to 'admin'. + * Env keys for all apps, are fetched and decrypted by the active user, then each key is re-encrypted for the new user and saved on the backend via the addMemberToApp mutation + * + * @returns {void} + */ + const upgradeDevToAdmin = () => { + if (appsData) { + const apps = appsData.apps + + // Function to process an individual app + const processApp = async (app: AppType) => { + //const keyring = await validateKeyring(password); + const { data: appEnvsData } = await getAppEnvs({ variables: { appId: app.id } }) + + const appEnvironments = appEnvsData.appEnvironments as EnvironmentType[] + + const envKeyPromises = appEnvironments.map(async (env: EnvironmentType) => { + const { data } = await getEnvKey({ + variables: { + envId: env.id, + appId: app.id, + }, + }) + + const { + wrappedSeed: userWrappedSeed, + wrappedSalt: userWrappedSalt, + identityKey, + } = data.environmentKeys[0] + + const { seed, salt } = await unwrapEnvSecretsForUser( + userWrappedSeed, + userWrappedSalt, + keyring! + ) + + const { wrappedSeed, wrappedSalt } = await wrapEnvSecretsForUser({ seed, salt }, member) + + return { + envId: env.id, + userId: member.id, + identityKey, + wrappedSeed, + wrappedSalt, + } + }) + + const envKeyInputs = await Promise.all(envKeyPromises) + + await addMemberToApp({ + variables: { memberId: member.id, appId: app.id, envKeys: envKeyInputs }, + }) + } + + // Process each app sequentially + const processAppsSequentially = async () => { + for (const app of apps) { + await processApp(app) + } + } + + // Call the function to process all apps sequentially + processAppsSequentially() + .then(async () => { + // All apps have been processed + await updateRole({ + variables: { + memberId: member.id, + role: 'admin', + }, + }) + toast.success('Updated member role', { autoClose: 2000 }) + }) + .catch((error) => { + console.error('Error processing apps:', error) + }) + } + } + + const handleUpdateRole = async (newRole: string) => { + setRole(newRole) + if (newRole.toLowerCase() === 'admin') upgradeDevToAdmin() + else { + await updateRole({ + variables: { + memberId: member.id, + role: newRole, + }, + }) + toast.success('Updated member role', { autoClose: 2000 }) + } + } + + const roleOptions = Object.keys(ApiOrganisationMemberRoleChoices).filter( + (option) => option !== 'Owner' + ) + + const disabled = isOwner || !activeUserIsAdmin || member.self! + + return disabled ? ( + + ) : ( +
+ + {({ open }) => ( + <> + +
+ + {!disabled && ( + + )} +
+
+ +
+ {roleOptions.map((role: string) => ( + + {({ active, selected }) => ( +
+ +
+ )} +
+ ))} +
+
+ + )} +
+
+ ) +} + +const InviteDialog = (props: { organisationId: string }) => { + const { organisationId } = props + + const { data: appsData, loading: appsLoading } = useQuery(GetApps, { + variables: { organisationId, appId: '' }, + }) + const [createInvite] = useMutation(InviteMember) + + const [isOpen, setIsOpen] = useState(false) + + const [email, setEmail] = useState('') + const [apps, setApps] = useState[]>([]) + + const [inviteLink, setInviteLink] = useState('') + + const roleOptions = Object.keys(ApiOrganisationMemberRoleChoices).filter( + (option) => option !== 'Owner' + ) + + const isLoading = appsLoading + + const reset = () => { + setEmail('') + setApps([]) + setInviteLink('') + } + + const closeModal = () => { + reset() + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + const handleClose = () => { + closeModal() + } + + const handleInvite = async (event: { preventDefault: () => void }) => { + event.preventDefault() + const { data } = await createInvite({ + variables: { + email, + orgId: organisationId, + apps: apps.map((app) => app.id), + role: 'dev', + }, + refetchQueries: [ + { + query: GetInvites, + variables: { + orgId: organisationId, + }, + }, + ], + }) + + setInviteLink(cryptoUtils.getInviteLink(data?.inviteOrganisationMember.invite.id)) + } + + const AppSelector = (props: { app: AppType }) => { + const { id: appId, name: appName } = props.app + + const isSelected = apps.map((app) => app.name).includes(appName) + + const handleAppClick = () => { + if (isSelected) { + setApps(apps.filter((app) => app.name !== appName)) + } else setApps([...apps, ...[{ id: appId, name: appName }]]) + } + + return ( +
+ {isSelected ? ( + + ) : ( + + )} +
+ {appName} +
+
+ ) + } + + return ( + <> +
+ +
+ + + + +
+ + +
+
+ + + +

+ Invite a new member +

+ + +
+ + {!isLoading && ( +
+
+ {!inviteLink && ( +
+
+
+ + setEmail(e.target.value)} + className="w-3/4" + /> +
+
+ +
+ + +
+ {appsData.apps.map((appOption: AppType) => ( + + ))} +
+
+ +
+ + +
+
+ )} + {inviteLink && ( +
+
+

+ Invite sent +

+

+ An invite link has been sent by email to{' '} + {email}. You can also share the + link below to invite this user to your organisation. This invite + will expire in 72 hours. +

+
+
+
+ {inviteLink} +
+ +
+
+ )} +
+
+ )} +
+
+
+
+
+
+ + ) +} + +export default function Members({ params }: { params: { team: string } }) { + const [getMembers, { data: membersData }] = useLazyQuery(GetOrganisationMembers) + const [getInvites, { data: invitesData }] = useLazyQuery(GetInvites) + const [deleteInvite] = useMutation(DeleteOrgInvite) + + const sortedInvites: OrganisationMemberInviteType[] = + invitesData?.organisationInvites + ?.slice() // Create a shallow copy of the array to avoid modifying the original + .sort((a: OrganisationMemberInviteType, b: OrganisationMemberInviteType) => { + // Compare the createdAt timestamps in descending order + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + }) || [] + + const { activeOrganisation: organisation } = useContext(organisationContext) + + const activeUserIsAdmin = organisation ? userIsAdmin(organisation.role!) : false + + const { data: session } = useSession() + + useEffect(() => { + if (organisation) { + getMembers({ + variables: { + organisationId: organisation.id, + role: null, + }, + pollInterval: 5000, + }) + getInvites({ + variables: { + orgId: organisation.id, + }, + pollInterval: 5000, + }) + } + }, [getInvites, getMembers, organisation]) + + const DeleteInviteConfirmDialog = (props: { inviteId: string }) => { + const { inviteId } = props + + const [isOpen, setIsOpen] = useState(false) + + const closeModal = () => { + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + const handleDeleteInvite = async (inviteId: string) => { + await deleteInvite({ + variables: { + inviteId, + }, + refetchQueries: [ + { + query: GetInvites, + variables: { + orgId: organisation!.id, + }, + }, + ], + }) + } + + return ( + <> +
+ +
+ + + + +
+ + +
+
+ + + +

+ Delete Invite +

+ + +
+ +
+

+ Are you sure you want to delete this invite? +

+
+ + +
+
+
+
+
+
+
+
+ + ) + } + + const DeleteMemberConfirmDialog = (props: { member: OrganisationMemberType }) => { + const { member } = props + + const [removeMember] = useMutation(RemoveMember) + + const [isOpen, setIsOpen] = useState(false) + + const closeModal = () => { + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + const handleRemoveMember = async () => { + await removeMember({ + variables: { memberId: member.id }, + refetchQueries: [ + { + query: GetOrganisationMembers, + variables: { organisationId: organisation?.id, role: null }, + }, + ], + }) + } + + const allowDelete = !member.self! && activeUserIsAdmin && member.role.toLowerCase() !== 'owner' + + return ( + <> + {allowDelete && ( +
+ +
+ )} + + + + +
+ + +
+
+ + + +

+ Remove member +

+ + +
+ +
+

+ Are you sure you want to remove {member.fullName || member.email} from this + organisation? +

+
+ + +
+
+
+
+
+
+
+
+ + ) + } + + return ( +
+
+
+

{params.team} Members

+

Manage organisation members and roles.

+
+
+
+ {organisation && } +
+ + + + + + + + + {activeUserIsAdmin && } + + + + {membersData?.organisationMembers.map((member: OrganisationMemberType) => ( + + + + + + + ))} + {sortedInvites.map((invite: OrganisationMemberInviteType) => ( + + + + + + + ))} + +
+ User + + Role + + Joined +
+ +
+ {member.fullName || member.email} + {member.fullName && ( + {member.email} + )} +
+
+
+ +
+
+ {relativeTimeFromDates(new Date(member.createdAt))} + + {!member.self! && + activeUserIsAdmin && + member.role.toLowerCase() !== 'owner' && ( + + )} +
+
+ +
+
+
+ {invite.inviteeEmail}{' '} + + (invited by{' '} + {invite.invitedBy.self + ? 'You' + : invite.invitedBy.fullName || invite.invitedBy.email} + ) + +
+
+
+ {inviteIsExpired(invite) + ? `Expired ${relativeTimeFromDates(new Date(invite.expiresAt))}` + : `Invited ${relativeTimeFromDates(new Date(invite.createdAt))}`} + + {!inviteIsExpired(invite) && ( + + )} + +
+
+
+ {activeUserIsAdmin && organisation && ( + + )} +
+ ) +} diff --git a/frontend/app/[team]/newdevice/page.tsx b/frontend/app/[team]/newdevice/page.tsx index 0a3778bb..8dbf52b3 100644 --- a/frontend/app/[team]/newdevice/page.tsx +++ b/frontend/app/[team]/newdevice/page.tsx @@ -4,44 +4,82 @@ import { Button } from '@/components/common/Button' import { AccountPassword } from '@/components/onboarding/AccountPassword' import { AccountSeedChecker } from '@/components/onboarding/AccountSeedChecker' import { Step, Stepper } from '@/components/onboarding/Stepper' -import { useEffect, useState } from 'react' +import { useContext, useEffect, useState } from 'react' import { MdContentPaste, MdOutlineKey } from 'react-icons/md' -import { GetOrganisations } from '@/apollo/queries/getOrganisations.gql' -import { useQuery } from '@apollo/client' +import { GetOrganisations } from '@/graphql/queries/getOrganisations.gql' +import { useMutation, useQuery } from '@apollo/client' import { OrganisationType } from '@/apollo/graphql' +import UpdateWrappedSecrets from '@/graphql/mutations/organisation/updateUserWrappedSecrets.gql' import { cryptoUtils } from '@/utils/auth' import { useSession } from 'next-auth/react' import { toast } from 'react-toastify' -import { setLocalOrg } from '@/utils/localStorage' +import { setLocalKeyring } from '@/utils/localStorage' import { useRouter } from 'next/navigation' import UserMenu from '@/components/UserMenu' +import { organisationContext } from '@/contexts/organisationContext' +import { FaEye, FaEyeSlash, FaInfo } from 'react-icons/fa' +import { KeyringContext } from '@/contexts/keyringContext' export default function NewDevice({ params }: { params: { team: string } }) { const { data: session } = useSession() const [inputs, setInputs] = useState>([]) const [pw, setPw] = useState('') const [pw2, setPw2] = useState('') + const [showPw, setShowPw] = useState(false) const [step, setStep] = useState(0) - const { loading, error, data } = useQuery(GetOrganisations) - const router = useRouter() - - const steps: Step[] = [ + const [steps, setSteps] = useState([ { index: 0, - name: 'Recovery phrase', - icon: , - title: 'Recovery phrase', - description: 'Please enter the your account recovery phrase in the correct order below.', - }, - { - index: 1, name: 'Sudo password', icon: , title: 'Sudo password', description: "Please set up a strong 'sudo' password to continue. This will be used to to perform administrative tasks and to encrypt keys locally on this device.", }, - ] + ]) + + const [updateWrappedSecrets] = useMutation(UpdateWrappedSecrets) + + const [recoveryRequired, setRecoveryRequired] = useState(false) + + const router = useRouter() + + const { organisations } = useContext(organisationContext) + + const { setKeyring } = useContext(KeyringContext) + + const org = organisations?.find((org) => org.name === params.team) ?? null + + //const recoveryRequired = org?.keyring === null + + useEffect(() => { + if (org) { + setRecoveryRequired(org.keyring === null || org.keyring === '') + } + }, [org]) + + useEffect(() => { + if (recoveryRequired) + setSteps([ + { + index: 0, + name: 'Recovery phrase', + icon: , + title: 'Recovery phrase', + description: 'Please enter the your account recovery phrase in the correct order below.', + }, + { + index: 1, + name: 'Sudo password', + icon: , + title: 'Sudo password', + description: + "Please set up a strong 'sudo' password to continue. This will be used to to perform administrative tasks and to encrypt keys locally on this device.", + }, + ]) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recoveryRequired]) const handleInputUpdate = (newValue: string, index: number) => { if (newValue.split(' ').length === 24) { @@ -52,31 +90,72 @@ export default function NewDevice({ params }: { params: { team: string } }) { const handleLocalAccountSetup = () => { return new Promise<{ publicKey: string; encryptedKeyring: string }>((resolve, reject) => { setTimeout(async () => { - const mnemonic = inputs.join(' ') - const orgs = data.organisations as OrganisationType[] - const org = orgs.find((org) => org.name === params.team) - const accountSeed = await cryptoUtils.organisationSeed(mnemonic, org?.id!) - - const accountKeyRing = await cryptoUtils.organisationKeyring(accountSeed) - if (accountKeyRing.publicKey !== org?.identityKey) { - toast.error('Incorrect account recovery key!') - reject('Incorrect account recovery key') - } + if (recoveryRequired) { + const mnemonic = inputs.join(' ') - const deviceKey = await cryptoUtils.deviceVaultKey(pw, session?.user?.email!) - const encryptedKeyring = await cryptoUtils.encryptAccountKeyring(accountKeyRing, deviceKey) - const encryptedMnemonic = await cryptoUtils.encryptAccountRecovery(mnemonic, deviceKey) - setLocalOrg({ - email: session?.user?.email!, - org: org!, - keyring: encryptedKeyring, - recovery: encryptedMnemonic, - }) + const accountSeed = await cryptoUtils.organisationSeed(mnemonic, org?.id!) - resolve({ - publicKey: accountKeyRing.publicKey, - encryptedKeyring, - }) + const accountKeyRing = await cryptoUtils.organisationKeyring(accountSeed) + if (accountKeyRing.publicKey !== org?.identityKey) { + toast.error('Incorrect account recovery key!') + reject('Incorrect account recovery key') + } + + const deviceKey = await cryptoUtils.deviceVaultKey(pw, session?.user?.email!) + const encryptedKeyring = await cryptoUtils.encryptAccountKeyring( + accountKeyRing, + deviceKey + ) + const encryptedMnemonic = await cryptoUtils.encryptAccountRecovery(mnemonic, deviceKey) + + setKeyring(accountKeyRing) + + setLocalKeyring({ + email: session?.user?.email!, + org: org!, + keyring: encryptedKeyring, + recovery: encryptedMnemonic, + }) + + await updateWrappedSecrets({ + variables: { + orgId: org!.id, + wrappedKeyring: encryptedKeyring, + wrappedRecovery: encryptedMnemonic, + }, + }) + + resolve({ + publicKey: accountKeyRing.publicKey, + encryptedKeyring, + }) + } else { + try { + const encryptedKeyring = org!.keyring! + const deviceKey = await cryptoUtils.deviceVaultKey(pw, session?.user?.email!) + const accountKeyRing = await cryptoUtils.decryptAccountKeyring( + encryptedKeyring, + deviceKey + ) + + setKeyring(accountKeyRing) + + setLocalKeyring({ + email: session?.user?.email!, + org: org!, + keyring: encryptedKeyring, + recovery: org?.recovery!, + }) + + resolve({ + publicKey: accountKeyRing.publicKey, + encryptedKeyring, + }) + } catch (error) { + toast.error('Something went wrong! Please check your sudo password and try again') + reject('Something went wrong! Please check your sudo password and try again') + } + } }, 1000) }) } @@ -84,9 +163,9 @@ export default function NewDevice({ params }: { params: { team: string } }) { const incrementStep = async (event: { preventDefault: () => void }) => { event.preventDefault() - if (step !== steps.length - 1) setStep(step + 1) + if (step !== steps.length - 1 && recoveryRequired) setStep(step + 1) if (step === steps.length - 1) { - if (pw !== pw2) { + if (recoveryRequired && pw !== pw2) { toast.error("Passwords don't match") return false } @@ -119,19 +198,22 @@ export default function NewDevice({ params }: { params: { team: string } }) { Welcome back

- { - "Looks like your signing in on a new browser or machine. You'll need to setup your account keys for this device before you can proceed." - } + {recoveryRequired + ? "Looks like you are signing in on a new browser or machine. You'll need to setup your account keys for this device before you can proceed." + : 'Looks like you are signing in on a new browser or machine. Please enter your sudo password to setup your keyring on this device.'}

-
- -
- {step === 0 && ( + {recoveryRequired && ( +
+ +
+ )} + + {step === 0 && recoveryRequired && ( )} - {step === 1 && } -
-
- {step !== 0 && ( - +
+
+ +
+
+
+ - )} +
-
- + )} + {recoveryRequired && ( +
+
+ {step !== 0 && ( + + )} +
+
+ +
-
+ )}
diff --git a/frontend/app/[team]/page.tsx b/frontend/app/[team]/page.tsx index f4deb228..6980829c 100644 --- a/frontend/app/[team]/page.tsx +++ b/frontend/app/[team]/page.tsx @@ -2,21 +2,25 @@ import AppsHomeCard from '@/components/apps/AppsHomeCard' import { useQuery } from '@apollo/client' -import GetOrganisations from '@/apollo/queries/getOrganisations.gql' +import GetOrganisations from '@/graphql/queries/getOrganisations.gql' import Link from 'next/link' +import { useContext } from 'react' +import { organisationContext } from '@/contexts/organisationContext' export default function AppsHome({ params }: { params: { team: string } }) { - const { loading, error, data } = useQuery(GetOrganisations) + //const { loading, error, data } = useQuery(GetOrganisations) + + const { activeOrganisation: organisation } = useContext(organisationContext) return ( <>
-

{params.team} Home

+

{organisation?.name} Home

- {!loading && ( + {organisation && ( - + )}
diff --git a/frontend/app/[team]/settings/page.tsx b/frontend/app/[team]/settings/page.tsx index 02c83b30..f84b8c62 100644 --- a/frontend/app/[team]/settings/page.tsx +++ b/frontend/app/[team]/settings/page.tsx @@ -1,12 +1,227 @@ +'use client' + +import { Alert } from '@/components/common/Alert' +import { Avatar } from '@/components/common/Avatar' +import { Button } from '@/components/common/Button' import { ModeToggle } from '@/components/common/ModeToggle' +import { AccountSeedGen } from '@/components/onboarding/AccountSeedGen' +import { RoleLabel } from '@/components/users/RoleLabel' +import { organisationContext } from '@/contexts/organisationContext' +import { cryptoUtils } from '@/utils/auth' +import { Dialog, Transition } from '@headlessui/react' +import { useSession } from 'next-auth/react' +import { Fragment, useContext, useState } from 'react' +import { FaEye, FaEyeSlash, FaMoon, FaSun, FaTimes } from 'react-icons/fa' + +const ViewRecoveryDialog = () => { + const { activeOrganisation } = useContext(organisationContext) + + const { data: session } = useSession() + + const [isOpen, setIsOpen] = useState(false) + const [password, setPassword] = useState('') + const [showPw, setShowPw] = useState(false) + const [recovery, setRecovery] = useState('') + + const handleDecryptRecovery = async (event: { preventDefault: () => void }) => { + event.preventDefault() + + const deviceKey = await cryptoUtils.deviceVaultKey(password, session?.user?.email!) + + const decryptedRecovery = await cryptoUtils.decryptAccountRecovery( + activeOrganisation?.recovery!, + deviceKey + ) + setRecovery(decryptedRecovery) + } + + const reset = () => { + setRecovery('') + setPassword('') + } + + const closeModal = () => { + reset() + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + return ( + <> +
+ +
+

Your recovery phrase is encrypted.

+ +

+ Backup your account recovery phrase in a safe place if you haven't already. If + you forget your sudo password, it is the only way to restore your accout keys. +

+
+
+
+ +
+
+ + + + +
+ + +
+
+ + + +

+ View account recovery +

+ + +
+ +
+ {recovery && } + + {!recovery && ( +
+
+

+ Please enter your sudo password to decrypt your account + recovery phrase. +

+
+
+
+ +
+ setPassword(e.target.value)} + type={showPw ? 'text' : 'password'} + minLength={16} + required + autoFocus + className="custom w-full text-zinc-800 font-mono dark:text-white bg-zinc-100 dark:bg-zinc-800 rounded-md" + /> + +
+
+
+ +
+
+
+ )} +
+
+
+
+
+
+
+ + ) +} export default function Settings({ params }: { params: { team: string } }) { + const { activeOrganisation } = useContext(organisationContext) + + const { data: session } = useSession() + return ( -
-

{params.team} Settings

-
- Theme: +
+

Settings

+ + {activeOrganisation && ( +
+
+

Account

+

Account information and recovery.

+
+
+ +
+
+ {session?.user?.name} + {session?.user?.email} +
+ +
+ + at {activeOrganisation?.name} +
+
+
+ +
+
Recovery phrase
+ +
+ +
+
Public key
+ + {activeOrganisation?.identityKey} + +
+
+ )} + +
+
+

App

+

Control the behavior and appearance of UI elements.

+
+
+
Theme
+
+ + + +
+
-
+ ) } diff --git a/frontend/app/[team]/tokens/page.tsx b/frontend/app/[team]/tokens/page.tsx new file mode 100644 index 00000000..b09321cf --- /dev/null +++ b/frontend/app/[team]/tokens/page.tsx @@ -0,0 +1,507 @@ +'use client' + +import { CreateNewUserToken } from '@/graphql/mutations/users/createUserToken.gql' +import { RevokeUserToken } from '@/graphql/mutations/users/deleteUserToken.gql' +import { RevokeServiceToken } from '@/graphql/mutations/environments/deleteServiceToken.gql' +import { CreateNewServiceToken } from '@/graphql/mutations/environments/createServiceToken.gql' +import { GetUserTokens } from '@/graphql/queries/users/getUserTokens.gql' +import { GetServiceTokens } from '@/graphql/queries/secrets/getServiceTokens.gql' +import { GetAppEnvironments } from '@/graphql/queries/secrets/getAppEnvironments.gql' +import { + generateUserToken, + newEnvToken, + newEnvWrapKey, + newServiceTokenKeys, + unwrapEnvSecretsForUser, + wrapEnvSecretsForServiceToken, +} from '@/utils/environments' +import { EnvironmentType, ServiceTokenType, UserTokenType } from '@/apollo/graphql' +import { cryptoUtils } from '@/utils/auth' +import { getUserKxPublicKey, getUserKxPrivateKey } from '@/utils/crypto' +import { splitSecret } from '@/utils/keyshares' +import { useLazyQuery, useMutation, useQuery } from '@apollo/client' +import { useState, useEffect, useContext, Fragment } from 'react' +import { KeyringContext } from '@/contexts/keyringContext' +import { Button } from '@/components/common/Button' +import { + FaCheckSquare, + FaChevronDown, + FaCircle, + FaDotCircle, + FaExclamationTriangle, + FaKey, + FaPlus, + FaSquare, + FaTimes, + FaTrashAlt, + FaUserLock, +} from 'react-icons/fa' +import { getUnixTimeStampinFuture, relativeTimeFromDates } from '@/utils/time' +import { Dialog, Listbox, RadioGroup, Transition } from '@headlessui/react' +import { copyToClipBoard } from '@/utils/clipboard' +import { MdContentCopy } from 'react-icons/md' +import { toast } from 'react-toastify' +import clsx from 'clsx' +import { organisationContext } from '@/contexts/organisationContext' +import { userIsAdmin } from '@/utils/permissions' +import { Avatar } from '@/components/common/Avatar' +import UnlockKeyringDialog from '@/components/auth/UnlockKeyringDialog' + +interface ExpiryOptionT { + name: string + getExpiry: () => number | null +} + +const handleCopy = (val: string) => { + copyToClipBoard(val) + toast.info('Copied', { + autoClose: 2000, + }) +} + +const tokenExpiryOptions: ExpiryOptionT[] = [ + { + name: 'Never', + getExpiry: () => null, + }, + { + name: '7 days', + getExpiry: () => getUnixTimeStampinFuture(7), + }, + { + name: '30 days', + getExpiry: () => getUnixTimeStampinFuture(30), + }, + { + name: '60 days', + getExpiry: () => getUnixTimeStampinFuture(60), + }, + { + name: '90 days', + getExpiry: () => getUnixTimeStampinFuture(90), + }, +] + +const compareExpiryOptions = (a: ExpiryOptionT, b: ExpiryOptionT) => { + return a.getExpiry() === b.getExpiry() +} + +const humanReadableExpiry = (expiryOption: ExpiryOptionT) => + expiryOption.getExpiry() === null + ? 'This token will never expire.' + : `This token will expire on ${new Date(expiryOption.getExpiry()!).toLocaleDateString()}.` + +const CreateUserTokenDialog = (props: { organisationId: string }) => { + const { organisationId } = props + + const { keyring } = useContext(KeyringContext) + + const [isOpen, setIsOpen] = useState(false) + const [name, setName] = useState('') + const [expiry, setExpiry] = useState(tokenExpiryOptions[0]) + + const [userToken, setUserToken] = useState('') + const [createUserToken] = useMutation(CreateNewUserToken) + + const reset = () => { + setName('') + setUserToken('') + } + + const closeModal = () => { + reset() + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + const handleCreateNewUserToken = async (event: { preventDefault: () => void }) => { + event.preventDefault() + + if (name.length === 0) { + toast.error('You must enter a name for the token') + return false + } + + if (keyring) { + const userKxKeys = { + publicKey: await getUserKxPublicKey(keyring.publicKey), + privateKey: await getUserKxPrivateKey(keyring.privateKey), + } + + const { pssUser, mutationPayload } = await generateUserToken( + organisationId, + userKxKeys, + name, + expiry.getExpiry() + ) + + await createUserToken({ + variables: mutationPayload, + refetchQueries: [ + { + query: GetUserTokens, + variables: { + organisationId, + }, + }, + ], + }) + + setUserToken(pssUser) + } else { + console.log('keyring unavailable') + } + } + + return ( + <> +
+ +
+ + + {}}> + +
+ + +
+
+ + + +

+ Create a new User token +

+ + +
+ + {userToken ? ( +
+
+
+ + user token + +
+ {userToken && ( +
+ +
+ {"Copy this value. You won't see it again!"} +
+
+ )} + {userToken && ( + + )} +
+
+ {userToken} +
+
+ ) : ( +
+
+ + setName(e.target.value)} + /> +
+ +
+ + + + +
+ {tokenExpiryOptions.map((option) => ( + + {({ active, checked }) => ( +
+ {checked ? ( + + ) : ( + + )} + {option.name} +
+ )} +
+ ))} +
+
+ + {humanReadableExpiry(expiry)} + +
+ +
+ + +
+
+ )} +
+
+
+
+
+
+ + ) +} + +export default function Tokens({ params }: { params: { team: string } }) { + const [getUserTokens, { data: userTokensData }] = useLazyQuery(GetUserTokens) + + const [deleteUserToken] = useMutation(RevokeUserToken) + + const { activeOrganisation: organisation } = useContext(organisationContext) + + const organisationId = organisation?.id + + const handleDeleteUserToken = async (tokenId: string) => { + await deleteUserToken({ + variables: { tokenId }, + refetchQueries: [ + { + query: GetUserTokens, + variables: { + organisationId, + }, + }, + ], + }) + } + + useEffect(() => { + if (organisationId) { + getUserTokens({ + variables: { + organisationId, + }, + }) + } + }, [getUserTokens, organisationId]) + + const userTokens = + [...(userTokensData?.userTokens || [])].sort((a: UserTokenType, b: UserTokenType) => { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + }) || [] + + const DeleteConfirmDialog = (props: { + token: UserTokenType | ServiceTokenType + onDelete: Function + }) => { + const { token, onDelete } = props + + const [isOpen, setIsOpen] = useState(false) + + const closeModal = () => { + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + return ( + <> +
+ +
+ + + + +
+ + +
+
+ + + +

+ Delete{' '} + + {token.name} + +

+ + +
+ +
+

+ Are you sure you want to delete this token? +

+
+ + +
+
+
+
+
+
+
+
+ + ) + } + + const CreatedToken = (props: { + token: ServiceTokenType | UserTokenType + deleteHandler: Function + }) => { + const { token, deleteHandler } = props + + const isExpired = token.expiresAt === null ? false : new Date(token.expiresAt) < new Date() + + return ( + + + +
+
{token.name}
+
+
+
Created {relativeTimeFromDates(new Date(token.createdAt))}
+
+
+
+ + + + {isExpired ? 'Expired' : 'Expires'}{' '} + {token.expiresAt ? relativeTimeFromDates(new Date(token.expiresAt)) : 'never'} + + + + + + + ) + } + + return ( +
+ {organisation && } +
+
+

User tokens

+

+ Tokens used to authenticate with the CLI from personal devices. Used for development and + manual configuration. +

+
+
+
+
+ +
+ + + + + + + + + + + {userTokens.map((userToken: UserTokenType) => ( + + ))} + +
+ token + + expiry +
+
+
+
+
+ ) +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index ec6a3eb8..8371afa0 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -39,6 +39,8 @@ --callout-border-rgb: 172, 175, 176; --card-rgb: 180, 185, 188; --card-border-rgb: 131, 134, 135; + + --toastify-color-success: rgb(16 185 129)!important; } @media (prefers-color-scheme: dark) { @@ -71,6 +73,8 @@ --callout-border-rgb: 108, 108, 108; --card-rgb: 100, 100, 100; --card-border-rgb: 200, 200, 200; + + --toastify-color-success: rgb(16 185 129)!important; } } @@ -109,14 +113,18 @@ a { @layer components { input { - @apply bg-white dark:bg-zinc-800 dark:bg-opacity-60 p-2 rounded-md text-black dark:text-white border border-zinc-300 dark:border-none focus:outline outline-emerald-500; + @apply p-2 focus:outline-none + } + + input:not(.custom) { + @apply bg-zinc-100 dark:bg-zinc-800 dark:bg-opacity-60 rounded-md text-zinc-800 dark:text-white ring-1 ring-inset ring-neutral-500/40 focus:ring-1 focus:ring-emerald-500 group-focus-within:invalid:ring-red-500 focus:ring-inset; } textarea { - @apply bg-white dark:bg-zinc-800 dark:bg-opacity-60 p-2 rounded-md text-black dark:text-white border border-zinc-300 dark:border-none focus:outline outline-emerald-500; + @apply bg-zinc-100 dark:bg-zinc-800 dark:bg-opacity-60 p-2 rounded-md text-zinc-800 dark:text-white border border-zinc-300 dark:border-none focus:outline outline-emerald-500; } select { - @apply bg-white dark:bg-zinc-800 dark:bg-opacity-60 p-2 rounded-md text-black dark:text-white border border-zinc-300 dark:border-none focus:outline outline-emerald-500; + @apply bg-zinc-100 dark:bg-zinc-800 dark:bg-opacity-60 p-2 rounded-md text-zinc-800 dark:text-white border border-zinc-300 dark:border-none focus:outline outline-emerald-500; } } diff --git a/frontend/app/head.tsx b/frontend/app/head.tsx index 3586955c..fb8ec7fd 100644 --- a/frontend/app/head.tsx +++ b/frontend/app/head.tsx @@ -4,6 +4,7 @@ export default function Head() { Phase Console + ) diff --git a/frontend/app/invite/[invite]/layout.tsx b/frontend/app/invite/[invite]/layout.tsx new file mode 100644 index 00000000..c188849b --- /dev/null +++ b/frontend/app/invite/[invite]/layout.tsx @@ -0,0 +1,11 @@ +import '@/app/globals.css' +import OnboardingNavbar from '@/components/layout/OnboardingNavbar' + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} diff --git a/frontend/app/invite/[invite]/page.tsx b/frontend/app/invite/[invite]/page.tsx new file mode 100644 index 00000000..82cabfb0 --- /dev/null +++ b/frontend/app/invite/[invite]/page.tsx @@ -0,0 +1,322 @@ +'use client' + +import { cryptoUtils } from '@/utils/auth' +import VerifyInvite from '@/graphql/queries/organisation/validateOrganisationInvite.gql' +import AcceptOrganisationInvite from '@/graphql/mutations/organisation/acceptInvite.gql' +import { useLazyQuery, useMutation, useQuery } from '@apollo/client' +import { HeroPattern } from '@/components/common/HeroPattern' +import { Button } from '@/components/common/Button' +import { FaArrowRight } from 'react-icons/fa' +import Loading from '@/app/loading' +import { useEffect, useState } from 'react' +import { Step, Stepper } from '@/components/onboarding/Stepper' +import { AccountPassword } from '@/components/onboarding/AccountPassword' +import { AccountSeedChecker } from '@/components/onboarding/AccountSeedChecker' +import { AccountSeedGen } from '@/components/onboarding/AccountSeedGen' +import { MdKey, MdOutlineVerifiedUser, MdOutlinePassword } from 'react-icons/md' +import { toast } from 'react-toastify' +import { OrganisationMemberInviteType } from '@/apollo/graphql' +import { useSession } from 'next-auth/react' +import { setLocalKeyring } from '@/utils/localStorage' +import { Logo } from '@/components/common/Logo' + +const bip39 = require('bip39') + +const errorToast = (message: string) => { + toast.error(message) +} + +const InvalidInvite = () => ( +
+
+

Something went wrong

+

+ This invite cannot be used by you. Please check that you are logged in to the correct + account, or contact the organisation owner to create a new invite. +

+
+
+) + +export default function Invite({ params }: { params: { invite: string } }) { + const [verifyInvite, { data, loading }] = useLazyQuery(VerifyInvite) + + const [acceptInvite] = useMutation(AcceptOrganisationInvite) + + const { data: session } = useSession() + + const invite: OrganisationMemberInviteType = data?.validateInvite + + const [showWelcome, setShowWelcome] = useState(true) + const [step, setStep] = useState(0) + const [recoverySkipped, setRecoverySkipped] = useState(false) + const [success, setSuccess] = useState(false) + const [inputs, setInputs] = useState>([]) + const [pw, setPw] = useState('') + const [pw2, setPw2] = useState('') + const [mnemonic, setMnemonic] = useState('') + const [isloading, setIsLoading] = useState(false) + + useEffect(() => { + const handleVerifyInvite = async () => { + const inviteId = await cryptoUtils.decodeInvite(params.invite) + + await verifyInvite({ + variables: { inviteId }, + }) + } + + if (params.invite) handleVerifyInvite() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params.invite]) + + const steps: Step[] = [ + { + index: 0, + name: 'Set up recovery phrase', + icon: , + title: 'Recovery', + description: + "This is your 24 word recovery phrase. You can use it log in to your Phase account if you forget the sudo password. It's used to derive your encryption keys. Only you have access to it. Please write it down or store it somewhere safe like a password manager.", + }, + { + index: 1, + name: 'Verify recovery phrase', + icon: , + title: 'Verify recovery phrase', + description: 'Please enter the your recovery phrase in the correct order below.', + }, + { + index: 2, + name: 'Sudo password', + icon: , + title: 'Set a sudo password', + description: + 'Please set up a strong sudo password to continue. This will be used to to perform administrative tasks and to secure your account keys.', + }, + ] + + const computeAccountKeys = () => { + return new Promise<{ publicKey: string; encryptedKeyring: string; encryptedMnemonic: string }>( + (resolve) => { + setTimeout(async () => { + const accountSeed = await cryptoUtils.organisationSeed(mnemonic, invite.organisation.id) + + const accountKeyRing = await cryptoUtils.organisationKeyring(accountSeed) + + const deviceKey = await cryptoUtils.deviceVaultKey(pw, session?.user?.email!) + + const encryptedKeyring = await cryptoUtils.encryptAccountKeyring( + accountKeyRing, + deviceKey + ) + + const encryptedMnemonic = await cryptoUtils.encryptAccountRecovery(mnemonic, deviceKey) + + resolve({ + publicKey: accountKeyRing.publicKey, + encryptedKeyring, + encryptedMnemonic, + }) + }, 1000) + } + ) + } + + const handleAccountInit = async () => { + return new Promise(async (resolve, reject) => { + setIsLoading(true) + const { publicKey, encryptedKeyring, encryptedMnemonic } = await computeAccountKeys() + + const { data } = await acceptInvite({ + variables: { + orgId: invite.organisation.id, + identityKey: publicKey, + wrappedKeyring: encryptedKeyring, + wrappedRecovery: encryptedMnemonic, + inviteId: invite.id, + }, + }) + + try { + setLocalKeyring({ + email: session?.user?.email!, + org: invite.organisation, + keyring: encryptedKeyring, + recovery: encryptedMnemonic, + }) + } catch (e) { + setIsLoading(false) + reject() + } + + setIsLoading(false) + if (data.createOrganisationMember.orgMember.id) { + setSuccess(true) + resolve(true) + } else { + reject() + } + }) + } + + const handleInputUpdate = (newValue: string, index: number) => { + if (newValue.split(' ').length === 24) { + setInputs(newValue.split(' ')) + } else setInputs(inputs.map((input: string, i: number) => (index === i ? newValue : input))) + } + + const validateCurrentStep = () => { + if (step === 1 && !recoverySkipped) { + if (inputs.join(' ') !== mnemonic && !recoverySkipped) { + errorToast('Incorrect account recovery key!') + return false + } + } else if (step === 2) { + if (pw !== pw2) { + errorToast("Passwords don't match") + return false + } + } + return true + } + + const incrementStep = async (event: { preventDefault: () => void }) => { + event.preventDefault() + + const isFormValid = validateCurrentStep() + if (step !== steps.length - 1 && isFormValid) setStep(step + 1) + if (step === steps.length - 1 && isFormValid) { + toast.promise(handleAccountInit, { + pending: 'Setting up your account', + success: 'Account setup complete!', + }) + } + } + + const decrementStep = () => { + if (step !== 0) setStep(step - 1) + } + + const skipRecoverySteps = () => { + setRecoverySkipped(true) + setStep(2) + } + + useEffect(() => { + setMnemonic(bip39.generateMnemonic(256)) + const id = crypto.randomUUID() + }, []) + + useEffect(() => { + setInputs([...Array(mnemonic.split(' ').length)].map(() => '')) + }, [mnemonic]) + + const WelcomePane = () => ( +
+
+
+ +
+ +

Welcome to Phase

+

+ You have been invited by{' '} + + {invite.invitedBy.email} + {' '} + to join the{' '} + + {invite.organisation.name} + {' '} + organisation. +

+
+ +
+ ) + + const SuccessPane = () => { + return ( +
+
+

Success!

+

Your account is setup!

+
+ +
+
+
+ ) + } + + return ( + <> +
+ + +
+ {loading ? ( + + ) : invite ? ( + showWelcome ? ( + + ) : success ? ( + + ) : ( +
+
+ +
+ + {step === 0 && } + {step === 1 && ( + + )} + {step === 2 && } + +
+
+ {step !== 0 && ( + + )} +
+
+ {step !== 2 && ( + + )} + +
+
+ + ) + ) : ( + + )} +
+
+ + ) +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 3f085b6a..5d80cb90 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,46 +1,106 @@ 'use client' -import { useQuery } from '@apollo/client' import { useRouter } from 'next/navigation' -import { useEffect } from 'react' +import { useContext, useEffect, useState } from 'react' import Loading from './loading' -import { GetOrganisations } from '@/apollo/queries/getOrganisations.gql' import { OrganisationType } from '@/apollo/graphql' -import { getLocalOrgs } from '@/utils/localStorage' +import { getLocalKeyrings } from '@/utils/localStorage' +import { organisationContext } from '@/contexts/organisationContext' +import { Button } from '@/components/common/Button' +import { FaArrowRight } from 'react-icons/fa' +import { useSession } from 'next-auth/react' +import { HeroPattern } from '@/components/common/HeroPattern' +import { Logo } from '@/components/common/Logo' +import { RoleLabel } from '@/components/users/RoleLabel' export default function Home() { - const { loading, error, data } = useQuery(GetOrganisations) - const router = useRouter() + const { data: session } = useSession() + + const { organisations, activeOrganisation, setActiveOrganisation, loading } = + useContext(organisationContext) + + const [showOrgCards, setShowOrgCards] = useState(false) + + const handleRouteToOrg = (org: OrganisationType) => { + const localOrgs = getLocalKeyrings() + + if ( + localOrgs?.find( + (localOrg) => localOrg.org.id === org!.id && localOrg.email === session?.user?.email + ) + ) { + router.push(`/${org!.name}`) + } else { + router.push(`${org!.name}/newdevice`) + } + } useEffect(() => { - if (data?.organisations) { - const orgs = data.organisations as OrganisationType[] + if (!loading && organisations !== null) { + const localOrgs = getLocalKeyrings() // if there is no org setup on the server, send to onboarding page - if (!orgs.length) router.push('/onboard') - else { - const defaultOrg = data.organisations[0].name - if (orgs.map((org) => org.name).includes(defaultOrg)) { - const localOrgs = getLocalOrgs() - // if org data exists on device - if (localOrgs?.find((localOrg) => localOrg.org.name === defaultOrg)) { - router.push(`/${defaultOrg}`) - } - // if no org data on device, send to new device login page - else { - router.push(`${orgs[0].name}/newdevice`) - } + if (organisations.length === 0) router.push('/signup') + else if (organisations.length === 1) { + const organisation = organisations[0] + setActiveOrganisation(organisation) + // if local keyring exists on device for active organisation + if ( + localOrgs?.find( + (localOrg) => + localOrg.org.id === organisation!.id && localOrg.email === session?.user?.email + ) + ) { + router.push(`/${organisation!.name}`) + } + // if no keyring on device, send to new device login page + else { + router.push(`${organisation!.name}/newdevice`) } + } else { + setShowOrgCards(true) } } - }, [data, router]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [organisations, router, loading]) - useEffect(() => { - if (error) { - throw error.message - } - }, [error]) + return ( +
+ {loading && } + {showOrgCards && ( + <> + - return
{loading && }
+
+
+
+ +
+

Choose a workspace

+
+
+ {organisations!.map((org: OrganisationType) => ( +
+

{org.name}

+
+ You are {org.role!.toLowerCase() === 'dev' ? 'a' : 'an'}{' '} + in this organisation +
+
+ +
+
+ ))} +
+
+ + )} +
+ ) } diff --git a/frontend/app/providers.tsx b/frontend/app/providers.tsx index 3e94646a..c94cab9a 100644 --- a/frontend/app/providers.tsx +++ b/frontend/app/providers.tsx @@ -4,12 +4,18 @@ import { ThemeProvider } from '@/contexts/themeContext' import { SessionProvider } from 'next-auth/react' import { ApolloProvider } from '@apollo/client' import { graphQlClient } from '@/apollo/client' +import { KeyringProvider } from '@/contexts/keyringContext' +import { OrganisationProvider } from '@/contexts/organisationContext' export default function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + + {children} + + ) diff --git a/frontend/app/onboard/head.tsx b/frontend/app/signup/head.tsx similarity index 100% rename from frontend/app/onboard/head.tsx rename to frontend/app/signup/head.tsx diff --git a/frontend/app/onboard/layout.tsx b/frontend/app/signup/layout.tsx similarity index 100% rename from frontend/app/onboard/layout.tsx rename to frontend/app/signup/layout.tsx diff --git a/frontend/app/onboard/page.tsx b/frontend/app/signup/page.tsx similarity index 94% rename from frontend/app/onboard/page.tsx rename to frontend/app/signup/page.tsx index f4444141..7585e9cd 100644 --- a/frontend/app/onboard/page.tsx +++ b/frontend/app/signup/page.tsx @@ -21,8 +21,8 @@ import { toast } from 'react-toastify' import { gql, useMutation } from '@apollo/client' import { useRouter } from 'next/navigation' import Link from 'next/link' -import { CreateOrganisation } from '@/apollo/mutations/createOrganisation.gql' -import { setLocalOrg } from '@/utils/localStorage' +import { CreateOrg } from '@/graphql/mutations/createOrganisation.gql' +import { setLocalKeyring } from '@/utils/localStorage' const bip39 = require('bip39') @@ -36,7 +36,7 @@ const Onboard = () => { const [inputs, setInputs] = useState>([]) const [step, setStep] = useState(0) const [showWelcome, setShowWelcome] = useState(true) - const [createOrganisation, { data, loading, error }] = useMutation(CreateOrganisation) + const [createOrganisation, { data, loading, error }] = useMutation(CreateOrg) const [isloading, setIsLoading] = useState(false) const [seedDownloaded, setSeedDownloaded] = useState(false) const [success, setSuccess] = useState(false) @@ -66,7 +66,7 @@ const Onboard = () => { icon: , title: 'Recovery', description: - "This is your 24 word recovery phrase. It's used to secure your application keys. Only you have access to it. Please write it down and store it somewhere safe like a password manager. You will need to enter your recovery phrase when logging in from a new device.", + "This is your 24 word recovery phrase. You can use it log in to your Phase account if you forget the sudo password. It's used to derive your encryption keys. Only you have access to it. Please write it down or store it somewhere safe like a password manager.", }, { index: 2, @@ -143,11 +143,13 @@ const Onboard = () => { id: orgId, name, identityKey: publicKey, + wrappedKeyring: encryptedKeyring, + wrappedRecovery: encryptedMnemonic, }, }) const { data } = result const newOrg = data.createOrganisation.organisation - setLocalOrg({ + setLocalKeyring({ email: session?.user?.email!, org: newOrg, keyring: encryptedKeyring, diff --git a/frontend/codegen.ts b/frontend/codegen.ts index ee8c981f..26aee833 100644 --- a/frontend/codegen.ts +++ b/frontend/codegen.ts @@ -3,11 +3,11 @@ import type { CodegenConfig } from '@graphql-codegen/cli' const config: CodegenConfig = { overwrite: true, schema: 'apollo/schema.graphql', - //documents: 'apollo/**/*.graphql', + documents: ['graphql/**/*.gql'], generates: { 'apollo/': { preset: 'client', - plugins: ['typescript'], + plugins: [], }, }, } diff --git a/frontend/components/UserMenu.tsx b/frontend/components/UserMenu.tsx index 85093bda..88f53041 100644 --- a/frontend/components/UserMenu.tsx +++ b/frontend/components/UserMenu.tsx @@ -7,6 +7,7 @@ import { useSession, signIn, signOut } from 'next-auth/react' import { MdLogout } from 'react-icons/md' import { handleSignout } from '@/apollo/client' import { Button } from './common/Button' +import { Avatar } from './common/Avatar' export default function UserMenu() { const { data: session } = useSession() @@ -18,10 +19,7 @@ export default function UserMenu() { @@ -34,7 +32,7 @@ export default function UserMenu() { leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - +
{({ active }) => ( diff --git a/frontend/components/apps/AppActivityChart.tsx b/frontend/components/apps/AppActivityChart.tsx index d5de9d20..81845680 100644 --- a/frontend/components/apps/AppActivityChart.tsx +++ b/frontend/components/apps/AppActivityChart.tsx @@ -1,5 +1,5 @@ import { AppType, ChartDataPointType, TimeRange } from '@/apollo/graphql' -import { GetAppActivityChart } from '@/apollo/queries/getAppActivityChart.gql' +import { GetAppActivityChart } from '@/graphql/queries/getAppActivityChart.gql' import { humanReadableNumber } from '@/utils/dataUnits' import { useLazyQuery } from '@apollo/client' import { useEffect, useState } from 'react' diff --git a/frontend/components/apps/AppCard.tsx b/frontend/components/apps/AppCard.tsx index 2c1c3908..6f137b09 100644 --- a/frontend/components/apps/AppCard.tsx +++ b/frontend/components/apps/AppCard.tsx @@ -1,104 +1,56 @@ import { useQuery } from '@apollo/client' -import { FaCube } from 'react-icons/fa' +import { FaBox, FaBoxes, FaCube, FaUser, FaUsers } from 'react-icons/fa' import { Card } from '../common/Card' -import Spinner from '../common/Spinner' -import { GetAppActivityChart } from '@/apollo/queries/getAppActivityChart.gql' -import { AppType, ChartDataPointType, TimeRange } from '@/apollo/graphql' -import { - Area, - AreaSeries, - AreaSparklineChart, - Gradient, - GradientStop, - Line, - Stripes, - TooltipArea, -} from 'reaviz' +import GetAppMembers from '@/graphql/queries/apps/getAppMembers.gql' +import GetAppEnvironments from '@/graphql/queries/secrets/getAppEnvironments.gql' +import { AppType } from '@/apollo/graphql' interface AppCardProps { app: AppType } export const AppCard = (props: AppCardProps) => { - const { name, id } = props.app - - const { data } = useQuery(GetAppActivityChart, { + const { name, id: appId } = props.app + const { data: appMembersData } = useQuery(GetAppMembers, { variables: { appId } }) + const { data: appEnvsData } = useQuery(GetAppEnvironments, { variables: { - appId: id, - period: TimeRange.Day, + appId, }, - fetchPolicy: 'cache-and-network', }) - const chartData = - data?.appActivityChart.map((dataPoint: ChartDataPointType) => { - const timestamp = new Date(dataPoint.date!) - return { - id: dataPoint.index!.toString(), - key: timestamp, - data: dataPoint.data, - } - }) || [] - - const chartIsLoading = chartData.length === 0 - return (
-
-
+
+
{name}
-
{id}
+
{appId}
-
- {!chartIsLoading && ( - } - markLine={null} - interpolation="smooth" - area={ - } - gradient={ - , - , - ]} - /> - } - /> - } - line={} - /> - } - /> - )} -
+
+
+
+ {appMembersData?.appUsers.length > 1 ? : } + {appMembersData?.appUsers.length} +
+ + {appMembersData?.appUsers.length > 1 ? 'Members' : 'Member'} + +
-
-
- {chartIsLoading && } - {data && ( -
- app is live - -
- )} +
+
+ {appEnvsData?.appEnvironments.length > 1 ? : } + {appEnvsData?.appEnvironments.length} +
+ + {appEnvsData?.appEnvironments.length > 1 ? 'Environments' : 'Environment'} +
diff --git a/frontend/components/apps/AppsHomeCard.tsx b/frontend/components/apps/AppsHomeCard.tsx index cb19989b..04cafeb3 100644 --- a/frontend/components/apps/AppsHomeCard.tsx +++ b/frontend/components/apps/AppsHomeCard.tsx @@ -1,7 +1,7 @@ 'use client' import { useQuery } from '@apollo/client' -import { GetApps } from '@/apollo/queries/getApps.gql' +import { GetApps } from '@/graphql/queries/getApps.gql' import { AppType } from '@/apollo/graphql' import Spinner from '../common/Spinner' import { FaCubes } from 'react-icons/fa' diff --git a/frontend/components/apps/DeleteAppDialog.tsx b/frontend/components/apps/DeleteAppDialog.tsx index 2ac74f64..e6c6c5b7 100644 --- a/frontend/components/apps/DeleteAppDialog.tsx +++ b/frontend/components/apps/DeleteAppDialog.tsx @@ -6,10 +6,11 @@ import { Fragment, useState } from 'react' import { FaTrash, FaTimes, FaExclamationTriangle } from 'react-icons/fa' import { toast } from 'react-toastify' import { Button } from '../common/Button' -import { DeleteApp } from '@/apollo/mutations/deleteApp.gql' -import { GetApps } from '@/apollo/queries/getApps.gql' +import { DeleteApplication } from '@/graphql/mutations/deleteApp.gql' +import { GetApps } from '@/graphql/queries/getApps.gql' import { useMutation } from '@apollo/client' import { useRouter } from 'next/navigation' +import { Alert } from '../common/Alert' export default function DeleteAppDialog(props: { organisationId: string @@ -20,7 +21,7 @@ export default function DeleteAppDialog(props: { const { organisationId, appId, appName, teamName } = props const [isOpen, setIsOpen] = useState(false) const [typedName, setTypedName] = useState('') - const [deleteApp, { loading }] = useMutation(DeleteApp) + const [deleteApp, { loading }] = useMutation(DeleteApplication) const router = useRouter() const reset = () => { @@ -126,14 +127,21 @@ export default function DeleteAppDialog(props: {

Permanently delete this App

-
- -
- Warning: This is permanent!
- Once you delete this App, you will not be able to decrypt any data that - was encrypted with these keys. + +
+

Warning: This is permanent!

+ +

+ Deleting this App will permanently delete all environments and secrets + associated with it. +

+ +

+ Once you delete this App, you will not be able to decrypt any data that + was encrypted with this App's KMS keys. +

-
+
diff --git a/frontend/components/apps/NewAppDialog.tsx b/frontend/components/apps/NewAppDialog.tsx index 64c087dd..0b303111 100644 --- a/frontend/components/apps/NewAppDialog.tsx +++ b/frontend/components/apps/NewAppDialog.tsx @@ -1,24 +1,40 @@ -import { cryptoUtils } from '@/utils/auth' +import { OrganisationKeyring, cryptoUtils } from '@/utils/auth' import { copyToClipBoard } from '@/utils/clipboard' -import { getLocalKeyring } from '@/utils/localStorage' -import { Dialog, Transition } from '@headlessui/react' +import { Dialog, Switch, Transition } from '@headlessui/react' import { useSession } from 'next-auth/react' -import { Fragment, ReactNode, useEffect, useState } from 'react' -import { FaCopy, FaCross, FaExclamationTriangle, FaEye, FaEyeSlash, FaTimes } from 'react-icons/fa' +import { Fragment, ReactNode, useContext, useEffect, useState } from 'react' +import { FaCopy, FaExclamationTriangle, FaEye, FaEyeSlash, FaTimes } from 'react-icons/fa' import { toast } from 'react-toastify' import { Button } from '../common/Button' -import { GetApps } from '@/apollo/queries/getApps.gql' -import { CreateApp } from '@/apollo/mutations/createApp.gql' -import { useMutation } from '@apollo/client' +import { GetApps } from '@/graphql/queries/getApps.gql' +import { CreateApplication } from '@/graphql/mutations/createApp.gql' +import { CreateNewSecret } from '@/graphql/mutations/environments/createSecret.gql' +import { GetOrganisationAdminsAndSelf } from '@/graphql/queries/organisation/getOrganisationAdminsAndSelf.gql' +import { InitAppEnvironments } from '@/graphql/mutations/environments/initAppEnvironments.gql' +import { GetAppEnvironments } from '@/graphql/queries/secrets/getAppEnvironments.gql' +import { useLazyQuery, useMutation } from '@apollo/client' import { + ApiEnvironmentEnvTypeChoices, ApiOrganisationPlanChoices, + EnvironmentType, MutationCreateAppArgs, OrganisationType, + SecretInput, + SecretType, } from '@/apollo/graphql' import { splitSecret } from '@/utils/keyshares' import { UpgradeRequestForm } from '../forms/UpgradeRequestForm' +import { KeyringContext } from '@/contexts/keyringContext' +import { createNewEnv } from '@/utils/environments' +import { + decryptAsymmetric, + digest, + encryptAsymmetric, + getUserKxPrivateKey, + getUserKxPublicKey, +} from '@/utils/crypto' -const FREE_APP_LIMIT = 1 +const FREE_APP_LIMIT = 5 const PRO_APP_LIMIT = 10 export default function NewAppDialog(props: { @@ -33,9 +49,16 @@ export default function NewAppDialog(props: { const [pw, setPw] = useState('') const [showPw, setShowPw] = useState(false) const [appId, setAppId] = useState('') + const [createStarters, setCreateStarters] = useState(appCount === 0) const [appSecret, setAppSecret] = useState('') const { data: session } = useSession() - const [createApp, { data, loading, error }] = useMutation(CreateApp) + + const [createApp] = useMutation(CreateApplication) + const [initAppEnvironments] = useMutation(InitAppEnvironments) + const [createSecret] = useMutation(CreateNewSecret) + const [getAppEnvs] = useLazyQuery(GetAppEnvironments) + + const [getOrgAdmins, { data: orgAdminsData }] = useLazyQuery(GetOrganisationAdminsAndSelf) const IS_CLOUD_HOSTED = process.env.APP_HOST || process.env.NEXT_PUBLIC_APP_HOST @@ -44,6 +67,18 @@ export default function NewAppDialog(props: { variant: 'primary', } + const { keyring, setKeyring } = useContext(KeyringContext) + + useEffect(() => { + if (organisation) { + getOrgAdmins({ + variables: { + organisationId: organisation.id, + }, + }) + } + }, [getOrgAdmins, organisation]) + const complete = () => appId && appSecret const reset = () => { @@ -67,6 +102,195 @@ export default function NewAppDialog(props: { toast.info('Copied') } + const validateKeyring = async (password: string) => { + return new Promise(async (resolve, reject) => { + if (keyring) resolve(keyring) + else { + try { + const decryptedKeyring = await cryptoUtils.getKeyring( + session?.user?.email!, + organisation!.id, + password + ) + setKeyring(decryptedKeyring) + resolve(decryptedKeyring) + } catch (error) { + reject(error) + } + } + }) + } + + async function processSecrets( + appId: string, + envType: ApiEnvironmentEnvTypeChoices, + secrets: Array> + ) { + const { data: appEnvsData } = await getAppEnvs({ variables: { appId } }) + + const userKxKeys = { + publicKey: await getUserKxPublicKey(keyring!.publicKey), + privateKey: await getUserKxPrivateKey(keyring!.privateKey), + } + + const env = appEnvsData.appEnvironments.find((env: EnvironmentType) => env.envType === envType) + + const envSalt = await decryptAsymmetric( + env.wrappedSalt, + userKxKeys.privateKey, + userKxKeys.publicKey + ) + + const promises = secrets.map(async (secret) => { + const { key, value, comment } = secret + + const encryptedKey = await encryptAsymmetric(key!, env.identityKey) + const encryptedValue = await encryptAsymmetric(value!, env.identityKey) + const keyDigest = await digest(key!, envSalt) + const encryptedComment = await encryptAsymmetric(comment!, env.identityKey) + + await createSecret({ + variables: { + newSecret: { + envId: env.id, + key: encryptedKey, + keyDigest, + value: encryptedValue, + folderId: null, + comment: encryptedComment, + tags: [], + } as SecretInput, + }, + }) + }) + + return Promise.all(promises) + } + + const createExampleSecrets = async (appId: string) => { + const DEV_SECRETS = [ + { + key: 'AWS_ACCESS_KEY_ID', + value: 'AKIAIX4ONRSG6ODEFVJA', + comment: 'This is an example secret.', + }, + { + key: 'AWS_SECRET_ACCESS_KEY', + value: 'aCRAMarEbFC3Q5c24pi7AVMIt6TaCfHeFZ4KCf/a', + comment: 'This is an example secret.', + }, + { + key: 'JWT_SECRET', + value: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaWF0IjoxNjMzNjIwMTcxLCJleHAiOjIyMDg5ODUyMDB9.pHnckabbMbwTHAJOkb5Z7G7B4chY6GllJf6K2m96z3A', + comment: 'This is an example secret.', + }, + { + key: 'STRIPE_SECRET_KEY', + value: 'sk_test_EeHnL644i6zo4Iyq4v1KdV9H', + comment: 'This is an example secret.', + }, + { + key: 'DJANGO_SECRET_KEY', + value: 'wwf*2#86t64!fgh6yav$aoeuo@u2o@fy&*gg76q!&%6x_wbduad', + comment: 'This is an example secret.', + }, + { + key: 'DJANGO_DEBUG', + value: 'True', + comment: 'This is an example secret.', + }, + { + key: 'POSTGRES_CONNECTION_STRING', + value: 'postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}', + comment: 'This is an example secret.', + }, + { + key: 'DB_HOST', + value: 'mc-laren-prod-db.c9ufzjtplsaq.us-west-1.rds.amazonaws.com', + comment: 'This is an example secret.', + }, + { + key: 'DB_NAME', + value: 'XP1_LM', + comment: 'This is an example secret.', + }, + { + key: 'DB_PASSWORD', + value: '6c37810ec6e74ec3228416d2844564fceb99ebd94b29f4334c244db011630b0e', + comment: 'This is an example secret.', + }, + { + key: 'DB_PORT', + value: '5432', + comment: 'This is an example secret.', + }, + ] + + const STAG_SECRETS = [ + { + key: 'DJANGO_DEBUG', + value: 'False', + comment: 'This is an example secret.', + }, + ] + + const PROD_SECRETS = [ + { + key: 'STRIPE_SECRET_KEY', + value: 'sk_live_epISNGSkdeXov2frTey7RHAi', + comment: 'This is an example secret.', + }, + { + key: 'DJANGO_DEBUG', + value: 'False', + comment: 'This is an example secret.', + }, + ] + + await processSecrets(appId, ApiEnvironmentEnvTypeChoices.Dev, DEV_SECRETS) + await processSecrets(appId, ApiEnvironmentEnvTypeChoices.Staging, STAG_SECRETS) + await processSecrets(appId, ApiEnvironmentEnvTypeChoices.Prod, PROD_SECRETS) + } + + const initAppEnvs = async (appId: string) => { + const mutationPayload = { + devEnv: await createNewEnv( + appId, + 'Development', + ApiEnvironmentEnvTypeChoices.Dev, + orgAdminsData.organisationAdminsAndSelf + ), + stagingEnv: await createNewEnv( + appId, + 'Staging', + ApiEnvironmentEnvTypeChoices.Staging, + orgAdminsData.organisationAdminsAndSelf + ), + prodEnv: await createNewEnv( + appId, + 'Production', + ApiEnvironmentEnvTypeChoices.Prod, + orgAdminsData.organisationAdminsAndSelf + ), + } + + await initAppEnvironments({ + variables: { + devEnv: mutationPayload.devEnv.createEnvPayload, + stagingEnv: mutationPayload.stagingEnv.createEnvPayload, + prodEnv: mutationPayload.prodEnv.createEnvPayload, + devAdminKeys: mutationPayload.devEnv.adminKeysPayload, + stagAdminKeys: mutationPayload.stagingEnv.adminKeysPayload, + prodAdminKeys: mutationPayload.prodEnv.adminKeysPayload, + }, + }) + + if (createStarters) { + await createExampleSecrets(appId) + } + } + const handleCreateApp = async () => { const APP_VERSION = 1 @@ -78,24 +302,14 @@ export default function NewAppDialog(props: { const id = crypto.randomUUID() try { - const deviceKey = await cryptoUtils.deviceVaultKey(pw, session?.user?.email!) - const encryptedKeyring = getLocalKeyring(organisation.id) - if (!encryptedKeyring) throw 'Error fetching local encrypted keys from browser' - const decryptedKeyring = await cryptoUtils.decryptAccountKeyring( - encryptedKeyring!, - deviceKey - ) - if (!decryptedKeyring) throw 'Failed to decrypt keys' - const encryptedAppSeed = await cryptoUtils.encryptedAppSeed( - appSeed, - decryptedKeyring.symmetricKey - ) + const keyring = await validateKeyring(pw) + const encryptedAppSeed = await cryptoUtils.encryptedAppSeed(appSeed, keyring.symmetricKey) const appKeys = await cryptoUtils.appKeyring(appSeed) const appKeyShares = await splitSecret(appKeys.privateKey) const wrappedShare = await cryptoUtils.wrappedKeyShare(appKeyShares[1], wrapKey) - await createApp({ + const { data } = await createApp({ variables: { id, name, @@ -117,10 +331,13 @@ export default function NewAppDialog(props: { ], }) + await initAppEnvs(data.createApp.app.id) + setAppSecret(`pss:v${APP_VERSION}:${appToken}:${appKeyShares[0]}:${wrapKey}`) setAppId(`phApp:v${APP_VERSION}:${appKeys.publicKey}`) resolve(true) + closeModal() } catch (error) { reject(error) } @@ -133,9 +350,7 @@ export default function NewAppDialog(props: { toast.promise(handleCreateApp, { pending: 'Setting up your app', success: 'App created!', - error: error?.message - ? undefined - : 'Something went wrong! Please check your password and try again.', + error: 'Something went wrong! Please check your sudo password and try again.', }) } @@ -152,14 +367,13 @@ export default function NewAppDialog(props: { return { planName: 'Free', dialogTitle: 'Upgrade to Pro', - description: - 'The Free plan is limited to a single application. To create more applications please upgrade to Pro.', + description: `The Free plan is limited to ${FREE_APP_LIMIT} Apps. To create more Apps, please upgrade to Pro.`, } else if (organisation.plan === ApiOrganisationPlanChoices.Pr) return { planName: 'Pro', dialogTitle: 'Upgrade to Enterprise', - description: `The Pro plan is limited to ${PRO_APP_LIMIT} applications. To create more applications please upgrade to Enterprise.`, + description: `The Pro plan is limited to ${PRO_APP_LIMIT} Apps. To create more Apps, please upgrade to Enterprise.`, } } @@ -212,12 +426,12 @@ export default function NewAppDialog(props: { {!complete() && allowNewApp() && (
-
+

Create a new app by entering an app name below. A new set of encryption keys will be created to secure your app.

-
+
-
+ {!keyring && ( +
+ +
+ setPw(e.target.value)} + type={showPw ? 'text' : 'password'} + minLength={16} + required + className="w-full " + /> + +
+
+ )} + +
-
- setPw(e.target.value)} - type={showPw ? 'text' : 'password'} - minLength={16} - required - className="w-full " - /> - -
+ setCreateStarters(!createStarters)} + className={`${ + createStarters + ? 'bg-emerald-400/10 ring-emerald-400/20' + : 'bg-neutral-500/40 ring-neutral-500/30' + } relative inline-flex h-6 w-11 items-center rounded-full ring-1 ring-inset`} + > + Initialize with example secrets + +
diff --git a/frontend/components/apps/tokens/SecretTokens.tsx b/frontend/components/apps/tokens/SecretTokens.tsx new file mode 100644 index 00000000..af29839c --- /dev/null +++ b/frontend/components/apps/tokens/SecretTokens.tsx @@ -0,0 +1,627 @@ +import { RevokeServiceToken } from '@/graphql/mutations/environments/deleteServiceToken.gql' +import { CreateNewServiceToken } from '@/graphql/mutations/environments/createServiceToken.gql' +import { GetServiceTokens } from '@/graphql/queries/secrets/getServiceTokens.gql' +import { GetEnvironmentKey } from '@/graphql/queries/secrets/getEnvironmentKey.gql' +import { GetAppEnvironments } from '@/graphql/queries/secrets/getAppEnvironments.gql' +import { + newEnvToken, + newEnvWrapKey, + newServiceTokenKeys, + unwrapEnvSecretsForUser, + wrapEnvSecretsForServiceToken, +} from '@/utils/environments' +import { EnvironmentType, ServiceTokenType, UserTokenType } from '@/apollo/graphql' +import { cryptoUtils } from '@/utils/auth' +import { splitSecret } from '@/utils/keyshares' +import { useLazyQuery, useMutation, useQuery } from '@apollo/client' +import { useState, useEffect, useContext, Fragment } from 'react' +import { KeyringContext } from '@/contexts/keyringContext' +import { Button } from '@/components/common/Button' +import { + FaCheckSquare, + FaChevronDown, + FaCircle, + FaDotCircle, + FaExclamationTriangle, + FaKey, + FaPlus, + FaSquare, + FaTimes, + FaTrashAlt, +} from 'react-icons/fa' +import { getUnixTimeStampinFuture, relativeTimeFromDates } from '@/utils/time' +import { Dialog, Listbox, RadioGroup, Transition } from '@headlessui/react' +import { copyToClipBoard } from '@/utils/clipboard' +import { MdContentCopy } from 'react-icons/md' +import { toast } from 'react-toastify' +import clsx from 'clsx' +import { organisationContext } from '@/contexts/organisationContext' +import { userIsAdmin } from '@/utils/permissions' +import { Avatar } from '@/components/common/Avatar' + +interface ExpiryOptionT { + name: string + getExpiry: () => number | null +} + +const handleCopy = (val: string) => { + copyToClipBoard(val) + toast.info('Copied', { + autoClose: 2000, + }) +} + +const tokenExpiryOptions: ExpiryOptionT[] = [ + { + name: 'Never', + getExpiry: () => null, + }, + { + name: '7 days', + getExpiry: () => getUnixTimeStampinFuture(7), + }, + { + name: '30 days', + getExpiry: () => getUnixTimeStampinFuture(30), + }, + { + name: '60 days', + getExpiry: () => getUnixTimeStampinFuture(60), + }, + { + name: '90 days', + getExpiry: () => getUnixTimeStampinFuture(90), + }, +] + +const compareExpiryOptions = (a: ExpiryOptionT, b: ExpiryOptionT) => { + return a.getExpiry() === b.getExpiry() +} + +const humanReadableExpiry = (expiryOption: ExpiryOptionT) => + expiryOption.getExpiry() === null + ? 'This token will never expire.' + : `This token will expire on ${new Date(expiryOption.getExpiry()!).toLocaleDateString()}.` + +const CreateServiceTokenDialog = (props: { organisationId: string; appId: string }) => { + const { organisationId, appId } = props + + const { keyring } = useContext(KeyringContext) + + const [isOpen, setIsOpen] = useState(false) + const [name, setName] = useState('') + const [envScope, setEnvScope] = useState>>([]) + const [expiry, setExpiry] = useState(tokenExpiryOptions[0]) + const [showEnvHint, setShowEnvHint] = useState(false) + + const [serviceToken, setServiceToken] = useState('') + + const { data } = useQuery(GetAppEnvironments, { + variables: { + appId, + }, + }) + const [getEnvKey] = useLazyQuery(GetEnvironmentKey) + const [createServiceToken] = useMutation(CreateNewServiceToken) + + const reset = () => { + setName('') + setEnvScope([]) + setServiceToken('') + setShowEnvHint(false) + } + + const closeModal = () => { + reset() + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + const envOptions = + data?.appEnvironments.map((env: EnvironmentType) => { + const { id, name } = env + + return { + id, + name, + } + }) ?? [] + + const handleCreateNewServiceToken = async (event: { preventDefault: () => void }) => { + event.preventDefault() + + if (envScope.length === 0) { + setShowEnvHint(true) + return false + } + + if (keyring) { + const appEnvironments = data.appEnvironments as EnvironmentType[] + + const token = await newEnvToken() + const wrapKey = await newEnvWrapKey() + + const tokenKeys = await newServiceTokenKeys() + const keyShares = await splitSecret(tokenKeys.privateKey) + const wrappedKeyShare = await cryptoUtils.wrappedKeyShare(keyShares[1], wrapKey) + + const pssService = `pss_service:v1:${token}:${tokenKeys.publicKey}:${keyShares[0]}:${wrapKey}` + + const envKeyPromises = appEnvironments + .filter((env) => envScope.map((selectedEnv) => selectedEnv.id).includes(env.id)) + .map(async (env: EnvironmentType) => { + const { data } = await getEnvKey({ + variables: { + envId: env.id, + appId, + }, + }) + + const { + wrappedSeed: userWrappedSeed, + wrappedSalt: userWrappedSalt, + identityKey, + } = data.environmentKeys[0] + + const { seed, salt } = await unwrapEnvSecretsForUser( + userWrappedSeed, + userWrappedSalt, + keyring! + ) + + const { wrappedSeed, wrappedSalt } = await wrapEnvSecretsForServiceToken( + { seed, salt }, + tokenKeys.publicKey + ) + + return { + envId: env.id, + identityKey, + wrappedSeed, + wrappedSalt, + } + }) + + const envKeyInputs = await Promise.all(envKeyPromises) + + await createServiceToken({ + variables: { + appId, + environmentKeys: envKeyInputs, + identityKey: tokenKeys.publicKey, + token, + wrappedKeyShare, + name, + expiry: expiry.getExpiry(), + }, + refetchQueries: [ + { + query: GetServiceTokens, + variables: { + appId, + }, + }, + ], + }) + + setServiceToken(pssService) + } + } + + return ( + <> +
+ +
+ + + {}}> + +
+ + +
+
+ + + +

+ Create a new Service token +

+ + +
+ + {serviceToken ? ( +
+
+
+ + service token + +
+ {serviceToken && ( +
+ +
+ {"Copy this value. You won't see it again!"} +
+
+ )} + {serviceToken && ( + + )} +
+
+ {serviceToken} +
+
+ ) : ( + +
+ + setName(e.target.value)} + /> +
+ +
+ {envScope.length === 0 && showEnvHint && ( + + Select an environment scope + + )} + + {({ open }) => ( + <> + + + + +
+ + {envScope + .map((env: Partial) => env.name) + .join(' + ')} + + +
+
+ + +
+ {envOptions.map((env: Partial) => ( + + {({ active, selected }) => ( +
+ {selected ? ( + + ) : ( + + )} + + {env.name} + +
+ )} +
+ ))} +
+
+
+ + )} +
+
+ +
+ + + + +
+ {tokenExpiryOptions.map((option) => ( + + {({ active, checked }) => ( +
+ {checked ? ( + + ) : ( + + )} + {option.name} +
+ )} +
+ ))} +
+
+ + {humanReadableExpiry(expiry)} + +
+ +
+ + +
+ + )} +
+
+
+
+
+
+ + ) +} + +export const SecretTokens = (props: { organisationId: string; appId: string }) => { + const { organisationId, appId } = props + + const [getServiceTokens, { data: serviceTokensData }] = useLazyQuery(GetServiceTokens) + + const [deleteServiceToken] = useMutation(RevokeServiceToken) + + const { activeOrganisation: organisation } = useContext(organisationContext) + + const handleDeleteServiceToken = async (tokenId: string) => { + await deleteServiceToken({ + variables: { tokenId }, + refetchQueries: [ + { + query: GetServiceTokens, + variables: { + organisationId, + appId, + }, + }, + ], + }) + } + + useEffect(() => { + if (organisationId && appId) { + getServiceTokens({ + variables: { + appId, + }, + }) + } + }, [appId, getServiceTokens, organisationId]) + + const DeleteConfirmDialog = (props: { + token: UserTokenType | ServiceTokenType + onDelete: Function + }) => { + const { token, onDelete } = props + + const [isOpen, setIsOpen] = useState(false) + + const closeModal = () => { + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + return ( + <> +
+ +
+ + + + +
+ + +
+
+ + + +

+ Delete{' '} + + {token.name} + +

+ + +
+ +
+

+ Are you sure you want to delete this token? +

+
+ + +
+
+
+
+
+
+
+
+ + ) + } + + const CreatedToken = (props: { token: ServiceTokenType; deleteHandler: Function }) => { + const { token, deleteHandler } = props + + const isExpired = token.expiresAt === null ? false : new Date(token.expiresAt) < new Date() + + const activeUserIsAdmin = organisation ? userIsAdmin(organisation.role!) : false + + const allowDelete = activeUserIsAdmin || token.createdBy!.self + + return ( +
+
+ +
+
{token.name}
+
+
+
Created {relativeTimeFromDates(new Date(token.createdAt))}
+ {token.__typename === 'ServiceTokenType' && ( +
+ by + + {token.createdBy?.self + ? 'You' + : token.createdBy?.fullName || token.createdBy?.email} +
+ )} +
+ +
+ {isExpired ? 'Expired' : 'Expires'}{' '} + {token.expiresAt ? relativeTimeFromDates(new Date(token.expiresAt)) : 'never'} +
+
+
+
+ {allowDelete && ( +
+ +
+ )} +
+ ) + } + + return ( +
+
+
+

Service tokens

+

+ Tokens used to authenticate with the CLI from automated machines. Used for CI and + production environments. +

+
+
+ {serviceTokensData?.serviceTokens.map((serviceToken: ServiceTokenType) => ( + + ))} +
+ + +
+
+ ) +} diff --git a/frontend/components/auth/SignInButtons.tsx b/frontend/components/auth/SignInButtons.tsx index 2da76025..667cba3d 100644 --- a/frontend/components/auth/SignInButtons.tsx +++ b/frontend/components/auth/SignInButtons.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx' import { signIn, useSession } from 'next-auth/react' -import { useRouter } from 'next/navigation' +import { useRouter, useSearchParams } from 'next/navigation' import { ReactNode, useEffect, useState } from 'react' import { FaGithub, FaGitlab, FaGoogle } from 'react-icons/fa' import { AnimatedLogo } from '../common/AnimatedLogo' @@ -44,6 +44,10 @@ export default function SignInButtons() { const { status } = useSession() const router = useRouter() + const searchParams = useSearchParams() + + const callbackUrl = searchParams.get('callbackUrl') + const titleText = () => (loading ? 'Logging you in' : 'Phase Console') useEffect(() => { @@ -57,7 +61,10 @@ export default function SignInButtons() { const handleProviderButtonClick = (providerId: string) => { setLoading(true) - signIn(providerId) + signIn(providerId, { + redirect: callbackUrl ? true : false, + callbackUrl: callbackUrl || '', + }) } return ( diff --git a/frontend/components/auth/UnlockKeyringDialog.tsx b/frontend/components/auth/UnlockKeyringDialog.tsx new file mode 100644 index 00000000..59f8bb43 --- /dev/null +++ b/frontend/components/auth/UnlockKeyringDialog.tsx @@ -0,0 +1,140 @@ +import { Dialog, Transition } from '@headlessui/react' +import { Fragment, useContext, useEffect, useState } from 'react' +import { FaEye, FaEyeSlash, FaLock, FaTimes } from 'react-icons/fa' +import { Button } from '../common/Button' +import { KeyringContext } from '@/contexts/keyringContext' +import { cryptoUtils } from '@/utils/auth' +import { getLocalKeyring } from '@/utils/localStorage' +import { useSession } from 'next-auth/react' +import clsx from 'clsx' +import { toast } from 'react-toastify' + +export default function UnlockKeyringDialog(props: { organisationId: string }) { + const [password, setPassword] = useState('') + const [showPw, setShowPw] = useState(false) + const [isOpen, setIsOpen] = useState(false) + const { keyring, setKeyring } = useContext(KeyringContext) + const { data: session } = useSession() + + useEffect(() => { + if (keyring === null) openModal() + }) + + const closeModal = () => { + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + const decryptLocalKeyring = async (event: { preventDefault: () => void }) => { + event.preventDefault() + + try { + const decryptedKeyring = await cryptoUtils.getKeyring( + session?.user?.email!, + props.organisationId, + password + ) + setKeyring(decryptedKeyring) + toast.success('Unlocked user keyring.', { autoClose: 2000 }) + closeModal() + } catch (e) { + console.error(e) + toast.error('Failed to decrypt keys. Please verify your sudo password and try again.') + return false + } + } + + return ( + <> + + {}}> + +
+ + +
+
+ + + + +

+ Unlock User Keyring +

+
+
+
+

+ Please enter your sudo password to unlock the user keyring. + This is required for data to be decrypted on this screen. +

+
+
+
+ +
+ setPassword(e.target.value)} + type={showPw ? 'text' : 'password'} + minLength={16} + required + autoFocus + className="custom w-full text-zinc-800 font-mono dark:text-white bg-zinc-100 dark:bg-zinc-800 rounded-md" + /> + +
+
+
+ +
+
+
+
+
+
+
+
+
+ + ) +} diff --git a/frontend/components/common/Alert.tsx b/frontend/components/common/Alert.tsx index 39cd9469..74bc1756 100644 --- a/frontend/components/common/Alert.tsx +++ b/frontend/components/common/Alert.tsx @@ -1,15 +1,42 @@ import clsx from 'clsx' import { ReactNode } from 'react' +import { FaCheck, FaExclamationTriangle, FaInfoCircle } from 'react-icons/fa' -export const Alert = (props: { children: ReactNode; variant: 'success' | 'warning' | 'info' }) => { - const variants = { +export const Alert = (props: { + children: ReactNode + variant: 'success' | 'warning' | 'info' | 'danger' + icon?: boolean + size?: 'sm' | 'md' +}) => { + const variantStyles = { success: 'bg-emerald-200/60 dark:bg-emerald-400/10 ring-emerald-500 text-emerald-500', - warning: 'bg-orange-800/20 dark:bg-orange-800/30 ring-orange-500 text-orange-500', - info: 'bg-sky-800/30 ring-sky-500 text-sky-500', + warning: + 'bg-amber-300/40 dark:bg-amber-400/10 ring-amber-500/40 text-black dark:text-amber-400', + info: 'bg-cyan-300/40 dark:bg-cyan-800/30 ring-cyan-400/10 text-black dark:text-cyan-400', + danger: 'bg-red-300/40 dark:bg-red-400/10 ring-red-500/40 text-black dark:text-red-400', + } + + const sizeStyles = { + sm: 'px-2 py-1 text-sm gap-2', + md: 'p-4 text-base gap-4', + } + + const variantIcons = { + success: , + warning: , + danger: , + info: , } return ( -
+
+ {props.icon && variantIcons[props.variant]} {props.children}
) diff --git a/frontend/components/common/Avatar.tsx b/frontend/components/common/Avatar.tsx new file mode 100644 index 00000000..8342dd29 --- /dev/null +++ b/frontend/components/common/Avatar.tsx @@ -0,0 +1,19 @@ +import clsx from 'clsx' + +export const Avatar = (props: { imagePath: string; size: 'sm' | 'md' | 'lg' | 'xl' }) => { + const sizes = { + sm: 'h-5 w-5', + md: 'h-8 w-8', + lg: 'h-12 w-12', + xl: 'h-20 w-20', + } + + const sizeStyle = sizes[props.size] + + return ( +
+ ) +} diff --git a/frontend/components/common/Button.tsx b/frontend/components/common/Button.tsx index b55b5a38..fb84f1fc 100644 --- a/frontend/components/common/Button.tsx +++ b/frontend/components/common/Button.tsx @@ -32,6 +32,10 @@ function ArrowIcon(props: { className: string }) { const variantStyles: Record = { primary: 'rounded-full bg-zinc-900 py-1 px-3 text-white hover:bg-zinc-700 dark:bg-emerald-400/10 dark:text-emerald-400 dark:ring-1 dark:ring-inset dark:ring-emerald-400/20 dark:hover:bg-emerald-400/10 dark:hover:text-emerald-300 dark:hover:ring-emerald-300', + warning: + 'rounded-full bg-amber-700 py-1 px-3 text-white hover:bg-amber-600 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-1 dark:ring-inset dark:ring-amber-400/20 dark:hover:bg-amber-400/10 dark:hover:text-amber-300 dark:hover:ring-amber-300', + danger: + 'rounded-full bg-red-700 py-1 px-3 text-white hover:bg-red-600 dark:bg-red-400/10 dark:text-red-400 dark:ring-1 dark:ring-inset dark:ring-red-400/20 dark:hover:bg-red-400/10 dark:hover:text-red-300 dark:hover:ring-red-300', secondary: 'rounded-full bg-zinc-100 py-1 px-3 text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800/40 dark:text-zinc-400 dark:ring-1 dark:ring-inset dark:ring-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-300', filled: diff --git a/frontend/components/common/ModeToggle.tsx b/frontend/components/common/ModeToggle.tsx index 58751d4a..b7d01d7e 100644 --- a/frontend/components/common/ModeToggle.tsx +++ b/frontend/components/common/ModeToggle.tsx @@ -20,17 +20,15 @@ export function ModeToggle() { checked={isDark} onChange={toggleTheme} className={`${ - isDark ? 'bg-sky-900' : 'bg-sky-200' - } relative inline-flex h-6 w-11 items-center rounded-full`} + isDark ? 'bg-emerald-400/10 ring-emerald-400/20' : 'bg-neutral-500/40 ring-neutral-500/30' + } relative inline-flex h-6 w-11 items-center rounded-full ring-1 ring-inset`} > Enable dark theme - {isDark ? : } - + isDark ? 'translate-x-6 bg-emerald-400' : 'translate-x-1 bg-black' + } flex items-center justify-center h-4 w-4 transform rounded-full transition`} + >
) diff --git a/frontend/components/environments/SecretPropertyDiffs.tsx b/frontend/components/environments/SecretPropertyDiffs.tsx new file mode 100644 index 00000000..2f4de44e --- /dev/null +++ b/frontend/components/environments/SecretPropertyDiffs.tsx @@ -0,0 +1,77 @@ +import { SecretEventType, SecretTagType, SecretType } from '@/apollo/graphql' +import { areTagsAreSame } from '@/utils/tags' +import { Tag } from './SecretRow' + +export const SecretPropertyDiffs = (props: { + secret: SecretType + historyItem: SecretEventType + index: number +}) => { + const { secret, historyItem, index } = props + + const previousItem = secret.history![index - 1]! + + const getAddedTags = () => { + const addedTags = historyItem!.tags.filter((currentTag: SecretTagType) => + previousItem.tags.every((previousTag: SecretTagType) => previousTag.id !== currentTag.id) + ) + return addedTags + } + + const getRemovedTags = () => { + const removedTags = previousItem.tags.filter((previousTag: SecretTagType) => + historyItem.tags.every((currentTag) => currentTag.id !== previousTag.id) + ) + return removedTags + } + + return ( + <> + {historyItem!.key !== previousItem.key && ( +
+ KEY: + {previousItem.key} + + {historyItem!.key} + +
+ )} + + {historyItem!.value !== previousItem.value && ( +
+ VALUE: + {previousItem.value} + + {historyItem!.value} + +
+ )} + + {historyItem!.comment !== previousItem.comment && ( +
+ COMMENT: + {previousItem.comment} + + {historyItem!.comment} + +
+ )} + + {!areTagsAreSame(historyItem!.tags, previousItem.tags) && ( +
+ TAGS: +
+ {getRemovedTags().map((tag) => ( + + ))} +
+
+ {getAddedTags().map((tag) => ( + + ))} +
+
+ )} + + ) +} diff --git a/frontend/components/environments/SecretRow.tsx b/frontend/components/environments/SecretRow.tsx new file mode 100644 index 00000000..66fa97d4 --- /dev/null +++ b/frontend/components/environments/SecretRow.tsx @@ -0,0 +1,674 @@ +import { + ApiSecretEventEventTypeChoices, + Maybe, + SecretEventType, + SecretTagType, + SecretType, +} from '@/apollo/graphql' +import { Fragment, useEffect, useState } from 'react' +import { + FaEyeSlash, + FaEye, + FaTimes, + FaRegCommentDots, + FaTrashAlt, + FaHistory, + FaPlus, + FaUser, + FaTags, + FaCheckSquare, + FaSquare, + FaKey, + FaInfo, +} from 'react-icons/fa' +import { Button } from '../common/Button' +import { Dialog, Popover, Transition } from '@headlessui/react' +import { GetSecretTags } from '@/graphql/queries/secrets/getSecretTags.gql' +import { CreateNewSecretTag } from '@/graphql/mutations/environments/createSecretTag.gql' +import { LogSecretRead } from '@/graphql/mutations/environments/readSecret.gql' +import clsx from 'clsx' +import { relativeTimeFromDates } from '@/utils/time' +import { useLazyQuery, useMutation } from '@apollo/client' +import { areTagsAreSame } from '@/utils/tags' +import { Avatar } from '../common/Avatar' +import { SecretPropertyDiffs } from './SecretPropertyDiffs' + +export const Tag = (props: { tag: SecretTagType }) => { + const { name, color } = props.tag + + return ( +
+
+ {name} +
+ ) +} + +const TagsDialog = (props: { + orgId: string + secretId: string + secretName: string + tags: Array + handlePropertyChange: Function +}) => { + const { orgId, secretId, secretName, tags, handlePropertyChange } = props + + const [getOrgTags, { data: orgTags }] = useLazyQuery(GetSecretTags) + const [createSecretTag] = useMutation(CreateNewSecretTag) + + const [secretTags, setSecretTags] = useState>(tags) + + const [isOpen, setIsOpen] = useState(false) + + const [newTag, setNewTag] = useState>({ name: '', color: '' }) + + useEffect(() => { + if (isOpen) + getOrgTags({ + variables: { + orgId, + }, + }) + }, [getOrgTags, isOpen, orgId]) + + const closeModal = () => { + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + const handleClose = () => { + handlePropertyChange(secretId, 'tags', secretTags) + closeModal() + } + + const handleNewTagNameChange = (name: string) => setNewTag({ ...newTag, ...{ name } }) + + const handleNewTagColorChange = (color: string) => setNewTag({ ...newTag, ...{ color } }) + + const handleCreateTag = async () => { + const { name, color } = newTag + + if (!name) return false + + await createSecretTag({ + variables: { + orgId, + name, + color, + }, + refetchQueries: [ + { + query: GetSecretTags, + variables: { + orgId, + }, + }, + ], + }) + setNewTag({ name: '', color: '' }) + } + + const TagSelector = (props: { tag: SecretTagType }) => { + const { id, name, color } = props.tag + + const isSelected = secretTags.map((secretTag) => secretTag.name).includes(name) + + const handleTagClick = () => { + if (isSelected) { + setSecretTags(secretTags.filter((tag) => tag.name !== name)) + } else setSecretTags([...secretTags, ...[{ id, name, color }]]) + } + + return ( +
+ {isSelected ? ( + + ) : ( + + )} +
+ +
+
+ ) + } + + return ( + <> + {tags.length > 0 ? ( +
+ {tags.map((tag) => ( + + ))} +
+ ) : ( +
+ +
+ )} + + + + +
+ + +
+
+ + + +

+ Update{' '} + + {secretName} + {' '} + tags +

+ + +
+ +
+
+ {orgTags?.secretTags.map((tag: SecretTagType) => ( + + ))} +
+
+ handleNewTagNameChange(e.target.value)} + /> + handleNewTagColorChange(e.target.value)} + /> +
+ +
+
+
+
+
+
+
+
+
+ + ) +} + +const HistoryDialog = (props: { secret: SecretType }) => { + const { secret } = props + + const [isOpen, setIsOpen] = useState(false) + + const closeModal = () => { + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + const getEventTypeColor = (eventType: ApiSecretEventEventTypeChoices) => { + if (eventType === ApiSecretEventEventTypeChoices.C) return 'bg-emerald-500' + if (eventType === ApiSecretEventEventTypeChoices.U) return 'bg-yellow-500' + if (eventType === ApiSecretEventEventTypeChoices.R) return 'bg-blue-500' + if (eventType === ApiSecretEventEventTypeChoices.D) return 'bg-red-500' + } + + const getEventTypeText = (eventType: ApiSecretEventEventTypeChoices) => { + if (eventType === ApiSecretEventEventTypeChoices.C) return 'Created' + if (eventType === ApiSecretEventEventTypeChoices.U) return 'Updated' + if (eventType === ApiSecretEventEventTypeChoices.R) return 'Read' + if (eventType === ApiSecretEventEventTypeChoices.D) return 'Deleted' + } + + const secretHistory = secret.history!.filter( + (event: Maybe) => event?.eventType! !== ApiSecretEventEventTypeChoices.R + ) + + return ( + <> +
+ +
+ + + + +
+ + +
+
+ + + +

+ + {secret.key} + {' '} + history +

+ + +
+ +
+
+
+ {secretHistory?.map((historyItem, index) => ( +
+
+ +
+ {getEventTypeText(historyItem!.eventType)} +
+
+ {relativeTimeFromDates(new Date(historyItem!.timestamp))} +
{' '} + by +
+ {historyItem!.user ? ( +
+ + {historyItem?.user.fullName || historyItem?.user.email} +
+ ) : ( +
+ Service token +
+ )} +
+
+ {index > 0 && ( + + )} +
+ ))} +
+
+
+
+
+
+
+
+
+ + ) +} + +const CommentDialog = (props: { + secretId: string + secretName: string + comment: string + handlePropertyChange: Function +}) => { + const { secretId, secretName, comment, handlePropertyChange } = props + + const [commentValue, setCommentValue] = useState(comment) + + const [isOpen, setIsOpen] = useState(false) + + const closeModal = () => { + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + const handleClose = () => { + handlePropertyChange(secretId, 'comment', commentValue) + closeModal() + } + + return ( + <> +
+ +
+ + + + +
+ + +
+
+ + + +

+ Update{' '} + + {secretName} + {' '} + comment +

+ + +
+ +
+ +
+ +
+ +
+
+
+
+
+
+
+ + ) +} + +const DeleteConfirmDialog = (props: { + secretId: string + secretName: string + onDelete: Function +}) => { + const { secretName, secretId, onDelete } = props + + const [isOpen, setIsOpen] = useState(false) + + const closeModal = () => { + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + return ( + <> +
+ +
+ + + + +
+ + +
+
+ + + +

+ Delete{' '} + + {secretName} + +

+ + +
+ +
+

Are you sure you want to delete this secret?

+
+ + +
+
+
+
+
+
+
+
+ + ) +} + +export default function SecretRow(props: { + orgId: string + secret: SecretType + cannonicalSecret: SecretType | undefined + secretNames: Array> + handlePropertyChange: Function + handleDelete: Function +}) { + const { orgId, secret, cannonicalSecret, secretNames, handlePropertyChange, handleDelete } = props + + const [isRevealed, setIsRevealed] = useState(false) + + const [readSecret] = useMutation(LogSecretRead) + + const handleRevealSecret = async () => { + setIsRevealed(true) + await readSecret({ variables: { id: secret.id } }) + } + + const handleHideSecret = () => setIsRevealed(false) + + const toggleReveal = () => { + isRevealed ? handleHideSecret() : handleRevealSecret() + } + + const INPUT_BASE_STYLE = + 'w-full text-zinc-800 font-mono custom bg-zinc-100 dark:bg-zinc-800 dark:text-white transition ease' + + const keyIsBlank = secret.key.length === 0 + + const keyIsDuplicate = + secretNames.findIndex((s) => s.key === secret.key && s.id !== secret.id) > -1 + + const secretHasBeenModified = () => { + if (cannonicalSecret === undefined) return true + return ( + secret.key !== cannonicalSecret.key || + secret.value !== cannonicalSecret.value || + secret.comment !== cannonicalSecret.comment || + !areTagsAreSame(secret.tags, cannonicalSecret.tags) + ) + } + + return ( +
+
+ handlePropertyChange(secret.id, 'key', e.target.value.toUpperCase())} + /> +
+
+ +
+
+
+
+ handlePropertyChange(secret.id, 'value', e.target.value)} + /> + +
+
+ +
+ +
+ +
+ +
+ +
+
+
+
+ +
+
+ ) +} diff --git a/frontend/components/layout/Navbar.tsx b/frontend/components/layout/Navbar.tsx index 461889f0..f9eba8ac 100644 --- a/frontend/components/layout/Navbar.tsx +++ b/frontend/components/layout/Navbar.tsx @@ -1,30 +1,31 @@ import { Logo } from '../common/Logo' import UserMenu from '../UserMenu' -import { useLazyQuery, useQuery } from '@apollo/client' -import GetOrganisations from '@/apollo/queries/getOrganisations.gql' -import { GetApps } from '@/apollo/queries/getApps.gql' +import { useLazyQuery } from '@apollo/client' +import { GetApps } from '@/graphql/queries/getApps.gql' +import { GetAppEnvironments } from '@/graphql/queries/secrets/getAppEnvironments.gql' import { usePathname } from 'next/navigation' -import { useEffect } from 'react' -import { AppType } from '@/apollo/graphql' +import { useContext, useEffect } from 'react' +import { AppType, EnvironmentType } from '@/apollo/graphql' import Link from 'next/link' import { Button } from '../common/Button' import { StatusIndicator } from '../common/StatusIndicator' +import { organisationContext } from '@/contexts/organisationContext' +import clsx from 'clsx' export const NavBar = (props: { team: string }) => { - const { data: orgsData } = useQuery(GetOrganisations) + const { activeOrganisation: organisation } = useContext(organisationContext) + const [getApps, { data: appsData }] = useLazyQuery(GetApps) + const [getAppEnvs, { data: appEnvsData }] = useLazyQuery(GetAppEnvironments) const IS_CLOUD_HOSTED = process.env.APP_HOST || process.env.NEXT_PUBLIC_APP_HOST useEffect(() => { - if (orgsData?.organisations) { + if (organisation) { const fetchData = async () => { - const org = orgsData.organisations[0] - - const organisationId = org.id getApps({ variables: { - organisationId, + organisationId: organisation.id, appId: '', }, }) @@ -32,14 +33,27 @@ export const NavBar = (props: { team: string }) => { fetchData() } - }, [getApps, orgsData]) + }, [getApps, organisation]) const apps = appsData?.apps as AppType[] + const envs: EnvironmentType[] = appEnvsData?.appEnvironments ?? [] + const appId = usePathname()?.split('/')[3] + const envId = usePathname()?.split('/')[5] + + const appPage = usePathname()?.split('/')[4] + const activeApp = apps?.find((app) => app.id === appId) + useEffect(() => { + if (activeApp) getAppEnvs({ variables: { appId: activeApp.id } }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeApp]) + + const activeEnv = activeApp ? envs.find((env) => env.id === envId) : undefined + return (
@@ -47,14 +61,42 @@ export const NavBar = (props: { team: string }) => { / + {!activeApp && {props.team}} + {activeApp && {props.team}} + {activeApp && /} - {activeApp && {activeApp.name}} + + {activeApp && + (appPage ? ( + {activeApp.name} + ) : ( + {activeApp.name} + ))} + + {appPage && /} + + {appPage && ( + + {appPage} + + )} + + {activeEnv && /} + + {activeEnv && {activeEnv.name}}
{IS_CLOUD_HOSTED && } - + + +
diff --git a/frontend/components/layout/OnboardingNavbar.tsx b/frontend/components/layout/OnboardingNavbar.tsx index 60e812e6..3324b720 100644 --- a/frontend/components/layout/OnboardingNavbar.tsx +++ b/frontend/components/layout/OnboardingNavbar.tsx @@ -2,6 +2,7 @@ import Link from 'next/link' import { AnimatedLogo } from '../common/AnimatedLogo' import { ModeToggle } from '../common/ModeToggle' import UserMenu from '../UserMenu' +import { FaSun, FaMoon } from 'react-icons/fa' const OnboardingNavbar = () => { return ( @@ -17,7 +18,11 @@ const OnboardingNavbar = () => { phase.dev
- +
+ + + +
diff --git a/frontend/components/layout/Sidebar.tsx b/frontend/components/layout/Sidebar.tsx index 749dd5b3..3fffa85e 100644 --- a/frontend/components/layout/Sidebar.tsx +++ b/frontend/components/layout/Sidebar.tsx @@ -1,10 +1,21 @@ 'use client' import Link from 'next/link' -import UserMenu from '../UserMenu' -import { usePathname } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' import clsx from 'clsx' -import { FaCog, FaCubes, FaHome } from 'react-icons/fa' +import { + FaChevronDown, + FaCog, + FaCubes, + FaExchangeAlt, + FaHome, + FaKey, + FaUsersCog, +} from 'react-icons/fa' +import { organisationContext } from '@/contexts/organisationContext' +import { Fragment, useContext } from 'react' +import { OrganisationType } from '@/apollo/graphql' +import { Menu, Transition } from '@headlessui/react' export type SidebarLinkT = { name: string @@ -15,12 +26,12 @@ export type SidebarLinkT = { const SidebarLink = (props: SidebarLinkT) => { const { name, href, icon, active } = props - const iconStyles = 'hover:text-emerald-500' + return (
@@ -34,52 +45,133 @@ const SidebarLink = (props: SidebarLinkT) => { const Sidebar = () => { const team = usePathname()?.split('/')[1] + const { organisations, activeOrganisation, setActiveOrganisation } = + useContext(organisationContext) + + const showOrgsMenu = organisations === null ? false : organisations?.length > 1 + + const OrgsMenu = () => { + const router = useRouter() + const switchOrg = (org: OrganisationType) => { + router.push(`/${org!.name}`) + } + return ( + + {({ open }) => ( + <> + + {activeOrganisation?.name} + + + + +
+ {organisations?.map((org: OrganisationType) => ( + + {({ active }) => ( + + )} + + ))} +
+
+
+ + )} +
+ ) + } + const links: SidebarLinkT[] = [ { name: 'Home', href: `/${team}`, - icon: , + icon: , active: usePathname() === `/${team}`, }, { name: 'Apps', href: `/${team}/apps`, - icon: , - active: usePathname() === `/${team}/apps`, + icon: , + active: usePathname()?.split('/')[2] === 'apps', + }, + { + name: 'Members', + href: `/${team}/members`, + icon: , + active: usePathname() === `/${team}/members`, + }, + { + name: 'Personal access tokens', + href: `/${team}/tokens`, + icon: , + active: usePathname() === `/${team}/tokens`, }, { name: 'Settings', href: `/${team}/settings`, - icon: , + icon: , active: usePathname() === `/${team}/settings`, }, ] return ( - +
+ +
) } diff --git a/frontend/components/logs/KmsLogs.tsx b/frontend/components/logs/KmsLogs.tsx new file mode 100644 index 00000000..0b9e8923 --- /dev/null +++ b/frontend/components/logs/KmsLogs.tsx @@ -0,0 +1,339 @@ +'use client' + +import { GetAppKmsLogs } from '@/graphql/queries/getAppKmsLogs.gql' +import { useLazyQuery } from '@apollo/client' +import { KmsLogType } from '@/apollo/graphql' +import { Disclosure, Transition } from '@headlessui/react' +import clsx from 'clsx' +import { FaChevronRight } from 'react-icons/fa' +import { SiNodedotjs, SiPython } from 'react-icons/si' +import { FiRefreshCw, FiChevronsDown } from 'react-icons/fi' +import getUnicodeFlagIcon from 'country-flag-icons/unicode' +import { relativeTimeFromDates } from '@/utils/time' +import { humanFileSize } from '@/utils/dataUnits' +import { ReactNode, useEffect, useRef, useState } from 'react' +import { Button } from '@/components/common/Button' +import { Count } from 'reaviz' +import Spinner from '@/components/common/Spinner' + +// The historical start date for all log data (May 1st, 2023) +const LOGS_START_DATE = 1682904457000 + +export default function KMSLogs(props: { app: string }) { + const DEFAULT_PAGE_SIZE = 25 + const loglistEndRef = useRef(null) + const tableBodyRef = useRef(null) + const [getAppLogs, { data, loading }] = useLazyQuery(GetAppKmsLogs) + const [totalCount, setTotalCount] = useState(0) + const [logList, setLogList] = useState([]) + + const [endofList, setEndofList] = useState(false) + + const getCurrentTimeStamp = () => Date.now() + const getLastLogTimestamp = () => + logList.length > 0 ? logList[logList.length - 1].timestamp : getCurrentTimeStamp() + + /** + * Fetches logs for the app with the given start and end timestamps, + * and then adds the result of the query to the current log list. + * + * @param {number} start - Start datetime as unix timestamp (ms) + * @param {number} end - End datetime as unix timestamp (ms) + * + * @returns {void} + */ + const fetchLogs = (start: number, end: number) => { + getAppLogs({ + variables: { + appId: props.app, + start, + end, + }, + fetchPolicy: 'network-only', + }).then((result) => { + if (result.data?.logs.kms.length) { + setLogList(logList.concat(result.data.logs.kms)) + } + if (result.data?.logs.length < DEFAULT_PAGE_SIZE) setEndofList(true) + }) + } + + const clearLogList = () => setLogList([]) + + /** + * Gets the first page of logs, by resetting the log list and fetching logs using the current unix timestamp. + * + * @returns {void} + */ + const getFirstPage = () => { + setEndofList(false) + fetchLogs(LOGS_START_DATE, getCurrentTimeStamp()) + } + + /** + * Gets the new page of logs by using the last available timestamp from the current log list + * + * @returns {void} + */ + const getNextPage = () => { + fetchLogs(LOGS_START_DATE, getLastLogTimestamp()) + } + + /** + * Hook to get the first page of logs on page load, or when the loglist is reset to empty + */ + useEffect(() => { + if (logList.length === 0) getFirstPage() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.app, logList]) + + /** + * Hook to update the log count once its available + */ + useEffect(() => { + if (data?.kmsLogsCount) setTotalCount(data.kmsLogsCount) + }, [data]) + + // useEffect(() => { + // const options = { + // root: null, + // rootMargin: '0px', + // threshold: 1.0, + // } + // const observer = new IntersectionObserver((entries) => { + // const [entry] = entries + // if (entry.isIntersecting) getNextPage() + // }, options) + + // if (loglistEndRef.current) { + // if (endofList) observer.unobserve(loglistEndRef.current) + // else observer.observe(loglistEndRef.current) + // } + + // return () => { + // if (loglistEndRef.current) observer.unobserve(loglistEndRef.current) + // } + + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [loglistEndRef]) + + const LogRow = (props: { log: KmsLogType }) => { + const { log } = props + + const SDKIcon = (sdkName: string) => { + const sdks = [ + { + name: 'node-js', + label: 'Node.js', + icon: , + color: 'bg-[#339933]', + }, + { + name: 'python', + label: 'Python', + icon: , + color: 'bg-[#3776AB]', + }, + ] + + const sdk = sdks.find((sdk) => sdkName.toLowerCase().includes(sdk.name)) || sdks[0] + + return ( +
+ {sdk.icon} +
+ ) + } + + const relativeTimeStamp = () => { + return relativeTimeFromDates(new Date(log.timestamp)) + } + + const verboseTimeStamp = () => { + const date = new Date(log.timestamp) + return date.toISOString() + } + + const LogField = (props: { label: string; children: ReactNode }) => { + return ( +
+ {props.label}: + {props.children} +
+ ) + } + + return ( + + {({ open }) => ( + <> + + {/* */} + + + + {SDKIcon(log.phaseNode!)} + {log.eventType} + + {humanFileSize(log.phSize!)} + + + {log.city} {log.country ? getUnicodeFlagIcon(log.country) : 'Not available'} + + + {relativeTimeStamp()} + + {/* */} + + + + +
+ Log ID: + {log.id} +
+
+ +
+ {SDKIcon(log.phaseNode!)} {log.phaseNode} +
+
+ + + {log.eventType} + + + {humanFileSize(log.phSize!)} + + {(log.city || log.country) && ( + + {' '} + {log.city}, {log.country}{' '} + {log.country ? getUnicodeFlagIcon(log.country) : 'Not available'} + + )} + + {log.ipAddress} + + {verboseTimeStamp()} +
+
+ +
+ + )} +
+ ) + } + + const SkeletonRow = (props: { rows: number }) => { + const SKELETON_BASE_STYLE = 'dark:bg-neutral-700 bg-neutral-300 animate-pulse' + return ( + <> + {[...Array(props.rows)].map((_, n) => ( + + + + + +
+ + +
+ + +
+ + +
+ + +
+ + + ))} + + ) + } + + return ( +
+
+ + {totalCount && } Events + + +
+ + + + + + + + + + + + + {logList.map((log, n) => ( + + ))} + {loading && } + + + + +
SDKEventDataLocationTime
+
+ {!endofList && ( + + )} + {endofList && `No${logList.length ? ' more ' : ' '}logs to show`} +
+
+
+ ) +} diff --git a/frontend/components/logs/SecretLogs.tsx b/frontend/components/logs/SecretLogs.tsx new file mode 100644 index 00000000..3680c2a2 --- /dev/null +++ b/frontend/components/logs/SecretLogs.tsx @@ -0,0 +1,431 @@ +'use client' + +import { GetAppSecretsLogs } from '@/graphql/queries/secrets/getAppSecretsLogs.gql' +import { useLazyQuery } from '@apollo/client' +import { + ApiSecretEventEventTypeChoices, + EnvironmentKeyType, + KmsLogType, + SecretEventType, +} from '@/apollo/graphql' +import { Disclosure, Transition } from '@headlessui/react' +import clsx from 'clsx' +import { FaChevronRight, FaExternalLinkAlt, FaKey } from 'react-icons/fa' +import { SiNodedotjs, SiPython } from 'react-icons/si' +import { FiRefreshCw, FiChevronsDown } from 'react-icons/fi' +import getUnicodeFlagIcon from 'country-flag-icons/unicode' +import { dateToUnixTimestamp, relativeTimeFromDates } from '@/utils/time' +import { humanFileSize } from '@/utils/dataUnits' +import { ReactNode, useContext, useEffect, useRef, useState } from 'react' +import { Button } from '@/components/common/Button' +import { Count } from 'reaviz' +import Spinner from '@/components/common/Spinner' +import { Avatar } from '../common/Avatar' +import { EnvKeyring, envKeyring } from '@/utils/environments' +import { KeyringContext } from '@/contexts/keyringContext' +import { getUserKxPublicKey, getUserKxPrivateKey, decryptAsymmetric } from '@/utils/crypto' +import { organisationContext } from '@/contexts/organisationContext' +import UnlockKeyringDialog from '../auth/UnlockKeyringDialog' +import { usePathname } from 'next/navigation' +import Link from 'next/link' + +// The historical start date for all log data (May 1st, 2023) +const LOGS_START_DATE = 1682904457000 + +type EnvKey = { + envId: string + keys: EnvKeyring +} + +export default function SecretLogs(props: { app: string }) { + const DEFAULT_PAGE_SIZE = 25 + const loglistEndRef = useRef(null) + const tableBodyRef = useRef(null) + const [getAppLogs, { data, loading }] = useLazyQuery(GetAppSecretsLogs) + const [totalCount, setTotalCount] = useState(0) + const [logList, setLogList] = useState([]) + const [envKeys, setEnvKeys] = useState([]) + const [endofList, setEndofList] = useState(false) + + const { activeOrganisation: organisation } = useContext(organisationContext) + const { keyring } = useContext(KeyringContext) + + const getCurrentTimeStamp = () => Date.now() + const getLastLogTimestamp = () => + logList.length > 0 + ? dateToUnixTimestamp(logList[logList.length - 1].timestamp) + : getCurrentTimeStamp() + + /** + * Fetches logs for the app with the given start and end timestamps, + * and then adds the result of the query to the current log list. + * + * @param {number} start - Start datetime as unix timestamp (ms) + * @param {number} end - End datetime as unix timestamp (ms) + * + * @returns {void} + */ + const fetchLogs = (start: number, end: number) => { + getAppLogs({ + variables: { + appId: props.app, + start, + end, + }, + fetchPolicy: 'network-only', + }).then((result) => { + if (result.data?.logs.secrets.length) { + setLogList(logList.concat(result.data.logs.secrets)) + } + if (result.data?.logs.length < DEFAULT_PAGE_SIZE) setEndofList(true) + }) + } + + const clearLogList = () => setLogList([]) + + /** + * Gets the first page of logs, by resetting the log list and fetching logs using the current unix timestamp. + * + * @returns {void} + */ + const getFirstPage = () => { + setEndofList(false) + fetchLogs(LOGS_START_DATE, getCurrentTimeStamp()) + } + + /** + * Gets the new page of logs by using the last available timestamp from the current log list + * + * @returns {void} + */ + const getNextPage = () => { + fetchLogs(LOGS_START_DATE, getLastLogTimestamp()) + } + + /** + * Hook to get the first page of logs on page load, or when the loglist is reset to empty + */ + useEffect(() => { + if (logList.length === 0) getFirstPage() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.app, logList]) + + /** + * Hook to update the log count once its available + */ + useEffect(() => { + if (data?.secretsLogsCount) setTotalCount(data.secretsLogsCount) + }, [data]) + + useEffect(() => { + const initEnvKeys = async () => { + const keys = [] as EnvKey[] + + const unwrapKeyPromises = data.environmentKeys.map(async (envKey: EnvironmentKeyType) => { + const { wrappedSeed } = envKey + + const userKxKeys = { + publicKey: await getUserKxPublicKey(keyring!.publicKey), + privateKey: await getUserKxPrivateKey(keyring!.privateKey), + } + const seed = await decryptAsymmetric( + wrappedSeed, + userKxKeys.privateKey, + userKxKeys.publicKey + ) + + const { publicKey, privateKey } = await envKeyring(seed) + + keys.push({ + envId: envKey.environment.id, + keys: { publicKey, privateKey }, + }) + }) + + await Promise.all(unwrapKeyPromises) + + setEnvKeys(keys) + } + + if (data && keyring) initEnvKeys() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, keyring]) + + // useEffect(() => { + // const options = { + // root: null, + // rootMargin: '0px', + // threshold: 1.0, + // } + // const observer = new IntersectionObserver((entries) => { + // const [entry] = entries + // if (entry.isIntersecting) getNextPage() + // }, options) + + // if (loglistEndRef.current) { + // if (endofList) observer.unobserve(loglistEndRef.current) + // else observer.observe(loglistEndRef.current) + // } + + // return () => { + // if (loglistEndRef.current) observer.unobserve(loglistEndRef.current) + // } + + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [loglistEndRef]) + + const LogRow = (props: { log: SecretEventType }) => { + const { log } = props + + const appPath = usePathname()?.split('/').slice(0, -1).join('/') + + const [decryptedEvent, setDecryptedEvent] = useState(null) + + useEffect(() => { + const decryptSecretEvent = async () => { + const event = log + + const decryptedEvent = structuredClone(event) + + const envKeyPair = envKeys.find((envKey) => envKey.envId === event.environment.id) + + const { publicKey, privateKey } = envKeyPair!.keys + + // Decrypt event fields + decryptedEvent!.key = await decryptAsymmetric(event!.key, privateKey, publicKey) + + // decryptedEvent!.value = await decryptAsymmetric(event!.value, privateKey, publicKey) + + // if (decryptedEvent!.comment !== '') { + // decryptedEvent!.comment = await decryptAsymmetric(event!.comment, privateKey, publicKey) + // } + + setDecryptedEvent(decryptedEvent) + } + + if (log && envKeys.length > 0) decryptSecretEvent() + }, [log]) + + const relativeTimeStamp = () => { + return relativeTimeFromDates(new Date(log.timestamp)) + } + + const verboseTimeStamp = () => { + const date = new Date(log.timestamp) + return date.toISOString() + } + + const LogField = (props: { label: string; children: ReactNode }) => { + return ( +
+ {props.label}: + {props.children} +
+ ) + } + + const getEventTypeColor = (eventType: ApiSecretEventEventTypeChoices) => { + if (eventType === ApiSecretEventEventTypeChoices.C) return 'bg-emerald-500' + if (eventType === ApiSecretEventEventTypeChoices.U) return 'bg-yellow-500' + if (eventType === ApiSecretEventEventTypeChoices.R) return 'bg-blue-500' + if (eventType === ApiSecretEventEventTypeChoices.D) return 'bg-red-500' + } + + const getEventTypeText = (eventType: ApiSecretEventEventTypeChoices) => { + if (eventType === ApiSecretEventEventTypeChoices.C) return 'Created secret' + if (eventType === ApiSecretEventEventTypeChoices.U) return 'Updated secret' + if (eventType === ApiSecretEventEventTypeChoices.R) return 'Read secret' + if (eventType === ApiSecretEventEventTypeChoices.D) return 'Deleted secret' + } + + return ( + + {({ open }) => ( + <> + + {/* */} + + + + +
+ {log.user ? ( +
+ + {log.user.fullName || log.user.email} +
+ ) : ( +
+ Service token +
+ )} +
+ + +
+ +
+ {getEventTypeText(log.eventType)} +
+
+ + {log.environment.envType} + {decryptedEvent?.key} + + {relativeTimeStamp()} + + {/* */} +
+ + + +
+ Event ID: + {log.id} +
+ +
+ +
{decryptedEvent?.key}
+
+ + {log.ipAddress} + + {log.userAgent} + + {verboseTimeStamp()} + +
+ +
+
+
+ +
+ + )} +
+ ) + } + + const SkeletonRow = (props: { rows: number }) => { + const SKELETON_BASE_STYLE = 'dark:bg-neutral-700 bg-neutral-300 animate-pulse' + return ( + <> + {[...Array(props.rows)].map((_, n) => ( + + + + + +
+ + +
+ + +
+ + +
+ + +
+ + + ))} + + ) + } + + return ( + <> + {organisation && } +
+
+ + {totalCount && } Events + + +
+ + + + + + + + + + + + + {logList.map((log, n) => ( + + ))} + {loading && } + + + + +
UserEventEnvironmentSecretTime
+
+ {!endofList && ( + + )} + {endofList && `No${logList.length ? ' more ' : ' '}logs to show`} +
+
+
+ + ) +} diff --git a/frontend/components/onboarding/AccountPassword.tsx b/frontend/components/onboarding/AccountPassword.tsx index f567645c..da503097 100644 --- a/frontend/components/onboarding/AccountPassword.tsx +++ b/frontend/components/onboarding/AccountPassword.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { ZXCVBNResult } from 'zxcvbn' -import { FaEye, FaEyeSlash, FaInfo } from 'react-icons/fa' +import { FaCheck, FaEye, FaEyeSlash, FaInfo } from 'react-icons/fa' +import clsx from 'clsx' interface AccountPasswordProps { pw: string @@ -27,23 +28,23 @@ export const AccountPassword = (props: AccountPasswordProps) => { * @returns {string} */ const pwStrengthColor = (): string => { - let color = 'red' + let color = 'bg-red-500' if (!pw) return color switch (pwStrength.score) { case 1: - color = 'orange' + color = 'bg-orange-500' break case 2: - color = 'yellow' + color = 'bg-yellow-500' break case 3: - color = 'blue' + color = 'bg-blue-500' break case 4: - color = 'green' + color = 'bg-emerald-500' break default: - color = 'red' + color = 'bg-red-500' break } @@ -56,66 +57,73 @@ export const AccountPassword = (props: AccountPasswordProps) => { return score } + const passwordIsStrong = pwStrength?.feedback?.suggestions?.length == 0 || false + return (
- -
- setPw(e.target.value)} - type={showPw ? 'text' : 'password'} - minLength={16} - required - className="w-full " - /> - +
+ +
+ setPw(e.target.value)} + type={showPw ? 'text' : 'password'} + minLength={16} + required + className="w-full " + /> + +
- -
- setPw2(e.target.value)} - type={showPw2 ? 'text' : 'password'} - minLength={16} - required - className="w-full" - /> - + +
+ +
+ setPw2(e.target.value)} + type={showPw2 ? 'text' : 'password'} + minLength={16} + required + className="w-full" + /> + +
-
+ +
- {pwStrength.feedback && ( -
- - {pwStrength.feedback.suggestions} -
- )} + +
+ {passwordIsStrong ? : } + {passwordIsStrong ? 'Strong password' : pwStrength?.feedback?.suggestions} +
) diff --git a/frontend/components/onboarding/AccountSeedChecker.tsx b/frontend/components/onboarding/AccountSeedChecker.tsx index 56fdbd58..e0a803b2 100644 --- a/frontend/components/onboarding/AccountSeedChecker.tsx +++ b/frontend/components/onboarding/AccountSeedChecker.tsx @@ -30,7 +30,7 @@ export const AccountSeedChecker = (props: AccountSeedCheckProps) => { //maxLength={25} className={clsx( 'font-mono w-full', - isCorrect(index) && '!bg-emerald-400/20 !text-emerald-500 border' + isCorrect(index) && '!bg-emerald-400/20 !text-emerald-500' )} /> {isCorrect(index) && ( diff --git a/frontend/components/onboarding/AccountSeedGen.tsx b/frontend/components/onboarding/AccountSeedGen.tsx index 0dc4d6be..bf02dffa 100644 --- a/frontend/components/onboarding/AccountSeedGen.tsx +++ b/frontend/components/onboarding/AccountSeedGen.tsx @@ -14,7 +14,7 @@ export const AccountSeedGen = (props: { mnemonic: string }) => {