From ad59872aad01a83962c164338e8ccbeff56cd43f Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Wed, 30 Oct 2024 18:53:21 -0700 Subject: [PATCH 01/38] Migrate from Auth0 to Jetstream Auth Remove 3rd party authentication provider in favor of Jetstream in-house Authentication Add support for MFA which is required by default - initial support for email and authenticator app Tighten upp session management to ensure that sessions are being used on the same device Add profile management with options to add a password, manage MFA options, link social accounts Added captcha via Cloudflare turnstile to authentication pages Added email templates and sending vie react email Added rate limiting to all authentication routes Added zod validation to environment variable parsing Added E2E tests for all authentication pages --- .env.example | 92 +- .github/workflows/ci.yml | 19 +- .gitignore | 3 +- Dockerfile | 4 + README.md | 18 +- apps/api/.env.development | 21 - apps/api/.env.production | 4 - .../src/app/controllers/auth.controller.ts | 886 +++++++++-- .../jetstream-organizations.controller.ts | 2 +- .../src/app/controllers/oauth.controller.ts | 13 +- .../src/app/controllers/orgs.controller.ts | 6 +- .../src/app/controllers/socket.controller.ts | 4 +- .../src/app/controllers/user.controller.ts | 516 ++++--- apps/api/src/app/db/organization.db.ts | 12 +- apps/api/src/app/db/salesforce-org.db.ts | 81 +- apps/api/src/app/db/transactions.db.ts | 4 +- apps/api/src/app/db/user.db.ts | 149 +- apps/api/src/app/routes/api.routes.ts | 25 +- apps/api/src/app/routes/auth.routes.ts | 58 + apps/api/src/app/routes/index.ts | 3 +- apps/api/src/app/routes/oauth.routes.ts | 40 - apps/api/src/app/routes/route.middleware.ts | 181 ++- apps/api/src/app/routes/test.routes.ts | 98 +- apps/api/src/app/services/auth0.ts | 135 -- .../api/src/app/services/comtd/cometd-init.ts | 4 +- apps/api/src/app/services/oauth.service.ts | 4 +- apps/api/src/app/services/worker-jobs.ts | 30 - apps/api/src/app/types/types.ts | 8 + apps/api/src/app/utils/auth-utils.ts | 62 - apps/api/src/app/utils/error-handler.ts | 26 +- apps/api/src/app/utils/response.handlers.ts | 99 +- apps/api/src/app/utils/route.utils.ts | 29 +- apps/api/src/app/utils/socket-utils.ts | 8 +- apps/api/src/main.ts | 173 +-- apps/api/tsconfig.json | 1 + apps/cron-tasks/src/config/env-config.ts | 9 - .../src/utils/amplitude-dashboard-api.ts | 4 +- apps/cron-tasks/src/utils/auth0.ts | 108 -- apps/cron-tasks/src/utils/cron-utils.ts | 3 +- apps/docs/docs/getting-started/overview.mdx | 2 +- apps/jetstream-e2e/playwright.config.ts | 29 +- .../src/fixtures/ApiRequestUtils.ts | 31 +- apps/jetstream-e2e/src/fixtures/fixtures.ts | 69 +- .../AuthenticationPage.model.ts | 326 ++++ .../LoadSingleObjectPage.model.ts | 6 +- .../LoadWithoutFilePage.model.ts | 6 +- .../src/pageObjectModels/OrganizationsPage.ts | 118 ++ .../PlatformEventPage.model.ts | 6 +- .../pageObjectModels/PlaywrightPage.model.ts | 21 +- .../src/pageObjectModels/QueryPage.model.ts | 9 +- apps/jetstream-e2e/src/setup/global-setup.ts | 16 - apps/jetstream-e2e/src/setup/global.setup.ts | 43 + .../src/setup/global.teardown.ts | 56 + .../src/tests/api/bulk-query-20.api.spec.ts | 4 + .../src/tests/api/bulk.api.spec.ts | 4 + .../src/tests/api/metadata-apex.api.spec.ts | 4 + .../src/tests/api/metadata.api.spec.ts | 4 + .../src/tests/api/misc.api.spec.ts | 4 + .../src/tests/api/query.api.spec.ts | 4 + .../src/tests/api/record.api.spec.ts | 4 + .../auth-form-navigation.spec.ts | 174 +++ .../auth-session-management.spec.ts | 15 + .../src/tests/authentication/login1.spec.ts | 68 + .../src/tests/authentication/login2.spec.ts | 120 ++ .../jetstream-e2e/src/tests/orgs/orgs.spec.ts | 67 + .../src/tests/security/security.spec.ts | 116 ++ .../src/utils/database-validation.utils.ts | 35 + .../environments/environment.prod.ts | 1 - .../environments/environment.test.ts | 1 - .../environments/environment.ts | 1 - .../src/controllers/extension.routes.ts | 21 +- .../jetstream-web-extension/webpack.config.js | 1 - apps/jetstream/src/app/AppRoutes.tsx | 4 +- apps/jetstream/src/app/app.tsx | 12 +- .../app/components/profile/2fa/Profile2fa.tsx | 57 + .../profile/2fa/Profile2faEmail.tsx | 82 + .../components/profile/2fa/Profile2faOtp.tsx | 177 +++ .../src/app/components/profile/Profile.tsx | 212 +++ .../profile/ProfileIdentityCard.tsx | 106 ++ .../profile/ProfileLinkedAccounts.tsx | 93 ++ .../profile/ProfileUserPassword.tsx | 195 +++ .../ProfileUserProfile.tsx} | 30 +- .../profile/session/ProfileSessionItem.tsx | 91 ++ .../profile/session/ProfileSessions.tsx | 129 ++ .../app/components/profile/useLinkAccount.ts | 42 + .../src/app/components/settings/Settings.tsx | 107 +- .../settings/SettingsDeleteAccount.tsx | 2 - .../settings/SettingsIdentityCard.tsx | 148 -- .../settings/SettingsLinkedAccounts.tsx | 47 - .../app/components/settings/useLinkAccount.ts | 61 - .../src/environments/environment.prod.ts | 1 - .../src/environments/environment.test.ts | 1 - .../jetstream/src/environments/environment.ts | 1 - apps/jetstream/src/main.scss | 4 + apps/landing/components/Alert.tsx | 73 + .../components/ErrorQueryParamErrorBanner.tsx | 37 + apps/landing/components/Footer.tsx | 6 +- apps/landing/components/Modal.tsx | 4 +- apps/landing/components/Navigation.tsx | 328 ++-- apps/landing/components/auth/Captcha.tsx | 42 + .../components/auth/ForgotPasswordLink.tsx | 12 + .../landing/components/auth/LoginOrSignUp.tsx | 280 ++++ .../components/auth/LoginOrSignUpWrapper.tsx | 58 + .../components/auth/PasswordResetInit.tsx | 149 ++ .../components/auth/PasswordResetVerify.tsx | 157 ++ .../components/auth/RegisterOrSignUpLink.tsx | 24 + .../components/auth/ShowPasswordButton.tsx | 12 + .../components/auth/VerifyEmailOr2fa.tsx | 238 +++ .../auth/VerifyEmailOr2faWrapper.tsx | 40 + apps/landing/components/form/Checkbox.tsx | 21 + apps/landing/components/form/Input.tsx | 50 + .../components/layouts/AuthPageLayout.tsx | 24 + apps/landing/components/layouts/Layout.tsx | 43 + .../landing/components/layouts/LayoutHead.tsx | 56 + .../components/new/ConnectWithTeam.tsx | 2 +- apps/landing/components/new/HeaderCta.tsx | 11 +- apps/landing/components/new/SupportCta.tsx | 4 +- apps/landing/hooks/auth.hooks.ts | 86 ++ apps/landing/next.config.js | 17 + apps/landing/pages/404.tsx | 71 +- apps/landing/pages/_app.js | 7 +- apps/landing/pages/about/index.tsx | 107 +- apps/landing/pages/auth/login/index.tsx | 14 + .../pages/auth/password-reset/index.tsx | 32 + .../auth/password-reset/verify/index.tsx | 50 + apps/landing/pages/auth/signup/index.tsx | 14 + apps/landing/pages/auth/verify/index.tsx | 14 + apps/landing/pages/blog/index.tsx | 98 +- apps/landing/pages/goodbye/index.tsx | 98 +- apps/landing/pages/index.tsx | 71 +- apps/landing/pages/oauth-link/index.tsx | 92 +- apps/landing/pages/privacy/index.tsx | 357 ++--- apps/landing/pages/subprocessors/index.tsx | 103 +- apps/landing/pages/terms-of-service/index.tsx | 490 +++--- apps/landing/tailwind.config.js | 13 + apps/landing/utils/environment.ts | 38 + apps/landing/utils/types.ts | 7 + docker-compose.e2e.yml | 7 - docker-compose.yml | 8 +- libs/api-config/src/index.ts | 1 + libs/api-config/src/lib/api-db-config.ts | 10 +- libs/api-config/src/lib/api-logger.ts | 15 +- .../src/lib/api-rate-limit.config.ts | 19 + libs/api-config/src/lib/api-rollbar-config.ts | 6 +- libs/api-config/src/lib/api-telemetry.ts | 8 +- libs/api-config/src/lib/email.config.ts | 56 +- libs/api-config/src/lib/env-config.ts | 253 ++-- libs/api-types/src/lib/api-user.types.ts | 21 - libs/auth/server/.eslintrc.json | 18 + libs/auth/server/README.md | 7 + libs/auth/server/jest.config.ts | 11 + libs/auth/server/project.json | 16 + libs/auth/server/src/index.ts | 5 + libs/auth/server/src/lib/OauthClients.ts | 86 ++ .../src/lib/__tests__/auth.utils.spec.ts | 180 +++ .../server/src/lib/auth-logging.db.service.ts | 87 ++ libs/auth/server/src/lib/auth.db.service.ts | 865 +++++++++++ libs/auth/server/src/lib/auth.errors.ts | 93 ++ libs/auth/server/src/lib/auth.service.ts | 272 ++++ libs/auth/server/src/lib/auth.utils.ts | 189 +++ libs/auth/server/tsconfig.json | 22 + libs/auth/server/tsconfig.lib.json | 11 + libs/auth/server/tsconfig.spec.json | 9 + libs/auth/types/.eslintrc.json | 18 + libs/auth/types/README.md | 3 + libs/auth/types/project.json | 9 + libs/auth/types/src/index.ts | 1 + libs/auth/types/src/lib/auth-types.ts | 275 ++++ libs/auth/types/tsconfig.json | 19 + libs/auth/types/tsconfig.lib.json | 10 + libs/email/.babelrc | 10 + libs/email/.eslintrc.json | 18 + libs/email/jest.config.ts | 11 + libs/email/project.json | 16 + libs/email/src/index.ts | 1 + libs/email/src/lib/components/EmailFooter.tsx | 62 + .../AuthenticationChangeConfirmationEmail.tsx | 62 + .../lib/email-templates/auth/GenericEmail.tsx | 75 + .../auth/PasswordResetConfirmationEmail.tsx | 33 + .../auth/PasswordResetEmail.tsx | 70 + .../auth/TwoStepVerificationEmail.tsx | 46 + .../lib/email-templates/auth/VerifyEmail.tsx | 53 + .../lib/email-templates/auth/WelcomeEmail.tsx | 245 +++ libs/email/src/lib/email.tsx | 168 +++ libs/email/src/lib/shared-styles.ts | 102 ++ libs/email/tsconfig.json | 24 + libs/email/tsconfig.lib.json | 10 + libs/email/tsconfig.spec.json | 9 + .../src/lib/OrganizationCard.tsx | 1 + .../lib/OrganizationCardNoOrganization.tsx | 1 + .../src/lib/SalesforceOrgCardDraggable.tsx | 1 + libs/icon-factory/src/lib/icon-factory.tsx | 10 + .../constants/src/lib/shared-constants.ts | 3 + .../data/src/lib/client-data-data-helper.ts | 5 +- libs/shared/data/src/lib/client-data.ts | 65 +- libs/shared/node-utils/src/index.ts | 1 + .../node-utils/src/lib/AsyncIntervalTimer.ts | 54 + .../node-utils/src/lib/shared-node-utils.ts | 15 +- libs/shared/ui-core/src/analytics.tsx | 15 +- libs/shared/ui-core/src/app/HeaderNavbar.tsx | 36 +- libs/shared/ui-core/src/app/app-routes.ts | 6 + .../ui-core/src/state-management/app-state.ts | 20 +- libs/shared/ui-utils/src/index.ts | 1 + .../ui-utils/src/lib/hooks/useCsrfToken.ts | 25 + .../ui-utils/src/lib/hooks/useRollbar.ts | 4 +- libs/types/src/lib/types.ts | 107 +- libs/ui/src/lib/form/dropdown/DropDown.tsx | 3 + libs/ui/src/lib/form/input/Input.tsx | 4 +- libs/ui/src/lib/layout/layout.stories.tsx | 22 +- package.json | 46 +- .../migration.sql | 136 ++ .../migration.sql | 11 + prisma/schema.prisma | 136 +- scripts/generate.env.mjs | 92 ++ tsconfig.base.json | 3 + yarn.lock | 1320 ++++++++++++++--- 216 files changed, 11835 insertions(+), 3313 deletions(-) delete mode 100644 apps/api/.env.development create mode 100644 apps/api/src/app/routes/auth.routes.ts delete mode 100644 apps/api/src/app/services/auth0.ts delete mode 100644 apps/api/src/app/services/worker-jobs.ts delete mode 100644 apps/api/src/app/utils/auth-utils.ts delete mode 100644 apps/cron-tasks/src/utils/auth0.ts create mode 100644 apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts create mode 100644 apps/jetstream-e2e/src/pageObjectModels/OrganizationsPage.ts delete mode 100644 apps/jetstream-e2e/src/setup/global-setup.ts create mode 100644 apps/jetstream-e2e/src/setup/global.setup.ts create mode 100644 apps/jetstream-e2e/src/setup/global.teardown.ts create mode 100644 apps/jetstream-e2e/src/tests/authentication/auth-form-navigation.spec.ts create mode 100644 apps/jetstream-e2e/src/tests/authentication/auth-session-management.spec.ts create mode 100644 apps/jetstream-e2e/src/tests/authentication/login1.spec.ts create mode 100644 apps/jetstream-e2e/src/tests/authentication/login2.spec.ts create mode 100644 apps/jetstream-e2e/src/tests/orgs/orgs.spec.ts create mode 100644 apps/jetstream-e2e/src/tests/security/security.spec.ts create mode 100644 apps/jetstream-e2e/src/utils/database-validation.utils.ts create mode 100644 apps/jetstream/src/app/components/profile/2fa/Profile2fa.tsx create mode 100644 apps/jetstream/src/app/components/profile/2fa/Profile2faEmail.tsx create mode 100644 apps/jetstream/src/app/components/profile/2fa/Profile2faOtp.tsx create mode 100644 apps/jetstream/src/app/components/profile/Profile.tsx create mode 100644 apps/jetstream/src/app/components/profile/ProfileIdentityCard.tsx create mode 100644 apps/jetstream/src/app/components/profile/ProfileLinkedAccounts.tsx create mode 100644 apps/jetstream/src/app/components/profile/ProfileUserPassword.tsx rename apps/jetstream/src/app/components/{settings/SettingsUserProfile.tsx => profile/ProfileUserProfile.tsx} (72%) create mode 100644 apps/jetstream/src/app/components/profile/session/ProfileSessionItem.tsx create mode 100644 apps/jetstream/src/app/components/profile/session/ProfileSessions.tsx create mode 100644 apps/jetstream/src/app/components/profile/useLinkAccount.ts delete mode 100644 apps/jetstream/src/app/components/settings/SettingsIdentityCard.tsx delete mode 100644 apps/jetstream/src/app/components/settings/SettingsLinkedAccounts.tsx delete mode 100644 apps/jetstream/src/app/components/settings/useLinkAccount.ts create mode 100644 apps/landing/components/Alert.tsx create mode 100644 apps/landing/components/ErrorQueryParamErrorBanner.tsx create mode 100644 apps/landing/components/auth/Captcha.tsx create mode 100644 apps/landing/components/auth/ForgotPasswordLink.tsx create mode 100644 apps/landing/components/auth/LoginOrSignUp.tsx create mode 100644 apps/landing/components/auth/LoginOrSignUpWrapper.tsx create mode 100644 apps/landing/components/auth/PasswordResetInit.tsx create mode 100644 apps/landing/components/auth/PasswordResetVerify.tsx create mode 100644 apps/landing/components/auth/RegisterOrSignUpLink.tsx create mode 100644 apps/landing/components/auth/ShowPasswordButton.tsx create mode 100644 apps/landing/components/auth/VerifyEmailOr2fa.tsx create mode 100644 apps/landing/components/auth/VerifyEmailOr2faWrapper.tsx create mode 100644 apps/landing/components/form/Checkbox.tsx create mode 100644 apps/landing/components/form/Input.tsx create mode 100644 apps/landing/components/layouts/AuthPageLayout.tsx create mode 100644 apps/landing/components/layouts/Layout.tsx create mode 100644 apps/landing/components/layouts/LayoutHead.tsx create mode 100644 apps/landing/hooks/auth.hooks.ts create mode 100644 apps/landing/pages/auth/login/index.tsx create mode 100644 apps/landing/pages/auth/password-reset/index.tsx create mode 100644 apps/landing/pages/auth/password-reset/verify/index.tsx create mode 100644 apps/landing/pages/auth/signup/index.tsx create mode 100644 apps/landing/pages/auth/verify/index.tsx create mode 100644 apps/landing/utils/environment.ts create mode 100644 libs/api-config/src/lib/api-rate-limit.config.ts create mode 100644 libs/auth/server/.eslintrc.json create mode 100644 libs/auth/server/README.md create mode 100644 libs/auth/server/jest.config.ts create mode 100644 libs/auth/server/project.json create mode 100644 libs/auth/server/src/index.ts create mode 100644 libs/auth/server/src/lib/OauthClients.ts create mode 100644 libs/auth/server/src/lib/__tests__/auth.utils.spec.ts create mode 100644 libs/auth/server/src/lib/auth-logging.db.service.ts create mode 100644 libs/auth/server/src/lib/auth.db.service.ts create mode 100644 libs/auth/server/src/lib/auth.errors.ts create mode 100644 libs/auth/server/src/lib/auth.service.ts create mode 100644 libs/auth/server/src/lib/auth.utils.ts create mode 100644 libs/auth/server/tsconfig.json create mode 100644 libs/auth/server/tsconfig.lib.json create mode 100644 libs/auth/server/tsconfig.spec.json create mode 100644 libs/auth/types/.eslintrc.json create mode 100644 libs/auth/types/README.md create mode 100644 libs/auth/types/project.json create mode 100644 libs/auth/types/src/index.ts create mode 100644 libs/auth/types/src/lib/auth-types.ts create mode 100644 libs/auth/types/tsconfig.json create mode 100644 libs/auth/types/tsconfig.lib.json create mode 100644 libs/email/.babelrc create mode 100644 libs/email/.eslintrc.json create mode 100644 libs/email/jest.config.ts create mode 100644 libs/email/project.json create mode 100644 libs/email/src/index.ts create mode 100644 libs/email/src/lib/components/EmailFooter.tsx create mode 100644 libs/email/src/lib/email-templates/auth/AuthenticationChangeConfirmationEmail.tsx create mode 100644 libs/email/src/lib/email-templates/auth/GenericEmail.tsx create mode 100644 libs/email/src/lib/email-templates/auth/PasswordResetConfirmationEmail.tsx create mode 100644 libs/email/src/lib/email-templates/auth/PasswordResetEmail.tsx create mode 100644 libs/email/src/lib/email-templates/auth/TwoStepVerificationEmail.tsx create mode 100644 libs/email/src/lib/email-templates/auth/VerifyEmail.tsx create mode 100644 libs/email/src/lib/email-templates/auth/WelcomeEmail.tsx create mode 100644 libs/email/src/lib/email.tsx create mode 100644 libs/email/src/lib/shared-styles.ts create mode 100644 libs/email/tsconfig.json create mode 100644 libs/email/tsconfig.lib.json create mode 100644 libs/email/tsconfig.spec.json create mode 100644 libs/shared/node-utils/src/lib/AsyncIntervalTimer.ts create mode 100644 libs/shared/ui-utils/src/lib/hooks/useCsrfToken.ts create mode 100644 prisma/migrations/20241024010915_add_user_authentication/migration.sql create mode 100644 prisma/migrations/20241024151025_change_unique_org_constraint/migration.sql create mode 100644 scripts/generate.env.mjs diff --git a/.env.example b/.env.example index 74f1b09bd..9f81280a0 100644 --- a/.env.example +++ b/.env.example @@ -2,66 +2,82 @@ ###### REQUIRED ###### ENVIRONMENT='development' -# Example key - not used in any real environments -JETSTREAM_SESSION_SECRET='15a845f36512d850dfd223af8809873c' + +# SFDC API VERSION TO USE +NX_SFDC_API_VERSION='61.0' + +# trace, debug (default), info, warn, error, fatal, silent +LOG_LEVEL='trace' + +# Session signing secret - minimum of 32 characters +# Generate using: `openssl rand -base64 32` +JETSTREAM_SESSION_SECRET='' +# Backup key to allow session rotation +JETSTREAM_SESSION_SECRET_BACKUP='' +# Auth secret - used to sign encrypt CSRF tokens for authentication pages +# Generate using: `openssl rand -base64 32` +JETSTREAM_AUTH_SECRET='' +# Secret used to encrypt OTP tokens for storage in the database +JETSTREAM_AUTH_OTP_SECRET='' + +# JETSTREAM URLS +# If developing, then these will be localhost +# If running locally but not developing the platform, use port `:3333` for all of these JETSTREAM_CLIENT_URL='http://localhost:4200/app' JETSTREAM_SERVER_DOMAIN='localhost:3333' JETSTREAM_SERVER_URL='http://localhost:3333' JETSTREAM_POSTGRES_DBURI='postgres://postgres@localhost:5432/postgres' -# trace, debug (default), info, warn, error, fatal, silent - determines how much server logging is done -LOG_LEVEL='trace' +# Used in landing page to redirect to the correct URL +# If running locally but not developing the platform, use port `:3333` for all of these +NEXT_PUBLIC_CLIENT_URL='http://localhost:4200/app' +NEXT_PUBLIC_SERVER_URL='http://localhost:3333' -# PLAYWRIGHT INTEGRATION TEST LOGIN -E2E_LOGIN_USERNAME='integration@jetstream.app.e2e' -E2E_LOGIN_PASSWORD='TODO' -E2E_LOGIN_URL='https://jetstream-e2e-dev-ed.develop.my.salesforce.com' +# OAUTH FOR LOGGING IN TO THE APP +# You can provide your own keys by creating a connected app in your dev or production org. +# Salesforce - Scopes: email, profile, openid +AUTH_SFDC_CLIENT_ID='3MVG9riCAn8HHkYWGpu4WgDxYOW_9snDbMX1MD9hZ5Hd9NZ4yIKUhecgKe.bLizoOuSZGUwL.214Oyhcfd..1' +AUTH_SFDC_CLIENT_SECRET='3DC73F32C7385596DF9625F914D96A2CADC68F074010D658C122A774A9EC6AA3' + +# Google - Scopes: email, profile, openid +AUTH_GOOGLE_CLIENT_ID='' +AUTH_GOOGLE_CLIENT_SECRET='' # SALESFORCE CONFIGURATION -# You can provide your own key by creating a connected app in your dev or production org. -# Ensure api, web, refresh_token scopes are included +# You can provide your own keys by creating a connected app in your dev or production org. +# Scopes: api, web, refresh_token SFDC_CALLBACK_URL='http://localhost:3333/oauth/sfdc/callback' -SFDC_CONSUMER_KEY='3MVG9tSqyyAXNH5ItQtuplEg40Ks_MLSG37L1PV.TLDjsCbdp7EDonFUW0csSDDrutnfuxKH5OKSXSbhiGPv5' -SFDC_CONSUMER_SECRET='F77C1B4AF03CF51B290A591766F4C430E3136949A636D4AA5339F8EB6A40052A' +SFDC_CONSUMER_KEY='3MVG9riCAn8HHkYWGpu4WgDxYOW_9snDbMX1MD9hZ5Hd9NZ4yIKUhecgKe.bLizoOuSZGUwL.214Oyhcfd..1' +SFDC_CONSUMER_SECRET='3DC73F32C7385596DF9625F914D96A2CADC68F074010D658C122A774A9EC6AA3' + +###### OPTIONAL ###### -# API VERSION TO USE -SFDC_API_VERSION='58.0' +# PLAYWRIGHT INTEGRATION TEST LOGIN +E2E_LOGIN_USERNAME='integration@jetstream.app.e2e' +E2E_LOGIN_PASSWORD='' +E2E_LOGIN_URL='https://jetstream-e2e-dev-ed.develop.my.salesforce.com' # If set to true, then authentication will be bypassed # You will use a test account instead of a real account - only works if running locally -EXAMPLE_USER_OVERRIDE=true - -# Auth0 configuration - Free public account, you can replace with your own if you want -AUTH0_CLIENT_ID='305Mn5azd97CZrHDf5SflQCZlEeEKfTU' -AUTH0_CLIENT_SECRET='CmOSq3HVhUVZhjmnlLy4IHk46E1XuhVXAxcx9Epjm38opRy-ycaBlJujkDlhL7zu' -AUTH0_DOMAIN='dev-ce6oji5b.us.auth0.com' -AUTH0_M2M_DOMAIN='dev-ce6oji5b.us.auth0.com' +EXAMPLE_USER_OVERRIDE='true' +EXAMPLE_USER_PASSWORD='EXAMPLE_123!' -# AUTH0 APPLICATION CONFIGURATION -# LOGO: https://getjetstream.app/assets/images/jetstream-logo.svg -# CALLBACK URL: http://localhost:3333/oauth/callback, http://localhost:3333/oauth/identity/link/callback, jetstream://localhost/oauth/callback -# LOGOUT URLS: https://staging.getjetstream.app, http://localhost:3333, jetstream://localhost/oauth/callback - -###### OPTIONAL ###### - -PRISMA_DEBUG='false' - -NX_PUBLIC_AUTH_AUDIENCE='http://getjetstream.app/app_metadata' NX_PUBLIC_ROLLBAR_KEY='' NX_PUBLIC_AMPLITUDE_KEY='' -# Used to save feedback as a github issue and run some build commands -# Also required for the release process -GITHUB_TOKEN='' - +# Credentials for sending emails +# If you are not using the example user, then you may need to configure this for MFA MAILGUN_API_KEY='' +JETSTREAM_EMAIL_DOMAIN='' +JETSTREAM_EMAIL_FROM_NAME='' +JETSTREAM_EMAIL_REPLY_TO='' # Used to generate blog when building landing page CONTENTFUL_HOST='cdn.contentful.com' CONTENTFUL_SPACE='' CONTENTFUL_TOKEN='' -# Required to use Google integration +# Required to use Google within application GOOGLE_APP_ID='' GOOGLE_API_KEY='' GOOGLE_CLIENT_ID='' @@ -79,3 +95,7 @@ ALGOLIA_API_KEY='' HONEYCOMB_ENABLED=false HONEYCOMB_API_KEY='' +# Nx 18 enables using plugins to infer targets by default +# This is disabled for existing workspaces to maintain compatibility +# For more info, see: https://nx.dev/concepts/inferred-tasks +NX_ADD_PLUGINS=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0451e6b8c..5e3b73910 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,6 @@ env: CONTENTFUL_TOKEN: ${{ secrets.CONTENTFUL_TOKEN }} NX_CLOUD_DISTRIBUTED_EXECUTION: false NX_PUBLIC_AMPLITUDE_KEY: ${{ secrets.NX_PUBLIC_AMPLITUDE_KEY }} - NX_PUBLIC_AUTH_AUDIENCE: http://getjetstream.app/app_metadata NX_PUBLIC_ROLLBAR_KEY: ${{ secrets.NX_PUBLIC_ROLLBAR_KEY }} jobs: @@ -63,31 +62,33 @@ jobs: runs-on: ubuntu-latest env: LOG_LEVEL: warn - AUTH0_CLIENT_ID: 'shxza1G0595Ut2htmAd3NfbMMsqelrE5' - AUTH0_CLIENT_SECRET: 'NOT-NEEDED' - AUTH0_DOMAIN: 'getjetstream-dev.us.auth0.com' - E2E_LOGIN_PASSWORD: ${{ secrets.E2E_LOGIN_PASSWORD }} E2E_LOGIN_URL: 'https://jetstream-e2e-dev-ed.develop.my.salesforce.com' E2E_LOGIN_USERNAME: 'integration@jetstream.app.e2e' + E2E_LOGIN_PASSWORD: ${{ secrets.E2E_LOGIN_PASSWORD }} EXAMPLE_USER_OVERRIDE: true + EXAMPLE_USER_PASSWORD: 'EXAMPLE_123!' GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} GOOGLE_APP_ID: ${{ secrets.GOOGLE_APP_ID }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} JETSTREAM_POSTGRES_DBURI: postgres://postgres:postgres@localhost:5432/postgres - JETSTREAM_SESSION_SECRET: ${{ secrets.JETSTREAM_SESSION_SECRET }} + JETSTREAM_SESSION_SECRET: '8e52194ce3b6650b93e95a5c40a705b2' + JETSTREAM_AUTH_SECRET: 'l26oD1TYqkJP/AZccmFwX2gPO45rG1qQuSXjVxRj9U/3' + JETSTREAM_AUTH_OTP_SECRET: 'pD0AwvBhZU5COntz97OBDAtonoEe/Z0lz5ulNFl4K04=' JETSTREAM_CLIENT_URL: http://localhost:3333/app JETSTREAM_SERVER_DOMAIN: localhost:3333 JETSTREAM_SERVER_URL: http://localhost:3333 + NEXT_PUBLIC_CLIENT_URL: 'http://localhost:4200/app' + NEXT_PUBLIC_SERVER_URL: 'http://localhost:3333' NX_PUBLIC_AMPLITUDE_KEY: ${{ secrets.NX_PUBLIC_AMPLITUDE_KEY }} - NX_PUBLIC_AUTH_AUDIENCE: http://getjetstream.app/app_metadata NX_CLOUD_DISTRIBUTED_EXECUTION: false NX_PUBLIC_ROLLBAR_KEY: ${{ secrets.NX_PUBLIC_ROLLBAR_KEY }} SFDC_CALLBACK_URL: http://localhost:3333/oauth/sfdc/callback SFDC_CONSUMER_KEY: ${{ secrets.SFDC_CONSUMER_KEY }} SFDC_CONSUMER_SECRET: ${{ secrets.SFDC_CONSUMER_SECRET }} - SFDC_ENC_KEY: ${{ secrets.SFDC_ENC_KEY }} - SFDC_API_VERSION: '58.0' + AUTH_SFDC_CLIENT_ID: ${{ secrets.SFDC_CONSUMER_KEY }} + AUTH_SFDC_CLIENT_SECRET: ${{ secrets.SFDC_CONSUMER_SECRET }} + SFDC_API_VERSION: '61.0' services: postgres: diff --git a/.gitignore b/.gitignore index f1f39f77d..421f1fd13 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,5 @@ package-lock.json **/playwright/.cache .nx/cache -.nx/workspace-data \ No newline at end of file +.nx/workspace-data +**/playwright/.auth/user.json diff --git a/Dockerfile b/Dockerfile index 1783aedfd..5481bab90 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,10 @@ RUN yarn build:core && \ RUN yarn install --production=true && \ yarn add cross-env npm-run-all --save-dev +# FIXME: figure out why this is not included +# Add missing dependencies +RUN yarn add @react-email/components + # Final stage for app image FROM base diff --git a/README.md b/README.md index 4b2dd7b1c..e96507273 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,17 @@ This project was generated using [Nx](https://nx.dev) - This repository is consi 2. If you are using docker, make sure you have Docker installed. 3. If you want to run the dev server, make sure you have yarn version 1 installed. +### Installing Dependencies + +### Setting up your environment + +Run this script to copy `.env.example` to `.env` which will generate encryption keys which are required to run the application. +You will be asked some questions which will determine some of the environment variables. + +```bash +yarn scripts:generate-env +``` + 📓 You can choose to skip authentication locally by setting the environment variable `EXAMPLE_USER_OVERRIDE=true`. This is set to true by default in the `.env.example` file. 🌟 To use this, don't click the login button, but instead just go to `http://localhost:3333/app` or `http://localhost:4200/app` (if running the react development server) directly. @@ -91,8 +102,9 @@ docker compose up - Jetstream will be running at `http://localhost:3333` - Postgres will be running on port `5555` if you wanted to connect to it locally. -- When you click "Login", you should immediately be logged in without having to sign in. - - You can set `EXAMPLE_USER_OVERRIDE` if you want to disable this behavior +- You can login with the `Example` user + - The username is `test@example.com` + - The password is contained in the `.env` file - If assets on the page don't load, do a hard refresh (hold cmd or shift and press refresh) - This might happen if you have re-built the image and the browser has cached the page with now missing resources. @@ -100,7 +112,7 @@ docker compose up Use this option if you want to contribute to the codebase. -Jetstream relies on a Postgres database, so you either need to [run Postgresql locally](https://www.postgresql.org/download/) or use a managed provider such as one from the list below. Optionally you can run jetstream in a Docker container which includes Postgresql. +Jetstream relies on a Postgres database, so you either need to [run Postgresql locally](https://www.postgresql.org/download/), in a docker container, or use a managed provider such as one from the list below. Optionally you can run jetstream in a Docker container which includes Postgresql. - [Render](https://render.com/) (Jetstream is hosted here) - [elephantsql](https://www.elephantsql.com/plans.html) diff --git a/apps/api/.env.development b/apps/api/.env.development deleted file mode 100644 index 78bccfbff..000000000 --- a/apps/api/.env.development +++ /dev/null @@ -1,21 +0,0 @@ -ENVIRONMENT="development" - -AUTH0_DOMAIN="getjetstream-dev.us.auth0.com" -AUTH0_M2M_DOMAIN="getjetstream-dev.us.auth0.com" - -CONTENTFUL_HOST="https://api.contentful.com" - -GOOGLE_REDIRECT_URI="http://localhost:3333/oauth/google/callback" - -HONEYCOMB_ENABLED=false - -JETSTREAM_CLIENT_URL="http://localhost:4200/app" -JETSTREAM_SERVER_DOMAIN="localhost:3333" -JETSTREAM_SERVER_URL="http://localhost:3333" - -NX_PUBLIC_AUTH_AUDIENCE="http://getjetstream.app/app_metadata" -NX_BRANCH="main" -NX_SFDC_API_VERSION="60.0" - -SFDC_API_VERSION="60.0" -SFDC_CALLBACK_URL="http://localhost:3333/oauth/sfdc/callback" diff --git a/apps/api/.env.production b/apps/api/.env.production index d15540737..f9542a4ff 100644 --- a/apps/api/.env.production +++ b/apps/api/.env.production @@ -1,8 +1,5 @@ ENVIRONMENT="production" -AUTH0_DOMAIN="auth.getjetstream.app" -AUTH0_M2M_DOMAIN="getjetstream.us.auth0.com" - CONTENTFUL_HOST="cdn.contentful.com" GOOGLE_REDIRECT_URI="https://getjetstream.app/oauth/google/callback" @@ -13,7 +10,6 @@ JETSTREAM_CLIENT_URL="https://getjetstream.app/app" JETSTREAM_SERVER_DOMAIN="getjetstream.app" JETSTREAM_SERVER_URL="https://getjetstream.app" -NX_PUBLIC_AUTH_AUDIENCE="http://getjetstream.app/app_metadata" NX_BRANCH="main" NX_SFDC_API_VERSION="61.0" diff --git a/apps/api/src/app/controllers/auth.controller.ts b/apps/api/src/app/controllers/auth.controller.ts index 565ebca6f..64ca26720 100644 --- a/apps/api/src/app/controllers/auth.controller.ts +++ b/apps/api/src/app/controllers/auth.controller.ts @@ -1,160 +1,770 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { ENV, getExceptionLog } from '@jetstream/api-config'; -import { UserProfileServer } from '@jetstream/types'; -import { NextFunction } from 'express'; -import { isString } from 'lodash'; -import * as passport from 'passport'; -import { URL } from 'url'; -import { hardDeleteUserAndOrgs } from '../db/transactions.db'; -import { createOrUpdateUser } from '../db/user.db'; -import { checkAuth } from '../routes/route.middleware'; -import { linkIdentity } from '../services/auth0'; -import { Request, Response } from '../types/types'; -import { AuthenticationError } from '../utils/error-handler'; -// import { sendWelcomeEmail } from '../services/worker-jobs'; - -export interface OauthLinkParams { - type: 'auth' | 'salesforce'; - error?: string; - message?: string; - clientUrl: string; - data?: string; +import { ENV, getExceptionLog, logger } from '@jetstream/api-config'; +import { + AuthError, + clearOauthCookies, + createRememberDevice, + createUserActivityFromReq, + createUserActivityFromReqWithError, + ensureAuthError, + ExpiredVerificationToken, + generatePasswordResetToken, + generateRandomCode, + generateRandomString, + getAuthorizationUrl, + getCookieConfig, + getTotpAuthenticationFactor, + handleSignInOrRegistration, + hasRememberDeviceRecord, + InvalidAction, + InvalidParameters, + InvalidProvider, + InvalidSession, + InvalidVerificationToken, + InvalidVerificationType, + linkIdentityToUser, + getProviders as listProviders, + resetUserPassword, + setUserEmailVerified, + validateCallback, + verify2faTotpOrThrow, + verifyCSRFFromRequestOrThrow, +} from '@jetstream/auth/server'; +import { OauthProviderType, OauthProviderTypeSchema, Provider, ProviderKeysSchema } from '@jetstream/auth/types'; +import { + sendAuthenticationChangeConfirmation, + sendEmailVerification, + sendPasswordReset, + sendVerificationCode, + sendWelcomeEmail, +} from '@jetstream/email'; +import { ensureBoolean } from '@jetstream/shared/utils'; +import { parse as parseCookie } from 'cookie'; +import { addMinutes } from 'date-fns'; +import { z } from 'zod'; +import { Request } from '../types/types'; +import { redirect, sendJson, setCsrfCookie } from '../utils/response.handlers'; +import { createRoute } from '../utils/route.utils'; + +export const routeDefinition = { + logout: { + controllerFn: () => logout, + validators: { + hasSourceOrg: false, + }, + }, + getProviders: { + controllerFn: () => getProviders, + validators: { + hasSourceOrg: false, + }, + }, + getCsrfToken: { + controllerFn: () => getCsrfToken, + validators: { + query: z.record(z.any()), + hasSourceOrg: false, + }, + }, + getSession: { + controllerFn: () => getSession, + validators: { + hasSourceOrg: false, + }, + }, + signin: { + controllerFn: () => signin, + validators: { + params: z.object({ provider: OauthProviderTypeSchema }), + query: z.object({ returnUrl: z.string().nullish(), isAccountLink: z.literal('true').nullish() }), + body: z.object({ captchaToken: z.string().nullish(), csrfToken: z.string(), callbackUrl: z.string().url() }), + hasSourceOrg: false, + }, + }, + callback: { + controllerFn: () => callback, + validators: { + query: z.record(z.any()), + params: z.object({ provider: ProviderKeysSchema }), + body: z.union([ + z.discriminatedUnion('action', [ + z.object({ + action: z.literal('login'), + csrfToken: z.string(), + captchaToken: z.string().nullish(), + email: z.string().email().min(5).max(255), + password: z.string().min(8).max(255), + }), + z.object({ + action: z.literal('register'), + csrfToken: z.string(), + captchaToken: z.string().nullish(), + email: z.string().email().min(5).max(255), + name: z.string().min(1).max(255).trim(), + password: z.string().min(8).max(255), + }), + ]), + z.object({}).nullish(), + ]), + hasSourceOrg: false, + }, + }, + verification: { + controllerFn: () => verification, + validators: { + body: z.object({ + csrfToken: z.string(), + captchaToken: z.string().nullish(), + rememberDevice: z + .union([z.enum(['true', 'false']), z.boolean()]) + .nullish() + .transform(ensureBoolean), + code: z.string(), + type: z.enum(['email', '2fa-otp', '2fa-email']), + }), + hasSourceOrg: false, + }, + }, + verifyEmailViaLink: { + controllerFn: () => verifyEmailViaLink, + validators: { + query: z.object({ + type: z.literal('email'), + code: z.string(), + }), + hasSourceOrg: false, + }, + }, + resendVerification: { + controllerFn: () => resendVerification, + validators: { + body: z.object({ captchaToken: z.string().nullish(), csrfToken: z.string(), type: z.enum(['email', '2fa-email']) }), + hasSourceOrg: false, + }, + }, + requestPasswordReset: { + controllerFn: () => requestPasswordReset, + validators: { + body: z.object({ captchaToken: z.string().nullish(), email: z.string(), csrfToken: z.string() }), + hasSourceOrg: false, + }, + }, + validatePasswordReset: { + controllerFn: () => validatePasswordReset, + validators: { + body: z.object({ + email: z.string().email(), + token: z.string(), + password: z.string(), + csrfToken: z.string(), + captchaToken: z.string().nullish(), + }), + hasSourceOrg: false, + }, + }, +}; + +function initSession( + req: Request, + { user, isNewUser, verificationRequired, provider }: Awaited> +) { + const userAgent = req.get('User-Agent'); + if (userAgent) { + req.session.userAgent = req.get('User-Agent'); + } + req.session.ipAddress = req.ip; + req.session.loginTime = new Date().getTime(); + req.session.provider = provider; + req.session.user = user as any; + req.session.pendingVerification = null; + + if (verificationRequired) { + const exp = addMinutes(new Date(), 10).getTime(); + const token = generateRandomCode(6); + if (isNewUser) { + req.session.sendNewUserEmailAfterVerify = true; + } + if (verificationRequired.email) { + // If email verification is required, we can consider that as 2fa as well, so do not need to combine with other 2fa factors + req.session.pendingVerification = [{ type: 'email', exp, token }]; + } else if (verificationRequired.twoFactor?.length > 0) { + req.session.pendingVerification = verificationRequired.twoFactor.map((factor) => { + switch (factor.type) { + case '2fa-otp': + return { type: '2fa-otp', exp }; + case '2fa-email': + return { type: '2fa-email', exp, token }; + default: + throw new InvalidVerificationType('Invalid two factor type'); + } + }); + } + } } -export async function login(req: Request, res: Response) { - // if user used a local login session, then we should be authenticated and can redirect to app - if (req.user && req.hostname === 'localhost') { - checkAuth(req, res, (err) => { - if (err) { - res.redirect('/'); - } else { - const user = req.user as UserProfileServer; - req.logIn(user, async (err) => { - if (err) { - req.log.warn({ ...getExceptionLog(err) }, '[AUTH][ERROR] Error logging in %o', err); - return res.redirect('/'); - } - - createOrUpdateUser(user) - .then(async ({ user: _user }) => { - req.log.info('[AUTH][SUCCESS] Logged in %s', _user.email); - res.redirect(ENV.JETSTREAM_CLIENT_URL!); +const logout = createRoute(routeDefinition.logout.validators, async ({ query }, req, res, next) => { + req.session.destroy((err) => { + if (err) { + logger.error({ ...getExceptionLog(err) }, '[AUTH][LOGOUT][ERROR] Error destroying session'); + } + redirect(res, ENV.JETSTREAM_SERVER_URL!); + }); +}); + +const getProviders = createRoute(routeDefinition.getProviders.validators, async ({ query }, req, res, next) => { + try { + const providers = listProviders(); + + sendJson(res, providers); + } catch (ex) { + next(ensureAuthError(ex)); + } +}); + +const getCsrfToken = createRoute(routeDefinition.getCsrfToken.validators, async (_, req, res, next) => { + try { + const csrfToken = await setCsrfCookie(res); + sendJson(res, { csrfToken }); + } catch (ex) { + next(ensureAuthError(ex)); + } +}); + +const getSession = createRoute(routeDefinition.getSession.validators, async (_, req, res, next) => { + try { + let isVerificationExpired = false; + + if (req.session.pendingVerification?.some(({ exp }) => exp && exp <= new Date().getTime())) { + isVerificationExpired = true; + } + + sendJson(res, { + isLoggedIn: !!req.session.user && !req.session.pendingVerification?.length, + pendingVerifications: req.session.pendingVerification?.map(({ type }) => type) || false, + isVerificationExpired, + }); + } catch (ex) { + next(ensureAuthError(ex)); + } +}); + +/** + * For OAuth: + * * Get Authorization URL, set cookies, and redirect + * For Magic Link: + * * Potentially supported in the future + * For Credentials + * * Not Used + */ +const signin = createRoute(routeDefinition.signin.validators, async ({ body, params, query, setCookie }, req, res, next) => { + let provider: Provider | null = null; + try { + const { isAccountLink, returnUrl } = query; + const { csrfToken } = body; + + const providers = listProviders(); + + await verifyCSRFFromRequestOrThrow(csrfToken, req.headers.cookie || ''); + const cookieConfig = getCookieConfig(ENV.ENVIRONMENT === 'production'); + + provider = providers[params.provider]; + if (provider.type === 'oauth') { + clearOauthCookies(res); + + if (isAccountLink) { + if (!req.session.user) { + throw new InvalidSession(); + } + setCookie(cookieConfig.linkIdentity.name, 'true', cookieConfig.linkIdentity.options); + } + + const { authorizationUrl, code_verifier, nonce } = await getAuthorizationUrl(provider.provider as OauthProviderType); + if (code_verifier) { + setCookie(cookieConfig.pkceCodeVerifier.name, code_verifier, cookieConfig.pkceCodeVerifier.options); + } + if (nonce) { + setCookie(cookieConfig.nonce.name, nonce, cookieConfig.nonce.options); + } + if (returnUrl) { + setCookie(cookieConfig.returnUrl.name, returnUrl, cookieConfig.returnUrl.options); + } + + createUserActivityFromReq(req, res, { + action: isAccountLink ? 'LINK_IDENTITY_INIT' : 'OAUTH_INIT', + method: provider.provider.toUpperCase(), + success: true, + }); + redirect(res, authorizationUrl.toString()); + return; + } + + throw new InvalidAction(); + } catch (ex) { + createUserActivityFromReqWithError(req, res, ex, { + action: query?.isAccountLink ? 'LINK_IDENTITY_INIT' : 'OAUTH_INIT', + method: provider?.provider?.toUpperCase(), + success: false, + }); + + next(ensureAuthError(ex)); + } +}); + +/** + * FIXME: This should probably be broken up and logic moved to the auth service + */ +const callback = createRoute(routeDefinition.callback.validators, async ({ body, params, query, clearCookie }, req, res, next) => { + let provider: Provider | null = null; + try { + const providers = listProviders(); + + provider = providers[params.provider]; + if (!provider) { + throw new InvalidParameters(); + } + + let isNewUser = false; + const { + pkceCodeVerifier, + nonce, + linkIdentity: linkIdentityCookie, + returnUrl, + rememberDevice, + } = getCookieConfig(ENV.ENVIRONMENT === 'production'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const cookies = parseCookie(req.headers.cookie!); + clearOauthCookies(res); + + if (provider.type === 'oauth') { + // oauth flow + const { userInfo } = await validateCallback( + provider.provider as OauthProviderType, + new URLSearchParams(query), + cookies[pkceCodeVerifier.name], + cookies[nonce.name] + ); + + if (!userInfo.email) { + throw new InvalidParameters(); + } + + const providerUser = { + id: userInfo.sub, + email: userInfo.email, + emailVerified: userInfo.email_verified ?? false, + givenName: userInfo.given_name, + familyName: userInfo.family_name, + username: userInfo.preferred_username || (userInfo.username as string | undefined) || userInfo.email, + name: + userInfo.name ?? + (userInfo.given_name && userInfo.family_name ? `${userInfo.given_name} ${userInfo.family_name}` : userInfo.email), + picture: (userInfo.picture_thumbnail as string | undefined) || userInfo.picture, + }; + + // If user has an active session and user is linking an identity to an existing account + // link and redirect to profile page + if (req.session.user && cookies[linkIdentityCookie.name] === 'true') { + await linkIdentityToUser({ + userId: req.session.user.id, + provider: provider.provider, + providerUser, + }); + createUserActivityFromReq(req, res, { + action: 'LINK_IDENTITY', + method: provider.provider.toUpperCase(), + success: true, + }); + redirect(res, cookies[returnUrl.name] || `${ENV.JETSTREAM_CLIENT_URL}/profile`); + return; + } + + const sessionData = await handleSignInOrRegistration({ + providerType: provider.type, + provider: provider.provider, + providerUser, + }); + isNewUser = sessionData.isNewUser; + + initSession(req, sessionData); + } else if (provider.type === 'credentials' && req.method === 'POST') { + if (!body || !('action' in body)) { + throw new InvalidAction(); + } + const { action, csrfToken, email, password } = body; + await verifyCSRFFromRequestOrThrow(csrfToken, req.headers.cookie || ''); + + const sessionData = + action === 'login' + ? await handleSignInOrRegistration({ + providerType: 'credentials', + action, + email, + password, }) - .catch((err) => { - req.log.error({ ...getExceptionLog(err) }, '[AUTH][DB][ERROR] Error creating or sending welcome email %o', err); - res.redirect('/'); + : await handleSignInOrRegistration({ + providerType: 'credentials', + action, + email, + name: body.name, + password, }); + + isNewUser = sessionData.isNewUser; + + initSession(req, sessionData); + } else { + throw new InvalidProvider(); + } + + if (!req.session.user) { + throw new AuthError(); + } + + // check for remembered device - emailVerification cannot be bypassed + if ( + cookies[rememberDevice.name] && + Array.isArray(req.session.pendingVerification) && + req.session.pendingVerification.length > 0 && + req.session.pendingVerification.find((item) => item.type !== 'email') + ) { + const deviceId = cookies[rememberDevice.name]; + const isDeviceRemembered = await hasRememberDeviceRecord({ + userId: req.session.user.id, + deviceId, + ipAddress: req.ip, + userAgent: req.get('User-Agent'), + }); + if (isDeviceRemembered) { + req.session.pendingVerification = null; + } else { + // deviceId is not valid, remove cookie + clearCookie(rememberDevice.name, rememberDevice.options); + } + } + + if (Array.isArray(req.session.pendingVerification) && req.session.pendingVerification.length > 0) { + const initialVerification = req.session.pendingVerification[0]; + + if (initialVerification.type === 'email') { + await sendEmailVerification(req.session.user.email, initialVerification.token); + } else if (initialVerification.type === '2fa-email') { + await sendVerificationCode(req.session.user.email, initialVerification.token); + } + + await setCsrfCookie(res); + + if (provider.type === 'oauth') { + redirect(res, `/auth/verify`); + } else { + sendJson(res, { error: false, redirect: `/auth/verify` }); + } + } else { + if (isNewUser) { + await sendWelcomeEmail(req.session.user.email); + } + // No verification required + if (provider.type === 'oauth') { + redirect(res, ENV.JETSTREAM_CLIENT_URL); + } else { + // this was an API call, client will handle redirect + sendJson(res, { + error: false, + redirect: ENV.JETSTREAM_CLIENT_URL, }); } + } + + createUserActivityFromReq(req, res, { + action: 'LOGIN', + method: provider.provider.toUpperCase(), + success: true, + }); + } catch (ex) { + createUserActivityFromReqWithError(req, res, ex, { + action: 'LOGIN', + email: (body as any)?.email, + method: provider?.provider?.toUpperCase(), + success: false, }); - } else { - res.redirect('/'); + next(ensureAuthError(ex)); } -} +}); -export async function callback(req: Request, res: Response, next: NextFunction) { - passport.authenticate( - 'auth0', - { - failureRedirect: '/', - }, - (err, user, info) => { - if (err) { - req.log.warn({ ...getExceptionLog(err) }, '[AUTH][ERROR] Error with authentication %o', err); - return next(new AuthenticationError(err)); - } - if (!user) { - req.log.warn('[AUTH][ERROR] no user'); - req.log.warn('[AUTH][ERROR] no info %o', info); - return res.redirect('/oauth/login'); - } - req.logIn(user, async (err) => { - if (err) { - req.log.warn('[AUTH][ERROR] Error logging in %o', err); - return next(new AuthenticationError(err)); - } +const verification = createRoute(routeDefinition.verification.validators, async ({ body, user, setCookie }, req, res, next) => { + try { + if (!req.session.user || !req.session.pendingVerification) { + throw new InvalidSession(); + } - createOrUpdateUser(user).catch((err) => { - req.log.error({ ...getExceptionLog(err) }, '[AUTH][DB][ERROR] Error creating or sending welcome email %o', err); - }); + const { csrfToken, code, type, rememberDevice } = body; + const pendingVerification = req.session.pendingVerification.find((item) => item.type === type); + let rememberDeviceId: string | undefined; + + const cookieConfig = getCookieConfig(ENV.ENVIRONMENT === 'production'); - // TODO: confirm returnTo 0 it suddenly was reported as bad - const returnTo = (req.session as any).returnTo; - delete (req.session as any).returnTo; - req.log.info('[AUTH][SUCCESS] Logged in %s', user.email); - res.redirect(returnTo || ENV.JETSTREAM_CLIENT_URL); + if (!pendingVerification) { + throw new InvalidSession(); + } + + await verifyCSRFFromRequestOrThrow(csrfToken, req.headers.cookie || ''); + + if (pendingVerification.exp <= new Date().getTime()) { + throw new ExpiredVerificationToken(); + } + + switch (pendingVerification.type) { + case 'email': { + const { token } = pendingVerification; + if (token !== code) { + throw new InvalidVerificationToken(); + } + req.session.user = (await setUserEmailVerified(req.session.user.id)) as any; + break; + } + case '2fa-email': { + const { token } = pendingVerification; + if (token !== code) { + throw new InvalidVerificationToken(); + } + rememberDeviceId = rememberDevice ? generateRandomString(32) : undefined; + break; + } + case '2fa-otp': { + const { secret } = await getTotpAuthenticationFactor(req.session.user.id); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await verify2faTotpOrThrow(secret!, code); + rememberDeviceId = rememberDevice ? generateRandomString(32) : undefined; + break; + } + default: { + throw new InvalidVerificationToken(); + } + } + + if (rememberDeviceId) { + await createRememberDevice({ + userId: user.id, + deviceId: rememberDeviceId, + ipAddress: req.ip, + userAgent: req.get('User-Agent'), }); + setCookie(cookieConfig.rememberDevice.name, rememberDeviceId, cookieConfig.rememberDevice.options); } - )(req, res, next); -} -export async function logout(req: Request, res: Response) { - req.logout(() => { - console.log('Logged out'); - }); + req.session.pendingVerification = null; + + if (req.session.sendNewUserEmailAfterVerify && req.session.user) { + req.session.sendNewUserEmailAfterVerify = undefined; + await sendWelcomeEmail(req.session.user.email); + } - const logoutURL = new URL(`https://${ENV.AUTH0_DOMAIN}/v2/logout`); + createUserActivityFromReq(req, res, { + action: '2FA_VERIFICATION', + method: type.toUpperCase(), + success: true, + }); - logoutURL.search = new URLSearchParams({ - client_id: ENV.AUTH0_CLIENT_ID!, - returnTo: ENV.JETSTREAM_SERVER_URL!, - }).toString(); + sendJson(res, { + error: false, + redirect: ENV.JETSTREAM_CLIENT_URL, + }); + } catch (ex) { + createUserActivityFromReqWithError(req, res, ex, { + action: '2FA_VERIFICATION', + method: body?.type?.toUpperCase(), + success: false, + }); - res.redirect(logoutURL.toString()); -} + next(ensureAuthError(ex)); + } +}); -/** Callback for linking accounts */ -export async function linkCallback(req: Request, res: Response, next: NextFunction) { - passport.authorize( - 'auth0-authz', - { - failureRedirect: `/oauth-link/?error=${new URLSearchParams({ error: 'Unknown Error' as any }).toString()}`, - } as any, - async (err, userProfile, info) => { - const params: OauthLinkParams = { - type: 'auth', - clientUrl: new URL(ENV.JETSTREAM_CLIENT_URL!).origin, - }; - if (err) { - req.log.warn({ ...getExceptionLog(err) }, '[AUTH][LINK][ERROR] Error with authentication %o', err); - params.error = isString(err) ? err : err.message || 'Unknown Error'; - params.message = (req.query.error_description as string) || undefined; - return res.redirect(`/oauth-link/?${new URLSearchParams(params as any).toString()}`); - } - if (!userProfile) { - req.log.warn('[AUTH][LINK][ERROR] no user'); - params.error = 'Authentication Error'; - params.message = (req.query.error_description as string) || undefined; - return res.redirect(`/oauth-link/?${new URLSearchParams(params as any).toString()}`); - } - try { - const user = req.user as UserProfileServer; - await linkIdentity(user, userProfile.user_id); - params.data = JSON.stringify({ userId: userProfile.user_id }); - - // If prior user existed with orgs and a user, then remove them - // If user linked account for very first time, then this may not apply - try { - await hardDeleteUserAndOrgs(userProfile.user_id); - } catch (ex) { - req.log.warn( - { - userId: user.id, - secondaryUserId: userProfile.user_id, - - ...getExceptionLog(ex), - }, - '[AUTH0][IDENTITY][LINK][ERROR] Failed to delete the secondary user orgs %s', - userProfile.user_id - ); +const resendVerification = createRoute(routeDefinition.resendVerification.validators, async ({ body }, req, res, next) => { + try { + if (!req.session.user || !req.session.pendingVerification) { + throw new InvalidSession(); + } + + const { csrfToken, type } = body; + const pendingVerification = req.session.pendingVerification.find((item) => item.type === type); + + if (!pendingVerification) { + throw new InvalidSession(); + } + + await verifyCSRFFromRequestOrThrow(csrfToken, req.headers.cookie || ''); + const exp = addMinutes(new Date(), 10).getTime(); + const token = generateRandomCode(6); + + // Refresh all pending verifications + req.session.pendingVerification = req.session.pendingVerification.map((item) => { + switch (item.type) { + case 'email': { + return { ...item, exp, token }; } + case '2fa-email': { + return { ...item, exp, token }; + } + case '2fa-otp': { + return { ...item, exp }; + } + default: { + return item; + } + } + }); - return res.redirect(`/oauth-link/?${new URLSearchParams(params as any).toString()}`); - } catch (ex) { - req.log.warn({ ...getExceptionLog(ex) }, '[AUTH][LINK][ERROR] Error linking account %s', ex); - params.error = 'Unexpected Error'; - return res.redirect(`/oauth-link/?${new URLSearchParams(params as any).toString()}&clientUrl=${ENV.JETSTREAM_CLIENT_URL}`); + switch (type) { + case 'email': { + await sendEmailVerification(req.session.user.email, token); + break; + } + case '2fa-email': { + await sendVerificationCode(req.session.user.email, token); + break; + } + default: { + break; } } - )(req, res, next); -} + + createUserActivityFromReq(req, res, { + action: '2FA_RESEND_VERIFICATION', + method: type.toUpperCase(), + success: true, + }); + + sendJson(res, { error: false }); + } catch (ex) { + createUserActivityFromReqWithError(req, res, ex, { + action: '2FA_RESEND_VERIFICATION', + method: body?.type?.toUpperCase(), + success: false, + }); + + next(ensureAuthError(ex)); + } +}); + +const requestPasswordReset = createRoute(routeDefinition.requestPasswordReset.validators, async ({ body }, req, res, next) => { + try { + const { csrfToken, email } = body; + await verifyCSRFFromRequestOrThrow(csrfToken, req.headers.cookie || ''); + + try { + const { token } = await generatePasswordResetToken(email); + await sendPasswordReset(email, token); + + sendJson(res, { error: false }); + } catch (ex) { + res.log.warn('[AUTH][PASSWORD_RESET] Attempt to reset a password for an email that does not exist %o', { email }); + sendJson(res, { error: false }); + } + + createUserActivityFromReq(req, res, { + action: 'PASSWORD_RESET_REQUEST', + method: 'UNAUTHENTICATED', + email, + success: true, + }); + } catch (ex) { + createUserActivityFromReqWithError(req, res, ex, { + action: 'PASSWORD_RESET_REQUEST', + method: 'UNAUTHENTICATED', + email: body?.email, + success: false, + }); + + next(ensureAuthError(ex)); + } +}); + +const validatePasswordReset = createRoute(routeDefinition.validatePasswordReset.validators, async ({ body }, req, res, next) => { + try { + const { csrfToken, email, password, token } = body; + await verifyCSRFFromRequestOrThrow(csrfToken, req.headers.cookie || ''); + + await resetUserPassword(email, token, password); + + await sendAuthenticationChangeConfirmation(email, 'Password change confirmation', { + preview: 'Your password has been successfully changed.', + heading: 'Password changed', + }); + + createUserActivityFromReq(req, res, { + action: 'PASSWORD_RESET_COMPLETION', + method: 'UNAUTHENTICATED', + email, + success: true, + }); + + sendJson(res, { error: false }); + } catch (ex) { + createUserActivityFromReqWithError(req, res, ex, { + action: 'PASSWORD_RESET_COMPLETION', + method: 'UNAUTHENTICATED', + email: body?.email, + success: false, + }); + + next(ensureAuthError(ex)); + } +}); + +const verifyEmailViaLink = createRoute(routeDefinition.verification.validators, async ({ query }, req, res, next) => { + try { + if (!req.session.user) { + throw new InvalidSession(); + } + + if (!req.session.pendingVerification?.length) { + sendJson(res, { + error: false, + redirect: ENV.JETSTREAM_CLIENT_URL, + }); + return; + } + + const { code } = query; + + const pendingVerification = req.session.pendingVerification.find(({ type }) => { + type === 'email'; + }); + + if (!pendingVerification) { + throw new InvalidSession(); + } + + if (pendingVerification.exp <= new Date().getTime()) { + throw new ExpiredVerificationToken(); + } + + switch (pendingVerification.type) { + case 'email': { + const { token } = pendingVerification; + if (token !== code) { + throw new InvalidVerificationToken(); + } + req.session.user = (await setUserEmailVerified(req.session.user.id)) as any; + break; + } + default: { + throw new InvalidVerificationToken(); + } + } + + createUserActivityFromReq(req, res, { + action: 'EMAIL_VERIFICATION', + success: true, + }); + + req.session.pendingVerification = null; + redirect(res, ENV.JETSTREAM_CLIENT_URL); + } catch (ex) { + createUserActivityFromReqWithError(req, res, ex, { + action: 'EMAIL_VERIFICATION', + success: false, + }); + + next(ensureAuthError(ex)); + } +}); diff --git a/apps/api/src/app/controllers/jetstream-organizations.controller.ts b/apps/api/src/app/controllers/jetstream-organizations.controller.ts index ca67ef279..0aa30e3fa 100644 --- a/apps/api/src/app/controllers/jetstream-organizations.controller.ts +++ b/apps/api/src/app/controllers/jetstream-organizations.controller.ts @@ -69,7 +69,7 @@ const updateOrganization = createRoute(routeDefinition.updateOrganization.valida try { const organization = await jetstreamOrganizationsDb.update(user.id, params.id, body); - sendJson(res, organization, 201); + sendJson(res, organization, 200); } catch (ex) { next(new UserFacingError(ex)); } diff --git a/apps/api/src/app/controllers/oauth.controller.ts b/apps/api/src/app/controllers/oauth.controller.ts index 9bf983588..418243ef1 100644 --- a/apps/api/src/app/controllers/oauth.controller.ts +++ b/apps/api/src/app/controllers/oauth.controller.ts @@ -9,7 +9,14 @@ import * as jetstreamOrganizationsDb from '../db/organization.db'; import * as salesforceOrgsDb from '../db/salesforce-org.db'; import * as oauthService from '../services/oauth.service'; import { createRoute } from '../utils/route.utils'; -import { OauthLinkParams } from './auth.controller'; + +export interface OauthLinkParams { + type: 'auth' | 'salesforce'; + error?: string; + message?: string; + clientUrl: string; + data?: string; +} export const routeDefinition = { salesforceOauthInitAuth: { @@ -40,7 +47,7 @@ export const routeDefinition = { * @param req * @param res */ -const salesforceOauthInitAuth = createRoute(routeDefinition.salesforceOauthInitAuth.validators, async ({ query }, req, res, next) => { +const salesforceOauthInitAuth = createRoute(routeDefinition.salesforceOauthInitAuth.validators, async ({ query }, req, res) => { const { loginUrl, addLoginParam, jetstreamOrganizationId } = query; const { authorizationUrl, code_verifier, nonce, state } = oauthService.salesforceOauthInit(loginUrl, { addLoginParam }); req.session.orgAuth = { code_verifier, nonce, state, loginUrl, jetstreamOrganizationId }; @@ -52,7 +59,7 @@ const salesforceOauthInitAuth = createRoute(routeDefinition.salesforceOauthInitA * @param req * @param res */ -const salesforceOauthCallback = createRoute(routeDefinition.salesforceOauthCallback.validators, async ({ query, user }, req, res, next) => { +const salesforceOauthCallback = createRoute(routeDefinition.salesforceOauthCallback.validators, async ({ query, user }, req, res) => { const queryParams = query as CallbackParamsType; const clientUrl = new URL(ENV.JETSTREAM_CLIENT_URL!).origin; const returnParams: OauthLinkParams = { diff --git a/apps/api/src/app/controllers/orgs.controller.ts b/apps/api/src/app/controllers/orgs.controller.ts index 0615c7882..6429b5086 100644 --- a/apps/api/src/app/controllers/orgs.controller.ts +++ b/apps/api/src/app/controllers/orgs.controller.ts @@ -68,7 +68,7 @@ const updateOrg = createRoute(routeDefinition.updateOrg.validators, async ({ bod const data = { label: body.label, color: body.color }; const salesforceOrg = await salesforceOrgsDb.updateSalesforceOrg(user.id, params.uniqueId, data); - sendJson(res, salesforceOrg, 201); + sendJson(res, salesforceOrg, 200); } catch (ex) { next(new UserFacingError(ex)); } @@ -76,7 +76,7 @@ const updateOrg = createRoute(routeDefinition.updateOrg.validators, async ({ bod const deleteOrg = createRoute(routeDefinition.deleteOrg.validators, async ({ params, user }, req, res, next) => { try { - salesforceOrgsDb.deleteSalesforceOrg(user.id, params.uniqueId); + await salesforceOrgsDb.deleteSalesforceOrg(user.id, params.uniqueId); sendJson(res, undefined, 204); } catch (ex) { @@ -139,7 +139,7 @@ const moveOrg = createRoute(routeDefinition.moveOrg.validators, async ({ body, p const { uniqueId } = params; const salesforceOrg = await salesforceOrgsDb.moveSalesforceOrg(user.id, uniqueId, body); - sendJson(res, salesforceOrg, 201); + sendJson(res, salesforceOrg, 200); } catch (ex) { next(new UserFacingError(ex)); } diff --git a/apps/api/src/app/controllers/socket.controller.ts b/apps/api/src/app/controllers/socket.controller.ts index b4adfa123..5bdee4098 100644 --- a/apps/api/src/app/controllers/socket.controller.ts +++ b/apps/api/src/app/controllers/socket.controller.ts @@ -1,5 +1,5 @@ import { getExceptionLog, logger } from '@jetstream/api-config'; -import { UserProfileServer } from '@jetstream/types'; +import { UserProfileSession } from '@jetstream/auth/types'; import * as cometdClient from 'cometd-nodejs-client'; import * as express from 'express'; import { IncomingMessage, createServer } from 'http'; @@ -19,7 +19,7 @@ const wrapMiddleware = middleware(socket.request, {}, next); function getUser(socket: Socket) { - const user = (socket.request as any).user as UserProfileServer; + const user = (socket.request as any).user as UserProfileSession; return user; } diff --git a/apps/api/src/app/controllers/user.controller.ts b/apps/api/src/app/controllers/user.controller.ts index b0da35872..4590c792c 100644 --- a/apps/api/src/app/controllers/user.controller.ts +++ b/apps/api/src/app/controllers/user.controller.ts @@ -1,24 +1,62 @@ -import { ENV, getExceptionLog, mailgun } from '@jetstream/api-config'; -import { UpdateProfileRequestSchema } from '@jetstream/api-types'; -import { UserProfileAuth0Ui, UserProfileServer, UserProfileUi, UserProfileUiWithIdentities } from '@jetstream/types'; +import { ENV, getExceptionLog } from '@jetstream/api-config'; +import { + clearOauthCookies, + convertBase32ToHex, + createOrUpdateOtpAuthFactor, + createUserActivityFromReq, + createUserActivityFromReqWithError, + deleteAuthFactor, + generate2faTotpUrl, + generatePasswordResetToken, + getAuthorizationUrl, + getCookieConfig, + getUserSessions, + removeIdentityFromUser, + removePasswordFromUser, + revokeAllUserSessions, + revokeUserSession, + setPasswordForUser, + toggleEnableDisableAuthFactor, + verify2faTotpOrThrow, +} from '@jetstream/auth/server'; +import { OauthProviderTypeSchema } from '@jetstream/auth/types'; +import { + sendAuthenticationChangeConfirmation, + sendGoodbyeEmail, + sendInternalAccountDeletionEmail, + sendPasswordReset, +} from '@jetstream/email'; import { AxiosError } from 'axios'; import { z } from 'zod'; -import { deleteUserAndOrgs } from '../db/transactions.db'; import * as userDbService from '../db/user.db'; -import * as auth0Service from '../services/auth0'; import { UserFacingError } from '../utils/error-handler'; -import { sendJson } from '../utils/response.handlers'; +import { redirect, sendJson } from '../utils/response.handlers'; import { createRoute } from '../utils/route.utils'; export const routeDefinition = { - emailSupport: { - controllerFn: () => emailSupport, + getUserProfile: { + controllerFn: () => getUserProfile, validators: { hasSourceOrg: false, }, }, - getUserProfile: { - controllerFn: () => getUserProfile, + initPassword: { + controllerFn: () => initPassword, + validators: { + body: z.object({ + password: z.string().min(8).max(255), + }), + hasSourceOrg: false, + }, + }, + initResetPassword: { + controllerFn: () => initResetPassword, + validators: { + hasSourceOrg: false, + }, + }, + deletePassword: { + controllerFn: () => deletePassword, validators: { hasSourceOrg: false, }, @@ -29,11 +67,79 @@ export const routeDefinition = { hasSourceOrg: false, }, }, + getSessions: { + controllerFn: () => getSessions, + validators: { + hasSourceOrg: false, + }, + }, + revokeSession: { + controllerFn: () => revokeSession, + validators: { + params: z.object({ + id: z.string().min(32).max(64), + }), + hasSourceOrg: false, + }, + }, + revokeAllSessions: { + controllerFn: () => revokeAllSessions, + validators: { + body: z + .object({ + exceptId: z.string().min(32).max(64).nullish(), + }) + .nullish(), + hasSourceOrg: false, + }, + }, updateProfile: { controllerFn: () => updateProfile, validators: { hasSourceOrg: false, - body: UpdateProfileRequestSchema, + body: z.object({ + name: z.string().min(1).max(255).trim().optional(), + preferences: z + .object({ + skipFrontdoorLogin: z.boolean(), + }) + .optional(), + }), + }, + }, + getOtpQrCode: { + controllerFn: () => getOtpQrCode, + validators: { + hasSourceOrg: false, + }, + }, + saveOtpAuthFactor: { + controllerFn: () => saveOtpAuthFactor, + validators: { + body: z.object({ + code: z.string().min(6).max(6), + secretToken: z.string().min(32).max(32), + }), + hasSourceOrg: false, + }, + }, + toggleEnableDisableAuthFactor: { + controllerFn: () => toggleEnableDisableAuthFactorRoute, + validators: { + params: z.object({ + type: z.enum(['2fa-otp', '2fa-email']), + action: z.enum(['enable', 'disable']), + }), + hasSourceOrg: false, + }, + }, + deleteAuthFactor: { + controllerFn: () => deleteAuthFactorRoute, + validators: { + params: z.object({ + type: z.enum(['2fa-otp', '2fa-email']), + }), + hasSourceOrg: false, }, }, unlinkIdentity: { @@ -41,18 +147,17 @@ export const routeDefinition = { validators: { hasSourceOrg: false, query: z.object({ - provider: z.string().min(1), - userId: z.string().min(1), + provider: OauthProviderTypeSchema, + providerAccountId: z.string().min(1), }), }, }, - resendVerificationEmail: { - controllerFn: () => resendVerificationEmail, + linkIdentity: { + controllerFn: () => linkIdentity, validators: { hasSourceOrg: false, query: z.object({ - provider: z.string().min(1), - userId: z.string().min(1), + provider: OauthProviderTypeSchema, }), }, }, @@ -67,227 +172,240 @@ export const routeDefinition = { }, }; -const emailSupport = createRoute(routeDefinition.emailSupport.validators, async ({ body, user }, req, res, next) => { - const files = Array.isArray(req.files) ? req.files : []; - const { emailBody } = body || {}; +const getUserProfile = createRoute(routeDefinition.getUserProfile.validators, async ({ user }, req, res) => { + const userProfile = await userDbService.findIdByUserIdUserFacing({ userId: user.id }); + sendJson(res, userProfile); +}); - try { - const results = await mailgun.messages.create('mail.getjetstream.app', { - from: 'Jetstream Support ', - to: 'support@getjetstream.app', - subject: 'Jetstream - User submitted feedback', - template: 'generic_notification', - attachment: files?.map((file) => ({ data: file.buffer, filename: file.originalname })), - 'h:X-Mailgun-Variables': JSON.stringify({ - title: 'User submitted feedback', - previewText: 'User submitted feedback', - headline: `User submitted feedback`, - bodySegments: [ - { - text: emailBody, - }, - { - text: `The account ${user.id} has submitted feedback.`, - }, - { - text: JSON.stringify(user, null, 2), - }, - ], - }), - 'h:Reply-To': 'support@getjetstream.app', - }); - req.log.info('[SUPPORT EMAIL][EMAIL SENT] %s', results.id); - sendJson(res); - } catch (ex) { - req.log.error(getExceptionLog(ex), '[SUPPORT EMAIL][ERROR] %s', ex.message || 'An unknown error has occurred.'); - throw new UserFacingError('There was a problem sending the email'); - } +const getFullUserProfile = createRoute(routeDefinition.getFullUserProfile.validators, async ({ user }, req, res) => { + sendJson(res, await userDbService.findUserWithIdentitiesById(user.id)); }); -const getUserProfile = createRoute(routeDefinition.getUserProfile.validators, async ({ user: auth0User }, req, res, next) => { - // use fallback locally and on CI - if (ENV.EXAMPLE_USER_OVERRIDE && ENV.EXAMPLE_USER_PROFILE && req.hostname === 'localhost') { - sendJson(res, ENV.EXAMPLE_USER_PROFILE); - return; - } +const initPassword = createRoute(routeDefinition.initPassword.validators, async ({ body, user }, req, res) => { + const { password } = body; + await setPasswordForUser(user.id, password); + sendJson(res, await userDbService.findUserWithIdentitiesById(user.id)); - const user = await userDbService.findByUserId(auth0User.id); - if (!user) { - throw new UserFacingError('User not found'); - } - const userProfileUi: UserProfileUi = { - ...(auth0User._json as any), - id: user.id, - userId: user.userId, - createdAt: user.createdAt.toISOString(), - updatedAt: user.updatedAt.toISOString(), - preferences: { - skipFrontdoorLogin: user.preferences?.skipFrontdoorLogin, - }, - }; - sendJson(res, userProfileUi); + createUserActivityFromReq(req, res, { + action: 'PASSWORD_SET', + method: 'USER_PROFILE', + success: true, + }); }); -async function getFullUserProfileFn(sessionUser: UserProfileServer, auth0User?: UserProfileAuth0Ui) { - auth0User = auth0User || (await auth0Service.getUser(sessionUser)); - const jetstreamUser = await userDbService.findByUserId(sessionUser.id); - if (!jetstreamUser) { - throw new UserFacingError('User not found'); - } - const response: UserProfileUiWithIdentities = { - id: jetstreamUser.id, - userId: sessionUser.id, - name: jetstreamUser.name || '', - email: jetstreamUser.email, - emailVerified: auth0User.email_verified, - username: auth0User.username || '', - nickname: auth0User.nickname, - picture: auth0User.picture, - preferences: { - skipFrontdoorLogin: jetstreamUser.preferences?.skipFrontdoorLogin ?? false, - }, - identities: auth0User.identities, - createdAt: jetstreamUser.createdAt.toISOString(), - updatedAt: jetstreamUser.updatedAt.toISOString(), - }; - return response; -} - -/** Get profile from Auth0 */ -const getFullUserProfile = createRoute(routeDefinition.getFullUserProfile.validators, async ({ user }, req, res, next) => { - try { - const response = await getFullUserProfileFn(user); - sendJson(res, response); - } catch (ex) { - if (ex.isAxiosError) { - const error: AxiosError = ex; - if (error.response) { - req.log.error(getExceptionLog(ex), '[AUTH0][PROFILE FETCH][ERROR] %o', error.response.data); - } else if (error.request) { - req.log.error(getExceptionLog(ex), '[AUTH0][PROFILE FETCH][ERROR] %s', error.message || 'An unknown error has occurred.'); - } - } - throw new UserFacingError('There was an error obtaining your profile information'); - } +const initResetPassword = createRoute(routeDefinition.initResetPassword.validators, async ({ user }, req, res) => { + const { email, token } = await generatePasswordResetToken(user.email); + await sendPasswordReset(email, token); + sendJson(res); + createUserActivityFromReq(req, res, { + action: 'PASSWORD_RESET_REQUEST', + method: 'USER_PROFILE', + success: true, + }); }); -const updateProfile = createRoute(routeDefinition.updateProfile.validators, async ({ body, user }, req, res, next) => { +const deletePassword = createRoute(routeDefinition.deletePassword.validators, async ({ user }, req, res) => { + await removePasswordFromUser(user.id); + + await sendAuthenticationChangeConfirmation(user.email, 'Your password has been removed from your account', { + preview: 'Your password has been removed from your account.', + heading: 'You have removed your password as a login method', + }); + + sendJson(res, await userDbService.findUserWithIdentitiesById(user.id)); +}); + +const updateProfile = createRoute(routeDefinition.updateProfile.validators, async ({ body, user }, req, res) => { const userProfile = body; try { - // check for name change, if so call auth0 to update - const auth0User = await auth0Service.updateUser(user, userProfile as any); - // update name and preferences locally - const response = await getFullUserProfileFn(user, auth0User); - sendJson(res, response); + await userDbService.updateUser(user, userProfile); + sendJson(res, await userDbService.findUserWithIdentitiesById(user.id)); } catch (ex) { - if (ex.isAxiosError) { - const error: AxiosError = ex; - if (error.response) { - req.log.error(getExceptionLog(ex), '[AUTH0][PROFILE][ERROR] %o', error.response.data); - } else if (error.request) { - req.log.error(getExceptionLog(ex), '[AUTH0][PROFILE][ERROR] %s', error.message || 'An unknown error has occurred.'); - } - } throw new UserFacingError('There was an error updating the user profile'); } }); -const unlinkIdentity = createRoute(routeDefinition.unlinkIdentity.validators, async ({ query, user }, req, res, next) => { +const getSessions = createRoute(routeDefinition.getSessions.validators, async ({ user }, req, res) => { + const sessions = await getUserSessions(user.id); + sendJson(res, { + currentSessionId: req.session.id, + sessions, + }); +}); + +const revokeSession = createRoute(routeDefinition.revokeSession.validators, async ({ params, user }, req, res) => { + const sessions = await revokeUserSession(user.id, params.id); + sendJson(res, { + currentSessionId: req.session.id, + sessions, + }); + + createUserActivityFromReq(req, res, { + action: 'REVOKE_SESSION', + method: 'SINGLE', + success: true, + }); +}); + +const revokeAllSessions = createRoute(routeDefinition.revokeAllSessions.validators, async ({ body, user }, req, res) => { + const sessions = await revokeAllUserSessions(user.id, body?.exceptId); + sendJson(res, { + currentSessionId: req.session.id, + sessions, + }); + + createUserActivityFromReq(req, res, { + action: 'REVOKE_SESSION', + method: 'ALL', + success: true, + }); +}); + +const getOtpQrCode = createRoute(routeDefinition.getOtpQrCode.validators, async ({ user }, req, res) => { + const { secret, imageUri, uri } = await generate2faTotpUrl(user.id); + sendJson(res, { secret, secretToken: new URL(uri).searchParams.get('secret'), imageUri, uri }); +}); + +const saveOtpAuthFactor = createRoute(routeDefinition.saveOtpAuthFactor.validators, async ({ body, user }, req, res) => { + const { code, secretToken } = body; + const secret = await convertBase32ToHex(secretToken); + await verify2faTotpOrThrow(secret, code); + const authFactors = await createOrUpdateOtpAuthFactor(user.id, secret); + sendJson(res, authFactors); + + await sendAuthenticationChangeConfirmation(user.email, 'A new 2FA method has been added to your account', { + preview: 'A new 2FA method has been added to your account.', + heading: 'Authenticator app added', + }); + + createUserActivityFromReq(req, res, { + action: '2FA_SETUP', + method: '2FA-OTP', + success: true, + }); +}); + +const toggleEnableDisableAuthFactorRoute = createRoute( + routeDefinition.toggleEnableDisableAuthFactor.validators, + async ({ params, user }, req, res) => { + const { type, action } = params; + const authFactors = await toggleEnableDisableAuthFactor(user.id, type, action); + sendJson(res, authFactors); + + createUserActivityFromReq(req, res, { + action: action === 'enable' ? '2FA_ACTIVATE' : '2FA_DEACTIVATE', + method: type.toUpperCase(), + success: true, + }); + } +); + +const deleteAuthFactorRoute = createRoute(routeDefinition.deleteAuthFactor.validators, async ({ params, user }, req, res) => { + const { type } = params; + const authFactors = await deleteAuthFactor(user.id, type); + sendJson(res, authFactors); + + await sendAuthenticationChangeConfirmation(user.email, 'Two-factor authentication method removed', { + preview: 'Two-factor authentication method removed.', + heading: 'An authentication method has been removed', + }); + + createUserActivityFromReq(req, res, { + action: '2FA_REMOVAL', + method: type.toUpperCase(), + success: true, + }); +}); + +const unlinkIdentity = createRoute(routeDefinition.unlinkIdentity.validators, async ({ query, user }, req, res) => { try { - const provider = query.provider; - const userId = query.userId; + const { provider, providerAccountId } = query; + + await removeIdentityFromUser(user.id, provider, providerAccountId); + const updatedUser = await userDbService.findUserWithIdentitiesById(user.id); - const auth0User = await auth0Service.unlinkIdentity(user, { provider, userId }); - const response = await getFullUserProfileFn(user, auth0User); - sendJson(res, response); + sendJson(res, updatedUser); + + createUserActivityFromReq(req, res, { + action: 'UNLINK_IDENTITY', + method: provider.toUpperCase(), + success: true, + }); } catch (ex) { - if (ex.isAxiosError) { - const error: AxiosError = ex; - if (error.response) { - req.log.error(getExceptionLog(ex), '[AUTH0][UNLINK][ERROR] %o', error.response.data); - } else if (error.request) { - req.log.error(getExceptionLog(ex), '[AUTH0][UNLINK][ERROR] %s', error.message || 'An unknown error has occurred.'); - } - } + createUserActivityFromReqWithError(req, res, ex, { + action: 'UNLINK_IDENTITY', + method: query?.provider?.toUpperCase(), + success: false, + }); + throw new UserFacingError('There was an error unlinking the account'); } }); -const resendVerificationEmail = createRoute(routeDefinition.resendVerificationEmail.validators, async ({ query, user }, req, res, next) => { - const provider = query.provider; - const userId = query.userId; - try { - await auth0Service.resendVerificationEmail(user, { provider, userId }); - sendJson(res); - } catch (ex) { - if (ex.isAxiosError) { - const error: AxiosError = ex; - if (error.response) { - req.log.error(getExceptionLog(ex), '[AUTH0][EMAIL VERIFICATION][ERROR] %o', error.response.data); - } else if (error.request) { - req.log.error(getExceptionLog(ex), '[AUTH0][EMAIL VERIFICATION][ERROR] %s', error.message || 'An unknown error has occurred.'); - } - } - throw new UserFacingError('There was an error re-sending the verification email'); +const linkIdentity = createRoute(routeDefinition.linkIdentity.validators, async ({ query, user, setCookie }, req, res) => { + const { provider } = query; + const cookieConfig = getCookieConfig(ENV.ENVIRONMENT === 'production'); + + clearOauthCookies(res); + const { authorizationUrl, code_verifier, nonce } = await getAuthorizationUrl(provider); + if (code_verifier) { + setCookie(cookieConfig.pkceCodeVerifier.name, code_verifier, cookieConfig.pkceCodeVerifier.options); } + if (nonce) { + setCookie(cookieConfig.nonce.name, nonce, cookieConfig.nonce.options); + } + setCookie(cookieConfig.linkIdentity.name, '1', cookieConfig.linkIdentity.options); + setCookie(cookieConfig.returnUrl.name, `${ENV.JETSTREAM_CLIENT_URL}/app/profile`, cookieConfig.returnUrl.options); + redirect(res, authorizationUrl.toString()); + + await sendAuthenticationChangeConfirmation(user.email, 'A new identity has been linked to your account', { + preview: 'A new identity has been linked to your account.', + heading: 'A new login method has been added to your account', + }); + + createUserActivityFromReq(req, res, { + action: 'LINK_IDENTITY_INIT', + method: 'USER_PROFILE', + success: true, + }); }); -const deleteAccount = createRoute(routeDefinition.deleteAccount.validators, async ({ body, user, requestId }, req, res, next) => { +const deleteAccount = createRoute(routeDefinition.deleteAccount.validators, async ({ body, user, requestId }, req, res) => { try { const reason = body.reason; - // delete from Auth0 - await auth0Service.deleteUser(user); - - // delete locally - await deleteUserAndOrgs(user); + await userDbService.deleteUserAndAllRelatedData(user.id); + // Destroy session - don't wait for response + req.session.destroy((error) => { + if (error) { + req.log.error({ requestId, ...getExceptionLog(error) }, '[ACCOUNT DELETE][ERROR DESTROYING SESSION]'); + } + }); try { - // Send email to team about account deletion - if user provided a reason, then we can capture that - mailgun.messages - .create('mail.getjetstream.app', { - from: 'Jetstream Support ', - to: 'support@getjetstream.app', - subject: 'Jetstream - Account deletion notification', - template: 'generic_notification', - 'h:X-Mailgun-Variables': JSON.stringify({ - title: 'Jetstream account deleted', - previewText: 'Account was deleted', - headline: `Account was deleted`, - bodySegments: [ - { - text: `The account ${user.id} was deleted.`, - }, - { - text: reason ? `Reason: ${reason}` : `The user did not provide any reason.`, - }, - { - text: JSON.stringify(user, null, 2), - }, - ], - }), - 'h:Reply-To': 'support@getjetstream.app', - }) - .then((results) => { - req.log.info('[ACCOUNT DELETE][EMAIL SENT] %s', results.id); - }) - .catch((error) => { - req.log.error({ requestId, ...getExceptionLog(error) }, '[ACCOUNT DELETE][ERROR SENDING EMAIL SUMMARY] %s', error.message); - }); + await sendGoodbyeEmail(user.email); + await sendInternalAccountDeletionEmail(user.id, reason); } catch (ex) { req.log.error('[ACCOUNT DELETE][ERROR SENDING EMAIL SUMMARY] %s', ex.message); } - // Destroy session - don't wait for response - req.session.destroy((error) => { - if (error) { - req.log.error({ requestId, ...getExceptionLog(error) }, '[ACCOUNT DELETE][ERROR DESTROYING SESSION] %s', error.message); - } + createUserActivityFromReq(req, res, { + action: 'DELETE_ACCOUNT', + method: 'USER_PROFILE', + email: user.email, + success: true, }); sendJson(res); } catch (ex) { + createUserActivityFromReqWithError(req, res, ex, { + action: 'DELETE_ACCOUNT', + method: 'USER_PROFILE', + email: user.email, + success: false, + }); + if (ex.isAxiosError) { const error: AxiosError = ex; if (error.response) { diff --git a/apps/api/src/app/db/organization.db.ts b/apps/api/src/app/db/organization.db.ts index 66bca18f4..70276ef0a 100644 --- a/apps/api/src/app/db/organization.db.ts +++ b/apps/api/src/app/db/organization.db.ts @@ -2,7 +2,6 @@ import { prisma } from '@jetstream/api-config'; import { Maybe } from '@jetstream/types'; import { Prisma } from '@prisma/client'; -import { findIdByUserId } from './user.db'; const SELECT = Prisma.validator()({ id: true, @@ -16,11 +15,11 @@ const SELECT = Prisma.validator()({ }); export const findByUserId = async ({ userId }: { userId: string }) => { - return await prisma.jetstreamOrganization.findMany({ where: { user: { userId } }, select: SELECT }); + return await prisma.jetstreamOrganization.findMany({ where: { userId }, select: SELECT }); }; export const findById = async ({ id, userId }: { id: string; userId: string }) => { - return await prisma.jetstreamOrganization.findFirstOrThrow({ where: { id, user: { userId } }, select: SELECT }); + return await prisma.jetstreamOrganization.findFirstOrThrow({ where: { id, userId }, select: SELECT }); }; export const create = async ( @@ -30,11 +29,10 @@ export const create = async ( description?: Maybe; } ) => { - const userActualId = await findIdByUserId({ userId }); return await prisma.jetstreamOrganization.create({ select: SELECT, data: { - userId: userActualId, + userId, name: payload.name.trim(), description: payload.description?.trim(), }, @@ -51,7 +49,7 @@ export const update = async ( ) => { return await prisma.jetstreamOrganization.update({ select: SELECT, - where: { user: { userId }, id }, + where: { userId, id }, data: { name: payload.name.trim(), description: payload.description?.trim() ?? null, @@ -62,6 +60,6 @@ export const update = async ( export const deleteOrganization = async (userId, id) => { return await prisma.jetstreamOrganization.delete({ select: SELECT, - where: { user: { userId }, id }, + where: { userId, id }, }); }; diff --git a/apps/api/src/app/db/salesforce-org.db.ts b/apps/api/src/app/db/salesforce-org.db.ts index bfe2a1478..78f6eed28 100644 --- a/apps/api/src/app/db/salesforce-org.db.ts +++ b/apps/api/src/app/db/salesforce-org.db.ts @@ -5,7 +5,7 @@ import { Maybe, SalesforceOrgUi } from '@jetstream/types'; import { Prisma, SalesforceOrg } from '@prisma/client'; import { parseISO } from 'date-fns/parseISO'; import isUndefined from 'lodash/isUndefined'; -import { findIdByUserId } from './user.db'; +import { NotFoundError } from '../utils/error-handler'; const SELECT = Prisma.validator()({ jetstreamOrganizationId: true, @@ -37,24 +37,19 @@ const SELECT = Prisma.validator()({ export const SALESFORCE_ORG_SELECT = SELECT; -/** - * TODO: add better error handling with non-db error messages! - */ - -const findUniqueOrg = ({ jetstreamUserId, uniqueId }: { jetstreamUserId: string; uniqueId: string }) => { +const findUniqueOrg = ({ userId, uniqueId }: { userId: string; uniqueId: string }) => { return Prisma.validator()({ uniqueOrg: { - jetstreamUserId, + jetstreamUserId2: userId, jetstreamUrl: ENV.JETSTREAM_SERVER_URL!, uniqueId: uniqueId, }, }); }; -const findUsersOrgs = ({ jetstreamUserId, actualUserId }: { jetstreamUserId: string; actualUserId: string }) => { +const findUsersOrgs = ({ userId }: { userId: string }) => { return Prisma.validator()({ - jetstreamUserId2: actualUserId, - jetstreamUserId, + jetstreamUserId2: userId, jetstreamUrl: ENV.JETSTREAM_SERVER_URL, }); }; @@ -64,6 +59,8 @@ export function encryptAccessToken(accessToken: string, refreshToken: string) { } export function decryptAccessToken(encryptedAccessToken: string) { + // FIXME: we should use a dedicated encryption key for this + // TODO: if org is not used for X timeperiod, we should auto-expire the token return decryptString(encryptedAccessToken, hexToBase64(ENV.SFDC_CONSUMER_SECRET!)).split(' '); } @@ -71,13 +68,13 @@ export function decryptAccessToken(encryptedAccessToken: string) { * Finds by unique id and returns all fields * This is unsafe to send to the browser and should only be used internally * - * @param jetstreamUserId + * @param userId * @param uniqueId * @returns */ -export async function findByUniqueId_UNSAFE(jetstreamUserId: string, uniqueId: string) { +export async function findByUniqueId_UNSAFE(userId: string, uniqueId: string) { return await prisma.salesforceOrg.findUnique({ - where: findUniqueOrg({ jetstreamUserId, uniqueId }), + where: findUniqueOrg({ userId, uniqueId }), }); } @@ -97,28 +94,30 @@ export async function updateOrg_UNSAFE(org: SalesforceOrg, data: Partial) { - const actualUserId = await findIdByUserId({ userId: jetstreamUserId }); - const existingOrg = await prisma.salesforceOrg.findUnique({ - where: findUniqueOrg({ jetstreamUserId, uniqueId: salesforceOrgUi.uniqueId! }), +export async function createOrUpdateSalesforceOrg(userId: string, salesforceOrgUi: Partial) { + const userWithOrgs = await prisma.user.findFirstOrThrow({ + where: { id: userId }, + select: { id: true, userId: true, salesforceOrgs: true }, }); + const existingOrg = userWithOrgs.salesforceOrgs.find((org) => org.uniqueId === salesforceOrgUi.uniqueId); - // FIXME: need to include organization - added orgs should be added to current organization + if (!salesforceOrgUi.uniqueId) { + throw new Error('uniqueId is required'); + } let orgToDelete: Maybe<{ id: number }>; /** @@ -127,16 +126,10 @@ export async function createOrUpdateSalesforceOrg(jetstreamUserId: string, sales * After a user does a sandbox refresh, this deletes the old org no matter how the user initiated the connection */ if (salesforceOrgUi.organizationId && salesforceOrgUi.username) { - orgToDelete = await prisma.salesforceOrg.findFirst({ - select: { id: true }, - where: { - jetstreamUserId2: { equals: actualUserId }, - jetstreamUserId: { equals: jetstreamUserId }, - jetstreamUrl: { equals: ENV.JETSTREAM_SERVER_URL! }, - username: { equals: salesforceOrgUi.username }, - uniqueId: { not: { equals: salesforceOrgUi.uniqueId! } }, - }, - }); + orgToDelete = userWithOrgs.salesforceOrgs.find( + ({ uniqueId, username, jetstreamUrl }) => + uniqueId !== salesforceOrgUi.uniqueId && username === salesforceOrgUi.username && jetstreamUrl === ENV.JETSTREAM_SERVER_URL + ); } if (existingOrg) { @@ -183,8 +176,8 @@ export async function createOrUpdateSalesforceOrg(jetstreamUserId: string, sales const org = await prisma.salesforceOrg.create({ select: SELECT, data: { - jetstreamUserId2: actualUserId, - jetstreamUserId, + jetstreamUserId2: userWithOrgs.id, + jetstreamUserId: userWithOrgs.userId, jetstreamUrl: ENV.JETSTREAM_SERVER_URL, jetstreamOrganizationId: salesforceOrgUi.jetstreamOrganizationId, label: salesforceOrgUi.label || salesforceOrgUi.username, @@ -218,14 +211,14 @@ export async function createOrUpdateSalesforceOrg(jetstreamUserId: string, sales } } -export async function updateSalesforceOrg(jetstreamUserId: string, uniqueId: string, data: { label: string; color?: string | null }) { +export async function updateSalesforceOrg(userId: string, uniqueId: string, data: { label: string; color?: string | null }) { const existingOrg = await prisma.salesforceOrg.findUnique({ select: { id: true, username: true, label: true, orgName: true, color: true }, - where: findUniqueOrg({ jetstreamUserId, uniqueId }), + where: findUniqueOrg({ userId, uniqueId }), }); if (!existingOrg) { - throw new Error('An org does not exist with the provided input'); + throw new NotFoundError('An org does not exist with the provided input'); } const label = data.label || existingOrg.username; @@ -242,14 +235,14 @@ export async function updateSalesforceOrg(jetstreamUserId: string, uniqueId: str }); } -export async function moveSalesforceOrg(jetstreamUserId: string, uniqueId: string, data: { jetstreamOrganizationId?: Maybe }) { +export async function moveSalesforceOrg(userId: string, uniqueId: string, data: { jetstreamOrganizationId?: Maybe }) { const existingOrg = await prisma.salesforceOrg.findUnique({ select: { id: true }, - where: findUniqueOrg({ jetstreamUserId, uniqueId }), + where: findUniqueOrg({ userId, uniqueId }), }); if (!existingOrg) { - throw new Error('An org does not exist with the provided input'); + throw new NotFoundError('An org does not exist with the provided input'); } return await prisma.salesforceOrg.update({ @@ -261,14 +254,14 @@ export async function moveSalesforceOrg(jetstreamUserId: string, uniqueId: strin }); } -export async function deleteSalesforceOrg(jetstreamUserId: string, uniqueId: string) { +export async function deleteSalesforceOrg(userId: string, uniqueId: string) { const existingOrg = await prisma.salesforceOrg.findUnique({ select: { id: true, username: true, label: true, orgName: true }, - where: findUniqueOrg({ jetstreamUserId, uniqueId }), + where: findUniqueOrg({ userId, uniqueId }), }); if (!existingOrg) { - throw new Error('An org does not exist with the provided input'); + throw new NotFoundError('An org does not exist with the provided input'); } await prisma.salesforceOrg.delete({ diff --git a/apps/api/src/app/db/transactions.db.ts b/apps/api/src/app/db/transactions.db.ts index eca010b4d..4ff7d6e4d 100644 --- a/apps/api/src/app/db/transactions.db.ts +++ b/apps/api/src/app/db/transactions.db.ts @@ -1,12 +1,12 @@ import { getExceptionLog, logger, prisma } from '@jetstream/api-config'; -import { UserProfileServer } from '@jetstream/types'; +import { UserProfileSession } from '@jetstream/auth/types'; import { PrismaPromise } from '@prisma/client'; /** * This file manages db operations as transactions that span multiple tables */ -export async function deleteUserAndOrgs(user: UserProfileServer) { +export async function deleteUserAndOrgs(user: UserProfileSession) { if (!user?.id) { throw new Error('A valid user must be provided'); } diff --git a/apps/api/src/app/db/user.db.ts b/apps/api/src/app/db/user.db.ts index ddbddbbda..854410d8f 100644 --- a/apps/api/src/app/db/user.db.ts +++ b/apps/api/src/app/db/user.db.ts @@ -1,5 +1,5 @@ -import { ENV, getExceptionLog, logger, prisma } from '@jetstream/api-config'; -import { UserProfileServer } from '@jetstream/types'; +import { getExceptionLog, logger, prisma } from '@jetstream/api-config'; +import { UserProfileSession } from '@jetstream/auth/types'; import { Prisma, User } from '@prisma/client'; const userSelect: Prisma.UserSelect = { @@ -19,35 +19,84 @@ const userSelect: Prisma.UserSelect = { userId: true, }; +const FullUserFacingProfileSelect = Prisma.validator()({ + id: true, + userId: true, + name: true, + email: true, + emailVerified: true, + appMetadata: false, + picture: true, + preferences: true, + hasPasswordSet: true, + identities: { + select: { + type: true, + email: true, + emailVerified: true, + familyName: true, + givenName: true, + name: true, + picture: true, + provider: true, + providerAccountId: true, + isPrimary: true, + username: true, + createdAt: true, + updatedAt: true, + }, + }, + authFactors: { + select: { + type: true, + enabled: true, + createdAt: true, + updatedAt: true, + }, + }, + createdAt: true, + updatedAt: true, +}); + +const UserFacingProfileSelect = Prisma.validator()({ + id: true, + userId: true, + name: true, + email: true, + emailVerified: true, + picture: true, + preferences: true, +}); + +export async function findUserWithIdentitiesById(id: string) { + return await prisma.user.findUniqueOrThrow({ + select: FullUserFacingProfileSelect, + where: { id }, + }); +} + export const findIdByUserId = ({ userId }: { userId: string }) => { return prisma.user.findFirstOrThrow({ where: { userId }, select: { id: true } }).then(({ id }) => id); }; -/** - * Find by Auth0 userId, not Jetstream Id - */ -export async function findByUserId(userId: string) { - const user = await prisma.user.findUnique({ - where: { userId }, - select: userSelect, - }); - return user; -} +export const findIdByUserIdUserFacing = ({ userId }: { userId: string }) => { + return prisma.user.findFirstOrThrow({ where: { id: userId }, select: UserFacingProfileSelect }).then(({ id }) => id); +}; export async function updateUser( - user: UserProfileServer, - data: { name: string; preferences: { skipFrontdoorLogin: boolean } } + user: UserProfileSession, + data: { name?: string; preferences?: { skipFrontdoorLogin: boolean } } ): Promise { try { - const existingUser = await prisma.user.findUnique({ - where: { userId: user.id }, - select: { id: true, preferences: { select: { skipFrontdoorLogin: true } } }, + const existingUser = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { id: true, name: true, preferences: { select: { skipFrontdoorLogin: true } } }, }); - const skipFrontdoorLogin = data.preferences.skipFrontdoorLogin ?? (existingUser?.preferences?.skipFrontdoorLogin || false); + const skipFrontdoorLogin = data.preferences?.skipFrontdoorLogin ?? (existingUser?.preferences?.skipFrontdoorLogin || false); const updatedUser = await prisma.user.update({ - where: { userId: user.id }, + where: { id: user.id }, data: { - name: data.name, + name: data.name ?? existingUser.name, preferences: { upsert: { create: { skipFrontdoorLogin }, @@ -64,51 +113,19 @@ export async function updateUser( } } -/** - * This is called each time a user logs in (e.x. goes through OAuth2 flow with Auth Provider) - */ -export async function createOrUpdateUser(user: UserProfileServer): Promise<{ created: boolean; user: User }> { - try { - const existingUser = await findByUserId(user.id); - - if (existingUser) { - const updatedUser = await prisma.user.update({ - where: { userId: user.id }, - data: { - appMetadata: JSON.stringify(user._json[ENV.AUTH_AUDIENCE!]), - deletedAt: null, - lastLoggedIn: new Date(), - preferences: { - upsert: { - create: { skipFrontdoorLogin: false }, - update: { skipFrontdoorLogin: false }, - }, - }, - }, - select: userSelect, - }); - logger.debug({ userId: user.id, id: existingUser.id }, '[DB][USER][UPDATED] %s', user.id); - return { created: false, user: updatedUser }; - } else { - const createdUser = await prisma.user.create({ - data: { - userId: user.id, - email: user._json.email, - name: user._json.name, - nickname: user._json.nickname, - picture: user._json.picture, - appMetadata: JSON.stringify(user._json[ENV.AUTH_AUDIENCE!]), - deletedAt: null, - lastLoggedIn: new Date(), - preferences: { create: { skipFrontdoorLogin: false } }, - }, - select: userSelect, - }); - logger.debug({ userId: user.id, id: createdUser.id }, '[DB][USER][CREATED] %s', user.id); - return { created: true, user: createdUser }; - } - } catch (ex) { - logger.error({ user, ...getExceptionLog(ex) }, '[DB][USER][CREATE][ERROR] %o', ex); - throw ex; +export async function deleteUserAndAllRelatedData(userId: string): Promise { + const existingUser = await prisma.user.findFirstOrThrow({ where: { id: userId }, select: { id: true } }); + if (!existingUser) { + throw new Error(`User with id ${userId} not found`); } + // This cascades to delete all related data + await prisma.user.delete({ where: { id: userId } }); + await prisma.sessions.deleteMany({ + where: { + sess: { + path: ['user', 'id'], + equals: userId, + }, + }, + }); } diff --git a/apps/api/src/app/routes/api.routes.ts b/apps/api/src/app/routes/api.routes.ts index 25e54fd53..a24f2e4b5 100644 --- a/apps/api/src/app/routes/api.routes.ts +++ b/apps/api/src/app/routes/api.routes.ts @@ -1,7 +1,6 @@ import { ENV } from '@jetstream/api-config'; import express from 'express'; import Router from 'express-promise-router'; -import multer from 'multer'; import { routeDefinition as imageController } from '../controllers/image.controller'; import { routeDefinition as jetstreamOrganizationsController } from '../controllers/jetstream-organizations.controller'; import { routeDefinition as orgsController } from '../controllers/orgs.controller'; @@ -16,9 +15,6 @@ import { routeDefinition as userController } from '../controllers/user.controlle import { sendJson } from '../utils/response.handlers'; import { addOrgsToLocal, checkAuth, ensureTargetOrgExists } from './route.middleware'; -const storage = multer.memoryStorage(); -const upload = multer({ storage: storage }); - const routes: express.Router = Router(); routes.use(checkAuth); @@ -26,7 +22,7 @@ routes.use(addOrgsToLocal); // used to make sure the user is authenticated and can communicate with the server routes.get('/heartbeat', (req: express.Request, res: express.Response) => { - sendJson(res, { version: ENV.GIT_VERSION || null }); + sendJson(res as any, { version: ENV.GIT_VERSION || null }); }); /** @@ -39,8 +35,23 @@ routes.delete('/me', userController.deleteAccount.controllerFn()); routes.get('/me/profile', userController.getFullUserProfile.controllerFn()); routes.post('/me/profile', userController.updateProfile.controllerFn()); routes.delete('/me/profile/identity', userController.unlinkIdentity.controllerFn()); -routes.post('/me/profile/identity/verify-email', userController.resendVerificationEmail.controllerFn()); -routes.post('/support/email', upload.array('files', 5) as any, userController.emailSupport.controllerFn()); +routes.get('/me/profile/sessions', userController.getSessions.controllerFn()); +routes.delete('/me/profile/sessions/:id', userController.revokeSession.controllerFn()); +routes.delete('/me/profile/sessions', userController.revokeAllSessions.controllerFn()); +/** + * Password Management Routes + */ +routes.post('/me/profile/password/init', userController.initPassword.controllerFn()); +routes.post('/me/profile/password/reset', userController.initResetPassword.controllerFn()); +// TODO: should we allow users to remove their password if they have social login? +routes.delete('/me/profile/password', userController.deletePassword.controllerFn()); +/** + * 2FA Routes + */ +routes.get('/me/profile/2fa-otp', userController.getOtpQrCode.controllerFn()); +routes.post('/me/profile/2fa-otp', userController.saveOtpAuthFactor.controllerFn()); +routes.post('/me/profile/2fa/:type/:action', userController.toggleEnableDisableAuthFactor.controllerFn()); +routes.delete('/me/profile/2fa/:type', userController.deleteAuthFactor.controllerFn()); /** * ************************************ diff --git a/apps/api/src/app/routes/auth.routes.ts b/apps/api/src/app/routes/auth.routes.ts new file mode 100644 index 000000000..0ffeb91f6 --- /dev/null +++ b/apps/api/src/app/routes/auth.routes.ts @@ -0,0 +1,58 @@ +import { createRateLimit, ENV } from '@jetstream/api-config'; +import * as express from 'express'; +import Router from 'express-promise-router'; +import * as authController from '../controllers/auth.controller'; +import { verifyCaptcha } from './route.middleware'; + +/** + * Authentication routes + */ + +function getMaxRequests(value: number) { + return ENV.IS_CI ? 10000 : value; +} + +export const LAX_AuthRateLimit = createRateLimit('auth_lax', { + windowMs: 1000 * 60 * 1, // 1 minutes + limit: getMaxRequests(25), +}); + +const STRICT_AuthRateLimit = createRateLimit('auth_strict', { + windowMs: 1000 * 60 * 5, // 5 minutes + limit: getMaxRequests(20), +}); + +const STRICT_2X_AuthRateLimit = createRateLimit('auth_strict_2x', { + windowMs: 1000 * 60 * 15, // 15 minutes + limit: getMaxRequests(10), +}); + +export const routes: express.Router = Router(); + +routes.get('/logout', LAX_AuthRateLimit, authController.routeDefinition.logout.controllerFn()); +// Get oauth provider information +routes.get('/providers', LAX_AuthRateLimit, authController.routeDefinition.getProviders.controllerFn()); +// Get CSRF token +routes.get('/csrf', LAX_AuthRateLimit, authController.routeDefinition.getCsrfToken.controllerFn()); +// Get session information (e.x. is logged in, is pending verification, etc - this drives UI) +routes.get('/session', LAX_AuthRateLimit, authController.routeDefinition.getSession.controllerFn()); + +// Init OAuth flow +routes.post('/signin/:provider', STRICT_AuthRateLimit, verifyCaptcha, authController.routeDefinition.signin.controllerFn()); +// Login via OAuth or credentials +routes.get('/callback/:provider', STRICT_AuthRateLimit, authController.routeDefinition.callback.controllerFn()); +routes.post('/callback/:provider', STRICT_AuthRateLimit, verifyCaptcha, authController.routeDefinition.callback.controllerFn()); +// 2FA and email verification +routes.post('/verify', STRICT_AuthRateLimit, authController.routeDefinition.verification.controllerFn()); +routes.post('/verify/resend', STRICT_2X_AuthRateLimit, authController.routeDefinition.resendVerification.controllerFn()); +// Request a password reset +routes.post( + '/password/reset/init', + STRICT_2X_AuthRateLimit, + verifyCaptcha, + authController.routeDefinition.requestPasswordReset.controllerFn() +); +// Finish resetting password +routes.post('/password/reset/verify', STRICT_AuthRateLimit, authController.routeDefinition.validatePasswordReset.controllerFn()); + +export default routes; diff --git a/apps/api/src/app/routes/index.ts b/apps/api/src/app/routes/index.ts index 3c07d08ae..c35dd1e70 100644 --- a/apps/api/src/app/routes/index.ts +++ b/apps/api/src/app/routes/index.ts @@ -1,7 +1,8 @@ import apiRoutes from './api.routes'; +import authRoutes from './auth.routes'; import oauthRoutes from './oauth.routes'; import platformEventRoutes from './platform-event.routes'; import staticAuthenticatedRoutes from './static-authenticated.routes'; import testRoutes from './test.routes'; -export { apiRoutes, platformEventRoutes, oauthRoutes, staticAuthenticatedRoutes, testRoutes }; +export { apiRoutes, authRoutes, oauthRoutes, platformEventRoutes, staticAuthenticatedRoutes, testRoutes }; diff --git a/apps/api/src/app/routes/oauth.routes.ts b/apps/api/src/app/routes/oauth.routes.ts index 5172950d3..6fd2ef85e 100644 --- a/apps/api/src/app/routes/oauth.routes.ts +++ b/apps/api/src/app/routes/oauth.routes.ts @@ -1,50 +1,10 @@ -import { ENV } from '@jetstream/api-config'; import * as express from 'express'; import Router from 'express-promise-router'; -import * as passport from 'passport'; -import * as authController from '../controllers/auth.controller'; import { routeDefinition as oauthController } from '../controllers/oauth.controller'; import { checkAuth } from './route.middleware'; export const routes: express.Router = Router(); -// https://auth0.com/docs/universal-login/new-experience#signup -routes.get( - '/signup', - passport.authenticate('auth0', { - scope: 'openid email profile', - screen_hint: 'signup', - } as any), - authController.login -); - -routes.get( - '/login', - passport.authenticate( - ['custom', 'auth0'].filter((item) => item === 'auth0' || (ENV.EXAMPLE_USER_OVERRIDE && ENV.EXAMPLE_USER)), - { - scope: 'openid email profile', - } - ), - authController.login -); -routes.get('/callback', authController.callback); -routes.get('/logout', authController.logout); - -// Link additional accounts -routes.get('/identity/link', (req: express.Request, res: express.Response, next: express.NextFunction) => { - const options: passport.AuthenticateOptions & { connection?: string } = { - scope: 'openid email profile', - prompt: 'select_account', - }; - - if (req.query.connection) { - options.connection = req.query.connection as string; - } - passport.authorize('auth0-authz', options as any)(req, res, next); -}); -routes.get('/identity/link/callback', authController.linkCallback); - // salesforce org authentication routes.get('/sfdc/auth', checkAuth, oauthController.salesforceOauthInitAuth.controllerFn()); routes.get('/sfdc/callback', checkAuth, oauthController.salesforceOauthCallback.controllerFn()); diff --git a/apps/api/src/app/routes/route.middleware.ts b/apps/api/src/app/routes/route.middleware.ts index 6eed7bb71..2b97fc6fe 100644 --- a/apps/api/src/app/routes/route.middleware.ts +++ b/apps/api/src/app/routes/route.middleware.ts @@ -1,16 +1,15 @@ -import { ENV, getExceptionLog, logger, rollbarServer, telemetryAddUserToAttributes } from '@jetstream/api-config'; +import { ENV, getExceptionLog, logger, telemetryAddUserToAttributes } from '@jetstream/api-config'; +import { AuthError, ExpiredVerificationToken, InvalidCaptcha, checkUserAgentSimilarity } from '@jetstream/auth/server'; +import { UserProfileSession } from '@jetstream/auth/types'; import { ApiConnection, getApiRequestFactoryFn } from '@jetstream/salesforce-api'; import { HTTP } from '@jetstream/shared/constants'; import { ensureBoolean } from '@jetstream/shared/utils'; -import { ApplicationCookie, UserProfileServer } from '@jetstream/types'; -import { AxiosError } from 'axios'; -import { addDays, fromUnixTime, getUnixTime } from 'date-fns'; +import { ApplicationCookie } from '@jetstream/types'; +import { getUnixTime } from 'date-fns'; import * as express from 'express'; -import { isNumber } from 'lodash'; import pino from 'pino'; import { v4 as uuid } from 'uuid'; import * as salesforceOrgsDb from '../db/salesforce-org.db'; -import { updateUserLastActivity } from '../services/auth0'; import { AuthenticationError, NotFoundError, UserFacingError } from '../utils/error-handler'; export function addContextMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) { @@ -82,77 +81,69 @@ export function blockBotByUserAgentMiddleware(req: express.Request, res: express next(); } -function getActivityExp() { - return getUnixTime(addDays(new Date(), 1)); +export function destroySessionIfPendingVerificationIsExpired(req: express.Request, res: express.Response, next: express.NextFunction) { + if (req.session?.pendingVerification?.length) { + const { exp } = req.session.pendingVerification[0]; + if (exp < new Date().getTime()) { + req.session.destroy(() => { + next(new ExpiredVerificationToken()); + }); + return; + } + } + next(); } -export async function checkAuth(req: express.Request, res: express.Response, next: express.NextFunction) { - if (ENV.EXAMPLE_USER_OVERRIDE && ENV.EXAMPLE_USER && req.hostname === 'localhost') { - req.user = ENV.EXAMPLE_USER; - return next(); +export async function redirectIfPendingVerificationMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) { + if (req.session?.pendingVerification?.length) { + const { exp } = req.session.pendingVerification[0]; + if (exp < getUnixTime(new Date())) { + req.session.destroy(() => { + return next(new ExpiredVerificationToken()); + }); + } + + const isJson = (req.get(HTTP.HEADERS.ACCEPT) || '').includes(HTTP.CONTENT_TYPE.JSON); + + if (!isJson) { + res.redirect(302, `/auth/verify`); + return; + } else { + next(new AuthError('Pending verification token')); + return; + } } - if (req.user) { - telemetryAddUserToAttributes(req.user as UserProfileServer); - try { - if (!isNumber(req.session.activityExp)) { - req.session.activityExp = getActivityExp(); - } else if (req.session.activityExp < getUnixTime(new Date())) { - req.session.activityExp = getActivityExp(); - // Update auth0 with expiration date - updateUserLastActivity(req.user as UserProfileServer, fromUnixTime(req.session.activityExp)) - .then(() => { - req.log.debug( - { - userId: (req.user as any)?.user_id, - requestId: res.locals.requestId, - }, - '[AUTH][LAST-ACTIVITY][UPDATED] %s', - req.session.activityExp - ); - }) - .catch((err) => { - // send error to rollbar - const error: AxiosError = err; - if (error.response) { - req.log.error( - { - userId: (req.user as any)?.user_id, - requestId: res.locals.requestId, - ...getExceptionLog(err), - }, - '[AUTH][LAST-ACTIVITY][ERROR] %o', - error.response.data - ); - } else if (error.request) { - req.log.error( - { - userId: (req.user as any)?.user_id, - requestId: res.locals.requestId, - ...getExceptionLog(err), - }, - '[AUTH][LAST-ACTIVITY][ERROR] %s', - error.message || 'An unknown error has occurred.' - ); - } - rollbarServer.error('Error updating Auth0 activityExp', req, { - context: `route#activityExp`, - custom: { - ...getExceptionLog(err), - url: req.url, - params: req.params, - query: req.query, - body: req.body, - userId: (req.user as UserProfileServer)?.id, - requestId: res.locals.requestId, - }, - }); - }); - } - } catch (ex) { - req.log.warn(getExceptionLog(ex), '[AUTH][LAST-ACTIVITY][ERROR] Exception: %s', ex.message); + next(); +} + +export async function checkAuth(req: express.Request, res: express.Response, next: express.NextFunction) { + const userAgent = req.get('User-Agent'); + + const user = req.session.user; + const pendingVerification = req.session.pendingVerification; + + if (req.session.userAgent && req.session.userAgent !== userAgent) { + if (!checkUserAgentSimilarity(req.session.userAgent, userAgent || '')) { + req.log.error(`[AUTH][UNAUTHORIZED] User-Agent mismatch: ${req.session.userAgent} !== ${userAgent}`); + req.session.destroy((err) => { + if (err) { + logger.error({ ...getExceptionLog(err) }, '[AUTH][UNAUTHORIZED][ERROR] Error destroying session'); + } + // TODO: Send email to user about potential suspicious activity + next(new AuthenticationError('Unauthorized')); + }); + return; } + } + + // TODO: consider adding a check for IP address - but should allow some buffer in case people change networks + // especially if the ip addresses are very far away + + if (user && !pendingVerification) { + telemetryAddUserToAttributes(user); return next(); } + req.log.error('[AUTH][UNAUTHORIZED]'); next(new AuthenticationError('Unauthorized')); } @@ -186,6 +177,9 @@ export async function addOrgsToLocal(req: express.Request, res: express.Response } } catch (ex) { req.log.warn(getExceptionLog(ex), '[INIT-ORG][ERROR]'); + if (ex instanceof NotFoundError) { + return next(ex); + } return next(new UserFacingError('There was an error initializing the connection to Salesforce')); } @@ -225,7 +219,8 @@ export async function getOrgFromHeaderOrQuery(req: express.Request, headerKey: s const includeCallOptions = ensureBoolean( req.get(HTTP.HEADERS.X_INCLUDE_CALL_OPTIONS) || (req.query.includeCallOptions as string | undefined) ); - const user = req.user as UserProfileServer; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const user = req.session.user!; if (!uniqueId) { return; @@ -235,7 +230,7 @@ export async function getOrgFromHeaderOrQuery(req: express.Request, headerKey: s } export async function getOrgForRequest( - user: UserProfileServer, + user: UserProfileSession, uniqueId: string, logger: pino.Logger | typeof console = console, apiVersion?: string, @@ -244,7 +239,7 @@ export async function getOrgForRequest( ) { const org = await salesforceOrgsDb.findByUniqueId_UNSAFE(user.id, uniqueId); if (!org) { - throw new UserFacingError('An org was not found with the provided id'); + throw new NotFoundError('An org with the provided id does not exist'); } const { accessToken: encryptedAccessToken, instanceUrl, orgNamespacePrefix, userId, organizationId } = org; @@ -293,3 +288,41 @@ export async function getOrgForRequest( return { org, jetstreamConn }; } + +export function verifyCaptcha(req: express.Request, res: express.Response, next: express.NextFunction) { + if (!ENV.CAPTCHA_SECRET_KEY || ENV.IS_CI) { + return next(); + } + const token = req.body?.[ENV.CAPTCHA_PROPERTY]; + if (!token) { + return next(new InvalidCaptcha()); + } + + fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + body: JSON.stringify({ + secret: ENV.CAPTCHA_SECRET_KEY, + response: token, + remoteip: req.ip, + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then((res) => { + if (res.ok) { + return res.json(); + } + throw new InvalidCaptcha(); + }) + .then((res) => { + if (res.success) { + return next(); + } + logger.warn({ token, res }, '[CAPTCHA][FAILED]'); + throw new InvalidCaptcha(); + }) + .catch((err) => { + next(new InvalidCaptcha()); + }); +} diff --git a/apps/api/src/app/routes/test.routes.ts b/apps/api/src/app/routes/test.routes.ts index cc114e444..389c4add0 100644 --- a/apps/api/src/app/routes/test.routes.ts +++ b/apps/api/src/app/routes/test.routes.ts @@ -1,4 +1,5 @@ -import { ENV } from '@jetstream/api-config'; +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { ENV, logger } from '@jetstream/api-config'; import { ApiConnection, getApiRequestFactoryFn } from '@jetstream/salesforce-api'; import * as express from 'express'; import Router from 'express-promise-router'; @@ -9,62 +10,51 @@ import { sendJson } from '../utils/response.handlers'; const routes: express.Router = Router(); +routes.use((req, res, next) => { + const E2E_LOGIN_URL = process.env.E2E_LOGIN_URL; + const E2E_LOGIN_USERNAME = process.env.E2E_LOGIN_USERNAME; + const E2E_LOGIN_PASSWORD = process.env.E2E_LOGIN_PASSWORD; + + if (!E2E_LOGIN_URL || !E2E_LOGIN_USERNAME || !E2E_LOGIN_PASSWORD || !ENV.EXAMPLE_USER || req.hostname !== 'localhost') { + return next(new NotAllowedError('Route not allowed in this environment')); + } + next(); +}); + /** * Create an org for the integration user to use in testing * this avoids the need to perform oauth for integration test environment */ -routes.post( - '/e2e-integration-org', - (req: express.Request, res: express.Response, next: express.NextFunction) => { - const E2E_LOGIN_USERNAME = process.env.E2E_LOGIN_USERNAME; - const E2E_LOGIN_PASSWORD = process.env.E2E_LOGIN_PASSWORD; - const E2E_LOGIN_URL = process.env.E2E_LOGIN_URL; - - if ( - // && ENV.ENVIRONMENT === 'test' - E2E_LOGIN_USERNAME && - E2E_LOGIN_PASSWORD && - E2E_LOGIN_URL && - ENV.EXAMPLE_USER_OVERRIDE && - ENV.EXAMPLE_USER && - req.hostname === 'localhost' - ) { - req.user = ENV.EXAMPLE_USER; - return next(); - } - return next(new NotAllowedError('Route not allowed in this environment')); - }, - async (req: express.Request, res: express.Response) => { - const E2E_LOGIN_USERNAME = process.env.E2E_LOGIN_USERNAME; - const E2E_LOGIN_PASSWORD = process.env.E2E_LOGIN_PASSWORD; - const E2E_LOGIN_URL = process.env.E2E_LOGIN_URL!; - - const { id, access_token, instance_url } = await salesforceLoginUsernamePassword_UNSAFE( - E2E_LOGIN_URL, - E2E_LOGIN_USERNAME!, - E2E_LOGIN_PASSWORD! - ); - const [userId, organizationId] = new URL(id).pathname.split('/').reverse(); - - console.log({ organizationId, userId }); - - const jetstreamConn = new ApiConnection({ - apiRequestAdapter: getApiRequestFactoryFn(fetch), - userId, - organizationId, - accessToken: access_token, - apiVersion: ENV.SFDC_API_VERSION, - instanceUrl: instance_url, - logging: false, - }); - - const salesforceOrg = await initConnectionFromOAuthResponse({ - jetstreamConn, - userId: 'EXAMPLE_USER', - }); - - sendJson(res, salesforceOrg); - } -); +routes.post('/e2e-integration-org', async (req: express.Request, res: express.Response, next: express.NextFunction) => { + const E2E_LOGIN_URL = process.env.E2E_LOGIN_URL!; + const E2E_LOGIN_USERNAME = process.env.E2E_LOGIN_USERNAME!; + const E2E_LOGIN_PASSWORD = process.env.E2E_LOGIN_PASSWORD!; + + const { id, access_token, instance_url } = await salesforceLoginUsernamePassword_UNSAFE( + E2E_LOGIN_URL, + E2E_LOGIN_USERNAME, + E2E_LOGIN_PASSWORD + ); + const [userId, organizationId] = new URL(id).pathname.split('/').reverse(); + + logger.info({ organizationId, userId }); + + const jetstreamConn = new ApiConnection({ + apiRequestAdapter: getApiRequestFactoryFn(fetch), + userId, + organizationId, + accessToken: access_token, + apiVersion: ENV.SFDC_API_VERSION, + instanceUrl: instance_url, + logging: false, + }); + + const salesforceOrg = await initConnectionFromOAuthResponse({ + jetstreamConn, + userId: ENV.EXAMPLE_USER!.id, + }); + + sendJson(res as any, salesforceOrg); +}); export default routes; diff --git a/apps/api/src/app/services/auth0.ts b/apps/api/src/app/services/auth0.ts deleted file mode 100644 index f6a407324..000000000 --- a/apps/api/src/app/services/auth0.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { ENV, logger } from '@jetstream/api-config'; -import { UserProfileAuth0Identity, UserProfileAuth0Ui, UserProfileServer, UserProfileUiWithIdentities } from '@jetstream/types'; -import axios, { AxiosError } from 'axios'; -import { addHours, addSeconds, formatISO, isBefore } from 'date-fns'; -import * as userDb from '../db/user.db'; -import { UserFacingError } from '../utils/error-handler'; - -interface TokenResponse { - access_token: string; - scope: string; - expires_in: number; - token_type: 'Bearer'; -} - -const USER_FIELDS = ['user_id', 'email', 'email_verified', 'identities', 'name', 'nickname', 'picture', 'app_metadata', 'username']; - -const BASE_URL = `https://${ENV.AUTH0_M2M_DOMAIN}`; - -const axiosAuth0 = axios.create({ - baseURL: `https://${ENV.AUTH0_M2M_DOMAIN}/`, -}); - -let _accessToken: string; -let _expires: Date; - -async function initAuthorizationToken(user: UserProfileServer) { - try { - if (_accessToken && _expires && isBefore(new Date(), _expires)) { - logger.info( { userId: user.id }, '[AUTH0] Using existing M2M token',); - return; - } - - logger.info('[AUTH0][M2M] Obtaining auth token'); - const response = await axiosAuth0.post(`/oauth/token`, { - grant_type: 'client_credentials', - client_id: ENV.AUTH0_MGMT_CLIENT_ID, - client_secret: ENV.AUTH0_MGMT_CLIENT_SECRET, - audience: `${BASE_URL}/api/v2/`, - }); - - _accessToken = response.data.access_token; - _expires = addHours(addSeconds(new Date(), response.data.expires_in), -1); - axiosAuth0.defaults.headers.common['Authorization'] = `Bearer ${_accessToken}`; - } catch (ex) { - logger.error( { userId: user.id }, '[AUTH0][M2M][ERROR] Obtaining token %s', ex.message,); - if (ex.isAxiosError) { - const error: AxiosError = ex; - if (error.response) { - logger.error( { userId: user.id }, '[AUTH0][M2M][ERROR][RESPONSE] %o', error.response.data,); - } else if (error.request) { - logger.error( { userId: user.id }, '[AUTH0][M2M][ERROR][REQUEST] %s', error.message || 'An unknown error has occurred.',); - } - } - throw new UserFacingError('An unknown error has occurred'); - } -} - -export async function getUser(user: UserProfileServer): Promise { - await initAuthorizationToken(user); - const response = await axiosAuth0.get(`/api/v2/users/${user.id}`, { - params: { - fields: USER_FIELDS.join(','), - include_fields: true, - }, - }); - - return response.data; -} - -export async function updateUserLastActivity(user: UserProfileServer, lastActivity: Date): Promise { - await initAuthorizationToken(user); - return ( - await axiosAuth0.patch(`/api/v2/users/${user.id}`, { - app_metadata: { lastActivity: formatISO(lastActivity, { representation: 'date' }) }, - }) - ).data; -} - -export async function updateUser(user: UserProfileServer, userProfile: UserProfileUiWithIdentities): Promise { - await initAuthorizationToken(user); - - if (user.displayName !== userProfile.name) { - // update on Auth0 if name changed (not allowed for OAuth connections) - await axiosAuth0.patch(`/api/v2/users/${user.id}`, { name: userProfile.name }); - } - // update locally - await userDb.updateUser(user, userProfile); - // re-fetch user from Auth0 - return await getUser(user); -} - -export async function deleteUser(user: UserProfileServer): Promise { - await initAuthorizationToken(user); - await axiosAuth0.delete(`/api/v2/users/${user.id}`); -} - -/** - * Link two accounts - * This should only be called after successful authorization of the second identity - */ -export async function linkIdentity(user: UserProfileServer, newUserId: string): Promise { - await initAuthorizationToken(user); - - const [provider, user_id] = newUserId.split('|'); - logger.info({ userId: user.id, provider, secondaryUserId: user_id }, '[AUTH0][IDENTITY][LINK] %s', newUserId); - await axiosAuth0.post(`/api/v2/users/${user.id}/identities`, { provider, user_id }); - - return await getUser(user); -} - -export async function unlinkIdentity( - user: UserProfileServer, - { provider, userId }: { provider: string; userId: string } -): Promise { - await initAuthorizationToken(user); - - // TODO: handle better if one step fails - logger.info({ userId: user.id, provider, unlinkedUserId: userId }, '[AUTH0][IDENTITY][UNLINK+DELETING]'); - await axiosAuth0.delete(`/api/v2/users/${user.id}/identities/${provider}/${userId}`); - - const userIdToDelete = `${provider}|${userId}`; - await axiosAuth0.delete(`/api/v2/users/${userIdToDelete}`); - - return await getUser(user); -} - -export async function resendVerificationEmail(user: UserProfileServer, { provider, userId }: { provider: string; userId: string }) { - await initAuthorizationToken(user); - - await axiosAuth0.post(`/api/v2/jobs/verification-email`, { - user_id: user.user_id, - client_id: ENV.AUTH0_CLIENT_ID, - identity: { provider, user_id: userId }, - }); -} diff --git a/apps/api/src/app/services/comtd/cometd-init.ts b/apps/api/src/app/services/comtd/cometd-init.ts index 93c1c8a1c..12ab2f2e8 100644 --- a/apps/api/src/app/services/comtd/cometd-init.ts +++ b/apps/api/src/app/services/comtd/cometd-init.ts @@ -2,12 +2,12 @@ * ENDED UP NOT USING THIS STUFF */ import { ENV, logger } from '@jetstream/api-config'; +import { UserProfileSession } from '@jetstream/auth/types'; import { ApiConnection } from '@jetstream/salesforce-api'; -import { UserProfileServer } from '@jetstream/types'; import { CometD } from 'cometd'; import { CometdReplayExtension } from './cometd-replay-extension'; -export function initCometD(user: UserProfileServer, cometd: CometD, jetstreamConn: ApiConnection) { +export function initCometD(user: UserProfileSession, cometd: CometD, jetstreamConn: ApiConnection) { return new Promise((resolve, reject) => { if (cometd.isDisconnected()) { // This appears to be unsupported diff --git a/apps/api/src/app/services/oauth.service.ts b/apps/api/src/app/services/oauth.service.ts index ce1ee05ec..24dcd693f 100644 --- a/apps/api/src/app/services/oauth.service.ts +++ b/apps/api/src/app/services/oauth.service.ts @@ -48,7 +48,7 @@ export function salesforceOauthInit( login_hint: loginHint, nonce, prompt: 'login', - scope: 'api web refresh_token', + scope: 'api refresh_token', state, }; @@ -97,6 +97,8 @@ export async function salesforceOauthRefresh(loginUrl: string, refreshToken: str /** * Login to Salesforce using username and password + * + * This is only used in tests to get an access_token to avoid having to go through OAuth+redirect flow */ export async function salesforceLoginUsernamePassword_UNSAFE( loginUrl: string, diff --git a/apps/api/src/app/services/worker-jobs.ts b/apps/api/src/app/services/worker-jobs.ts deleted file mode 100644 index 13ca4b6bd..000000000 --- a/apps/api/src/app/services/worker-jobs.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ENV, getExceptionLog, logger } from '@jetstream/api-config'; -import { User } from '@prisma/client'; -import axios, { AxiosError } from 'axios'; - -export async function sendWelcomeEmail(user: User) { - try { - await axios.request({ - baseURL: ENV.JETSTREAM_WORKER_URL, - url: '/job', - method: 'POST', - data: { - type: 'EMAIL', - payload: { - type: 'WELCOME', - userId: user.userId, - email: user.email, - }, - }, - }); - } catch (ex) { - if (ex.isAxiosError) { - if (ex.response) { - const errorResponse = (ex as AxiosError).response; - logger.error(getExceptionLog(ex), '[WORKER-SERVICE][WELCOME EMAIL][ERROR] %s %o', errorResponse?.status, errorResponse?.data); - } else { - logger.error(getExceptionLog(ex), '[WORKER-SERVICE][WELCOME EMAIL][ERROR] Unknown error occurred'); - } - } - } -} diff --git a/apps/api/src/app/types/types.ts b/apps/api/src/app/types/types.ts index f99ffe26e..80bb249b1 100644 --- a/apps/api/src/app/types/types.ts +++ b/apps/api/src/app/types/types.ts @@ -1,3 +1,4 @@ +import { ResponseLocalsCookies } from '@jetstream/auth/types'; import { ApiConnection } from '@jetstream/salesforce-api'; import { SalesforceOrg } from '@prisma/client'; import type { Request as ExpressRequest, Response as ExpressResponse } from 'express'; @@ -27,5 +28,12 @@ export type Response = ExpressResponse< org: SalesforceOrg; targetJetstreamConn?: ApiConnection; targetOrg?: SalesforceOrg; + /** + * Cookie configuration + * This is used to store all the cookies that need to be set or cleared + * This ensures that if we want to clear and set the same cookie, we can just set it without worrying about clearing it first + * which simplifies having the same header specified twice + */ + cookies?: ResponseLocalsCookies; } > & { log: pino.Logger }; diff --git a/apps/api/src/app/utils/auth-utils.ts b/apps/api/src/app/utils/auth-utils.ts deleted file mode 100644 index 1f0ae00fc..000000000 --- a/apps/api/src/app/utils/auth-utils.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as Auth0Strategy from 'passport-auth0'; - -interface AuthorizationParamsOptions { - audience?: string; - connection?: string; - prompt?: string; - screen_hint?: string; - connection_scope?: string; - login_hint?: string; - acr_values?: string; - maxAge?: number; - nonce?: string; -} - -// Monkey Patch Auth0Strategy to allow directing a user to the login page -// :sob: - https://github.com/auth0/passport-auth0/issues/53 -// https://auth0.com/docs/universal-login/new-experience#signup -// https://github.com/auth0/passport-auth0/blob/096f789bea36a45a18d1a06accdd73decfb65131/lib/index.js#L99 -(Auth0Strategy as any).prototype.authorizationParams = function (options: AuthorizationParamsOptions) { - options = options || {}; - - const params: any = {}; - if (options.connection && typeof options.connection === 'string') { - params.connection = options.connection; - - if (options.connection_scope && typeof options.connection_scope === 'string') { - params.connection_scope = options.connection_scope; - } - } - - if (options.audience && typeof options.audience === 'string') { - params.audience = options.audience; - } - - if (options.prompt && typeof options.prompt === 'string') { - params.prompt = options.prompt; - } - - if (options.login_hint && typeof options.login_hint === 'string') { - params.login_hint = options.login_hint; - } - - // This was added - now screen_hint can be passed as an Passport option - if (options.screen_hint && typeof options.screen_hint === 'string') { - params.screen_hint = options.screen_hint; - } - - if (options.acr_values && typeof options.acr_values === 'string') { - params.acr_values = options.acr_values; - } - - const strategyOptions = this.options; - if (strategyOptions && typeof strategyOptions.maxAge === 'number') { - params.max_age = strategyOptions.maxAge; - } - - if (this.authParams && typeof this.authParams.nonce === 'string') { - params.nonce = this.authParams.nonce; - } - - return params; -}; diff --git a/apps/api/src/app/utils/error-handler.ts b/apps/api/src/app/utils/error-handler.ts index 3909635e0..dc8b1da21 100644 --- a/apps/api/src/app/utils/error-handler.ts +++ b/apps/api/src/app/utils/error-handler.ts @@ -2,16 +2,24 @@ import { logger } from '@jetstream/api-config'; import { ApiRequestError } from '@jetstream/salesforce-api'; import { ZodError } from 'zod'; +function initStatus(data: unknown, fallback: number) { + if (data && typeof data === 'object' && 'status' in data && typeof data.status === 'number') { + return data.status; + } + return fallback; +} + /* eslint-disable @typescript-eslint/no-explicit-any */ export class UserFacingError extends Error { + readonly status: number; /** * This data is propagated so that response can include the http status code */ - apiRequestError?: ApiRequestError; + readonly apiRequestError?: ApiRequestError; /** * additionalData will be included in http response */ - additionalData?: any; + readonly additionalData?: any; constructor(message: string | Error | ZodError, additionalData?: any) { if (message instanceof ZodError) { const errorDetails = Object.values( @@ -49,6 +57,8 @@ export class UserFacingError extends Error { this.additionalData = additionalData; } + this.status = initStatus(message, 400); + if (message instanceof ApiRequestError) { this.apiRequestError = message; } @@ -56,7 +66,8 @@ export class UserFacingError extends Error { } export class AuthenticationError extends Error { - additionalData?: any; + readonly status: number; + readonly additionalData?: any; constructor(message: string | Error, additionalData?: any) { if (message instanceof Error) { super(message.message); @@ -65,12 +76,14 @@ export class AuthenticationError extends Error { } else { super(message); } + this.status = initStatus(message, 401); this.additionalData = additionalData; } } export class NotFoundError extends Error { - additionalData?: any; + readonly status: number; + readonly additionalData?: any; constructor(message: string | Error, additionalData?: any) { if (message instanceof Error) { super(message.message); @@ -79,12 +92,14 @@ export class NotFoundError extends Error { } else { super(message); } + this.status = initStatus(message, 404); this.additionalData = additionalData; } } export class NotAllowedError extends Error { - additionalData?: any; + readonly status: number; + readonly additionalData?: any; constructor(message: string | Error, additionalData?: any) { logger.warn({ message, additionalData }, '[ROUTE NOT ALLOWED]'); if (message instanceof Error) { @@ -94,6 +109,7 @@ export class NotAllowedError extends Error { } else { super(message); } + this.status = initStatus(message, 403); this.additionalData = additionalData; } } diff --git a/apps/api/src/app/utils/response.handlers.ts b/apps/api/src/app/utils/response.handlers.ts index cf10a26e3..c940efb07 100644 --- a/apps/api/src/app/utils/response.handlers.ts +++ b/apps/api/src/app/utils/response.handlers.ts @@ -1,9 +1,12 @@ import { ENV, getExceptionLog, logger, prisma, rollbarServer } from '@jetstream/api-config'; +import { AuthError, createCSRFToken, getCookieConfig } from '@jetstream/auth/server'; import { ERROR_MESSAGES, HTTP } from '@jetstream/shared/constants'; -import { UserProfileServer } from '@jetstream/types'; +import { Maybe } from '@jetstream/types'; import { SalesforceOrg } from '@prisma/client'; +import { serialize } from 'cookie'; import * as express from 'express'; import * as salesforceOrgsDb from '../db/salesforce-org.db'; +import { Response } from '../types/types'; import { AuthenticationError, NotFoundError, UserFacingError } from './error-handler'; export async function healthCheck(req: express.Request, res: express.Response) { @@ -23,7 +26,46 @@ export async function healthCheck(req: express.Request, res: express.Response) { } } -export function sendJson(res: express.Response, content?: ResponseType, status = 200) { +export async function setCsrfCookie(res: Response) { + const { csrfToken, cookie: csrfCookie } = await createCSRFToken({ secret: ENV.JETSTREAM_AUTH_SECRET }); + const cookieConfig = getCookieConfig(ENV.ENVIRONMENT === 'production'); + res.locals.cookies = res.locals.cookies || {}; + res.locals.cookies[cookieConfig.csrfToken.name] = { + name: cookieConfig.csrfToken.name, + value: csrfCookie, + options: cookieConfig.csrfToken.options, + }; + return csrfToken; +} + +/** + * Sets all cookies stored in res.locals to actual headers + * This is centralized here to ensure all cookies are set and to avoid clearing and setting the same cookie + */ +function setCookieHeaders(res: Response) { + try { + Object.values(res.locals?.cookies || {}).forEach(({ name, options, clear, value }) => { + try { + if (clear) { + res.appendHeader('Set-Cookie', serialize(name, '', { ...options, expires: new Date(0) })); + return; + } + res.appendHeader('Set-Cookie', serialize(name, value, options)); + } catch (ex) { + logger.error({ ...getExceptionLog(ex) }, 'Error setting cookie: %s', name); + } + }); + } catch (ex) { + logger.error({ ...getExceptionLog(ex) }, 'Error setting cookies'); + } +} + +export function redirect(res: Response, url, status = 302) { + setCookieHeaders(res); + res.redirect(status, url); +} + +export function sendJson(res: Response, content?: ResponseType, status = 200) { if (res.headersSent) { res.log.warn('Response headers already sent'); try { @@ -38,12 +80,13 @@ export function sendJson(res: express.Response, content?: Re } return; } + setCookieHeaders(res); res.status(status); return res.json({ data: content || {} }); } export function blockBotHandler(req: express.Request, res: express.Response) { - res.log.debug('[BLOCKED REQUEST] %s %s'); + res.log.debug('[BLOCKED REQUEST]'); res.status(403).send('Forbidden'); } @@ -51,6 +94,7 @@ export function blockBotHandler(req: express.Request, res: express.Response) { // eslint-disable-next-line @typescript-eslint/no-unused-vars export async function uncaughtErrorHandler(err: any, req: express.Request, res: express.Response, next: express.NextFunction) { try { + setCookieHeaders(res as any); // Logger is not added to the response object in all cases depending on where error is encountered const responseLogger = res.log || logger; @@ -65,7 +109,7 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: params: req.params, query: req.query, body: req.body, - userId: (req.user as UserProfileServer)?.id, + userId: req.session.user?.id, requestId: res.locals.requestId, }, }); @@ -77,6 +121,11 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: const isJson = (req.get(HTTP.HEADERS.ACCEPT) || '').includes(HTTP.CONTENT_TYPE.JSON); + let status = err.status as Maybe; + if (typeof err?.status === 'number') { + res.status(err.status); + } + // If org had a connection error, ensure that the database is updated // TODO: what about alternate org? if ( @@ -102,22 +151,43 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: } } - if (err instanceof UserFacingError) { + if (err instanceof AuthError) { + res.status(status || 400); + // These errors are emitted during the authentication process + responseLogger.warn({ ...getExceptionLog(err, true), type: err.type }, '[RESPONSE][AUTH_ERROR]'); + if (isJson) { + return res.json({ + error: true, + errorType: err.type, + data: { + error: true, + errorType: err.type, + }, + }); + } + const params = new URLSearchParams({ error: err.type }).toString(); + return res.redirect(`${ENV.JETSTREAM_SERVER_URL}/auth/login/?${params}`); + } else if (err instanceof UserFacingError) { // Attempt to use response code from 3rd party request if we have it available - const statusCode = err.apiRequestError?.status || 400; + const statusCode = err.apiRequestError?.status || status || 400; res.status(statusCode); // TODO: should we log 400s? They happen a lot and are not necessarily errors we care about - responseLogger.debug({ ...getExceptionLog(err), statusCode }, '[RESPONSE][ERROR]'); + responseLogger.debug({ ...getExceptionLog(err, true), statusCode }, '[RESPONSE][ERROR]'); return res.json({ error: true, message: err.message, data: err.additionalData, }); } else if (err instanceof AuthenticationError) { + // This error is emitted when a user attempts to make a request taht requires authentication, but the user is not logged in responseLogger.warn({ ...getExceptionLog(err), statusCode: 401 }, '[RESPONSE][ERROR]'); - res.status(401); + res.status(status || 401); res.set(HTTP.HEADERS.X_LOGOUT, '1'); - res.set(HTTP.HEADERS.X_LOGOUT_URL, `${ENV.JETSTREAM_SERVER_URL}/oauth/login`); + let redirectUrl = `${ENV.JETSTREAM_SERVER_URL}/auth/login`; + if (req.session?.pendingVerification && req.session.pendingVerification.some(({ exp }) => exp > Date.now())) { + redirectUrl = `${ENV.JETSTREAM_SERVER_URL}/auth/verify?type=${req.session.pendingVerification[0].type}`; + } + res.set(HTTP.HEADERS.X_LOGOUT_URL, redirectUrl); if (isJson) { return res.json({ error: true, @@ -132,7 +202,7 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: } } else if (err instanceof NotFoundError) { responseLogger.warn({ ...getExceptionLog(err), statusCode: 404 }, '[RESPONSE][ERROR]'); - res.status(404); + res.status(status || 404); if (isJson) { return res.json({ error: true, @@ -156,7 +226,7 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: params: req.params, query: req.query, body: req.body, - userId: (req.user as UserProfileServer)?.id, + userId: req.session.user?.id, requestId: res.locals.requestId, }, }); @@ -165,12 +235,11 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: } const errorMessage = 'There was an error processing the request'; - let status = err.status || 500; - if (status < 100 || status > 500) { + if (!status || status < 100 || status > 500) { status = 500; } res.status(status); - responseLogger.warn({ ...getExceptionLog(err), statusCode: 500 }, '[RESPONSE][ERROR]'); + responseLogger.warn({ ...getExceptionLog(err, true), statusCode: 500 }, '[RESPONSE][ERROR]'); // Return JSON error response for all other scenarios return res.json({ error: errorMessage, @@ -178,7 +247,7 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res: data: err.data, }); } catch (ex) { - logger.error(getExceptionLog(ex), 'Error in uncaughtErrorHandler'); + logger.error(getExceptionLog(ex, true), 'Error in uncaughtErrorHandler'); res.status(500).json({ error: true, message: 'Internal Server Error' }); } } diff --git a/apps/api/src/app/utils/route.utils.ts b/apps/api/src/app/utils/route.utils.ts index d5b1c64a0..a2489e13d 100644 --- a/apps/api/src/app/utils/route.utils.ts +++ b/apps/api/src/app/utils/route.utils.ts @@ -1,6 +1,6 @@ import { getExceptionLog, rollbarServer } from '@jetstream/api-config'; +import { CookieOptions, UserProfileSession } from '@jetstream/auth/types'; import { ApiConnection } from '@jetstream/salesforce-api'; -import { UserProfileServer } from '@jetstream/types'; import { NextFunction } from 'express'; import { z } from 'zod'; import { findByUniqueId_UNSAFE } from '../db/salesforce-org.db'; @@ -20,9 +20,11 @@ export type ControllerFunction; body: z.infer; query: z.infer; + setCookie: (name: string, value: string, options: CookieOptions) => void; + clearCookie: (name: string, options: CookieOptions) => void; jetstreamConn: ApiConnection; targetJetstreamConn: ApiConnection; - user: UserProfileServer; + user: UserProfileSession; requestId: string; org: NonNullable>>; targetOrg: NonNullable>>; @@ -54,6 +56,7 @@ export function createRoute, res: Response, next: NextFunction) => { try { + res.locals.cookies = res.locals.cookies || {}; const data = { params: params ? params.parse(req.params) : undefined, body: body ? body.parse(req.body) : undefined, @@ -64,8 +67,20 @@ export function createRoute>>, // this will exist if targetJetstreamConn exists, otherwise will throw targetOrg: res.locals.targetOrg as NonNullable>>, - user: req.user as UserProfileServer, + user: req.session.user!, requestId: res.locals.requestId, + setCookie: (name: string, value: string, options: CookieOptions) => { + res.locals.cookies = res.locals.cookies || {}; + res.locals.cookies[name] = { name, value, options }; + }, + clearCookie: (name: string, options: CookieOptions) => { + res.locals.cookies = res.locals.cookies || {}; + res.locals.cookies[name] = { + clear: true, + name, + options, + }; + }, }; if (hasSourceOrg && !data.jetstreamConn) { req.log.info('[INIT-ORG][ERROR] A source org did not exist on locals'); @@ -75,7 +90,11 @@ export function createRoute; socket: Socket; - user: UserProfileServer; + user: UserProfileSession; cometdConnections: Record< string, { @@ -19,7 +19,7 @@ export interface SocketConnectionState { >; } -export async function getOrg(user: UserProfileServer, uniqueId: string) { +export async function getOrg(user: UserProfileSession, uniqueId: string) { if (!user || !uniqueId) { throw new UserFacingError('An org was not found with the provided id'); } @@ -29,7 +29,7 @@ export async function getOrg(user: UserProfileServer, uniqueId: string) { export function disconnectCometD( cometd: CometD, socket?: Socket, - user?: UserProfileServer + user?: UserProfileSession ) { if (cometd) { cometd.clearListeners(); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0cd952717..a26704f1e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,7 +1,10 @@ +import { ClusterMemoryStorePrimary } from '@express-rate-limit/cluster-memory-store'; import '@jetstream/api-config'; // this gets imported first to ensure as some items require early initialization import { ENV, getExceptionLog, httpLogger, logger, pgPool, prisma } from '@jetstream/api-config'; +import { hashPassword, pruneExpiredRecords } from '@jetstream/auth/server'; +import { SessionData as JetstreamSessionData, UserProfileSession } from '@jetstream/auth/types'; import { HTTP, SESSION_EXP_DAYS } from '@jetstream/shared/constants'; -import { Maybe } from '@jetstream/types'; +import { AsyncIntervalTimer } from '@jetstream/shared/node-utils'; import { json, raw, urlencoded } from 'body-parser'; import cluster from 'cluster'; import pgSimple from 'connect-pg-simple'; @@ -11,35 +14,41 @@ import proxy from 'express-http-proxy'; import session from 'express-session'; import helmet from 'helmet'; import { cpus } from 'os'; -import passport from 'passport'; -import Auth0Strategy from 'passport-auth0'; -import { Strategy as CustomStrategy } from 'passport-custom'; import { join } from 'path'; import { initSocketServer } from './app/controllers/socket.controller'; -import { apiRoutes, oauthRoutes, platformEventRoutes, staticAuthenticatedRoutes, testRoutes } from './app/routes'; +import { apiRoutes, authRoutes, oauthRoutes, platformEventRoutes, staticAuthenticatedRoutes, testRoutes } from './app/routes'; import { addContextMiddleware, blockBotByUserAgentMiddleware, + destroySessionIfPendingVerificationIsExpired, notFoundMiddleware, + redirectIfPendingVerificationMiddleware, setApplicationCookieMiddleware, } from './app/routes/route.middleware'; import { blockBotHandler, healthCheck, uncaughtErrorHandler } from './app/utils/response.handlers'; import { environment } from './environments/environment'; declare module 'express-session' { - interface SessionData { - activityExp: number; - orgAuth?: { code_verifier: string; nonce: string; state: string; loginUrl: string; jetstreamOrganizationId?: Maybe }; - } + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface SessionData extends JetstreamSessionData {} } // NOTE: render reports more CPUs than are actually available const CPU_COUNT = Math.min(cpus().length, 3); +if (ENV.NODE_ENV !== 'production' || cluster.isPrimary) { + setTimeout(() => { + new AsyncIntervalTimer(pruneExpiredRecords, { name: 'pruneExpiredRecords', intervalMs: /** 1 hour */ 60 * 60 * 1000, runOnInit: true }); + }, 1000 * 5); // Delay 5 seconds to allow for other services to start +} + if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { logger.info(`Number of CPUs is ${CPU_COUNT}`); logger.info(`Master ${process.pid} is running`); + const rateLimiterStore = new ClusterMemoryStorePrimary(); + rateLimiterStore.init(); + for (let i = 0; i < CPU_COUNT; i++) { cluster.fork(); } @@ -60,13 +69,16 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { }), cookie: { path: '/', - // httpOnly: true, secure: !ENV.IS_LOCAL_DOCKER && environment.production, // Set to two - if you don't login for 48 hours, then expire session - consider changing to 1 maxAge: 1000 * 60 * 60 * 24 * SESSION_EXP_DAYS, - // sameSite: 'strict', + httpOnly: true, + sameSite: 'lax', }, - secret: ENV.JETSTREAM_SESSION_SECRET, + // If previous key is provided, include both to allow for key rotation + secret: ENV.JETSTREAM_SESSION_SECRET_PREV + ? [ENV.JETSTREAM_SESSION_SECRET, ENV.JETSTREAM_SESSION_SECRET_PREV] + : ENV.JETSTREAM_SESSION_SECRET, resave: false, saveUninitialized: false, // This will extend the cookie expiration date if there is a request of any kind to a logged in user @@ -74,20 +86,8 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { name: 'sessionid', }); - passport.serializeUser(function (user, done) { - done(null, user); - }); - - passport.deserializeUser(function (user, done) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - done(null, user!); - }); - - const passportInitMiddleware = passport.initialize(); - const passportMiddleware = passport.session(); - const app = express(); - const httpServer = initSocketServer(app, [sessionMiddleware, passportInitMiddleware, passportMiddleware]); + const httpServer = initSocketServer(app, [sessionMiddleware]); if (environment.production) { app.set('trust proxy', 1); // required for environments such as heroku / {render?} @@ -112,6 +112,8 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { '*.rollbar.com', 'api.amplitude.com', 'api.cloudinary.com', + 'challenges.cloudflare.com', + 'ip-api.com', ], baseUri: ["'self'"], blockAllMixedContent: [], @@ -143,6 +145,7 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { '*.gstatic.com', '*.google-analytics.com', '*.googletagmanager.com', + 'challenges.cloudflare.com', ], scriptSrcAttr: ["'none'"], styleSrc: ["'self'", 'https:', "'unsafe-inline'"], @@ -169,60 +172,6 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { app.use(blockBotByUserAgentMiddleware); app.use(setApplicationCookieMiddleware); - /** Manual test user, skip Auth0 completely */ - passport.use( - 'custom', - new CustomStrategy(function (req, callback) { - if (req.hostname !== 'localhost' || !ENV.EXAMPLE_USER_OVERRIDE || !ENV.EXAMPLE_USER) { - return callback(new Error('Test user not enabled')); - } - - const user = ENV.EXAMPLE_USER; - req.user = user; - callback(null, user); - }) - ); - - passport.use( - 'auth0', - new Auth0Strategy( - { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - domain: ENV.AUTH0_DOMAIN!, - clientID: ENV.AUTH0_CLIENT_ID!, - clientSecret: ENV.AUTH0_CLIENT_SECRET!, - callbackURL: `${ENV.JETSTREAM_SERVER_URL}/oauth/callback`, - }, - (accessToken, refreshToken, extraParams, profile, done) => { - // accessToken is the token to call Auth0 API (not needed in the most cases) - // extraParams.id_token has the JSON Web Token - // profile has all the information from the user - return done(null, profile); - } - ) - ); - - /** This configuration is used for authorization, not authentication (e.x. link second identity to user) */ - passport.use( - 'auth0-authz', - new Auth0Strategy( - { - domain: ENV.AUTH0_DOMAIN!, - clientID: ENV.AUTH0_CLIENT_ID!, - clientSecret: ENV.AUTH0_CLIENT_SECRET!, - callbackURL: `${ENV.JETSTREAM_SERVER_URL}/oauth/identity/link/callback`, - }, - (accessToken, refreshToken, extraParams, profile, done) => { - // accessToken is the token to call Auth0 API (not needed in the most cases) - // extraParams.id_token has the JSON Web Token - // profile has all the information from the user - return done(null, profile); - } - ) - ); - - app.use(passportInitMiddleware); - app.use(passportMiddleware); app.use(httpLogger); // proxy must be provided prior to body parser to ensure streaming response @@ -230,6 +179,7 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { if (req.headers.origin?.includes('localhost')) { res.setHeader('Access-Control-Allow-Origin', req.headers.origin); + res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader( 'Access-Control-Expose-Headers', [ @@ -290,7 +240,10 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { app.use(json({ limit: '20mb', type: ['json', 'application/csp-report'] })); app.use(urlencoded({ extended: true })); + app.use(destroySessionIfPendingVerificationIsExpired); + app.use('/healthz', healthCheck); + app.use('/api/auth', authRoutes); app.use('/api', apiRoutes); app.use('/static', staticAuthenticatedRoutes); // these are routes that return files or redirect (e.x. NOT JSON) app.use('/oauth', oauthRoutes); // NOTE: there are also static files with same path @@ -299,10 +252,6 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { app.use('/test', testRoutes); } - // const server = app.listen(Number(ENV.PORT), () => { - // logger.info('Listening at http://localhost:' + ENV.PORT); - // }); - const server = httpServer.listen(Number(ENV.PORT), () => { logger.info('Listening at http://localhost:' + ENV.PORT); logger.info('[ENVIRONMENT]: ' + ENV.ENVIRONMENT); @@ -325,7 +274,7 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { if (environment.production || ENV.IS_CI) { app.use(express.static(join(__dirname, '../jetstream'))); - app.use('/app', (req: express.Request, res: express.Response) => { + app.use('/app', redirectIfPendingVerificationMiddleware, (req: express.Request, res: express.Response) => { res.sendFile(join(__dirname, '../jetstream/index.html')); }); } @@ -365,31 +314,37 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { }); } -if (ENV.EXAMPLE_USER_OVERRIDE && ENV.EXAMPLE_USER && (ENV.ENVIRONMENT !== 'production' || ENV.IS_CI)) { - const id = 'AAAAAAAA-0000-0000-0000-AAAAAAAAAAAA'; - logger.info('Upserting example user. id: %s', id); - prisma.user - .upsert({ - create: { - id, - userId: ENV.EXAMPLE_USER.user_id, - email: ENV.EXAMPLE_USER._json.email, - name: ENV.EXAMPLE_USER._json.name, - nickname: ENV.EXAMPLE_USER._json.nickname, - picture: ENV.EXAMPLE_USER._json.picture, - appMetadata: JSON.stringify(ENV.EXAMPLE_USER._json[ENV.AUTH_AUDIENCE!]), - deletedAt: null, - lastLoggedIn: new Date(), - preferences: { create: { skipFrontdoorLogin: false } }, - }, - update: {}, - where: { id }, - }) - .then(() => { +/** + * FIXME: Should this live somewhere else and be de-coupled with application? + */ +if (ENV.EXAMPLE_USER && ENV.EXAMPLE_USER_PASSWORD && (ENV.ENVIRONMENT !== 'production' || ENV.IS_CI)) { + (async () => { + try { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const passwordHash = await hashPassword(ENV.EXAMPLE_USER_PASSWORD!); + const user = ENV.EXAMPLE_USER as UserProfileSession; + logger.info('Upserting example user. id: %s', user.id); + await prisma.user.upsert({ + create: { + id: user.id, + userId: user.userId, + email: user.email, + emailVerified: user.emailVerified, + name: user.name, + password: passwordHash, + passwordUpdatedAt: new Date(), + deletedAt: null, + lastLoggedIn: new Date(), + preferences: { create: { skipFrontdoorLogin: false } }, + authFactors: { create: { type: '2fa-email', enabled: false } }, + }, + update: {}, + where: { id: user.id }, + }); logger.info('Example user created'); - }) - .catch((ex) => { - logger.error(getExceptionLog(ex), '[EXAMPLE_USER][ERROR] Fatal error, could not create example user'); + } catch (ex) { + logger.error(getExceptionLog(ex), '[EXAMPLE_USER][ERROR] Fatal error, could not upsert example user'); process.exit(1); - }); + } + })(); } diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 9f3d4de92..ae452d548 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -13,5 +13,6 @@ "compilerOptions": { "esModuleInterop": true, "strictNullChecks": true, + "jsx": "react" } } diff --git a/apps/cron-tasks/src/config/env-config.ts b/apps/cron-tasks/src/config/env-config.ts index 5ba31d295..17e446c8d 100644 --- a/apps/cron-tasks/src/config/env-config.ts +++ b/apps/cron-tasks/src/config/env-config.ts @@ -22,15 +22,6 @@ export const ENV = { JETSTREAM_POSTGRES_DBURI: process.env.JETSTREAM_POSTGRES_DBURI || process.env.JESTREAM_POSTGRES_DBURI, PRISMA_DEBUG: ensureBoolean(process.env.PRISMA_DEBUG), - // AUTH - AUTH0_DOMAIN: process.env.AUTH0_DOMAIN, - /** use for M2M tokens - in DEV this is the same, but different in production */ - AUTH0_M2M_DOMAIN: process.env.AUTH0_M2M_DOMAIN, - AUTH0_CLIENT_ID: process.env.AUTH0_CLIENT_ID, - AUTH0_CLIENT_SECRET: process.env.AUTH0_CLIENT_SECRET, - AUTH0_MGMT_CLIENT_ID: process.env.AUTH0_MGMT_CLIENT_ID, - AUTH0_MGMT_CLIENT_SECRET: process.env.AUTH0_MGMT_CLIENT_SECRET, - // MAILGUN MAILGUN_API_KEY: process.env.MAILGUN_API_KEY, MAILGUN_PUBLIC_KEY: process.env.MAILGUN_PUBLIC_KEY, diff --git a/apps/cron-tasks/src/utils/amplitude-dashboard-api.ts b/apps/cron-tasks/src/utils/amplitude-dashboard-api.ts index 7737983c1..3e462f1f7 100644 --- a/apps/cron-tasks/src/utils/amplitude-dashboard-api.ts +++ b/apps/cron-tasks/src/utils/amplitude-dashboard-api.ts @@ -3,7 +3,7 @@ import { ENV } from '../config/env-config'; import { logger } from '../config/logger.config'; import { AmplitudeChartResult } from './types'; -const axiosAuth0 = axios.create({ +const axiosClient = axios.create({ baseURL: `https://amplitude.com/api/3`, }); @@ -11,7 +11,7 @@ const BASIC_AUTH_HEADER = `Basic ${Buffer.from(`${ENV.AMPLITUDE_API_KEY}:${ENV.A export async function getAmplitudeChart(chartId: string) { logger.info(`getAmplitudeChart: ${chartId}`); - return await axiosAuth0 + return await axiosClient .get(`/chart/${chartId}/query`, { headers: { Authorization: BASIC_AUTH_HEADER, diff --git a/apps/cron-tasks/src/utils/auth0.ts b/apps/cron-tasks/src/utils/auth0.ts deleted file mode 100644 index 7959dc9c7..000000000 --- a/apps/cron-tasks/src/utils/auth0.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { getExceptionLog } from '@jetstream/api-config'; -import type { Auth0PaginatedResponse, UserProfileAuth0, UserProfileAuth0Ui } from '@jetstream/types'; -import axios, { AxiosError } from 'axios'; -import { addHours, addSeconds, isBefore } from 'date-fns'; -import { ENV } from '../config/env-config'; -import { logger } from '../config/logger.config'; -// import * as userDb from '../db/user.db'; - -interface TokenResponse { - access_token: string; - scope: string; - expires_in: number; - token_type: 'Bearer'; -} - -// const USER_FIELDS = ['user_id', 'email', 'email_verified', 'identities', 'name', 'nickname', 'picture', 'app_metadata', 'username']; - -const BASE_URL = `https://${ENV.AUTH0_M2M_DOMAIN}`; - -const axiosAuth0 = axios.create({ - baseURL: `https://${ENV.AUTH0_M2M_DOMAIN}/`, -}); - -let _accessToken: string; -let _expires: Date; - -async function initAuthorizationToken() { - try { - if (_accessToken && _expires && isBefore(new Date(), _expires)) { - logger.info('[AUTH0] Using existing M2M token'); - return; - } - - logger.info('[AUTH0][M2M] Obtaining auth token'); - const response = await axiosAuth0.post(`/oauth/token`, { - grant_type: 'client_credentials', - client_id: ENV.AUTH0_MGMT_CLIENT_ID, - client_secret: ENV.AUTH0_MGMT_CLIENT_SECRET, - audience: `${BASE_URL}/api/v2/`, - }); - - _accessToken = response.data.access_token; - _expires = addHours(addSeconds(new Date(), response.data.expires_in), -1); - axiosAuth0.defaults.headers.common['Authorization'] = `Bearer ${_accessToken}`; - } catch (ex) { - logger.error(getExceptionLog(ex), '[AUTH0][M2M][ERROR] Obtaining token'); - if (ex.isAxiosError) { - const error: AxiosError = ex; - if (error.response) { - logger.error(getExceptionLog(error), '[AUTH0][M2M][ERROR][RESPONSE] %o', error.response.data); - } else if (error.request) { - logger.error(getExceptionLog(error), '[AUTH0][M2M][ERROR][REQUEST] %s', error.message || 'An unknown error has occurred.'); - } - } - throw new Error('An unknown error has occurred'); - } -} - -// https://auth0.com/docs/api/management/v2#!/Users/get_users -export async function searchUsersPaginateAll(params: any = {}): Promise { - await initAuthorizationToken(); - let done = false; - let currPage = 0; - params.page = currPage; - params.per_page = 50; - params.include_totals = 'true'; - - let users: T[] = []; - while (!done) { - const response = await axiosAuth0.get>(`/api/v2/users`, { params }); - const { length, limit, users: _users } = response.data; - users = users.concat(_users); - currPage++; - params.page = currPage; - if (length < limit) { - done = true; - } - } - - return users; -} - -// export async function getUser(user: UserProfileServer): Promise { -// await initAuthorizationToken(user); -// const response = await axiosAuth0.get(`/api/v2/users/${user.id}`, { -// params: { -// fields: USER_FIELDS.join(','), -// include_fields: true, -// }, -// }); - -// return response.data; -// } - -export async function updateUser(userId: string, userProfile: { app_metadata: { accountDeletionDate: string } }): Promise { - await initAuthorizationToken(); - // update on Auth0 - await axiosAuth0.patch(`/api/v2/users/${userId}`, userProfile); - // update locally - // await userDb.updateUser(user, userProfile); - // re-fetch user from Auth0 - // return await getUser(user); -} - -export async function deleteUser(userId: string): Promise { - await initAuthorizationToken(); - await axiosAuth0.delete(`/api/v2/users/${userId}`); -} diff --git a/apps/cron-tasks/src/utils/cron-utils.ts b/apps/cron-tasks/src/utils/cron-utils.ts index db41f7a05..77e6fff1f 100644 --- a/apps/cron-tasks/src/utils/cron-utils.ts +++ b/apps/cron-tasks/src/utils/cron-utils.ts @@ -1,8 +1,7 @@ -import { UserProfileAuth0 } from '@jetstream/types'; import { prisma } from '../config/db.config'; import { logger } from '../config/logger.config'; -export async function deleteUserAndOrgs(user: UserProfileAuth0) { +export async function deleteUserAndOrgs(user: any) { if (!user?.user_id) { throw new Error('A valid user must be provided'); } diff --git a/apps/docs/docs/getting-started/overview.mdx b/apps/docs/docs/getting-started/overview.mdx index 42386b848..a879fb786 100644 --- a/apps/docs/docs/getting-started/overview.mdx +++ b/apps/docs/docs/getting-started/overview.mdx @@ -15,7 +15,7 @@ If you have questions or want to talk with a human, you can reach support by ema :::tip -If you haven't created a Jetstream account, you can [sign-up here](https://getjetstream.app/oauth/signup). +If you haven't created a Jetstream account, you can [sign up here](https://getjetstream.app/auth/signup/). ::: diff --git a/apps/jetstream-e2e/playwright.config.ts b/apps/jetstream-e2e/playwright.config.ts index 6defe88de..8cdb3a743 100644 --- a/apps/jetstream-e2e/playwright.config.ts +++ b/apps/jetstream-e2e/playwright.config.ts @@ -7,7 +7,8 @@ dotenv.config(); const ONE_SECOND = 1000; const THIRTY_SECONDS = 30 * ONE_SECOND; -const baseURL = process.env.CI ? 'http://localhost:3333/' : 'http://localhost:4200/'; +// const baseURL = process.env.CI ? 'http://localhost:3333/' : 'http://localhost:4200/'; +const baseURL = 'http://localhost:3333/'; // Ensure tests run via VSCode debugger are run from the root of the repo if (process.cwd().endsWith('/apps/jetstream-e2e')) { @@ -19,7 +20,7 @@ if (process.cwd().endsWith('/apps/jetstream-e2e')) { */ export default defineConfig({ ...nxE2EPreset(__filename, { testDir: './src' }), - globalSetup: require.resolve('./src/setup/global-setup.ts'), + // globalSetup: require.resolve('./src/setup/global-setup.ts'), retries: 3, expect: { timeout: THIRTY_SECONDS, @@ -39,8 +40,28 @@ export default defineConfig({ }, projects: [ { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + name: 'setup', + testMatch: /.*\.setup\.ts/, + use: { + ...devices['Desktop Chrome'], + }, + }, + { + name: 'teardown', + testMatch: /.*\.teardown\.ts/, + use: { + ...devices['Desktop Chrome'], + }, + }, + { + name: 'chrome', + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/user.json', + }, + testMatch: /.*\.spec\.ts/, + dependencies: ['setup'], + // teardown: 'teardown', }, // { diff --git a/apps/jetstream-e2e/src/fixtures/ApiRequestUtils.ts b/apps/jetstream-e2e/src/fixtures/ApiRequestUtils.ts index ecaa3e6ae..035322887 100644 --- a/apps/jetstream-e2e/src/fixtures/ApiRequestUtils.ts +++ b/apps/jetstream-e2e/src/fixtures/ApiRequestUtils.ts @@ -1,19 +1,40 @@ import { HTTP } from '@jetstream/shared/constants'; import { HttpMethod } from '@jetstream/types'; -import { APIRequestContext, APIResponse } from '@playwright/test'; +import { APIRequestContext, APIResponse, Page } from '@playwright/test'; export class ApiRequestUtils { + readonly page: Page; readonly BASE_URL: string; - readonly selectedOrgId: string; - readonly request: APIRequestContext; + readonly E2E_LOGIN_USERNAME: string; - constructor(selectedOrgId: string, request: APIRequestContext) { + request: APIRequestContext; + + selectedOrgId: string; + + constructor(page: Page, e2eLoginUsername: string) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.BASE_URL = process.env.JETSTREAM_SERVER_URL!; - this.selectedOrgId = selectedOrgId; + this.page = page; + this.E2E_LOGIN_USERNAME = e2eLoginUsername; + this.request = page.request; + } + + // Used to change request context for the page + setRequest(request: APIRequestContext) { this.request = request; } + async selectDefaultOrg() { + await this.page.goto('/app'); + await this.page.getByPlaceholder('Select an Org').click(); + await this.page.getByRole('option', { name: this.E2E_LOGIN_USERNAME }).click(); + + this.selectedOrgId = await this.page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return window.atob(localStorage.getItem('SELECTED_ORG')!); + }); + } + async makeRequest(method: HttpMethod, path: string, data?: unknown, headers?: Record): Promise { const response = await this.makeRequestRaw(method, path, data, headers); const results = await response.json(); diff --git a/apps/jetstream-e2e/src/fixtures/fixtures.ts b/apps/jetstream-e2e/src/fixtures/fixtures.ts index 32666baf2..f5299aeb7 100644 --- a/apps/jetstream-e2e/src/fixtures/fixtures.ts +++ b/apps/jetstream-e2e/src/fixtures/fixtures.ts @@ -1,8 +1,11 @@ import * as dotenv from 'dotenv'; import { test as base } from '@playwright/test'; +import { z } from 'zod'; +import { AuthenticationPage } from '../pageObjectModels/AuthenticationPage.model'; import { LoadSingleObjectPage } from '../pageObjectModels/LoadSingleObjectPage.model'; import { LoadWithoutFilePage } from '../pageObjectModels/LoadWithoutFilePage.model'; +import { OrganizationsPage } from '../pageObjectModels/OrganizationsPage'; import { PlatformEventPage } from '../pageObjectModels/PlatformEventPage.model'; import { PlaywrightPage } from '../pageObjectModels/PlaywrightPage.model'; import { QueryPage } from '../pageObjectModels/QueryPage.model'; @@ -17,44 +20,64 @@ if (process.cwd().endsWith('/apps/jetstream-e2e')) { dotenv.config(); +const environmentSchema = z.object({ + E2E_LOGIN_USERNAME: z.string(), + TEST_ORG_2: z.string().optional().default('support+test2@getjetstream.app'), + TEST_ORG_3: z.string().optional().default('support+test3@getjetstream.app'), + TEST_ORG_4: z.string().optional().default('support+test4@getjetstream.app'), + E2E_LOGIN_PASSWORD: z.string(), + E2E_LOGIN_URL: z.string(), + JETSTREAM_SESSION_SECRET: z.string(), +}); + type MyFixtures = { + environment: z.infer; apiRequestUtils: ApiRequestUtils; playwrightPage: PlaywrightPage; + authenticationPage: AuthenticationPage; + newUser: Awaited>; queryPage: QueryPage; loadSingleObjectPage: LoadSingleObjectPage; + organizationsPage: OrganizationsPage; loadWithoutFilePage: LoadWithoutFilePage; platformEventPage: PlatformEventPage; }; export const test = base.extend({ - apiRequestUtils: async ({ page, request }, use) => { - await page.goto('/app'); - // TODO: figure this out - // TODO: this should be e2e org - await page.getByPlaceholder('Select an Org').click(); - await page.getByRole('option', { name: process.env.E2E_LOGIN_USERNAME }).click(); - - const selectedOrgId = await page.evaluate(async () => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return window.atob(localStorage.getItem('SELECTED_ORG')!); - }); - - await use(new ApiRequestUtils(selectedOrgId, request)); + // eslint-disable-next-line no-empty-pattern + environment: async ({}, use) => { + await use(environmentSchema.parse(process.env)); + }, + apiRequestUtils: async ({ page, environment }, use) => { + await use(new ApiRequestUtils(page, environment.E2E_LOGIN_USERNAME)); + }, + playwrightPage: async ({ page }, use) => { + await use(new PlaywrightPage(page)); + }, + authenticationPage: async ({ page }, use) => { + await use(new AuthenticationPage(page)); + }, + newUser: async ({ authenticationPage }, use) => { + await use(await authenticationPage.signUpAndVerifyEmail()); }, - playwrightPage: async ({ page, request, apiRequestUtils }, use) => { - await use(new PlaywrightPage(page, request, apiRequestUtils)); + queryPage: async ({ page, apiRequestUtils, playwrightPage }, use) => { + await apiRequestUtils.selectDefaultOrg(); + await use(new QueryPage(page, apiRequestUtils, playwrightPage)); }, - queryPage: async ({ page, request, apiRequestUtils, playwrightPage }, use) => { - await use(new QueryPage(page, request, apiRequestUtils, playwrightPage)); + loadSingleObjectPage: async ({ page, apiRequestUtils, playwrightPage }, use) => { + await apiRequestUtils.selectDefaultOrg(); + await use(new LoadSingleObjectPage(page, apiRequestUtils, playwrightPage)); }, - loadSingleObjectPage: async ({ page, request, apiRequestUtils, playwrightPage }, use) => { - await use(new LoadSingleObjectPage(page, request, apiRequestUtils, playwrightPage)); + loadWithoutFilePage: async ({ page, apiRequestUtils, playwrightPage }, use) => { + await apiRequestUtils.selectDefaultOrg(); + await use(new LoadWithoutFilePage(page, apiRequestUtils, playwrightPage)); }, - loadWithoutFilePage: async ({ page, request, apiRequestUtils, playwrightPage }, use) => { - await use(new LoadWithoutFilePage(page, request, apiRequestUtils, playwrightPage)); + organizationsPage: async ({ page }, use) => { + await use(new OrganizationsPage(page)); }, - platformEventPage: async ({ page, request, apiRequestUtils, playwrightPage }, use) => { - await use(new PlatformEventPage(page, request, apiRequestUtils, playwrightPage)); + platformEventPage: async ({ page, apiRequestUtils, playwrightPage }, use) => { + await apiRequestUtils.selectDefaultOrg(); + await use(new PlatformEventPage(page, apiRequestUtils, playwrightPage)); }, }); diff --git a/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts b/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts new file mode 100644 index 000000000..eeb2ab6b1 --- /dev/null +++ b/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts @@ -0,0 +1,326 @@ +import { prisma } from '@jetstream/api-config'; +import { Locator, Page, expect } from '@playwright/test'; +import { randomBytes } from 'crypto'; +import { + getPasswordResetToken, + getUserSessionByEmail, + hasPasswordResetToken, + verifyEmailLogEntryExists, +} from '../utils/database-validation.utils'; + +export class AuthenticationPage { + readonly page: Page; + + readonly routes = { + signup: (wildcard = false) => `/auth/signup/${wildcard ? '*' : ''}`, + login: (wildcard = false) => `/auth/login/${wildcard ? '*' : ''}`, + passwordReset: (wildcard = false) => `/auth/password-reset/${wildcard ? '*' : ''}`, + passwordResetVerify: (wildcard = false) => `/auth/password-reset/verify/${wildcard ? '*' : ''}`, + mfaVerify: (wildcard = false) => `/auth/verify/${wildcard ? '*' : ''}`, + } as const; + + readonly signInFromHomePageButton: Locator; + readonly signUpFromHomePageButton: Locator; + + readonly signInFromFormLink: Locator; + readonly signUpFromFormLink: Locator; + readonly loginPageFromPasswordReset: Locator; + + readonly signInButton: Locator; + readonly signUpButton: Locator; + readonly submitButton: Locator; + readonly continueButton: Locator; + + readonly showHidePasswordButton: Locator; + readonly forgotPasswordLink: Locator; + + readonly googleAuthButton: Locator; + readonly salesforceAuthButton: Locator; + + readonly emailInput: Locator; + readonly passwordInput: Locator; + readonly confirmPasswordInput: Locator; + readonly newPasswordInput: Locator; + readonly fullNameInput: Locator; + readonly verificationCodeInput: Locator; + readonly rememberDeviceInput: Locator; + + readonly mfaTotpMenuButton: Locator; + readonly mfaEmailMenuButton: Locator; + + constructor(page: Page) { + this.page = page; + this.signInFromHomePageButton = page.getByRole('link', { name: 'Log in' }); + this.signUpFromHomePageButton = page.getByRole('link', { name: 'Sign up' }); + + this.signUpFromFormLink = page.getByText('Need to register? Sign up').getByRole('link', { name: 'Sign up' }); + this.signInFromFormLink = page.getByText('Already have an account? Login').getByRole('link', { name: 'Login' }); + this.loginPageFromPasswordReset = page.getByRole('link', { name: 'Go to Login Page' }); + + this.signInButton = page.getByRole('button', { name: 'Sign in' }); + this.signUpButton = page.getByRole('button', { name: 'Sign up' }); + this.continueButton = page.getByRole('button', { name: 'Continue' }); + this.submitButton = page.getByRole('button', { name: 'Submit' }); + + this.showHidePasswordButton = page.getByRole('button', { name: /(Show|Hide) Password/ }); + this.forgotPasswordLink = page.getByRole('link', { name: 'Forgot password?' }); + this.googleAuthButton = page.getByRole('button', { name: 'Google Logo Google' }); + this.salesforceAuthButton = page.getByRole('button', { name: 'Salesforce Logo Salesforce' }); + + this.emailInput = page.getByLabel('Email Address'); + this.fullNameInput = page.getByLabel('Full Name'); + this.passwordInput = page.getByLabel('Password', { exact: true }); + this.newPasswordInput = page.getByLabel('New Password'); + this.confirmPasswordInput = page.getByLabel('Confirm Password'); + this.verificationCodeInput = page.getByLabel('Verification Code'); + this.rememberDeviceInput = page.getByLabel('Remember this device'); + + this.mfaTotpMenuButton = page.getByTestId('mfa-totp-menu-button'); + this.mfaEmailMenuButton = page.getByTestId('mfa-email-menu-button'); + } + + async goToSignUp(viaHomePage = true) { + if (viaHomePage) { + await this.page.goto('/'); + await this.signUpFromHomePageButton.first().click(); + } else { + await this.page.goto(this.routes.signup()); + } + } + + async goToLogin(viaHomePage = true) { + if (viaHomePage) { + await this.page.goto('/'); + await this.signInFromHomePageButton.first().click(); + } else { + await this.page.goto(this.routes.login()); + } + } + + async goToPasswordReset() { + await this.page.goto(this.routes.passwordReset()); + } + + async goToPasswordResetVerify(params?: { email: string; code: string }) { + await this.page.goto(`${this.routes.passwordResetVerify()}?${new URLSearchParams(params).toString()}`); + } + + async goToMfaVerify() { + await this.page.goto(this.routes.mfaVerify()); + } + + generateTestEmail() { + return `test-${new Date().getTime()}.${randomBytes(8).toString('hex')}@getjetstream.app`; + } + + generateTestName() { + return `Test User ${new Date().getTime()}${randomBytes(8).toString('hex')}`; + } + + generateTestPassword() { + return `PWD-${new Date().getTime()}!${randomBytes(8).toString('hex')}`; + } + + async signUpAndVerifyEmail() { + const email = this.generateTestEmail(); + const name = this.generateTestName(); + const password = this.generateTestPassword(); + + await this.fillOutSignUpForm(email, name, password, password); + + await expect(this.page.getByText('Verify your email address')).toBeVisible(); + + // ensure email verification was sent + await verifyEmailLogEntryExists(email, 'Verify your email'); + + // Get token from session + const { pendingVerification } = await getUserSessionByEmail(email); + + await expect(pendingVerification || []).toHaveLength(1); + + if (pendingVerification[0].type !== 'email') { + throw new Error('Expected email verification'); + } + const { token } = pendingVerification[0]; + + await this.verificationCodeInput.fill(token); + await this.continueButton.click(); + + await this.page.waitForURL(`**/app`); + + await verifyEmailLogEntryExists(email, 'Welcome to Jetstream'); + + return { + email, + name, + password, + }; + } + + async loginAndVerifyEmail(email: string, password: string, rememberMe = false) { + await this.fillOutLoginForm(email, password); + + await expect(this.page.getByText('Enter your verification code from your email')).toBeVisible(); + + // ensure email verification was sent + await verifyEmailLogEntryExists(email, 'Verify your identity'); + + // Get token from session + const { pendingVerification } = await getUserSessionByEmail(email); + + await expect(pendingVerification || []).toHaveLength(1); + + if (pendingVerification[0].type !== '2fa-email') { + throw new Error('Expected email verification'); + } + const { token } = pendingVerification[0]; + + await this.verificationCodeInput.fill(token); + if (rememberMe) { + await this.rememberDeviceInput.check(); + } + await this.continueButton.click(); + + await this.page.waitForURL(`**/app`); + + return { + email, + password, + }; + } + + async loginAndVerifyTotp(email: string, password: string, secret: string, rememberMe = false) { + const { decodeBase32IgnorePadding } = await import('@oslojs/encoding'); + const { generateTOTP } = await import('@oslojs/otp'); + + await this.fillOutLoginForm(email, password); + + await expect(this.page.getByText('Enter your verification code from your authenticator app')).toBeVisible(); + + const code = await generateTOTP(decodeBase32IgnorePadding(secret), 30, 6); + + // Get token from session + const { pendingVerification } = await getUserSessionByEmail(email); + const pendingVerificationLength = (pendingVerification || []).length; + + await expect(pendingVerificationLength).toBeGreaterThanOrEqual(1); + + if (pendingVerification[0].type !== '2fa-otp') { + throw new Error('Expected totp verification as primary'); + } + + await this.verificationCodeInput.fill(code); + if (rememberMe) { + await this.rememberDeviceInput.check(); + } + await this.continueButton.click(); + + await this.page.waitForURL(`**/app`); + + return { + email, + password, + }; + } + + async resetPassword(email: string) { + const password = this.generateTestPassword(); + + await this.goToPasswordReset(); + await this.fillOutResetPasswordForm(email); + await expect(this.page.getByText('Check your email to continue the reset process.')).toBeVisible(); + + // ensure email verification was sent + await prisma.emailActivity.findFirstOrThrow({ where: { email, subject: { contains: 'Reset your password' } } }); + const { token: code } = await getPasswordResetToken(email); + + await this.goToPasswordResetVerify({ email, code }); + + await this.fillOutResetPasswordVerifyForm(password, password); + + const passwordResetToken = await hasPasswordResetToken(email, code); + await expect(passwordResetToken).toBeFalsy(); + + await this.fillOutLoginForm(email, password); + // TODO: what about 2fa? + + await this.page.waitForURL(`**/app`); + } + + async fillOutLoginForm(email: string, password: string) { + await this.goToLogin(); + + await expect(this.signUpFromFormLink).toBeVisible(); + await expect(this.forgotPasswordLink).toBeVisible(); + await expect(this.signInButton).toBeVisible(); + await expect(this.googleAuthButton).toBeVisible(); + await expect(this.salesforceAuthButton).toBeVisible(); + + await this.emailInput.click(); + await this.emailInput.fill(email); + + await this.passwordInput.click(); + await this.passwordInput.fill(password); + + await this.signInButton.click(); + } + + async fillOutSignUpForm(email: string, name: string, password: string, confirmPassword: string) { + await this.goToSignUp(); + + await this.page.goto('/'); + await this.signUpFromHomePageButton.first().click(); + + await expect(this.signInFromFormLink).toBeVisible(); + await expect(this.forgotPasswordLink).toBeVisible(); + await expect(this.signUpButton).toBeVisible(); + await expect(this.googleAuthButton).toBeVisible(); + await expect(this.salesforceAuthButton).toBeVisible(); + + await this.emailInput.click(); + await this.emailInput.fill(email); + + await this.fullNameInput.click(); + await this.fullNameInput.fill(name); + + await this.passwordInput.click(); + await this.passwordInput.fill(password); + + await this.confirmPasswordInput.click(); + await this.confirmPasswordInput.fill(confirmPassword); + + await this.signUpButton.click(); + } + + async fillOutVerification(email: string, name: string, password: string, confirmPassword: string) { + await this.passwordInput.click(); + await this.passwordInput.fill(password); + + await this.signUpButton.click(); + } + + async fillOutResetPasswordForm(email: string) { + await this.page.goto('/'); + await this.signInFromHomePageButton.click(); + + await this.forgotPasswordLink.click(); + + await expect(this.loginPageFromPasswordReset).toBeVisible(); + await expect(this.submitButton).toBeVisible(); + + await this.emailInput.click(); + await this.emailInput.fill(email); + + await this.submitButton.click(); + } + + async fillOutResetPasswordVerifyForm(password: string, confirmPassword: string) { + await this.newPasswordInput.click(); + await this.newPasswordInput.fill(password); + + await this.confirmPasswordInput.click(); + await this.confirmPasswordInput.fill(confirmPassword); + + await this.submitButton.click(); + } +} diff --git a/apps/jetstream-e2e/src/pageObjectModels/LoadSingleObjectPage.model.ts b/apps/jetstream-e2e/src/pageObjectModels/LoadSingleObjectPage.model.ts index 256a0efe7..ac08ddbc2 100644 --- a/apps/jetstream-e2e/src/pageObjectModels/LoadSingleObjectPage.model.ts +++ b/apps/jetstream-e2e/src/pageObjectModels/LoadSingleObjectPage.model.ts @@ -1,4 +1,4 @@ -import { APIRequestContext, expect, Locator, Page } from '@playwright/test'; +import { expect, Locator, Page } from '@playwright/test'; import { ApiRequestUtils } from '../fixtures/ApiRequestUtils'; import { PlaywrightPage } from './PlaywrightPage.model'; @@ -6,14 +6,12 @@ export class LoadSingleObjectPage { readonly apiRequestUtils: ApiRequestUtils; readonly playwrightPage: PlaywrightPage; readonly page: Page; - readonly request: APIRequestContext; readonly sobjectList: Locator; - constructor(page: Page, request: APIRequestContext, apiRequestUtils: ApiRequestUtils, playwrightPage: PlaywrightPage) { + constructor(page: Page, apiRequestUtils: ApiRequestUtils, playwrightPage: PlaywrightPage) { this.apiRequestUtils = apiRequestUtils; this.playwrightPage = playwrightPage; this.page = page; - this.request = request; this.sobjectList = page.getByTestId('sobject-list'); } diff --git a/apps/jetstream-e2e/src/pageObjectModels/LoadWithoutFilePage.model.ts b/apps/jetstream-e2e/src/pageObjectModels/LoadWithoutFilePage.model.ts index ca21c8477..e2e079c2c 100644 --- a/apps/jetstream-e2e/src/pageObjectModels/LoadWithoutFilePage.model.ts +++ b/apps/jetstream-e2e/src/pageObjectModels/LoadWithoutFilePage.model.ts @@ -1,4 +1,4 @@ -import { APIRequestContext, Locator, Page } from '@playwright/test'; +import { Locator, Page } from '@playwright/test'; import { ApiRequestUtils } from '../fixtures/ApiRequestUtils'; import { PlaywrightPage } from './PlaywrightPage.model'; @@ -6,14 +6,12 @@ export class LoadWithoutFilePage { readonly apiRequestUtils: ApiRequestUtils; readonly playwrightPage: PlaywrightPage; readonly page: Page; - readonly request: APIRequestContext; readonly sobjectList: Locator; - constructor(page: Page, request: APIRequestContext, apiRequestUtils: ApiRequestUtils, playwrightPage: PlaywrightPage) { + constructor(page: Page, apiRequestUtils: ApiRequestUtils, playwrightPage: PlaywrightPage) { this.apiRequestUtils = apiRequestUtils; this.playwrightPage = playwrightPage; this.page = page; - this.request = request; this.sobjectList = page.getByTestId('sobject-list'); } diff --git a/apps/jetstream-e2e/src/pageObjectModels/OrganizationsPage.ts b/apps/jetstream-e2e/src/pageObjectModels/OrganizationsPage.ts new file mode 100644 index 000000000..d9e7d4883 --- /dev/null +++ b/apps/jetstream-e2e/src/pageObjectModels/OrganizationsPage.ts @@ -0,0 +1,118 @@ +import { APIRequestContext, Locator, Page, expect } from '@playwright/test'; +import { ApiRequestUtils } from '../fixtures/ApiRequestUtils'; +import { PlaywrightPage } from './PlaywrightPage.model'; + +export class OrganizationsPage { + readonly apiRequestUtils: ApiRequestUtils; + readonly playwrightPage: PlaywrightPage; + readonly page: Page; + readonly request: APIRequestContext; + + readonly addOrgButton: Locator; + readonly orgDropdownContainer: Locator; + readonly orgDropdown: Locator; + readonly sobjectList: Locator; + + constructor(page: Page) { + this.page = page; + this.addOrgButton = page.getByRole('button', { name: 'Add Org' }); + this.orgDropdownContainer = page.getByTestId('orgs-combobox-container'); + this.orgDropdown = page.getByPlaceholder('Select an Org'); + } + + async goToJetstreamOrganizationsPage() { + await this.page.goto('/app/home'); + const navigationPromise = this.page.waitForURL('/app/organizations'); + await this.page.getByRole('link', { name: 'Manage Organizations' }).click(); + await navigationPromise; + } + + async addSalesforceOrg( + username: string, + password: string, + method: { type: 'production' } | { type: 'pre-release' } | { type: 'sandbox' } | { type: 'custom'; domain: string } + ) { + await this.page.getByRole('button', { name: 'Add Org' }).click(); + + const salesforcePagePromise = this.page.waitForEvent('popup'); + + switch (method.type) { + case 'production': { + await this.page.getByText('Production / Developer').click(); + break; + } + case 'sandbox': { + await this.page.getByText('Production / Developer').click(); + break; + } + case 'pre-release': { + await this.page.getByText('Pre-release').click(); + break; + } + case 'custom': { + await this.page.getByText('Custom Login URL').click(); + await this.page.getByPlaceholder('org-domain').click(); + await this.page.getByPlaceholder('org-domain').fill(method.domain); + break; + } + } + + await this.page.getByRole('button', { name: 'Continue' }).click(); + + const salesforcePage = await salesforcePagePromise; + + await salesforcePage.getByLabel('Username').click(); + await salesforcePage.getByLabel('Username').fill(username); + + await salesforcePage.getByLabel('Password').click(); + await salesforcePage.getByLabel('Password').fill(password); + + const pageClosePromise = salesforcePage.waitForEvent('close'); + + await salesforcePage.getByRole('button', { name: 'Log In' }).click(); + + await pageClosePromise; + } + + async selectSalesforceOrg(username: string) { + // + } + + async removeSalesforceOrg(username: string) { + // + } + + async addJetstreamOrganization(name: string, description: string) { + await this.page.getByRole('button', { name: 'Create New Organization' }).click(); + await this.page.locator('#organization-name').click(); + await this.page.locator('#organization-name').fill(name); + await this.page.locator('#organization-description').click(); + await this.page.locator('#organization-description').fill(description); + await this.page.getByRole('button', { name: 'Save' }).click(); + await expect(this.page.getByTestId(`organization-card-${name}`)).toBeVisible(); + } + + async dragOrgToOrganization(jetstreamOrgName: string, salesforceOrgLabel: string) { + await this.page + .getByTestId(`salesforce-organization-${salesforceOrgLabel}`) + .dragTo(this.page.getByTestId(`organization-card-${jetstreamOrgName}`)); + + await expect( + this.page + .getByTestId(`organization-card-${jetstreamOrgName}`) + .locator(this.page.getByTestId(`salesforce-organization-${salesforceOrgLabel}`)) + ).toBeVisible(); + } + + async makeActiveJetstreamOrganization(jetstreamOrgName: string) { + const locator = this.page.getByTestId(`organization-card-${jetstreamOrgName}`); + await locator.getByRole('button', { name: 'Make Active' }).click(); + } + + async deleteJetstreamOrganization(jetstreamOrgName: string) { + const locator = this.page.getByTestId(`organization-card-${jetstreamOrgName}`); + await locator.getByRole('button', { name: 'action' }).click(); + await locator.getByRole('menuitem', { name: 'Delete' }).click(); + await this.page.getByRole('button', { name: 'Continue' }).click(); + } +} diff --git a/apps/jetstream-e2e/src/pageObjectModels/PlatformEventPage.model.ts b/apps/jetstream-e2e/src/pageObjectModels/PlatformEventPage.model.ts index 693901063..04960a8c7 100644 --- a/apps/jetstream-e2e/src/pageObjectModels/PlatformEventPage.model.ts +++ b/apps/jetstream-e2e/src/pageObjectModels/PlatformEventPage.model.ts @@ -1,4 +1,4 @@ -import { APIRequestContext, Locator, Page, expect } from '@playwright/test'; +import { Locator, Page, expect } from '@playwright/test'; import { ApiRequestUtils } from '../fixtures/ApiRequestUtils'; import { PlaywrightPage } from './PlaywrightPage.model'; @@ -6,16 +6,14 @@ export class PlatformEventPage { readonly apiRequestUtils: ApiRequestUtils; readonly playwrightPage: PlaywrightPage; readonly page: Page; - readonly request: APIRequestContext; readonly listenerCard: Locator; readonly publisherCard: Locator; - constructor(page: Page, request: APIRequestContext, apiRequestUtils: ApiRequestUtils, playwrightPage: PlaywrightPage) { + constructor(page: Page, apiRequestUtils: ApiRequestUtils, playwrightPage: PlaywrightPage) { page.evaluate('__IS_CHROME_EXTENSION__ = false;'); this.apiRequestUtils = apiRequestUtils; this.playwrightPage = playwrightPage; this.page = page; - this.request = request; this.listenerCard = page.getByTestId('platform-event-monitor-listener-card'); this.publisherCard = page.getByTestId('platform-event-monitor-publisher-card'); } diff --git a/apps/jetstream-e2e/src/pageObjectModels/PlaywrightPage.model.ts b/apps/jetstream-e2e/src/pageObjectModels/PlaywrightPage.model.ts index a457c80e7..9f910c034 100644 --- a/apps/jetstream-e2e/src/pageObjectModels/PlaywrightPage.model.ts +++ b/apps/jetstream-e2e/src/pageObjectModels/PlaywrightPage.model.ts @@ -1,15 +1,11 @@ -import { APIRequestContext, Page } from '@playwright/test'; -import { ApiRequestUtils } from '../fixtures/ApiRequestUtils'; +import { Browser, expect, Page } from '@playwright/test'; export class PlaywrightPage { - readonly apiRequestUtils: ApiRequestUtils; + readonly browser: Browser; readonly page: Page; - readonly request: APIRequestContext; - constructor(page: Page, request: APIRequestContext, apiRequestUtils: ApiRequestUtils) { - this.apiRequestUtils = apiRequestUtils; + constructor(page: Page) { this.page = page; - this.request = request; } async selectDropdownItem( @@ -28,4 +24,15 @@ export class PlaywrightPage { await dropdown.getByRole('option', { name: value }).click(); return dropdown; } + + async logout() { + await this.page.getByRole('button', { name: 'Avatar' }).click(); + await this.page.getByRole('menuitem', { name: 'Logout' }).click(); + await expect(this.page.getByTestId('home-hero-container')).toBeVisible(); + } + + async goToProfile() { + await this.page.getByRole('button', { name: 'Avatar' }).click(); + await this.page.getByRole('menuitem', { name: 'Your Profile' }).click(); + } } diff --git a/apps/jetstream-e2e/src/pageObjectModels/QueryPage.model.ts b/apps/jetstream-e2e/src/pageObjectModels/QueryPage.model.ts index 86c1789d0..6ef043737 100644 --- a/apps/jetstream-e2e/src/pageObjectModels/QueryPage.model.ts +++ b/apps/jetstream-e2e/src/pageObjectModels/QueryPage.model.ts @@ -1,8 +1,8 @@ import { formatNumber } from '@jetstream/shared/ui-utils'; import { isRecordWithId } from '@jetstream/shared/utils'; import { QueryFilterOperator, QueryResults } from '@jetstream/types'; -import { APIRequestContext, Locator, Page, expect } from '@playwright/test'; -import isNumber from 'lodash/isNumber'; +import { Locator, Page, expect } from '@playwright/test'; +import { isNumber } from 'lodash'; import { ApiRequestUtils } from '../fixtures/ApiRequestUtils'; import { PlaywrightPage } from './PlaywrightPage.model'; @@ -10,17 +10,15 @@ export class QueryPage { readonly apiRequestUtils: ApiRequestUtils; readonly playwrightPage: PlaywrightPage; readonly page: Page; - readonly request: APIRequestContext; readonly sobjectList: Locator; readonly fieldsList: Locator; readonly soqlQuery: Locator; readonly executeBtn: Locator; - constructor(page: Page, request: APIRequestContext, apiRequestUtils: ApiRequestUtils, playwrightPage: PlaywrightPage) { + constructor(page: Page, apiRequestUtils: ApiRequestUtils, playwrightPage: PlaywrightPage) { this.apiRequestUtils = apiRequestUtils; this.playwrightPage = playwrightPage; this.page = page; - this.request = request; this.sobjectList = page.getByTestId('sobject-list'); this.fieldsList = page.getByTestId('sobject-fields'); this.soqlQuery = page.getByText('Generated SOQL'); @@ -45,6 +43,7 @@ export class QueryPage { if (action === 'EXECUTE') { await Promise.all([ + // eslint-disable-next-line playwright/no-networkidle this.page.waitForURL('**/query/results', { waitUntil: 'networkidle' }), manualQueryPopover.getByRole('link', { name: 'Execute' }).click(), ]); diff --git a/apps/jetstream-e2e/src/setup/global-setup.ts b/apps/jetstream-e2e/src/setup/global-setup.ts deleted file mode 100644 index 3264b83e7..000000000 --- a/apps/jetstream-e2e/src/setup/global-setup.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { request } from '@playwright/test'; - -const baseApiURL = 'http://localhost:3333'; - -async function globalSetup() { - console.log('GLOBAL SETUP - STARTED'); - console.log('Ensuring E2E org exists'); - const requestContext = await request.newContext(); - await requestContext.post(`${baseApiURL}/test/e2e-integration-org`, { - failOnStatusCode: true, - }); - await requestContext.dispose(); - console.log('GLOBAL SETUP - FINISHED\n'); -} - -export default globalSetup; diff --git a/apps/jetstream-e2e/src/setup/global.setup.ts b/apps/jetstream-e2e/src/setup/global.setup.ts new file mode 100644 index 000000000..c28628434 --- /dev/null +++ b/apps/jetstream-e2e/src/setup/global.setup.ts @@ -0,0 +1,43 @@ +import { ENV } from '@jetstream/api-config'; +import { UserProfileSession } from '@jetstream/auth/types'; +import { expect, test as setup } from '@playwright/test'; +import { join } from 'path'; + +const baseApiURL = 'http://localhost:3333'; + +const authFile = join('playwright/.auth/user.json'); + +setup('login and ensure org exists', async ({ page, request }) => { + console.log('GLOBAL SETUP - STARTED'); + + console.log('Ensuring E2E org exists'); + await request.post(`${baseApiURL}/test/e2e-integration-org`, { + failOnStatusCode: true, + }); + + console.log('Logging in as example user'); + const user = ENV.EXAMPLE_USER as UserProfileSession; + + await page.goto(baseApiURL); + await page.getByRole('link', { name: 'Log in' }).click(); + await page.getByLabel('Email Address').click(); + await page.getByLabel('Email Address').fill(user.email); + await page.getByLabel('Password').click(); + await page.getByLabel('Password').fill(ENV.EXAMPLE_USER_PASSWORD as string); + await page.getByRole('button', { name: 'Sign in' }).click(); + + await page.waitForURL(`${baseApiURL}/app`); + + await expect(page.getByRole('button', { name: 'Avatar' })).toBeVisible(); + + await page.evaluate(async () => { + localStorage.setItem('TEST_USER_AGENT', navigator.userAgent); + }); + + await request.dispose(); + console.log('GLOBAL SETUP - FINISHED\n'); + + console.log(`Saving storage state: ${authFile}\n`); + + await page.context().storageState({ path: authFile }); +}); diff --git a/apps/jetstream-e2e/src/setup/global.teardown.ts b/apps/jetstream-e2e/src/setup/global.teardown.ts new file mode 100644 index 000000000..83593f409 --- /dev/null +++ b/apps/jetstream-e2e/src/setup/global.teardown.ts @@ -0,0 +1,56 @@ +import { prisma } from '@jetstream/api-config'; +import { test as teardown } from '@playwright/test'; + +teardown('login and ensure org exists', async ({ page, request }) => { + console.log('GLOBAL TEARDOWN - STARTED'); + let results = await prisma.user.deleteMany({ + where: { + email: { + startsWith: 'test-', + endsWith: '@getjetstream.app', + }, + }, + }); + console.log(`Deleted ${results.count} users + dependent records`); + + results = await prisma.passwordResetToken.deleteMany({ + where: { + email: { + startsWith: 'test-', + endsWith: '@getjetstream.app', + }, + }, + }); + console.log(`Deleted ${results.count} passwordResetToken`); + + results = await prisma.loginActivity.deleteMany({ + where: { + email: { + startsWith: 'test-', + endsWith: '@getjetstream.app', + }, + }, + }); + console.log(`Deleted ${results.count} passwordResetToken`); + + results = await prisma.emailActivity.deleteMany({ + where: { + email: { + startsWith: 'test-', + endsWith: '@getjetstream.app', + }, + }, + }); + console.log(`Deleted ${results.count} passwordResetToken`); + + results = await prisma.sessions.deleteMany({ + where: { + sess: { + path: ['user', 'email'], + string_starts_with: 'test-', + string_ends_with: '@getjetstream.app', + }, + }, + }); + console.log(`Deleted ${results.count} sessions`); +}); diff --git a/apps/jetstream-e2e/src/tests/api/bulk-query-20.api.spec.ts b/apps/jetstream-e2e/src/tests/api/bulk-query-20.api.spec.ts index 9ad1edd22..bcf899226 100644 --- a/apps/jetstream-e2e/src/tests/api/bulk-query-20.api.spec.ts +++ b/apps/jetstream-e2e/src/tests/api/bulk-query-20.api.spec.ts @@ -5,6 +5,10 @@ import { expect, test } from '../../fixtures/fixtures'; test.describe.configure({ mode: 'parallel' }); test.describe('API - Bulk Query 2.0', () => { + test.beforeAll(async ({ apiRequestUtils }) => { + await apiRequestUtils.selectDefaultOrg(); + }); + /** * This test is super flaky because it relies on how fast salesforce starts processing the job */ diff --git a/apps/jetstream-e2e/src/tests/api/bulk.api.spec.ts b/apps/jetstream-e2e/src/tests/api/bulk.api.spec.ts index 10c0fd9a9..6bfc56343 100644 --- a/apps/jetstream-e2e/src/tests/api/bulk.api.spec.ts +++ b/apps/jetstream-e2e/src/tests/api/bulk.api.spec.ts @@ -5,6 +5,10 @@ import { expect, test } from '../../fixtures/fixtures'; test.describe.configure({ mode: 'parallel' }); test.describe('API - Bulk', () => { + test.beforeAll(async ({ apiRequestUtils }) => { + await apiRequestUtils.selectDefaultOrg(); + }); + test('Bulk Job - Create,Get,Add Batch', async ({ apiRequestUtils }) => { const createJobResponse = await apiRequestUtils.makeRequest('POST', `/api/bulk`, { externalId: null, diff --git a/apps/jetstream-e2e/src/tests/api/metadata-apex.api.spec.ts b/apps/jetstream-e2e/src/tests/api/metadata-apex.api.spec.ts index b2a73beeb..a098ac24d 100644 --- a/apps/jetstream-e2e/src/tests/api/metadata-apex.api.spec.ts +++ b/apps/jetstream-e2e/src/tests/api/metadata-apex.api.spec.ts @@ -4,6 +4,10 @@ import { expect, test } from '../../fixtures/fixtures'; test.describe.configure({ mode: 'parallel' }); test.describe('API - Apex', () => { + test.beforeAll(async ({ apiRequestUtils }) => { + await apiRequestUtils.selectDefaultOrg(); + }); + test('anonymousApex', async ({ apiRequestUtils }) => { const [validWithLogLevel, validWithoutLogLevel, validApexWithXmlChars, invalidApex, runtimeError, missingApex, invalidLogLevel] = await Promise.all([ diff --git a/apps/jetstream-e2e/src/tests/api/metadata.api.spec.ts b/apps/jetstream-e2e/src/tests/api/metadata.api.spec.ts index 4408329d0..1498fd8ba 100644 --- a/apps/jetstream-e2e/src/tests/api/metadata.api.spec.ts +++ b/apps/jetstream-e2e/src/tests/api/metadata.api.spec.ts @@ -6,6 +6,10 @@ import { expect, test } from '../../fixtures/fixtures'; test.describe.configure({ mode: 'parallel' }); test.describe('API - Metadata', () => { + test.beforeAll(async ({ apiRequestUtils }) => { + await apiRequestUtils.selectDefaultOrg(); + }); + test('describe metadata', async ({ apiRequestUtils }) => { const results = await apiRequestUtils.makeRequest('GET', `/api/metadata/describe`); diff --git a/apps/jetstream-e2e/src/tests/api/misc.api.spec.ts b/apps/jetstream-e2e/src/tests/api/misc.api.spec.ts index f22a0aee6..a034ca6b6 100644 --- a/apps/jetstream-e2e/src/tests/api/misc.api.spec.ts +++ b/apps/jetstream-e2e/src/tests/api/misc.api.spec.ts @@ -4,6 +4,10 @@ import { expect, test } from '../../fixtures/fixtures'; test.describe.configure({ mode: 'parallel' }); test.describe('API - Misc', () => { + test.beforeAll(async ({ apiRequestUtils }) => { + await apiRequestUtils.selectDefaultOrg(); + }); + test('Stream file download', async ({ apiRequestUtils }) => { const file = await apiRequestUtils.makeRequestRaw( 'GET', diff --git a/apps/jetstream-e2e/src/tests/api/query.api.spec.ts b/apps/jetstream-e2e/src/tests/api/query.api.spec.ts index 704857561..ee506f714 100644 --- a/apps/jetstream-e2e/src/tests/api/query.api.spec.ts +++ b/apps/jetstream-e2e/src/tests/api/query.api.spec.ts @@ -4,6 +4,10 @@ import { expect, test } from '../../fixtures/fixtures'; test.describe.configure({ mode: 'parallel' }); test.describe('API - Query', () => { + test.beforeAll(async ({ apiRequestUtils }) => { + await apiRequestUtils.selectDefaultOrg(); + }); + test('describe global', async ({ apiRequestUtils }) => { const [describeSuccess, describeToolingSuccess, invalidParam] = await Promise.all([ apiRequestUtils.makeRequest('GET', `/api/describe`), diff --git a/apps/jetstream-e2e/src/tests/api/record.api.spec.ts b/apps/jetstream-e2e/src/tests/api/record.api.spec.ts index db6fd96c8..f26664451 100644 --- a/apps/jetstream-e2e/src/tests/api/record.api.spec.ts +++ b/apps/jetstream-e2e/src/tests/api/record.api.spec.ts @@ -4,6 +4,10 @@ import { expect, test } from '../../fixtures/fixtures'; test.describe.configure({ mode: 'parallel' }); test.describe('API - Record Controller', () => { + test.beforeAll(async ({ apiRequestUtils }) => { + await apiRequestUtils.selectDefaultOrg(); + }); + test('Record Operation', async ({ apiRequestUtils }) => { const [lead, leadInvalid] = await apiRequestUtils.makeRequest<[SuccessResult, ErrorResult]>( 'POST', diff --git a/apps/jetstream-e2e/src/tests/authentication/auth-form-navigation.spec.ts b/apps/jetstream-e2e/src/tests/authentication/auth-form-navigation.spec.ts new file mode 100644 index 000000000..eb8ee4a2d --- /dev/null +++ b/apps/jetstream-e2e/src/tests/authentication/auth-form-navigation.spec.ts @@ -0,0 +1,174 @@ +import { expect, test } from '../../fixtures/fixtures'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe.configure({ mode: 'parallel' }); + +// Reset storage state for this file to avoid being authenticated +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe('Auth Page Navigation', () => { + test('Should be able to navigate between all authentication pages', async ({ page, authenticationPage }) => { + await authenticationPage.goToSignUp(); + + await page.waitForURL(authenticationPage.routes.signup()); + await expect(page.url()).toContain(authenticationPage.routes.signup()); + await authenticationPage.signInFromFormLink.click(); + + await page.waitForURL(authenticationPage.routes.login()); + await expect(page.url()).toContain(authenticationPage.routes.login()); + await authenticationPage.signUpFromFormLink.click(); + await page.waitForURL(authenticationPage.routes.signup()); + await expect(page.url()).toContain(authenticationPage.routes.signup()); + await authenticationPage.forgotPasswordLink.click(); + await page.waitForURL(authenticationPage.routes.passwordReset()); + await expect(page.url()).toContain(authenticationPage.routes.passwordReset()); + await authenticationPage.loginPageFromPasswordReset.click(); + await page.waitForURL(authenticationPage.routes.login()); + await expect(page.url()).toContain(authenticationPage.routes.login()); + + await authenticationPage.goToLogin(); + await page.waitForURL(authenticationPage.routes.login()); + await expect(page.url()).toContain(authenticationPage.routes.login()); + }); + + test('Should not be able to go to password reset form without proper URL parameters', async ({ page, authenticationPage }) => { + await authenticationPage.goToPasswordResetVerify(); + await page.waitForURL(authenticationPage.routes.login(true)); + + await expect(page.getByText('Your reset token is invalid,')).toBeVisible(); + + await page.getByRole('button', { name: 'Dismiss' }).click(); + await expect(page.getByText('Your reset token is invalid,')).toHaveCount(0); + }); + + test('Should not be able to go to mfa page without verification session', async ({ page, authenticationPage }) => { + await authenticationPage.goToMfaVerify(); + await page.waitForURL(authenticationPage.routes.login(true)); + await expect(page.url()).toContain(authenticationPage.routes.login()); + }); +}); + +test.describe('Auth Form Validation', () => { + test('login form validation', async ({ page, authenticationPage }) => { + // Invalid email and password + await authenticationPage.fillOutLoginForm('inva lid @email.com', ''); + await expect(authenticationPage.emailInput).toHaveAttribute('aria-invalid', 'true'); + await expect(authenticationPage.emailInput).toHaveAttribute('aria-describedby'); + await expect(page.getByText('A valid email address is required')).toBeVisible(); + await expect(authenticationPage.passwordInput).toHaveAttribute('aria-invalid', 'true'); + await expect(authenticationPage.passwordInput).toHaveAttribute('aria-describedby'); + await expect(page.getByText('Password is required')).toBeVisible(); + + // Invalid email + await authenticationPage.fillOutLoginForm('test @email.com', 'password123'); + await expect(authenticationPage.emailInput).toHaveAttribute('aria-invalid', 'true'); + await expect(authenticationPage.emailInput).toHaveAttribute('aria-describedby'); + await expect(page.getByText('A valid email address is required')).toBeVisible(); + await expect(authenticationPage.passwordInput).toHaveAttribute('aria-invalid', 'false'); + await expect(authenticationPage.passwordInput).not.toHaveAttribute('aria-describedby'); + await expect(page.getByText('Password must be at least 8 characters')).toHaveCount(0); + await expect(page.getByText('Password must be at least 8 characters')).toHaveCount(0); + await expect(page.getByText('Password must be at most 255 characters')).toHaveCount(0); + + // Invalid password + await authenticationPage.fillOutLoginForm('test@email.com', 'pwd'); + await expect(authenticationPage.emailInput).toHaveAttribute('aria-invalid', 'false'); + await expect(authenticationPage.emailInput).not.toHaveAttribute('aria-describedby'); + await expect(page.getByText('A valid email address is required')).toHaveCount(0); + await expect(page.getByText('Password must be at least 8 characters')).toBeVisible(); + }); + + test('Signup form validation', async ({ page, authenticationPage }) => { + // Invalid email and password + await authenticationPage.fillOutSignUpForm('inva lid @email.com', '', '', ''); + await expect(authenticationPage.emailInput).toHaveAttribute('aria-invalid', 'true'); + await expect(authenticationPage.emailInput).toHaveAttribute('aria-describedby'); + await expect(page.getByText('A valid email address is required')).toBeVisible(); + await expect(authenticationPage.fullNameInput).toHaveAttribute('aria-invalid', 'true'); + await expect(authenticationPage.fullNameInput).toHaveAttribute('aria-describedby'); + await expect(page.getByText('Name is required')).toBeVisible(); + await expect(authenticationPage.passwordInput).toHaveAttribute('aria-invalid', 'true'); + await expect(authenticationPage.passwordInput).toHaveAttribute('aria-describedby'); + await expect(page.getByText('Password is required')).toHaveCount(2); + await expect(authenticationPage.confirmPasswordInput).toHaveAttribute('aria-invalid', 'true'); + await expect(authenticationPage.confirmPasswordInput).toHaveAttribute('aria-describedby'); + await expect(page.getByText('Password is required')).toHaveCount(2); + + // Invalid email + await authenticationPage.fillOutSignUpForm('inva lid @email.com', 'Test Person', 'password123', 'password123'); + await expect(authenticationPage.emailInput).toHaveAttribute('aria-invalid', 'true'); + await expect(authenticationPage.emailInput).toHaveAttribute('aria-describedby'); + await expect(page.getByText('A valid email address is required')).toBeVisible(); + await expect(authenticationPage.fullNameInput).toHaveAttribute('aria-invalid', 'false'); + await expect(authenticationPage.fullNameInput).not.toHaveAttribute('aria-describedby'); + await expect(authenticationPage.passwordInput).toHaveAttribute('aria-invalid', 'false'); + await expect(authenticationPage.passwordInput).not.toHaveAttribute('aria-describedby'); + await expect(authenticationPage.confirmPasswordInput).toHaveAttribute('aria-invalid', 'false'); + await expect(authenticationPage.confirmPasswordInput).not.toHaveAttribute('aria-describedby'); + await expect(page.getByText('Name is required')).toHaveCount(0); + await expect(page.getByText('Password must be at least 8 characters')).toHaveCount(0); + await expect(page.getByText('Password must be at most 255 characters')).toHaveCount(0); + + // Invalid password - too short + await authenticationPage.fillOutSignUpForm('invalid@email.com', 'Test Person', 'pwd', 'pwd'); + await expect(authenticationPage.emailInput).toHaveAttribute('aria-invalid', 'false'); + await expect(authenticationPage.emailInput).not.toHaveAttribute('aria-describedby'); + await expect(authenticationPage.fullNameInput).toHaveAttribute('aria-invalid', 'false'); + await expect(authenticationPage.fullNameInput).not.toHaveAttribute('aria-describedby'); + await expect(page.getByText('A valid email address is required')).toHaveCount(0); + await expect(page.getByText('Name is required')).toHaveCount(0); + await expect(page.getByText('Password must be at least 8 characters')).toHaveCount(2); + + // Invalid password - do not match, one too short + await authenticationPage.fillOutSignUpForm('invalid@email.com', 'Test Person', 'pwd', 'pwdabc1234533232'); + await expect(authenticationPage.emailInput).toHaveAttribute('aria-invalid', 'false'); + await expect(authenticationPage.emailInput).not.toHaveAttribute('aria-describedby'); + await expect(authenticationPage.fullNameInput).toHaveAttribute('aria-invalid', 'false'); + await expect(authenticationPage.fullNameInput).not.toHaveAttribute('aria-describedby'); + await expect(page.getByText('A valid email address is required')).toHaveCount(0); + await expect(page.getByText('Name is required')).toHaveCount(0); + await expect(page.getByText('Password must be at least 8 characters')).toBeVisible(); + await expect(page.getByText('Passwords do not match')).toBeVisible(); + + // Invalid password - do not match + await authenticationPage.fillOutSignUpForm('invalid@email.com', 'Test Person', 'PWDabc1234533232', 'pwdabc1234533232'); + await expect(authenticationPage.emailInput).toHaveAttribute('aria-invalid', 'false'); + await expect(authenticationPage.emailInput).not.toHaveAttribute('aria-describedby'); + await expect(authenticationPage.fullNameInput).toHaveAttribute('aria-invalid', 'false'); + await expect(authenticationPage.fullNameInput).not.toHaveAttribute('aria-describedby'); + await expect(page.getByText('A valid email address is required')).toHaveCount(0); + await expect(page.getByText('Name is required')).toHaveCount(0); + await expect(page.getByText('Password must be at least 8 characters')).toHaveCount(0); + await expect(page.getByText('Passwords do not match')).toBeVisible(); + }); + + test('Test showing and hiding password', async ({ page, authenticationPage }) => { + const PASSWORD = 'pwd'; + await authenticationPage.fillOutLoginForm('email@test.com', PASSWORD); + + let typeAttribute = await authenticationPage.passwordInput.getAttribute('type'); + await expect(typeAttribute).toEqual('password'); + await expect(authenticationPage.passwordInput).toHaveValue(PASSWORD); + + await authenticationPage.showHidePasswordButton.click(); + typeAttribute = await authenticationPage.passwordInput.getAttribute('type'); + await expect(typeAttribute).toEqual('text'); + await expect(authenticationPage.passwordInput).toHaveValue(PASSWORD); + + await authenticationPage.showHidePasswordButton.click(); + typeAttribute = await authenticationPage.passwordInput.getAttribute('type'); + await expect(typeAttribute).toEqual('password'); + await expect(authenticationPage.passwordInput).toHaveValue(PASSWORD); + }); +}); + +// test('Password reset form should be validated', async ({ page, authenticationPage }) => { +// // TODO: ensure invalid password input is not accepted +// }); + +// test('MFA form should be validated', async ({ page, authenticationPage }) => { +// // TODO: ensure invalid password input is not accepted +// }); diff --git a/apps/jetstream-e2e/src/tests/authentication/auth-session-management.spec.ts b/apps/jetstream-e2e/src/tests/authentication/auth-session-management.spec.ts new file mode 100644 index 000000000..cadf86c43 --- /dev/null +++ b/apps/jetstream-e2e/src/tests/authentication/auth-session-management.spec.ts @@ -0,0 +1,15 @@ +import { test } from '../../fixtures/fixtures'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe.configure({ mode: 'parallel' }); + +test.describe('Authentication Session Management', () => { + test.use({ userAgent: '' }); + + test('Should be logged out', async ({ page, authenticationPage, playwrightPage }) => { + // TODO: ensure that using the same session with a different user agent deletes the session from DB + }); +}); diff --git a/apps/jetstream-e2e/src/tests/authentication/login1.spec.ts b/apps/jetstream-e2e/src/tests/authentication/login1.spec.ts new file mode 100644 index 000000000..b59c2071e --- /dev/null +++ b/apps/jetstream-e2e/src/tests/authentication/login1.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '../../fixtures/fixtures'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe.configure({ mode: 'parallel' }); + +// Reset storage state for this file to avoid being authenticated +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe('Login 1', () => { + test('Sign up, login, disable 2fa, login again', async ({ page, authenticationPage, playwrightPage }) => { + const { email, password, name } = await test.step('Sign up and verify email', async () => { + const { email, password, name } = await authenticationPage.signUpAndVerifyEmail(); + await playwrightPage.logout(); + await expect(page.getByTestId('home-hero-container')).toBeVisible(); + return { email, password, name }; + }); + + await test.step('Login and verify email and logout', async () => { + await authenticationPage.loginAndVerifyEmail(email, password); + await expect(page.url()).toContain('/app'); + await playwrightPage.goToProfile(); + await expect(page.getByText(name)).toBeVisible(); + await expect(page.getByText(email)).toBeVisible(); + }); + + await test.step('Disable MFA and logout', async () => { + // Disable MFA and login again to confirm it is no longer required + await authenticationPage.mfaEmailMenuButton.click(); + await page.getByRole('menuitem', { name: 'Disable' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByText("You don't have two-factor")).toBeVisible(); + + await playwrightPage.logout(); + await expect(page.getByTestId('home-hero-container')).toBeVisible(); + }); + + await test.step('Login without MFA', async () => { + await authenticationPage.fillOutLoginForm(email, password); + await page.waitForURL(`**/app`); + await expect(page.url()).toContain('/app'); + }); + }); + + test('Sign up, login, enable remember me and login', async ({ page, authenticationPage, playwrightPage }) => { + const { email, password } = await test.step('Sign up and verify email', async () => { + const { email, password, name } = await authenticationPage.signUpAndVerifyEmail(); + await playwrightPage.logout(); + await expect(page.getByTestId('home-hero-container')).toBeVisible(); + return { email, password, name }; + }); + + await test.step('Login with remembered device', async () => { + await authenticationPage.loginAndVerifyEmail(email, password); + await expect(page.url()).toContain('/app'); + await playwrightPage.logout(); + await expect(page.getByTestId('home-hero-container')).toBeVisible(); + }); + + await test.step('Should not need 2fa since device is remembered', async () => { + await authenticationPage.fillOutLoginForm(email, password); + await page.waitForURL(`**/app`); + await expect(page.url()).toContain('/app'); + }); + }); +}); diff --git a/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts b/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts new file mode 100644 index 000000000..2ee6ba0a7 --- /dev/null +++ b/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts @@ -0,0 +1,120 @@ +import { prisma } from '@jetstream/api-config'; +import { expect, test } from '../../fixtures/fixtures'; +import { getPasswordResetToken } from '../../utils/database-validation.utils'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe.configure({ mode: 'parallel' }); + +// Reset storage state for this file to avoid being authenticated +test.use({ storageState: { cookies: [], origins: [] } }); + +// TODO: use test.step + +test.describe('Login 2', () => { + test('Should SignUp, Log Out, Reset Password, and Login with new password', async ({ page, authenticationPage, playwrightPage }) => { + const { email } = await authenticationPage.signUpAndVerifyEmail(); + const password = authenticationPage.generateTestPassword(); + + await playwrightPage.logout(); + + await authenticationPage.goToPasswordReset(); + await authenticationPage.fillOutResetPasswordForm(email); + await expect(page.getByText('Check your email to continue the reset process.')).toBeVisible(); + + // ensure email verification was sent + await prisma.emailActivity.findFirstOrThrow({ where: { email, subject: { contains: 'Reset your password' } } }); + const { token: code } = await getPasswordResetToken(email); + + await authenticationPage.goToPasswordResetVerify({ email, code }); + + await authenticationPage.fillOutResetPasswordVerifyForm(password, password); + + await authenticationPage.loginAndVerifyEmail(email, password); + + await authenticationPage.page.waitForURL(`**/app`); + }); + + test('Should SignUp, Add TOTP MFA, logout, login', async ({ page, authenticationPage, playwrightPage }) => { + const { decodeBase32IgnorePadding } = await import('@oslojs/encoding'); + const { generateTOTP } = await import('@oslojs/otp'); + + const { email, password } = await authenticationPage.signUpAndVerifyEmail(); + + await playwrightPage.goToProfile(); + + // Setup TOTP MFA + await page.getByRole('button', { name: 'Set Up' }).click(); + const secret = await page.getByTestId('totp-secret').innerText(); + + // attempt to save invalid token + await page.getByTestId('settings-page').getByRole('textbox').click(); + await page.getByTestId('settings-page').getByRole('textbox').fill('123456'); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Failed to save 2fa settings')).toBeVisible(); + await page.getByRole('button', { name: 'Close' }).click(); + + // save a valid token + await page.getByTestId('settings-page').getByRole('textbox').click(); + const code = await generateTOTP(decodeBase32IgnorePadding(secret), 30, 6); + await page.getByTestId('settings-page').getByRole('textbox').fill(code); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByRole('heading', { name: 'Authenticator App Active' }).locator('span')).toBeVisible(); + + // TODO: validate that email was sent for adding/removing 2fa (probably disabling as well?) + + await playwrightPage.logout(); + + await authenticationPage.loginAndVerifyTotp(email, password, secret); + await expect(page.url()).toContain('/app'); + + await playwrightPage.goToProfile(); + + // Disable all MFA + await authenticationPage.mfaTotpMenuButton.click(); + await page.getByRole('menuitem', { name: 'Disable' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + + await authenticationPage.mfaEmailMenuButton.click(); + await page.getByRole('menuitem', { name: 'Disable' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + + await expect(page.getByText("You don't have two-factor")).toBeVisible(); + + await playwrightPage.logout(); + await expect(page.getByTestId('home-hero-container')).toBeVisible(); + + // Should not need 2fa since device is remembered + await authenticationPage.fillOutLoginForm(email, password); + await page.waitForURL(`**/app`); + await expect(page.url()).toContain('/app'); + + // re-enable TOTP to make sure that works + await playwrightPage.goToProfile(); + await authenticationPage.mfaTotpMenuButton.click(); + await page.getByRole('menuitem', { name: 'Enable' }).click(); + await expect(page.getByRole('heading', { name: 'Authenticator App Active' }).locator('span')).toBeVisible(); + + await playwrightPage.logout(); + + // Ensure 2fa is reactivated on logout and login + await authenticationPage.loginAndVerifyTotp(email, password, secret); + await expect(page.url()).toContain('/app'); + + // Delete TOTP and ensure that logout/login works successfully + await playwrightPage.goToProfile(); + await authenticationPage.mfaTotpMenuButton.click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Continue' }).click(); + + await expect(page.getByText("You don't have two-factor")).toBeVisible(); + + await playwrightPage.logout(); + + await authenticationPage.fillOutLoginForm(email, password); + await page.waitForURL(`**/app`); + await expect(page.url()).toContain('/app'); + }); +}); diff --git a/apps/jetstream-e2e/src/tests/orgs/orgs.spec.ts b/apps/jetstream-e2e/src/tests/orgs/orgs.spec.ts new file mode 100644 index 000000000..e20393327 --- /dev/null +++ b/apps/jetstream-e2e/src/tests/orgs/orgs.spec.ts @@ -0,0 +1,67 @@ +import { prisma } from '@jetstream/api-config'; +import { expect, test } from '../../fixtures/fixtures'; + +test.beforeEach(async ({ page }) => { + await page.goto('/app'); +}); + +test.describe.configure({ mode: 'serial' }); + +test.describe('Salesforce Orgs + Jetstream Orgs', () => { + test.beforeAll(async ({ environment }) => { + await prisma.jetstreamOrganization.deleteMany(); + await prisma.salesforceOrg.deleteMany({ + where: { + username: { + in: [environment.TEST_ORG_2, environment.TEST_ORG_2], + }, + }, + }); + }); + + test('Salesforce Org Lifecycle', async ({ page, environment, organizationsPage }) => { + await test.step('Add a production org', async () => { + await organizationsPage.addSalesforceOrg(environment.TEST_ORG_2, environment.E2E_LOGIN_PASSWORD, { + type: 'production', + }); + await expect(organizationsPage.orgDropdown).toHaveValue(environment.TEST_ORG_2); + }); + + await test.step('Add an org with a custom domain', async () => { + await organizationsPage.addSalesforceOrg(environment.TEST_ORG_3, environment.E2E_LOGIN_PASSWORD, { + type: 'custom', + domain: new URL(environment.E2E_LOGIN_URL).hostname.replace('.my.salesforce.com', ''), + }); + await expect(organizationsPage.orgDropdown).toHaveValue(environment.TEST_ORG_3); + }); + + const organizationName = 'Test Org'; + const organizationDescription = 'Test Description'; + + await test.step('Create a Jetstream Organization and add orgs to it', async () => { + await organizationsPage.goToJetstreamOrganizationsPage(); + await organizationsPage.addJetstreamOrganization(organizationName, organizationDescription); + + await organizationsPage.dragOrgToOrganization(organizationName, environment.TEST_ORG_2); + await organizationsPage.dragOrgToOrganization(organizationName, environment.TEST_ORG_3); + + await expect(page.getByRole('button', { name: 'Choose Organization' })).toBeVisible(); + }); + + await test.step('Ensure org dropdown shows correct orgs', async () => { + await organizationsPage.makeActiveJetstreamOrganization(organizationName); + await expect(page.getByTestId('header').getByText(organizationName)).toBeVisible(); + + await organizationsPage.orgDropdownContainer.click(); + const orgGroup = organizationsPage.orgDropdownContainer.getByRole('listbox').getByRole('group').getByRole('option'); + await expect(orgGroup).toHaveCount(2); + await expect(orgGroup).toHaveText([environment.TEST_ORG_2, environment.TEST_ORG_3]); + }); + + await test.step('Delete Organization', async () => { + await organizationsPage.deleteJetstreamOrganization(organizationName); + + await expect(page.getByText('Salesforce Orgs Not Assigned to Organization (3)')).toBeVisible(); + }); + }); +}); diff --git a/apps/jetstream-e2e/src/tests/security/security.spec.ts b/apps/jetstream-e2e/src/tests/security/security.spec.ts new file mode 100644 index 000000000..1ef594f71 --- /dev/null +++ b/apps/jetstream-e2e/src/tests/security/security.spec.ts @@ -0,0 +1,116 @@ +import { prisma } from '@jetstream/api-config'; +import { expect, test } from '../../fixtures/fixtures'; + +test.beforeEach(async ({ page }) => { + await page.goto('/app'); +}); + +test.describe.configure({ mode: 'parallel' }); + +// Reset storage state for this file to avoid being authenticated +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe('Security Checks', () => { + // TODO: maybe look into tampering with cookies? + + test('Attempt to access data different user', async ({ apiRequestUtils, newUser }) => { + const { email } = newUser; + + const { salesforceOrgs, jetstreamOrgs } = await test.step('Sign up and verify email', async () => { + // const { email, password, name } = await authenticationPage.signUpAndVerifyEmail(); + const salesforceOrgs = await prisma.salesforceOrg.findMany({ + where: { + jetstreamUser: { + email: { + not: email, + }, + }, + }, + }); + + const jetstreamOrgs = await prisma.jetstreamOrganization.findMany({ + where: { + user: { + email: { + not: email, + }, + }, + }, + }); + + await expect(salesforceOrgs.length).toBeGreaterThanOrEqual(1); + await expect(jetstreamOrgs.length).toBeGreaterThanOrEqual(1); + + return { salesforceOrgs, jetstreamOrgs }; + }); + + await test.step('Try to update or delete org from different user', async () => { + const orgsResponse = await apiRequestUtils.makeRequestRaw('GET', `/api/orgs`); + await expect(orgsResponse.ok()).toEqual(true); + + const updateSalesforceOrgResponse = await apiRequestUtils.makeRequestRaw('PATCH', `/api/orgs/${salesforceOrgs[0].uniqueId}`, { + jetstreamOrganizationId: salesforceOrgs[0].jetstreamOrganizationId, + uniqueId: salesforceOrgs[0].uniqueId, + label: 'I should not be able to change anything!', + filterText: salesforceOrgs[0].filterText, + instanceUrl: salesforceOrgs[0].instanceUrl, + loginUrl: salesforceOrgs[0].loginUrl, + userId: salesforceOrgs[0].userId, + email: salesforceOrgs[0].email, + organizationId: salesforceOrgs[0].organizationId, + username: salesforceOrgs[0].username, + displayName: salesforceOrgs[0].displayName, + thumbnail: salesforceOrgs[0].thumbnail, + apiVersion: salesforceOrgs[0].apiVersion, + orgName: salesforceOrgs[0].orgName, + orgCountry: salesforceOrgs[0].orgCountry, + orgOrganizationType: salesforceOrgs[0].orgOrganizationType, + orgInstanceName: salesforceOrgs[0].orgInstanceName, + orgIsSandbox: salesforceOrgs[0].orgIsSandbox, + orgLanguageLocaleKey: salesforceOrgs[0].orgLanguageLocaleKey, + orgNamespacePrefix: salesforceOrgs[0].orgNamespacePrefix, + orgTrialExpirationDate: salesforceOrgs[0].orgTrialExpirationDate, + color: salesforceOrgs[0].color, + connectionError: salesforceOrgs[0].connectionError, + createdAt: salesforceOrgs[0].createdAt, + updatedAt: salesforceOrgs[0].updatedAt, + }); + await expect(updateSalesforceOrgResponse.ok()).toBeFalsy(); + await expect(updateSalesforceOrgResponse.status()).toBe(404); + + const deleteSalesforceOrgResponse = await apiRequestUtils.makeRequestRaw('DELETE', `/api/orgs/${salesforceOrgs[0].uniqueId}`); + await expect(deleteSalesforceOrgResponse.ok()).toBeFalsy(); + await expect(deleteSalesforceOrgResponse.status()).toBe(404); + }); + + await test.step('Try to use an org from a different user for a query API request', async () => { + const useOrgFromDifferentUserResponse = await apiRequestUtils.makeRequestRaw( + 'POST', + `/api/query?isTooling=false&includeDeletedRecords=false`, + { query: 'SELECT Id FROM Account' }, + { + 'X-SFDC-ID': salesforceOrgs[0].uniqueId, + } + ); + await expect(useOrgFromDifferentUserResponse.ok()).toBeFalsy(); + await expect(useOrgFromDifferentUserResponse.status()).toBe(404); + }); + + await test.step('Try to update and delete a Jetstream organization from a different user', async () => { + const updateJetstreamResponse = await apiRequestUtils.makeRequestRaw('PUT', `/api/orgs/${jetstreamOrgs[0].id}`, { + id: jetstreamOrgs[0].id, + orgs: [], + name: 'I should not be able to change anything!', + description: jetstreamOrgs[0].description, + createdAt: jetstreamOrgs[0].createdAt, + updatedAt: jetstreamOrgs[0].updatedAt, + }); + await expect(updateJetstreamResponse.ok()).toBeFalsy(); + await expect(updateJetstreamResponse.status()).toBe(404); + + const deleteJetstreamResponse = await apiRequestUtils.makeRequestRaw('DELETE', `/api/orgs/${jetstreamOrgs[0].id}`); + await expect(deleteJetstreamResponse.ok()).toBeFalsy(); + await expect(deleteJetstreamResponse.status()).toBe(404); + }); + }); +}); diff --git a/apps/jetstream-e2e/src/utils/database-validation.utils.ts b/apps/jetstream-e2e/src/utils/database-validation.utils.ts new file mode 100644 index 000000000..1734859be --- /dev/null +++ b/apps/jetstream-e2e/src/utils/database-validation.utils.ts @@ -0,0 +1,35 @@ +import { prisma } from '@jetstream/api-config'; +import { SessionData } from '@jetstream/auth/types'; + +export async function verifyEmailLogEntryExists(email: string, subject: string) { + await prisma.emailActivity.findFirstOrThrow({ where: { email, subject: { contains: subject } } }); +} + +export async function getPasswordResetToken(email: string) { + return await prisma.passwordResetToken.findFirst({ where: { email, expiresAt: { gt: new Date() } } }); +} + +export async function hasPasswordResetToken(email: string, token: string) { + return (await prisma.passwordResetToken.count({ where: { email, token } })) > 0; +} + +export async function getUserSessionByEmail(email: string) { + const session = await prisma.sessions.findFirstOrThrow({ + where: { + sess: { + path: ['user', 'email'], + equals: email, + }, + }, + }); + return session.sess as unknown as SessionData; +} + +export async function getUserSessionById(sessionId: string) { + const session = await prisma.sessions.findFirstOrThrow({ + where: { + sid: sessionId, + }, + }); + return session.sess as unknown as SessionData; +} diff --git a/apps/jetstream-web-extension/environments/environment.prod.ts b/apps/jetstream-web-extension/environments/environment.prod.ts index a69234e1b..0cb902d86 100644 --- a/apps/jetstream-web-extension/environments/environment.prod.ts +++ b/apps/jetstream-web-extension/environments/environment.prod.ts @@ -3,7 +3,6 @@ export const environment = { production: true, rollbarClientAccessToken: process.env.NX_PUBLIC_ROLLBAR_KEY, amplitudeToken: process.env.NX_PUBLIC_AMPLITUDE_KEY, - authAudience: process.env.NX_PUBLIC_AUTH_AUDIENCE, MODE: process.env.MODE, BASE_URL: process.env.BASE_URL, PROD: process.env.PROD, diff --git a/apps/jetstream-web-extension/environments/environment.test.ts b/apps/jetstream-web-extension/environments/environment.test.ts index f16c251bc..1125cd0ad 100644 --- a/apps/jetstream-web-extension/environments/environment.test.ts +++ b/apps/jetstream-web-extension/environments/environment.test.ts @@ -3,7 +3,6 @@ export const environment = { production: true, rollbarClientAccessToken: process.env.NX_PUBLIC_ROLLBAR_KEY, amplitudeToken: process.env.NX_PUBLIC_AMPLITUDE_KEY, - authAudience: process.env.NX_PUBLIC_AUTH_AUDIENCE, // MODE: process.env.MODE, // BASE_URL: process.env.BASE_URL, // PROD: process.env.PROD, diff --git a/apps/jetstream-web-extension/environments/environment.ts b/apps/jetstream-web-extension/environments/environment.ts index d8de25c9e..c2df5a9f6 100644 --- a/apps/jetstream-web-extension/environments/environment.ts +++ b/apps/jetstream-web-extension/environments/environment.ts @@ -6,7 +6,6 @@ export const environment = { production: false, rollbarClientAccessToken: process.env.NX_PUBLIC_ROLLBAR_KEY, amplitudeToken: process.env.NX_PUBLIC_AMPLITUDE_KEY, - authAudience: process.env.NX_PUBLIC_AUTH_AUDIENCE, MODE: process.env.MODE, BASE_URL: process.env.BASE_URL, PROD: process.env.PROD, diff --git a/apps/jetstream-web-extension/src/controllers/extension.routes.ts b/apps/jetstream-web-extension/src/controllers/extension.routes.ts index d6c267d23..7453bfec1 100644 --- a/apps/jetstream-web-extension/src/controllers/extension.routes.ts +++ b/apps/jetstream-web-extension/src/controllers/extension.routes.ts @@ -16,29 +16,18 @@ router.get('/api/heartbeat', async (req) => { }); router.get('/api/me', async (req) => { return handleJsonResponse({ - email: 'unknown', - email_verified: true, - name: 'unknown', - nickname: 'unknown', - picture: 'unknown', - sub: 'unknown', - updated_at: 'unknown', id: 'unknown', userId: 'unknown', - createdAt: 'unknown', - updatedAt: 'unknown', - 'http://getjetstream.app/app_metadata': { - featureFlags: { - flagVersion: '', - flags: [], - isDefault: true, - }, - }, + email: 'unknown', + name: 'unknown', + emailVerified: true, + picture: null, preferences: { skipFrontdoorLogin: true, }, } as UserProfileUi); }); + router.get('/api/orgs', async (req) => { return handleJsonResponse([]); }); diff --git a/apps/jetstream-web-extension/webpack.config.js b/apps/jetstream-web-extension/webpack.config.js index 680b3ae16..f8056eb25 100644 --- a/apps/jetstream-web-extension/webpack.config.js +++ b/apps/jetstream-web-extension/webpack.config.js @@ -35,7 +35,6 @@ module.exports = composePlugins(withNx(), withReact(), (config) => { config.plugins.push( // @ts-expect-error this is valid, not sure why it is complaining new webpack.EnvironmentPlugin({ - NX_PUBLIC_AUTH_AUDIENCE: 'http://getjetstream.app/app_metadata', NX_PUBLIC_AMPLITUDE_KEY: '', NX_PUBLIC_ROLLBAR_KEY: '', }), diff --git a/apps/jetstream/src/app/AppRoutes.tsx b/apps/jetstream/src/app/AppRoutes.tsx index 30e8cb4ea..aff133fcc 100644 --- a/apps/jetstream/src/app/AppRoutes.tsx +++ b/apps/jetstream/src/app/AppRoutes.tsx @@ -3,6 +3,7 @@ import { APP_ROUTES, AppHome, OrgSelectionRequired } from '@jetstream/ui-core'; import { useEffect } from 'react'; import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; import lazy from './components/core/LazyLoad'; +import Profile from './components/profile/Profile'; const Organizations = lazy(() => import('@jetstream/feature/organizations').then((module) => ({ default: module.Organizations }))); @@ -247,7 +248,8 @@ export const AppRoutes = ({ featureFlags, userProfile }: AppRoutesProps) => { } /> } /> - } /> + } /> + } /> } /> ); diff --git a/apps/jetstream/src/app/app.tsx b/apps/jetstream/src/app/app.tsx index 86f573d8a..378cf9bd3 100644 --- a/apps/jetstream/src/app/app.tsx +++ b/apps/jetstream/src/app/app.tsx @@ -3,13 +3,12 @@ import { AppToast, ConfirmationServiceProvider } from '@jetstream/ui'; // import { initSocket } from '@jetstream/shared/data'; import { AppLoading, DownloadFileStream, ErrorBoundaryFallback, HeaderNavbar } from '@jetstream/ui-core'; import { OverlayProvider } from '@react-aria/overlays'; -import { Suspense, useEffect, useState } from 'react'; +import { Suspense, useState } from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { ErrorBoundary } from 'react-error-boundary'; import ModalContainer from 'react-modal-promise'; import { RecoilRoot } from 'recoil'; -import { environment } from '../environments/environment'; import { AppRoutes } from './AppRoutes'; import AppInitializer from './components/core/AppInitializer'; import AppStateResetOnOrgChange from './components/core/AppStateResetOnOrgChange'; @@ -25,14 +24,7 @@ import './components/core/monaco-loader'; export const App = () => { const [userProfile, setUserProfile] = useState>(); - const [featureFlags, setFeatureFlags] = useState>(new Set()); - - useEffect(() => { - if (userProfile && userProfile[environment.authAudience || '']?.featureFlags) { - const flags = new Set(userProfile[environment.authAudience || ''].featureFlags.flags); - setFeatureFlags(flags); - } - }, [userProfile]); + const [featureFlags, setFeatureFlags] = useState>(new Set(['all'])); return ( diff --git a/apps/jetstream/src/app/components/profile/2fa/Profile2fa.tsx b/apps/jetstream/src/app/components/profile/2fa/Profile2fa.tsx new file mode 100644 index 000000000..2c7d90124 --- /dev/null +++ b/apps/jetstream/src/app/components/profile/2fa/Profile2fa.tsx @@ -0,0 +1,57 @@ +import { css } from '@emotion/react'; +import { UserProfileAuthFactor } from '@jetstream/auth/types'; +import { Card, ScopedNotification } from '@jetstream/ui'; +import { FunctionComponent, useMemo } from 'react'; +import { Profile2faEmail } from './Profile2faEmail'; +import { Profile2faOtp } from './Profile2faOtp'; + +export interface Profile2faProps { + authFactors: UserProfileAuthFactor[]; + onUpdate: (authFactors: UserProfileAuthFactor[]) => void; +} + +export const Profile2fa: FunctionComponent = ({ authFactors, onUpdate }) => { + const factorsByType = useMemo(() => { + return { + // 'email': authFactors.filter((factor) => factor.type === 'email'), // TODO: + otp: authFactors.find((factor) => factor.type === '2fa-otp'), + otpEmail: authFactors.find((factor) => factor.type === '2fa-email'), + }; + }, [authFactors]); + + const has2faEnabled = useMemo(() => { + return authFactors.some(({ enabled }) => enabled); + }, [authFactors]); + + return ( + + {!has2faEnabled && ( + + You don't have two-factor authentication enabled. Enable it to add an extra layer of security to your account. + + )} + + + + ); +}; diff --git a/apps/jetstream/src/app/components/profile/2fa/Profile2faEmail.tsx b/apps/jetstream/src/app/components/profile/2fa/Profile2faEmail.tsx new file mode 100644 index 000000000..8b0d0afb6 --- /dev/null +++ b/apps/jetstream/src/app/components/profile/2fa/Profile2faEmail.tsx @@ -0,0 +1,82 @@ +import { UserProfileAuthFactor } from '@jetstream/auth/types'; +import { toggleEnableDisableAuthFactor } from '@jetstream/shared/data'; +import { DropDownItem } from '@jetstream/types'; +import { Badge, Card, ConfirmationModalPromise, DropDown, fireToast, Spinner } from '@jetstream/ui'; +import { FunctionComponent, useMemo, useState } from 'react'; + +export interface Profile2faEmailProps { + isEnabled: boolean; + onUpdate: (authFactors: UserProfileAuthFactor[]) => void; +} + +export const Profile2faEmail: FunctionComponent = ({ isEnabled, onUpdate }) => { + const [isLoading, setIsLoading] = useState(false); + + async function handleMenuAction(action: string) { + try { + if (action === 'disable') { + if ( + await ConfirmationModalPromise({ + content: 'Are you sure you want to disable two-factor authentication?', + }) + ) { + setIsLoading(true); + onUpdate(await toggleEnableDisableAuthFactor('2fa-email', 'disable')); + } + } else if (action === 'enable') { + setIsLoading(true); + onUpdate(await toggleEnableDisableAuthFactor('2fa-email', 'enable')); + } + } catch (ex) { + fireToast({ message: 'Failed to save 2fa settings', type: 'error' }); + } finally { + setIsLoading(false); + } + } + + const menuItems = useMemo(() => { + const items: DropDownItem[] = []; + if (isEnabled) { + items.push({ + id: 'disable', + value: 'Disable', + icon: { type: 'utility', icon: 'toggle_off', description: 'Disable' }, + }); + } else { + items.push({ + id: 'enable', + value: 'Enable', + icon: { type: 'utility', icon: 'toggle_on', description: 'Disable' }, + }); + } + return items; + }, [isEnabled]); + + return ( + + Email + {isEnabled && ( + + Active + + )} + + } + className="slds-is-relative" + actions={ + + } + > + {isLoading && } +

Enter a code sent to your email address

+
+ ); +}; diff --git a/apps/jetstream/src/app/components/profile/2fa/Profile2faOtp.tsx b/apps/jetstream/src/app/components/profile/2fa/Profile2faOtp.tsx new file mode 100644 index 000000000..bebe9e2b8 --- /dev/null +++ b/apps/jetstream/src/app/components/profile/2fa/Profile2faOtp.tsx @@ -0,0 +1,177 @@ +import { UserProfileAuthFactor } from '@jetstream/auth/types'; +import { logger } from '@jetstream/shared/client-logger'; +import { deleteAuthFactor, getOtpQrCode, saveOtpAuthFactor, toggleEnableDisableAuthFactor } from '@jetstream/shared/data'; +import { DropDownItem } from '@jetstream/types'; +import { Badge, Card, ConfirmationModalPromise, DropDown, fireToast, Input, Spinner } from '@jetstream/ui'; +import { FormEvent, FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'; + +export interface Profile2faOtpProps { + isConfigured: boolean; + isEnabled: boolean; + onUpdate: (authFactors: UserProfileAuthFactor[]) => void; +} + +export const Profile2faOtp: FunctionComponent = ({ isConfigured, isEnabled, onUpdate }) => { + const [otp2fa, setOtp2fa] = useState<{ secretToken: string; imageUri: string; uri: string }>(); + const [editIsActive, setEditIsActive] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [twoFaCode, setTwoFaCode] = useState(''); + + const get2faConfiguration = useCallback(async () => { + try { + setIsLoading(true); + setOtp2fa(await getOtpQrCode()); + } catch (ex) { + logger.error('Failed to get 2fa config', ex); + fireToast({ message: 'Failed to get configuration, please try again later.', type: 'error' }); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (editIsActive && !otp2fa) { + get2faConfiguration(); + } + }, [editIsActive, get2faConfiguration, otp2fa]); + + async function handleMenuAction(action: string) { + try { + if (action === 'disable') { + if ( + await ConfirmationModalPromise({ + content: 'Are you sure you want to disable two-factor authentication?', + }) + ) { + setIsLoading(true); + onUpdate(await toggleEnableDisableAuthFactor('2fa-otp', 'disable')); + } + } else if (action === 'enable') { + setIsLoading(true); + onUpdate(await toggleEnableDisableAuthFactor('2fa-otp', 'enable')); + } else if (action === 'delete') { + if ( + await ConfirmationModalPromise({ + content: 'Are you sure you want to delete your authenticator app configuration?', + }) + ) { + setIsLoading(true); + onUpdate(await deleteAuthFactor('2fa-otp')); + } + } + } catch (ex) { + logger.error('Failed to save 2fa', ex); + fireToast({ message: 'Failed to save 2fa settings', type: 'error' }); + } finally { + setIsLoading(false); + } + } + + async function handleSave(ev: FormEvent) { + try { + ev.preventDefault(); + if (!otp2fa || !twoFaCode) { + return; + } + setIsLoading(true); + onUpdate(await saveOtpAuthFactor(otp2fa.secretToken, twoFaCode)); + } catch (ex) { + logger.error('Failed to save 2fa', ex); + fireToast({ message: 'Failed to save 2fa settings', type: 'error' }); + } finally { + setIsLoading(false); + } + } + + async function handleCancel() { + setEditIsActive(false); + setTwoFaCode(''); + setIsLoading(false); + } + + const menuItems = useMemo(() => { + const items: DropDownItem[] = []; + if (isEnabled) { + items.push({ + id: 'disable', + value: 'Disable', + trailingDivider: true, + icon: { type: 'utility', icon: 'toggle_off', description: 'Disable' }, + }); + } else { + items.push({ + id: 'enable', + value: 'Enable', + trailingDivider: true, + icon: { type: 'utility', icon: 'toggle_on', description: 'Disable' }, + }); + } + items.push({ id: 'delete', value: 'Delete', icon: { type: 'utility', icon: 'delete', description: 'Delete' } }); + return items; + }, [isEnabled]); + + return ( + + Authenticator App + {isEnabled && ( + + Active + + )} + + } + className="slds-is-relative" + actions={ + <> + {!isConfigured && ( + + )} + {isConfigured && ( + + )} + + } + > + {isLoading && } + {!editIsActive &&

Use a code from an authenticator app

} + {editIsActive && otp2fa && ( +
+
Scan the QR code with your authenticator app
+ qr code +

+ Or enter the following code in your authenticator app: {otp2fa.secretToken} +

+ + setTwoFaCode(event.target.value)} + maxLength={6} + /> + +
+ + +
+
+ )} +
+ ); +}; diff --git a/apps/jetstream/src/app/components/profile/Profile.tsx b/apps/jetstream/src/app/components/profile/Profile.tsx new file mode 100644 index 000000000..1f45cc5cb --- /dev/null +++ b/apps/jetstream/src/app/components/profile/Profile.tsx @@ -0,0 +1,212 @@ +import type { UserProfileAuthFactor, UserProfileUiWithIdentities } from '@jetstream/auth/types'; +import { logger } from '@jetstream/shared/client-logger'; +import { ANALYTICS_KEYS, TITLES } from '@jetstream/shared/constants'; +import { + getFullUserProfile, + getUserProfile as getUserProfileUi, + initPassword, + initResetPassword, + removePassword, + updateUserProfile, +} from '@jetstream/shared/data'; +import { useRollbar, useTitle } from '@jetstream/shared/ui-utils'; +import { + AutoFullHeightContainer, + Grid, + GridCol, + Page, + PageHeader, + PageHeaderRow, + PageHeaderTitle, + ScopedNotification, + Spinner, + fireToast, +} from '@jetstream/ui'; +import { useAmplitude, userProfileState } from '@jetstream/ui-core'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSetRecoilState } from 'recoil'; +import { Profile2fa } from './2fa/Profile2fa'; +import { ProfileLinkedAccounts } from './ProfileLinkedAccounts'; +import { ProfileUserProfile } from './ProfileUserProfile'; +import { ProfileSessions } from './session/ProfileSessions'; + +const HEIGHT_BUFFER = 170; + +export const Profile = () => { + useTitle(TITLES.SETTINGS); + const isMounted = useRef(true); + const { trackEvent } = useAmplitude(); + const rollbar = useRollbar(); + const [loading, setLoading] = useState(false); + const [loadingError, setLoadingError] = useState(false); + const setUserProfile = useSetRecoilState(userProfileState); + const [fullUserProfile, setFullUserProfile] = useState(); + const [modifiedUser, setModifiedUser] = useState(); + const [editMode, setEditMode] = useState(false); + // const { linkAccount, unlinkAccount, loading: linkAccountLoading, providers } = useLinkAccount(); + // const {csrfToken} = useCsrfToken(); + + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + const getUserProfile = useCallback(async () => { + setLoading(true); + try { + setLoadingError(false); + setFullUserProfile(await getFullUserProfile()); + } catch (ex) { + rollbar.error('Settings: Error fetching user', { stack: ex.stack, message: ex.message }); + setLoadingError(true); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + getUserProfile(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (fullUserProfile) { + setModifiedUser({ ...fullUserProfile }); + } + }, [fullUserProfile]); + + async function handleSave() { + try { + if (!modifiedUser) { + return; + } + setLoading(true); + const userProfile = await updateUserProfile({ name: modifiedUser.name }); + setUserProfile(await getUserProfileUi()); + setFullUserProfile(userProfile); + trackEvent(ANALYTICS_KEYS.settings_update_user); + } catch (ex) { + logger.warn('Error updating user', ex); + fireToast({ + message: 'There was a problem updating your user. Try again or file a support ticket for assistance.', + type: 'error', + }); + rollbar.error('Settings: Error updating user', { stack: ex.stack, message: ex.message }); + } finally { + setLoading(false); + setEditMode(false); + } + } + + function handleUpdatedAuthFactors(authFactors: UserProfileAuthFactor[]) { + setFullUserProfile((prior) => { + if (!prior) { + return prior; + } + return { ...prior, authFactors }; + }); + } + + function handleCancelEdit() { + setEditMode(false); + fullUserProfile && setModifiedUser({ ...fullUserProfile }); + } + + async function handleSetPassword(password: string) { + try { + setFullUserProfile(await initPassword(password)); + trackEvent(ANALYTICS_KEYS.settings_password_action, { action: 'set-password' }); + } catch (ex) { + fireToast({ + message: 'There was a problem setting your password. Try again or file a support ticket for assistance.', + type: 'error', + }); + rollbar.error('Settings: Error setting password', { stack: ex.stack, message: ex.message }); + } + } + + async function handleResetPassword() { + try { + await initResetPassword(); + trackEvent(ANALYTICS_KEYS.settings_password_action, { action: 'reset-password' }); + fireToast({ + message: 'An email has been sent to continue the password reset process.', + type: 'success', + }); + } catch (ex) { + fireToast({ + message: 'There was a problem resetting your password. Try again or file a support ticket for assistance.', + type: 'error', + }); + rollbar.error('Settings: Error resetting password', { stack: ex.stack, message: ex.message }); + } + } + + async function handleRemovePassword() { + try { + setFullUserProfile(await removePassword()); + trackEvent(ANALYTICS_KEYS.settings_password_action, { action: 'remove-password' }); + } catch (ex) { + fireToast({ + message: 'There was a problem removing your password. Try again or file a support ticket for assistance.', + type: 'error', + }); + rollbar.error('Settings: Error removing password', { stack: ex.stack, message: ex.message }); + } + } + + function handleProfileChange(modified: { name: string }) { + setModifiedUser((priorValue) => ({ ...fullUserProfile, ...priorValue, ...modified } as UserProfileUiWithIdentities)); + } + + return ( + + + + + + + + {/* Settings */} + {loading && } + {loadingError && ( + + There was a problem getting your profile information. Make sure you have an internet connection and file a support ticket if you + need additional assistance. + + )} + {fullUserProfile && ( + + + + + + + + + + + + + + )} + + + ); +}; + +export default Profile; diff --git a/apps/jetstream/src/app/components/profile/ProfileIdentityCard.tsx b/apps/jetstream/src/app/components/profile/ProfileIdentityCard.tsx new file mode 100644 index 000000000..7cfd1f05f --- /dev/null +++ b/apps/jetstream/src/app/components/profile/ProfileIdentityCard.tsx @@ -0,0 +1,106 @@ +import { css } from '@emotion/react'; +import { UserProfileIdentity } from '@jetstream/auth/types'; +import { ConfirmationModalPromise } from '@jetstream/ui'; +import { FunctionComponent, useState } from 'react'; + +function getProviderName(identity: UserProfileIdentity) { + if (identity.type === 'credentials') { + return 'Jetstream'; + } + switch (identity.provider) { + case 'google': + return 'Google'; + case 'salesforce': + return 'Salesforce'; + default: + return identity.provider; + } +} + +export interface ProfileIdentityCardProps { + identity: UserProfileIdentity; + omitUnlink?: boolean; + onUnlink: (identity: UserProfileIdentity) => void; +} + +export const ProfileIdentityCard: FunctionComponent = ({ identity, omitUnlink, onUnlink }) => { + const [providerName] = useState(() => getProviderName(identity)); + + const { name, email, picture } = identity; + const username = identity.username !== email ? identity.username : undefined; + + async function confirmUnlink() { + if ( + await ConfirmationModalPromise({ + confirm: 'Unlink Account', + content: ( +
+

+ Are you sure you want to unlink{' '} + + {providerName} - {email} + + ? +

+

You will no longer be able to login with the linked account.

+
+ ), + }) + ) { + onUnlink(identity); + } + } + + return ( +
  • +
    +

    + {providerName} +

    +
    +

    + {name} +

    + {username && username !== email && ( +

    + {username} +

    + )} +

    + {email} +

    + {omitUnlink && ( +

    + You must set a password before unlinking the remaining social identity +

    + )} + {!omitUnlink && ( +
    + +
    + )} + {picture && ( +
    + + Avatar + +
    + )} +
    +
    +
  • + ); +}; diff --git a/apps/jetstream/src/app/components/profile/ProfileLinkedAccounts.tsx b/apps/jetstream/src/app/components/profile/ProfileLinkedAccounts.tsx new file mode 100644 index 000000000..cce391f7c --- /dev/null +++ b/apps/jetstream/src/app/components/profile/ProfileLinkedAccounts.tsx @@ -0,0 +1,93 @@ +import { css } from '@emotion/react'; +import type { UserProfileIdentity, UserProfileUiWithIdentities } from '@jetstream/auth/types'; +import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; +import { useCsrfToken, useRollbar } from '@jetstream/shared/ui-utils'; +import { fireToast, Grid } from '@jetstream/ui'; +import { useAmplitude } from '@jetstream/ui-core'; +import { FunctionComponent } from 'react'; +import { ProfileIdentityCard } from './ProfileIdentityCard'; +import { useLinkAccount } from './useLinkAccount'; + +export interface ProfileLinkedAccountsProps { + fullUserProfile: UserProfileUiWithIdentities; + onUserProfilesChange: (userProfile: UserProfileUiWithIdentities) => void; +} + +const searchParams = new URLSearchParams({ + returnUrl: window.location.href, + isAccountLink: 'true', +}).toString(); + +export const ProfileLinkedAccounts: FunctionComponent = ({ fullUserProfile, onUserProfilesChange }) => { + const { trackEvent } = useAmplitude(); + const rollbar = useRollbar(); + const { unlinkAccount, loading: linkAccountLoading, providers } = useLinkAccount(); + const { csrfToken } = useCsrfToken(); + + async function handleUnlinkAccount(identity: UserProfileIdentity) { + try { + const userProfile = await unlinkAccount(identity); + onUserProfilesChange(userProfile); + trackEvent(ANALYTICS_KEYS.settings_unlink_account, { provider: identity.provider, userId: identity.providerAccountId }); + } catch (ex) { + fireToast({ + message: 'There was a problem unlinking your account. Try again or file a support ticket for assistance.', + type: 'error', + }); + rollbar.error('Settings: Error unlinking account', { stack: ex.stack, message: ex.message }); + } + } + + return ( + +

    Linked Accounts

    +

    You can login to Jetstream using any of these accounts

    +
      + {fullUserProfile.identities.map((identity, i) => ( + + ))} +
    + {providers && csrfToken && ( + +
    + + + +
    +
    + + + +
    +
    + )} +
    + ); +}; diff --git a/apps/jetstream/src/app/components/profile/ProfileUserPassword.tsx b/apps/jetstream/src/app/components/profile/ProfileUserPassword.tsx new file mode 100644 index 000000000..312ac7d32 --- /dev/null +++ b/apps/jetstream/src/app/components/profile/ProfileUserPassword.tsx @@ -0,0 +1,195 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import type { UserProfileUiWithIdentities } from '@jetstream/auth/types'; +import { DropDownItem } from '@jetstream/types'; +import { ConfirmationModalPromise, DropDown, FormRowItem, Input, ReadOnlyFormItem, Spinner } from '@jetstream/ui'; +import { FunctionComponent, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const FormSchema = z + .object({ + password: z.string().min(8).max(255), + confirmPassword: z.string().min(8).max(255), + }) + .superRefine((data, ctx) => { + if (data.password !== data.confirmPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Passwords do not match', + path: ['confirmPassword'], + }); + } + }); + +type Form = z.infer; + +export interface ProfileUserPasswordProps { + fullUserProfile: UserProfileUiWithIdentities; + onSetPassword: (password: string) => Promise; + onResetPassword: () => Promise; + onRemovePassword: () => Promise; +} + +export const ProfileUserPassword: FunctionComponent = ({ + fullUserProfile, + onResetPassword, + onRemovePassword, + onSetPassword, +}) => { + const items = useMemo(() => { + const items: DropDownItem[] = [ + { + id: 'reset-password', + value: 'Reset Password', + icon: { type: 'utility', icon: 'refresh', description: 'Reset password' }, + }, + ]; + + if (fullUserProfile.identities.some((i) => i.type === 'oauth')) { + items.push({ + id: 'remove-password', + value: 'Remove Password', + icon: { type: 'utility', icon: 'delete', description: 'Delete password' }, + }); + } + return items; + }, [fullUserProfile.identities]); + + async function handleSelection(id: string) { + try { + switch (id) { + case 'reset-password': { + await onResetPassword(); + break; + } + case 'remove-password': { + if ( + await ConfirmationModalPromise({ + content: 'Are you sure you want to remove your password?', + }) + ) { + await onRemovePassword(); + } + break; + } + default: + return; + } + } catch (ex) { + // + } + } + + if (fullUserProfile.hasPasswordSet) { + return ( + + + + + + ); + } + + return ; +}; + +function SetPassword({ onSetPassword }: { onSetPassword: (password: string) => Promise }) { + const [changePasswordActive, setChangePasswordActive] = useState(false); + const [loading, setLoading] = useState(false); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + password: '', + confirmPassword: '', + }, + }); + + async function handleSetPassword(data: Form) { + try { + setLoading(true); + await onSetPassword(data.password); + } catch (ex) { + //TODO: handle error + } finally { + setLoading(false); + } + } + + function handleCancelEdit() { + setChangePasswordActive(false); + reset({ confirmPassword: '', password: '' }); + } + + if (changePasswordActive) { + return ( +
    + {loading && } + + + + + + + + + + + + + + +
    + + +
    +
    + + ); + } + + return ( + + + + + + ); +} diff --git a/apps/jetstream/src/app/components/settings/SettingsUserProfile.tsx b/apps/jetstream/src/app/components/profile/ProfileUserProfile.tsx similarity index 72% rename from apps/jetstream/src/app/components/settings/SettingsUserProfile.tsx rename to apps/jetstream/src/app/components/profile/ProfileUserProfile.tsx index b5b65d223..0a6083158 100644 --- a/apps/jetstream/src/app/components/settings/SettingsUserProfile.tsx +++ b/apps/jetstream/src/app/components/profile/ProfileUserProfile.tsx @@ -1,9 +1,11 @@ import { css } from '@emotion/react'; -import { UserProfileUiWithIdentities } from '@jetstream/types'; +import type { UserProfileUiWithIdentities } from '@jetstream/auth/types'; import { Form, FormRow, FormRowItem, Grid, Input, ReadOnlyFormItem } from '@jetstream/ui'; -import { Fragment, FunctionComponent, useMemo } from 'react'; +import Avatar from '@salesforce-ux/design-system/assets/images/profile_avatar_96.png'; +import { Fragment, FunctionComponent, useMemo, useState } from 'react'; +import { ProfileUserPassword } from './ProfileUserPassword'; -export interface SettingsUserProfileProps { +export interface ProfileUserProfileProps { fullUserProfile: UserProfileUiWithIdentities; name: string; editMode: boolean; @@ -11,9 +13,12 @@ export interface SettingsUserProfileProps { onChange: (value: { name: string }) => void; onSave: () => void; onCancel: () => void; + onSetPassword: (password: string) => Promise; + onResetPassword: () => Promise; + onRemovePassword: () => Promise; } -export const SettingsUserProfile: FunctionComponent = ({ +export const ProfileUserProfile: FunctionComponent = ({ fullUserProfile, name, editMode, @@ -21,19 +26,24 @@ export const SettingsUserProfile: FunctionComponent = onChange, onSave, onCancel, + onSetPassword, + onResetPassword, + onRemovePassword, }) => { const invalidName = !name || name.length > 255; const blockNameEdit = useMemo( - () => fullUserProfile.identities.some((identity) => identity.provider !== 'auth0'), + () => fullUserProfile.identities.some((identity) => identity.isPrimary && identity.provider !== 'credentials'), [fullUserProfile.identities] ); + const [avatarSrc, setAvatarSrc] = useState(fullUserProfile?.picture || Avatar); + return (
    - {fullUserProfile.name} + Avatar setAvatarSrc(Avatar)} />
    {/* TODO: implement this sometime */} {/*
    @@ -76,6 +86,12 @@ export const SettingsUserProfile: FunctionComponent = {fullUserProfile.email} + {editMode && ( @@ -92,5 +108,3 @@ export const SettingsUserProfile: FunctionComponent = ); }; - -export default SettingsUserProfile; diff --git a/apps/jetstream/src/app/components/profile/session/ProfileSessionItem.tsx b/apps/jetstream/src/app/components/profile/session/ProfileSessionItem.tsx new file mode 100644 index 000000000..04484167d --- /dev/null +++ b/apps/jetstream/src/app/components/profile/session/ProfileSessionItem.tsx @@ -0,0 +1,91 @@ +import { css } from '@emotion/react'; +import { SessionIpData, UserSessionWithLocation } from '@jetstream/auth/types'; +import { Badge, Card, Grid } from '@jetstream/ui'; +import Bowser from 'bowser'; +import { parseISO } from 'date-fns/parseISO'; +import startCase from 'lodash/startCase'; +import { FunctionComponent, useMemo } from 'react'; + +export interface ProfileSessionItemProps { + isCurrentSession: boolean; + session: UserSessionWithLocation; + onRevokeSession: (sessionId: string) => void; +} + +export const ProfileSessionItem: FunctionComponent = ({ isCurrentSession, session, onRevokeSession }) => { + const { expires, ipAddress, location, loginTime, provider, userAgent } = session; + + const { browserName, browserVersion, osName } = useMemo(() => { + const browser = Bowser.getParser(userAgent); + return { + browserName: browser.getBrowserName(), + browserVersion: browser.getBrowserVersion(), + osName: browser.getOSName(), + }; + }, [userAgent]); + + return ( + + {osName} + {isCurrentSession && ( + + This Device + + )} + + } + actions={ + !isCurrentSession && ( + + ) + } + > +

    + {browserName} {browserVersion} +

    +

    + {ipAddress} {location && } +

    +

    Logged in via {provider === 'credentials' ? 'Email & Password' : startCase(provider)}

    +

    + Issued at + {parseISO(loginTime).toLocaleString()} +

    +

    + Expires at + {parseISO(expires).toLocaleString()} +

    +
    + ); +}; + +function Location({ location }: { location: SessionIpData }) { + if (location.status !== 'success') { + return null; + } + const { city, countryCode } = location; + return ( + + ({city}, {countryCode}) + + ); +} diff --git a/apps/jetstream/src/app/components/profile/session/ProfileSessions.tsx b/apps/jetstream/src/app/components/profile/session/ProfileSessions.tsx new file mode 100644 index 000000000..bd3dc0926 --- /dev/null +++ b/apps/jetstream/src/app/components/profile/session/ProfileSessions.tsx @@ -0,0 +1,129 @@ +import { css } from '@emotion/react'; +import { UserSessionWithLocation } from '@jetstream/auth/types'; +import { logger } from '@jetstream/shared/client-logger'; +import { getUserSessions, revokeAllUserSessions, revokeUserSession } from '@jetstream/shared/data'; +import { DropDownItem } from '@jetstream/types'; +import { Card, ConfirmationModalPromise, DropDown, fireToast, ScopedNotification, Spinner } from '@jetstream/ui'; +import partition from 'lodash/partition'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ProfileSessionItem } from './ProfileSessionItem'; + +const items: DropDownItem[] = [ + { + id: 'revoke-all', + value: 'Revoke All Other Sessions', + icon: { type: 'utility', icon: 'delete', description: 'Delete' }, + }, +]; + +export const ProfileSessions = () => { + const [currentSessionId, setCurrentSessionId] = useState(); + const [sessions, setSessions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + + const [currentSession, otherSessions] = useMemo(() => { + return partition(sessions, (session) => session.sessionId === currentSessionId); + }, [currentSessionId, sessions]); + + const getSessions = useCallback(async () => { + try { + setIsLoading(true); + setErrorMessage(null); + const response = await getUserSessions(); + setCurrentSessionId(response.currentSessionId); + setSessions(response.sessions); + } catch (ex) { + logger.error('Failed to get sessions', ex); + setErrorMessage('Failed to get sessions, please try again later.'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + getSessions(); + }, [getSessions]); + + async function handleRevokeSession(sessionId: string) { + try { + if ( + await ConfirmationModalPromise({ + content: 'Are you sure you want to revoke this session?', + }) + ) { + setIsLoading(true); + const response = await revokeUserSession(sessionId); + setCurrentSessionId(response.currentSessionId); + setSessions(response.sessions); + } + } catch (ex) { + logger.error('Failed to revoke session', ex); + fireToast({ message: 'There was an error revoking your sessions, try again later.', type: 'error' }); + } finally { + setIsLoading(false); + } + } + + async function handleMenuAction(action: string) { + try { + if (action === 'revoke-all') { + if ( + await ConfirmationModalPromise({ + content: 'Are you sure you want to revoke all sessions?', + }) + ) { + setIsLoading(true); + const response = await revokeAllUserSessions(currentSessionId); + setCurrentSessionId(response.currentSessionId); + setSessions(response.sessions); + } + } + } catch (ex) { + logger.error('Failed to revoke sessions', ex); + fireToast({ message: 'There was an error revoking your sessions, try again later.', type: 'error' }); + } finally { + setIsLoading(false); + } + } + + return ( + 1 && ( + + ) + } + > + {errorMessage && {errorMessage}} + {isLoading && } + {currentSession.map((session) => ( + + ))} + {otherSessions.map((session) => ( + + ))} + + ); +}; diff --git a/apps/jetstream/src/app/components/profile/useLinkAccount.ts b/apps/jetstream/src/app/components/profile/useLinkAccount.ts new file mode 100644 index 000000000..21d0bde4a --- /dev/null +++ b/apps/jetstream/src/app/components/profile/useLinkAccount.ts @@ -0,0 +1,42 @@ +import { Providers, UserProfileIdentity } from '@jetstream/auth/types'; +import { logger } from '@jetstream/shared/client-logger'; +import { getAuthProviders, unlinkIdentityFromProfile } from '@jetstream/shared/data'; +import { useCallback, useEffect, useState } from 'react'; + +export function useLinkAccount() { + const [providers, setProviders] = useState(); + const [loading, setLoading] = useState(false); + + const fetchAuthProviders = useCallback(async () => { + try { + setLoading(true); + setProviders(await getAuthProviders()); + } catch (ex) { + logger.warn('[FETCH AUTH PROVIDERS][ERROR]', ex); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAuthProviders(); + }, [fetchAuthProviders]); + + const unlinkAccount = useCallback(async (identity: UserProfileIdentity) => { + try { + setLoading(true); + const fullUserProfile = await unlinkIdentityFromProfile({ + provider: identity.provider, + providerAccountId: identity.providerAccountId, + }); + setLoading(false); + return fullUserProfile; + } catch (ex) { + logger.error('[UNLINK ACCOUNT][ERROR]', ex); + setLoading(false); + throw new Error(ex); + } + }, []); + + return { unlinkAccount, providers, loading }; +} diff --git a/apps/jetstream/src/app/components/settings/Settings.tsx b/apps/jetstream/src/app/components/settings/Settings.tsx index 6dff6638b..8c00f7c9c 100644 --- a/apps/jetstream/src/app/components/settings/Settings.tsx +++ b/apps/jetstream/src/app/components/settings/Settings.tsx @@ -1,21 +1,8 @@ +import type { UserProfileUiWithIdentities } from '@jetstream/auth/types'; import { logger } from '@jetstream/shared/client-logger'; import { ANALYTICS_KEYS, TITLES } from '@jetstream/shared/constants'; -import { - deleteUserProfile, - getFullUserProfile, - getUserProfile as getUserProfileUi, - resendVerificationEmail, - updateUserProfile, -} from '@jetstream/shared/data'; +import { deleteUserProfile, getFullUserProfile, getUserProfile as getUserProfileUi, updateUserProfile } from '@jetstream/shared/data'; import { eraseCookies, useRollbar, useTitle } from '@jetstream/shared/ui-utils'; -import { - Auth0ConnectionName, - Maybe, - UserProfileAuth0Identity, - UserProfileAuth0Ui, - UserProfileUi, - UserProfileUiWithIdentities, -} from '@jetstream/types'; import { AutoFullHeightContainer, CheckboxToggle, @@ -27,25 +14,16 @@ import { Spinner, fireToast, } from '@jetstream/ui'; -import { userProfileState } from '@jetstream/ui-core'; +import { useAmplitude, userProfileState } from '@jetstream/ui-core'; import localforage from 'localforage'; -import { Fragment, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useSetRecoilState } from 'recoil'; -import { useAmplitude } from '@jetstream/ui-core'; import LoggerConfig from './LoggerConfig'; -import SettingsDeleteAccount from './SettingsDeleteAccount'; -import SettingsLinkedAccounts from './SettingsLinkedAccounts'; -import SettingsUserProfile from './SettingsUserProfile'; -import { useLinkAccount } from './useLinkAccount'; +import { SettingsDeleteAccount } from './SettingsDeleteAccount'; const HEIGHT_BUFFER = 170; -export interface SettingsProps { - userProfile: Maybe; - featureFlags: Set; -} - -export const Settings: FunctionComponent = ({ userProfile, featureFlags }) => { +export const Settings = () => { useTitle(TITLES.SETTINGS); const isMounted = useRef(true); const { trackEvent } = useAmplitude(); @@ -55,8 +33,6 @@ export const Settings: FunctionComponent = ({ userProfile, featur const setUserProfile = useSetRecoilState(userProfileState); const [fullUserProfile, setFullUserProfile] = useState(); const [modifiedUser, setModifiedUser] = useState(); - const [editMode, setEditMode] = useState(false); - const { linkAccount, unlinkAccount, loading: linkAccountLoading } = useLinkAccount(); useEffect(() => { isMounted.current = true; @@ -110,61 +86,15 @@ export const Settings: FunctionComponent = ({ userProfile, featur rollbar.error('Settings: Error updating user', { stack: ex.stack, message: ex.message }); } finally { setLoading(false); - setEditMode(false); - } - } - - function handleCancelEdit() { - setEditMode(false); - fullUserProfile && setModifiedUser({ ...fullUserProfile }); - } - - async function handleUnlinkAccount(identity: UserProfileAuth0Identity) { - try { - const userProfile = await unlinkAccount(identity); - setFullUserProfile(userProfile); - trackEvent(ANALYTICS_KEYS.settings_unlink_account, { provider: identity.provider, userId: identity.user_id }); - } catch (ex) { - fireToast({ - message: 'There was a problem unlinking your account. Try again or file a support ticket for assistance.', - type: 'error', - }); - rollbar.error('Settings: Error unlinking account', { stack: ex.stack, message: ex.message }); - } - } - - async function handleOnResendVerificationEmail(identity: UserProfileAuth0Identity) { - try { - await resendVerificationEmail({ provider: identity.provider, userId: identity.user_id }); - fireToast({ - message: 'You have been sent an email to verify your email address.', - type: 'success', - }); - trackEvent(ANALYTICS_KEYS.settings_resend_email_verification, { provider: identity.provider, userId: identity.user_id }); - } catch (ex) { - fireToast({ - message: 'There was a problem sending the email verification. Try again or file a support ticket for assistance.', - type: 'error', - }); - rollbar.error('Settings: Error sending verification email', { stack: ex.stack, message: ex.message }); } } - function handleProfileChange(modified: Pick) { - setModifiedUser((priorValue) => ({ ...fullUserProfile, ...priorValue, ...modified } as UserProfileUiWithIdentities)); - } - function handleFrontdoorLoginChange(skipFrontdoorLogin: boolean) { const _modifiedUser = { ...modifiedUser, preferences: { skipFrontdoorLogin } } as UserProfileUiWithIdentities; setModifiedUser(_modifiedUser); handleSave(_modifiedUser); } - function handleLinkAccount(connection: Auth0ConnectionName) { - linkAccount(connection, getUserProfile); - trackEvent(ANALYTICS_KEYS.settings_link_account, { provider: connection }); - } - async function handleDelete(reason: string) { /** * FUTURE: @@ -199,7 +129,7 @@ export const Settings: FunctionComponent = ({ userProfile, featur {/* Settings */} - {(loading || linkAccountLoading) && } + {loading && } {loadingError && ( There was a problem getting your profile information. Make sure you have an internet connection and file a support ticket if you @@ -207,17 +137,7 @@ export const Settings: FunctionComponent = ({ userProfile, featur )} {fullUserProfile && ( - - - +
    = ({ userProfile, featur onChange={handleFrontdoorLoginChange} /> - - -
    +
    - +
    )} diff --git a/apps/jetstream/src/app/components/settings/SettingsDeleteAccount.tsx b/apps/jetstream/src/app/components/settings/SettingsDeleteAccount.tsx index 217358fd1..4f41ff6cd 100644 --- a/apps/jetstream/src/app/components/settings/SettingsDeleteAccount.tsx +++ b/apps/jetstream/src/app/components/settings/SettingsDeleteAccount.tsx @@ -78,5 +78,3 @@ export const SettingsDeleteAccount: FunctionComponent ); }; - -export default SettingsDeleteAccount; diff --git a/apps/jetstream/src/app/components/settings/SettingsIdentityCard.tsx b/apps/jetstream/src/app/components/settings/SettingsIdentityCard.tsx deleted file mode 100644 index 92780deb8..000000000 --- a/apps/jetstream/src/app/components/settings/SettingsIdentityCard.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { css } from '@emotion/react'; -import { Maybe, UserProfileAuth0Identity, UserProfileUiWithIdentities } from '@jetstream/types'; -import { Badge, ConfirmationModalPromise } from '@jetstream/ui'; -import { Fragment, FunctionComponent, useState } from 'react'; - -function getProviderName(identity: UserProfileAuth0Identity) { - if (!identity.isSocial) { - return 'Jetstream'; - } - switch (identity.connection) { - case 'google-oauth2': - return 'Google'; - case 'salesforce': - return 'Salesforce'; - case 'github': - return 'GitHub'; - default: - return identity.connection; - } -} - -function getUsername(identity: UserProfileAuth0Identity, fallback: UserProfileUiWithIdentities) { - if (!identity.isSocial) { - return null; - } - // The first profile has root-level properties set and does not have profileData property set - switch (identity.connection) { - case 'salesforce': - if (!identity.profileData) { - return fallback.username; - } - return identity.profileData.username; - case 'github': - if (!identity.profileData) { - return fallback.nickname; - } - return identity.profileData.nickname; - default: - return null; - } -} - -export interface SettingsIdentityCardProps { - identity: UserProfileAuth0Identity; - fallback: UserProfileUiWithIdentities; - omitUnlink?: boolean; - onUnlink: (identity: UserProfileAuth0Identity) => void; - onResendVerificationEmail: (identity: UserProfileAuth0Identity) => void; -} - -export const SettingsIdentityCard: FunctionComponent = ({ - identity, - fallback, - omitUnlink, - onResendVerificationEmail, - onUnlink, -}) => { - const [providerName] = useState(() => getProviderName(identity)); - const [username] = useState>(() => getUsername(identity, fallback)); - - const { profileData, user_id } = identity; - const name = profileData?.name || fallback.name; - const email = profileData?.email || fallback.email; - const picture = profileData?.picture || fallback.picture; - const emailVerified = profileData?.email_verified || fallback.emailVerified; - - async function confirmUnlink() { - if ( - await ConfirmationModalPromise({ - confirm: 'Unlink Account', - content: ( -
    -

    - Are you sure you want to unlink{' '} - - {providerName} - {email} - - ? -

    -

    You will no longer be able to login with the linked account.

    -
    - ), - }) - ) { - onUnlink(identity); - } - } - - return ( -
  • -
    -

    - {providerName} -

    -
    -

    - {name} - {username && ( - - - {username} - - )} -

    -

    - {email} -

    - {!emailVerified && ( - - Unverified Email - - - )} - {omitUnlink &&

    Your primary account cannot be unlinked

    } - {!omitUnlink && ( -
    - -
    - )} - {picture && ( -
    - - Avatar - -
    - )} -
    -
    -
  • - ); -}; - -export default SettingsIdentityCard; diff --git a/apps/jetstream/src/app/components/settings/SettingsLinkedAccounts.tsx b/apps/jetstream/src/app/components/settings/SettingsLinkedAccounts.tsx deleted file mode 100644 index 66d12e7ff..000000000 --- a/apps/jetstream/src/app/components/settings/SettingsLinkedAccounts.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Auth0ConnectionName, UserProfileAuth0Identity, UserProfileUiWithIdentities } from '@jetstream/types'; -import { Grid } from '@jetstream/ui'; -import { FunctionComponent } from 'react'; -import SettingsIdentityCard from './SettingsIdentityCard'; - -export interface SettingsLinkedAccountsProps { - fullUserProfile: UserProfileUiWithIdentities; - onLink: (connection: Auth0ConnectionName) => void; - onUnlink: (identity: UserProfileAuth0Identity) => void; - onResendVerificationEmail: (identity: UserProfileAuth0Identity) => void; -} - -export const SettingsLinkedAccounts: FunctionComponent = ({ - fullUserProfile, - onLink, - onUnlink, - onResendVerificationEmail, -}) => { - return ( - -
    Linked Accounts
    -

    You can login to Jetstream using any of these accounts

    -
      - {fullUserProfile.identities.map((identity, i) => ( - - ))} -
    -
    - - -
    -
    - ); -}; - -export default SettingsLinkedAccounts; diff --git a/apps/jetstream/src/app/components/settings/useLinkAccount.ts b/apps/jetstream/src/app/components/settings/useLinkAccount.ts deleted file mode 100644 index 919517c3a..000000000 --- a/apps/jetstream/src/app/components/settings/useLinkAccount.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { logger } from '@jetstream/shared/client-logger'; -import { unlinkIdentityFromProfile } from '@jetstream/shared/data'; -import { Auth0ConnectionName, Maybe, UserProfileAuth0Identity } from '@jetstream/types'; -import { applicationCookieState } from '@jetstream/ui-core'; -import { useCallback, useState } from 'react'; -import { useRecoilState } from 'recoil'; - -let windowRef: Maybe; -let addOrgCallbackFn: () => void; - -function handleWindowEvent(event: MessageEvent) { - try { - if (addOrgCallbackFn) { - addOrgCallbackFn(); - } - if (windowRef) { - windowRef.close(); - window.removeEventListener('message', handleWindowEvent); - } - } catch (ex) { - // TODO: tell user there was a problem - } -} - -function linkAccountFn(options: { serverUrl: string; connection: Auth0ConnectionName }, callback: () => void) { - const { serverUrl, connection } = options; - addOrgCallbackFn = callback; - window.removeEventListener('message', handleWindowEvent); - const strWindowFeatures = 'toolbar=no, menubar=no, width=1025, height=700'; - const url = `${serverUrl}/oauth/identity/link?connection=${encodeURIComponent(connection)}`; - - windowRef = window.open(url, 'Link Jetstream Account', strWindowFeatures); - window.addEventListener('message', handleWindowEvent, false); -} - -export function useLinkAccount() { - const [{ serverUrl }] = useRecoilState(applicationCookieState); - const [loading, setLoading] = useState(false); - - const linkAccount = useCallback( - (connection: Auth0ConnectionName, callback: () => void) => { - linkAccountFn({ serverUrl, connection }, callback); - }, - [serverUrl] - ); - - const unlinkAccount = useCallback(async (identity: UserProfileAuth0Identity) => { - try { - setLoading(true); - const fullUserProfile = await unlinkIdentityFromProfile({ provider: identity.provider, userId: identity.user_id }); - setLoading(false); - return fullUserProfile; - } catch (ex) { - logger.error('[UNLINK ACCOUNT][ERROR]', ex); - setLoading(false); - throw new Error(ex); - } - }, []); - - return { linkAccount, unlinkAccount, loading }; -} diff --git a/apps/jetstream/src/environments/environment.prod.ts b/apps/jetstream/src/environments/environment.prod.ts index ab635d6f2..76a75d775 100644 --- a/apps/jetstream/src/environments/environment.prod.ts +++ b/apps/jetstream/src/environments/environment.prod.ts @@ -3,7 +3,6 @@ export const environment = { production: true, rollbarClientAccessToken: import.meta.env.NX_PUBLIC_ROLLBAR_KEY, amplitudeToken: import.meta.env.NX_PUBLIC_AMPLITUDE_KEY, - authAudience: import.meta.env.NX_PUBLIC_AUTH_AUDIENCE, MODE: import.meta.env.MODE, BASE_URL: import.meta.env.BASE_URL, PROD: import.meta.env.PROD, diff --git a/apps/jetstream/src/environments/environment.test.ts b/apps/jetstream/src/environments/environment.test.ts index 3785a4f16..4ed5f6ee0 100644 --- a/apps/jetstream/src/environments/environment.test.ts +++ b/apps/jetstream/src/environments/environment.test.ts @@ -3,7 +3,6 @@ export const environment = { production: true, rollbarClientAccessToken: process.env.NX_PUBLIC_ROLLBAR_KEY, amplitudeToken: process.env.NX_PUBLIC_AMPLITUDE_KEY, - authAudience: process.env.NX_PUBLIC_AUTH_AUDIENCE, // MODE: import.meta.env.MODE, // BASE_URL: import.meta.env.BASE_URL, // PROD: import.meta.env.PROD, diff --git a/apps/jetstream/src/environments/environment.ts b/apps/jetstream/src/environments/environment.ts index 862506cd9..7f2d3c6b9 100644 --- a/apps/jetstream/src/environments/environment.ts +++ b/apps/jetstream/src/environments/environment.ts @@ -6,7 +6,6 @@ export const environment = { production: false, rollbarClientAccessToken: import.meta.env.NX_PUBLIC_ROLLBAR_KEY, amplitudeToken: import.meta.env.NX_PUBLIC_AMPLITUDE_KEY, - authAudience: import.meta.env.NX_PUBLIC_AUTH_AUDIENCE, MODE: import.meta.env.MODE, BASE_URL: import.meta.env.BASE_URL, PROD: import.meta.env.PROD, diff --git a/apps/jetstream/src/main.scss b/apps/jetstream/src/main.scss index cb28b439f..3cd5144d8 100644 --- a/apps/jetstream/src/main.scss +++ b/apps/jetstream/src/main.scss @@ -373,6 +373,10 @@ pre { background-color: $color-background-highlight-search; } +.mw-reset { + max-width: unset !important; +} + .w-100 { width: 100%; } diff --git a/apps/landing/components/Alert.tsx b/apps/landing/components/Alert.tsx new file mode 100644 index 000000000..5e553fafb --- /dev/null +++ b/apps/landing/components/Alert.tsx @@ -0,0 +1,73 @@ +import { XCircleIcon, XIcon } from '@heroicons/react/solid'; +import classNames from 'classnames'; +import { useEffect, useState } from 'react'; + +const errorClassMap = { + error: { + container: 'bg-red-50', + icon: 'text-red-400', + message: 'text-red-700', + }, + success: { + container: 'bg-green-50', + icon: 'text-green-400', + message: 'text-green-700', + }, + info: { + container: 'bg-blue-50', + icon: 'text-blue-400', + message: 'text-blue-700', + }, + warning: { + container: 'bg-yellow-50', + icon: 'text-yellow-400', + message: 'text-yellow-700', + }, +}; + +interface AlertProps { + message: string; + type?: keyof typeof errorClassMap; + dismissable?: boolean; +} + +export default function Alert({ message, type = 'error', dismissable }: AlertProps) { + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + setDismissed(false); + }, [message]); + + if (dismissed) { + return null; + } + + return ( +
    +
    +
    +
    +
    +
    +

    {message}

    +
    +
    + {dismissable && ( +
    +
    + +
    +
    + )} +
    +
    + ); +} diff --git a/apps/landing/components/ErrorQueryParamErrorBanner.tsx b/apps/landing/components/ErrorQueryParamErrorBanner.tsx new file mode 100644 index 000000000..e3ff55b3e --- /dev/null +++ b/apps/landing/components/ErrorQueryParamErrorBanner.tsx @@ -0,0 +1,37 @@ +import { Maybe } from '@jetstream/types'; +import { useSearchParams } from 'next/navigation'; +import { SIGN_IN_ERRORS } from '../utils/environment'; +import Alert from './Alert'; + +interface ErrorQueryParamErrorBannerProps { + /** + * If provided, this will be used instead of checking the query params. + */ + error?: Maybe; + success?: Maybe; +} + +export function ErrorQueryParamErrorBanner({ error, success }: ErrorQueryParamErrorBannerProps) { + const params = useSearchParams(); + + error = error ?? params.get('error'); + success = success ?? params.get('success'); + + if (error) { + return ( +
    + +
    + ); + } + + if (success) { + return ( +
    + +
    + ); + } + + return null; +} diff --git a/apps/landing/components/Footer.tsx b/apps/landing/components/Footer.tsx index 14e3f2d59..62ab4f0ed 100644 --- a/apps/landing/components/Footer.tsx +++ b/apps/landing/components/Footer.tsx @@ -19,7 +19,11 @@ const footerNavigation = { ], }; -export const Footer = ({ omitLinks = [] }: { omitLinks?: string[] }) => ( +export interface FooterProps { + omitLinks?: string[]; +} + +export const Footer = ({ omitLinks = [] }: FooterProps) => (
    +
    + ); +} diff --git a/apps/landing/components/auth/LoginOrSignUpWrapper.tsx b/apps/landing/components/auth/LoginOrSignUpWrapper.tsx new file mode 100644 index 000000000..8e92fc638 --- /dev/null +++ b/apps/landing/components/auth/LoginOrSignUpWrapper.tsx @@ -0,0 +1,58 @@ +import { Providers } from '@jetstream/auth/types'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { useCsrfToken, useUserProfile } from '../../hooks/auth.hooks'; +import { AUTH_PATHS, ENVIRONMENT, SIGN_IN_ERRORS } from '../../utils/environment'; +import Alert from '../Alert'; +import { LoginOrSignUp } from './LoginOrSignUp'; + +interface LoginOrSignUpWrapperProps { + action: 'login' | 'register'; +} + +export function LoginOrSignUpWrapper({ action }: LoginOrSignUpWrapperProps) { + const router = useRouter(); + const [providers, setProviders] = useState(); + const [error, setError] = useState(); + const { isLoading, pendingVerifications, isLoggedIn } = useUserProfile(); + const { csrfToken, csrfTokenError } = useCsrfToken(); + + useEffect(() => { + if (isLoggedIn && (!pendingVerifications || !pendingVerifications.length)) { + window.location.href = ENVIRONMENT.CLIENT_URL; + } else if (pendingVerifications) { + router.push(`${AUTH_PATHS.verify}`); + } + }, [isLoggedIn, pendingVerifications, router]); + + useEffect(() => { + fetch(AUTH_PATHS.api_providers) + .then((response) => { + if (response.ok) { + return response.json(); + } + throw new Error('Unable to fetch providers'); + }) + .then(({ data }: { data: Providers }) => { + setProviders(data); + }) + .catch((error) => { + setError(error?.message ?? 'Unable to initialize the form'); + }); + }, []); + + if (error || csrfTokenError) { + return ( +
    + +
    + ); + } + + // TODO: show loading indicator here instead + if (isLoading || !csrfToken || !providers) { + return null; + } + + return ; +} diff --git a/apps/landing/components/auth/PasswordResetInit.tsx b/apps/landing/components/auth/PasswordResetInit.tsx new file mode 100644 index 000000000..f98daa727 --- /dev/null +++ b/apps/landing/components/auth/PasswordResetInit.tsx @@ -0,0 +1,149 @@ +/* eslint-disable @next/next/no-img-element */ +import { zodResolver } from '@hookform/resolvers/zod'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { Fragment, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { AUTH_PATHS } from '../../utils/environment'; +import Alert from '../Alert'; +import { ErrorQueryParamErrorBanner } from '../ErrorQueryParamErrorBanner'; +import { Input } from '../form/Input'; +import { Captcha } from './Captcha'; + +const FormSchema = z.object({ + csrfToken: z.string(), + captchaToken: z.string(), + email: z.string().email({ message: 'A valid email address is required' }).min(5).max(255).trim(), +}); + +type Form = z.infer; + +interface PasswordResetInitProps { + csrfToken: string; +} + +export function PasswordResetInit({ csrfToken }: PasswordResetInitProps) { + const router = useRouter(); + const [isSaving, setIsSaving] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); + const [error, setError] = useState(); + + const { + register, + handleSubmit, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + email: '', + csrfToken: csrfToken, + captchaToken: '', + }, + }); + + const onSubmit = async (payload: Form) => { + try { + setIsSaving(true); + const response = await fetch(AUTH_PATHS.api_reset_password_init, { + method: 'POST', + credentials: 'include', + body: new URLSearchParams(payload).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Unable to initialize the reset process'); + } + + const responseData: { + data: { + error: boolean; + errorType?: string; + redirect?: string; + }; + } = await response.json(); + + const error = responseData.data.error; + const errorType = responseData.data.errorType; + + if (!response.ok || error) { + router.push(`${router.pathname}?${new URLSearchParams({ error: errorType || 'UNKNOWN_ERROR' })}`); + return; + } + + setIsSubmitted(true); + } catch (error) { + setError(error?.message ?? 'Unable to initialize the reset process'); + } finally { + setIsSaving(true); + } + }; + + return ( + + +
    +
    + + Jetstream + +

    Reset Password

    +
    +
    + {isSubmitted && } + {!isSubmitted && ( +
    + + + + + + setValue('captchaToken', token)} + formError={errors?.captchaToken?.message} + /> + +
    + +
    + + )} +

    + + Go to Login Page + +

    +
    +
    +
    + ); +} diff --git a/apps/landing/components/auth/PasswordResetVerify.tsx b/apps/landing/components/auth/PasswordResetVerify.tsx new file mode 100644 index 000000000..c0bb7f8fe --- /dev/null +++ b/apps/landing/components/auth/PasswordResetVerify.tsx @@ -0,0 +1,157 @@ +/* eslint-disable @next/next/no-img-element */ +import { zodResolver } from '@hookform/resolvers/zod'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { Fragment } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { AUTH_PATHS } from '../../utils/environment'; +import { PasswordSchema } from '../../utils/types'; +import { ErrorQueryParamErrorBanner } from '../ErrorQueryParamErrorBanner'; +import { Input } from '../form/Input'; + +const FormSchema = z + .object({ + csrfToken: z.string(), + captchaToken: z.string(), + token: z.string(), + email: z.string(), + password: PasswordSchema, + confirmPassword: PasswordSchema, + }) + .superRefine((data, ctx) => { + if (data.password !== data.confirmPassword) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Passwords do not match', + path: ['confirmPassword'], + }); + } + }); + +type Form = z.infer; + +interface PasswordResetVerifyProps { + csrfToken: string; + email: string; + token: string; +} + +export function PasswordResetVerify({ csrfToken, email, token }: PasswordResetVerifyProps) { + const router = useRouter(); + + const { + register, + handleSubmit, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + email, + token, + password: '', + confirmPassword: '', + csrfToken: csrfToken, + captchaToken: '', + }, + }); + + const onSubmit = async (payload: Form) => { + const response = await fetch(AUTH_PATHS.api_reset_password_verify, { + method: 'POST', + credentials: 'include', + body: new URLSearchParams(payload).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + }); + const responseData: { + data: { + error: boolean; + errorType?: string; + redirect?: string; + }; + } = await response.json(); + + const error = responseData.data.error; + const errorType = responseData.data.errorType; + + if (!response.ok || error) { + router.push(`${router.pathname}?${new URLSearchParams({ error: errorType || 'UNKNOWN_ERROR' })}`); + return; + } + + router.push(`${AUTH_PATHS.login}?${new URLSearchParams({ success: 'Login with your new password to continue' })}`); + }; + + // TODO: should user be required to confirm 2fa at this point, or just on the next login? + + return ( + + +
    +
    + + Jetstream + +

    Reset Password

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

    + + Go to Login Page + +

    +
    +
    +
    + ); +} diff --git a/apps/landing/components/auth/RegisterOrSignUpLink.tsx b/apps/landing/components/auth/RegisterOrSignUpLink.tsx new file mode 100644 index 000000000..07fd2a28a --- /dev/null +++ b/apps/landing/components/auth/RegisterOrSignUpLink.tsx @@ -0,0 +1,24 @@ +import Link from 'next/link'; +import { AUTH_PATHS } from '../../utils/environment'; + +export function RegisterOrSignUpLink({ action }: { action: 'login' | 'register' }) { + if (action === 'login') { + return ( +

    + Need to register?{' '} + + Sign up + +

    + ); + } + + return ( +

    + Already have an account?{' '} + + Login + +

    + ); +} diff --git a/apps/landing/components/auth/ShowPasswordButton.tsx b/apps/landing/components/auth/ShowPasswordButton.tsx new file mode 100644 index 000000000..25d84183d --- /dev/null +++ b/apps/landing/components/auth/ShowPasswordButton.tsx @@ -0,0 +1,12 @@ +interface ShowPasswordButtonProps { + isActive: boolean; + onClick: () => void; +} + +export function ShowPasswordButton({ isActive, onClick }: ShowPasswordButtonProps) { + return ( + + ); +} diff --git a/apps/landing/components/auth/VerifyEmailOr2fa.tsx b/apps/landing/components/auth/VerifyEmailOr2fa.tsx new file mode 100644 index 000000000..d329162a0 --- /dev/null +++ b/apps/landing/components/auth/VerifyEmailOr2fa.tsx @@ -0,0 +1,238 @@ +/* eslint-disable @next/next/no-img-element */ +import { zodResolver } from '@hookform/resolvers/zod'; +import { TwoFactorType } from '@jetstream/auth/types'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { FormEvent, Fragment, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { AUTH_PATHS, ENVIRONMENT } from '../../utils/environment'; +import { ErrorQueryParamErrorBanner } from '../ErrorQueryParamErrorBanner'; +import { Checkbox } from '../form/Checkbox'; +import { Input } from '../form/Input'; + +const FormSchema = z.object({ + csrfToken: z.string(), + code: z.string().min(6).max(6), + type: z.enum(['email', '2fa-otp', '2fa-email']), + rememberDevice: z.boolean().optional().default(false), +}); + +const TITLE_TEXT = { + email: 'Verify your email address', + '2fa-email': 'Enter your verification code from your email', + '2fa-otp': 'Enter your verification code from your authenticator app', +}; + +type Form = z.infer; + +interface VerifyEmailOr2faProps { + csrfToken: string; + pendingVerifications: TwoFactorType[]; +} + +export function VerifyEmailOr2fa({ csrfToken, pendingVerifications }: VerifyEmailOr2faProps) { + const router = useRouter(); + const [error, setError] = useState(); + const [hasResent, setHasResent] = useState(false); + + const [activeFactor, setActiveFactor] = useState(pendingVerifications[0]); + + const { + register, + handleSubmit, + setValue, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + code: '', + csrfToken, + captchaToken: '', + type: activeFactor, + rememberDevice: false, + }, + }); + + const captchaToken = watch('captchaToken'); + + const onSubmit = async (payload: Form) => { + const response = await fetch(AUTH_PATHS.api_verify, { + method: 'POST', + credentials: 'include', + body: new URLSearchParams({ + ...payload, + rememberDevice: payload.rememberDevice ? 'true' : 'false', + }).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + }); + + const data = (await response.json()) as { + error: boolean; + errorType?: string; + redirect?: string; + }; + + if (!response.ok || data.error) { + setError(data.errorType || 'UNKNOWN_ERROR'); + return; + } + + if (data.redirect?.startsWith(AUTH_PATHS._root_path)) { + router.push(data.redirect); + return; + } + + window.location.href = data.redirect || ENVIRONMENT.CLIENT_URL; + }; + + const handleResendEmailVerification = async (event: FormEvent) => { + try { + event.preventDefault(); + const formData = new FormData(event.target as HTMLFormElement); + const response = await fetch(AUTH_PATHS.api_verify_resend, { + method: 'POST', + credentials: 'include', + body: new URLSearchParams(formData as any).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + }); + if (!response.ok) { + throw new Error('Failed to send verification code'); + } + setHasResent(true); + } catch (ex) { + // FIXME: better error + setError('UNKNOWN_ERROR'); + } + }; + + return ( + + +
    +
    + + Jetstream + +

    {TITLE_TEXT[activeFactor]}

    +
    +
    +
    + + + + + + + {activeFactor !== 'email' && Remember this device} + +
    + +
    +
    + + { + setValue('type', type); + setActiveFactor(type); + }} + /> + +
    + {!hasResent && activeFactor !== '2fa-otp' && ( +
    + + + + +

    + Need a new code?{' '} + +

    +
    + )} + +

    + Need to start over?{' '} + + Logout + +

    +
    +
    +
    +
    + ); +} + +function ChangeAuthenticationMethod({ + activeFactor, + pendingVerifications, + onChange, +}: { + activeFactor: TwoFactorType; + pendingVerifications: TwoFactorType[]; + onChange: (type: TwoFactorType) => void; +}) { + const secondaryFactor = pendingVerifications.find((factor) => factor !== 'email' && factor !== activeFactor); + + if (activeFactor === 'email' || secondaryFactor === 'email' || !secondaryFactor) { + return null; + } + + if (activeFactor === '2fa-otp') { + return ( +

    + +

    + ); + } + + if (activeFactor === '2fa-email') { + return ( +

    + +

    + ); + } + + return null; +} diff --git a/apps/landing/components/auth/VerifyEmailOr2faWrapper.tsx b/apps/landing/components/auth/VerifyEmailOr2faWrapper.tsx new file mode 100644 index 000000000..8788054f3 --- /dev/null +++ b/apps/landing/components/auth/VerifyEmailOr2faWrapper.tsx @@ -0,0 +1,40 @@ +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; +import { useCsrfToken, useUserProfile } from '../../hooks/auth.hooks'; +import { AUTH_PATHS, ENVIRONMENT, SIGN_IN_ERRORS } from '../../utils/environment'; +import Alert from '../Alert'; +import { VerifyEmailOr2fa } from './VerifyEmailOr2fa'; + +export function VerifyEmailOr2faWrapper() { + const router = useRouter(); + const { isLoading, isVerificationExpired, pendingVerifications, isLoggedIn } = useUserProfile(); + const { csrfToken, csrfTokenError: error } = useCsrfToken(); + + useEffect(() => { + if (isLoading) { + return; + } + if (isLoggedIn && (!pendingVerifications || !pendingVerifications?.length)) { + // user is logged in and has no pending verification + window.location.href = ENVIRONMENT.CLIENT_URL; + } else if (!pendingVerifications || !pendingVerifications?.length || isVerificationExpired) { + // user is not logged in and has no pending verification + router.push(`${AUTH_PATHS.login}`); + } + }, [isLoading, isLoggedIn, isVerificationExpired, pendingVerifications, router]); + + if (error) { + return ( +
    + +
    + ); + } + + // TODO: show loading indicator here instead + if (isLoading || !csrfToken || !pendingVerifications) { + return null; + } + + return ; +} diff --git a/apps/landing/components/form/Checkbox.tsx b/apps/landing/components/form/Checkbox.tsx new file mode 100644 index 000000000..c95f11e21 --- /dev/null +++ b/apps/landing/components/form/Checkbox.tsx @@ -0,0 +1,21 @@ +import { DetailedHTMLProps, HTMLAttributes, InputHTMLAttributes, LabelHTMLAttributes, ReactNode, useId } from 'react'; + +interface InputProps { + labelProps?: DetailedHTMLProps, HTMLLabelElement>; + inputProps?: DetailedHTMLProps, HTMLInputElement>; + containerProps?: DetailedHTMLProps, HTMLDivElement>; + children?: ReactNode; +} + +export function Checkbox({ labelProps, inputProps, containerProps, children }: InputProps) { + const id = useId(); + + return ( +
    + + +
    + ); +} diff --git a/apps/landing/components/form/Input.tsx b/apps/landing/components/form/Input.tsx new file mode 100644 index 000000000..9be207701 --- /dev/null +++ b/apps/landing/components/form/Input.tsx @@ -0,0 +1,50 @@ +import { ExclamationCircleIcon } from '@heroicons/react/solid'; +import classNames from 'classnames'; +import { DetailedHTMLProps, HTMLAttributes, InputHTMLAttributes, LabelHTMLAttributes, ReactNode, useId } from 'react'; + +interface InputProps { + label: string; + error?: string; + labelProps?: DetailedHTMLProps, HTMLLabelElement>; + inputProps?: DetailedHTMLProps, HTMLInputElement>; + inputContainerProps?: DetailedHTMLProps, HTMLDivElement>; + children?: ReactNode; +} + +export function Input({ label, error, labelProps, inputProps, inputContainerProps, children }: InputProps) { + const id = useId(); + + return ( +
    + +
    + + {error && ( +
    +
    + )} +
    + {error && ( + + )} + {children} +
    + ); +} diff --git a/apps/landing/components/layouts/AuthPageLayout.tsx b/apps/landing/components/layouts/AuthPageLayout.tsx new file mode 100644 index 000000000..c1f0171e1 --- /dev/null +++ b/apps/landing/components/layouts/AuthPageLayout.tsx @@ -0,0 +1,24 @@ +import Head from 'next/head'; +import { FooterProps } from '../Footer'; +import { NavigationProps } from '../Navigation'; +import Layout from './Layout'; + +export default function AuthPageLayout(props: { + title?: string; + isInverse?: boolean; + navigationProps?: Omit; + footerProps?: FooterProps; + omitNavigation?: boolean; + omitFooter?: boolean; + userHeaderWithoutNavigation?: boolean; + children: React.ReactNode; +}) { + return ( + + + + + {props.children} + + ); +} diff --git a/apps/landing/components/layouts/Layout.tsx b/apps/landing/components/layouts/Layout.tsx new file mode 100644 index 000000000..aca43534a --- /dev/null +++ b/apps/landing/components/layouts/Layout.tsx @@ -0,0 +1,43 @@ +import { useUserProfile } from '../../hooks/auth.hooks'; +import Footer, { FooterProps } from '../Footer'; +import HeaderNoNavigation from '../HeaderNoNavigation'; +import Navigation, { NavigationProps } from '../Navigation'; +import LayoutHead from './LayoutHead'; + +export default function Layout({ + title, + isInverse, + navigationProps, + footerProps, + omitNavigation, + omitFooter, + userHeaderWithoutNavigation, + children, +}: { + title?: string; + isInverse?: boolean; + navigationProps?: Omit; + footerProps?: FooterProps; + omitNavigation?: boolean; + omitFooter?: boolean; + userHeaderWithoutNavigation?: boolean; + children: React.ReactNode; +}) { + const userProfile = useUserProfile(); + + return ( +
    + +
    +
    + {!omitNavigation && !userHeaderWithoutNavigation && ( + + )} + {userHeaderWithoutNavigation && } + {children} + {!omitFooter &&
    } +
    +
    +
    + ); +} diff --git a/apps/landing/components/layouts/LayoutHead.tsx b/apps/landing/components/layouts/LayoutHead.tsx new file mode 100644 index 000000000..29960d898 --- /dev/null +++ b/apps/landing/components/layouts/LayoutHead.tsx @@ -0,0 +1,56 @@ +import Head from 'next/head'; + +export default function LayoutHead({ title = 'Jetstream' }: { title?: string }) { + return ( + + {title} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/landing/components/new/ConnectWithTeam.tsx b/apps/landing/components/new/ConnectWithTeam.tsx index f95c4df58..05a4e2543 100644 --- a/apps/landing/components/new/ConnectWithTeam.tsx +++ b/apps/landing/components/new/ConnectWithTeam.tsx @@ -41,7 +41,7 @@ export const ConnectWithTeam = () => ( {items.map(({ image, footer, link, subtitle, title }) => (
    diff --git a/apps/landing/components/new/HeaderCta.tsx b/apps/landing/components/new/HeaderCta.tsx index 1b9f4c6ba..7226d0fea 100644 --- a/apps/landing/components/new/HeaderCta.tsx +++ b/apps/landing/components/new/HeaderCta.tsx @@ -1,12 +1,13 @@ /* eslint-disable @next/next/no-img-element */ import { HeartIcon } from '@heroicons/react/solid'; +import Link from 'next/link'; export const HeaderCta = () => (
    -
    +
    Jetstream is community supported and free to use @@ -21,12 +22,12 @@ export const HeaderCta = () => ( get your work done faster.

    - - Sign-up for a free account - + Sign up for a free account +

    diff --git a/apps/landing/components/new/SupportCta.tsx b/apps/landing/components/new/SupportCta.tsx index 7e1dda319..b2c44b8d2 100644 --- a/apps/landing/components/new/SupportCta.tsx +++ b/apps/landing/components/new/SupportCta.tsx @@ -32,7 +32,7 @@ const items = [ export const SupportCta = () => (

    -
    +
    ( {items.map(({ image, footer, link, subtitle, title }) => (
    diff --git a/apps/landing/hooks/auth.hooks.ts b/apps/landing/hooks/auth.hooks.ts new file mode 100644 index 000000000..4ae56e853 --- /dev/null +++ b/apps/landing/hooks/auth.hooks.ts @@ -0,0 +1,86 @@ +import { TwoFactorType } from '@jetstream/auth/types'; +import { useEffect, useState } from 'react'; +import { AUTH_PATHS } from '../utils/environment'; + +interface AuthState { + isLoggedIn: boolean; + pendingVerifications: Array | false; + isVerificationExpired: boolean; +} + +export function useUserProfile() { + const [authState, setAuthState] = useState({ + isLoggedIn: false, + pendingVerifications: false, + isVerificationExpired: false, + }); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetch(AUTH_PATHS.api_session, { + headers: { + Accept: 'application/json', + }, + }) + .then((response) => { + if (response.ok) { + return response.json(); + } + throw new Error('Unable to fetch user'); + }) + .then( + ({ + data, + }: { + data: { + isLoggedIn: boolean; + pendingVerifications: TwoFactorType[] | false; + isVerificationExpired: boolean; + }; + }) => { + setAuthState(data); + setIsLoading(false); + } + ) + .catch((error) => { + setIsLoading(false); + }); + }, []); + + return { + ...authState, + isLoading, + }; +} + +export function useCsrfToken() { + const [isLoadingCsrfToken, setIsLoadingCsrfToken] = useState(true); + const [csrfToken, setCsrfToken] = useState(); + const [csrfTokenError, setCsrfTokenError] = useState(); + + useEffect(() => { + setIsLoadingCsrfToken(true); + fetch(AUTH_PATHS.api_csrf) + .then((response) => { + if (response.ok) { + return response.json(); + } + throw new Error('Unable to fetch csrf token'); + }) + .then(({ data }: { data: { csrfToken: string } }) => { + setCsrfToken(data.csrfToken); + }) + .catch((error) => { + setCsrfTokenError(error?.message ?? 'Unable to initialize the form'); + }) + .finally(() => { + setIsLoadingCsrfToken(false); + }); + }, []); + + return { + isLoadingCsrfToken, + csrfToken, + csrfTokenError, + }; +} diff --git a/apps/landing/next.config.js b/apps/landing/next.config.js index 9ced2a460..75ac5a69c 100644 --- a/apps/landing/next.config.js +++ b/apps/landing/next.config.js @@ -4,6 +4,23 @@ const { composePlugins, withNx } = require('@nx/next'); * @type {import('@nx/next/plugins/with-nx').WithNxOptions} **/ const nextConfig = { + rewrites: async () => { + if (process.env.NODE_ENV !== 'development') { + return []; + } + return [ + { + source: '/api/:path*', + destination: 'http://localhost:3333/api/:path*', // Proxy to Backend + has: [ + { + type: 'host', + value: 'localhost', + }, + ], + }, + ]; + }, trailingSlash: true, nx: { // Set this to true if you would like to use SVGR diff --git a/apps/landing/pages/404.tsx b/apps/landing/pages/404.tsx index d7bfef655..544b8464a 100644 --- a/apps/landing/pages/404.tsx +++ b/apps/landing/pages/404.tsx @@ -1,62 +1,25 @@ -import Head from 'next/head'; import Link from 'next/link'; -import React, { Fragment } from 'react'; -import Footer from '../components/Footer'; -import Navigation from '../components/Navigation'; +import Layout from '../components/layouts/Layout'; -function NotFound404() { +export default function Page() { return ( - - - Page Not Found | Jetstream - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    -
    -

    404 ERROR

    -

    Page not found

    -

    Sorry, we couldn't find the page you were looking for.

    -
    - - Go home - -
    +
    +
    +
    +

    404 ERROR

    +

    Page not found

    +

    Sorry, we couldn't find the page you were looking for.

    +
    + + Go home +
    -
    -
    - +
    +
    ); } -export default NotFound404; +Page.getLayout = function getLayout(page) { + return {page}; +}; diff --git a/apps/landing/pages/_app.js b/apps/landing/pages/_app.js index 7ccd38628..655a0ee0f 100644 --- a/apps/landing/pages/_app.js +++ b/apps/landing/pages/_app.js @@ -1,6 +1,9 @@ +import Layout from '../components/layouts/Layout'; import './index.scss'; -// This default export is required in a new `pages/_app.js` file. export default function MyApp({ Component, pageProps }) { - return ; + // Use page layout or fallback to default inverse layout + const getLayout = Component.getLayout ?? ((page) => {page}); + + return getLayout(); } diff --git a/apps/landing/pages/about/index.tsx b/apps/landing/pages/about/index.tsx index fc03a099b..cee34d587 100644 --- a/apps/landing/pages/about/index.tsx +++ b/apps/landing/pages/about/index.tsx @@ -1,81 +1,40 @@ -import Head from 'next/head'; -import Footer from '../../components/Footer'; -import Navigation from '../../components/Navigation'; -import { BlogPost } from '../../utils/types'; +import Layout from '../../components/layouts/Layout'; -interface PostProps { - blogPosts: Array; -} - -function About({ blogPosts }: PostProps) { +export default function Page() { return ( -
    - - About Jetstream - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - -
    -
    -

    - About Jetstream -

    -

    - Jetstream is a source-available project created and maintained{' '} - - Austin Turner - - . -

    -

    - I created Jetstream as a side project to solve common problems that my co-workers and I faced on a daily basis. Salesforce - is an amazing platform with a lot of extensibility, but it can be difficult to manage and maintain. -

    -

    - I truly hope that you love Jetstream as much as I do! -

    -
    -
    -
    -
    +
    +
    +

    + About Jetstream +

    +

    + Jetstream is a source-available project created and maintained{' '} + + Austin Turner + + . +

    +

    + I created Jetstream as a side project to solve common problems that my co-workers and I faced on a daily basis. Salesforce is an + amazing platform with a lot of extensibility, but it can be difficult to manage and maintain. +

    +

    + I truly hope that you love Jetstream as much as I do! +

    ); } -export default About; +Page.getLayout = function getLayout(page) { + return ( + + {page} + + ); +}; diff --git a/apps/landing/pages/auth/login/index.tsx b/apps/landing/pages/auth/login/index.tsx new file mode 100644 index 000000000..61dd29f59 --- /dev/null +++ b/apps/landing/pages/auth/login/index.tsx @@ -0,0 +1,14 @@ +import { LoginOrSignUpWrapper } from '../../../components/auth/LoginOrSignUpWrapper'; +import Layout from '../../../components/layouts/Layout'; + +export default function Page() { + return ; +} + +Page.getLayout = function getLayout(page) { + return ( + + {page} + + ); +}; diff --git a/apps/landing/pages/auth/password-reset/index.tsx b/apps/landing/pages/auth/password-reset/index.tsx new file mode 100644 index 000000000..eed0b904d --- /dev/null +++ b/apps/landing/pages/auth/password-reset/index.tsx @@ -0,0 +1,32 @@ +import Alert from '../../../components/Alert'; +import { PasswordResetInit } from '../../../components/auth/PasswordResetInit'; +import Layout from '../../../components/layouts/Layout'; +import { useCsrfToken } from '../../../hooks/auth.hooks'; +import { SIGN_IN_ERRORS } from '../../../utils/environment'; + +export default function Page() { + const { csrfToken, csrfTokenError: error, isLoadingCsrfToken: isLoading } = useCsrfToken(); + + if (error) { + return ( +
    + +
    + ); + } + + // TODO: show loading indicator here instead + if (isLoading || !csrfToken) { + return null; + } + + return ; +} + +Page.getLayout = function getLayout(page) { + return ( + + {page} + + ); +}; diff --git a/apps/landing/pages/auth/password-reset/verify/index.tsx b/apps/landing/pages/auth/password-reset/verify/index.tsx new file mode 100644 index 000000000..42adb377e --- /dev/null +++ b/apps/landing/pages/auth/password-reset/verify/index.tsx @@ -0,0 +1,50 @@ +import { useSearchParams } from 'next/navigation'; +import { useRouter } from 'next/router'; +import { useEffect } from 'react'; +import Alert from '../../../../components/Alert'; +import { PasswordResetVerify } from '../../../../components/auth/PasswordResetVerify'; +import Layout from '../../../../components/layouts/Layout'; +import { useCsrfToken } from '../../../../hooks/auth.hooks'; +import { AUTH_PATHS, SIGN_IN_ERRORS } from '../../../../utils/environment'; + +export default function Page() { + const router = useRouter(); + const { csrfToken, csrfTokenError: error, isLoadingCsrfToken: isLoading } = useCsrfToken(); + + const searchParams = useSearchParams(); + + const email = searchParams.get('email') || ''; + const token = searchParams.get('code') || ''; + + useEffect(() => { + if (isLoading) { + return; + } + if (!email || !token) { + router.push(`${AUTH_PATHS.login}?error=InvalidOrExpiredResetToken`); + } + }, [token, email, isLoading, router]); + + if (error) { + return ( +
    + +
    + ); + } + + // TODO: show loading indicator here instead + if (isLoading || !csrfToken) { + return null; + } + + return ; +} + +Page.getLayout = function getLayout(page) { + return ( + + {page} + + ); +}; diff --git a/apps/landing/pages/auth/signup/index.tsx b/apps/landing/pages/auth/signup/index.tsx new file mode 100644 index 000000000..c0d88be10 --- /dev/null +++ b/apps/landing/pages/auth/signup/index.tsx @@ -0,0 +1,14 @@ +import { LoginOrSignUpWrapper } from '../../../components/auth/LoginOrSignUpWrapper'; +import Layout from '../../../components/layouts/Layout'; + +export default function Page() { + return ; +} + +Page.getLayout = function getLayout(page) { + return ( + + {page} + + ); +}; diff --git a/apps/landing/pages/auth/verify/index.tsx b/apps/landing/pages/auth/verify/index.tsx new file mode 100644 index 000000000..efb91acc6 --- /dev/null +++ b/apps/landing/pages/auth/verify/index.tsx @@ -0,0 +1,14 @@ +import { VerifyEmailOr2faWrapper } from '../../../components/auth/VerifyEmailOr2faWrapper'; +import Layout from '../../../components/layouts/Layout'; + +export default function Page() { + return ; +} + +Page.getLayout = function getLayout(page) { + return ( + + {page} + + ); +}; diff --git a/apps/landing/pages/blog/index.tsx b/apps/landing/pages/blog/index.tsx index cb15b248f..bc863c576 100644 --- a/apps/landing/pages/blog/index.tsx +++ b/apps/landing/pages/blog/index.tsx @@ -1,7 +1,5 @@ import { format, parseISO } from 'date-fns'; -import Head from 'next/head'; -import Footer from '../../components/Footer'; -import Navigation from '../../components/Navigation'; +import Layout from '../../components/layouts/Layout'; import { fetchBlogPosts } from '../../utils/data'; import { BlogPost } from '../../utils/types'; @@ -9,76 +7,46 @@ interface PostProps { blogPosts: Array; } -function BlogPosts({ blogPosts }: PostProps) { +export default function Page({ blogPosts }: PostProps) { // TODO: helmet etc.. return ( -
    - - Jetstream Blog - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - - {blogPosts.length === 0 && ( -
    There aren't any blog posts right now, check back soon.
    - )} -
    - {blogPosts.map((post, i) => ( -
    - {i !== 0 &&
    } -
    -
    -

    - {post.title} -

    -

    {format(parseISO(post.publishDate), 'MMMM d, y')}

    -
    {post.summary}
    - -
    + <> + {blogPosts.length === 0 && ( +
    There aren't any blog posts right now, check back soon.
    + )} +
    + {blogPosts.map((post, i) => ( +
    + {i !== 0 &&
    } +
    +
    +

    + {post.title} +

    +

    {format(parseISO(post.publishDate), 'MMMM d, y')}

    +
    {post.summary}
    +
    - ))} +
    -
    -
    + ))}
    -
    + ); } +Page.getLayout = function getLayout(page) { + return ( + + {page} + + ); +}; + // This also gets called at build time export async function getStaticProps({ params }) { // uses cached data @@ -88,5 +56,3 @@ export async function getStaticProps({ params }) { // Pass post data to the page via props return { props: { blogPosts: Object.values(blogPostsWithRelated) } }; } - -export default BlogPosts; diff --git a/apps/landing/pages/goodbye/index.tsx b/apps/landing/pages/goodbye/index.tsx index 5077f0e31..56f7c6946 100644 --- a/apps/landing/pages/goodbye/index.tsx +++ b/apps/landing/pages/goodbye/index.tsx @@ -1,75 +1,39 @@ -import Head from 'next/head'; import Link from 'next/link'; -import { Fragment } from 'react'; -import Footer from '../../components/Footer'; -import Navigation from '../../components/Navigation'; +import Layout from '../../components/layouts/Layout'; -function Goodbye() { - const email = 'support@getjetstream.app'; - return ( - - - Jetstream - - - - - - - - - - - - - - - - - +const email = 'support@getjetstream.app'; - - - - - - - - - - -
    -
    -
    -

    Goodbye

    -

    We're sorry to see you go!

    -

    We would love to have you back in the future!

    -

    - Don't hesitate to reach out to us via email at{' '} - - {email} - {' '} - if you have any questions or have feedback on what we can do better. -

    -
    - - Go back home - -
    +export default function Page() { + return ( +
    +
    +
    +

    Goodbye

    +

    We're sorry to see you go!

    +

    We would love to have you back in the future!

    +

    + Don't hesitate to reach out to us via email at{' '} + + {email} + {' '} + if you have any questions or have feedback on what we can do better. +

    +
    + + Go back home +
    -
    -
    - +
    +
    ); } -export default Goodbye; +Page.getLayout = function getLayout(page) { + return {page}; +}; diff --git a/apps/landing/pages/index.tsx b/apps/landing/pages/index.tsx index 2f599ac5e..80704cdc6 100644 --- a/apps/landing/pages/index.tsx +++ b/apps/landing/pages/index.tsx @@ -1,74 +1,11 @@ import { AnalyticStat } from '@jetstream/types'; import { GetStaticProps, InferGetStaticPropsType } from 'next'; -import Head from 'next/head'; -import Footer from '../components/Footer'; -import Navigation from '../components/Navigation'; import LandingPage from '../components/new/LandingPage'; import { fetchBlogPosts, getAnalyticSummary } from '../utils/data'; -export const Index = ({ stats, omitBlogPosts }: InferGetStaticPropsType) => { - return ( -
    - - Jetstream - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - - -
    -
    -
    -
    - ); -}; +export default function Page({ stats, omitBlogPosts }: InferGetStaticPropsType) { + return ; +} // This also gets called at build time export const getStaticProps: GetStaticProps<{ @@ -79,5 +16,3 @@ export const getStaticProps: GetStaticProps<{ const blogPostsWithRelated = await fetchBlogPosts(); return { props: { stats, omitBlogPosts: Object.values(blogPostsWithRelated || {}).length === 0 } }; }; - -export default Index; diff --git a/apps/landing/pages/oauth-link/index.tsx b/apps/landing/pages/oauth-link/index.tsx index 34301bf86..031500361 100644 --- a/apps/landing/pages/oauth-link/index.tsx +++ b/apps/landing/pages/oauth-link/index.tsx @@ -1,7 +1,5 @@ -import Head from 'next/head'; -import { Fragment, useEffect, useState } from 'react'; -import Footer from '../../components/Footer'; -import HeaderNoNavigation from '../../components/HeaderNoNavigation'; +import { useEffect, useState } from 'react'; +import Layout from '../../components/layouts/Layout'; import { parseQueryString } from '../../utils/utils'; export interface OauthLinkParams { @@ -55,7 +53,7 @@ const STATUS_MAP: StatusMap = { * If no error: * - data=anything to send back to the server (no processing at all, this should be pre-stringified data) */ -function LinkAuthAccount() { +export default function Page() { const [hasError, setHasError] = useState(false); const [errorHeading, setErrorHeading] = useState(null); const [status, setStatus] = useState('Your request is being processed, please wait.'); @@ -87,66 +85,32 @@ function LinkAuthAccount() { }, []); return ( - - - Jetstream Link External Connection - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    -
    -

    Authentication

    - {errorHeading &&

    {errorHeading}

    } -

    {status}

    - {hasError && ( -

    - If you need more assistance, you can file a support ticket or email{' '} - - {email} - - . -

    - )} -
    +
    +
    +
    +

    Authentication

    + {errorHeading &&

    {errorHeading}

    } +

    {status}

    + {hasError && ( +

    + If you need more assistance, you can file a support ticket or email{' '} + + {email} + + . +

    + )}
    -
    -
    - +
    +
    ); } -export default LinkAuthAccount; +Page.getLayout = function getLayout(page) { + return {page}; +}; diff --git a/apps/landing/pages/privacy/index.tsx b/apps/landing/pages/privacy/index.tsx index caefe0302..80901eeac 100644 --- a/apps/landing/pages/privacy/index.tsx +++ b/apps/landing/pages/privacy/index.tsx @@ -1,206 +1,173 @@ -import Head from 'next/head'; -import { Fragment } from 'react'; -import Footer from '../../components/Footer'; -import Navigation from '../../components/Navigation'; +import Layout from '../../components/layouts/Layout'; -function Privacy() { +export default function Page() { const email = 'support@getjetstream.app'; return ( - - - Privacy | Jetstream - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -

    Privacy Policy

    -

    - This Privacy Policy describes how your personal information is collected, used, and shared when you visit or make a purchase from - https://getjetstream.app (the “Site”). -

    -

    Privacy and Security Summary

    -
      -
    1. - We strive to follow industry best practices for all processes, including our development processes, deployment processes, - hosting processes, and data access processes. -
    2. -
    3. We never store any of your Salesforce record data.
    4. -
    5. We use browser-based caching to avoid having to store any metadata on our server.
    6. -
    7. We will never make API requests to Salesforce without explicit action from a user, such as executing a query.
    8. -
    9. - All data stored by Jetstream is{' '} - - encrypted at rest - {' '} - and stored using industry best practices. -
    10. -
    11. - Some metadata, such as Object and Field names may be included in log entries for up to 1 year with our logging provider. This is - used only for the purpose of troubleshooting errors and ensuring auditability for security purposes. -
    12. -
    13. - If there is a fatal error with the application, our bug tracker may store metadata and error messages based on whatever - Salesforce includes in the error message. Our bug tracker has data retention of 30 days. -
    14. -
    15. All network traffic is encrypted using HTTPS 1.1, HTTPS/2, or HTTPS/3 TLS 1.3 X35519Kyber768Draft00 and AES_128_GCM
    16. -
    17. - Refer to our{' '} - - data sub-processors - {' '} - for information about our vendors. -
    18. -
    -

    PERSONAL INFORMATION WE COLLECT

    -

    - When you visit the Site, we may automatically collect certain information about your device, including information about your web - browser, IP address, and time zone. Additionally, as you browse the Site, we collect information about the individual web pages or - products that you view, what websites or search terms referred you to the Site, and information about how you interact with the - Site. We refer to this automatically-collected information as “Device Information.” -

    -

    We collect Device Information using the following technologies:

    -
      -
    • - “Cookies” are data files that are placed on your device or computer and often include an anonymous unique identifier. For more - information about cookies, and how to disable cookies, visit http://www.allaboutcookies.org. -
    • -
    • - “Log files” track actions occurring on the Site, and collect data including your IP address, browser type, referring/exit pages, - and date/time stamps. -
    • -
    • - Any information we gather will be used solely for the purpose of improving our product and will not be sold to any 3rd parties. -
    • -
    -

    - Additionally when you make a purchase or attempt to make a purchase through the Site, we collect certain information from you, - including your name, billing address, payment information, email address, and phone number. We refer to this information as “Order - Information.” -

    -

    - When we talk about “Personal Information” in this Privacy Policy, we are talking both about Device Information and Order - Information. -

    -

    SALESFORCE DATA

    -

    - We will never store any of your Salesforce metadata or record data on any of our servers unless explicitly requested by an action - taken by you, the user. We may store names of Salesforce objects, fields, or other metadata temporarily in logs, but we will never - persist this beyond our standard log retention policy of up to 1 year and will only use this data for the purpose of - troubleshooting or other error identification. -

    -

    - We encrypt and store connection details to any Salesforce organization in our database so that you can utilize Jetstream to - connect to your Salesforce org. We do not have access to or store your Salesforce password, and at any time you can revoke access - to Jetstream through the Salesforce.com setup menu in the "Manage Connected App Usage" section. -

    -

    HOW DO WE USE YOUR PERSONAL INFORMATION?

    -

    - We use the Order Information that we collect generally to fulfill any orders placed through the Site (including processing your - payment information and providing you with invoices and/or order confirmations). Additionally, we use this Order Information to: -

    -
      -
    • Communicate with you
    • -
    • Screen our orders for potential risk or fraud
    • -
    • - Based on your preferences you have shared with us, provide you with information or advertising relating to our products or - services. -
    • -
    -

    - We use the Device Information that we collect to help us screen for potential risk and fraud (in particular, your IP address), and - more generally to improve and optimize our Site (for example, by generating analytics about how our customers browse and interact - with the Site, and to assess the success of our marketing and advertising campaigns). -

    -

    SHARING YOUR PERSONAL INFORMATION

    -

    - We share your Personal Information with third parties to help us use your Personal Information, as described above. For example, - we use Stripe to power our payment collection. We use Amplitude to track what parts of the application is used and in which ways - to help make product decisions. We also use Google Analytics to help us understand how our customers use the Site - you can read - more about how Google uses your Personal Information here: https://www.google.com/intl/en/policies/privacy/. You can also opt-out - of Google Analytics here: https://tools.google.com/dlpage/gaoptout. Finally, we may also share your Personal Information to comply - with applicable laws and regulations, to respond to a subpoena, search warrant or other lawful request for information we receive, - or to otherwise protect our rights. -

    -

    +

    +

    Privacy Policy

    +

    + This Privacy Policy describes how your personal information is collected, used, and shared when you visit or make a purchase from + https://getjetstream.app (the “Site”). +

    +

    Privacy and Security Summary

    +
      +
    1. + We strive to follow industry best practices for all processes, including our development processes, deployment processes, hosting + processes, and data access processes. +
    2. +
    3. We never store any of your Salesforce record data.
    4. +
    5. We use browser-based caching to avoid having to store any metadata on our server.
    6. +
    7. We will never make API requests to Salesforce without explicit action from a user, such as executing a query.
    8. +
    9. + All data stored by Jetstream is{' '} + + encrypted at rest + {' '} + and stored using industry best practices. +
    10. +
    11. + Some metadata, such as Object and Field names may be included in log entries for up to 1 year with our logging provider. This is + used only for the purpose of troubleshooting errors and ensuring auditability for security purposes. +
    12. +
    13. + If there is a fatal error with the application, our bug tracker may store metadata and error messages based on whatever Salesforce + includes in the error message. Our bug tracker has data retention of 30 days. +
    14. +
    15. All network traffic is encrypted using HTTPS 1.1, HTTPS/2, or HTTPS/3 TLS 1.3 X35519Kyber768Draft00 and AES_128_GCM
    16. +
    17. Refer to our{' '} data sub-processors {' '} for information about our vendors. -

      -

      - We will not sell your information to 3rd parties or provide your information to 3rd parties for any other reason. -

      -

      DO NOT TRACK

      -

      - Please note that we do not alter our Site's data collection and use practices when we see a Do Not Track signal from your browser. -

      -

      YOUR RIGHTS

      -

      - If you are a European resident, you have the right to access personal information we hold about you and to ask that your personal - information be corrected, updated, or deleted. If you would like to exercise this right, please contact us through the contact - information below. -

      - Additionally, if you are a European resident we note that we are processing your information in order to fulfill contracts we might - have with you (for example if you make an order through the Site), or otherwise to pursue our legitimate business interests listed - above. Additionally, please note that your information will be transferred outside of Europe, to the United States. -

      DATA RETENTION

      -

      - We will store information in our logs for up to 14 days. We will store information related to errors for up to 30 days. -

      -

      MINORS

      -

      The Site is not intended for individuals under the age of 13.

      -

      CHANGES

      -

      - We may update this privacy policy from time to time in order to reflect, for example, changes to our practices or for other - operational, legal or regulatory reasons. -

      -

      CONTACT US

      -

      - For more information about our privacy practices, if you have questions, or if you would like to make a complaint, please contact - us by e-mail at{' '} - - {email} - - . -

      -
    -
    - + + +

    PERSONAL INFORMATION WE COLLECT

    +

    + When you visit the Site, we may automatically collect certain information about your device, including information about your web + browser, IP address, and time zone. Additionally, as you browse the Site, we collect information about the individual web pages or + products that you view, what websites or search terms referred you to the Site, and information about how you interact with the + Site. We refer to this automatically-collected information as “Device Information.” +

    +

    We collect Device Information using the following technologies:

    +
      +
    • + “Cookies” are data files that are placed on your device or computer and often include an anonymous unique identifier. For more + information about cookies, and how to disable cookies, visit http://www.allaboutcookies.org. +
    • +
    • + “Log files” track actions occurring on the Site, and collect data including your IP address, browser type, referring/exit pages, + and date/time stamps. +
    • +
    • + Any information we gather will be used solely for the purpose of improving our product and will not be sold to any 3rd parties. +
    • +
    +

    + Additionally when you make a purchase or attempt to make a purchase through the Site, we collect certain information from you, + including your name, billing address, payment information, email address, and phone number. We refer to this information as “Order + Information.” +

    +

    + When we talk about “Personal Information” in this Privacy Policy, we are talking both about Device Information and Order + Information. +

    +

    SALESFORCE DATA

    +

    + We will never store any of your Salesforce metadata or record data on any of our servers unless explicitly requested by an action + taken by you, the user. We may store names of Salesforce objects, fields, or other metadata temporarily in logs, but we will never + persist this beyond our standard log retention policy of up to 1 year and will only use this data for the purpose of troubleshooting + or other error identification. +

    +

    + We encrypt and store connection details to any Salesforce organization in our database so that you can utilize Jetstream to connect + to your Salesforce org. We do not have access to or store your Salesforce password, and at any time you can revoke access to + Jetstream through the Salesforce.com setup menu in the "Manage Connected App Usage" section. +

    +

    HOW DO WE USE YOUR PERSONAL INFORMATION?

    +

    + We use the Order Information that we collect generally to fulfill any orders placed through the Site (including processing your + payment information and providing you with invoices and/or order confirmations). Additionally, we use this Order Information to: +

    +
      +
    • Communicate with you
    • +
    • Screen our orders for potential risk or fraud
    • +
    • + Based on your preferences you have shared with us, provide you with information or advertising relating to our products or + services. +
    • +
    +

    + We use the Device Information that we collect to help us screen for potential risk and fraud (in particular, your IP address), and + more generally to improve and optimize our Site (for example, by generating analytics about how our customers browse and interact + with the Site, and to assess the success of our marketing and advertising campaigns). +

    +

    SHARING YOUR PERSONAL INFORMATION

    +

    + We share your Personal Information with third parties to help us use your Personal Information, as described above. For example, we + use Stripe to power our payment collection. We use Amplitude to track what parts of the application is used and in which ways to + help make product decisions. We also use Google Analytics to help us understand how our customers use the Site - you can read more + about how Google uses your Personal Information here: https://www.google.com/intl/en/policies/privacy/. You can also opt-out of + Google Analytics here: https://tools.google.com/dlpage/gaoptout. Finally, we may also share your Personal Information to comply with + applicable laws and regulations, to respond to a subpoena, search warrant or other lawful request for information we receive, or to + otherwise protect our rights. +

    +

    + Refer to our{' '} + + data sub-processors + {' '} + for information about our vendors. +

    +

    + We will not sell your information to 3rd parties or provide your information to 3rd parties for any other reason. +

    +

    DO NOT TRACK

    +

    + Please note that we do not alter our Site's data collection and use practices when we see a Do Not Track signal from your browser. +

    +

    YOUR RIGHTS

    +

    + If you are a European resident, you have the right to access personal information we hold about you and to ask that your personal + information be corrected, updated, or deleted. If you would like to exercise this right, please contact us through the contact + information below. +

    + Additionally, if you are a European resident we note that we are processing your information in order to fulfill contracts we might + have with you (for example if you make an order through the Site), or otherwise to pursue our legitimate business interests listed + above. Additionally, please note that your information will be transferred outside of Europe, to the United States. +

    DATA RETENTION

    +

    + We will store information in our logs for up to 14 days. We will store information related to errors for up to 30 days. +

    +

    MINORS

    +

    The Site is not intended for individuals under the age of 13.

    +

    CHANGES

    +

    + We may update this privacy policy from time to time in order to reflect, for example, changes to our practices or for other + operational, legal or regulatory reasons. +

    +

    CONTACT US

    +

    + For more information about our privacy practices, if you have questions, or if you would like to make a complaint, please contact us + by e-mail at{' '} + + {email} + + . +

    +
    ); } -export default Privacy; +Page.getLayout = function getLayout(page) { + return ( + + {page} + + ); +}; diff --git a/apps/landing/pages/subprocessors/index.tsx b/apps/landing/pages/subprocessors/index.tsx index 6b7c05b27..0b92b1206 100644 --- a/apps/landing/pages/subprocessors/index.tsx +++ b/apps/landing/pages/subprocessors/index.tsx @@ -1,7 +1,4 @@ -import Head from 'next/head'; -import { Fragment } from 'react'; -import Footer from '../../components/Footer'; -import Navigation from '../../components/Navigation'; +import Layout from '../../components/layouts/Layout'; const webSubProcessors = [ { name: 'Amplitude', function: 'Telemetry', location: 'United States', optional: 'No' }, @@ -24,74 +21,40 @@ const webSubProcessors = [ { name: 'Salesforce.com', function: 'Application core', location: 'United States', optional: 'No' }, ]; -function Privacy() { +export default function Page() { return ( - - - Sub-Processors | Jetstream - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -

    Jetstream Sub-Processors

    -

    - This page provides a list of sub-processors that Jetstream uses to provide services to our customers. Our web-based application - and desktop application have a different set of processors and different opt-in/opt-out capabilities. -

    -

    Web-based version of Jetstream

    - - - - - - - - +
    +

    Jetstream Sub-Processors

    +

    + This page provides a list of sub-processors that Jetstream uses to provide services to our customers. Our web-based application and + desktop application have a different set of processors and different opt-in/opt-out capabilities. +

    +

    Web-based version of Jetstream

    + +
    Sub-Processor NameFunctionLocationOptional / Allow opt-out
    + + + + + + + + + + {webSubProcessors.map((item) => ( + + + + + - - - {webSubProcessors.map((item) => ( - - - - - - - ))} - -
    Sub-Processor NameFunctionLocationOptional / Allow opt-out
    {item.name}{item.function}{item.location}{item.optional}
    {item.name}{item.function}{item.location}{item.optional}
    -
    -
    - + ))} + + +
    ); } -export default Privacy; +Page.getLayout = function getLayout(page) { + return {page}; +}; diff --git a/apps/landing/pages/terms-of-service/index.tsx b/apps/landing/pages/terms-of-service/index.tsx index 975b0d4e7..959973f96 100644 --- a/apps/landing/pages/terms-of-service/index.tsx +++ b/apps/landing/pages/terms-of-service/index.tsx @@ -1,268 +1,234 @@ -import Head from 'next/head'; import Link from 'next/link'; -import React, { Fragment } from 'react'; -import Footer from '../../components/Footer'; -import Navigation from '../../components/Navigation'; +import Layout from '../../components/layouts/Layout'; -function TermsOfService() { - const email = 'support@getjetstream.app'; - return ( - - - Terms of Service | Jetstream - - - - - - - - - - - - - - - - - - - +const email = 'support@getjetstream.app'; - - - - - - - - -
    -

    TERMS OF SERVICE

    -

    OVERVIEW

    -

    - This website is operated by Jetstream. Throughout the site, the terms “we”, “us” and “our” refer to Jetstream. Jetstream offers - this website, including all information, tools and services available from this site to you, the user, conditioned upon your - acceptance of all terms, conditions, policies and notices stated here. By visiting our site and / or purchasing something from us, - you engage in our “Service” and agree to be bound by the following terms and conditions (“Terms of Service”, “Terms”), including - those additional terms and conditions and policies referenced herein and/or available by hyperlink. These Terms of Service apply - to all users of the site, including without limitation users who are browsers, vendors, customers, merchants, and / or - contributors of content. Please read these Terms of Service carefully before accessing or using our website. By accessing or using - any part of the site, you agree to be bound by these Terms of Service. If you do not agree to all the terms and conditions of this - agreement, then you may not access the website or use any services. If these Terms of Service are considered an offer, acceptance - is expressly limited to these Terms of Service. Any new features or tools which are added to the current store shall also be - subject to the Terms of Service. You can review the most current version of the Terms of Service at any time on this page. We - reserve the right to update, change or replace any part of these Terms of Service by posting updates and/or changes to our - website. It is your responsibility to check this page periodically for changes. Your continued use of or access to the website - following the posting of any changes constitutes acceptance of those changes. -

    -

    SECTION 1 - ONLINE TERMS

    -

    - By agreeing to these Terms of Service, you represent that you are at least the age of majority in your state or province of - residence, or that you are the age of majority in your state or province of residence and you have given us your consent to allow - any of your minor dependents to use this site. You may not use our products for any illegal or unauthorized purpose nor may you, - in the use of the Service, violate any laws in your jurisdiction (including but not limited to copyright laws). You must not - transmit any worms or viruses or any code of a destructive nature. A breach or violation of any of the Terms will result in an - immediate termination of your Services. -

    -

    SECTION 2 - GENERAL CONDITIONS

    -

    - We reserve the right to refuse service to anyone for any reason at any time. You understand that your content (not including - credit card information), may be transferred unencrypted and involve (a) transmissions over various networks; and (b) changes to - conform and adapt to technical requirements of connecting networks or devices. Credit card information is always encrypted during - transfer over networks. You agree not to reproduce, duplicate, copy, sell, resell or exploit any portion of the Service, use of - the Service, or access to the Service or any contact on the website through which the service is provided, without express written - permission by us. The headings used in this agreement are included for convenience only and will not limit or otherwise affect - these Terms. -

    -

    SECTION 3 - ACCURACY, COMPLETENESS AND TIMELINESS OF INFORMATION

    -

    - We are not responsible if information made available on this site is not accurate, complete or current. The material on this site - is provided for general information only and should not be relied upon or used as the sole basis for making decisions without - consulting primary, more accurate, more complete or more timely sources of information. Any reliance on the material on this site - is at your own risk. This site may contain certain historical information. Historical information, necessarily, is not current and - is provided for your reference only. We reserve the right to modify the contents of this site at any time, but we have no - obligation to update any information on our site. You agree that it is your responsibility to monitor changes to our site. -

    -

    SECTION 4 - MODIFICATIONS TO THE SERVICE AND PRICES

    -

    - Prices for our products are subject to change without notice. We reserve the right at any time to modify or discontinue the - Service (or any part or content thereof) without notice at any time. We shall not be liable to you or to any third-party for any - modification, price change, suspension or discontinuance of the Service. -

    -

    SECTION 5 - PRODUCTS OR SERVICES (if applicable)

    -

    - Certain products or services may be available exclusively online through the website. These products or services may have limited - quantities and are subject to return or exchange only according to our Return Policy. We have made every effort to display as - accurately as possible the colors and images of our products that appear at the store. We cannot guarantee that your computer - monitor's display of any color will be accurate. We reserve the right, but are not obligated, to limit the sales of our products - or Services to any person, geographic region or jurisdiction. We may exercise this right on a case-by-case basis. We reserve the - right to limit the quantities of any products or services that we offer. All descriptions of products or product pricing are - subject to change at anytime without notice, at the sole discretion of us. We reserve the right to discontinue any product at any - time. Any offer for any product or service made on this site is void where prohibited. We do not warrant that the quality of any - products, services, information, or other material purchased or obtained by you will meet your expectations, or that any errors in - the Service will be corrected. -

    -

    SECTION 6 - ACCURACY OF BILLING AND ACCOUNT INFORMATION

    -

    - We reserve the right to refuse any order you place with us. We may, in our sole discretion, limit or cancel quantities purchased - per person, per household or per order. These restrictions may include orders placed by or under the same customer account, the - same credit card, and/or orders that use the same billing and/or shipping address. In the event that we make a change to or cancel - an order, we may attempt to notify you by contacting the e-mail and/or billing address/phone number provided at the time the order - was made. We reserve the right to limit or prohibit orders that, in our sole judgment, appear to be placed by dealers, resellers - or distributors. You agree to provide current, complete and accurate purchase and account information for all purchases made at - our store. You agree to promptly update your account and other information, including your email address and credit card numbers - and expiration dates, so that we can complete your transactions and contact you as needed. For more detail, please review our - Returns Policy. -

    -

    SECTION 7 - OPTIONAL TOOLS

    -

    - We may provide you with access to third-party tools over which we neither monitor nor have any control nor input. You acknowledge - and agree that we provide access to such tools ”as is” and “as available” without any warranties, representations or conditions of - any kind and without any endorsement. We shall have no liability whatsoever arising from or relating to your use of optional - third-party tools. Any use by you of optional tools offered through the site is entirely at your own risk and discretion and you - should ensure that you are familiar with and approve of the terms on which tools are provided by the relevant third-party - provider(s). We may also, in the future, offer new services and/or features through the website (including, the release of new - tools and resources). Such new features and/or services shall also be subject to these Terms of Service. -

    -

    SECTION 8 - THIRD-PARTY LINKS

    -

    - Certain content, products and services available via our Service may include materials from third-parties. Third-party links on - this site may direct you to third-party websites that are not affiliated with us. We are not responsible for examining or - evaluating the content or accuracy and we do not warrant and will not have any liability or responsibility for any third-party - materials or websites, or for any other materials, products, or services of third-parties. We are not liable for any harm or - damages related to the purchase or use of goods, services, resources, content, or any other transactions made in connection with - any third-party websites. Please review carefully the third-party's policies and practices and make sure you understand them - before you engage in any transaction. Complaints, claims, concerns, or questions regarding third-party products should be directed - to the third-party. -

    -

    SECTION 9 - USER COMMENTS, FEEDBACK AND OTHER SUBMISSIONS

    -

    - If, at our request, you send certain specific submissions (for example contest entries) or without a request from us you send - creative ideas, suggestions, proposals, plans, or other materials, whether online, by email, by postal mail, or otherwise - (collectively, 'comments'), you agree that we may, at any time, without restriction, edit, copy, publish, distribute, translate - and otherwise use in any medium any comments that you forward to us. We are and shall be under no obligation (1) to maintain any - comments in confidence; (2) to pay compensation for any comments; or (3) to respond to any comments. We may, but have no - obligation to, monitor, edit or remove content that we determine in our sole discretion are unlawful, offensive, threatening, - libelous, defamatory, pornographic, obscene or otherwise objectionable or violates any party’s intellectual property or these - Terms of Service. You agree that your comments will not violate any right of any third-party, including copyright, trademark, - privacy, personality or other personal or proprietary right. You further agree that your comments will not contain libelous or - otherwise unlawful, abusive or obscene material, or contain any computer virus or other malware that could in any way affect the - operation of the Service or any related website. You may not use a false e-mail address, pretend to be someone other than - yourself, or otherwise mislead us or third-parties as to the origin of any comments. You are solely responsible for any comments - you make and their accuracy. We take no responsibility and assume no liability for any comments posted by you or any third-party. -

    -

    SECTION 10 - PERSONAL INFORMATION

    -

    - Your submission of personal information through the store is governed by our Privacy Policy. -

    -

    SECTION 11 - ERRORS, INACCURACIES AND OMISSIONS

    -

    - Occasionally there may be information on our site or in the Service that contains typographical errors, inaccuracies or omissions - that may relate to product descriptions, pricing, promotions, offers, product shipping charges, transit times and availability. We - reserve the right to correct any errors, inaccuracies or omissions, and to change or update information or cancel orders if any - information in the Service or on any related website is inaccurate at any time without prior notice (including after you have - submitted your order). We undertake no obligation to update, amend or clarify information in the Service or on any related - website, including without limitation, pricing information, except as required by law. No specified update or refresh date applied - in the Service or on any related website, should be taken to indicate that all information in the Service or on any related - website has been modified or updated. -

    -

    SECTION 12 - PROHIBITED USES

    -

    - In addition to other prohibitions as set forth in the Terms of Service, you are prohibited from using the site or its content: (a) - for any unlawful purpose; (b) to solicit others to perform or participate in any unlawful acts; (c) to violate any international, - federal, provincial or state regulations, rules, laws, or local ordinances; (d) to infringe upon or violate our intellectual - property rights or the intellectual property rights of others; (e) to harass, abuse, insult, harm, defame, slander, disparage, - intimidate, or discriminate based on gender, sexual orientation, religion, ethnicity, race, age, national origin, or disability; - (f) to submit false or misleading information; (g) to upload or transmit viruses or any other type of malicious code that will or - may be used in any way that will affect the functionality or operation of the Service or of any related website, other websites, - or the Internet; (h) to collect or track the personal information of others; (i) to spam, phish, pharm, pretext, spider, crawl, or - scrape; (j) for any obscene or immoral purpose; or (k) to interfere with or circumvent the security features of the Service or any - related website, other websites, or the Internet. We reserve the right to terminate your use of the Service or any related website - for violating any of the prohibited uses. -

    -

    SECTION 13 - DISCLAIMER OF WARRANTIES; LIMITATION OF LIABILITY

    -

    - We do not guarantee, represent or warrant that your use of our service will be uninterrupted, timely, secure or error-free. We do - not warrant that the results that may be obtained from the use of the service will be accurate or reliable. You agree that from - time to time we may remove the service for indefinite periods of time or cancel the service at any time, without notice to you. - You expressly agree that your use of, or inability to use, the service is at your sole risk. The service and all products and - services delivered to you through the service are (except as expressly stated by us) provided 'as is' and 'as available' for your - use, without any representation, warranties or conditions of any kind, either express or implied, including all implied warranties - or conditions of merchantability, merchantable quality, fitness for a particular purpose, durability, title, and non-infringement. - In no case shall Jetstream, our directors, officers, employees, affiliates, agents, contractors, interns, suppliers, service - providers or licensors be liable for any injury, loss, claim, or any direct, indirect, incidental, punitive, special, or - consequential damages of any kind, including, without limitation lost profits, lost revenue, lost savings, loss of data, - replacement costs, or any similar damages, whether based in contract, tort (including negligence), strict liability or otherwise, - arising from your use of any of the service or any products procured using the service, or for any other claim related in any way - to your use of the service or any product, including, but not limited to, any errors or omissions in any content, or any loss or - damage of any kind incurred as a result of the use of the service or any content (or product) posted, transmitted, or otherwise - made available via the service, even if advised of their possibility. Because some states or jurisdictions do not allow the - exclusion or the limitation of liability for consequential or incidental damages, in such states or jurisdictions, our liability - shall be limited to the maximum extent permitted by law. -

    -

    SECTION 14 - INDEMNIFICATION

    -

    - You agree to indemnify, defend and hold harmless Jetstream and our parent, subsidiaries, affiliates, partners, officers, - directors, agents, contractors, licensors, service providers, subcontractors, suppliers, interns and employees, harmless from any - claim or demand, including reasonable attorneys’ fees, made by any third-party due to or arising out of your breach of these Terms - of Service or the documents they incorporate by reference, or your violation of any law or the rights of a third-party. -

    -

    SECTION 15 - SEVERABILITY

    -

    - In the event that any provision of these Terms of Service is determined to be unlawful, void or unenforceable, such provision - shall nonetheless be enforceable to the fullest extent permitted by applicable law, and the unenforceable portion shall be deemed - to be severed from these Terms of Service, such determination shall not affect the validity and enforceability of any other - remaining provisions. -

    -

    SECTION 16 - TERMINATION

    -

    - The obligations and liabilities of the parties incurred prior to the termination date shall survive the termination of this - agreement for all purposes. These Terms of Service are effective unless and until terminated by either you or us. You may - terminate these Terms of Service at any time by notifying us that you no longer wish to use our Services, or when you cease using - our site. If in our sole judgment you fail, or we suspect that you have failed, to comply with any term or provision of these - Terms of Service, we also may terminate this agreement at any time without notice and you will remain liable for all amounts due - up to and including the date of termination; and/or accordingly may deny you access to our Services (or any part thereof). -

    -

    SECTION 17 - ENTIRE AGREEMENT

    -

    - The failure of us to exercise or enforce any right or provision of these Terms of Service shall not constitute a waiver of such - right or provision. These Terms of Service and any policies or operating rules posted by us on this site or in respect to The - Service constitutes the entire agreement and understanding between you and us and govern your use of the Service, superseding any - prior or contemporaneous agreements, communications and proposals, whether oral or written, between you and us (including, but not - limited to, any prior versions of the Terms of Service). Any ambiguities in the interpretation of these Terms of Service shall not - be construed against the drafting party. -

    -

    SECTION 18 - GOVERNING LAW

    -

    - These Terms of Service and any separate agreements whereby we provide you Services shall be governed by and construed in - accordance with the laws of the United States of America. -

    -

    SECTION 19 - CHANGES TO TERMS OF SERVICE

    -

    - You can review the most current version of the Terms of Service at any time at this page. We reserve the right, at our sole - discretion, to update, change or replace any part of these Terms of Service by posting updates and changes to our website. It is - your responsibility to check our website periodically for changes. Your continued use of or access to our website or the Service - following the posting of any changes to these Terms of Service constitutes acceptance of those changes. -

    -

    SECTION 20 - CONTACT INFORMATION

    -

    Questions about the Terms of Service should be sent to us at {email}.

    -

    SECTION 21 - 3RD PARTY LICENSE INFORMATION

    -

    - License information is available upon request. The Jetstream homepage uses the following open source licenses that require - attribution. -

    -

    https://github.com/salesforce-ux/design-system/blob/master/LICENSE-font.txt

    -

    https://github.com/salesforce-ux/design-system/blob/master/LICENSE-icons-images.txt

    -

    https://github.com/salesforce-ux/design-system/blob/master/LICENSE.txt

    -
    -
    - +export default function Page() { + return ( +
    +

    TERMS OF SERVICE

    +

    OVERVIEW

    +

    + This website is operated by Jetstream. Throughout the site, the terms “we”, “us” and “our” refer to Jetstream. Jetstream offers this + website, including all information, tools and services available from this site to you, the user, conditioned upon your acceptance + of all terms, conditions, policies and notices stated here. By visiting our site and / or purchasing something from us, you engage + in our “Service” and agree to be bound by the following terms and conditions (“Terms of Service”, “Terms”), including those + additional terms and conditions and policies referenced herein and/or available by hyperlink. These Terms of Service apply to all + users of the site, including without limitation users who are browsers, vendors, customers, merchants, and / or contributors of + content. Please read these Terms of Service carefully before accessing or using our website. By accessing or using any part of the + site, you agree to be bound by these Terms of Service. If you do not agree to all the terms and conditions of this agreement, then + you may not access the website or use any services. If these Terms of Service are considered an offer, acceptance is expressly + limited to these Terms of Service. Any new features or tools which are added to the current store shall also be subject to the Terms + of Service. You can review the most current version of the Terms of Service at any time on this page. We reserve the right to + update, change or replace any part of these Terms of Service by posting updates and/or changes to our website. It is your + responsibility to check this page periodically for changes. Your continued use of or access to the website following the posting of + any changes constitutes acceptance of those changes. +

    +

    SECTION 1 - ONLINE TERMS

    +

    + By agreeing to these Terms of Service, you represent that you are at least the age of majority in your state or province of + residence, or that you are the age of majority in your state or province of residence and you have given us your consent to allow + any of your minor dependents to use this site. You may not use our products for any illegal or unauthorized purpose nor may you, in + the use of the Service, violate any laws in your jurisdiction (including but not limited to copyright laws). You must not transmit + any worms or viruses or any code of a destructive nature. A breach or violation of any of the Terms will result in an immediate + termination of your Services. +

    +

    SECTION 2 - GENERAL CONDITIONS

    +

    + We reserve the right to refuse service to anyone for any reason at any time. You understand that your content (not including credit + card information), may be transferred unencrypted and involve (a) transmissions over various networks; and (b) changes to conform + and adapt to technical requirements of connecting networks or devices. Credit card information is always encrypted during transfer + over networks. You agree not to reproduce, duplicate, copy, sell, resell or exploit any portion of the Service, use of the Service, + or access to the Service or any contact on the website through which the service is provided, without express written permission by + us. The headings used in this agreement are included for convenience only and will not limit or otherwise affect these Terms. +

    +

    SECTION 3 - ACCURACY, COMPLETENESS AND TIMELINESS OF INFORMATION

    +

    + We are not responsible if information made available on this site is not accurate, complete or current. The material on this site is + provided for general information only and should not be relied upon or used as the sole basis for making decisions without + consulting primary, more accurate, more complete or more timely sources of information. Any reliance on the material on this site is + at your own risk. This site may contain certain historical information. Historical information, necessarily, is not current and is + provided for your reference only. We reserve the right to modify the contents of this site at any time, but we have no obligation to + update any information on our site. You agree that it is your responsibility to monitor changes to our site. +

    +

    SECTION 4 - MODIFICATIONS TO THE SERVICE AND PRICES

    +

    + Prices for our products are subject to change without notice. We reserve the right at any time to modify or discontinue the Service + (or any part or content thereof) without notice at any time. We shall not be liable to you or to any third-party for any + modification, price change, suspension or discontinuance of the Service. +

    +

    SECTION 5 - PRODUCTS OR SERVICES (if applicable)

    +

    + Certain products or services may be available exclusively online through the website. These products or services may have limited + quantities and are subject to return or exchange only according to our Return Policy. We have made every effort to display as + accurately as possible the colors and images of our products that appear at the store. We cannot guarantee that your computer + monitor's display of any color will be accurate. We reserve the right, but are not obligated, to limit the sales of our products or + Services to any person, geographic region or jurisdiction. We may exercise this right on a case-by-case basis. We reserve the right + to limit the quantities of any products or services that we offer. All descriptions of products or product pricing are subject to + change at anytime without notice, at the sole discretion of us. We reserve the right to discontinue any product at any time. Any + offer for any product or service made on this site is void where prohibited. We do not warrant that the quality of any products, + services, information, or other material purchased or obtained by you will meet your expectations, or that any errors in the Service + will be corrected. +

    +

    SECTION 6 - ACCURACY OF BILLING AND ACCOUNT INFORMATION

    +

    + We reserve the right to refuse any order you place with us. We may, in our sole discretion, limit or cancel quantities purchased per + person, per household or per order. These restrictions may include orders placed by or under the same customer account, the same + credit card, and/or orders that use the same billing and/or shipping address. In the event that we make a change to or cancel an + order, we may attempt to notify you by contacting the e-mail and/or billing address/phone number provided at the time the order was + made. We reserve the right to limit or prohibit orders that, in our sole judgment, appear to be placed by dealers, resellers or + distributors. You agree to provide current, complete and accurate purchase and account information for all purchases made at our + store. You agree to promptly update your account and other information, including your email address and credit card numbers and + expiration dates, so that we can complete your transactions and contact you as needed. For more detail, please review our Returns + Policy. +

    +

    SECTION 7 - OPTIONAL TOOLS

    +

    + We may provide you with access to third-party tools over which we neither monitor nor have any control nor input. You acknowledge + and agree that we provide access to such tools ”as is” and “as available” without any warranties, representations or conditions of + any kind and without any endorsement. We shall have no liability whatsoever arising from or relating to your use of optional + third-party tools. Any use by you of optional tools offered through the site is entirely at your own risk and discretion and you + should ensure that you are familiar with and approve of the terms on which tools are provided by the relevant third-party + provider(s). We may also, in the future, offer new services and/or features through the website (including, the release of new tools + and resources). Such new features and/or services shall also be subject to these Terms of Service. +

    +

    SECTION 8 - THIRD-PARTY LINKS

    +

    + Certain content, products and services available via our Service may include materials from third-parties. Third-party links on this + site may direct you to third-party websites that are not affiliated with us. We are not responsible for examining or evaluating the + content or accuracy and we do not warrant and will not have any liability or responsibility for any third-party materials or + websites, or for any other materials, products, or services of third-parties. We are not liable for any harm or damages related to + the purchase or use of goods, services, resources, content, or any other transactions made in connection with any third-party + websites. Please review carefully the third-party's policies and practices and make sure you understand them before you engage in + any transaction. Complaints, claims, concerns, or questions regarding third-party products should be directed to the third-party. +

    +

    SECTION 9 - USER COMMENTS, FEEDBACK AND OTHER SUBMISSIONS

    +

    + If, at our request, you send certain specific submissions (for example contest entries) or without a request from us you send + creative ideas, suggestions, proposals, plans, or other materials, whether online, by email, by postal mail, or otherwise + (collectively, 'comments'), you agree that we may, at any time, without restriction, edit, copy, publish, distribute, translate and + otherwise use in any medium any comments that you forward to us. We are and shall be under no obligation (1) to maintain any + comments in confidence; (2) to pay compensation for any comments; or (3) to respond to any comments. We may, but have no obligation + to, monitor, edit or remove content that we determine in our sole discretion are unlawful, offensive, threatening, libelous, + defamatory, pornographic, obscene or otherwise objectionable or violates any party’s intellectual property or these Terms of + Service. You agree that your comments will not violate any right of any third-party, including copyright, trademark, privacy, + personality or other personal or proprietary right. You further agree that your comments will not contain libelous or otherwise + unlawful, abusive or obscene material, or contain any computer virus or other malware that could in any way affect the operation of + the Service or any related website. You may not use a false e-mail address, pretend to be someone other than yourself, or otherwise + mislead us or third-parties as to the origin of any comments. You are solely responsible for any comments you make and their + accuracy. We take no responsibility and assume no liability for any comments posted by you or any third-party. +

    +

    SECTION 10 - PERSONAL INFORMATION

    +

    + Your submission of personal information through the store is governed by our Privacy Policy. +

    +

    SECTION 11 - ERRORS, INACCURACIES AND OMISSIONS

    +

    + Occasionally there may be information on our site or in the Service that contains typographical errors, inaccuracies or omissions + that may relate to product descriptions, pricing, promotions, offers, product shipping charges, transit times and availability. We + reserve the right to correct any errors, inaccuracies or omissions, and to change or update information or cancel orders if any + information in the Service or on any related website is inaccurate at any time without prior notice (including after you have + submitted your order). We undertake no obligation to update, amend or clarify information in the Service or on any related website, + including without limitation, pricing information, except as required by law. No specified update or refresh date applied in the + Service or on any related website, should be taken to indicate that all information in the Service or on any related website has + been modified or updated. +

    +

    SECTION 12 - PROHIBITED USES

    +

    + In addition to other prohibitions as set forth in the Terms of Service, you are prohibited from using the site or its content: (a) + for any unlawful purpose; (b) to solicit others to perform or participate in any unlawful acts; (c) to violate any international, + federal, provincial or state regulations, rules, laws, or local ordinances; (d) to infringe upon or violate our intellectual + property rights or the intellectual property rights of others; (e) to harass, abuse, insult, harm, defame, slander, disparage, + intimidate, or discriminate based on gender, sexual orientation, religion, ethnicity, race, age, national origin, or disability; (f) + to submit false or misleading information; (g) to upload or transmit viruses or any other type of malicious code that will or may be + used in any way that will affect the functionality or operation of the Service or of any related website, other websites, or the + Internet; (h) to collect or track the personal information of others; (i) to spam, phish, pharm, pretext, spider, crawl, or scrape; + (j) for any obscene or immoral purpose; or (k) to interfere with or circumvent the security features of the Service or any related + website, other websites, or the Internet. We reserve the right to terminate your use of the Service or any related website for + violating any of the prohibited uses. +

    +

    SECTION 13 - DISCLAIMER OF WARRANTIES; LIMITATION OF LIABILITY

    +

    + We do not guarantee, represent or warrant that your use of our service will be uninterrupted, timely, secure or error-free. We do + not warrant that the results that may be obtained from the use of the service will be accurate or reliable. You agree that from time + to time we may remove the service for indefinite periods of time or cancel the service at any time, without notice to you. You + expressly agree that your use of, or inability to use, the service is at your sole risk. The service and all products and services + delivered to you through the service are (except as expressly stated by us) provided 'as is' and 'as available' for your use, + without any representation, warranties or conditions of any kind, either express or implied, including all implied warranties or + conditions of merchantability, merchantable quality, fitness for a particular purpose, durability, title, and non-infringement. In + no case shall Jetstream, our directors, officers, employees, affiliates, agents, contractors, interns, suppliers, service providers + or licensors be liable for any injury, loss, claim, or any direct, indirect, incidental, punitive, special, or consequential damages + of any kind, including, without limitation lost profits, lost revenue, lost savings, loss of data, replacement costs, or any similar + damages, whether based in contract, tort (including negligence), strict liability or otherwise, arising from your use of any of the + service or any products procured using the service, or for any other claim related in any way to your use of the service or any + product, including, but not limited to, any errors or omissions in any content, or any loss or damage of any kind incurred as a + result of the use of the service or any content (or product) posted, transmitted, or otherwise made available via the service, even + if advised of their possibility. Because some states or jurisdictions do not allow the exclusion or the limitation of liability for + consequential or incidental damages, in such states or jurisdictions, our liability shall be limited to the maximum extent permitted + by law. +

    +

    SECTION 14 - INDEMNIFICATION

    +

    + You agree to indemnify, defend and hold harmless Jetstream and our parent, subsidiaries, affiliates, partners, officers, directors, + agents, contractors, licensors, service providers, subcontractors, suppliers, interns and employees, harmless from any claim or + demand, including reasonable attorneys’ fees, made by any third-party due to or arising out of your breach of these Terms of Service + or the documents they incorporate by reference, or your violation of any law or the rights of a third-party. +

    +

    SECTION 15 - SEVERABILITY

    +

    + In the event that any provision of these Terms of Service is determined to be unlawful, void or unenforceable, such provision shall + nonetheless be enforceable to the fullest extent permitted by applicable law, and the unenforceable portion shall be deemed to be + severed from these Terms of Service, such determination shall not affect the validity and enforceability of any other remaining + provisions. +

    +

    SECTION 16 - TERMINATION

    +

    + The obligations and liabilities of the parties incurred prior to the termination date shall survive the termination of this + agreement for all purposes. These Terms of Service are effective unless and until terminated by either you or us. You may terminate + these Terms of Service at any time by notifying us that you no longer wish to use our Services, or when you cease using our site. If + in our sole judgment you fail, or we suspect that you have failed, to comply with any term or provision of these Terms of Service, + we also may terminate this agreement at any time without notice and you will remain liable for all amounts due up to and including + the date of termination; and/or accordingly may deny you access to our Services (or any part thereof). +

    +

    SECTION 17 - ENTIRE AGREEMENT

    +

    + The failure of us to exercise or enforce any right or provision of these Terms of Service shall not constitute a waiver of such + right or provision. These Terms of Service and any policies or operating rules posted by us on this site or in respect to The + Service constitutes the entire agreement and understanding between you and us and govern your use of the Service, superseding any + prior or contemporaneous agreements, communications and proposals, whether oral or written, between you and us (including, but not + limited to, any prior versions of the Terms of Service). Any ambiguities in the interpretation of these Terms of Service shall not + be construed against the drafting party. +

    +

    SECTION 18 - GOVERNING LAW

    +

    + These Terms of Service and any separate agreements whereby we provide you Services shall be governed by and construed in accordance + with the laws of the United States of America. +

    +

    SECTION 19 - CHANGES TO TERMS OF SERVICE

    +

    + You can review the most current version of the Terms of Service at any time at this page. We reserve the right, at our sole + discretion, to update, change or replace any part of these Terms of Service by posting updates and changes to our website. It is + your responsibility to check our website periodically for changes. Your continued use of or access to our website or the Service + following the posting of any changes to these Terms of Service constitutes acceptance of those changes. +

    +

    SECTION 20 - CONTACT INFORMATION

    +

    Questions about the Terms of Service should be sent to us at {email}.

    +

    SECTION 21 - 3RD PARTY LICENSE INFORMATION

    +

    + License information is available upon request. The Jetstream homepage uses the following open source licenses that require + attribution. +

    +

    https://github.com/salesforce-ux/design-system/blob/master/LICENSE-font.txt

    +

    https://github.com/salesforce-ux/design-system/blob/master/LICENSE-icons-images.txt

    +

    https://github.com/salesforce-ux/design-system/blob/master/LICENSE.txt

    +
    ); } -export default TermsOfService; +Page.getLayout = function getLayout(page) { + return ( + + {page} + + ); +}; diff --git a/apps/landing/tailwind.config.js b/apps/landing/tailwind.config.js index 67bd8d840..051a9c4a6 100644 --- a/apps/landing/tailwind.config.js +++ b/apps/landing/tailwind.config.js @@ -14,6 +14,19 @@ module.exports = { colors: { teal: colors.teal, cyan: colors.cyan, + blue: { + 50: '#eef4ff', + 100: '#d8e6fe', + 200: '#aacbff', + 300: '#78b0fd', + 400: '#57a3fd', + 500: '#1b96ff', + 600: '#0176d3', + 700: '#0b5cab', + 800: '#014486', + 900: '#032d60', + 950: '#001639', + }, }, fontFamily: { sans: ['Salesforce Sans', ...defaultTheme.fontFamily.sans], diff --git a/apps/landing/utils/environment.ts b/apps/landing/utils/environment.ts new file mode 100644 index 000000000..1f6e2a1f8 --- /dev/null +++ b/apps/landing/utils/environment.ts @@ -0,0 +1,38 @@ +export const ENVIRONMENT = { + CLIENT_URL: process.env.NEXT_PUBLIC_CLIENT_URL || 'https://getjetstream.app/app', + SERVER_URL: process.env.NEXT_PUBLIC_SERVER_URL || 'https://getjetstream.app', + CAPTCHA_KEY: process.env.NEXT_PUBLIC_CAPTCHA_KEY || null, +}; + +export const AUTH_PATHS = { + _root_path: '/auth/', + login: '/auth/login/', + signup: '/auth/signup/', + resetPassword: '/auth/password-reset', + resetPasswordVerify: '/auth/password-reset/verify', + verify: `/auth/verify`, + api_csrf: `/api/auth/csrf`, + api_logout: `/api/auth/logout`, + api_providers: `/api/auth/providers`, + api_session: `/api/auth/session`, + api_verify: `/api/auth/verify`, + api_verify_resend: `/api/auth/verify/resend`, + api_reset_password_init: `/api/auth/password/reset/init`, + api_reset_password_verify: `/api/auth/password/reset/verify`, +}; + +export const SIGN_IN_ERRORS = { + default: 'Check your details and try again.', + AuthError: 'Check your details and try again.', + InvalidCsrfToken: 'The form is invalid, refresh the page and start over.', + InvalidCredentials: 'Sign in failed. Check the details you provided are correct.', + InvalidAction: 'The form is invalid, refresh the page and start over.', + InvalidParameters: 'The form is invalid, refresh the page and start over.', + InvalidProvider: 'The form is invalid, refresh the page and start over.', + InvalidSession: 'Your session is invalid, please sign in again.', + InvalidCaptcha: 'Invalid captcha verification, refresh and try again to confirm you are not a bot.', + ExpiredVerificationToken: 'Your verification token has expired, sign in again.', + InvalidVerificationToken: 'Your verification token is invalid.', + LoginWithExistingIdentity: 'To confirm your identity, sign in with the same account you used originally.', + InvalidOrExpiredResetToken: 'Your reset token is invalid, please restart the reset process.', +}; diff --git a/apps/landing/utils/types.ts b/apps/landing/utils/types.ts index 609d8a4cf..a7ec91128 100644 --- a/apps/landing/utils/types.ts +++ b/apps/landing/utils/types.ts @@ -1,5 +1,12 @@ import { Document } from '@contentful/rich-text-types'; import { Asset, EntryFields, Sys } from 'contentful'; +import { z } from 'zod'; + +export const PasswordSchema = z + .string() + .min(1, { message: 'Password is required' }) + .min(8, { message: 'Password must be at least 8 characters' }) + .max(255, { message: 'Password must be at most 255 characters' }); export interface AnalyticSummaryItem { type: 'LOAD_SUMMARY' | 'QUERY_SUMMARY'; diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index f31698c5c..27461e0e2 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -18,19 +18,12 @@ services: JETSTREAM_SERVER_DOMAIN: localhost:3333 JETSTREAM_SERVER_URL: http://localhost:3333 EXAMPLE_USER_OVERRIDE: 'true' - AUTH0_DOMAIN: '${AUTH0_DOMAIN}' - AUTH0_M2M_DOMAIN: '${AUTH0_M2M_DOMAIN}' - AUTH0_CLIENT_ID: '${AUTH0_CLIENT_ID}' - AUTH0_MGMT_CLIENT_ID: '${AUTH0_MGMT_CLIENT_ID}' - AUTH0_MGMT_CLIENT_SECRET: '${AUTH0_MGMT_CLIENT_SECRET}' - AUTH0_CLIENT_SECRET: '${AUTH0_CLIENT_SECRET}' SFDC_API_VERSION: '${SFDC_API_VERSION}' SFDC_CONSUMER_SECRET: '${SFDC_CONSUMER_SECRET}' SFDC_CONSUMER_KEY: '${SFDC_CONSUMER_KEY}' SFDC_CALLBACK_URL: '${SFDC_CALLBACK_URL}' NX_PUBLIC_ROLLBAR_KEY: '${NX_PUBLIC_ROLLBAR_KEY}' NX_PUBLIC_AMPLITUDE_KEY: '${NX_PUBLIC_AMPLITUDE_KEY}' - NX_PUBLIC_AUTH_AUDIENCE: '${NX_PUBLIC_AUTH_AUDIENCE}' ports: - '3333:3333' - '9229:9229' diff --git a/docker-compose.yml b/docker-compose.yml index 6cc14b65b..9bc7b8b39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,10 +15,16 @@ services: IS_LOCAL_DOCKER: true JETSTREAM_POSTGRES_DBURI: postgres://postgres:postgres@postgres:5432/postgres EXAMPLE_USER_OVERRIDE: true - JETSTREAM_SESSION_SECRET: '123456' + EXAMPLE_USER_PASSWORD: 'EXAMPLE_123!' + JETSTREAM_SESSION_SECRET: '8e52194ce3b6650b93e95a5c40a705b2' + JETSTREAM_AUTH_SECRET: 'l26oD1TYqkJP/AZccmFwX2gPO45rG1qQuSXjVxRj9U/3' + JETSTREAM_AUTH_OTP_SECRET: 'pD0AwvBhZU5COntz97OBDAtonoEe/Z0lz5ulNFl4K04=' JETSTREAM_CLIENT_URL: http://localhost:3333/app JETSTREAM_SERVER_DOMAIN: localhost:3333 JETSTREAM_SERVER_URL: http://localhost:3333 + NEXT_PUBLIC_CLIENT_URL: 'http://localhost:4200/app' + NEXT_PUBLIC_SERVER_URL: 'http://localhost:3333' + ports: - '3333:3333' links: diff --git a/libs/api-config/src/index.ts b/libs/api-config/src/index.ts index 674aa25b7..bc23a65b0 100644 --- a/libs/api-config/src/index.ts +++ b/libs/api-config/src/index.ts @@ -7,6 +7,7 @@ import './lib/env-config'; // Exports export * from './lib/api-db-config'; export * from './lib/api-logger'; +export * from './lib/api-rate-limit.config'; export * from './lib/api-rollbar-config'; export * from './lib/api-telemetry'; export * from './lib/email.config'; diff --git a/libs/api-config/src/lib/api-db-config.ts b/libs/api-config/src/lib/api-db-config.ts index 5aa35865e..b3efed3aa 100644 --- a/libs/api-config/src/lib/api-db-config.ts +++ b/libs/api-config/src/lib/api-db-config.ts @@ -15,6 +15,15 @@ if (ENV.PRISMA_DEBUG) { export const prisma = new PrismaClient({ log, +}).$extends({ + result: { + user: { + hasPasswordSet: { + needs: { password: true }, + compute: (user) => !!user.password, + }, + }, + }, }); export const pgPool = new Pool({ @@ -24,7 +33,6 @@ export const pgPool = new Pool({ }); pgPool.on('connect', (client) => { - // logger.info('[DB][POOL] Connected'); client.on('error', (err) => { logger.error(getExceptionLog(err), '[DB][CLIENT][ERROR] Unexpected error on client.'); }); diff --git a/libs/api-config/src/lib/api-logger.ts b/libs/api-config/src/lib/api-logger.ts index 884cfee68..c4fa31ae2 100644 --- a/libs/api-config/src/lib/api-logger.ts +++ b/libs/api-config/src/lib/api-logger.ts @@ -7,16 +7,29 @@ import { ENV } from './env-config'; export const logger = pino({ level: ENV.LOG_LEVEL, transport: - ENV.ENVIRONMENT === 'development' && ENV.LOG_LEVEL === 'trace' + ENV.ENVIRONMENT === 'development' && !ENV.IS_LOCAL_DOCKER ? { target: 'pino-pretty', } : undefined, }); +const ignoreLogsFileExtensions = /.*\.(js|map|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|otf|json)$/; + export const httpLogger = pinoHttp({ logger, genReqId: (req, res) => res.locals.requestId || uuid(), + autoLogging: { + // ignore static files based on file extension + ignore: (req) => ignoreLogsFileExtensions.test(req.url) || req.url === '/healthz' || req.url === '/api/heartbeat', + }, + customLogLevel: function (req, res, error) { + if (res.statusCode > 400) { + // these are manually logged in the request handler + return 'silent'; + } + return ENV.LOG_LEVEL; + }, customSuccessMessage: function (req, res) { if (res.statusCode === 404) { return `[404] [${req.method}] ${req.url}`; diff --git a/libs/api-config/src/lib/api-rate-limit.config.ts b/libs/api-config/src/lib/api-rate-limit.config.ts new file mode 100644 index 000000000..aee7072af --- /dev/null +++ b/libs/api-config/src/lib/api-rate-limit.config.ts @@ -0,0 +1,19 @@ +import { ClusterMemoryStoreWorker } from '@express-rate-limit/cluster-memory-store'; +import cluster from 'cluster'; +import { MemoryStore, Options, rateLimit } from 'express-rate-limit'; + +export function createRateLimit(prefix: string, options: Partial) { + return rateLimit({ + // cluster.isPrimary will be true on dev + store: cluster.isPrimary + ? new MemoryStore() + : new ClusterMemoryStoreWorker({ + prefix, + }), + windowMs: 1000 * 60 * 1, // 1 minute + max: 50, // limit each IP to 50 requests per windowMs + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers + ...options, + }); +} diff --git a/libs/api-config/src/lib/api-rollbar-config.ts b/libs/api-config/src/lib/api-rollbar-config.ts index cc5ccc5d1..0420c0f98 100644 --- a/libs/api-config/src/lib/api-rollbar-config.ts +++ b/libs/api-config/src/lib/api-rollbar-config.ts @@ -2,9 +2,9 @@ import Rollbar from 'rollbar'; import { ENV } from './env-config'; export const rollbarServer = new Rollbar({ - codeVersion: ENV.GIT_VERSION, - code_version: ENV.GIT_VERSION, - accessToken: ENV.ROLLBAR_SERVER_TOKEN, + codeVersion: ENV.GIT_VERSION || '', + code_version: ENV.GIT_VERSION || '', + accessToken: ENV.ROLLBAR_SERVER_TOKEN || '', environment: ENV.ENVIRONMENT, captureUncaught: true, captureUnhandledRejections: true, diff --git a/libs/api-config/src/lib/api-telemetry.ts b/libs/api-config/src/lib/api-telemetry.ts index 7c54bf3a1..c4a58927b 100644 --- a/libs/api-config/src/lib/api-telemetry.ts +++ b/libs/api-config/src/lib/api-telemetry.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { credentials, Metadata } from '@grpc/grpc-js'; -import { UserProfileServer } from '@jetstream/types'; +import { UserProfileSession } from '@jetstream/auth/types'; import telemetryApi from '@opentelemetry/api'; import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc'; @@ -50,10 +50,10 @@ if (ENV.HONEYCOMB_ENABLED) { }); } -export function telemetryAddUserToAttributes(user?: UserProfileServer) { - if (ENV.HONEYCOMB_ENABLED && user && (user.user_id || user.id)) { +export function telemetryAddUserToAttributes(user?: UserProfileSession) { + if (ENV.HONEYCOMB_ENABLED && user?.id) { try { - telemetryApi.trace.getSpan(telemetryApi.context.active())?.setAttribute('user.id', user.user_id || user.id); + telemetryApi.trace.getSpan(telemetryApi.context.active())?.setAttribute('user.id', user.id); } catch (ex) { logger.warn(getExceptionLog(ex), '[TELEMETRY] Error adding user to attributes'); } diff --git a/libs/api-config/src/lib/email.config.ts b/libs/api-config/src/lib/email.config.ts index 084fe7e05..61371b1f3 100644 --- a/libs/api-config/src/lib/email.config.ts +++ b/libs/api-config/src/lib/email.config.ts @@ -1,13 +1,65 @@ import formData from 'form-data'; import Mailgun from 'mailgun.js'; +import { prisma } from './api-db-config'; +import { logger } from './api-logger'; import { ENV } from './env-config'; -export let mailgun: ReturnType; +let mailgun: ReturnType; if (ENV.MAILGUN_API_KEY) { mailgun = new Mailgun(formData).client({ username: 'api', key: ENV.MAILGUN_API_KEY, - public_key: ENV.MAILGUN_PUBLIC_KEY, }); } + +export async function sendEmail({ + from, + replyTo, + to, + subject, + attachment, + html, + text, + ...rest +}: { + from?: string; + replyTo?: string; + to: string; + subject: string; + attachment?: any; + html: string; + text: string; + [key: string]: any; +}) { + if (!mailgun) { + logger.warn('[EMAIL][ERROR] Mail client not configured, skipping sending email'); + prisma.emailActivity + .create({ + data: { email: to, subject, status: `unsent` }, + select: { id: true }, + }) + .catch((err) => logger.error({ message: err?.message }, '[EMAIL][ERROR] Error logging email activity')); + return; + } + + const results = await mailgun.messages.create(ENV.JETSTREAM_EMAIL_DOMAIN, { + from: from || ENV.JETSTREAM_EMAIL_FROM_NAME, + 'h:Reply-To': replyTo || ENV.JETSTREAM_EMAIL_REPLY_TO, + to, + subject, + text, + html, + attachment, + ...rest, + }); + + if (results.id) { + prisma.emailActivity + .create({ + data: { email: to, subject, status: `${results.status || ''}`, providerId: results.id }, + select: { id: true }, + }) + .catch((err) => logger.error({ message: err?.message }, '[EMAIL][ERROR] Error logging email activity')); + } +} diff --git a/libs/api-config/src/lib/env-config.ts b/libs/api-config/src/lib/env-config.ts index 03ca5f0a4..669846338 100644 --- a/libs/api-config/src/lib/env-config.ts +++ b/libs/api-config/src/lib/env-config.ts @@ -1,9 +1,13 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { ensureBoolean, ensureStringValue } from '@jetstream/shared/utils'; -import { UserProfileServer, UserProfileUi } from '@jetstream/types'; +import { UserProfileSession, UserProfileUiWithIdentities } from '@jetstream/auth/types'; +import { ensureBoolean } from '@jetstream/shared/utils'; +import chalk from 'chalk'; import * as dotenv from 'dotenv'; import { readFileSync } from 'fs-extra'; +import { isNumber } from 'lodash'; import { join } from 'path'; +import { z } from 'zod'; + dotenv.config(); let VERSION = 'unknown'; @@ -16,91 +20,170 @@ try { /** * This object allows for someone to run Jetstream in a local environment - * without having to authenticate with a real account. + * an bypass authentication - this is useful for running locally. + * + * This user cannot be used outside of localhost regardless of the environment variables. */ -const EXAMPLE_USER: UserProfileServer = { - _json: { - sub: 'EXAMPLE_USER', - nickname: 'Jetstream', - name: 'Jetstream Test', - picture: null, - updated_at: '2022-06-18T16:27:37.491Z', - email: 'test@example.com', - email_verified: true, - 'http://getjetstream.app/app_metadata': { - featureFlags: { flagVersion: 'V1.4', flags: ['all'], isDefault: false }, - }, - }, - _raw: null, - id: 'EXAMPLE_USER', - displayName: 'Jetstream Test', - emails: [], - name: 'Jetstream Test', - nickname: 'Jetstream', - picture: null, - provider: 'auth0', - user_id: 'EXAMPLE_USER', +const EXAMPLE_USER: UserProfileSession = { + id: 'AAAAAAAA-0000-0000-0000-AAAAAAAAAAAA', + name: 'Test User', + email: 'test@example.com', + emailVerified: true, + userId: 'test|AAAAAAAA-0000-0000-0000-AAAAAAAAAAAA', + authFactors: [], }; -const EXAMPLE_USER_PROFILE: UserProfileUi = { - ...EXAMPLE_USER._json, - id: 'EXAMPLE_USER', - userId: 'EXAMPLE_USER', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - preferences: { skipFrontdoorLogin: false }, +const EXAMPLE_USER_FULL_PROFILE: UserProfileUiWithIdentities = { + ...EXAMPLE_USER, + hasPasswordSet: false, + authFactors: [], + identities: [], + picture: null, + createdAt: new Date(), + updatedAt: new Date(), + preferences: { + skipFrontdoorLogin: false, + id: 'AAAAAAAA-0000-0000-0000-AAAAAAAAAAAA', + userId: 'test|TEST_USER_ID', + createdAt: new Date(), + updatedAt: new Date(), + }, }; -export const ENV = { - LOG_LEVEL: (process.env.LOG_LEVEL || 'debug') as 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent', - IS_CI: ensureBoolean(process.env.CI), - // LOCAL OVERRIDE - EXAMPLE_USER_OVERRIDE: ensureBoolean(process.env.EXAMPLE_USER_OVERRIDE), - EXAMPLE_USER: process.env.EXAMPLE_USER_OVERRIDE ? EXAMPLE_USER : null, - EXAMPLE_USER_PROFILE: process.env.EXAMPLE_USER_OVERRIDE ? EXAMPLE_USER_PROFILE : null, - IS_LOCAL_DOCKER: process.env.IS_LOCAL_DOCKER || false, - // SYSTEM - NODE_ENV: process.env.NODE_ENV, - ENVIRONMENT: process.env.ENVIRONMENT || 'production', - PORT: process.env.port || 3333, - GIT_VERSION: VERSION, - ROLLBAR_SERVER_TOKEN: process.env.ROLLBAR_SERVER_TOKEN, - // JETSTREAM - JETSTREAM_SERVER_DOMAIN: process.env.JETSTREAM_SERVER_DOMAIN, - // FIXME: there was a typo in env variables, using both temporarily as a safe fallback - JETSTREAM_SESSION_SECRET: process.env.JETSTREAM_SESSION_SECRET || '', - JETSTREAM_SERVER_URL: process.env.JETSTREAM_SERVER_URL, - // FIXME: there was a typo in env variables, using both temporarily as a safe fallback - JETSTREAM_POSTGRES_DBURI: process.env.JETSTREAM_POSTGRES_DBURI, - JETSTREAM_CLIENT_URL: process.env.JETSTREAM_CLIENT_URL, - JETSTREAM_WORKER_URL: process.env.JETSTREAM_WORKER_URL, - PRISMA_DEBUG: ensureBoolean(process.env.PRISMA_DEBUG), - COMETD_DEBUG: ensureStringValue(process.env.COMETD_DEBUG, ['error', 'warn', 'info', 'debug']) as 'error' | 'warn' | 'info' | 'debug', - // AUTH - AUTH0_DOMAIN: process.env.AUTH0_DOMAIN, - /** use for M2M tokens - in DEV this is the same, but different in production */ - AUTH0_M2M_DOMAIN: process.env.AUTH0_M2M_DOMAIN, - AUTH0_CLIENT_ID: process.env.AUTH0_CLIENT_ID, - AUTH0_MGMT_CLIENT_ID: process.env.AUTH0_MGMT_CLIENT_ID, - AUTH0_MGMT_CLIENT_SECRET: process.env.AUTH0_MGMT_CLIENT_SECRET, - AUTH0_CLIENT_SECRET: process.env.AUTH0_CLIENT_SECRET, - // MAILGUN - MAILGUN_API_KEY: process.env.MAILGUN_API_KEY, - MAILGUN_PUBLIC_KEY: process.env.MAILGUN_PUBLIC_KEY, - MAILGUN_WEBHOOK_KEY: process.env.MAILGUN_WEBHOOK_KEY, - // SFDC - SFDC_API_VERSION: process.env.NX_SFDC_API_VERSION || process.env.SFDC_API_VERSION || '58.0', - SFDC_CONSUMER_SECRET: process.env.SFDC_CONSUMER_SECRET!, - SFDC_CONSUMER_KEY: process.env.SFDC_CONSUMER_KEY!, - SFDC_CALLBACK_URL: process.env.SFDC_CALLBACK_URL!, - // GOOGLE - GOOGLE_APP_ID: process.env.GOOGLE_APP_ID, - GOOGLE_API_KEY: process.env.GOOGLE_API_KEY, - GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, - // GITHUB - GITHUB_TOKEN: process.env.GITHUB_TOKEN || process.env.GH_TOKEN, - // HONEYCOMB - HONEYCOMB_ENABLED: ensureBoolean(process.env.HONEYCOMB_ENABLED), - HONEYCOMB_API_KEY: process.env.HONEYCOMB_API_KEY, - AUTH_AUDIENCE: process.env.NX_PUBLIC_AUTH_AUDIENCE, -}; +const booleanSchema = z.union([z.string(), z.boolean()]).nullish().transform(ensureBoolean); +const numberSchema = z + .union([z.string(), z.number()]) + .nullish() + .transform((val) => { + if (isNumber(val) || !val) { + return val ?? null; + } + return /[0-9]+/.test(val) ? parseInt(val) : null; + }); + +const parseResults = z + .object({ + LOG_LEVEL: z + .enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent']) + .nullish() + .transform((value) => value ?? 'debug'), + IS_CI: booleanSchema, + // LOCAL OVERRIDE + EXAMPLE_USER: z.record(z.any()).nullish(), + EXAMPLE_USER_PASSWORD: z.string().nullish(), + EXAMPLE_USER_FULL_PROFILE: z.record(z.any()).nullish(), + IS_LOCAL_DOCKER: booleanSchema, + // SYSTEM + NODE_ENV: z + .enum(['development', 'test', 'staging', 'production']) + .nullish() + .transform((value) => value ?? 'production'), + ENVIRONMENT: z + .enum(['development', 'test', 'staging', 'production']) + .nullish() + .transform((value) => value ?? 'production'), + PORT: numberSchema.default(3333), + CAPTCHA_SECRET_KEY: z.string().nullish(), + CAPTCHA_PROPERTY: z.literal('captchaToken').optional().default('captchaToken'), + IP_API_KEY: z.string().nullish().describe('API Key used to get location information from IP address'), + GIT_VERSION: z.string().nullish(), + ROLLBAR_SERVER_TOKEN: z.string().nullish(), + // JETSTREAM + JETSTREAM_AUTH_SECRET: z.string().describe('Used to sign authentication cookies.'), + // Must be 32 characters + JETSTREAM_AUTH_OTP_SECRET: z.string(), + JETSTREAM_SERVER_DOMAIN: z.string(), + JETSTREAM_SESSION_SECRET: z.string(), + JETSTREAM_SESSION_SECRET_PREV: z + .string() + .nullish() + .transform((val) => val || null), + JETSTREAM_SERVER_URL: z.string().url(), + JETSTREAM_POSTGRES_DBURI: z.string(), + JETSTREAM_CLIENT_URL: z.string(), + PRISMA_DEBUG: booleanSchema, + COMETD_DEBUG: z.enum(['error', 'warn', 'info', 'debug']).nullish(), + // AUTH - OAuth2 credentials for logging in via OAuth2 + AUTH_SFDC_CLIENT_ID: z + .string() + .optional() + .transform((val) => { + if (!val) { + console.error('AUTH_SFDC_CLIENT_ID is not set - Logging in with Salesforce will not be available'); + } + return val || ''; + }), + AUTH_SFDC_CLIENT_SECRET: z + .string() + .optional() + .transform((val) => { + if (!val) { + console.error('AUTH_SFDC_CLIENT_SECRET is not set - Logging in with Salesforce will not be available'); + } + return val || ''; + }), + AUTH_GOOGLE_CLIENT_ID: z + .string() + .optional() + .transform((val) => { + if (!val) { + console.error('AUTH_GOOGLE_CLIENT_ID is not set - Logging in with Google will not be available'); + } + return val || ''; + }), + AUTH_GOOGLE_CLIENT_SECRET: z + .string() + .optional() + .transform((val) => { + if (!val) { + console.error('AUTH_GOOGLE_CLIENT_SECRET is not set - Logging in with Google will not be available'); + } + return val || ''; + }), + /** + * EMAIL + * If not set, email will not be sent + */ + JETSTREAM_EMAIL_DOMAIN: z.string().optional().default('mail@getjetstream.app'), + JETSTREAM_EMAIL_FROM_NAME: z.string().optional().default('Jetstream Support '), + JETSTREAM_EMAIL_REPLY_TO: z.string().optional().default('support@getjetstream.app'), + MAILGUN_API_KEY: z.string().nullish(), + MAILGUN_WEBHOOK_KEY: z.string().nullish(), + /** + * Salesforce Org Connections + * Connected App OAuth2 for connecting orgs + */ + SFDC_API_VERSION: z.string().regex(/^[0-9]{2,4}\.[0-9]$/), + SFDC_CONSUMER_SECRET: z.string().min(1), + SFDC_CONSUMER_KEY: z.string().min(1), + SFDC_CALLBACK_URL: z.string().url(), + /** + * Google OAuth2 + * Allows google drive configuration + */ + GOOGLE_APP_ID: z.string().nullish(), + GOOGLE_API_KEY: z.string().nullish(), + GOOGLE_CLIENT_ID: z.string().nullish(), + /** + * HONEYCOMB + * This is used for logging node application metrics + */ + HONEYCOMB_ENABLED: booleanSchema, + HONEYCOMB_API_KEY: z.string().nullish(), + }) + .safeParse({ + ...process.env, + EXAMPLE_USER: ensureBoolean(process.env.EXAMPLE_USER_OVERRIDE) ? EXAMPLE_USER : null, + EXAMPLE_USER_PASSWORD: ensureBoolean(process.env.EXAMPLE_USER_OVERRIDE) ? process.env.EXAMPLE_USER_PASSWORD : null, + EXAMPLE_USER_FULL_PROFILE: ensureBoolean(process.env.EXAMPLE_USER_OVERRIDE) ? EXAMPLE_USER_FULL_PROFILE : null, + SFDC_API_VERSION: process.env.NX_SFDC_API_VERSION || process.env.SFDC_API_VERSION, + }); + +if (!parseResults.success) { + console.error(`❌ ${chalk.red('Error parsing environment variables:')} +${chalk.yellow(JSON.stringify(parseResults.error.flatten().fieldErrors, null, 2))} +`); + process.exit(1); +} + +export const ENV = parseResults.data; diff --git a/libs/api-types/src/lib/api-user.types.ts b/libs/api-types/src/lib/api-user.types.ts index fefb6d5ae..4f20911e1 100644 --- a/libs/api-types/src/lib/api-user.types.ts +++ b/libs/api-types/src/lib/api-user.types.ts @@ -1,27 +1,6 @@ -import { ensureBoolean } from '@jetstream/shared/utils'; import { z } from 'zod'; export const EmailSupportRequestSchema = z.object({ emailBody: z.string().min(1), }); export type EmailSupportRequest = z.infer; - -export const UpdateProfileRequestSchema = z.object({ - id: z.string(), - userId: z.string(), - name: z.string(), - email: z.string(), - emailVerified: z.boolean().nullish().transform(ensureBoolean), - picture: z.string().nullish(), - username: z.string(), - nickname: z.string().nullish(), - identities: z.any().array(), - createdAt: z.string().nullish(), - updatedAt: z.string().nullish(), - preferences: z - .object({ - skipFrontdoorLogin: z.boolean().nullish(), - }) - .nullish(), -}); -export type UpdateProfileRequest = z.infer; diff --git a/libs/auth/server/.eslintrc.json b/libs/auth/server/.eslintrc.json new file mode 100644 index 000000000..3456be9b9 --- /dev/null +++ b/libs/auth/server/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/auth/server/README.md b/libs/auth/server/README.md new file mode 100644 index 000000000..6fcfc662f --- /dev/null +++ b/libs/auth/server/README.md @@ -0,0 +1,7 @@ +# server-auth + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test server-auth` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/auth/server/jest.config.ts b/libs/auth/server/jest.config.ts new file mode 100644 index 000000000..bbe09a34f --- /dev/null +++ b/libs/auth/server/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'server-auth', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/auth/server', +}; diff --git a/libs/auth/server/project.json b/libs/auth/server/project.json new file mode 100644 index 000000000..69a35f5c4 --- /dev/null +++ b/libs/auth/server/project.json @@ -0,0 +1,16 @@ +{ + "name": "server-auth", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/auth/server/src", + "projectType": "library", + "tags": ["server"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/auth/server/jest.config.ts" + } + } + } +} diff --git a/libs/auth/server/src/index.ts b/libs/auth/server/src/index.ts new file mode 100644 index 000000000..6c401cd29 --- /dev/null +++ b/libs/auth/server/src/index.ts @@ -0,0 +1,5 @@ +export * from './lib/auth-logging.db.service'; +export * from './lib/auth.db.service'; +export * from './lib/auth.errors'; +export * from './lib/auth.service'; +export * from './lib/auth.utils'; diff --git a/libs/auth/server/src/lib/OauthClients.ts b/libs/auth/server/src/lib/OauthClients.ts new file mode 100644 index 000000000..04bb5c789 --- /dev/null +++ b/libs/auth/server/src/lib/OauthClients.ts @@ -0,0 +1,86 @@ +import { ENV, logger } from '@jetstream/api-config'; +import type * as oauth from 'oauth4webapi'; + +const oauthPromise = import('oauth4webapi'); + +export interface OauthClientProvider { + authorizationServer: oauth.AuthorizationServer; + client: oauth.Client; +} + +export class OauthClients { + private static instance: OauthClients | null = null; + private static initPromise: Promise | null = null; + + public google!: OauthClientProvider; + public salesforce!: OauthClientProvider; + + private providers = { + salesforce: new URL('https://login.salesforce.com'), + google: new URL('https://accounts.google.com'), + } as const; + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + + static getInstance(): Promise { + if (!OauthClients.instance) { + if (!OauthClients.initPromise) { + const instance = new OauthClients(); + OauthClients.initPromise = instance.init().then(() => { + OauthClients.instance = instance; + return instance; + }); + } + return OauthClients.initPromise; + } + return Promise.resolve(OauthClients.instance); + } + + private async init() { + const oauth = await oauthPromise; + const [salesforceClient, googleClient] = await Promise.all([ + oauth + .discoveryRequest(this.providers.salesforce) + .then((response) => oauth.processDiscoveryResponse(this.providers.salesforce, response)) + .then((authorizationServer) => + // FIXME: why is this coming back as unknown? + this.getClient(authorizationServer, ENV.AUTH_SFDC_CLIENT_ID as string, ENV.AUTH_SFDC_CLIENT_SECRET as string) + ), + oauth + .discoveryRequest(this.providers.google) + .then((response) => oauth.processDiscoveryResponse(this.providers.google, response)) + .then((authorizationServer) => + // FIXME: why is this coming back as unknown? + this.getClient(authorizationServer, ENV.AUTH_GOOGLE_CLIENT_ID as string, ENV.AUTH_GOOGLE_CLIENT_SECRET as string) + ), + ]); + this.salesforce = salesforceClient; + this.google = googleClient; + } + + private getClient(authorizationServer: oauth.AuthorizationServer, clientId: string, clientSecret: string): OauthClientProvider { + // test + return { + authorizationServer, + client: { + client_id: clientId, + client_secret: clientSecret, + token_endpoint_auth_method: authorizationServer['token_endpoint_auth_method'], + id_token_signed_response_alg: authorizationServer['id_token_signed_response_alg'], + authorization_signed_response_alg: authorizationServer['authorization_signed_response_alg'], + require_auth_time: authorizationServer['require_auth_time'], + userinfo_signed_response_alg: authorizationServer['userinfo_signed_response_alg'], + introspection_signed_response_alg: authorizationServer['introspection_signed_response_alg'], + default_max_age: authorizationServer['default_max_age'], + use_mtls_endpoint_aliases: authorizationServer['use_mtls_endpoint_aliases'], + } as oauth.Client, + }; + } +} + +// eager init +OauthClients.getInstance().catch((err) => { + logger.error('FATAL INIT ERROR - could not load oauth clients', err); + process.exit(1); +}); diff --git a/libs/auth/server/src/lib/__tests__/auth.utils.spec.ts b/libs/auth/server/src/lib/__tests__/auth.utils.spec.ts new file mode 100644 index 000000000..2ab8dae09 --- /dev/null +++ b/libs/auth/server/src/lib/__tests__/auth.utils.spec.ts @@ -0,0 +1,180 @@ +import { + checkUserAgentSimilarity, + createCSRFToken, + createHash, + getCookieConfig, + hashPassword, + randomString, + validateCSRFToken, + verifyPassword, +} from '../auth.utils'; + +describe('getCookieConfig', () => { + it('should return correct cookie config for secure cookies', () => { + const config = getCookieConfig(true); + expect(config.callbackUrl.name).toBe('__Secure-jetstream-auth.callback-url'); + expect(config.callbackUrl.options.secure).toBe(true); + }); + + it('should return correct cookie config for non-secure cookies', () => { + const config = getCookieConfig(false); + expect(config.callbackUrl.name).toBe('jetstream-auth.callback-url'); + expect(config.callbackUrl.options.secure).toBe(false); + }); +}); + +describe('hashPassword', () => { + it('should hash the password correctly', async () => { + const password = 'testPassword'; + const hashedPassword = await hashPassword(password); + expect(hashedPassword).not.toBe(password); + }); +}); + +describe('verifyPassword', () => { + it('should verify the password correctly', async () => { + const password = 'testPassword'; + const hashedPassword = await hashPassword(password); + const isMatch = await verifyPassword(password, hashedPassword); + expect(isMatch).toBe(true); + }); + + it('should return false for incorrect password', async () => { + const password = 'testPassword'; + const hashedPassword = await hashPassword(password); + const isMatch = await verifyPassword('wrongPassword', hashedPassword); + expect(isMatch).toBe(false); + }); +}); + +describe('createHash', () => { + it('should create a correct hash', async () => { + const message = 'testMessage'; + const hash = await createHash(message); + expect(hash).toHaveLength(64); + }); +}); + +describe('randomString', () => { + it('should create a random string of specified length', () => { + const size = 16; + const randomStr = randomString(size); + expect(randomStr).toHaveLength(size * 2); // Each byte is represented by 2 hex characters + }); +}); + +describe('createCSRFToken', () => { + it('should create a CSRF token and cookie', async () => { + const secret = 'testSecret'; + const { cookie, csrfToken } = await createCSRFToken({ secret }); + expect(cookie).toContain(csrfToken); + }); +}); + +describe('validateCSRFToken', () => { + it('should validate the CSRF token correctly', async () => { + const secret = 'testSecret'; + const { cookie, csrfToken } = await createCSRFToken({ secret }); + const isValid = await validateCSRFToken({ secret, cookieValue: cookie, bodyValue: csrfToken }); + expect(isValid).toBe(true); + }); + + it('should return false for invalid CSRF token', async () => { + const secret = 'testSecret'; + const { cookie } = await createCSRFToken({ secret }); + const isValid = await validateCSRFToken({ secret, cookieValue: cookie, bodyValue: 'wrongToken' }); + expect(isValid).toBe(false); + }); +}); + +describe('isValidUserAgent', () => { + it('should return true for matching user agents', () => { + const sessionUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + const currentUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + + expect(checkUserAgentSimilarity(sessionUserAgent, currentUserAgent)).toBe(true); + }); + + it('should return false for different browsers', () => { + const sessionUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + const currentUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/89.0'; + + expect(checkUserAgentSimilarity(sessionUserAgent, currentUserAgent)).toBe(false); + }); + + it('should return false for different OS', () => { + const sessionUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + const currentUserAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + + expect(checkUserAgentSimilarity(sessionUserAgent, currentUserAgent)).toBe(false); + }); + + it('should return false for lower browser versions', () => { + const sessionUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + const currentUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4515.107 Safari/537.36'; + + expect(checkUserAgentSimilarity(sessionUserAgent, currentUserAgent)).toBe(false); + }); + + it('should return true for higher browser versions', () => { + const sessionUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + const currentUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36'; + + expect(checkUserAgentSimilarity(sessionUserAgent, currentUserAgent)).toBe(true); + }); +}); + +describe('isValidUserAgent', () => { + it('should return true for matching user agents', () => { + const sessionUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + const currentUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + + expect(checkUserAgentSimilarity(sessionUserAgent, currentUserAgent)).toBe(true); + }); + + it('should return false for different browsers', () => { + const sessionUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + const currentUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Firefox/89.0'; + + expect(checkUserAgentSimilarity(sessionUserAgent, currentUserAgent)).toBe(false); + }); + + it('should return false for different OS', () => { + const sessionUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + const currentUserAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + + expect(checkUserAgentSimilarity(sessionUserAgent, currentUserAgent)).toBe(false); + }); + + it('should return false for lower browser versions', () => { + const sessionUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + const currentUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4515.107 Safari/537.36'; + + expect(checkUserAgentSimilarity(sessionUserAgent, currentUserAgent)).toBe(false); + }); + + it('should return true for higher browser versions', () => { + const sessionUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; + const currentUserAgent = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36'; + + expect(checkUserAgentSimilarity(sessionUserAgent, currentUserAgent)).toBe(true); + }); +}); diff --git a/libs/auth/server/src/lib/auth-logging.db.service.ts b/libs/auth/server/src/lib/auth-logging.db.service.ts new file mode 100644 index 000000000..b6c2c77c6 --- /dev/null +++ b/libs/auth/server/src/lib/auth-logging.db.service.ts @@ -0,0 +1,87 @@ +import { logger, prisma } from '@jetstream/api-config'; +import type { Request, Response } from 'express'; +import { AuthError } from './auth.errors'; + +export type Action = + | 'LOGIN' + | 'PASSWORD_SET' + | 'PASSWORD_RESET_REQUEST' + | 'PASSWORD_RESET_COMPLETION' + | 'OAUTH_INIT' + | 'LINK_IDENTITY_INIT' + | 'LINK_IDENTITY' + | 'UNLINK_IDENTITY' + | 'EMAIL_VERIFICATION' + | '2FA_VERIFICATION' + | '2FA_RESEND_VERIFICATION' + | '2FA_SETUP' + | '2FA_REMOVAL' + | '2FA_ACTIVATE' + | '2FA_DEACTIVATE' + | 'REVOKE_SESSION' + | 'DELETE_ACCOUNT'; + +interface LoginActivity { + action: Action; + method?: string; + success: boolean; + email?: string; + userId?: string; + ipAddress?: string; + userAgent?: string; + errorMessage?: string; + requestId?: string; +} + +export async function createUserActivityFromReq( + req: Request, + res: Response, + data: LoginActivity +) { + try { + const ipAddress = req.ip; + const userAgent = req.headers['user-agent']; + const userId = data.userId || (req as any).session?.user?.id; + const email = data.email || (req as any).session?.user?.email; + const requestId = data.requestId || res.locals?.['requestId']; + + createUserActivity({ ...data, userId, email, ipAddress, userAgent, requestId }).catch((ex) => + logger.error('Error creating login activity', ex) + ); + } catch (ex) { + logger.error('Error creating login activity', ex); + } +} + +export async function createUserActivityFromReqWithError( + req: Request, + res: Response, + ex: unknown, + data: LoginActivity +) { + try { + data.success = false; + if (ex instanceof AuthError) { + data.errorMessage = `${ex.type}: ${ex.message}`; + } else if (ex instanceof Error) { + data.errorMessage = ex.message; + } + createUserActivityFromReq(req, res, data); + } catch (ex) { + logger.error('Error creating login activity', ex); + } +} + +export async function createUserActivity(data: LoginActivity) { + try { + data.success = data.success ?? false; + prisma.loginActivity + .create({ + data, + select: { id: true }, + }) + .catch((ex) => logger.error('Error creating login activity', ex)); + } catch (ex) { + logger.error('Error creating login activity', ex); + } +} diff --git a/libs/auth/server/src/lib/auth.db.service.ts b/libs/auth/server/src/lib/auth.db.service.ts new file mode 100644 index 000000000..28df3593e --- /dev/null +++ b/libs/auth/server/src/lib/auth.db.service.ts @@ -0,0 +1,865 @@ +import { ENV, logger, prisma } from '@jetstream/api-config'; +import { + AuthenticatedUser, + OauthProviderType, + ProviderTypeCredentials, + ProviderTypeOauth, + ProviderUser, + SessionData, + SessionIpData, + TwoFactorTypeWithoutEmail, + UserSession, + UserSessionWithLocation, +} from '@jetstream/auth/types'; +import { decryptString, encryptString } from '@jetstream/shared/node-utils'; +import { getErrorMessageAndStackObj } from '@jetstream/shared/utils'; +import { Maybe } from '@jetstream/types'; +import { Prisma } from '@prisma/client'; +import { addDays, startOfDay } from 'date-fns'; +import { addMinutes } from 'date-fns/addMinutes'; +import { InvalidAction, InvalidCredentials, InvalidOrExpiredResetToken, InvalidProvider, LoginWithExistingIdentity } from './auth.errors'; +import { ensureAuthError } from './auth.service'; +import { hashPassword, verifyPassword } from './auth.utils'; + +const userSelect = Prisma.validator()({ + id: true, + userId: true, + name: true, + email: true, + emailVerified: true, + appMetadata: false, + authFactors: { + select: { + type: true, + enabled: true, + secret: false, + }, + }, +}); + +export async function pruneExpiredRecords() { + await prisma.loginActivity.deleteMany({ + where: { + createdAt: { lte: addDays(startOfDay(new Date()), -30) }, + }, + }); + await prisma.emailActivity.deleteMany({ + where: { + createdAt: { lte: addDays(startOfDay(new Date()), -30) }, + }, + }); + await prisma.passwordResetToken.deleteMany({ + where: { + expiresAt: { lte: addDays(startOfDay(new Date()), -3) }, + }, + }); + await prisma.rememberedDevice.deleteMany({ + where: { + expiresAt: { lte: addDays(startOfDay(new Date()), -3) }, + }, + }); +} + +async function findUserByProviderId(provider: OauthProviderType, providerAccountId: string) { + return await prisma.user.findFirst({ + select: userSelect, + where: { + identities: { some: { provider, providerAccountId } }, + }, + }); +} + +async function findUsersByEmail(email: string) { + return prisma.user.findMany({ + select: userSelect, + where: { email }, + }); +} + +/** + * This should only be used for internal purposes, such as when a user is already authenticated + */ +export async function findUserById_UNSAFE(id: string) { + return await prisma.user.findFirstOrThrow({ + select: userSelect, + where: { id }, + }); +} + +export async function setUserEmailVerified(id: string) { + return prisma.user.update({ + select: userSelect, + data: { emailVerified: true }, + where: { id }, + }); +} + +export async function createRememberDevice({ + deviceId, + ipAddress, + userId, + userAgent, +}: { + userId: string; + deviceId: string; + ipAddress: string; + userAgent?: Maybe; +}) { + return prisma.rememberedDevice.create({ + select: { id: true }, + data: { + userId, + deviceId, + ipAddress, + userAgent, + expiresAt: addDays(new Date(), 30), + }, + }); +} + +export async function hasRememberDeviceRecord({ + deviceId, + ipAddress, + userId, + userAgent = null, +}: { + userId: string; + deviceId: string; + ipAddress: string; + userAgent?: Maybe; +}) { + try { + const matchingRecords = await prisma.rememberedDevice.count({ + where: { + userId, + deviceId, + ipAddress, + userAgent, + expiresAt: { gte: new Date() }, + }, + }); + return matchingRecords > 0; + } catch (ex) { + logger.error({ ...getErrorMessageAndStackObj(ex) }, 'Error checking for remember device record'); + return false; + } +} + +export async function getAuthFactors(userId: string) { + return prisma.authFactors.findMany({ + select: { + type: true, + enabled: true, + createdAt: true, + updatedAt: true, + }, + where: { userId }, + }); +} + +export async function getTotpAuthenticationFactor(userId: string) { + return prisma.authFactors + .findFirstOrThrow({ + select: { + type: true, + enabled: true, + secret: true, + }, + where: { userId, type: '2fa-otp', enabled: true, secret: { not: null } }, + }) + .then((factor) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const secret = decryptString(factor.secret!, ENV.JETSTREAM_AUTH_OTP_SECRET as string); + return { type: '2fa-otp', enabled: factor.enabled, secret }; + }); +} + +export async function createOrUpdateOtpAuthFactor(userId: string, secretPlainText: string) { + const secret = encryptString(secretPlainText, ENV.JETSTREAM_AUTH_OTP_SECRET as string); + + const factors = await prisma.authFactors.findMany({ + select: { type: true, userId: true }, + where: { userId, type: { not: '2fa-email' } }, + }); + await prisma.$transaction([ + // Set all others to disabled + ...factors.map(({ type, userId }) => + prisma.authFactors.update({ + data: { enabled: false }, + where: { userId_type: { type, userId } }, + }) + ), + // Save the new one + prisma.authFactors.upsert({ + create: { + userId, + type: '2fa-otp', + enabled: true, + secret, + }, + update: { + type: '2fa-otp', + enabled: true, + secret, + }, + where: { userId_type: { userId, type: '2fa-otp' } }, + }), + ]); + return getAuthFactors(userId); +} + +export async function toggleEnableDisableAuthFactor(userId: string, type: TwoFactorTypeWithoutEmail, action: 'enable' | 'disable') { + await prisma.$transaction(async (tx) => { + // When enabling, ensure all others are disabled + if (action === 'enable') { + // For non-totp, ensure we have a record (totp setup is handled separately) + if (type !== '2fa-otp') { + await prisma.authFactors.upsert({ + create: { type, userId, enabled: true }, + update: { enabled: true }, + where: { userId_type: { type, userId } }, + }); + } else { + await tx.authFactors.update({ + data: { enabled: true }, + where: { userId_type: { userId, type } }, + }); + } + } else { + await tx.authFactors.update({ + data: { enabled: false }, + where: { userId_type: { userId, type } }, + }); + } + }); + return getAuthFactors(userId); +} + +export async function deleteAuthFactor(userId: string, type: TwoFactorTypeWithoutEmail) { + await prisma.authFactors.delete({ + where: { userId_type: { type, userId } }, + }); + return getAuthFactors(userId); +} + +export async function getUserSessions(userId: string, omitLocationData?: boolean): Promise { + const sessions = await prisma.sessions + .findMany({ + select: { + sid: true, + sess: true, + expire: true, + }, + where: { + sess: { + path: ['user', 'id'], + equals: userId, + }, + }, + }) + .then((sessions) => + sessions.map((session): UserSession => { + const { sid, sess, expire } = session; + const { ipAddress, loginTime, provider, user, userAgent } = sess as unknown as SessionData; + return { + sessionId: sid, + expires: expire.toISOString(), + userAgent: userAgent, + ipAddress: ipAddress, + loginTime: new Date(loginTime).toISOString(), + provider: provider, + // TODO: last activity? + }; + }) + ); + + // Fetch location data and add to each session + if (!omitLocationData && ENV.IP_API_KEY && sessions.length > 0) { + try { + const ipAddresses = sessions.map((session) => session.ipAddress); + + const params = new URLSearchParams({ + fields: 'status,country,countryCode,region,regionName,city,isp,query', + key: ENV.IP_API_KEY as string, + }); + + const response = await fetch(`https://pro.ip-api.com/batch?${params.toString()}`, { + method: 'POST', + body: JSON.stringify(ipAddresses), + }); + + if (response.ok) { + const locations = (await response.json()) as SessionIpData[]; + return sessions.map( + (session, i): UserSessionWithLocation => ({ + ...session, + location: locations[i], + }) + ); + } + } catch (ex) { + logger.warn({ ...getErrorMessageAndStackObj(ex) }, 'Error fetching location data for sessions'); + } + } + + return sessions; +} + +export async function revokeUserSession(userId: string, sessionId: string) { + if (!userId || !sessionId) { + throw new Error('Invalid parameters'); + } + await prisma.sessions.delete({ + where: { + sess: { + path: ['user', 'id'], + equals: userId, + }, + sid: sessionId, + }, + }); + return getUserSessions(userId); +} + +export async function revokeAllUserSessions(userId: string, exceptId?: Maybe) { + if (!userId) { + throw new Error('Invalid parameters'); + } + await prisma.sessions.deleteMany({ + where: exceptId + ? { + sess: { + path: ['user', 'id'], + equals: userId, + }, + NOT: { sid: exceptId }, + } + : { + sess: { + path: ['user', 'id'], + equals: userId, + }, + }, + }); + return getUserSessions(userId); +} + +export async function setPasswordForUser(id: string, password: string) { + const UNSAFE_userWithPassword = await prisma.user.findFirst({ + select: { id: true, password: true }, + where: { id }, + }); + if (!UNSAFE_userWithPassword) { + return { error: new InvalidCredentials() }; + } + if (UNSAFE_userWithPassword.password) { + return { error: new Error('Cannot set password when already set, you must go through the password reset flow') }; + } + return prisma.user.update({ + select: userSelect, + data: { password: await hashPassword(password), passwordUpdatedAt: new Date() }, + where: { id }, + }); +} + +export const generatePasswordResetToken = async (email: string) => { + const user = await prisma.user.findFirst({ + where: { email }, + }); + + if (!user) { + throw new InvalidAction(); + } + + // if there is an existing token, delete it + const existingToken = await prisma.passwordResetToken.findFirst({ + where: { email }, + }); + + if (existingToken) { + await prisma.passwordResetToken.delete({ + where: { email_token: { email, token: existingToken.token } }, + }); + } + + const passwordResetToken = await prisma.passwordResetToken.create({ + data: { + email, + expiresAt: addMinutes(new Date(), 10), + }, + }); + + return passwordResetToken; +}; + +export const resetUserPassword = async (email: string, token: string, password: string) => { + // if there is an existing token, delete it + const existingToken = await prisma.passwordResetToken.findUnique({ + where: { email_token: { email, token } }, + }); + + if (!existingToken) { + throw new InvalidOrExpiredResetToken(); + } + + // delete token - we don't need it anymore and if we fail later, the user will need to reset again + await prisma.passwordResetToken.delete({ + where: { email_token: { email, token: existingToken.token } }, + }); + + if (existingToken.expiresAt < new Date()) { + throw new InvalidOrExpiredResetToken(); + } + + const user = await prisma.user.findFirst({ + where: { email }, + }); + + if (!user) { + throw new InvalidOrExpiredResetToken(); + } + + const hashedPassword = await hashPassword(password); + + await prisma.user.update({ + data: { + password: hashedPassword, + passwordUpdatedAt: new Date(), + }, + where: { + id: user.id, + }, + }); + + await revokeAllUserSessions(user.id); +}; + +export const removePasswordFromUser = async (id: string) => { + const UNSAFE_userWithPassword = await prisma.user.findUniqueOrThrow({ + select: { id: true, password: true, identities: { select: { provider: true, type: true } } }, + where: { id }, + }); + + if (!UNSAFE_userWithPassword.password) { + return { error: new Error('Password is not set') }; + } + + // FIXME: should we allow using magic link to login? in which case we can remove the password without oauth provider + if (UNSAFE_userWithPassword.identities.some(({ type }) => type !== 'oauth')) { + return { error: new Error('Your password cannot be removed without an alternative login method, such as a social provider') }; + } + + return prisma.user.update({ + select: userSelect, + data: { password: null, passwordUpdatedAt: new Date() }, + where: { id }, + }); +}; + +async function getUserAndVerifyPassword(email: string, password: string) { + const UNSAFE_userWithPassword = await prisma.user.findFirst({ + select: { id: true, password: true }, + where: { email, password: { not: null } }, + }); + if (!UNSAFE_userWithPassword) { + return { error: new InvalidCredentials() }; + } + if (await verifyPassword(password, UNSAFE_userWithPassword.password!)) { + return { + error: null, + user: await prisma.user.findFirstOrThrow({ + select: userSelect, + where: { id: UNSAFE_userWithPassword.id }, + }), + }; + } + return { error: new InvalidCredentials() }; +} + +async function addIdentityToUser(userId: string, providerUser: ProviderUser, provider: OauthProviderType) { + await prisma.authIdentity.create({ + data: { + userId, + type: 'oauth', + provider: provider, + providerAccountId: providerUser.id, + email: providerUser.email, + name: providerUser.name, + emailVerified: providerUser.emailVerified, + username: providerUser.username, + familyName: providerUser.familyName, + givenName: providerUser.givenName, + picture: providerUser.picture, + }, + }); + return prisma.user.findFirstOrThrow({ + select: userSelect, + where: { id: userId }, + }); +} + +export async function removeIdentityFromUser(userId: string, provider: OauthProviderType, providerAccountId: string) { + const { hasPasswordSet, identities } = await prisma.user.findFirstOrThrow({ + where: { id: userId }, + select: { + hasPasswordSet: true, + identities: { + select: { + provider: true, + providerAccountId: true, + }, + }, + }, + }); + + if (identities.length === 1 && !hasPasswordSet) { + throw new Error('Cannot remove the last identity without a password set'); + } + + await prisma.authIdentity.delete({ + where: { + userId, + provider_providerAccountId: { provider, providerAccountId }, + }, + }); + + return prisma.user.findFirstOrThrow({ + select: userSelect, + where: { id: userId }, + }); +} + +async function createUserFromProvider(providerUser: ProviderUser, provider: OauthProviderType) { + return prisma.user.create({ + select: userSelect, + data: { + email: providerUser.email, + // TODO: do we really get any benefit from storing this userId like this? + // TODO: only reason I can think of is user migration since the id is a UUID so we need to different identifier + userId: `${provider}|${providerUser.id}`, + name: providerUser.name, + emailVerified: providerUser.emailVerified, + // picture: providerUser.picture, + lastLoggedIn: new Date(), + preferences: { create: { skipFrontdoorLogin: false } }, + identities: { + create: { + type: 'oauth', + provider: provider, + providerAccountId: providerUser.id, + email: providerUser.email, + name: providerUser.name, + emailVerified: providerUser.emailVerified, + username: providerUser.username, + isPrimary: true, + familyName: providerUser.familyName, + givenName: providerUser.givenName, + picture: providerUser.picture, + }, + }, + authFactors: { + create: { + type: '2fa-email', + enabled: true, + }, + }, + }, + }); +} + +async function updateIdentityAttributesFromProvider(userId: string, providerUser: ProviderUser, provider: OauthProviderType) { + try { + const existingProfile = await prisma.authIdentity.findUniqueOrThrow({ + select: { + isPrimary: true, + provider: true, + providerAccountId: true, + email: true, + name: true, + emailVerified: true, + username: true, + familyName: true, + givenName: true, + picture: true, + }, + where: { + userId, + provider_providerAccountId: { provider, providerAccountId: providerUser.id }, + }, + }); + + const skipUpdate = + existingProfile.email === providerUser.email && + existingProfile.name === providerUser.name && + existingProfile.emailVerified === providerUser.emailVerified && + existingProfile.username === providerUser.username && + existingProfile.familyName === providerUser.familyName && + existingProfile.givenName === providerUser.givenName && + existingProfile.picture === providerUser.picture; + + if (skipUpdate) { + return; + } + + if (existingProfile.isPrimary && existingProfile.name !== providerUser.name) { + // TODO: what if email changed? + await prisma.user.update({ + data: { + name: providerUser.name, + identities: { + update: { + data: { + provider: provider, + providerAccountId: providerUser.id, + email: providerUser.email, + name: providerUser.name, + emailVerified: providerUser.emailVerified, + username: providerUser.username, + familyName: providerUser.familyName, + givenName: providerUser.givenName, + picture: providerUser.picture, + }, + where: { + provider_providerAccountId: { provider, providerAccountId: providerUser.id }, + }, + }, + }, + }, + where: { + id: userId, + }, + }); + } else { + await prisma.authIdentity.update({ + data: { + provider: provider, + providerAccountId: providerUser.id, + email: providerUser.email, + name: providerUser.name, + emailVerified: providerUser.emailVerified, + username: providerUser.username, + familyName: providerUser.familyName, + givenName: providerUser.givenName, + picture: providerUser.picture, + }, + where: { + userId, + provider_providerAccountId: { provider, providerAccountId: providerUser.id }, + }, + }); + } + } catch (ex) { + logger.error({ ...getErrorMessageAndStackObj(ex) }, 'Error updating identity attributes from provider'); + } +} + +async function createUserFromUserInfo(email: string, name: string, password: string) { + const passwordHash = await hashPassword(password); + return prisma.$transaction(async (tx) => { + // Create initial user + const user = await tx.user.create({ + select: userSelect, + data: { + email, + userId: `jetstream|${email}`, // this is temporary, we will update this after the user is created + name, + emailVerified: false, + password: passwordHash, + passwordUpdatedAt: new Date(), + lastLoggedIn: new Date(), + preferences: { create: { skipFrontdoorLogin: false } }, + authFactors: { + create: { + type: '2fa-email', + enabled: true, + }, + }, + }, + }); + // FIXME: do we really need a userId? Should be able to drop after Auth0 migration + // update userId to include the DB id as the second part of the userId instead of the email + return await tx.user.update({ + data: { userId: `jetstream|${user.id}` }, + where: { id: user.id }, + select: userSelect, + }); + }); +} + +export async function handleSignInOrRegistration( + payload: + | { + providerType: ProviderTypeOauth; + provider: OauthProviderType; + providerUser: ProviderUser; + } + | { + providerType: ProviderTypeCredentials; + action: 'login'; + email: string; + password: string; + } + | { + providerType: ProviderTypeCredentials; + action: 'register'; + email: string; + name: string; + password: string; + } +): Promise<{ + user: AuthenticatedUser; + providerType: ProviderTypeOauth | ProviderTypeCredentials; + provider: OauthProviderType | 'credentials'; + isNewUser: boolean; + verificationRequired: { + email: boolean; + twoFactor: { + type: string; + enabled: boolean; + }[]; + }; +}> { + try { + // see if user exists, optionally create + // potentially auto-link identities if email is verified and matches - else return error + // see if email address needs to be verified and return info if so + // else see if 2fa is enabled, if so then generate tokens and return info + + let isNewUser = false; + let user: AuthenticatedUser | null = null; + let provider: OauthProviderType | 'credentials' = 'credentials'; + + /** + * Flow for Oauth - we allow both login and registration in one flow + * + * * attempt to find a user by the provider type + provider id + * * If no match, find user by email address + * + */ + const { providerType } = payload; + if (providerType === 'oauth') { + const { providerUser } = payload; + provider = payload.provider; + // Check for existing user + user = await findUserByProviderId(provider, providerUser.id); + if (!user) { + const usersWithEmail = await findUsersByEmail(providerUser.email); + if (usersWithEmail.length > 1) { + // throw error or return error? + // tell user to login with existing account and link this identity + // TODO: we should try to prevent duplicate email addresses to avoid this complexity + throw new LoginWithExistingIdentity(); + } + if (usersWithEmail.length === 1) { + if (!usersWithEmail[0].emailVerified || !providerUser.emailVerified) { + // return error - cannot link since email addresses are not verified + throw new LoginWithExistingIdentity(); + } + // TODO: should we allow auto-linking accounts, or reject and make user login and link? + user = await addIdentityToUser(usersWithEmail[0].id, providerUser, provider); + } + } else { + // Update provider information + await updateIdentityAttributesFromProvider(user.id, providerUser, provider); + } + if (!user) { + /** + * Create user with identity + */ + user = await createUserFromProvider(providerUser, provider); + isNewUser = true; + } + } else if (providerType === 'credentials') { + const { action, email, password } = payload; + if (!password) { + throw new InvalidCredentials(); + } + + if (action === 'login') { + const userOrError = await getUserAndVerifyPassword(email, password); + if (userOrError.error) { + throw userOrError.error; + } else if (!userOrError.user) { + throw new InvalidCredentials(); + } + user = userOrError.user; + } else if (action === 'register') { + const usersWithEmail = await findUsersByEmail(email); + if (usersWithEmail.length > 0) { + throw new InvalidCredentials(); + } + user = await createUserFromUserInfo(payload.email, payload.name, password); + isNewUser = true; + } else { + throw new InvalidAction(); + } + } else { + throw new InvalidProvider(); + } + + if (!user) { + throw new InvalidCredentials(); + } + + await setLastLoginDate(user.id); + + return { + user, + isNewUser, + providerType, + provider, + verificationRequired: { + email: !user.emailVerified, + twoFactor: user.authFactors + .filter(({ enabled }) => enabled) + .sort((a, b) => { + const priority = { + '2fa-otp': 1, + '2fa-email': 2, + email: 3, + } as Record; + return (priority[a.type] || 4) - (priority[b.type] || 4); + }), + }, + }; + } catch (ex) { + throw ensureAuthError(ex, new InvalidCredentials()); + } +} + +export async function setLastLoginDate(userId: string) { + await prisma.user.update({ + select: { id: true }, + data: { lastLoggedIn: new Date() }, + where: { id: userId }, + }); +} + +/** + * This is called when a logged in user authenticates a new identity from the user profile page + */ +export async function linkIdentityToUser({ + userId, + provider, + providerUser, +}: { + userId: string; + provider: OauthProviderType; + providerUser: ProviderUser; +}) { + try { + // Check for existing user + const existingUser = await prisma.user.findFirstOrThrow({ select: userSelect, where: { id: userId } }); + const existingProviderUser = await findUserByProviderId(provider, providerUser.id); + if (existingProviderUser && existingProviderUser.id !== userId) { + // TODO: is this the correct error message? some other user already has this identity linked + throw new LoginWithExistingIdentity(); + } else if (existingProviderUser) { + // identity is already linked to this user - NO_OP + return existingUser; + } + return await addIdentityToUser(userId, providerUser, provider); + } catch (ex) { + throw ensureAuthError(ex, new InvalidCredentials()); + } +} diff --git a/libs/auth/server/src/lib/auth.errors.ts b/libs/auth/server/src/lib/auth.errors.ts new file mode 100644 index 000000000..49b1f3cc4 --- /dev/null +++ b/libs/auth/server/src/lib/auth.errors.ts @@ -0,0 +1,93 @@ +type ErrorType = + | 'AuthError' + | 'InvalidCsrfToken' + | 'InvalidCredentials' + | 'InvalidAction' + | 'InvalidParameters' + | 'InvalidProvider' + | 'InvalidSession' + | 'InvalidCaptcha' + | 'InvalidVerificationType' + | 'ExpiredVerificationToken' + | 'InvalidVerificationToken' + | 'InvalidOrExpiredResetToken' + | 'LoginWithExistingIdentity'; + +type ErrorOptions = Error | Record; + +export class AuthError extends Error { + type: ErrorType; + kind?: 'signIn' | 'error'; + + constructor(message?: string | Error, errorOptions?: ErrorOptions) { + if (message instanceof Error) { + super(undefined, { + cause: { err: message, ...(message.cause as any), ...errorOptions }, + }); + } else if (typeof message === 'string') { + if (errorOptions instanceof Error) { + errorOptions = { err: errorOptions, ...(errorOptions.cause as any) }; + } + super(message, errorOptions); + } else { + super(undefined, message); + } + this.name = this.constructor.name; + + // @ts-expect-error https://github.com/microsoft/TypeScript/issues/3841 + this.type = this.constructor.type ?? 'AuthError'; + + // @ts-expect-error https://github.com/microsoft/TypeScript/issues/3841 + this.kind = this.constructor.kind ?? 'error'; + + Error.captureStackTrace?.(this, this.constructor); + } +} + +export class InvalidCsrfToken extends AuthError { + static type: ErrorType = 'InvalidCsrfToken'; +} + +export class InvalidCredentials extends AuthError { + static type: ErrorType = 'InvalidCredentials'; +} + +export class InvalidAction extends AuthError { + static type: ErrorType = 'InvalidAction'; +} + +export class InvalidProvider extends AuthError { + static type: ErrorType = 'InvalidProvider'; +} + +export class InvalidParameters extends AuthError { + static type: ErrorType = 'InvalidParameters'; +} + +export class LoginWithExistingIdentity extends AuthError { + static type: ErrorType = 'LoginWithExistingIdentity'; +} + +export class InvalidSession extends AuthError { + static type: ErrorType = 'InvalidSession'; +} + +export class InvalidCaptcha extends AuthError { + static type: ErrorType = 'InvalidCaptcha'; +} + +export class InvalidVerificationType extends AuthError { + static type: ErrorType = 'InvalidVerificationType'; +} + +export class ExpiredVerificationToken extends AuthError { + static type: ErrorType = 'ExpiredVerificationToken'; +} + +export class InvalidVerificationToken extends AuthError { + static type: ErrorType = 'InvalidVerificationToken'; +} + +export class InvalidOrExpiredResetToken extends AuthError { + static type: ErrorType = 'InvalidOrExpiredResetToken'; +} diff --git a/libs/auth/server/src/lib/auth.service.ts b/libs/auth/server/src/lib/auth.service.ts new file mode 100644 index 000000000..87fa2ec52 --- /dev/null +++ b/libs/auth/server/src/lib/auth.service.ts @@ -0,0 +1,272 @@ +import { ENV } from '@jetstream/api-config'; +import { OauthProviderType, Providers, ResponseLocalsCookies } from '@jetstream/auth/types'; +import { parse as parseCookie } from 'cookie'; +import * as crypto from 'crypto'; +import type { Response } from 'express'; +import * as QRCode from 'qrcode'; +import { OauthClientProvider, OauthClients } from './OauthClients'; +import { AuthError, InvalidCsrfToken, InvalidVerificationToken } from './auth.errors'; +import { getCookieConfig, validateCSRFToken } from './auth.utils'; + +const oauthPromise = import('oauth4webapi'); +const osloEncodingPromise = import('@oslojs/encoding'); +const osloOtpPromise = import('@oslojs/otp'); + +export const TOTP_DIGITS = 6; +export const TOTP_INTERVAL_SEC = 30; + +export function ensureAuthError(ex: unknown, fallback?: AuthError) { + if (ex instanceof AuthError) { + return ex; + } + if (fallback) { + return fallback; + } + return new AuthError(); +} + +export function getProviders(): Providers { + return { + google: { + provider: 'google', + type: 'oauth', + label: 'Google', + icon: 'https://res.cloudinary.com/getjetstream/image/upload/v1693697889/public/google-login-icon_bzw1hi.svg', + signinUrl: `${ENV.JETSTREAM_SERVER_URL}/api/auth/signin/google`, + callbackUrl: `${ENV.JETSTREAM_SERVER_URL}/api/auth/callback/google`, + }, + salesforce: { + provider: 'salesforce', + type: 'oauth', + label: 'Salesforce', + icon: 'https://res.cloudinary.com/getjetstream/image/upload/v1724511801/salesforce-blue_qdptxw.svg', + signinUrl: `${ENV.JETSTREAM_SERVER_URL}/api/auth/signin/salesforce`, + callbackUrl: `${ENV.JETSTREAM_SERVER_URL}/api/auth/callback/salesforce`, + }, + credentials: { + provider: 'credentials', + type: 'credentials', + label: 'Jetstream', + icon: 'https://res.cloudinary.com/getjetstream/image/upload/v1634516624/public/jetstream-icon.svg', + signinUrl: `${ENV.JETSTREAM_SERVER_URL}/api/auth/signin/credentials`, + callbackUrl: `${ENV.JETSTREAM_SERVER_URL}/api/auth/callback/credentials`, + }, + // TODO: magic link + }; +} + +export function clearOauthCookies(res: Response) { + const cookieConfig = getCookieConfig(ENV.ENVIRONMENT === 'production'); + + res.locals['cookies'] = res.locals['cookies'] || {}; + const cookies = res.locals['cookies'] as ResponseLocalsCookies; + + cookies[cookieConfig.pkceCodeVerifier.name] = { + clear: true, + name: cookieConfig.pkceCodeVerifier.name, + options: cookieConfig.pkceCodeVerifier.options, + }; + + cookies[cookieConfig.state.name] = { + clear: true, + name: cookieConfig.state.name, + options: cookieConfig.state.options, + }; + + cookies[cookieConfig.nonce.name] = { + clear: true, + name: cookieConfig.nonce.name, + options: cookieConfig.nonce.options, + }; + + cookies[cookieConfig.linkIdentity.name] = { + clear: true, + name: cookieConfig.linkIdentity.name, + options: cookieConfig.linkIdentity.options, + }; + + cookies[cookieConfig.returnUrl.name] = { + clear: true, + name: cookieConfig.returnUrl.name, + options: cookieConfig.returnUrl.options, + }; + + cookies[cookieConfig.webauthnChallenge.name] = { + clear: true, + name: cookieConfig.webauthnChallenge.name, + options: cookieConfig.webauthnChallenge.options, + }; +} + +export async function getAuthorizationUrl(provider: OauthProviderType) { + const oauth = await oauthPromise; + const oauthClients = await OauthClients.getInstance(); + + const code_challenge_method = 'S256'; + /** + * The following MUST be generated for every redirect to the authorization_endpoint. You must store + * the code_verifier and nonce in the end-user session such that it can be recovered as the user + * gets redirected from the authorization server back to your application. + */ + const code_verifier = oauth.generateRandomCodeVerifier(); + const code_challenge = await oauth.calculatePKCECodeChallenge(code_verifier); + let nonce: string | undefined; + + const { authorizationServer, client } = oauthClients[provider]; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const authorizationUrl = new URL(authorizationServer.authorization_endpoint!); + authorizationUrl.searchParams.set('client_id', client.client_id); + authorizationUrl.searchParams.set('redirect_uri', `${ENV.JETSTREAM_SERVER_URL}/api/auth/callback/${provider}`); + authorizationUrl.searchParams.set('response_type', 'code'); + authorizationUrl.searchParams.set('scope', 'openid profile email'); + authorizationUrl.searchParams.set('code_challenge', code_challenge); + authorizationUrl.searchParams.set('code_challenge_method', code_challenge_method); + if (provider === 'salesforce') { + authorizationUrl.searchParams.set('prompt', 'login'); + } + if (provider === 'google') { + authorizationUrl.searchParams.set('prompt', 'select_account'); + } + + /** + * We cannot be sure the AS supports PKCE so we're going to use nonce too. Use of PKCE is + * backwards compatible even if the AS doesn't support it which is why we're using it regardless. + */ + if (authorizationServer.code_challenge_methods_supported?.includes('S256') !== true) { + nonce = oauth.generateRandomNonce(); + authorizationUrl.searchParams.set('nonce', nonce); + } + + return { + code_verifier, + code_challenge, + nonce, + authorizationUrl, + }; +} + +export async function validateCallback( + provider: OauthProviderType, + parameters: URL | URLSearchParams, + codeVerifier: string, + nonce?: string +) { + const oauthClients = await OauthClients.getInstance(); + + const clientProvider = oauthClients[provider]; + const { claims, idTokenResult } = await handleOauthCallback(clientProvider, provider, parameters, codeVerifier, nonce); + const userInfo = await getUserInfo(clientProvider, idTokenResult.access_token, claims.sub); + + return { claims, idTokenResult, userInfo }; +} + +export async function verifyCSRFFromRequestOrThrow(csrfToken: string, cookieString: string) { + try { + const cookieConfig = getCookieConfig(ENV.ENVIRONMENT === 'production'); + const cookies = parseCookie(cookieString); + const cookieValue = cookies[cookieConfig.csrfToken.name]; + const validCSRFToken = await validateCSRFToken({ + secret: ENV.JETSTREAM_AUTH_SECRET as string, + bodyValue: csrfToken, + cookieValue, + }); + + if (!validCSRFToken) { + throw new InvalidCsrfToken(); + } + } catch (ex) { + throw new InvalidCsrfToken(); + } +} + +export async function convertBase32ToHex(base32String: string) { + const { encodeHexUpperCase, decodeBase32IgnorePadding } = await osloEncodingPromise; + return encodeHexUpperCase(decodeBase32IgnorePadding(base32String)); +} + +export async function generate2faTotpSecret() { + const { encodeHexUpperCase } = await osloEncodingPromise; + return encodeHexUpperCase(crypto.getRandomValues(new Uint8Array(20))); +} + +export async function verify2faTotpOrThrow(secret: string, code: string) { + const { decodeHex } = await osloEncodingPromise; + const { verifyTOTP } = await osloOtpPromise; + console.log(decodeHex(secret)); + const validOTP = verifyTOTP(decodeHex(secret), TOTP_INTERVAL_SEC, TOTP_DIGITS, code); + if (!validOTP) { + throw new InvalidVerificationToken(); + } +} + +export async function generate2faTotpUrl(userId: string) { + const { decodeHex } = await osloEncodingPromise; + const { createTOTPKeyURI } = await osloOtpPromise; + const secret = await generate2faTotpSecret(); + const uri = createTOTPKeyURI('jetstream', userId, decodeHex(secret), TOTP_INTERVAL_SEC, TOTP_DIGITS); + const imageUri = await QRCode.toDataURL(uri); + return { secret, uri, imageUri }; +} + +export const generateRandomCode = (length = 6) => { + const max = Math.pow(10, length); + return crypto.randomInt(0, max).toString().padStart(length, '0'); +}; + +/** + * + * @param size size in bytes, default is 32 which produces a 64 character string + * @returns + */ +export const generateRandomString = (size = 32) => { + return crypto.randomBytes(size).toString('hex'); +}; + +async function handleOauthCallback( + { authorizationServer, client }: OauthClientProvider, + provider: OauthProviderType, + parameters: URL | URLSearchParams, + codeVerifier: string, + nonce?: string +) { + const oauth = await oauthPromise; + // TODO: should move to function to support other providers + const clientAuth = oauth.ClientSecretPost( + provider === 'salesforce' ? (ENV.AUTH_SFDC_CLIENT_SECRET as string) : (ENV.AUTH_GOOGLE_CLIENT_SECRET as string) + ); + const params = oauth.validateAuthResponse(authorizationServer, client, parameters); + + const response = await oauth.authorizationCodeGrantRequest( + authorizationServer, + client, + clientAuth, + params, + `${ENV.JETSTREAM_SERVER_URL}/api/auth/callback/${provider}`, + codeVerifier + ); + + const idTokenResult = await oauth.processAuthorizationCodeResponse(authorizationServer, client, response, { + expectedNonce: nonce, + requireIdToken: true, + }); + + const claims = oauth.getValidatedIdTokenClaims(idTokenResult); + + if (!claims) { + // TODO: is there a more specific error we can throw here? + throw new AuthError('Invalid claims'); + } + + return { + idTokenResult, + claims, + }; +} + +async function getUserInfo({ authorizationServer, client }: OauthClientProvider, access_token: string, sub: string) { + const oauth = await oauthPromise; + const response = await oauth.userInfoRequest(authorizationServer, client, access_token); + + const userInfo = await oauth.processUserInfoResponse(authorizationServer, client, sub, response); + return userInfo; +} diff --git a/libs/auth/server/src/lib/auth.utils.ts b/libs/auth/server/src/lib/auth.utils.ts new file mode 100644 index 000000000..9b5e03f7a --- /dev/null +++ b/libs/auth/server/src/lib/auth.utils.ts @@ -0,0 +1,189 @@ +import { CookieConfig, CreateCSRFTokenParams, ValidateCSRFTokenParams } from '@jetstream/auth/types'; +import * as bcrypt from 'bcrypt'; +import * as Bowser from 'bowser'; + +const TIME_15_MIN = 60 * 15; +const TIME_30_DAYS = 30 * 24 * 60 * 60; + +export function getCookieConfig(useSecureCookies: boolean): CookieConfig { + const cookiePrefix = useSecureCookies ? '__Secure-' : ''; + return { + callbackUrl: { + name: `${cookiePrefix}jetstream-auth.callback-url`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: useSecureCookies, + }, + }, + csrfToken: { + // Default to __Host- for CSRF token for additional protection if using useSecureCookies + // NB: The `__Host-` prefix is stricter than the `__Secure-` prefix. + name: `${useSecureCookies ? '__Host-' : ''}jetstream-auth.csrf-token`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: useSecureCookies, + }, + }, + pkceCodeVerifier: { + name: `${cookiePrefix}jetstream-auth.pkce.code_verifier`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: useSecureCookies, + maxAge: TIME_15_MIN, + }, + }, + linkIdentity: { + name: `${cookiePrefix}jetstream-auth.link-identity`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: useSecureCookies, + maxAge: TIME_15_MIN, + }, + }, + state: { + name: `${cookiePrefix}jetstream-auth.state`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: useSecureCookies, + maxAge: TIME_15_MIN, + }, + }, + nonce: { + name: `${cookiePrefix}jetstream-auth.nonce`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: useSecureCookies, + }, + }, + returnUrl: { + name: `${cookiePrefix}jetstream-auth.return-url`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: useSecureCookies, + maxAge: TIME_15_MIN, + }, + }, + webauthnChallenge: { + name: `${cookiePrefix}jetstream-auth.challenge`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: useSecureCookies, + maxAge: TIME_15_MIN, + }, + }, + rememberDevice: { + name: `${cookiePrefix}jetstream-auth.remember-device`, + options: { + httpOnly: true, + // Development is on a different port, using lax for sameSite ensures the cookie is sent + sameSite: useSecureCookies ? 'strict' : 'lax', + path: '/', + secure: useSecureCookies, + maxAge: TIME_30_DAYS, + }, + }, + } as const; +} + +export async function hashPassword(password: string): Promise { + try { + const saltRounds = 10; + const salt = await bcrypt.genSalt(saltRounds); + const hashedPassword = await bcrypt.hash(password, salt); + return hashedPassword; + } catch (error) { + throw new Error('Error hashing the password'); + } +} + +export async function verifyPassword(password: string, hashedPassword: string): Promise { + try { + const isMatch = await bcrypt.compare(password, hashedPassword); + return isMatch; + } catch (error) { + throw new Error('Error verifying the password'); + } +} + +export async function createHash(message: string) { + const data = new TextEncoder().encode(message); + const hash = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .toString(); +} + +export function randomString(size: number) { + const i2hex = (i: number) => ('0' + i.toString(16)).slice(-2); + const r = (a: string, i: number): string => a + i2hex(i); + const bytes = crypto.getRandomValues(new Uint8Array(size)); + return Array.from(bytes).reduce(r, ''); +} + +export async function createCSRFToken({ secret }: CreateCSRFTokenParams) { + if (!secret) { + throw new Error('Secret is required to create a CSRF token'); + } + + const csrfToken = randomString(32); + const csrfTokenHash = await createHash(`${csrfToken}${secret}`); + const cookie = `${csrfToken}|${csrfTokenHash}`; + + return { cookie, csrfToken }; +} + +/** + * Verify that the provided Csrf token matches the same token stored in the http only cookie + */ +export async function validateCSRFToken({ secret, cookieValue, bodyValue }: ValidateCSRFTokenParams): Promise { + if (!cookieValue) { + return false; + } + const [csrfToken, csrfTokenHash] = cookieValue.split('|'); + + const expectedCsrfTokenHash = await createHash(`${csrfToken}${secret}`); + + if (csrfTokenHash !== expectedCsrfTokenHash) { + return false; + } + + const csrfTokenVerified = csrfToken === bodyValue; + + if (!csrfTokenVerified) { + return false; + } + + return true; +} + +/** + * Compares two user agents to make sure they are similar enough to be valid + * This is used to ensure that sessions are not hijacked by a different browser + */ +export function checkUserAgentSimilarity(sessionUserAgent: string, currentUserAgent: string): boolean { + const sessionUA = Bowser.getParser(sessionUserAgent); + return ( + Bowser.getParser(currentUserAgent).satisfies({ + [sessionUA.getOSName(true)]: { + [sessionUA.getBrowserName(true)]: `>=${sessionUA.getBrowserVersion()}`, + }, + }) === true + ); +} diff --git a/libs/auth/server/tsconfig.json b/libs/auth/server/tsconfig.json new file mode 100644 index 000000000..8122543a9 --- /dev/null +++ b/libs/auth/server/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/auth/server/tsconfig.lib.json b/libs/auth/server/tsconfig.lib.json new file mode 100644 index 000000000..e583571ea --- /dev/null +++ b/libs/auth/server/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/libs/auth/server/tsconfig.spec.json b/libs/auth/server/tsconfig.spec.json new file mode 100644 index 000000000..b2ee74a6b --- /dev/null +++ b/libs/auth/server/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/auth/types/.eslintrc.json b/libs/auth/types/.eslintrc.json new file mode 100644 index 000000000..3456be9b9 --- /dev/null +++ b/libs/auth/types/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/auth/types/README.md b/libs/auth/types/README.md new file mode 100644 index 000000000..350f43615 --- /dev/null +++ b/libs/auth/types/README.md @@ -0,0 +1,3 @@ +# auth-types + +This library was generated with [Nx](https://nx.dev). diff --git a/libs/auth/types/project.json b/libs/auth/types/project.json new file mode 100644 index 000000000..67c4d7d37 --- /dev/null +++ b/libs/auth/types/project.json @@ -0,0 +1,9 @@ +{ + "name": "auth-types", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/auth/types/src", + "projectType": "library", + "tags": ["types", "scope:any"], + "// targets": "to see all targets run: nx show project auth-types --web", + "targets": {} +} diff --git a/libs/auth/types/src/index.ts b/libs/auth/types/src/index.ts new file mode 100644 index 000000000..71af89f23 --- /dev/null +++ b/libs/auth/types/src/index.ts @@ -0,0 +1 @@ +export * from './lib/auth-types'; diff --git a/libs/auth/types/src/lib/auth-types.ts b/libs/auth/types/src/lib/auth-types.ts new file mode 100644 index 000000000..fb80c96b7 --- /dev/null +++ b/libs/auth/types/src/lib/auth-types.ts @@ -0,0 +1,275 @@ +import { Maybe } from '@jetstream/types'; +import type { CookieSerializeOptions } from 'cookie'; +import { z } from 'zod'; + +// TODO: we might want a SessionUser (this) and a UserProfile with a few extra fields, like photo +export interface UserProfile { + id: string; + userId: string; + name: string; + email: string; + emailVerified: boolean; +} + +export interface UserProfileSession extends UserProfile { + authFactors?: UserProfileAuthFactor[]; +} + +export interface UserProfileUiWithIdentities extends UserProfile { + name: string; + picture: string | null; + createdAt: Date; + updatedAt: Date; + hasPasswordSet: boolean; + preferences: { + id: string; + userId: string; + createdAt: Date; + updatedAt: Date; + skipFrontdoorLogin: boolean; + } | null; + identities: UserProfileIdentity[]; + authFactors: UserProfileAuthFactor[]; +} + +export interface UserProfileIdentity { + type: 'oauth' | 'credentials'; + email: string; + emailVerified: boolean; + familyName: string; + givenName: string; + name: string; + picture: string | null; + provider: OauthAndLocalProviders; + providerAccountId: string; + username: string; + isPrimary: boolean; + createdAt: string; + updatedAt: string; +} + +export interface UserProfileAuthFactor { + type: TwoFactorType; + enabled: boolean; + createdAt?: string; + updatedAt?: string; +} + +export interface UserSession { + sessionId: string; + expires: string; + userAgent: string; + ipAddress: string; + provider: OauthProviderType | 'credentials'; + loginTime: string; +} + +export interface UserSessionWithLocation extends UserSession { + location?: SessionIpData; +} + +export type TwoFactorTypeEmail = 'email'; +export type TwoFactorTypeOtp = '2fa-otp'; +export type TwoFactorTypeOtpEmail = '2fa-email'; + +export type TwoFactorTypeWithoutEmail = TwoFactorTypeOtp | TwoFactorTypeOtpEmail; +export type TwoFactorType = TwoFactorTypeEmail | TwoFactorTypeOtp | TwoFactorTypeOtpEmail; + +export interface SessionData { + user: UserProfileSession; + csrfToken: string; + pendingVerification?: Array< + | { + type: TwoFactorTypeEmail; + token: string; + exp: number; + } + | { + type: TwoFactorTypeOtp; + // secret: string; + exp: number; + } + | { + type: TwoFactorTypeOtpEmail; + token: string; + exp: number; + } + > | null; + loginTime: number; + provider: OauthProviderType | 'credentials'; + // TODO: lastActivity: number; + ipAddress: string; + userAgent: string; + sendNewUserEmailAfterVerify?: boolean; + orgAuth?: { code_verifier: string; nonce: string; state: string; loginUrl: string; jetstreamOrganizationId?: Maybe }; +} + +export interface SessionIpSuccess { + status: 'success'; + country: string; + countryCode: string; + region: string; + regionName: string; + city: string; + isp: string; + query: string; +} + +export interface SessionIpFail { + status: 'fail'; + query: string; +} + +export type SessionIpData = SessionIpSuccess | SessionIpFail; + +export interface ProviderUser { + id: string; + email: string; + emailVerified: boolean; + username: string; + name: string; + givenName?: Maybe; + familyName?: Maybe; + picture?: Maybe; +} + +export type AuthenticatedUser = { + id: string; + userId: string; + name: string; + email: string; + emailVerified: boolean; + authFactors: { + type: string; + enabled: boolean; + }[]; +}; + +export interface CreateCSRFTokenParams { + secret: string; +} + +export interface ValidateCSRFTokenParams { + secret: string; + cookieValue?: string; + bodyValue?: string; +} + +export type PartitionCookieConstraint = + | { + partition: true; + secure: true; + } + | { + partition?: boolean; + secure?: boolean; + }; + +export type CookieOptions = { + signingSecret?: string; // TODO: + sameSite?: 'Strict' | 'Lax' | 'None' | 'strict' | 'lax' | 'none'; + partitioned?: boolean; + prefix?: CookiePrefixOptions; +} & Omit & + PartitionCookieConstraint; + +export type SecureCookieConstraint = { + secure: true; +}; +export type HostCookieConstraint = { + secure: true; + path: '/'; + domain?: undefined; +}; +export type CookiePrefixOptions = 'host' | 'secure'; + +type CallbackUrlCookie = 'callbackUrl'; +type CsrfTokenCookie = 'csrfToken'; +type PkceCodeVerifierCookie = 'pkceCodeVerifier'; +type LinkIdentityCookie = 'linkIdentity'; +type ReturnUrlCookie = 'returnUrl'; +type StateCookie = 'state'; +type NonceCookie = 'nonce'; +type WebauthnChallengeCookie = 'webauthnChallenge'; +type RememberDeviceCookie = 'rememberDevice'; + +type CookieConfigKey = + | CallbackUrlCookie + | CsrfTokenCookie + | PkceCodeVerifierCookie + | LinkIdentityCookie + | ReturnUrlCookie + | StateCookie + | NonceCookie + | WebauthnChallengeCookie + | RememberDeviceCookie; + +type cookieNamePrefix = '__Host-' | '__Secure-' | ''; + +export type CookieConfig = Record; + +export type ResponseLocalsCookies = Record< + string, + | { + name: string; + clear: true; + value?: never; + options: CookieOptions; + } + | { + name: string; + clear?: false; + value: string; + options: CookieOptions; + } +>; + +// TODO: some of these type names/structures seem convoluted + +export const ProviderTypeOauthSchema = z.literal('oauth'); +export const ProviderTypeCredentialsSchema = z.literal('credentials'); +export const ProviderTypeSchema = z.union([ProviderTypeOauthSchema, ProviderTypeCredentialsSchema]); + +export type ProviderTypeOauth = z.infer; +export type ProviderTypeCredentials = z.infer; +export type ProviderType = z.infer; + +export const OauthProviderTypeSchema = z.enum(['salesforce', 'google']); +export const LocalProviderTypeSchema = z.enum(['credentials']); + +export type OauthProviderType = z.infer; +export type LocalProviderType = z.infer; +export type OauthAndLocalProviders = OauthProviderType | LocalProviderType; + +// TODO: could do discriminated union? +export const ProviderBaseSchema = z.object({ + label: z.string(), + icon: z.string().url(), + signinUrl: z.string().url(), + callbackUrl: z.string().url(), +}); + +export const OauthProviderSchema = ProviderBaseSchema.extend({ + provider: OauthProviderTypeSchema, + type: ProviderTypeOauthSchema, +}); + +export const CredentialProviderSchema = ProviderBaseSchema.extend({ + provider: LocalProviderTypeSchema, + type: ProviderTypeCredentialsSchema, +}); + +const ProviderSchema = z.discriminatedUnion('type', [OauthProviderSchema, CredentialProviderSchema]); + +export type Provider = z.infer; + +export const ProvidersSchema = z.object({ + google: ProviderSchema, + salesforce: ProviderSchema, + credentials: ProviderSchema, +}); + +export type Providers = z.infer; + +export const ProviderKeysSchema = ProvidersSchema.keyof(); +export type ProviderKeys = z.infer; diff --git a/libs/auth/types/tsconfig.json b/libs/auth/types/tsconfig.json new file mode 100644 index 000000000..f2400abed --- /dev/null +++ b/libs/auth/types/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libs/auth/types/tsconfig.lib.json b/libs/auth/types/tsconfig.lib.json new file mode 100644 index 000000000..8f9c818ee --- /dev/null +++ b/libs/auth/types/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/email/.babelrc b/libs/email/.babelrc new file mode 100644 index 000000000..fd4cbcdef --- /dev/null +++ b/libs/email/.babelrc @@ -0,0 +1,10 @@ +{ + "presets": [ + [ + "@nx/js/babel", + { + "useBuiltIns": "usage" + } + ] + ] +} diff --git a/libs/email/.eslintrc.json b/libs/email/.eslintrc.json new file mode 100644 index 000000000..9d9c0db55 --- /dev/null +++ b/libs/email/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/email/jest.config.ts b/libs/email/jest.config.ts new file mode 100644 index 000000000..de6feb8e5 --- /dev/null +++ b/libs/email/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'email', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/libs/email', +}; diff --git a/libs/email/project.json b/libs/email/project.json new file mode 100644 index 000000000..99f1c6137 --- /dev/null +++ b/libs/email/project.json @@ -0,0 +1,16 @@ +{ + "name": "email", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/email/src", + "projectType": "library", + "tags": [], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/email/jest.config.ts" + } + } + } +} diff --git a/libs/email/src/index.ts b/libs/email/src/index.ts new file mode 100644 index 000000000..813b6d872 --- /dev/null +++ b/libs/email/src/index.ts @@ -0,0 +1 @@ +export * from './lib/email'; diff --git a/libs/email/src/lib/components/EmailFooter.tsx b/libs/email/src/lib/components/EmailFooter.tsx new file mode 100644 index 000000000..c098600a2 --- /dev/null +++ b/libs/email/src/lib/components/EmailFooter.tsx @@ -0,0 +1,62 @@ +import { Section, Text } from '@react-email/components'; +import * as React from 'react'; + +export const EmailFooter = () => { + return ( +
    + + {/* + + */} + + + + + + +
    + Jetstream logo +
    + + Jetstream Solutions LLC + +
    + + Whitefish, MT USA + + + support@getjetstream.app + +
    +
    + ); +}; diff --git a/libs/email/src/lib/email-templates/auth/AuthenticationChangeConfirmationEmail.tsx b/libs/email/src/lib/email-templates/auth/AuthenticationChangeConfirmationEmail.tsx new file mode 100644 index 000000000..955051095 --- /dev/null +++ b/libs/email/src/lib/email-templates/auth/AuthenticationChangeConfirmationEmail.tsx @@ -0,0 +1,62 @@ +import { Body, Container, Head, Heading, Html, Img, Preview, Section, Text } from '@react-email/components'; +import * as React from 'react'; +import { EmailFooter } from '../../components/EmailFooter'; +import { EMAIL_STYLES } from '../../shared-styles'; + +export interface AuthenticationChangeConfirmationEmailProps { + preview: string; + heading: string; + additionalTextSegments?: string[]; +} + +export const AuthenticationChangeConfirmationEmail = ({ + preview, + heading, + additionalTextSegments, +}: AuthenticationChangeConfirmationEmailProps) => { + return ( + + + {preview} + + + Jetstream + {heading} + + {!!additionalTextSegments?.length && ( +
    + {additionalTextSegments.map((text, index) => ( + + {text} + + ))} +
    + )} + + Didn't request this? + You should immediately reset your password or login and ensure your account is secure. + + Contact Jetstream Support if you need further assistance. + +
    + + + + ); +}; + +export default AuthenticationChangeConfirmationEmail; + +const sectionText: React.CSSProperties = { + margin: '0px', + fontSize: 14, + fontWeight: 500, + lineHeight: '16px', + color: '#111827', + marginBottom: 16, +}; diff --git a/libs/email/src/lib/email-templates/auth/GenericEmail.tsx b/libs/email/src/lib/email-templates/auth/GenericEmail.tsx new file mode 100644 index 000000000..31f969535 --- /dev/null +++ b/libs/email/src/lib/email-templates/auth/GenericEmail.tsx @@ -0,0 +1,75 @@ +import { Body, Container, Head, Heading, Html, Img, Preview, Section, Text } from '@react-email/components'; +import * as React from 'react'; +import { EmailFooter } from '../../components/EmailFooter'; +import { EMAIL_STYLES } from '../../shared-styles'; + +interface GenericEmailProps { + preview: string; + heading: string; + segments: string[]; +} + +export const GenericEmail = ({ preview, heading, segments }: GenericEmailProps) => ( + + + {preview} + + + Jetstream + {heading} + +
    + {segments.map((text, index) => ( + + {text} + + ))} +
    +
    + + + +); + +export default GenericEmail; + +GenericEmail.PreviewProps = { + preview: 'Test preview', + heading: 'Some fancy heading', + segments: ['can you do xyz?', 'yes, we can do xyz!'], +} as GenericEmailProps; + +const main: React.CSSProperties = { + backgroundColor: '#ffffff', + fontFamily: + '-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol', +}; + +const container: React.CSSProperties = { + backgroundColor: '#ffffff', + border: '1px solid #ddd', + borderRadius: '5px', + marginTop: '20px', + width: '710px', + maxWidth: '100%', + margin: '0 auto', + padding: '5% 3%', +}; + +const title: React.CSSProperties = { + textAlign: 'center' as const, +}; + +const sectionText: React.CSSProperties = { + margin: '0px', + fontSize: 14, + fontWeight: 500, + lineHeight: '16px', + color: '#111827', + marginBottom: 16, +}; diff --git a/libs/email/src/lib/email-templates/auth/PasswordResetConfirmationEmail.tsx b/libs/email/src/lib/email-templates/auth/PasswordResetConfirmationEmail.tsx new file mode 100644 index 000000000..f52e10a44 --- /dev/null +++ b/libs/email/src/lib/email-templates/auth/PasswordResetConfirmationEmail.tsx @@ -0,0 +1,33 @@ +import { Body, Container, Head, Heading, Html, Img, Preview, Text } from '@react-email/components'; +import * as React from 'react'; +import { EmailFooter } from '../../components/EmailFooter'; +import { EMAIL_STYLES } from '../../shared-styles'; + +export const PasswordResetConfirmationEmail = () => { + return ( + + + Your password has been reset + + + Jetstream + Your password has been successfully reset + + Didn't request this? + + You should immediately reset your password, contact Jetstream Support if you need + further assistance. + + + + + + ); +}; + +export default PasswordResetConfirmationEmail; diff --git a/libs/email/src/lib/email-templates/auth/PasswordResetEmail.tsx b/libs/email/src/lib/email-templates/auth/PasswordResetEmail.tsx new file mode 100644 index 000000000..42c73cbf7 --- /dev/null +++ b/libs/email/src/lib/email-templates/auth/PasswordResetEmail.tsx @@ -0,0 +1,70 @@ +import { Body, Button, Container, Head, Heading, Html, Img, Link, Preview, Section, Text } from '@react-email/components'; +import * as React from 'react'; +import { EmailFooter } from '../../components/EmailFooter'; +import { EMAIL_STYLES } from '../../shared-styles'; + +interface PasswordResetEmailProps { + baseUrl?: string; + emailAddress: string; + validationCode: string; + expMinutes: number; +} + +export const PasswordResetEmail = ({ + baseUrl = 'https://getjetstream.app', + emailAddress, + validationCode, + expMinutes, +}: PasswordResetEmailProps) => { + const url = `${baseUrl}/auth/password-reset/verify?email=${emailAddress}&code=${validationCode}`; + + return ( + + + Reset your password with Jetstream + + + Jetstream + Reset your password + + + Follow the link below to finish resetting your password. This link will expire in {expMinutes} minutes. + + +
    + +
    + + Having issues with the button above? + + + Use this link{' '} + + {url} + + . + + + Didn't request this? + If you didn't make this request, you can safely ignore this email. +
    + + + + ); +}; + +export default PasswordResetEmail; + +PasswordResetEmail.PreviewProps = { + validationCode: '123456', + emailAddress: 'test-long-name@some-long-email-address.com', + expMinutes: 10, +} as PasswordResetEmailProps; diff --git a/libs/email/src/lib/email-templates/auth/TwoStepVerificationEmail.tsx b/libs/email/src/lib/email-templates/auth/TwoStepVerificationEmail.tsx new file mode 100644 index 000000000..26ddc3f71 --- /dev/null +++ b/libs/email/src/lib/email-templates/auth/TwoStepVerificationEmail.tsx @@ -0,0 +1,46 @@ +import { Body, Container, Head, Heading, Html, Img, Preview, Section, Text } from '@react-email/components'; +import * as React from 'react'; +import { EmailFooter } from '../../components/EmailFooter'; +import { EMAIL_STYLES } from '../../shared-styles'; + +interface TwoStepVerificationEmailProps { + validationCode: string; + expMinutes: number; +} + +export const TwoStepVerificationEmail = ({ validationCode, expMinutes }: TwoStepVerificationEmailProps) => ( + + + Verify your identity with Jetstream - {validationCode} + + + Jetstream + Verification code + + + Enter this code in your open browser window. This code will expire in {expMinutes} minutes. + + +
    + {validationCode} +
    + + Didn't request this? + If you didn't make this request, you can safely ignore this email. +
    + + + +); + +export default TwoStepVerificationEmail; + +TwoStepVerificationEmail.PreviewProps = { + validationCode: '123456', + expMinutes: 10, +} as TwoStepVerificationEmailProps; diff --git a/libs/email/src/lib/email-templates/auth/VerifyEmail.tsx b/libs/email/src/lib/email-templates/auth/VerifyEmail.tsx new file mode 100644 index 000000000..811fba785 --- /dev/null +++ b/libs/email/src/lib/email-templates/auth/VerifyEmail.tsx @@ -0,0 +1,53 @@ +import { Body, Button, Container, Head, Heading, Html, Img, Preview, Section, Text } from '@react-email/components'; +import * as React from 'react'; +import { EmailFooter } from '../../components/EmailFooter'; +import { EMAIL_STYLES } from '../../shared-styles'; + +interface VerifyEmailProps { + baseUrl?: string; + validationCode: string; + expMinutes: number; +} + +export const VerifyEmail = ({ baseUrl = 'https://getjetstream.app', validationCode, expMinutes }: VerifyEmailProps) => ( + + + Verify your email address with Jetstream - {validationCode} + + + Jetstream + Verify your email address + + + Enter this code in your open browser window or press the button below. This code will expire in {expMinutes} minutes. + + +
    + {validationCode} +
    + +
    + +
    + + Didn't request this? + If you didn't make this request, you can safely ignore this email. +
    + + + +); + +export default VerifyEmail; + +VerifyEmail.PreviewProps = { + validationCode: '123456', + expMinutes: 10, +} as VerifyEmailProps; diff --git a/libs/email/src/lib/email-templates/auth/WelcomeEmail.tsx b/libs/email/src/lib/email-templates/auth/WelcomeEmail.tsx new file mode 100644 index 000000000..bf05c13a1 --- /dev/null +++ b/libs/email/src/lib/email-templates/auth/WelcomeEmail.tsx @@ -0,0 +1,245 @@ +import { Body, Column, Container, Head, Heading, Hr, Html, Img, Link, Preview, Row, Section, Text } from '@react-email/components'; +import * as React from 'react'; +import { EmailFooter } from '../../components/EmailFooter'; +import { EMAIL_STYLES } from '../../shared-styles'; + +export const WelcomeEmail = () => ( + + + Welcome to Jetstream 🚀 + + + Jetstream + We’re excited to welcome you to Jetstream! + + We’d love to hear from you! Share your thoughts on Jetstream. + +
      +
    • + Send us an email +
    • +
    • + Join the conversation on Discord +
    • +
    • + Request a feature on Github +
    • +
    + +
    + +
    +
    + + Amazing Features + + Jetstream offers an advanced suite of tools which make working on Salesforce more productive and enjoyable. + + +
    +
    +
    + + {getFeatures().map((feature, index) => ( + +
    + + + heart icon + + + {feature.title} + {feature.content.map((content, index) => ( + + {content} + + ))} + + +
    +
    +
    + ))} +
    +
    +
    + + + +); + +export default WelcomeEmail; + +function getFeatures() { + return [ + { + image: 'https://res.cloudinary.com/getjetstream/image/upload/c_scale,w_40/v1634490318/public/email/query.png', + title: 'Query Records', + content: [ + 'Jetstream simplifies exploring records in your org.', + <> + Use the most advanced query builder to easily explore your data model and quickly find the + records you are looking for. + , + ], + }, + { + image: 'https://res.cloudinary.com/getjetstream/image/upload/c_scale,w_40/v1634490318/public/email/load.png', + title: 'Load Records', + content: [ + 'Easily update your record data with Jetstream.', + <> + Jetstream’s data loader is simple, powerful, and has no usage limits. + , + <> + You can also load related data across multiple objects simultaneously. Say goodbye to using + complicated VLOOKUPS in Excel to load related data into Salesforce. + , + ], + }, + { + image: 'https://res.cloudinary.com/getjetstream/image/upload/c_scale,w_40/v1634490318/public/email/automation.png', + title: 'Automation Control', + content: [ + `Easily review and toggle automation in your org.`, + <> + Use Jetstream's Automation Control to view and toggle automation in your org. Use this if you + need to temporarily disable automation prior to a data load. + , + ], + }, + { + image: 'https://res.cloudinary.com/getjetstream/image/upload/c_scale,w_40/v1634490318/public/email/permissions.png', + title: 'Permission Manager', + content: [ + 'Updating field and object permissions has never been easier.', + <> + Easily view and toggle field and object permissions across many objects for multiple profiles + and permission sets at once. + , + ], + }, + { + image: 'https://res.cloudinary.com/getjetstream/image/upload/c_scale,w_40/v1634490318/public/email/deploy.png', + title: 'Metadata Tools', + content: [ + 'Jetstream offers a versatile set of metadata tools.', + <> + Deploy metadata between orgs. + , + <> + Compare metadata between orgs. + , + <> + Add metadata to an outbound changeset. + , + <> + Download metadata locally as a backup or make changes and re-deploy. + , + ], + }, + { + image: 'https://res.cloudinary.com/getjetstream/image/upload/c_scale,w_40/v1634490318/public/email/developer.png', + title: 'Developer Tools', + content: [ + 'Replace the Developer Console with Jetstream.', + <> + Easily execute anonymous Apex. + , + <> + View debug logs from your org. + , + <> + Submit API requests using the Salesforce API. + , + <> + Subscribe to and publish Platform Events. + , + ], + }, + ]; +} + +const main: React.CSSProperties = { + backgroundColor: '#ffffff', + fontFamily: + '-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol', +}; + +const container: React.CSSProperties = { + backgroundColor: '#ffffff', + border: '1px solid #ddd', + borderRadius: '5px', + marginTop: '20px', + width: '710px', + maxWidth: '100%', + margin: '0 auto', + padding: '5% 3%', +}; + +const title: React.CSSProperties = { + textAlign: 'center' as const, +}; + +const description: React.CSSProperties = { + textAlign: 'left' as const, + fontSize: 16, +}; + +const mainHeading: React.CSSProperties = { + margin: '0px', + fontSize: 24, + lineHeight: '32px', + fontWeight: 600, + color: '#111827', +}; + +const sectionHeading: React.CSSProperties = { + margin: '0px', + fontSize: 20, + fontWeight: 600, + lineHeight: '28px', + color: '#111827', +}; + +const SectionDetail: React.CSSProperties = { + margin: '0px', + marginTop: 8, + fontSize: 16, + lineHeight: '24px', + color: '#111827', +}; + +const SectionDetailBold: React.CSSProperties = { + ...SectionDetail, + fontWeight: 500, +}; + +const textHighlight: React.CSSProperties = { + color: '#0176d3', +}; + +const image: React.CSSProperties = { + maxWidth: '100%', + verticalAlign: 'middle', + lineHeight: '100%', + border: '0', + borderRadius: '4px', + backgroundColor: '#0176d3', +}; + +const horizontalRule: React.CSSProperties = { + marginLeft: '0px', + marginRight: '0px', + marginTop: 32, + marginBottom: 32, + width: '100%', + borderWidth: 1, + borderStyle: 'solid', + borderColor: 'rgb(209,213,219) !important', +}; diff --git a/libs/email/src/lib/email.tsx b/libs/email/src/lib/email.tsx new file mode 100644 index 000000000..1d9e454cb --- /dev/null +++ b/libs/email/src/lib/email.tsx @@ -0,0 +1,168 @@ +import type { EmotionJSX } from '@emotion/react/types/jsx-namespace'; +import { ENV, logger, sendEmail } from '@jetstream/api-config'; +import { getErrorMessageAndStackObj } from '@jetstream/shared/utils'; +import { Maybe } from '@jetstream/types'; +import { render } from '@react-email/render'; +import React from 'react'; +import { + AuthenticationChangeConfirmationEmail, + AuthenticationChangeConfirmationEmailProps, +} from './email-templates/auth/AuthenticationChangeConfirmationEmail'; +import { GenericEmail } from './email-templates/auth/GenericEmail'; +import { PasswordResetConfirmationEmail } from './email-templates/auth/PasswordResetConfirmationEmail'; +import { PasswordResetEmail } from './email-templates/auth/PasswordResetEmail'; +import { TwoStepVerificationEmail } from './email-templates/auth/TwoStepVerificationEmail'; +import { VerifyEmail } from './email-templates/auth/VerifyEmail'; +import { WelcomeEmail } from './email-templates/auth/WelcomeEmail'; +/** + * + * TODO: + * Is there any benefit of sending these via mailgun instead of just SMTP directly? + */ + +function renderComponent(component: EmotionJSX.Element) { + return Promise.all([render(component, { plainText: false }), render(component, { plainText: true })]); +} + +export async function sendUserFeedbackEmail( + emailAddress: string, + userId: string, + content: string, + attachment?: { data: Buffer; filename: string }[] +) { + try { + const component = ; + const [html, text] = await renderComponent(component); + + await sendEmail({ + to: emailAddress, + subject: 'User submitted feedback', + text, + html, + attachment, + }); + } catch (error) { + logger.error({ ...getErrorMessageAndStackObj(error) }, 'Error sending user feedback email'); + } +} + +export async function sendWelcomeEmail(emailAddress: string) { + try { + const component = ; + const [html, text] = await renderComponent(component); + + await sendEmail({ + to: emailAddress, + subject: 'Welcome to Jetstream', + text, + html, + }); + } catch (error) { + logger.error({ ...getErrorMessageAndStackObj(error) }, 'Error sending welcome email'); + } +} + +export async function sendGoodbyeEmail(emailAddress: string) { + const component = ( + + ); + const [html, text] = await renderComponent(component); + + await sendEmail({ + to: emailAddress, + subject: `We're sorry to see you go!`, + text, + html, + }); +} + +export async function sendInternalAccountDeletionEmail(userId: string, reason?: Maybe) { + const component = ( + + ); + const [html, text] = await renderComponent(component); + + await sendEmail({ + to: ENV.JETSTREAM_EMAIL_REPLY_TO, + subject: `Jetstream account deleted`, + text, + html, + }); +} + +export async function sendEmailVerification(emailAddress: string, code: string) { + const component = ; + const [html, text] = await renderComponent(component); + + await sendEmail({ + to: emailAddress, + subject: 'Verify your email on Jetstream', + text, + html, + }); +} + +export async function sendVerificationCode(emailAddress: string, code: string) { + const component = ; + const [html, text] = await renderComponent(component); + + await sendEmail({ + to: emailAddress, + subject: 'Verify your identity on Jetstream', + text, + html, + }); +} + +export async function sendPasswordReset(emailAddress: string, code: string) { + const component = ( + + ); + const [html, text] = await renderComponent(component); + + await sendEmail({ + to: emailAddress, + subject: 'Reset your password on Jetstream', + text, + html, + }); +} + +export async function sendPasswordResetConfirmation(emailAddress: string) { + const component = ; + const [html, text] = await renderComponent(component); + + await sendEmail({ + to: emailAddress, + subject: 'Jetstream password reset confirmation', + text, + html, + }); +} + +export async function sendAuthenticationChangeConfirmation( + emailAddress: string, + subject: string, + props: AuthenticationChangeConfirmationEmailProps +) { + const component = ; + const [html, text] = await renderComponent(component); + + await sendEmail({ + to: emailAddress, + subject, + text, + html, + }); +} diff --git a/libs/email/src/lib/shared-styles.ts b/libs/email/src/lib/shared-styles.ts new file mode 100644 index 000000000..625057875 --- /dev/null +++ b/libs/email/src/lib/shared-styles.ts @@ -0,0 +1,102 @@ +const main: React.CSSProperties = { + backgroundColor: '#ffffff', + // fontFamily: 'HelveticaNeue,Helvetica,Arial,sans-serif', + fontFamily: + '-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol', + textAlign: 'center' as const, +}; + +const logo: React.CSSProperties = { + margin: '0 auto', +}; + +const container: React.CSSProperties = { + backgroundColor: '#ffffff', + border: '1px solid #ddd', + borderRadius: '5px', + marginTop: '20px', + width: '480px', + maxWidth: '100%', + margin: '0 auto', + padding: '5% 3%', +}; + +const codeTitle: React.CSSProperties = { + textAlign: 'center' as const, +}; + +const codeDescription: React.CSSProperties = { + textAlign: 'center' as const, +}; + +const codeContainer: React.CSSProperties = { + background: 'rgba(0,0,0,.05)', + borderRadius: '4px', + margin: '16px auto 14px', + verticalAlign: 'middle', + width: '280px', + maxWidth: '100%', +}; + +const codeStyle: React.CSSProperties = { + color: '#000', + display: 'inline-block', + paddingBottom: '8px', + paddingTop: '8px', + margin: '0 auto', + width: '100%', + textAlign: 'center' as const, + letterSpacing: '8px', +}; + +const buttonContainer: React.CSSProperties = { + margin: '27px auto', + width: 'auto', +}; + +const button: React.CSSProperties = { + backgroundColor: '#0176d3', + borderRadius: '4px', + fontWeight: '400', + color: '#fff', + textAlign: 'center' as const, + padding: '12px 24px', + margin: '0 auto', +}; + +const paragraphHeading: React.CSSProperties = { + color: '#444', + letterSpacing: '0', + padding: '0 40px', + margin: '5px', + textAlign: 'center' as const, + fontWeight: '600', +}; + +const paragraph: React.CSSProperties = { + color: '#444', + letterSpacing: '0', + padding: '0 40px', + margin: '0', + textAlign: 'center' as const, +}; + +const link: React.CSSProperties = { + color: '#444', + textDecoration: 'underline', +}; + +export const EMAIL_STYLES = { + main, + logo, + container, + codeTitle, + codeDescription, + codeContainer, + codeStyle, + buttonContainer, + button, + paragraphHeading, + paragraph, + link, +} as const; diff --git a/libs/email/tsconfig.json b/libs/email/tsconfig.json new file mode 100644 index 000000000..2d0e66147 --- /dev/null +++ b/libs/email/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "jsx": "react", + "esModuleInterop": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/email/tsconfig.lib.json b/libs/email/tsconfig.lib.json new file mode 100644 index 000000000..9986a3ec1 --- /dev/null +++ b/libs/email/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/lib/email.tsx"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/email/tsconfig.spec.json b/libs/email/tsconfig.spec.json new file mode 100644 index 000000000..f6d8ffcc9 --- /dev/null +++ b/libs/email/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/features/organizations/src/lib/OrganizationCard.tsx b/libs/features/organizations/src/lib/OrganizationCard.tsx index 9b61f1de7..24376bfc0 100644 --- a/libs/features/organizations/src/lib/OrganizationCard.tsx +++ b/libs/features/organizations/src/lib/OrganizationCard.tsx @@ -67,6 +67,7 @@ export function OrganizationCard({ > = {}; /** Use for API calls going to external locations */ export async function handleExternalRequest(config: AxiosRequestConfig): Promise> { const axiosInstance = axios.create({ ...baseConfig, ...config }); - axiosInstance.interceptors.request.use(requestInterceptor({})); axiosInstance.interceptors.response.use( (response) => { logger.info(`[HTTP][RES][${response.config.method?.toUpperCase()}][${response.status}]`, response.config.url, { - requestId: response.headers['x-request-id'], response: response.data, }); return response; }, (error: AxiosError) => { logger.error('[HTTP][RESPONSE][ERROR]', { - requestId: response.headers['x-request-id'], errorName: error.name, errorMessage: error.message, }); @@ -285,7 +282,7 @@ function responseErrorInterceptor(options: { // take user to login page if (getHeader(response.headers, HTTP.HEADERS.X_LOGOUT) === '1') { // LOG USER OUT - const logoutUrl = getHeader(response.headers, HTTP.HEADERS.X_LOGOUT_URL) || '/oauth/login'; + const logoutUrl = getHeader(response.headers, HTTP.HEADERS.X_LOGOUT_URL) || '/auth/login'; // stupid unit tests - location.href is readonly TS compilation failure // eslint-disable-next-line no-restricted-globals (location as any).href = logoutUrl; diff --git a/libs/shared/data/src/lib/client-data.ts b/libs/shared/data/src/lib/client-data.ts index 55a2312dc..a7f6af6f4 100644 --- a/libs/shared/data/src/lib/client-data.ts +++ b/libs/shared/data/src/lib/client-data.ts @@ -1,4 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + Providers, + TwoFactorTypeWithoutEmail, + UserProfileAuthFactor, + UserProfileUiWithIdentities, + UserSessionWithLocation, +} from '@jetstream/auth/types'; import { HTTP, MIME_TYPES } from '@jetstream/shared/constants'; import { AnonymousApexResponse, @@ -35,12 +42,12 @@ import { SalesforceOrgUi, SobjectOperation, UserProfileUi, - UserProfileUiWithIdentities, } from '@jetstream/types'; import { parseISO } from 'date-fns/parseISO'; import isFunction from 'lodash/isFunction'; import isNil from 'lodash/isNil'; import { handleExternalRequest, handleRequest, transformListMetadataResponse } from './client-data-data-helper'; + //// LANDING PAGE ROUTES let cloudinarySignature: CloudinarySignature; @@ -87,11 +94,65 @@ export async function getFullUserProfile(): Promise return handleRequest({ method: 'GET', url: '/api/me/profile' }).then(unwrapResponseIgnoreCache); } +export async function initPassword(password: string): Promise { + return handleRequest({ method: 'POST', url: '/api/me/profile/password/init', data: { password } }).then(unwrapResponseIgnoreCache); +} + +export async function initResetPassword(): Promise { + return handleRequest({ method: 'POST', url: '/api/me/profile/password/reset' }).then(unwrapResponseIgnoreCache); +} + +export async function removePassword(): Promise { + return handleRequest({ method: 'DELETE', url: '/api/me/profile/password' }).then(unwrapResponseIgnoreCache); +} + export async function updateUserProfile(userProfile: { name: string }): Promise { return handleRequest({ method: 'POST', url: '/api/me/profile', data: userProfile }).then(unwrapResponseIgnoreCache); } -export async function unlinkIdentityFromProfile(identity: { provider: string; userId: string }): Promise { +export async function getUserSessions(): Promise<{ currentSessionId: string; sessions: UserSessionWithLocation[] }> { + return handleRequest({ method: 'GET', url: '/api/me/profile/sessions' }).then(unwrapResponseIgnoreCache); +} + +export async function getAuthProviders(): Promise { + return handleRequest({ method: 'GET', url: '/api/auth/providers' }).then(unwrapResponseIgnoreCache); +} + +export async function getCsrfToken(): Promise<{ csrfToken: string }> { + return handleRequest({ method: 'GET', url: '/api/auth/csrf' }).then(unwrapResponseIgnoreCache); +} + +export async function revokeUserSession(sessionId: string): Promise<{ currentSessionId: string; sessions: UserSessionWithLocation[] }> { + return handleRequest({ method: 'DELETE', url: `/api/me/profile/sessions/${sessionId}` }).then(unwrapResponseIgnoreCache); +} + +export async function revokeAllUserSessions(exceptId?: string): Promise<{ currentSessionId: string; sessions: UserSessionWithLocation[] }> { + return handleRequest({ method: 'DELETE', url: '/api/me/profile/sessions/', data: { exceptId } }).then(unwrapResponseIgnoreCache); +} + +export async function getOtpQrCode(): Promise<{ secret: string; secretToken: string; imageUri: string; uri: string }> { + return handleRequest({ method: 'GET', url: '/api/me/profile/2fa-otp' }).then(unwrapResponseIgnoreCache); +} + +export async function saveOtpAuthFactor(secretToken: string, code: string): Promise { + return handleRequest({ method: 'POST', url: `/api/me/profile/2fa-otp`, data: { secretToken, code } }).then(unwrapResponseIgnoreCache); +} + +export async function toggleEnableDisableAuthFactor( + type: TwoFactorTypeWithoutEmail, + action: 'enable' | 'disable' +): Promise { + return handleRequest({ method: 'POST', url: `/api/me/profile/2fa/${type}/${action}` }).then(unwrapResponseIgnoreCache); +} + +export async function deleteAuthFactor(type: TwoFactorTypeWithoutEmail): Promise { + return handleRequest({ method: 'DELETE', url: `/api/me/profile/2fa/${type}` }).then(unwrapResponseIgnoreCache); +} + +export async function unlinkIdentityFromProfile(identity: { + provider: string; + providerAccountId: string; +}): Promise { return handleRequest({ method: 'DELETE', url: '/api/me/profile/identity', params: identity }).then(unwrapResponseIgnoreCache); } diff --git a/libs/shared/node-utils/src/index.ts b/libs/shared/node-utils/src/index.ts index 96cdf34c4..8f761ed2d 100644 --- a/libs/shared/node-utils/src/index.ts +++ b/libs/shared/node-utils/src/index.ts @@ -1 +1,2 @@ +export * from './lib/AsyncIntervalTimer'; export * from './lib/shared-node-utils'; diff --git a/libs/shared/node-utils/src/lib/AsyncIntervalTimer.ts b/libs/shared/node-utils/src/lib/AsyncIntervalTimer.ts new file mode 100644 index 000000000..997b0af29 --- /dev/null +++ b/libs/shared/node-utils/src/lib/AsyncIntervalTimer.ts @@ -0,0 +1,54 @@ +import { logger } from '@jetstream/api-config'; +import { getErrorMessageAndStackObj } from '@jetstream/shared/utils'; + +type TimerOptions = { + name: string; + /** + * Option to change the interval + */ + intervalMs: number; + /** + * Option to run the function immediately on initialization + * @default false + */ + runOnInit?: boolean; +}; + +export class AsyncIntervalTimer { + private timerId: NodeJS.Timeout | null = null; + private failureCount = 0; + + constructor(private callback: () => Promise, private options: TimerOptions) { + this.options.runOnInit = !!this.options.runOnInit; + if (this.options.runOnInit) { + this.runCallback(); // Run immediately if `runOnInit` is true + } + this.startTimer(); + } + + private async runCallback() { + try { + logger.info(`[AsyncIntervalTimer][INVOKING]: %s`, this.options.name); + await this.callback(); + this.failureCount = 0; // Reset failure count on success + } catch (error) { + logger.error(getErrorMessageAndStackObj(error), `[AsyncIntervalTimer][FAILURE]: %s`, this.options.name); + this.failureCount++; + if (this.failureCount >= 3) { + logger.error(`[AsyncIntervalTimer][FAILURE][FATAL]: %s`, this.options.name); + this.cancelTimer(); + } + } + } + + private startTimer() { + this.timerId = setInterval(() => this.runCallback(), this.options.intervalMs); + } + + public cancelTimer() { + if (this.timerId) { + clearInterval(this.timerId); + this.timerId = null; + } + } +} diff --git a/libs/shared/node-utils/src/lib/shared-node-utils.ts b/libs/shared/node-utils/src/lib/shared-node-utils.ts index 8fda95098..e3ce900aa 100644 --- a/libs/shared/node-utils/src/lib/shared-node-utils.ts +++ b/libs/shared/node-utils/src/lib/shared-node-utils.ts @@ -1,10 +1,9 @@ -import * as crypto from 'crypto'; -import { Transform } from 'stream'; +import { Cipher, createCipheriv, createDecipheriv, Decipher, randomBytes } from 'crypto'; export function generateKey(): string { // Generates 32 byte cryptographically strong pseudo-random data as a base64 encoded string - // https://nodejs.org/api/crypto.html#crypto_crypto_randombytes_size_callback - return crypto.randomBytes(32).toString('base64'); + // https://nodejs.org/api/html#crypto_crypto_randombytes_size_callback + return randomBytes(32).toString('base64'); } export function hexToBase64(hexStr: string) { @@ -12,7 +11,7 @@ export function hexToBase64(hexStr: string) { } /** - * Encrypt a string using standardized encyryption of AES256 + * Encrypt a string using standardized encryption of AES256 * @param plainText value to encrypt * @param secret secret to use */ @@ -30,11 +29,11 @@ export function encryptString(plainText: string, secret: string): string { // Generates 16 byte cryptographically strong pseudo-random data as IV // https://nodejs.org/api/crypto.html#crypto_crypto_randombytes_size_callback - const ivBytes: Buffer = crypto.randomBytes(16); + const ivBytes: Buffer = randomBytes(16); const ivText: string = ivBytes.toString('base64'); // encrypt using aes256 iv + key + plainText = encryptedText - const cipher: crypto.Cipher = crypto.createCipheriv('aes256', keyBytes, ivBytes); + const cipher: Cipher = createCipheriv('aes256', keyBytes, ivBytes); let encryptedValue: string = cipher.update(plainText, 'utf8', 'base64'); encryptedValue += cipher.final('base64'); @@ -75,7 +74,7 @@ export function decryptString(encryptedValue: string, secret: string): string { } // decrypt using aes256 iv + key + encryptedText = decryptedText - const decipher: crypto.Decipher = crypto.createDecipheriv('aes-256-cbc', keyBytes, ivBytes); + const decipher: Decipher = createDecipheriv('aes-256-cbc', keyBytes, ivBytes); let value: string = decipher.update(encryptedText, 'base64', 'utf8'); value += decipher.final('utf8'); diff --git a/libs/shared/ui-core/src/analytics.tsx b/libs/shared/ui-core/src/analytics.tsx index ab8979207..9ed790f8c 100644 --- a/libs/shared/ui-core/src/analytics.tsx +++ b/libs/shared/ui-core/src/analytics.tsx @@ -9,7 +9,6 @@ import { useRecoilValue } from 'recoil'; import { fromAppState } from '.'; let amplitudeToken = ''; -let authAudience = 'http://getjetstream.app/app_metadata'; try { amplitudeToken = import.meta.env.NX_PUBLIC_AMPLITUDE_KEY; @@ -17,12 +16,6 @@ try { logger.warn('Amplitude key not found'); } -try { - authAudience = import.meta.env.NX_PUBLIC_AUTH_AUDIENCE; -} catch (ex) { - logger.warn('authAudience key not found'); -} - let hasInit = false; let hasProfileInit = false; @@ -70,9 +63,9 @@ export function useAmplitude(optOut?: boolean) { if (!hasProfileInit && userProfile && appCookie) { hasProfileInit = true; const identify = new amplitude.Identify() - .set('id', userProfile.sub) + .set('id', userProfile.id) .set('email', userProfile.email) - .set('email-verified', userProfile.email_verified) + .set('email-verified', userProfile.emailVerified) .set('environment', appCookie.environment) .add('app-init-count', 1) .add('application-type', 'web'); @@ -81,10 +74,6 @@ export function useAmplitude(optOut?: boolean) { identify.set('denied-notifications', userPreferences.deniedNotifications); } - if (authAudience) { - identify.set('feature-flags', (userProfile as any)[authAudience]?.featureFlags); - } - amplitude.getInstance().identify(identify); amplitude.getInstance().setUserId(userProfile.email); } diff --git a/libs/shared/ui-core/src/app/HeaderNavbar.tsx b/libs/shared/ui-core/src/app/HeaderNavbar.tsx index fb991815e..c63be1eac 100644 --- a/libs/shared/ui-core/src/app/HeaderNavbar.tsx +++ b/libs/shared/ui-core/src/app/HeaderNavbar.tsx @@ -1,7 +1,7 @@ import { DropDownItem, Maybe, UserProfileUi } from '@jetstream/types'; -import { FeedbackLink, Header, Navbar, NavbarItem, NavbarMenuItems } from '@jetstream/ui'; +import { Header, Navbar, NavbarItem, NavbarMenuItems } from '@jetstream/ui'; import { Fragment, FunctionComponent, useEffect, useMemo, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { useRecoilState, useRecoilValue } from 'recoil'; import Jobs from '../jobs/Jobs'; import OrgsDropdown from '../orgs/OrgsDropdown'; @@ -22,7 +22,7 @@ export interface HeaderNavbarProps { } function logout(serverUrl: string) { - const logoutUrl = `${serverUrl}/oauth/logout`; + const logoutUrl = `${serverUrl}/api/auth/logout`; // eslint-disable-next-line no-restricted-globals location.href = logoutUrl; } @@ -30,7 +30,8 @@ function logout(serverUrl: string) { function getMenuItems(userProfile: Maybe, featureFlags: Set, deniedNotifications?: boolean) { const menu: DropDownItem[] = []; - menu.push({ id: 'settings', value: 'Settings', subheader: userProfile?.email, icon: { type: 'utility', icon: 'settings' } }); + menu.push({ id: 'profile', value: 'Your Profile', subheader: userProfile?.email, icon: { type: 'utility', icon: 'profile_alt' } }); + menu.push({ id: 'settings', value: 'Settings', icon: { type: 'utility', icon: 'settings' } }); menu.push({ id: 'nav-user-logout', value: 'Logout', icon: { type: 'utility', icon: 'logout' } }); if (deniedNotifications && window.Notification && window.Notification.permission === 'default') { @@ -53,6 +54,9 @@ export const HeaderNavbar: FunctionComponent = ({ userProfile function handleUserMenuSelection(id: string) { switch (id) { + case 'profile': + navigate('/profile'); + break; case 'settings': navigate('/settings'); break; @@ -81,28 +85,12 @@ export const HeaderNavbar: FunctionComponent = ({ userProfile ? [, , ] : [ -

    We are working on upgrades to our authentication and user management systems in the coming weeks.

    -

    Upcoming Features:

    -
      -
    • Multi-factor authentication
    • -
    • Visibility to all active sessions
    • -
    -

    Important information:

    +

    We have launched our new authentication experience

    +

    New Features:

      -
    • All users will be signed out and need to sign back in
    • -
    • Some users may require a password reset to log back in
    • +
    • Multi-factor authentication via email or authenticator app
    • +
    • Visibility to all active sessions, with option to revoke sessions
    -
    - Stay tuned for a timeline. If you have any questions . - {!!userProfile && !userProfile.email_verified && ( - <> -
    -

    - Your email address is not verified, make sure verify your email address or{' '} - link a social identity to make sure you can continue to login. -

    - - )}
    , , , diff --git a/libs/shared/ui-core/src/app/app-routes.ts b/libs/shared/ui-core/src/app/app-routes.ts index 4b1728542..b88a28cf1 100644 --- a/libs/shared/ui-core/src/app/app-routes.ts +++ b/libs/shared/ui-core/src/app/app-routes.ts @@ -16,6 +16,7 @@ type RouteKey = | 'SALESFORCE_API' | 'PLATFORM_EVENT_MONITOR' | 'FEEDBACK_SUPPORT' + | 'PROFILE' | 'SETTINGS'; interface RouteItem { @@ -131,6 +132,11 @@ export const APP_ROUTES: RouteMap = { TITLE: 'Feedback and Support', DESCRIPTION: 'Report bugs and request features', }, + PROFILE: { + ROUTE: '/profile', + TITLE: 'Profile', + DESCRIPTION: 'Update your user profile', + }, SETTINGS: { ROUTE: '/settings', TITLE: 'User Settings', diff --git a/libs/shared/ui-core/src/state-management/app-state.ts b/libs/shared/ui-core/src/state-management/app-state.ts index c1c3364c6..731629109 100644 --- a/libs/shared/ui-core/src/state-management/app-state.ts +++ b/libs/shared/ui-core/src/state-management/app-state.ts @@ -19,24 +19,12 @@ import isString from 'lodash/isString'; import { atom, DefaultValue, selector, useRecoilValue, useSetRecoilState } from 'recoil'; const DEFAULT_PROFILE = { - email: 'unknown', - email_verified: true, - name: 'unknown', - nickname: 'unknown', - picture: 'unknown', - sub: 'unknown', - updated_at: 'unknown', id: 'unknown', userId: 'unknown', - createdAt: 'unknown', - updatedAt: 'unknown', - 'http://getjetstream.app/app_metadata': { - featureFlags: { - flagVersion: '', - flags: [], - isDefault: true, - }, - }, + email: 'unknown', + name: 'unknown', + emailVerified: true, + picture: null, preferences: { skipFrontdoorLogin: true, }, diff --git a/libs/shared/ui-utils/src/index.ts b/libs/shared/ui-utils/src/index.ts index 2bbad747c..63ea3d7f0 100644 --- a/libs/shared/ui-utils/src/index.ts +++ b/libs/shared/ui-utils/src/index.ts @@ -1,3 +1,4 @@ +export * from './lib//hooks/useCsrfToken'; export * from './lib/download-zip/downzip'; export * from './lib/hooks/useBrowserNotifications'; export * from './lib/hooks/useCombinedRefs'; diff --git a/libs/shared/ui-utils/src/lib/hooks/useCsrfToken.ts b/libs/shared/ui-utils/src/lib/hooks/useCsrfToken.ts new file mode 100644 index 000000000..7aa00e015 --- /dev/null +++ b/libs/shared/ui-utils/src/lib/hooks/useCsrfToken.ts @@ -0,0 +1,25 @@ +import { logger } from '@jetstream/shared/client-logger'; +import { getCsrfToken } from '@jetstream/shared/data'; +import { useCallback, useEffect, useState } from 'react'; + +export function useCsrfToken() { + const [csrfToken, setCsrfToken] = useState(); + const [loading, setLoading] = useState(false); + + const fetchCsrfToken = useCallback(async () => { + try { + setLoading(true); + setCsrfToken((await getCsrfToken()).csrfToken); + } catch (ex) { + logger.warn('[FETCH CSRF][ERROR]', ex); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchCsrfToken(); + }, [fetchCsrfToken]); + + return { loading, csrfToken }; +} diff --git a/libs/shared/ui-utils/src/lib/hooks/useRollbar.ts b/libs/shared/ui-utils/src/lib/hooks/useRollbar.ts index 6a420c1e2..2d5ef8624 100644 --- a/libs/shared/ui-utils/src/lib/hooks/useRollbar.ts +++ b/libs/shared/ui-utils/src/lib/hooks/useRollbar.ts @@ -91,7 +91,7 @@ class RollbarConfig { private configure() { if (!this.rollbarIsConfigured && this.userProfile) { this.rollbarIsConfigured = true; - const { sub, email } = this.userProfile; + const { id, email } = this.userProfile; this.rollbar.configure({ enabled: !this.optOut, codeVersion: this.version, @@ -123,7 +123,7 @@ class RollbarConfig { }, }, person: { - id: sub, + id, email, }, }, diff --git a/libs/types/src/lib/types.ts b/libs/types/src/lib/types.ts index c00f039f8..32b02600c 100644 --- a/libs/types/src/lib/types.ts +++ b/libs/types/src/lib/types.ts @@ -91,121 +91,28 @@ export interface UserProfilePreferences { deniedNotifications?: boolean; } -export type UserProfileUsernameStatus = 'ACTIVE' | 'PENDING' | 'REJECTED'; - -export type Auth0ConnectionName = 'google-oauth2' | 'salesforce' | 'github'; - +/** + * @deprecated + */ export interface FeatureFlag { flagVersion: string; // V1.0 flags: string[]; // all | query isDefault: boolean; } -export type Auth0PaginatedResponse = { - start: number; - limit: number; - length: number; - total: number; -} & { [P in PropertyName]: T[] }; - -export interface UserProfileAuth0Identity { - profileData?: UserProfileAuth0IdentityProfileData; - provider: string; - access_token: string; - expires_in: 3599; - user_id: string; - connection: string; - isSocial: boolean; -} - -export interface UserProfileAuth0IdentityProfileData { - email: string; - email_verified: boolean; - name: string; - given_name: string; - family_name: string; - picture: string; - locale: string; - username?: string; - nickname?: string; -} - -export interface UserProfileAuth0 { - user_id: string; - created_at: string; - updated_at: string; - email: string; - email_verified: boolean; - identities: UserProfileAuth0Identity[]; - name: string; - nickname: string; - picture: string; - user_metadata: any; - app_metadata: { - featureFlags: FeatureFlag; - accountDeletionDate?: string; - }; - last_ip: string; - last_login: string; - logins_count: number; - username?: string; -} - -export type UserProfileAuth0Ui = Pick< - UserProfileAuth0, - 'user_id' | 'email' | 'email_verified' | 'identities' | 'name' | 'nickname' | 'picture' | 'app_metadata' | 'username' ->; - -export interface UserProfileUiWithIdentities { +export interface UserProfileUi { id: string; + /** @deprecated */ userId: string; - name: string; - email: string; - emailVerified: boolean; - picture?: string; - username: string; - nickname: string; - preferences: { - skipFrontdoorLogin: boolean; - }; - identities: UserProfileAuth0Identity[]; - createdAt: string; - updatedAt: string; -} - -export interface UserProfileUi { email: string; - email_verified: boolean; - // Set from environment variable, could be different - 'http://getjetstream.app/app_metadata': { featureFlags: FeatureFlag }; name: string; - nickname: string; - picture?: string | null; - sub: string; // userid - updated_at: string; - id: string; - userId: string; - createdAt: string; - updatedAt: string; + emailVerified: boolean; + picture?: Maybe; preferences: { skipFrontdoorLogin: boolean; }; } -// SERVER ONLY TYPE - BROWSER WILL GET UserProfileUi -export interface UserProfileServer { - _json: Omit; - _raw: string | null; - id: string; - displayName: string; - emails: { value: string }[]; - name: any; - nickname: string; - picture?: string | null; - provider: string; - user_id: string; -} - export interface SalesforceUserInfo { sub: string; user_id: string; diff --git a/libs/ui/src/lib/form/dropdown/DropDown.tsx b/libs/ui/src/lib/form/dropdown/DropDown.tsx index a0288e8ae..edafc4976 100644 --- a/libs/ui/src/lib/form/dropdown/DropDown.tsx +++ b/libs/ui/src/lib/form/dropdown/DropDown.tsx @@ -30,6 +30,7 @@ import Icon from '../../widgets/Icon'; export interface DropDownProps { className?: string; + testId?: string; disabled?: boolean; position?: 'left' | 'right'; leadingIcon?: IconObj; // ignored if buttonContent is provided @@ -47,6 +48,7 @@ export interface DropDownProps { export const DropDown: FunctionComponent = ({ className, + testId, disabled, position = 'left', leadingIcon, @@ -168,6 +170,7 @@ export const DropDown: FunctionComponent = ({ setIsOpen(false)}>
    + +
    + + + {providers?.salesforce.callbackUrl && } + +
    +
    + +
    +
    + +
    +
    -
    -
    - - -
    -
    - - - - {providers?.google.callbackUrl && } - -
    -
    - - - - {providers?.salesforce.callbackUrl && } - -
    -
    -
    -
    diff --git a/apps/landing/components/auth/PasswordResetInit.tsx b/apps/landing/components/auth/PasswordResetInit.tsx index f98daa727..e0c8ba93e 100644 --- a/apps/landing/components/auth/PasswordResetInit.tsx +++ b/apps/landing/components/auth/PasswordResetInit.tsx @@ -99,7 +99,12 @@ export function PasswordResetInit({ csrfToken }: PasswordResetInitProps) {

    Reset Password

    - {isSubmitted && } + {isSubmitted && ( + + )} {!isSubmitted && (
    diff --git a/libs/auth/server/src/lib/auth.db.service.ts b/libs/auth/server/src/lib/auth.db.service.ts index 4b5e624e4..0d084f139 100644 --- a/libs/auth/server/src/lib/auth.db.service.ts +++ b/libs/auth/server/src/lib/auth.db.service.ts @@ -345,16 +345,29 @@ export async function revokeAllUserSessions(userId: string, exceptId?: Maybe { - const user = await prisma.user.findFirst({ - where: { email }, + // NOTE: There could be duplicate users with the same email, but only one with a password set + // These users were migrated from Auth0, but we do not support this as a standard path + const user = await prisma.user.findMany({ + where: { email, password: { not: null } }, + take: 2, }); - if (!user) { + if (!user.length) { + throw new InvalidAction(); + } + + if (user.length > 1) { throw new InvalidAction(); } @@ -384,6 +404,7 @@ export const generatePasswordResetToken = async (email: string) => { const passwordResetToken = await prisma.passwordResetToken.create({ data: { + userId: user[0].id, email, expiresAt: addMinutes(new Date(), 10), }, @@ -394,28 +415,20 @@ export const generatePasswordResetToken = async (email: string) => { export const resetUserPassword = async (email: string, token: string, password: string) => { // if there is an existing token, delete it - const existingToken = await prisma.passwordResetToken.findUnique({ + const restToken = await prisma.passwordResetToken.findUnique({ where: { email_token: { email, token } }, }); - if (!existingToken) { + if (!restToken) { throw new InvalidOrExpiredResetToken(); } // delete token - we don't need it anymore and if we fail later, the user will need to reset again await prisma.passwordResetToken.delete({ - where: { email_token: { email, token: existingToken.token } }, - }); - - if (existingToken.expiresAt < new Date()) { - throw new InvalidOrExpiredResetToken(); - } - - const user = await prisma.user.findFirst({ - where: { email }, + where: { email_token: { email, token: restToken.token } }, }); - if (!user) { + if (restToken.expiresAt < new Date()) { throw new InvalidOrExpiredResetToken(); } @@ -427,11 +440,11 @@ export const resetUserPassword = async (email: string, token: string, password: passwordUpdatedAt: new Date(), }, where: { - id: user.id, + id: restToken.userId, }, }); - await revokeAllUserSessions(user.id); + await revokeAllUserSessions(restToken.userId); }; export const removePasswordFromUser = async (id: string) => { @@ -464,6 +477,7 @@ async function getUserAndVerifyPassword(email: string, password: string) { if (!UNSAFE_userWithPassword) { return { error: new InvalidCredentials() }; } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (await verifyPassword(password, UNSAFE_userWithPassword.password!)) { return { error: null, @@ -536,6 +550,7 @@ async function createUserFromProvider(providerUser: ProviderUser, provider: Oaut email: providerUser.email, // TODO: do we really get any benefit from storing this userId like this? // TODO: only reason I can think of is user migration since the id is a UUID so we need to different identifier + // TODO: this is nice as we can identify which identity is primary without joining the identity table - but could solve in other ways userId: `${provider}|${providerUser.id}`, name: providerUser.name, emailVerified: providerUser.emailVerified, diff --git a/prisma/migrations/20241102174238_add_userid_lookup_password_reset/migration.sql b/prisma/migrations/20241102174238_add_userid_lookup_password_reset/migration.sql new file mode 100644 index 000000000..615869ec1 --- /dev/null +++ b/prisma/migrations/20241102174238_add_userid_lookup_password_reset/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `userId` to the `PasswordResetToken` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "PasswordResetToken" ADD COLUMN "userId" UUID NOT NULL; + +-- AddForeignKey +ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4a51bb6e5..1579c0043 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,30 +8,31 @@ datasource db { } model User { - id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid // TODO: we should deprecate this field - is there a purpose of having two user ids? // Might be nice to rename this to something like "legacyId" for reference - userId String @unique @db.VarChar + userId String @unique @db.VarChar // TODO: we want to make this unique - email String @db.VarChar - emailVerified Boolean @default(false) - password String? @db.VarChar - passwordUpdatedAt DateTime? - name String @db.VarChar - nickname String? @db.VarChar - picture String? @db.VarChar - appMetadata Json? @db.Json - preferences UserPreference? - salesforceOrgs SalesforceOrg[] - organizations JetstreamOrganization[] - identities AuthIdentity[] - authFactors AuthFactors[] - loginActivity LoginActivity[] - rememberdDevices RememberedDevice[] - lastLoggedIn DateTime? - deletedAt DateTime? - createdAt DateTime @default(now()) @db.Timestamp(6) - updatedAt DateTime @updatedAt + email String @db.VarChar + emailVerified Boolean @default(false) + password String? @db.VarChar + passwordUpdatedAt DateTime? + name String @db.VarChar + nickname String? @db.VarChar + picture String? @db.VarChar + appMetadata Json? @db.Json + preferences UserPreference? + salesforceOrgs SalesforceOrg[] + organizations JetstreamOrganization[] + identities AuthIdentity[] + authFactors AuthFactors[] + loginActivity LoginActivity[] + rememberdDevices RememberedDevice[] + passwordResetTokens PasswordResetToken[] + lastLoggedIn DateTime? + deletedAt DateTime? + createdAt DateTime @default(now()) @db.Timestamp(6) + updatedAt DateTime @updatedAt } model AuthFactors { @@ -49,10 +50,13 @@ model AuthFactors { } model PasswordResetToken { + userId String @db.Uuid email String token String @unique @default(dbgenerated("uuid_generate_v4()")) @db.Uuid expiresAt DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@unique([email, token]) } From ebe38ac643d2a5ccf24dd1f626db1222470612cd Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sat, 2 Nov 2024 13:23:32 -0700 Subject: [PATCH 07/38] Add user migration script --- .gitignore | 4 + package.json | 1 + scripts/auth-migration/auth-migration.ts | 387 +++++++++++++++++++++++ scripts/auth-migration/data/.gitkeep | 0 yarn.lock | 19 ++ 5 files changed, 411 insertions(+) create mode 100644 scripts/auth-migration/auth-migration.ts create mode 100644 scripts/auth-migration/data/.gitkeep diff --git a/.gitignore b/.gitignore index 421f1fd13..40b03c4df 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,7 @@ package-lock.json .nx/cache .nx/workspace-data **/playwright/.auth/user.json + +# Ignore data directory in scripts +/scripts/**/data/**/* +!/scripts/**/data/.gitkeep diff --git a/package.json b/package.json index e78f869b4..52b7d2812 100644 --- a/package.json +++ b/package.json @@ -175,6 +175,7 @@ "@vitejs/plugin-react": "4.2.1", "@vitest/coverage-v8": "1.5.2", "@vitest/ui": "1.5.2", + "auth0": "^4.10.0", "autoprefixer": "10.4.13", "babel-jest": "29.7.0", "babel-loader": "^9.1.3", diff --git a/scripts/auth-migration/auth-migration.ts b/scripts/auth-migration/auth-migration.ts new file mode 100644 index 000000000..89ae91489 --- /dev/null +++ b/scripts/auth-migration/auth-migration.ts @@ -0,0 +1,387 @@ +import { Prisma, PrismaClient } from '@prisma/client'; +import { ManagementClient } from 'auth0'; +import { writeFileSync } from 'fs-extra'; +import { isString } from 'lodash'; +import path from 'path'; +import { pipeline, Readable, Writable } from 'stream'; +import { promisify } from 'util'; +import { createGunzip } from 'zlib'; + +const pipelineAsync = promisify(pipeline); + +/** + * Run this with `bun auth-migration.ts` + */ + +const ENV = { + CLIENT_ID: `${process.env.AUTH0_MGMT_CLIENT_ID}`, + CLIENT_SECRET: `${process.env.AUTH0_MGMT_CLIENT_SECRET}`, + DOMAIN: `${process.env.AUTH0_DOMAIN}`, + M2M_DOMAIN: `${process.env.AUTH0_M2M_DOMAIN}`, + JETSTREAM_POSTGRES_DBURI: `${process.env.JETSTREAM_POSTGRES_DBURI}`, +}; + +console.log(JSON.stringify(ENV, null, 2)); + +export const prisma = new PrismaClient({}); + +const management = new ManagementClient({ + domain: ENV.DOMAIN, + clientId: ENV.CLIENT_ID, + clientSecret: ENV.CLIENT_SECRET, +}); + +interface Auth0User { + user_id: string; + nickname: string; + updated_at: string; + user_metadata?: any; + identities: (Auth0Identity | GoogleIdentity | SalesforceIdentity)[]; + name: string; + picture: string; + email: string; + email_verified: boolean; + created_at: string; + last_login?: string; + last_ip?: string; + logins_count?: number; + app_metadata: any; +} + +interface Auth0Identity { + profileData: { + email: string; + email_verified: boolean; + last_password_reset?: string; + }; + user_id: string; + provider: 'auth0'; + connection: 'Username-Password-Authentication'; + isSocial: false; +} + +interface GoogleIdentity { + profileData: { + email: string; + email_verified: boolean; + name: string; + given_name: string; + family_name: string; + picture: string; + }; + provider: 'google-oauth2'; + user_id: string; + connection: 'google-oauth2'; + isSocial: true; +} +interface SalesforceIdentity { + profileData: { + picture: string; + picture_thumbnail: string; + email: string; + name: string; + family_name: string; + given_name: string; + nickname: string; + email_verified: boolean; + id: string; + organization_id: string; + username: string; + urls: Record; + active: boolean; + user_type: 'STANDARD'; + language: 'en_US'; + locale: 'en_US'; + utcOffset: number; + last_modified_date: string; + }; + provider: 'salesforce'; + user_id: string; + connection: 'salesforce'; + isSocial: true; +} + +const timestamp = formatTimestampForFilename(); + +const DATA_DIR = path.join(__dirname, 'data'); +const outputAuth0PathJson = path.join(DATA_DIR, `jetstream-users-${timestamp}-PRE_AUTH0.json`); +const outputPreUpdatePathJson = path.join(DATA_DIR, `jetstream-users-${timestamp}-PRE_UPDATE.json`); +const outputPathSuccessJson = path.join(DATA_DIR, `jetstream-users-${timestamp}-OUT_SUCCESS.json`); +const outputPathErrorsJson = path.join(DATA_DIR, `jetstream-users-${timestamp}-OUT_ERROR.json`); +const outputPathAllJson = path.join(DATA_DIR, `jetstream-users-${timestamp}-OUT_ALL.json`); + +function formatTimestampForFilename(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + + return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`; +} + +async function unzipBufferToMemory(buffer: Buffer): Promise { + const gunzip = createGunzip(); + const source = Readable.from(buffer); + + let chunks: Buffer[] = []; + const destination = new Writable({ + write(chunk, encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + + try { + await pipelineAsync(source, gunzip, destination); + return Buffer.concat(chunks); + } catch (err) { + console.error('An error occurred:', err); + throw err; + } +} + +function convertJsonLinesToJson(jsonLines: string): any[] { + const lines = jsonLines.split('\n').filter((line) => line.trim() !== ''); + const jsonArray = lines.map((line) => JSON.parse(line)); + return jsonArray; +} + +async function delay(milliseconds: number) { + // return await for better async stack trace support in case of errors. + return await new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +/** + * ********************************************** + * EXPORT USERS FUNCTION + * ********************************************** + */ +async function exportUsers() { + let response = await management.jobs.exportUsers({ + format: 'json', + fields: [ + { name: 'user_id' }, + { name: 'nickname' }, + { name: 'updated_at' }, + { name: 'user_metadata' }, + { name: 'identities' }, + { name: 'name' }, + { name: 'picture' }, + { name: 'email' }, + { name: 'email_verified' }, + { name: 'created_at' }, + { name: 'last_login' }, + { name: 'last_ip' }, + { name: 'logins_count' }, + { name: 'app_metadata' }, + ], + }); + + if (response.status < 200 || response.status >= 300) { + throw new Error(`HTTP error! status: ${response.status}. ${JSON.stringify(response.data)}`); + } + + let numChecks = 0; + const maxChecks = 10; + const interval = 1000; + + let job = response.data; + + while (numChecks < maxChecks) { + response = await management.jobs.get({ id: job.id }); + job = response.data; + if (job.status === 'completed') { + if (!isString(job.location)) { + throw new Error('Job location is missing'); + } + console.log(job.location); + const userExportResponse = await fetch(job.location); + if (!userExportResponse.ok) { + throw new Error(`HTTP error! status: ${userExportResponse.status}`); + } + + const results = await unzipBufferToMemory(Buffer.from(await userExportResponse.arrayBuffer())); + const jsonLines = results.toString('utf8'); + let users = convertJsonLinesToJson(jsonLines) as Auth0User[]; + + console.log('Saving auth0 users to', outputAuth0PathJson); + writeFileSync(outputAuth0PathJson, JSON.stringify(users, null, 2)); + + return users; + } + await delay(interval); + numChecks++; + } + throw new Error('Export job did not complete in time'); +} + +/** + * ********************************************** + * UPDATE IN JETSTREAM DATABASE + * ********************************************** + */ +async function updateUsersInJetstreamDatabase(users: Auth0User[]) { + console.log('Preparing users for import'); + const userUpdateInput = users.map((user) => { + const jetstreamUser: Prisma.UserUpdateInput = { + userId: user.user_id, + email: user.email, + emailVerified: user.email_verified ?? false, + name: (user.name || user.email).trim(), + nickname: (user.nickname || user.name || user.email).trim(), + picture: user.picture || null, + updatedAt: new Date(), + password: null, // TODO: if there is a password, set this + passwordUpdatedAt: null, // TODO: if there is a password, set this + }; + const jetstreamAuthFactors: Prisma.AuthFactorsCreateWithoutUserInput[] = []; + const jetstreamAuthIdentity: Prisma.AuthIdentityCreateWithoutUserInput[] = []; + + jetstreamAuthFactors.push({ + enabled: true, // Users can choose "remember this device" or disable in settings + secret: null, + type: '2fa-email', + createdAt: new Date(), + updatedAt: new Date(), + }); + + let identities = user.identities; + let isFirstItemPrimary = true; + if (user.identities[0].provider === 'auth0') { + identities = user.identities.slice(1); + isFirstItemPrimary = false; + } + + (identities as (GoogleIdentity | SalesforceIdentity)[]).forEach((identity, i) => { + jetstreamAuthIdentity.push({ + createdAt: new Date(), + updatedAt: new Date(), + provider: identity.provider === 'google-oauth2' ? 'google' : 'salesforce', + providerAccountId: (identity.profileData as any).id || identity.user_id, + email: identity.profileData.email, + emailVerified: identity.profileData.email_verified, + familyName: identity.profileData.family_name, + givenName: identity.profileData.given_name, + isPrimary: isFirstItemPrimary && i === 0, + name: (identity.profileData.name || identity.profileData.email).trim(), + picture: identity.profileData.picture, + type: 'oauth', + username: (identity.profileData as any).username || identity.profileData.email, + }); + }); + + return Prisma.validator()({ + ...jetstreamUser, + authFactors: { + createMany: { + data: jetstreamAuthFactors, + }, + }, + identities: { + createMany: { + data: jetstreamAuthIdentity, + }, + }, + }); + }); + + console.log('Saving pre-update file to', outputPreUpdatePathJson); + writeFileSync(outputPreUpdatePathJson, JSON.stringify(userUpdateInput, null, 2)); + + console.log('Updating users in Jetstream database'); + + const results: { + success: boolean; + error?: string; + user?: any; + }[] = []; + + // TODO: if we want to run this multiple times, then we might want to delete the authFactors and identities first + + for (let userInput of userUpdateInput) { + try { + console.log(`Attempting to update ${userInput.userId} (${userInput.email})`); + if (!isString(userInput.userId)) { + throw new Error('User ID is required'); + } + + const existingUser = await prisma.user.findFirst({ + where: { userId: userInput.userId }, + select: { + id: true, + userId: true, + authFactors: { + select: { + type: true, + userId: true, + }, + }, + identities: { + select: { + type: true, + provider: true, + providerAccountId: true, + }, + }, + }, + }); + + if (!existingUser) { + throw new Error(`User not found with id: ${userInput.userId}`); + } + + // TODO: I could calculate if we need to update the authFactors here? + + results.push({ + success: true, + user: await prisma.user.update({ + data: userInput, + where: { userId: userInput.userId }, + include: { + authFactors: true, + identities: true, + }, + }), + }); + } catch (ex) { + console.error('Error updating user', ex); + results.push({ + success: false, + error: (ex as Error).message, + }); + } + } + + console.log('Saving success-update file to', outputPathSuccessJson); + console.log('Saving errors-update file to', outputPathErrorsJson); + console.log('Saving all-update file to', outputPathAllJson); + writeFileSync( + outputPathSuccessJson, + JSON.stringify( + results.filter(({ success }) => success), + null, + 2 + ) + ); + writeFileSync( + outputPathErrorsJson, + JSON.stringify( + results.filter(({ success }) => !success), + null, + 2 + ) + ); + writeFileSync(outputPathAllJson, JSON.stringify(results, null, 2)); +} + +(async () => { + console.log('Starting export process'); + // TODO: ask user if they want to continue + const users = await exportUsers(); + // TODO: ask user if they want to continue + await updateUsersInJetstreamDatabase(users); + console.log('Done'); +})(); diff --git a/scripts/auth-migration/data/.gitkeep b/scripts/auth-migration/data/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/yarn.lock b/yarn.lock index 669b643d3..7caad26cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10813,6 +10813,15 @@ atomic-sleep@^1.0.0: resolved "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz" integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== +auth0@^4.10.0: + version "4.10.0" + resolved "https://registry.yarnpkg.com/auth0/-/auth0-4.10.0.tgz#c4af01f77f0eb8022fe774a488b78cd8de60780f" + integrity sha512-xfNtSyL84w9z1DQXWV1GXgtq2Oi3OXeJe/r+pI29GKZHpfgspNb4rFqp/CqI8zKVir6L3Iq2KZgE2rDHRDtxfA== + dependencies: + jose "^4.13.2" + undici-types "^6.15.0" + uuid "^9.0.0" + autoprefixer@10.4.13, autoprefixer@^10.4.9: version "10.4.13" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.13.tgz#b5136b59930209a321e9fa3dca2e7c4d223e83a8" @@ -18100,6 +18109,11 @@ joi@^17.3.0, joi@^17.6.0: "@sideway/formula" "^3.0.0" "@sideway/pinpoint" "^2.0.0" +jose@^4.13.2: + version "4.15.9" + resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.9.tgz#9b68eda29e9a0614c042fa29387196c7dd800100" + integrity sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA== + jose@^4.14.6, jose@^4.15.5: version "4.15.5" resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.5.tgz#6475d0f467ecd3c630a1b5dadd2735a7288df706" @@ -25098,6 +25112,11 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +undici-types@^6.15.0: + version "6.20.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" + integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== + undici-types@~5.26.4: version "5.26.5" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" From 64b513382a9d8044a8b735b7ba1bcfdc974bb5f1 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 3 Nov 2024 08:35:04 -0700 Subject: [PATCH 08/38] Add environment setup for auth server tests --- .../src/app/controllers/auth.controller.ts | 2 +- libs/api-config/src/lib/env-config.ts | 48 +++++++++---------- libs/api-config/tsconfig.json | 2 + libs/api-config/tsconfig.lib.json | 1 - libs/auth/server/jest.config.ts | 2 +- libs/auth/server/jest.setup.ts | 17 +++++++ libs/auth/server/project.json | 4 +- libs/shared/ui-core-worker/.babelrc | 13 ----- libs/shared/ui-core-worker/.eslintrc.json | 18 ------- libs/shared/ui-core-worker/README.md | 7 --- libs/shared/ui-core-worker/jest.config.ts | 10 ---- libs/shared/ui-core-worker/project.json | 16 ------- libs/shared/ui-core-worker/src/index.ts | 1 - .../src/lib/ui-core-worker.spec.tsx | 10 ---- .../ui-core-worker/src/lib/ui-core-worker.tsx | 18 ------- libs/shared/ui-core-worker/tsconfig.json | 21 -------- libs/shared/ui-core-worker/tsconfig.lib.json | 19 -------- libs/shared/ui-core-worker/tsconfig.spec.json | 20 -------- 18 files changed, 47 insertions(+), 182 deletions(-) create mode 100644 libs/auth/server/jest.setup.ts delete mode 100644 libs/shared/ui-core-worker/.babelrc delete mode 100644 libs/shared/ui-core-worker/.eslintrc.json delete mode 100644 libs/shared/ui-core-worker/README.md delete mode 100644 libs/shared/ui-core-worker/jest.config.ts delete mode 100644 libs/shared/ui-core-worker/project.json delete mode 100644 libs/shared/ui-core-worker/src/index.ts delete mode 100644 libs/shared/ui-core-worker/src/lib/ui-core-worker.spec.tsx delete mode 100644 libs/shared/ui-core-worker/src/lib/ui-core-worker.tsx delete mode 100644 libs/shared/ui-core-worker/tsconfig.json delete mode 100644 libs/shared/ui-core-worker/tsconfig.lib.json delete mode 100644 libs/shared/ui-core-worker/tsconfig.spec.json diff --git a/apps/api/src/app/controllers/auth.controller.ts b/apps/api/src/app/controllers/auth.controller.ts index ecb3c1216..1d48c21ec 100644 --- a/apps/api/src/app/controllers/auth.controller.ts +++ b/apps/api/src/app/controllers/auth.controller.ts @@ -76,7 +76,7 @@ export const routeDefinition = { validators: { params: z.object({ provider: OauthProviderTypeSchema }), query: z.object({ returnUrl: z.string().nullish(), isAccountLink: z.literal('true').nullish() }), - body: z.object({ captchaToken: z.string().nullish(), csrfToken: z.string(), callbackUrl: z.string().url() }), + body: z.object({ csrfToken: z.string(), callbackUrl: z.string().url() }), hasSourceOrg: false, }, }, diff --git a/libs/api-config/src/lib/env-config.ts b/libs/api-config/src/lib/env-config.ts index d525f1701..56ac5aec1 100644 --- a/libs/api-config/src/lib/env-config.ts +++ b/libs/api-config/src/lib/env-config.ts @@ -51,10 +51,10 @@ const EXAMPLE_USER_FULL_PROFILE: UserProfileUiWithIdentities = { }, }; -const booleanSchema = z.union([z.string(), z.boolean()]).nullish().transform(ensureBoolean); +const booleanSchema = z.union([z.string(), z.boolean()]).optional().transform(ensureBoolean); const numberSchema = z .union([z.string(), z.number()]) - .nullish() + .optional() .transform((val) => { if (isNumber(val) || !val) { return val ?? null; @@ -65,11 +65,11 @@ const numberSchema = z const envSchema = z.object({ LOG_LEVEL: z .enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent']) - .nullish() + .optional() .transform((value) => value ?? 'debug'), CI: booleanSchema, // LOCAL OVERRIDE - // EXAMPLE_USER: z.record(z.any()).nullish(), + // EXAMPLE_USER: z.record(z.any()).optional(), EXAMPLE_USER: z .object({ id: z.string(), @@ -91,40 +91,40 @@ const envSchema = z.object({ // SYSTEM NODE_ENV: z .enum(['development', 'test', 'staging', 'production']) - .nullish() + .optional() .transform((value) => value ?? 'production'), ENVIRONMENT: z .enum(['development', 'test', 'staging', 'production']) - .nullish() + .optional() .transform((value) => value ?? 'production'), PORT: numberSchema.default(3333), - CAPTCHA_SECRET_KEY: z.string().nullish(), + CAPTCHA_SECRET_KEY: z.string().optional(), CAPTCHA_PROPERTY: z.literal('captchaToken').optional().default('captchaToken'), - IP_API_KEY: z.string().nullish().describe('API Key used to get location information from IP address'), - GIT_VERSION: z.string().nullish(), - ROLLBAR_SERVER_TOKEN: z.string().nullish(), + IP_API_KEY: z.string().optional().describe('API Key used to get location information from IP address'), + GIT_VERSION: z.string().optional(), + ROLLBAR_SERVER_TOKEN: z.string().optional(), // JETSTREAM JETSTREAM_AUTH_SECRET: z.string().describe('Used to sign authentication cookies.'), // Must be 32 characters JETSTREAM_AUTH_OTP_SECRET: z.string(), - JETSTREAM_SERVER_DOMAIN: z.string(), JETSTREAM_SESSION_SECRET: z.string(), JETSTREAM_SESSION_SECRET_PREV: z .string() - .nullish() + .optional() .transform((val) => val || null), - JETSTREAM_SERVER_URL: z.string().url(), JETSTREAM_POSTGRES_DBURI: z.string(), + JETSTREAM_SERVER_DOMAIN: z.string(), + JETSTREAM_SERVER_URL: z.string().url(), JETSTREAM_CLIENT_URL: z.string(), PRISMA_DEBUG: booleanSchema, - COMETD_DEBUG: z.enum(['error', 'warn', 'info', 'debug']).nullish(), + COMETD_DEBUG: z.enum(['error', 'warn', 'info', 'debug']).optional(), // AUTH - OAuth2 credentials for logging in via OAuth2 AUTH_SFDC_CLIENT_ID: z .string() .optional() .transform((val) => { if (!val) { - console.error('AUTH_SFDC_CLIENT_ID is not set - Logging in with Salesforce will not be available'); + console.warn('AUTH_SFDC_CLIENT_ID is not set - Logging in with Salesforce will not be available'); } return val || ''; }), @@ -133,7 +133,7 @@ const envSchema = z.object({ .optional() .transform((val) => { if (!val) { - console.error('AUTH_SFDC_CLIENT_SECRET is not set - Logging in with Salesforce will not be available'); + console.warn('AUTH_SFDC_CLIENT_SECRET is not set - Logging in with Salesforce will not be available'); } return val || ''; }), @@ -142,7 +142,7 @@ const envSchema = z.object({ .optional() .transform((val) => { if (!val) { - console.error('AUTH_GOOGLE_CLIENT_ID is not set - Logging in with Google will not be available'); + console.warn('AUTH_GOOGLE_CLIENT_ID is not set - Logging in with Google will not be available'); } return val || ''; }), @@ -151,7 +151,7 @@ const envSchema = z.object({ .optional() .transform((val) => { if (!val) { - console.error('AUTH_GOOGLE_CLIENT_SECRET is not set - Logging in with Google will not be available'); + console.warn('AUTH_GOOGLE_CLIENT_SECRET is not set - Logging in with Google will not be available'); } return val || ''; }), @@ -162,8 +162,8 @@ const envSchema = z.object({ JETSTREAM_EMAIL_DOMAIN: z.string().optional().default('mail@getjetstream.app'), JETSTREAM_EMAIL_FROM_NAME: z.string().optional().default('Jetstream Support '), JETSTREAM_EMAIL_REPLY_TO: z.string().optional().default('support@getjetstream.app'), - MAILGUN_API_KEY: z.string().nullish(), - MAILGUN_WEBHOOK_KEY: z.string().nullish(), + MAILGUN_API_KEY: z.string().optional(), + MAILGUN_WEBHOOK_KEY: z.string().optional(), /** * Salesforce Org Connections * Connected App OAuth2 for connecting orgs @@ -176,15 +176,15 @@ const envSchema = z.object({ * Google OAuth2 * Allows google drive configuration */ - GOOGLE_APP_ID: z.string().nullish(), - GOOGLE_API_KEY: z.string().nullish(), - GOOGLE_CLIENT_ID: z.string().nullish(), + GOOGLE_APP_ID: z.string().optional(), + GOOGLE_API_KEY: z.string().optional(), + GOOGLE_CLIENT_ID: z.string().optional(), /** * HONEYCOMB * This is used for logging node application metrics */ HONEYCOMB_ENABLED: booleanSchema, - HONEYCOMB_API_KEY: z.string().nullish(), + HONEYCOMB_API_KEY: z.string().optional(), }); const parseResults = envSchema.safeParse({ diff --git a/libs/api-config/tsconfig.json b/libs/api-config/tsconfig.json index 000f70846..523dfab9a 100644 --- a/libs/api-config/tsconfig.json +++ b/libs/api-config/tsconfig.json @@ -2,6 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "strictNullChecks": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, "types": ["node"] }, "files": [], diff --git a/libs/api-config/tsconfig.lib.json b/libs/api-config/tsconfig.lib.json index acdfb7a31..9ce897611 100644 --- a/libs/api-config/tsconfig.lib.json +++ b/libs/api-config/tsconfig.lib.json @@ -2,7 +2,6 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "allowSyntheticDefaultImports": true, "types": ["node"] }, "exclude": ["**/*.spec.ts", "**/*.test.ts"], diff --git a/libs/auth/server/jest.config.ts b/libs/auth/server/jest.config.ts index bbe09a34f..e96def3c6 100644 --- a/libs/auth/server/jest.config.ts +++ b/libs/auth/server/jest.config.ts @@ -7,5 +7,5 @@ export default { '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], }, moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: '../../../coverage/libs/auth/server', + setupFiles: ['/jest.setup.ts'], }; diff --git a/libs/auth/server/jest.setup.ts b/libs/auth/server/jest.setup.ts new file mode 100644 index 000000000..5b7aca392 --- /dev/null +++ b/libs/auth/server/jest.setup.ts @@ -0,0 +1,17 @@ +process.env.CI = 'false'; +process.env.IS_LOCAL_DOCKER = 'false'; +process.env.NODE_ENV = 'development'; +process.env.ENVIRONMENT = 'development'; +process.env.PORT = '1234'; +process.env.GIT_VERSION = '299e9f089'; +process.env.JETSTREAM_AUTH_SECRET = 'test-secret'; +process.env.JETSTREAM_AUTH_OTP_SECRET = 'test-secret'; +process.env.JETSTREAM_SESSION_SECRET = 'test-secret'; +process.env.JETSTREAM_POSTGRES_DBURI = 'postgres://test@localhost:5432/postgres'; +process.env.JETSTREAM_SERVER_DOMAIN = 'localhost:3333'; +process.env.JETSTREAM_SERVER_URL = 'https://localhost:3333'; +process.env.JETSTREAM_CLIENT_URL = 'https://localhost:3333'; +process.env.SFDC_API_VERSION = '25.0'; +process.env.SFDC_CONSUMER_SECRET = 'test-secret'; +process.env.SFDC_CONSUMER_KEY = 'test-key'; +process.env.SFDC_CALLBACK_URL = 'https://localhost:3333/auth/sfdc/callback'; diff --git a/libs/auth/server/project.json b/libs/auth/server/project.json index 69a35f5c4..33fe681e3 100644 --- a/libs/auth/server/project.json +++ b/libs/auth/server/project.json @@ -3,7 +3,6 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "libs/auth/server/src", "projectType": "library", - "tags": ["server"], "targets": { "test": { "executor": "@nx/jest:jest", @@ -12,5 +11,6 @@ "jestConfig": "libs/auth/server/jest.config.ts" } } - } + }, + "tags": ["scope:server"] } diff --git a/libs/shared/ui-core-worker/.babelrc b/libs/shared/ui-core-worker/.babelrc deleted file mode 100644 index ca85798cd..000000000 --- a/libs/shared/ui-core-worker/.babelrc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "presets": [ - [ - "@nx/react/babel", - { - "runtime": "automatic", - "useBuiltIns": "usage", - "importSource": "@emotion/react" - } - ] - ], - "plugins": ["@emotion/babel-plugin"] -} diff --git a/libs/shared/ui-core-worker/.eslintrc.json b/libs/shared/ui-core-worker/.eslintrc.json deleted file mode 100644 index 75b85077d..000000000 --- a/libs/shared/ui-core-worker/.eslintrc.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], - "ignorePatterns": ["!**/*"], - "overrides": [ - { - "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], - "rules": {} - }, - { - "files": ["*.ts", "*.tsx"], - "rules": {} - }, - { - "files": ["*.js", "*.jsx"], - "rules": {} - } - ] -} diff --git a/libs/shared/ui-core-worker/README.md b/libs/shared/ui-core-worker/README.md deleted file mode 100644 index 72ccb305a..000000000 --- a/libs/shared/ui-core-worker/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# ui-core-worker - -This library was generated with [Nx](https://nx.dev). - -## Running unit tests - -Run `nx test ui-core-worker` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/shared/ui-core-worker/jest.config.ts b/libs/shared/ui-core-worker/jest.config.ts deleted file mode 100644 index c18517694..000000000 --- a/libs/shared/ui-core-worker/jest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint-disable */ -export default { - displayName: 'ui-core-worker', - preset: '../../../jest.preset.js', - transform: { - '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', - '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], -}; diff --git a/libs/shared/ui-core-worker/project.json b/libs/shared/ui-core-worker/project.json deleted file mode 100644 index 5afcd4987..000000000 --- a/libs/shared/ui-core-worker/project.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "ui-core-worker", - "$schema": "../../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "libs/shared/ui-core-worker/src", - "projectType": "library", - "tags": [], - "targets": { - "test": { - "executor": "@nx/jest:jest", - "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], - "options": { - "jestConfig": "libs/shared/ui-core-worker/jest.config.ts" - } - } - } -} diff --git a/libs/shared/ui-core-worker/src/index.ts b/libs/shared/ui-core-worker/src/index.ts deleted file mode 100644 index 44b1cbf79..000000000 --- a/libs/shared/ui-core-worker/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './lib/ui-core-worker'; diff --git a/libs/shared/ui-core-worker/src/lib/ui-core-worker.spec.tsx b/libs/shared/ui-core-worker/src/lib/ui-core-worker.spec.tsx deleted file mode 100644 index e2795bc2c..000000000 --- a/libs/shared/ui-core-worker/src/lib/ui-core-worker.spec.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { render } from '@testing-library/react'; - -import UiCoreWorker from './ui-core-worker'; - -describe('UiCoreWorker', () => { - it('should render successfully', () => { - const { baseElement } = render(); - expect(baseElement).toBeTruthy(); - }); -}); diff --git a/libs/shared/ui-core-worker/src/lib/ui-core-worker.tsx b/libs/shared/ui-core-worker/src/lib/ui-core-worker.tsx deleted file mode 100644 index 7447a0314..000000000 --- a/libs/shared/ui-core-worker/src/lib/ui-core-worker.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import styled from '@emotion/styled'; - -/* eslint-disable-next-line */ -export interface UiCoreWorkerProps {} - -const StyledUiCoreWorker = styled.div` - color: pink; -`; - -export function UiCoreWorker(props: UiCoreWorkerProps) { - return ( - -

    Welcome to UiCoreWorker!

    -
    - ); -} - -export default UiCoreWorker; diff --git a/libs/shared/ui-core-worker/tsconfig.json b/libs/shared/ui-core-worker/tsconfig.json deleted file mode 100644 index 913a7b290..000000000 --- a/libs/shared/ui-core-worker/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "jsx": "react-jsx", - "allowJs": false, - "esModuleInterop": false, - "allowSyntheticDefaultImports": true, - "strict": true, - "jsxImportSource": "@emotion/react" - }, - "files": [], - "include": [], - "references": [ - { - "path": "./tsconfig.lib.json" - }, - { - "path": "./tsconfig.spec.json" - } - ], - "extends": "../../../tsconfig.base.json" -} diff --git a/libs/shared/ui-core-worker/tsconfig.lib.json b/libs/shared/ui-core-worker/tsconfig.lib.json deleted file mode 100644 index b0e36d76c..000000000 --- a/libs/shared/ui-core-worker/tsconfig.lib.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../../dist/out-tsc", - "types": ["node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"] - }, - "exclude": [ - "jest.config.ts", - "src/**/*.spec.ts", - "src/**/*.test.ts", - "src/**/*.spec.tsx", - "src/**/*.test.tsx", - "src/**/*.spec.js", - "src/**/*.test.js", - "src/**/*.spec.jsx", - "src/**/*.test.jsx" - ], - "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] -} diff --git a/libs/shared/ui-core-worker/tsconfig.spec.json b/libs/shared/ui-core-worker/tsconfig.spec.json deleted file mode 100644 index 25b7af8f6..000000000 --- a/libs/shared/ui-core-worker/tsconfig.spec.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../../dist/out-tsc", - "module": "commonjs", - "types": ["jest", "node"] - }, - "include": [ - "jest.config.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "src/**/*.test.tsx", - "src/**/*.spec.tsx", - "src/**/*.test.js", - "src/**/*.spec.js", - "src/**/*.test.jsx", - "src/**/*.spec.jsx", - "src/**/*.d.ts" - ] -} From b0c68ebf4fcf7c28114b89d1faea9f2b46148ded Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 3 Nov 2024 10:10:12 -0700 Subject: [PATCH 09/38] Fix tests by ensuring secure cookies are not used --- .../src/app/controllers/auth.controller.ts | 6 +++--- .../src/app/controllers/user.controller.ts | 2 +- apps/api/src/app/db/salesforce-org.db.ts | 2 +- apps/api/src/app/utils/response.handlers.ts | 2 +- apps/api/src/main.ts | 2 +- apps/jetstream/src/app/app.tsx | 7 ------- .../app/components/core/AppInitializer.tsx | 11 +++++++++- libs/api-config/src/lib/env-config.ts | 20 ++++++++++++++++++- libs/auth/server/src/lib/auth.service.ts | 7 ++++--- 9 files changed, 40 insertions(+), 19 deletions(-) diff --git a/apps/api/src/app/controllers/auth.controller.ts b/apps/api/src/app/controllers/auth.controller.ts index 1d48c21ec..83b75b8e9 100644 --- a/apps/api/src/app/controllers/auth.controller.ts +++ b/apps/api/src/app/controllers/auth.controller.ts @@ -264,7 +264,7 @@ const signin = createRoute(routeDefinition.signin.validators, async ({ body, par const providers = listProviders(); await verifyCSRFFromRequestOrThrow(csrfToken, req.headers.cookie || ''); - const cookieConfig = getCookieConfig(ENV.ENVIRONMENT === 'production'); + const cookieConfig = getCookieConfig(ENV.USE_SECURE_COOKIES); provider = providers[params.provider]; if (provider.type === 'oauth') { @@ -329,7 +329,7 @@ const callback = createRoute(routeDefinition.callback.validators, async ({ body, linkIdentity: linkIdentityCookie, returnUrl, rememberDevice, - } = getCookieConfig(ENV.ENVIRONMENT === 'production'); + } = getCookieConfig(ENV.USE_SECURE_COOKIES); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const cookies = parseCookie(req.headers.cookie!); clearOauthCookies(res); @@ -499,7 +499,7 @@ const verification = createRoute(routeDefinition.verification.validators, async const pendingVerification = req.session.pendingVerification.find((item) => item.type === type); let rememberDeviceId: string | undefined; - const cookieConfig = getCookieConfig(ENV.ENVIRONMENT === 'production'); + const cookieConfig = getCookieConfig(ENV.USE_SECURE_COOKIES); if (!pendingVerification) { throw new InvalidSession(); diff --git a/apps/api/src/app/controllers/user.controller.ts b/apps/api/src/app/controllers/user.controller.ts index 3f97db728..daf21dffd 100644 --- a/apps/api/src/app/controllers/user.controller.ts +++ b/apps/api/src/app/controllers/user.controller.ts @@ -348,7 +348,7 @@ const unlinkIdentity = createRoute(routeDefinition.unlinkIdentity.validators, as const linkIdentity = createRoute(routeDefinition.linkIdentity.validators, async ({ query, user, setCookie }, req, res) => { const { provider } = query; - const cookieConfig = getCookieConfig(ENV.ENVIRONMENT === 'production'); + const cookieConfig = getCookieConfig(ENV.USE_SECURE_COOKIES); clearOauthCookies(res); const { authorizationUrl, code_verifier, nonce } = await getAuthorizationUrl(provider); diff --git a/apps/api/src/app/db/salesforce-org.db.ts b/apps/api/src/app/db/salesforce-org.db.ts index 558dda16d..59e471c6a 100644 --- a/apps/api/src/app/db/salesforce-org.db.ts +++ b/apps/api/src/app/db/salesforce-org.db.ts @@ -160,7 +160,7 @@ export async function createOrUpdateSalesforceOrg(userId: string, salesforceOrgU connectionError: null, }; data.label = data.label || data.username; - data.filterText = `${data.username}${data.orgName}${data.label}`; + data.filterText = `${data.username}${data.orgName}${data.label || ''}`; // update existing const org = await prisma.salesforceOrg.update({ select: SELECT, diff --git a/apps/api/src/app/utils/response.handlers.ts b/apps/api/src/app/utils/response.handlers.ts index 4fe73a29b..1b4770a8e 100644 --- a/apps/api/src/app/utils/response.handlers.ts +++ b/apps/api/src/app/utils/response.handlers.ts @@ -28,7 +28,7 @@ export async function healthCheck(req: express.Request, res: express.Response) { export async function setCsrfCookie(res: Response) { const { csrfToken, cookie: csrfCookie } = await createCSRFToken({ secret: ENV.JETSTREAM_AUTH_SECRET }); - const cookieConfig = getCookieConfig(ENV.ENVIRONMENT === 'production'); + const cookieConfig = getCookieConfig(ENV.USE_SECURE_COOKIES); res.locals.cookies = res.locals.cookies || {}; res.locals.cookies[cookieConfig.csrfToken.name] = { name: cookieConfig.csrfToken.name, diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index be91a91ee..c5a717f74 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -69,7 +69,7 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { }), cookie: { path: '/', - secure: !ENV.IS_LOCAL_DOCKER && environment.production, + secure: ENV.USE_SECURE_COOKIES, // Set to two - if you don't login for 48 hours, then expire session - consider changing to 1 maxAge: 1000 * 60 * 60 * 24 * SESSION_EXP_DAYS, httpOnly: true, diff --git a/apps/jetstream/src/app/app.tsx b/apps/jetstream/src/app/app.tsx index 378cf9bd3..21454c09f 100644 --- a/apps/jetstream/src/app/app.tsx +++ b/apps/jetstream/src/app/app.tsx @@ -1,6 +1,5 @@ import { Maybe, UserProfileUi } from '@jetstream/types'; import { AppToast, ConfirmationServiceProvider } from '@jetstream/ui'; -// import { initSocket } from '@jetstream/shared/data'; import { AppLoading, DownloadFileStream, ErrorBoundaryFallback, HeaderNavbar } from '@jetstream/ui-core'; import { OverlayProvider } from '@react-aria/overlays'; import { Suspense, useState } from 'react'; @@ -16,12 +15,6 @@ import LogInitializer from './components/core/LogInitializer'; import NotificationsRequestModal from './components/core/NotificationsRequestModal'; import './components/core/monaco-loader'; -/** - * TODO: disabled socket from browser until we have a solid use-case for it - * previously this was used for platform events, but that was moved to browser - */ -// initSocket(); - export const App = () => { const [userProfile, setUserProfile] = useState>(); const [featureFlags, setFeatureFlags] = useState>(new Set(['all'])); diff --git a/apps/jetstream/src/app/components/core/AppInitializer.tsx b/apps/jetstream/src/app/components/core/AppInitializer.tsx index c605a38b5..894d782af 100644 --- a/apps/jetstream/src/app/components/core/AppInitializer.tsx +++ b/apps/jetstream/src/app/components/core/AppInitializer.tsx @@ -42,7 +42,16 @@ export const AppInitializer: FunctionComponent = ({ onUserP const invalidOrg = useObservable(orgConnectionError$); useEffect(() => { - console.log('APP VERSION', version); + console.log(` + ██╗███████╗████████╗███████╗████████╗██████╗ ███████╗ █████╗ ███╗ ███╗ + ██║██╔════╝╚══██╔══╝██╔════╝╚══██╔══╝██╔══██╗██╔════╝██╔══██╗████╗ ████║ + ██║█████╗ ██║ ███████╗ ██║ ██████╔╝█████╗ ███████║██╔████╔██║ +██ ██║██╔══╝ ██║ ╚════██║ ██║ ██╔══██╗██╔══╝ ██╔══██║██║╚██╔╝██║ +╚█████╔╝███████╗ ██║ ███████║ ██║ ██║ ██║███████╗██║ ██║██║ ╚═╝ ██║ + ╚════╝ ╚══════╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ + +APP VERSION ${version} + `); }, [version]); useRollbar({ diff --git a/libs/api-config/src/lib/env-config.ts b/libs/api-config/src/lib/env-config.ts index 56ac5aec1..63164dd6a 100644 --- a/libs/api-config/src/lib/env-config.ts +++ b/libs/api-config/src/lib/env-config.ts @@ -98,6 +98,7 @@ const envSchema = z.object({ .optional() .transform((value) => value ?? 'production'), PORT: numberSchema.default(3333), + USE_SECURE_COOKIES: booleanSchema, CAPTCHA_SECRET_KEY: z.string().optional(), CAPTCHA_PROPERTY: z.literal('captchaToken').optional().default('captchaToken'), IP_API_KEY: z.string().optional().describe('API Key used to get location information from IP address'), @@ -189,6 +190,7 @@ const envSchema = z.object({ const parseResults = envSchema.safeParse({ ...process.env, + USE_SECURE_COOKIES: ensureBoolean(process.env.ENVIRONMENT === 'production' && process.env.JETSTREAM_SERVER_URL?.startsWith('https')), EXAMPLE_USER: ensureBoolean(process.env.EXAMPLE_USER_OVERRIDE) ? EXAMPLE_USER : null, EXAMPLE_USER_PASSWORD: ensureBoolean(process.env.EXAMPLE_USER_OVERRIDE) ? process.env.EXAMPLE_USER_PASSWORD : null, EXAMPLE_USER_FULL_PROFILE: ensureBoolean(process.env.EXAMPLE_USER_OVERRIDE) ? EXAMPLE_USER_FULL_PROFILE : null, @@ -203,5 +205,21 @@ ${chalk.yellow(JSON.stringify(parseResults.error.flatten().fieldErrors, null, 2) } export type Env = z.infer; - export const ENV: Env = parseResults.data; + +// prettier-ignore +console.log(` + ██╗███████╗████████╗███████╗████████╗██████╗ ███████╗ █████╗ ███╗ ███╗ + ██║██╔════╝╚══██╔══╝██╔════╝╚══██╔══╝██╔══██╗██╔════╝██╔══██╗████╗ ████║ + ██║█████╗ ██║ ███████╗ ██║ ██████╔╝█████╗ ███████║██╔████╔██║ +██ ██║██╔══╝ ██║ ╚════██║ ██║ ██╔══██╗██╔══╝ ██╔══██║██║╚██╔╝██║ +╚█████╔╝███████╗ ██║ ███████║ ██║ ██║ ██║███████╗██║ ██║██║ ╚═╝ ██║ + ╚════╝ ╚══════╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ + +NODE_ENV=${ENV.NODE_ENV} +ENVIRONMENT=${ENV.ENVIRONMENT} +GIT_VERSION=${ENV.GIT_VERSION ?? ''} +LOG_LEVEL=${ENV.LOG_LEVEL} +JETSTREAM_SERVER_URL=${ENV.JETSTREAM_SERVER_URL} +JETSTREAM_CLIENT_URL=${ENV.JETSTREAM_CLIENT_URL} +`); diff --git a/libs/auth/server/src/lib/auth.service.ts b/libs/auth/server/src/lib/auth.service.ts index d76862c84..c1185a71a 100644 --- a/libs/auth/server/src/lib/auth.service.ts +++ b/libs/auth/server/src/lib/auth.service.ts @@ -1,4 +1,4 @@ -import { ENV } from '@jetstream/api-config'; +import { ENV, getExceptionLog, logger } from '@jetstream/api-config'; import { OauthProviderType, Providers, ResponseLocalsCookies } from '@jetstream/auth/types'; import { parse as parseCookie } from 'cookie'; import * as crypto from 'crypto'; @@ -56,7 +56,7 @@ export function getProviders(): Providers { } export function clearOauthCookies(res: Response) { - const cookieConfig = getCookieConfig(ENV.ENVIRONMENT === 'production'); + const cookieConfig = getCookieConfig(ENV.USE_SECURE_COOKIES); res.locals['cookies'] = res.locals['cookies'] || {}; const cookies = res.locals['cookies'] as ResponseLocalsCookies; @@ -162,7 +162,7 @@ export async function validateCallback( export async function verifyCSRFFromRequestOrThrow(csrfToken: string, cookieString: string) { try { - const cookieConfig = getCookieConfig(ENV.ENVIRONMENT === 'production'); + const cookieConfig = getCookieConfig(ENV.USE_SECURE_COOKIES); const cookies = parseCookie(cookieString); const cookieValue = cookies[cookieConfig.csrfToken.name]; const validCSRFToken = await validateCSRFToken({ @@ -175,6 +175,7 @@ export async function verifyCSRFFromRequestOrThrow(csrfToken: string, cookieStri throw new InvalidCsrfToken(); } } catch (ex) { + logger.error(getExceptionLog(ex), '[ERROR] verifyCSRFFromRequestOrThrow'); throw new InvalidCsrfToken(); } } From c962abbf1d6b5253452faabc0f65c5bd7a90c287 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 3 Nov 2024 11:22:10 -0700 Subject: [PATCH 10/38] Fix API integration tests --- apps/api/src/main.ts | 23 ++++++++++++++++--- .../src/setup/global.teardown.ts | 8 +++---- .../src/tests/api/bulk-query-20.api.spec.ts | 2 +- .../src/tests/api/bulk.api.spec.ts | 2 +- .../src/tests/api/metadata-apex.api.spec.ts | 2 +- .../src/tests/api/metadata.api.spec.ts | 2 +- .../src/tests/api/misc.api.spec.ts | 2 +- .../src/tests/api/query.api.spec.ts | 2 +- .../src/tests/api/record.api.spec.ts | 2 +- libs/api-config/src/lib/env-config.ts | 17 -------------- 10 files changed, 31 insertions(+), 31 deletions(-) diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index c5a717f74..964981a14 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -42,7 +42,25 @@ if (ENV.NODE_ENV !== 'production' || cluster.isPrimary) { }, 1000 * 5); // Delay 5 seconds to allow for other services to start } -if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { +if (cluster.isPrimary) { + console.log(` + ██╗███████╗████████╗███████╗████████╗██████╗ ███████╗ █████╗ ███╗ ███╗ + ██║██╔════╝╚══██╔══╝██╔════╝╚══██╔══╝██╔══██╗██╔════╝██╔══██╗████╗ ████║ + ██║█████╗ ██║ ███████╗ ██║ ██████╔╝█████╗ ███████║██╔████╔██║ +██ ██║██╔══╝ ██║ ╚════██║ ██║ ██╔══██╗██╔══╝ ██╔══██║██║╚██╔╝██║ +╚█████╔╝███████╗ ██║ ███████║ ██║ ██║ ██║███████╗██║ ██║██║ ╚═╝ ██║ + ╚════╝ ╚══════╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ + +NODE_ENV=${ENV.NODE_ENV} +ENVIRONMENT=${ENV.ENVIRONMENT} +GIT_VERSION=${ENV.GIT_VERSION ?? ''} +LOG_LEVEL=${ENV.LOG_LEVEL} +JETSTREAM_SERVER_URL=${ENV.JETSTREAM_SERVER_URL} +JETSTREAM_CLIENT_URL=${ENV.JETSTREAM_CLIENT_URL} + `); +} + +if (ENV.NODE_ENV === 'production' && !ENV.CI && cluster.isPrimary) { logger.info(`Number of CPUs is ${CPU_COUNT}`); logger.info(`Master ${process.pid} is running`); @@ -253,8 +271,7 @@ if (ENV.NODE_ENV === 'production' && cluster.isPrimary) { } const server = httpServer.listen(Number(ENV.PORT), () => { - logger.info('Listening at http://localhost:' + ENV.PORT); - logger.info('[ENVIRONMENT]: ' + ENV.ENVIRONMENT); + logger.info(`Listening at http://localhost:${ENV.PORT}`); }); if (!environment.production) { diff --git a/apps/jetstream-e2e/src/setup/global.teardown.ts b/apps/jetstream-e2e/src/setup/global.teardown.ts index 83593f409..916eb531e 100644 --- a/apps/jetstream-e2e/src/setup/global.teardown.ts +++ b/apps/jetstream-e2e/src/setup/global.teardown.ts @@ -21,7 +21,7 @@ teardown('login and ensure org exists', async ({ page, request }) => { }, }, }); - console.log(`Deleted ${results.count} passwordResetToken`); + console.log(`Deleted ${results.count} passwordResetToken records`); results = await prisma.loginActivity.deleteMany({ where: { @@ -31,7 +31,7 @@ teardown('login and ensure org exists', async ({ page, request }) => { }, }, }); - console.log(`Deleted ${results.count} passwordResetToken`); + console.log(`Deleted ${results.count} loginActivity records`); results = await prisma.emailActivity.deleteMany({ where: { @@ -41,7 +41,7 @@ teardown('login and ensure org exists', async ({ page, request }) => { }, }, }); - console.log(`Deleted ${results.count} passwordResetToken`); + console.log(`Deleted ${results.count} emailActivity records`); results = await prisma.sessions.deleteMany({ where: { @@ -52,5 +52,5 @@ teardown('login and ensure org exists', async ({ page, request }) => { }, }, }); - console.log(`Deleted ${results.count} sessions`); + console.log(`Deleted ${results.count} session records`); }); diff --git a/apps/jetstream-e2e/src/tests/api/bulk-query-20.api.spec.ts b/apps/jetstream-e2e/src/tests/api/bulk-query-20.api.spec.ts index bcf899226..73578343b 100644 --- a/apps/jetstream-e2e/src/tests/api/bulk-query-20.api.spec.ts +++ b/apps/jetstream-e2e/src/tests/api/bulk-query-20.api.spec.ts @@ -5,7 +5,7 @@ import { expect, test } from '../../fixtures/fixtures'; test.describe.configure({ mode: 'parallel' }); test.describe('API - Bulk Query 2.0', () => { - test.beforeAll(async ({ apiRequestUtils }) => { + test.beforeEach(async ({ apiRequestUtils }) => { await apiRequestUtils.selectDefaultOrg(); }); diff --git a/apps/jetstream-e2e/src/tests/api/bulk.api.spec.ts b/apps/jetstream-e2e/src/tests/api/bulk.api.spec.ts index 6bfc56343..81ca0ec16 100644 --- a/apps/jetstream-e2e/src/tests/api/bulk.api.spec.ts +++ b/apps/jetstream-e2e/src/tests/api/bulk.api.spec.ts @@ -5,7 +5,7 @@ import { expect, test } from '../../fixtures/fixtures'; test.describe.configure({ mode: 'parallel' }); test.describe('API - Bulk', () => { - test.beforeAll(async ({ apiRequestUtils }) => { + test.beforeEach(async ({ apiRequestUtils, page }) => { await apiRequestUtils.selectDefaultOrg(); }); diff --git a/apps/jetstream-e2e/src/tests/api/metadata-apex.api.spec.ts b/apps/jetstream-e2e/src/tests/api/metadata-apex.api.spec.ts index a098ac24d..205c8189e 100644 --- a/apps/jetstream-e2e/src/tests/api/metadata-apex.api.spec.ts +++ b/apps/jetstream-e2e/src/tests/api/metadata-apex.api.spec.ts @@ -4,7 +4,7 @@ import { expect, test } from '../../fixtures/fixtures'; test.describe.configure({ mode: 'parallel' }); test.describe('API - Apex', () => { - test.beforeAll(async ({ apiRequestUtils }) => { + test.beforeEach(async ({ apiRequestUtils }) => { await apiRequestUtils.selectDefaultOrg(); }); diff --git a/apps/jetstream-e2e/src/tests/api/metadata.api.spec.ts b/apps/jetstream-e2e/src/tests/api/metadata.api.spec.ts index 1498fd8ba..86ef65f20 100644 --- a/apps/jetstream-e2e/src/tests/api/metadata.api.spec.ts +++ b/apps/jetstream-e2e/src/tests/api/metadata.api.spec.ts @@ -6,7 +6,7 @@ import { expect, test } from '../../fixtures/fixtures'; test.describe.configure({ mode: 'parallel' }); test.describe('API - Metadata', () => { - test.beforeAll(async ({ apiRequestUtils }) => { + test.beforeEach(async ({ apiRequestUtils }) => { await apiRequestUtils.selectDefaultOrg(); }); diff --git a/apps/jetstream-e2e/src/tests/api/misc.api.spec.ts b/apps/jetstream-e2e/src/tests/api/misc.api.spec.ts index a034ca6b6..c323c5c90 100644 --- a/apps/jetstream-e2e/src/tests/api/misc.api.spec.ts +++ b/apps/jetstream-e2e/src/tests/api/misc.api.spec.ts @@ -4,7 +4,7 @@ import { expect, test } from '../../fixtures/fixtures'; test.describe.configure({ mode: 'parallel' }); test.describe('API - Misc', () => { - test.beforeAll(async ({ apiRequestUtils }) => { + test.beforeEach(async ({ apiRequestUtils }) => { await apiRequestUtils.selectDefaultOrg(); }); diff --git a/apps/jetstream-e2e/src/tests/api/query.api.spec.ts b/apps/jetstream-e2e/src/tests/api/query.api.spec.ts index ee506f714..16e69cee9 100644 --- a/apps/jetstream-e2e/src/tests/api/query.api.spec.ts +++ b/apps/jetstream-e2e/src/tests/api/query.api.spec.ts @@ -4,7 +4,7 @@ import { expect, test } from '../../fixtures/fixtures'; test.describe.configure({ mode: 'parallel' }); test.describe('API - Query', () => { - test.beforeAll(async ({ apiRequestUtils }) => { + test.beforeEach(async ({ apiRequestUtils }) => { await apiRequestUtils.selectDefaultOrg(); }); diff --git a/apps/jetstream-e2e/src/tests/api/record.api.spec.ts b/apps/jetstream-e2e/src/tests/api/record.api.spec.ts index f26664451..ede359c40 100644 --- a/apps/jetstream-e2e/src/tests/api/record.api.spec.ts +++ b/apps/jetstream-e2e/src/tests/api/record.api.spec.ts @@ -4,7 +4,7 @@ import { expect, test } from '../../fixtures/fixtures'; test.describe.configure({ mode: 'parallel' }); test.describe('API - Record Controller', () => { - test.beforeAll(async ({ apiRequestUtils }) => { + test.beforeEach(async ({ apiRequestUtils }) => { await apiRequestUtils.selectDefaultOrg(); }); diff --git a/libs/api-config/src/lib/env-config.ts b/libs/api-config/src/lib/env-config.ts index 63164dd6a..a2cfb8bc2 100644 --- a/libs/api-config/src/lib/env-config.ts +++ b/libs/api-config/src/lib/env-config.ts @@ -206,20 +206,3 @@ ${chalk.yellow(JSON.stringify(parseResults.error.flatten().fieldErrors, null, 2) export type Env = z.infer; export const ENV: Env = parseResults.data; - -// prettier-ignore -console.log(` - ██╗███████╗████████╗███████╗████████╗██████╗ ███████╗ █████╗ ███╗ ███╗ - ██║██╔════╝╚══██╔══╝██╔════╝╚══██╔══╝██╔══██╗██╔════╝██╔══██╗████╗ ████║ - ██║█████╗ ██║ ███████╗ ██║ ██████╔╝█████╗ ███████║██╔████╔██║ -██ ██║██╔══╝ ██║ ╚════██║ ██║ ██╔══██╗██╔══╝ ██╔══██║██║╚██╔╝██║ -╚█████╔╝███████╗ ██║ ███████║ ██║ ██║ ██║███████╗██║ ██║██║ ╚═╝ ██║ - ╚════╝ ╚══════╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ - -NODE_ENV=${ENV.NODE_ENV} -ENVIRONMENT=${ENV.ENVIRONMENT} -GIT_VERSION=${ENV.GIT_VERSION ?? ''} -LOG_LEVEL=${ENV.LOG_LEVEL} -JETSTREAM_SERVER_URL=${ENV.JETSTREAM_SERVER_URL} -JETSTREAM_CLIENT_URL=${ENV.JETSTREAM_CLIENT_URL} -`); From 8b1b0d57424ec8582b6d206a1eba248fb5c6c84e Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 3 Nov 2024 11:25:04 -0700 Subject: [PATCH 11/38] remove accidental package typo --- package.json | 1 - yarn.lock | 5 ----- 2 files changed, 6 deletions(-) diff --git a/package.json b/package.json index 52b7d2812..a7203c593 100644 --- a/package.json +++ b/package.json @@ -237,7 +237,6 @@ "zx": "^7.2.3" }, "dependencies": { - "-": "^0.0.1", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@express-rate-limit/cluster-memory-store": "^0.3.0", diff --git a/yarn.lock b/yarn.lock index 7caad26cc..e1d33f285 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,11 +2,6 @@ # yarn lockfile v1 -"-@^0.0.1": - version "0.0.1" - resolved "https://registry.yarnpkg.com/-/-/--0.0.1.tgz#db6db7cd866142880dd03e5b8781d1b4fac0e5bd" - integrity sha512-3HfneK3DGAm05fpyj20sT3apkNcvPpCuccOThOPdzz8sY7GgQGe0l93XH9bt+YzibcTIgUAIMoyVJI740RtgyQ== - "@aashutoshrathi/word-wrap@^1.2.3": version "1.2.6" resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" From 252e630e496f65f6a428f02c2fa11d5ee82a1406 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 3 Nov 2024 11:41:59 -0700 Subject: [PATCH 12/38] Dependency cleanup --- package.json | 3 +-- yarn.lock | 29 +++++++++++------------------ 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index a7203c593..3e7855873 100644 --- a/package.json +++ b/package.json @@ -145,12 +145,11 @@ "@types/cookie-parser": "^1.4.7", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", - "@types/express-rate-limit": "^6.0.0", "@types/express-session": "^1.17.0", "@types/file-saver": "^2.0.1", "@types/fs-extra": "^9.0.6", "@types/gapi.auth2": "^0.0.60", - "@types/gapi.client.drive": "^3.0.15", + "@types/gapi.client.drive-v3": "^0.0.5", "@types/google.accounts": "^0.0.2", "@types/google.picker": "^0.0.42", "@types/jest": "29.5.12", diff --git a/yarn.lock b/yarn.lock index e1d33f285..822474a79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5577,9 +5577,9 @@ "@types/gapi.client.discovery-v1" "*" "@maxim_mazurok/gapi.client.drive-v3@latest": - version "0.0.20240422" - resolved "https://registry.yarnpkg.com/@maxim_mazurok/gapi.client.drive-v3/-/gapi.client.drive-v3-0.0.20240422.tgz#9a1f3324c709c5007019a0fc038f7698f22a9409" - integrity sha512-icuJRgm+jySdgZCkfCsaiC2oUdgmOLv+rNZdybuJ+qHMCIK9qB+UJWM8CPdFryeA7gRPRFK7U1HEQgOyjhlpyg== + version "0.0.20241101" + resolved "https://registry.yarnpkg.com/@maxim_mazurok/gapi.client.drive-v3/-/gapi.client.drive-v3-0.0.20241101.tgz#b467721370c96cdfb87180099c2db5db06d901c7" + integrity sha512-cr3RshkAPa1bvcRAgBKz6E4SKm6+KgggH3H2SkBX/94Kr9NHD2apt80hqwp7FPS4nOM1mo143YfU4sreTQv9iQ== dependencies: "@types/gapi.client" "*" "@types/gapi.client.discovery-v1" "*" @@ -8909,13 +8909,6 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.1.tgz#aa22750962f3bf0e79d753d3cc067f010c95f194" integrity sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA== -"@types/express-rate-limit@^6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@types/express-rate-limit/-/express-rate-limit-6.0.0.tgz#11a314477895a8a888958f27650ed0d1ddad01b0" - integrity sha512-nZxo3nwU20EkTl/f2eGdndQkDIJYwkXIX4S3Vrp2jMdSdFJ6AWtIda8gOz0wiMuOFoeH/UUlCAiacz3x3eWNFA== - dependencies: - express-rate-limit "*" - "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.18": version "4.17.29" resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.29.tgz" @@ -9008,17 +9001,17 @@ dependencies: "@maxim_mazurok/gapi.client.discovery-v1" latest -"@types/gapi.client.drive@^3.0.15": - version "3.0.15" - resolved "https://registry.yarnpkg.com/@types/gapi.client.drive/-/gapi.client.drive-3.0.15.tgz#4c93f2335ccb53fa4032d8971e8d14e3a5a37783" - integrity sha512-qEfI0LxUBadOLmym4FkaNGpI4ibBCBPJHiUFWKIv0GIp7yKT2d+wztJYKr9giIRecErUCF+jGSDw1fzTZ6hPVQ== +"@types/gapi.client.drive-v3@^0.0.5": + version "0.0.5" + resolved "https://registry.yarnpkg.com/@types/gapi.client.drive-v3/-/gapi.client.drive-v3-0.0.5.tgz#19aa36500e6cd4c27bf9646fdde370792f72230f" + integrity sha512-yYBxiqMqJVBg4bns4Q28+f2XdJnd3tVA9dxQX1lXMVmzT2B+pZdyCi1u9HLwGveVlookSsAXuqfLfS9KO6MF6w== dependencies: "@maxim_mazurok/gapi.client.drive-v3" latest "@types/gapi.client@*": - version "1.0.5" - resolved "https://registry.npmjs.org/@types/gapi.client/-/gapi.client-1.0.5.tgz" - integrity sha512-OTpbBMuzfC4lkvaomxqskI/iWRGW3zOZbDXZLNSyiuswTiSSGgILRLkg0POuZ4EgzEdaYaTlXpnXiCp07ri/Yw== + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/gapi.client/-/gapi.client-1.0.8.tgz#8e02c57493b014521f2fa3359166c01dc2861cd7" + integrity sha512-qJQUmmumbYym3Amax0S8CVzuSngcXsC1fJdwRS2zeW5lM63zXkw4wJFP+bG0jzgi0R6EsJKoHnGNVTDbOyG1ng== "@types/gapi@*": version "0.0.42" @@ -14694,7 +14687,7 @@ express-promise-router@^4.1.1: lodash.flattendeep "^4.0.0" methods "^1.0.0" -express-rate-limit@*, express-rate-limit@^7.4.0: +express-rate-limit@^7.4.0: version "7.4.0" resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-7.4.0.tgz#5db412b8de83fa07ddb40f610c585ac8c1dab988" integrity sha512-v1204w3cXu5gCDmAvgvzI6qjzZzoMWKnyVDk3ACgfswTQLYiGen+r8w0VnXnGMmzEN/g8fwIQ4JrFFd4ZP6ssg== From dcc6b97e26f588ffa8ef749dff7bba750bed7d09 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 3 Nov 2024 12:01:43 -0700 Subject: [PATCH 13/38] Ensure next_public env vars are set during CI build --- .github/workflows/ci.yml | 25 +++++++++++-------------- docker-compose.yml | 7 ++----- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e3b73910..71c020633 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ env: NX_CLOUD_DISTRIBUTED_EXECUTION: false NX_PUBLIC_AMPLITUDE_KEY: ${{ secrets.NX_PUBLIC_AMPLITUDE_KEY }} NX_PUBLIC_ROLLBAR_KEY: ${{ secrets.NX_PUBLIC_ROLLBAR_KEY }} + NEXT_PUBLIC_CLIENT_URL: 'http://localhost:3333/app' + NEXT_PUBLIC_SERVER_URL: 'http://localhost:3333' jobs: # Build application @@ -61,34 +63,29 @@ jobs: needs: build-and-test runs-on: ubuntu-latest env: - LOG_LEVEL: warn + NX_CLOUD_DISTRIBUTED_EXECUTION: false + AUTH_SFDC_CLIENT_ID: ${{ secrets.SFDC_CONSUMER_KEY }} + AUTH_SFDC_CLIENT_SECRET: ${{ secrets.SFDC_CONSUMER_SECRET }} + E2E_LOGIN_PASSWORD: ${{ secrets.E2E_LOGIN_PASSWORD }} E2E_LOGIN_URL: 'https://jetstream-e2e-dev-ed.develop.my.salesforce.com' E2E_LOGIN_USERNAME: 'integration@jetstream.app.e2e' - E2E_LOGIN_PASSWORD: ${{ secrets.E2E_LOGIN_PASSWORD }} EXAMPLE_USER_OVERRIDE: true EXAMPLE_USER_PASSWORD: 'EXAMPLE_123!' GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} GOOGLE_APP_ID: ${{ secrets.GOOGLE_APP_ID }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} - JETSTREAM_POSTGRES_DBURI: postgres://postgres:postgres@localhost:5432/postgres - JETSTREAM_SESSION_SECRET: '8e52194ce3b6650b93e95a5c40a705b2' - JETSTREAM_AUTH_SECRET: 'l26oD1TYqkJP/AZccmFwX2gPO45rG1qQuSXjVxRj9U/3' - JETSTREAM_AUTH_OTP_SECRET: 'pD0AwvBhZU5COntz97OBDAtonoEe/Z0lz5ulNFl4K04=' + JETSTREAM_AUTH_OTP_SECRET: ${{ secrets.JETSTREAM_AUTH_OTP_SECRET }} + JETSTREAM_AUTH_SECRET: ${{ secrets.JETSTREAM_AUTH_SECRET }} JETSTREAM_CLIENT_URL: http://localhost:3333/app + JETSTREAM_POSTGRES_DBURI: postgres://postgres:postgres@localhost:5432/postgres JETSTREAM_SERVER_DOMAIN: localhost:3333 JETSTREAM_SERVER_URL: http://localhost:3333 - NEXT_PUBLIC_CLIENT_URL: 'http://localhost:4200/app' - NEXT_PUBLIC_SERVER_URL: 'http://localhost:3333' - NX_PUBLIC_AMPLITUDE_KEY: ${{ secrets.NX_PUBLIC_AMPLITUDE_KEY }} - NX_CLOUD_DISTRIBUTED_EXECUTION: false - NX_PUBLIC_ROLLBAR_KEY: ${{ secrets.NX_PUBLIC_ROLLBAR_KEY }} + JETSTREAM_SESSION_SECRET: ${{ secrets.JETSTREAM_SESSION_SECRET }} + SFDC_API_VERSION: '61.0' SFDC_CALLBACK_URL: http://localhost:3333/oauth/sfdc/callback SFDC_CONSUMER_KEY: ${{ secrets.SFDC_CONSUMER_KEY }} SFDC_CONSUMER_SECRET: ${{ secrets.SFDC_CONSUMER_SECRET }} - AUTH_SFDC_CLIENT_ID: ${{ secrets.SFDC_CONSUMER_KEY }} - AUTH_SFDC_CLIENT_SECRET: ${{ secrets.SFDC_CONSUMER_SECRET }} - SFDC_API_VERSION: '61.0' services: postgres: diff --git a/docker-compose.yml b/docker-compose.yml index 9bc7b8b39..185e8cd87 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: db: condition: service_healthy env_file: - - .env.example + - .env environment: NODE_ENV: development ENVIRONMENT: development @@ -16,13 +16,10 @@ services: JETSTREAM_POSTGRES_DBURI: postgres://postgres:postgres@postgres:5432/postgres EXAMPLE_USER_OVERRIDE: true EXAMPLE_USER_PASSWORD: 'EXAMPLE_123!' - JETSTREAM_SESSION_SECRET: '8e52194ce3b6650b93e95a5c40a705b2' - JETSTREAM_AUTH_SECRET: 'l26oD1TYqkJP/AZccmFwX2gPO45rG1qQuSXjVxRj9U/3' - JETSTREAM_AUTH_OTP_SECRET: 'pD0AwvBhZU5COntz97OBDAtonoEe/Z0lz5ulNFl4K04=' JETSTREAM_CLIENT_URL: http://localhost:3333/app JETSTREAM_SERVER_DOMAIN: localhost:3333 JETSTREAM_SERVER_URL: http://localhost:3333 - NEXT_PUBLIC_CLIENT_URL: 'http://localhost:4200/app' + NEXT_PUBLIC_CLIENT_URL: 'http://localhost:3333/app' NEXT_PUBLIC_SERVER_URL: 'http://localhost:3333' ports: From 0dbbc07a2c407d6e308377a184ed9c52b422b773 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 3 Nov 2024 12:25:42 -0700 Subject: [PATCH 14/38] Fix gapi drive types --- apps/api/tsconfig.app.json | 2 +- apps/cron-tasks/tsconfig.app.json | 2 +- apps/jetstream-web-extension/tsconfig.app.json | 2 +- apps/jetstream-web-extension/tsconfig.json | 2 +- apps/jetstream-worker/tsconfig.app.json | 2 +- apps/jetstream-worker/tsconfig.json | 2 +- apps/jetstream/tsconfig.app.json | 2 +- apps/jetstream/tsconfig.json | 2 +- apps/landing/tsconfig.json | 2 +- libs/salesforce-api/tsconfig.lib.json | 2 +- libs/salesforce-api/tsconfig.spec.json | 2 +- libs/shared/ui-core-shared/tsconfig.lib.json | 2 +- libs/shared/ui-core/tsconfig.lib.json | 2 +- libs/shared/ui-utils/tsconfig.json | 2 +- libs/shared/ui-utils/tsconfig.lib.json | 2 +- libs/types/tsconfig.json | 2 +- libs/types/tsconfig.lib.json | 2 +- libs/ui/src/lib/form/file-selector/GoogleFileSelector.tsx | 1 + libs/ui/tsconfig.json | 2 +- libs/ui/tsconfig.lib.json | 2 +- libs/ui/tsconfig.spec.json | 2 +- 21 files changed, 21 insertions(+), 20 deletions(-) diff --git a/apps/api/tsconfig.app.json b/apps/api/tsconfig.app.json index ae0dc35c7..f4c026432 100644 --- a/apps/api/tsconfig.app.json +++ b/apps/api/tsconfig.app.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "module": "commonjs", - "types": ["node", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + "types": ["node", "google.accounts", "google.picker", "gapi.auth2"] }, "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], "include": ["src/**/*.ts"] diff --git a/apps/cron-tasks/tsconfig.app.json b/apps/cron-tasks/tsconfig.app.json index ad75a0311..a3abf41de 100644 --- a/apps/cron-tasks/tsconfig.app.json +++ b/apps/cron-tasks/tsconfig.app.json @@ -8,7 +8,7 @@ "strictNullChecks": true, "outDir": "../../dist/out-tsc", "target": "esnext", - "types": ["node", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + "types": ["node", "google.accounts", "google.picker", "gapi.auth2"] }, "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"], "include": ["**/*.ts"] diff --git a/apps/jetstream-web-extension/tsconfig.app.json b/apps/jetstream-web-extension/tsconfig.app.json index 0f85b1d31..91f144c03 100644 --- a/apps/jetstream-web-extension/tsconfig.app.json +++ b/apps/jetstream-web-extension/tsconfig.app.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "types": ["node", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + "types": ["node", "google.accounts", "google.picker", "gapi.auth2"] }, "exclude": [ "src/**/*.spec.ts", diff --git a/apps/jetstream-web-extension/tsconfig.json b/apps/jetstream-web-extension/tsconfig.json index ae449ddd4..859fe09c8 100644 --- a/apps/jetstream-web-extension/tsconfig.json +++ b/apps/jetstream-web-extension/tsconfig.json @@ -14,7 +14,7 @@ "resolveJsonModule": true, "skipLibCheck": true, "strictNullChecks": true, - "types": ["chrome-types", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + "types": ["chrome-types", "google.accounts", "google.picker", "gapi.auth2"] }, "files": [], "include": [], diff --git a/apps/jetstream-worker/tsconfig.app.json b/apps/jetstream-worker/tsconfig.app.json index 27ecb49dd..ee11b3e87 100644 --- a/apps/jetstream-worker/tsconfig.app.json +++ b/apps/jetstream-worker/tsconfig.app.json @@ -5,7 +5,7 @@ "module": "commonjs", "allowSyntheticDefaultImports": true, "strictNullChecks": true, - "types": ["node", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + "types": ["node", "google.accounts", "google.picker", "gapi.auth2"] }, "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"], "include": ["**/*.ts"] diff --git a/apps/jetstream-worker/tsconfig.json b/apps/jetstream-worker/tsconfig.json index 97649d35b..086d9dfb4 100644 --- a/apps/jetstream-worker/tsconfig.json +++ b/apps/jetstream-worker/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "types": ["node", "jest", "express", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + "types": ["node", "jest", "express", "google.accounts", "google.picker", "gapi.auth2"] }, "files": [], "include": [], diff --git a/apps/jetstream/tsconfig.app.json b/apps/jetstream/tsconfig.app.json index c4c2ffb3a..57910071b 100644 --- a/apps/jetstream/tsconfig.app.json +++ b/apps/jetstream/tsconfig.app.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "types": ["node", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + "types": ["node", "google.accounts", "google.picker", "gapi.auth2"] }, "exclude": ["**/*.spec.ts", "**/*.test.ts", "**/*.spec.tsx", "**/*.test.tsx", "jest.config.ts"], "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx", "../../custom-typings/index.d.ts"], diff --git a/apps/jetstream/tsconfig.json b/apps/jetstream/tsconfig.json index cd7457292..559eae1f5 100644 --- a/apps/jetstream/tsconfig.json +++ b/apps/jetstream/tsconfig.json @@ -16,7 +16,7 @@ "skipLibCheck": true, "strictNullChecks": true, "target": "ESNext", - "types": ["vite/client", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"], + "types": ["vite/client", "google.accounts", "google.picker", "gapi.auth2"], "useDefineForClassFields": true }, "files": [], diff --git a/apps/landing/tsconfig.json b/apps/landing/tsconfig.json index 4a1bc7986..e6ee73992 100644 --- a/apps/landing/tsconfig.json +++ b/apps/landing/tsconfig.json @@ -5,7 +5,7 @@ "allowJs": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, - "types": ["node", "jest", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"], + "types": ["node", "jest", "google.accounts", "google.picker", "gapi.auth2"], "strict": false, "forceConsistentCasingInFileNames": true, "noEmit": true, diff --git a/libs/salesforce-api/tsconfig.lib.json b/libs/salesforce-api/tsconfig.lib.json index 1a4e9f7e7..83561e0ca 100644 --- a/libs/salesforce-api/tsconfig.lib.json +++ b/libs/salesforce-api/tsconfig.lib.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "declaration": true, - "types": ["node", "google.accounts", "google.picker", "gapi.client.drive"] + "types": ["node", "google.accounts", "google.picker"] }, "include": ["src/**/*.ts"], "exclude": ["vite.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] diff --git a/libs/salesforce-api/tsconfig.spec.json b/libs/salesforce-api/tsconfig.spec.json index 5d807c236..aba2b662d 100644 --- a/libs/salesforce-api/tsconfig.spec.json +++ b/libs/salesforce-api/tsconfig.spec.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "module": "NodeNext", - "types": ["node", "jest", "google.accounts", "google.picker", "gapi.client.drive"] + "types": ["node", "jest", "google.accounts", "google.picker"] }, "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] } diff --git a/libs/shared/ui-core-shared/tsconfig.lib.json b/libs/shared/ui-core-shared/tsconfig.lib.json index 39af3d223..3d7e2e39c 100644 --- a/libs/shared/ui-core-shared/tsconfig.lib.json +++ b/libs/shared/ui-core-shared/tsconfig.lib.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../../dist/out-tsc", - "types": ["node", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + "types": ["node", "google.accounts", "google.picker", "gapi.auth2"] }, "exclude": [ "jest.config.ts", diff --git a/libs/shared/ui-core/tsconfig.lib.json b/libs/shared/ui-core/tsconfig.lib.json index 8ad846986..0caec539d 100644 --- a/libs/shared/ui-core/tsconfig.lib.json +++ b/libs/shared/ui-core/tsconfig.lib.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../../dist/out-tsc", - "types": ["node", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive", "vite/client"] + "types": ["node", "google.accounts", "google.picker", "gapi.auth2", "vite/client"] }, "exclude": [ "jest.config.ts", diff --git a/libs/shared/ui-utils/tsconfig.json b/libs/shared/ui-utils/tsconfig.json index 826fdb3e0..123210484 100644 --- a/libs/shared/ui-utils/tsconfig.json +++ b/libs/shared/ui-utils/tsconfig.json @@ -7,7 +7,7 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strictNullChecks": true, - "types": ["node", "jest", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + "types": ["node", "jest", "google.accounts", "google.picker", "gapi.auth2"] }, "files": [], "include": [], diff --git a/libs/shared/ui-utils/tsconfig.lib.json b/libs/shared/ui-utils/tsconfig.lib.json index 1e6f2f4e9..9bde3bc72 100644 --- a/libs/shared/ui-utils/tsconfig.lib.json +++ b/libs/shared/ui-utils/tsconfig.lib.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../../dist/out-tsc", - "types": ["node", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + "types": ["node", "google.accounts", "google.picker", "gapi.auth2"] }, "exclude": ["**/*.spec.ts", "**/*.test.ts", "**/*.spec.tsx", "**/*.test.tsx", "jest.config.ts"], "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"], diff --git a/libs/types/tsconfig.json b/libs/types/tsconfig.json index dc85ab2a1..feb1ecf01 100644 --- a/libs/types/tsconfig.json +++ b/libs/types/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "strictNullChecks": true, - "types": ["node", "jest", "google.accounts", "google.picker", "gapi.client.drive"] + "types": ["node", "jest", "google.accounts", "google.picker"] }, "include": [], "files": [], diff --git a/libs/types/tsconfig.lib.json b/libs/types/tsconfig.lib.json index 1ad497969..945cf0cfd 100644 --- a/libs/types/tsconfig.lib.json +++ b/libs/types/tsconfig.lib.json @@ -4,7 +4,7 @@ "module": "commonjs", "outDir": "../../dist/out-tsc", "declaration": true, - "types": ["node", "google.accounts", "google.picker", "gapi.client.drive"] + "types": ["node", "google.accounts", "google.picker"] }, "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"], "include": ["**/*.ts"] diff --git a/libs/ui/src/lib/form/file-selector/GoogleFileSelector.tsx b/libs/ui/src/lib/form/file-selector/GoogleFileSelector.tsx index cc47d2fdb..1af691990 100644 --- a/libs/ui/src/lib/form/file-selector/GoogleFileSelector.tsx +++ b/libs/ui/src/lib/form/file-selector/GoogleFileSelector.tsx @@ -1,3 +1,4 @@ +/// import { logger } from '@jetstream/shared/client-logger'; import { GoogleApiClientConfig, initXlsx, useDrivePicker } from '@jetstream/shared/ui-utils'; import { InputReadGoogleSheet, Maybe } from '@jetstream/types'; diff --git a/libs/ui/tsconfig.json b/libs/ui/tsconfig.json index 4179bec70..270f87ce5 100644 --- a/libs/ui/tsconfig.json +++ b/libs/ui/tsconfig.json @@ -7,7 +7,7 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strictNullChecks": true, - "types": ["node", "jest", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + "types": ["node", "jest", "google.accounts", "google.picker", "gapi.auth2"] }, "files": [], "include": [], diff --git a/libs/ui/tsconfig.lib.json b/libs/ui/tsconfig.lib.json index 1afe1e9f5..7877064da 100644 --- a/libs/ui/tsconfig.lib.json +++ b/libs/ui/tsconfig.lib.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "types": ["node", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + "types": ["node", "google.accounts", "google.picker", "gapi.auth2"] }, "exclude": [ "**/*.spec.ts", diff --git a/libs/ui/tsconfig.spec.json b/libs/ui/tsconfig.spec.json index 2dfe10328..d4a47398f 100644 --- a/libs/ui/tsconfig.spec.json +++ b/libs/ui/tsconfig.spec.json @@ -4,7 +4,7 @@ "outDir": "../../dist/out-tsc", "module": "commonjs", "strictNullChecks": false, - "types": ["jest", "node", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + "types": ["jest", "node", "google.accounts", "google.picker", "gapi.auth2"] }, "include": [ "**/*.spec.ts", From 22f344c862136faa2b131ebebf9462b957f3df90 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 3 Nov 2024 12:58:37 -0700 Subject: [PATCH 15/38] Fix test - did not check remember me checkbox --- apps/jetstream-e2e/src/tests/authentication/login1.spec.ts | 2 +- apps/landing/components/auth/LoginOrSignUpWrapper.tsx | 1 + libs/api-config/src/lib/api-logger.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/jetstream-e2e/src/tests/authentication/login1.spec.ts b/apps/jetstream-e2e/src/tests/authentication/login1.spec.ts index b59c2071e..2375d5b0e 100644 --- a/apps/jetstream-e2e/src/tests/authentication/login1.spec.ts +++ b/apps/jetstream-e2e/src/tests/authentication/login1.spec.ts @@ -53,7 +53,7 @@ test.describe('Login 1', () => { }); await test.step('Login with remembered device', async () => { - await authenticationPage.loginAndVerifyEmail(email, password); + await authenticationPage.loginAndVerifyEmail(email, password, true); await expect(page.url()).toContain('/app'); await playwrightPage.logout(); await expect(page.getByTestId('home-hero-container')).toBeVisible(); diff --git a/apps/landing/components/auth/LoginOrSignUpWrapper.tsx b/apps/landing/components/auth/LoginOrSignUpWrapper.tsx index 8e92fc638..9e6a8045b 100644 --- a/apps/landing/components/auth/LoginOrSignUpWrapper.tsx +++ b/apps/landing/components/auth/LoginOrSignUpWrapper.tsx @@ -37,6 +37,7 @@ export function LoginOrSignUpWrapper({ action }: LoginOrSignUpWrapperProps) { setProviders(data); }) .catch((error) => { + console.error('Error fetching providers', error); setError(error?.message ?? 'Unable to initialize the form'); }); }, []); diff --git a/libs/api-config/src/lib/api-logger.ts b/libs/api-config/src/lib/api-logger.ts index c4fa31ae2..01fab6522 100644 --- a/libs/api-config/src/lib/api-logger.ts +++ b/libs/api-config/src/lib/api-logger.ts @@ -7,7 +7,7 @@ import { ENV } from './env-config'; export const logger = pino({ level: ENV.LOG_LEVEL, transport: - ENV.ENVIRONMENT === 'development' && !ENV.IS_LOCAL_DOCKER + ENV.ENVIRONMENT === 'development' && !ENV.IS_LOCAL_DOCKER && !ENV.CI ? { target: 'pino-pretty', } From 35e254f34bbeb28daa7ed4e67227e0a636bc2e17 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 3 Nov 2024 13:51:37 -0700 Subject: [PATCH 16/38] E2E fix tests: Optionally authorize org if needed and fix security test data --- .../src/pageObjectModels/OrganizationsPage.ts | 8 ++ .../src/tests/security/security.spec.ts | 135 +++++++++++------- 2 files changed, 89 insertions(+), 54 deletions(-) diff --git a/apps/jetstream-e2e/src/pageObjectModels/OrganizationsPage.ts b/apps/jetstream-e2e/src/pageObjectModels/OrganizationsPage.ts index d9e7d4883..8f7692198 100644 --- a/apps/jetstream-e2e/src/pageObjectModels/OrganizationsPage.ts +++ b/apps/jetstream-e2e/src/pageObjectModels/OrganizationsPage.ts @@ -71,6 +71,14 @@ export class OrganizationsPage { await salesforcePage.getByRole('button', { name: 'Log In' }).click(); + try { + const allowLocator = this.page.getByRole('button', { name: 'Allow' }); + await expect(allowLocator).toBeVisible({ timeout: 5000 }); + await allowLocator.click(); + } catch { + // ignore error - this is expected if the org is already authorized + } + await pageClosePromise; } diff --git a/apps/jetstream-e2e/src/tests/security/security.spec.ts b/apps/jetstream-e2e/src/tests/security/security.spec.ts index 1ef594f71..f3ed52374 100644 --- a/apps/jetstream-e2e/src/tests/security/security.spec.ts +++ b/apps/jetstream-e2e/src/tests/security/security.spec.ts @@ -11,74 +11,101 @@ test.describe.configure({ mode: 'parallel' }); test.use({ storageState: { cookies: [], origins: [] } }); test.describe('Security Checks', () => { - // TODO: maybe look into tampering with cookies? - test('Attempt to access data different user', async ({ apiRequestUtils, newUser }) => { const { email } = newUser; - const { salesforceOrgs, jetstreamOrgs } = await test.step('Sign up and verify email', async () => { - // const { email, password, name } = await authenticationPage.signUpAndVerifyEmail(); - const salesforceOrgs = await prisma.salesforceOrg.findMany({ - where: { - jetstreamUser: { - email: { - not: email, - }, - }, - }, - }); + const { salesforceOrg, jetstreamOrg } = await test.step('Sign up and verify email', async () => { + const userId = 'BAAAAAAA-1000-1000-1000-BAAAAAAAAAAA'; + const email = `test-example-${new Date().getTime()}@getjetstream.app`; + const jetstreamUserId = `test|${userId}`; - const jetstreamOrgs = await prisma.jetstreamOrganization.findMany({ - where: { - user: { - email: { - not: email, + const otherUser = await prisma.user.create({ + data: { + id: userId, + userId: jetstreamUserId, + email: `test-example-${new Date().getTime()}@getjetstream.app`, + emailVerified: true, + name: 'Test User', + lastLoggedIn: new Date(), + preferences: { create: { skipFrontdoorLogin: false } }, + authFactors: { create: { type: '2fa-email', enabled: false } }, + salesforceOrgs: { + create: { + jetstreamUserId, + jetstreamUrl: 'https://localhost:3333', + jetstreamOrganization: { + create: { + name: 'Test Org', + description: 'Test Description', + userId, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + label: 'Test Org', + uniqueId: 'Test-Test', + accessToken: + 'EqbKGE6V3v58bUfweWrzaQ==!nd6ZL+eTulpXc1Z1SJSpFA881pt0bcH4vYpTpWr+WLGJJWKO+pnXCLdls1LO0qZkpKk1irJ0VDDIXj6rN6t588KZLrX0zyYuthilvBDfUVO/xGg/MclFs04IHJ+CfrPf3zkGGcLpQziM3hvTqoWgbX65vI4wknLjPabAc+z35fAliiTjdRqLIUNAvevEgDRD+J2lDqH4Vud36g3baBr2OV+bBD3fa9aE/TISxjwUmI7DqFBfgwXHvarLgA4FX6oW/bP6336q5YJ1zeIm+5Lq9g==', + instanceUrl: 'https://jetstream-e2e-dev-ed.develop.my.salesforce.com', + loginUrl: 'https://jetstream-e2e-dev-ed.develop.my.salesforce.com', + userId: '005Dn000003DuJgIAK', + email, + organizationId: 'test', + username: email, + displayName: email, + orgName: 'test', + orgCountry: 'US', + orgOrganizationType: 'Developer Edition', + orgInstanceName: 'NA224', + orgIsSandbox: false, + orgLanguageLocaleKey: 'en-US', + filterText: 'test', }, }, }, }); - await expect(salesforceOrgs.length).toBeGreaterThanOrEqual(1); - await expect(jetstreamOrgs.length).toBeGreaterThanOrEqual(1); + const salesforceOrg = await prisma.salesforceOrg.findFirst({ where: { jetstreamUserId2: userId } }); + const jetstreamOrg = await prisma.jetstreamOrganization.findFirst({ where: { userId } }); - return { salesforceOrgs, jetstreamOrgs }; + return { salesforceOrg, jetstreamOrg }; }); await test.step('Try to update or delete org from different user', async () => { const orgsResponse = await apiRequestUtils.makeRequestRaw('GET', `/api/orgs`); await expect(orgsResponse.ok()).toEqual(true); - const updateSalesforceOrgResponse = await apiRequestUtils.makeRequestRaw('PATCH', `/api/orgs/${salesforceOrgs[0].uniqueId}`, { - jetstreamOrganizationId: salesforceOrgs[0].jetstreamOrganizationId, - uniqueId: salesforceOrgs[0].uniqueId, + const updateSalesforceOrgResponse = await apiRequestUtils.makeRequestRaw('PATCH', `/api/orgs/${salesforceOrg.uniqueId}`, { + jetstreamOrganizationId: salesforceOrg.jetstreamOrganizationId, + uniqueId: salesforceOrg.uniqueId, label: 'I should not be able to change anything!', - filterText: salesforceOrgs[0].filterText, - instanceUrl: salesforceOrgs[0].instanceUrl, - loginUrl: salesforceOrgs[0].loginUrl, - userId: salesforceOrgs[0].userId, - email: salesforceOrgs[0].email, - organizationId: salesforceOrgs[0].organizationId, - username: salesforceOrgs[0].username, - displayName: salesforceOrgs[0].displayName, - thumbnail: salesforceOrgs[0].thumbnail, - apiVersion: salesforceOrgs[0].apiVersion, - orgName: salesforceOrgs[0].orgName, - orgCountry: salesforceOrgs[0].orgCountry, - orgOrganizationType: salesforceOrgs[0].orgOrganizationType, - orgInstanceName: salesforceOrgs[0].orgInstanceName, - orgIsSandbox: salesforceOrgs[0].orgIsSandbox, - orgLanguageLocaleKey: salesforceOrgs[0].orgLanguageLocaleKey, - orgNamespacePrefix: salesforceOrgs[0].orgNamespacePrefix, - orgTrialExpirationDate: salesforceOrgs[0].orgTrialExpirationDate, - color: salesforceOrgs[0].color, - connectionError: salesforceOrgs[0].connectionError, - createdAt: salesforceOrgs[0].createdAt, - updatedAt: salesforceOrgs[0].updatedAt, + filterText: salesforceOrg.filterText, + instanceUrl: salesforceOrg.instanceUrl, + loginUrl: salesforceOrg.loginUrl, + userId: salesforceOrg.userId, + email: salesforceOrg.email, + organizationId: salesforceOrg.organizationId, + username: salesforceOrg.username, + displayName: salesforceOrg.displayName, + thumbnail: salesforceOrg.thumbnail, + apiVersion: salesforceOrg.apiVersion, + orgName: salesforceOrg.orgName, + orgCountry: salesforceOrg.orgCountry, + orgOrganizationType: salesforceOrg.orgOrganizationType, + orgInstanceName: salesforceOrg.orgInstanceName, + orgIsSandbox: salesforceOrg.orgIsSandbox, + orgLanguageLocaleKey: salesforceOrg.orgLanguageLocaleKey, + orgNamespacePrefix: salesforceOrg.orgNamespacePrefix, + orgTrialExpirationDate: salesforceOrg.orgTrialExpirationDate, + color: salesforceOrg.color, + connectionError: salesforceOrg.connectionError, + createdAt: salesforceOrg.createdAt, + updatedAt: salesforceOrg.updatedAt, }); await expect(updateSalesforceOrgResponse.ok()).toBeFalsy(); await expect(updateSalesforceOrgResponse.status()).toBe(404); - const deleteSalesforceOrgResponse = await apiRequestUtils.makeRequestRaw('DELETE', `/api/orgs/${salesforceOrgs[0].uniqueId}`); + const deleteSalesforceOrgResponse = await apiRequestUtils.makeRequestRaw('DELETE', `/api/orgs/${salesforceOrg.uniqueId}`); await expect(deleteSalesforceOrgResponse.ok()).toBeFalsy(); await expect(deleteSalesforceOrgResponse.status()).toBe(404); }); @@ -89,7 +116,7 @@ test.describe('Security Checks', () => { `/api/query?isTooling=false&includeDeletedRecords=false`, { query: 'SELECT Id FROM Account' }, { - 'X-SFDC-ID': salesforceOrgs[0].uniqueId, + 'X-SFDC-ID': salesforceOrg.uniqueId, } ); await expect(useOrgFromDifferentUserResponse.ok()).toBeFalsy(); @@ -97,18 +124,18 @@ test.describe('Security Checks', () => { }); await test.step('Try to update and delete a Jetstream organization from a different user', async () => { - const updateJetstreamResponse = await apiRequestUtils.makeRequestRaw('PUT', `/api/orgs/${jetstreamOrgs[0].id}`, { - id: jetstreamOrgs[0].id, + const updateJetstreamResponse = await apiRequestUtils.makeRequestRaw('PUT', `/api/orgs/${jetstreamOrg.id}`, { + id: jetstreamOrg.id, orgs: [], name: 'I should not be able to change anything!', - description: jetstreamOrgs[0].description, - createdAt: jetstreamOrgs[0].createdAt, - updatedAt: jetstreamOrgs[0].updatedAt, + description: jetstreamOrg.description, + createdAt: jetstreamOrg.createdAt, + updatedAt: jetstreamOrg.updatedAt, }); await expect(updateJetstreamResponse.ok()).toBeFalsy(); await expect(updateJetstreamResponse.status()).toBe(404); - const deleteJetstreamResponse = await apiRequestUtils.makeRequestRaw('DELETE', `/api/orgs/${jetstreamOrgs[0].id}`); + const deleteJetstreamResponse = await apiRequestUtils.makeRequestRaw('DELETE', `/api/orgs/${jetstreamOrg.id}`); await expect(deleteJetstreamResponse.ok()).toBeFalsy(); await expect(deleteJetstreamResponse.status()).toBe(404); }); From d75b54044775f277bbe6679c5b790159a33730cf Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 3 Nov 2024 14:22:56 -0700 Subject: [PATCH 17/38] Fix Text: ensure that oauth2 accept button is clicked if required --- .../jetstream-e2e/src/pageObjectModels/OrganizationsPage.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/jetstream-e2e/src/pageObjectModels/OrganizationsPage.ts b/apps/jetstream-e2e/src/pageObjectModels/OrganizationsPage.ts index 8f7692198..58d5f5e9d 100644 --- a/apps/jetstream-e2e/src/pageObjectModels/OrganizationsPage.ts +++ b/apps/jetstream-e2e/src/pageObjectModels/OrganizationsPage.ts @@ -67,13 +67,13 @@ export class OrganizationsPage { await salesforcePage.getByLabel('Password').click(); await salesforcePage.getByLabel('Password').fill(password); - const pageClosePromise = salesforcePage.waitForEvent('close'); + const pageClosePromise = salesforcePage.waitForEvent('close', { timeout: 30000 }); await salesforcePage.getByRole('button', { name: 'Log In' }).click(); try { - const allowLocator = this.page.getByRole('button', { name: 'Allow' }); - await expect(allowLocator).toBeVisible({ timeout: 5000 }); + const allowLocator = salesforcePage.getByRole('button', { name: 'Allow' }); + await expect(allowLocator).toBeVisible({ timeout: 1000 }); await allowLocator.click(); } catch { // ignore error - this is expected if the org is already authorized From a9375464668f4386b7967b892b1e2acbff2b30ac Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Mon, 4 Nov 2024 08:41:05 -0700 Subject: [PATCH 18/38] Show which email was used while there is pending auth verification --- .../src/app/controllers/auth.controller.ts | 4 +++ .../components/auth/VerifyEmailOr2fa.tsx | 30 ++++++++++++++----- .../auth/VerifyEmailOr2faWrapper.tsx | 4 +-- apps/landing/hooks/auth.hooks.ts | 6 ++++ 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/apps/api/src/app/controllers/auth.controller.ts b/apps/api/src/app/controllers/auth.controller.ts index 83b75b8e9..609ea1ead 100644 --- a/apps/api/src/app/controllers/auth.controller.ts +++ b/apps/api/src/app/controllers/auth.controller.ts @@ -239,6 +239,10 @@ const getSession = createRoute(routeDefinition.getSession.validators, async (_, sendJson(res, { isLoggedIn: !!req.session.user && !req.session.pendingVerification?.length, + email: + req.session.user?.email && req.session.pendingVerification?.some(({ type }) => type === 'email' || type === '2fa-email') + ? req.session.user.email + : null, pendingVerifications: req.session.pendingVerification?.map(({ type }) => type) || false, isVerificationExpired, }); diff --git a/apps/landing/components/auth/VerifyEmailOr2fa.tsx b/apps/landing/components/auth/VerifyEmailOr2fa.tsx index d329162a0..fad9ce9d4 100644 --- a/apps/landing/components/auth/VerifyEmailOr2fa.tsx +++ b/apps/landing/components/auth/VerifyEmailOr2fa.tsx @@ -1,6 +1,7 @@ /* eslint-disable @next/next/no-img-element */ import { zodResolver } from '@hookform/resolvers/zod'; import { TwoFactorType } from '@jetstream/auth/types'; +import { Maybe } from '@jetstream/types'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { FormEvent, Fragment, useState } from 'react'; @@ -18,20 +19,33 @@ const FormSchema = z.object({ rememberDevice: z.boolean().optional().default(false), }); -const TITLE_TEXT = { - email: 'Verify your email address', - '2fa-email': 'Enter your verification code from your email', - '2fa-otp': 'Enter your verification code from your authenticator app', -}; +function getTitleText(authFactor: TwoFactorType, email?: Maybe) { + if (email) { + const TITLE_TEXT: Record = { + email: `Verify your email address ${email}`, + '2fa-email': `Enter verification code sent to ${email}`, + '2fa-otp': 'Enter your verification code from your authenticator app', + }; + return TITLE_TEXT[authFactor]; + } + + const TITLE_TEXT: Record = { + email: 'Verify your email address', + '2fa-email': `Enter your verification sent to your email`, + '2fa-otp': 'Enter your verification code from your authenticator app', + }; + return TITLE_TEXT[authFactor]; +} type Form = z.infer; interface VerifyEmailOr2faProps { csrfToken: string; + email?: Maybe; pendingVerifications: TwoFactorType[]; } -export function VerifyEmailOr2fa({ csrfToken, pendingVerifications }: VerifyEmailOr2faProps) { +export function VerifyEmailOr2fa({ csrfToken, email, pendingVerifications }: VerifyEmailOr2faProps) { const router = useRouter(); const [error, setError] = useState(); const [hasResent, setHasResent] = useState(false); @@ -125,7 +139,9 @@ export function VerifyEmailOr2fa({ csrfToken, pendingVerifications }: VerifyEmai className="mx-auto h-10 w-auto" /> -

    {TITLE_TEXT[activeFactor]}

    +

    + {getTitleText(activeFactor, email)} +

    diff --git a/apps/landing/components/auth/VerifyEmailOr2faWrapper.tsx b/apps/landing/components/auth/VerifyEmailOr2faWrapper.tsx index 8788054f3..0e0549424 100644 --- a/apps/landing/components/auth/VerifyEmailOr2faWrapper.tsx +++ b/apps/landing/components/auth/VerifyEmailOr2faWrapper.tsx @@ -7,7 +7,7 @@ import { VerifyEmailOr2fa } from './VerifyEmailOr2fa'; export function VerifyEmailOr2faWrapper() { const router = useRouter(); - const { isLoading, isVerificationExpired, pendingVerifications, isLoggedIn } = useUserProfile(); + const { isLoading, isVerificationExpired, email, pendingVerifications, isLoggedIn } = useUserProfile(); const { csrfToken, csrfTokenError: error } = useCsrfToken(); useEffect(() => { @@ -36,5 +36,5 @@ export function VerifyEmailOr2faWrapper() { return null; } - return ; + return ; } diff --git a/apps/landing/hooks/auth.hooks.ts b/apps/landing/hooks/auth.hooks.ts index 4ae56e853..cb48d1521 100644 --- a/apps/landing/hooks/auth.hooks.ts +++ b/apps/landing/hooks/auth.hooks.ts @@ -1,9 +1,15 @@ import { TwoFactorType } from '@jetstream/auth/types'; +import { Maybe } from '@jetstream/types'; import { useEffect, useState } from 'react'; import { AUTH_PATHS } from '../utils/environment'; interface AuthState { isLoggedIn: boolean; + /** + * Used to show user which email address the 2fa was sent to + * in case there was a typo, the user can identify it + */ + email?: Maybe; pendingVerifications: Array | false; isVerificationExpired: boolean; } From 8fc92a3b8f81a241801dcd5ec41d9b0f2e196b14 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Mon, 4 Nov 2024 09:12:04 -0700 Subject: [PATCH 19/38] Added e2e test for pending verification --- .../AuthenticationPage.model.ts | 58 ++++++++++++------- .../src/tests/authentication/login3.spec.ts | 50 ++++++++++++++++ 2 files changed, 86 insertions(+), 22 deletions(-) create mode 100644 apps/jetstream-e2e/src/tests/authentication/login3.spec.ts diff --git a/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts b/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts index 64193f48b..c4f7bd6a1 100644 --- a/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts +++ b/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts @@ -21,6 +21,7 @@ export class AuthenticationPage { readonly signInFromHomePageButton: Locator; readonly signUpFromHomePageButton: Locator; + readonly signUpCtaFromHomePageButton: Locator; readonly signInFromFormLink: Locator; readonly signUpFromFormLink: Locator; @@ -51,7 +52,8 @@ export class AuthenticationPage { constructor(page: Page) { this.page = page; this.signInFromHomePageButton = page.getByRole('link', { name: 'Log in' }); - this.signUpFromHomePageButton = page.getByRole('link', { name: 'Sign up' }); + this.signUpFromHomePageButton = page.getByRole('link', { name: 'Sign up', exact: true }); + this.signUpCtaFromHomePageButton = page.getByRole('link', { name: 'Sign up for a free account' }); this.signUpFromFormLink = page.getByText('Need to register? Sign up').getByRole('link', { name: 'Sign up' }); this.signInFromFormLink = page.getByText('Already have an account? Login').getByRole('link', { name: 'Login' }); @@ -82,7 +84,7 @@ export class AuthenticationPage { async goToSignUp(viaHomePage = true) { if (viaHomePage) { await this.page.goto('/'); - await this.signUpFromHomePageButton.first().click(); + await this.signUpFromHomePageButton.click(); } else { await this.page.goto(this.routes.signup()); } @@ -91,7 +93,7 @@ export class AuthenticationPage { async goToLogin(viaHomePage = true) { if (viaHomePage) { await this.page.goto('/'); - await this.signInFromHomePageButton.first().click(); + await this.signInFromHomePageButton.click(); } else { await this.page.goto(this.routes.login()); } @@ -121,7 +123,7 @@ export class AuthenticationPage { return `PWD-${new Date().getTime()}!${randomBytes(8).toString('hex')}`; } - async signUpAndVerifyEmail() { + async signUpWithoutEmailVerification() { const email = this.generateTestEmail(); const name = this.generateTestName(); const password = this.generateTestPassword(); @@ -133,6 +135,19 @@ export class AuthenticationPage { // ensure email verification was sent await verifyEmailLogEntryExists(email, 'Verify your email'); + return { + email, + name, + password, + }; + } + + async signUpAndVerifyEmail() { + const { email, name, password } = await this.signUpWithoutEmailVerification(); + + // ensure email verification was sent + await verifyEmailLogEntryExists(email, 'Verify your email'); + // Get token from session const { pendingVerification } = await getUserSessionByEmail(email); @@ -165,15 +180,28 @@ export class AuthenticationPage { // ensure email verification was sent await verifyEmailLogEntryExists(email, 'Verify your identity'); + await this.verifyEmail(email, rememberMe); + } + + async loginAndVerifyTotp(email: string, password: string, secret: string, rememberMe = false) { + await this.fillOutLoginForm(email, password); + + await expect(this.page.getByText('Enter your verification code from your authenticator app')).toBeVisible(); + + await this.verifyTotp(email, password, secret, rememberMe); + } + + async verifyEmail(email: string, rememberMe = false) { // Get token from session const { pendingVerification } = await getUserSessionByEmail(email); await expect(pendingVerification || []).toHaveLength(1); - if (pendingVerification[0].type !== '2fa-email') { + if (pendingVerification.some(({ type }) => type !== '2fa-email' && type !== 'email')) { throw new Error('Expected email verification'); } - const { token } = pendingVerification[0]; + + const { token } = pendingVerification[0] as { token: string }; await this.verificationCodeInput.fill(token); if (rememberMe) { @@ -182,21 +210,12 @@ export class AuthenticationPage { await this.continueButton.click(); await this.page.waitForURL(`**/app`); - - return { - email, - password, - }; } - async loginAndVerifyTotp(email: string, password: string, secret: string, rememberMe = false) { + async verifyTotp(email: string, password: string, secret: string, rememberMe = false) { const { decodeBase32IgnorePadding } = await import('@oslojs/encoding'); const { generateTOTP } = await import('@oslojs/otp'); - await this.fillOutLoginForm(email, password); - - await expect(this.page.getByText('Enter your verification code from your authenticator app')).toBeVisible(); - const code = await generateTOTP(decodeBase32IgnorePadding(secret), 30, 6); // Get token from session @@ -216,11 +235,6 @@ export class AuthenticationPage { await this.continueButton.click(); await this.page.waitForURL(`**/app`); - - return { - email, - password, - }; } async resetPassword(email: string) { @@ -271,7 +285,7 @@ export class AuthenticationPage { await this.goToSignUp(); await this.page.goto('/'); - await this.signUpFromHomePageButton.first().click(); + await this.signUpFromHomePageButton.click(); await expect(this.signInFromFormLink).toBeVisible(); await expect(this.forgotPasswordLink).toBeVisible(); diff --git a/apps/jetstream-e2e/src/tests/authentication/login3.spec.ts b/apps/jetstream-e2e/src/tests/authentication/login3.spec.ts new file mode 100644 index 000000000..97c2b463a --- /dev/null +++ b/apps/jetstream-e2e/src/tests/authentication/login3.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from '../../fixtures/fixtures'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe.configure({ mode: 'parallel' }); + +// Reset storage state for this file to avoid being authenticated +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe('Login 3', () => { + test('Pending Email Verification should not allow any other activity', async ({ page, authenticationPage, apiRequestUtils }) => { + const { email, password } = await test.step('Sign up without email verification', async () => { + return await authenticationPage.signUpWithoutEmailVerification(); + }); + + await test.step('Ensure clicking login shows email verification', async () => { + await page.goto('/'); + await expect(authenticationPage.signInFromHomePageButton).toBeVisible(); + await authenticationPage.signInFromHomePageButton.click(); + await expect(page.getByText('Verify your email address')).toBeVisible(); + }); + + await test.step('Ensure clicking sign up shows email verification', async () => { + await page.goto('/'); + await expect(authenticationPage.signUpFromHomePageButton).toBeVisible(); + await authenticationPage.signUpFromHomePageButton.click(); + await expect(page.getByText('Verify your email address')).toBeVisible(); + }); + + await test.step('Ensure clicking sign up from CTA shows email verification', async () => { + await page.goto('/'); + await expect(authenticationPage.signUpCtaFromHomePageButton).toBeVisible(); + await authenticationPage.signUpCtaFromHomePageButton.click(); + await expect(page.getByText('Verify your email address')).toBeVisible(); + }); + + await test.step('Ensure authenticated API fails prior to email verification', async () => { + const response = await apiRequestUtils.makeRequestRaw('GET', '/api/me', { Accept: 'application/json' }); + await expect(response.status()).toBe(401); + }); + + await test.step('Verify email and ensure we can make an authenticated API request', async () => { + await authenticationPage.verifyEmail(email); + const response = await apiRequestUtils.makeRequestRaw('GET', '/api/me', { Accept: 'application/json' }); + await expect(response.status()).toBe(200); + }); + }); +}); From d6e32b376f1154a6a3f1a3186f308405249b6878 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Mon, 4 Nov 2024 17:03:54 -0700 Subject: [PATCH 20/38] Fix test to account for verbiage change --- .../src/pageObjectModels/AuthenticationPage.model.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts b/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts index c4f7bd6a1..11e3e1675 100644 --- a/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts +++ b/apps/jetstream-e2e/src/pageObjectModels/AuthenticationPage.model.ts @@ -175,7 +175,8 @@ export class AuthenticationPage { async loginAndVerifyEmail(email: string, password: string, rememberMe = false) { await this.fillOutLoginForm(email, password); - await expect(this.page.getByText('Enter your verification code from your email')).toBeVisible(); + await expect(this.page.getByText('Enter verification code')).toBeVisible(); + await expect(this.page.getByText(email)).toBeVisible(); // ensure email verification was sent await verifyEmailLogEntryExists(email, 'Verify your identity'); From 437f405604c4430cd1170439a8c02dba4134118e Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Mon, 4 Nov 2024 17:17:56 -0700 Subject: [PATCH 21/38] Update migration script Only force 2fa for migrated users that have a password set up --- scripts/auth-migration/auth-migration.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/scripts/auth-migration/auth-migration.ts b/scripts/auth-migration/auth-migration.ts index 89ae91489..56e50d5c1 100644 --- a/scripts/auth-migration/auth-migration.ts +++ b/scripts/auth-migration/auth-migration.ts @@ -240,13 +240,15 @@ async function updateUsersInJetstreamDatabase(users: Auth0User[]) { const jetstreamAuthFactors: Prisma.AuthFactorsCreateWithoutUserInput[] = []; const jetstreamAuthIdentity: Prisma.AuthIdentityCreateWithoutUserInput[] = []; - jetstreamAuthFactors.push({ - enabled: true, // Users can choose "remember this device" or disable in settings - secret: null, - type: '2fa-email', - createdAt: new Date(), - updatedAt: new Date(), - }); + if (jetstreamUser.password) { + jetstreamAuthFactors.push({ + enabled: true, // Users can choose "remember this device" or disable in settings + secret: null, + type: '2fa-email', + createdAt: new Date(), + updatedAt: new Date(), + }); + } let identities = user.identities; let isFirstItemPrimary = true; From 1f5f0cd2243d6df0f5e00c140be5daac1b8e44f9 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Fri, 8 Nov 2024 07:27:53 -0700 Subject: [PATCH 22/38] Change error message when user attempts to sign in with existing email address --- apps/landing/utils/environment.ts | 1 + libs/auth/server/src/lib/auth.db.service.ts | 11 +++++++++-- libs/auth/server/src/lib/auth.errors.ts | 5 +++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/landing/utils/environment.ts b/apps/landing/utils/environment.ts index 1f6e2a1f8..9f038bf54 100644 --- a/apps/landing/utils/environment.ts +++ b/apps/landing/utils/environment.ts @@ -34,5 +34,6 @@ export const SIGN_IN_ERRORS = { ExpiredVerificationToken: 'Your verification token has expired, sign in again.', InvalidVerificationToken: 'Your verification token is invalid.', LoginWithExistingIdentity: 'To confirm your identity, sign in with the same account you used originally.', + InvalidRegistration: 'This email is already registered. If you already have an account, please log in or reset your password.', InvalidOrExpiredResetToken: 'Your reset token is invalid, please restart the reset process.', }; diff --git a/libs/auth/server/src/lib/auth.db.service.ts b/libs/auth/server/src/lib/auth.db.service.ts index 0d084f139..69acf2b95 100644 --- a/libs/auth/server/src/lib/auth.db.service.ts +++ b/libs/auth/server/src/lib/auth.db.service.ts @@ -17,7 +17,14 @@ import { Maybe } from '@jetstream/types'; import { Prisma } from '@prisma/client'; import { addDays, startOfDay } from 'date-fns'; import { addMinutes } from 'date-fns/addMinutes'; -import { InvalidAction, InvalidCredentials, InvalidOrExpiredResetToken, InvalidProvider, LoginWithExistingIdentity } from './auth.errors'; +import { + InvalidAction, + InvalidCredentials, + InvalidOrExpiredResetToken, + InvalidProvider, + InvalidRegistration, + LoginWithExistingIdentity, +} from './auth.errors'; import { ensureAuthError } from './auth.service'; import { hashPassword, verifyPassword } from './auth.utils'; @@ -801,7 +808,7 @@ export async function handleSignInOrRegistration( } else if (action === 'register') { const usersWithEmail = await findUsersByEmail(email); if (usersWithEmail.length > 0) { - throw new InvalidCredentials(); + throw new InvalidRegistration(); } user = await createUserFromUserInfo(payload.email, payload.name, password); isNewUser = true; diff --git a/libs/auth/server/src/lib/auth.errors.ts b/libs/auth/server/src/lib/auth.errors.ts index 49b1f3cc4..784d025ac 100644 --- a/libs/auth/server/src/lib/auth.errors.ts +++ b/libs/auth/server/src/lib/auth.errors.ts @@ -11,6 +11,7 @@ type ErrorType = | 'ExpiredVerificationToken' | 'InvalidVerificationToken' | 'InvalidOrExpiredResetToken' + | 'InvalidRegistration' | 'LoginWithExistingIdentity'; type ErrorOptions = Error | Record; @@ -64,6 +65,10 @@ export class InvalidParameters extends AuthError { static type: ErrorType = 'InvalidParameters'; } +export class InvalidRegistration extends AuthError { + static type: ErrorType = 'InvalidRegistration'; +} + export class LoginWithExistingIdentity extends AuthError { static type: ErrorType = 'LoginWithExistingIdentity'; } From 4cde4131f99cb010f5ec34c66b035b67952d2991 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Fri, 8 Nov 2024 07:28:02 -0700 Subject: [PATCH 23/38] remove unverified email banner --- apps/jetstream/src/app/app.tsx | 2 -- .../components/core/UnverifiedEmailAlert.tsx | 31 ------------------- 2 files changed, 33 deletions(-) delete mode 100644 apps/jetstream/src/app/components/core/UnverifiedEmailAlert.tsx diff --git a/apps/jetstream/src/app/app.tsx b/apps/jetstream/src/app/app.tsx index cb7add1d9..c824e600c 100644 --- a/apps/jetstream/src/app/app.tsx +++ b/apps/jetstream/src/app/app.tsx @@ -14,7 +14,6 @@ import AppStateResetOnOrgChange from './components/core/AppStateResetOnOrgChange import LogInitializer from './components/core/LogInitializer'; import './components/core/monaco-loader'; import NotificationsRequestModal from './components/core/NotificationsRequestModal'; -import { UnverifiedEmailAlert } from './components/core/UnverifiedEmailAlert'; export const App = () => { const [userProfile, setUserProfile] = useState>(); @@ -38,7 +37,6 @@ export const App = () => {
    - }> diff --git a/apps/jetstream/src/app/components/core/UnverifiedEmailAlert.tsx b/apps/jetstream/src/app/components/core/UnverifiedEmailAlert.tsx deleted file mode 100644 index a162da3e8..000000000 --- a/apps/jetstream/src/app/components/core/UnverifiedEmailAlert.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Maybe, UserProfileUi } from '@jetstream/types'; -import { Alert } from '@jetstream/ui'; -import { useState } from 'react'; - -interface UnverifiedEmailAlertProps { - userProfile: Maybe; -} - -const LS_KEY = 'unverified_email_dismissed'; - -export function UnverifiedEmailAlert({ userProfile }: UnverifiedEmailAlertProps) { - const [dismissed, setDismissed] = useState(() => localStorage.getItem(LS_KEY) === 'true'); - - if (!userProfile || userProfile.email_verified || dismissed) { - return null; - } - - return ( - { - localStorage.setItem(LS_KEY, 'true'); - setDismissed(true); - }} - > - We will soon be enabling two-factor authentication via email for all users. Verify your email address to avoid any interruptions. - - ); -} From cf9e5646d247f7dd399c1cd2645e3c72a454ae3f Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Fri, 8 Nov 2024 07:33:56 -0700 Subject: [PATCH 24/38] Add test to confirm registering with existing credentials --- .../src/tests/authentication/login3.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/jetstream-e2e/src/tests/authentication/login3.spec.ts b/apps/jetstream-e2e/src/tests/authentication/login3.spec.ts index 97c2b463a..8beb1bbb3 100644 --- a/apps/jetstream-e2e/src/tests/authentication/login3.spec.ts +++ b/apps/jetstream-e2e/src/tests/authentication/login3.spec.ts @@ -47,4 +47,17 @@ test.describe('Login 3', () => { await expect(response.status()).toBe(200); }); }); + + test('Should not be able to register with an existing email address', async ({ page, authenticationPage, playwrightPage }) => { + const { email, password } = await test.step('Sign up, verify, logout', async () => { + const user = await authenticationPage.signUpAndVerifyEmail(); + await playwrightPage.logout(); + return user; + }); + + await test.step('Attempt to register with same email address', async () => { + await authenticationPage.fillOutSignUpForm(email, 'test person', password, password); + await expect(page.getByText('This email is already registered.')).toBeVisible(); + }); + }); }); From f032239cff9b025984ede0ebf4dc53ac3961a44a Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Fri, 8 Nov 2024 07:41:22 -0700 Subject: [PATCH 25/38] Updated docs to include authentication --- .../docs/getting-started/troubleshooting.mdx | 38 ++++++++++++++++++ .../user-profile-and-settings/settings.mdx | 19 +++++++++ .../user-profile-and-settings/settings.png | Bin 0 -> 30796 bytes .../user-profile.mdx | 23 +++++++++++ .../user-profile.png | Bin 0 -> 87867 bytes apps/docs/docusaurus.config.ts | 4 ++ apps/docs/sidebars.ts | 7 +++- 7 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 apps/docs/docs/getting-started/troubleshooting.mdx create mode 100644 apps/docs/docs/user-profile-and-settings/settings.mdx create mode 100644 apps/docs/docs/user-profile-and-settings/settings.png create mode 100644 apps/docs/docs/user-profile-and-settings/user-profile.mdx create mode 100644 apps/docs/docs/user-profile-and-settings/user-profile.png diff --git a/apps/docs/docs/getting-started/troubleshooting.mdx b/apps/docs/docs/getting-started/troubleshooting.mdx new file mode 100644 index 000000000..f4fd4bccd --- /dev/null +++ b/apps/docs/docs/getting-started/troubleshooting.mdx @@ -0,0 +1,38 @@ +--- +id: troubleshooting +title: Troubleshoot Connecting to Salesforce +description: Tips on connecting your Salesforce orgs within Jetstream +keywords: [salesforce, salesforce admin, salesforce developer, salesforce automation, salesforce workbench] +sidebar_label: Troubleshoot Connecting to Salesforce +slug: /troubleshooting +--- + +import OrgTroubleshootingTable from './_org-troubleshooting-table.mdx'; + +If you are having problems connecting to Salesforce, here are some tips to help you resolve any issues. + +### Jetstream IP Addresses + +If you need to allow IP addresses for your org, Jetstream will use one of the following three ip addresses: + +- `3.134.238.10` +- `3.129.111.220` +- `52.15.118.168` + +### Installing Jetstream + +After you attempt to connect your Salesforce Org, you should be able to see the connection in Salesforce settings under the **Connected Apps OAuth Usage** option. + +From here you have the option to **install** the **Jetstream Connected App**, which will allow you to configure security policies for the connection. + +- **Permitted Users** - You can restrict the connection to Admin users. +- **IP Relaxation** - You can configure unique IP address restrictions for this connection. +- **Refresh Token Policy** - Jetstream uses OAuth2 which means there is a short-lived `accessToken` and a long lived `refreshToken` which is used for Jetstream to call Salesforce APIs without ever having direct access to your password. + - You can adjust the settings here based on your desired security preferences. +- **Session Policies** - You can control how you want your session for your `accessToken` and `refreshToken` to behave + +Based on your Refresh Token and Session policies, this will control how often you need to re-authorize your org from within Jetstream. If your `refreshToken` expires, then you will need to re-authorize. + +### Troubleshooting Tips + + diff --git a/apps/docs/docs/user-profile-and-settings/settings.mdx b/apps/docs/docs/user-profile-and-settings/settings.mdx new file mode 100644 index 000000000..cc92767d2 --- /dev/null +++ b/apps/docs/docs/user-profile-and-settings/settings.mdx @@ -0,0 +1,19 @@ +--- +id: settings +title: Settings +description: Settings +keywords: [salesforce, settings, delete account] +sidebar_label: Settings +slug: /user-profile-and-settings/settings +--- + +On the settings page, you can perform the following actions: + +- **Don't Auto-Login on Link Clicks**: When enabled, Jetstream will not automatically log you in to Salesforce when you click on a link in an email. + - Use this if you have SSO configured and clicking Salesforce links from Jetstream asks you to login again. + - When this setting is enabled, clicking Salesforce links will be much faster as long as you are already logged in to Salesforce. +- **Logging**: Enable or disable logging of your Jetstream activity within your browser's console. + - Use this if you are experiencing issues with Jetstream and need to provide logs to the Jetstream team. +- **Delete Account**: Delete your Jetstream account. This action is irreversible. + +Settings diff --git a/apps/docs/docs/user-profile-and-settings/settings.png b/apps/docs/docs/user-profile-and-settings/settings.png new file mode 100644 index 0000000000000000000000000000000000000000..c07c07ab790f876bd8e6b368452768456c49c6e5 GIT binary patch literal 30796 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⁣S0s(-1XpDf7;{K}+FICj_+c$nio6}VlX&IW?&pFnmF)}vq z&iW)f^89qI+tb}Zw=#+Dutte=q;|Tu-(SlXU>tCwZ_@Ov0X{p(7L_MxN&*e(y%8T78H643e_556o zSp3RW9AaFk`1mc-V5_-ja?UMZq38KdZx*w{v%v^*Ocs4bTCv{2pt*kws5f;>q^xG|+spEFumRfERG<@F&vyMtT zv6&m089noHoV!}r$Kr9O9lDmvHuL_@C#HuqmIJT%7cyG7-x@?ZQ@Y0U!{gTny*iXD4CcG?1C_q|R$sjjhaCkh&A2C7Stj~3xNiPy^`d zi959>XEMLBxp>9fmvW(JuIm;0WALihTzxLYy}I`dDNEJHr#u4=nK`YSe3s^BJ{56E z5UP~Dj6WsWAAO_Qu0xv7W<4gvj!k6um##<4X4-xJ4d1m?QGFT`67Km~(0<0m&#%uI zYTZowOjX7k=*DJ)#^c_EZj*9((HT5KT%N=lRQ4;SdTE(WeYF*IsmPG%1M6sVib&^% z+gY8Pv`B**9KNUeZ7E4DoygD|O?{*Bvace%JG5UpnVxPeks#x2--+DvAcaj&aW0TQ z);HlxPX!4-qLz+?O6gTjJw1f+yqfj%PCWCj_=)og9CO)3Yu6z7Ipy1GG)+6L83Lx!oS?T;@Q)q2~7BKlb=Y|jyI z(h4XcV6H_vZ8aJ1PkvU!1pQ25Py5tuQ*Jr(>b!?>VeE~9Vjo?MkVW+ylq}TbSs=F= zI`*6XAt;5iZhy&U;Hk%h8pL8YAuonCv)l0}jb7D8v_$4J8c#ZE#_sOcVO!Ydk0Fgv zpG{0&EUR3fp3KNPX17v!Ky_f)+o*E{(lp%R3bxFd82(z$PZe}%nXdKTToy&L#%*H= zEflpIkzeC>k1n7VR2%)w!bnI*X4G5S?rdo_JlOtbWiy0XXw9>31@c2ksJjtbbbJyt zP05^NwZy zi4m!$#$1Q8n`{i{3Zl3;tld;cM<)gb+bA_;U{{AGz*wTWE$QwqmFgLgzU61Mx`Au^ zrJPAADUmR>m&pDgRkSek8A&e-X%y=h_`B4)&h;q&y^NqGGblm1wy#KC$89 z_78YDHH3moKHPQ7s_Nh{a;>VYUNVWtemO6?dhx>Tqg1_iRT&{d+3n^Jnq!nCh~k064ZCg^gPS`{e?|w~ zfD*kjyw>!!KmG734i)rDK(_G=QPIsm9a9=4&R`i=l_K$HgZ>g6TxP-zUx(+vPXL!k z{^~Wktop6nRDXW{o`pD0oYa!en&Vm2yc$)M|Ezt(&fg-eC&86xkdu@96nKls(@A6) zorb%5K8jJ6_|wFw1JZF~y1NxS`uY+88v!@?SC0YSf}r5DOq9+})vOynFq#1;J&TL8 z1k*i|3=0p>NlzCK3k%E6%xs2PXDQ#uU*-PT8)0!@PWH=*#hJ+Zfnkzz zAT;7MwuSGw|n(lp*@0O`7RX&mzXnz7oJ3 zopkuGn0@hxWzS5qNd8s=j+(zXsjSke diff --git a/apps/docs/docs/user-profile-and-settings/user-profile.png b/apps/docs/docs/user-profile-and-settings/user-profile.png new file mode 100644 index 0000000000000000000000000000000000000000..8ee5fbdcb341c12ba324ae2beb58303ef97c5fa1 GIT binary patch literal 87867 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 Date: Sat, 9 Nov 2024 08:47:36 -0700 Subject: [PATCH 26/38] Fix IP address for remember device The API address was using cloudflare's ip address instead of the user address for the "remember this device" feature which is not correct --- apps/api/src/app/controllers/auth.controller.ts | 8 ++++---- apps/api/src/app/routes/platform-event.routes.ts | 2 +- apps/api/src/app/routes/route.middleware.ts | 8 +++++--- apps/api/src/app/types/types.ts | 1 + apps/api/src/app/utils/route.utils.ts | 16 +++++++++++++++- .../server/src/lib/auth-logging.db.service.ts | 5 +++-- 6 files changed, 29 insertions(+), 11 deletions(-) diff --git a/apps/api/src/app/controllers/auth.controller.ts b/apps/api/src/app/controllers/auth.controller.ts index 609ea1ead..9dbe32944 100644 --- a/apps/api/src/app/controllers/auth.controller.ts +++ b/apps/api/src/app/controllers/auth.controller.ts @@ -43,7 +43,7 @@ import { addMinutes } from 'date-fns'; import { z } from 'zod'; import { Request } from '../types/types'; import { redirect, sendJson, setCsrfCookie } from '../utils/response.handlers'; -import { createRoute } from '../utils/route.utils'; +import { createRoute, getApiAddressFromReq } from '../utils/route.utils'; export const routeDefinition = { logout: { @@ -171,7 +171,7 @@ function initSession( if (userAgent) { req.session.userAgent = req.get('User-Agent'); } - req.session.ipAddress = req.ip; + req.session.ipAddress = getApiAddressFromReq(req); req.session.loginTime = new Date().getTime(); req.session.provider = provider; req.session.user = user as UserProfileSession; @@ -434,7 +434,7 @@ const callback = createRoute(routeDefinition.callback.validators, async ({ body, const isDeviceRemembered = await hasRememberDeviceRecord({ userId: req.session.user.id, deviceId, - ipAddress: req.ip, + ipAddress: res.locals.ipAddress || getApiAddressFromReq(req), userAgent: req.get('User-Agent'), }); if (isDeviceRemembered) { @@ -548,7 +548,7 @@ const verification = createRoute(routeDefinition.verification.validators, async await createRememberDevice({ userId: user.id, deviceId: rememberDeviceId, - ipAddress: req.ip, + ipAddress: res.locals.ipAddress || getApiAddressFromReq(req), userAgent: req.get('User-Agent'), }); setCookie(cookieConfig.rememberDevice.name, rememberDeviceId, cookieConfig.rememberDevice.options); diff --git a/apps/api/src/app/routes/platform-event.routes.ts b/apps/api/src/app/routes/platform-event.routes.ts index 373c82a80..bc2164ca4 100644 --- a/apps/api/src/app/routes/platform-event.routes.ts +++ b/apps/api/src/app/routes/platform-event.routes.ts @@ -37,7 +37,7 @@ routes.use('/', async (req: express.Request, res: express.Response, next: expres Accept: req.headers.accept || '*/*', 'Accept-Encoding': req.headers['accept-encoding'] || 'gzip, deflate, br, zstd', Cookie: req.headers.cookie, - 'user-agent': req.headers['user-agent'], + 'user-agent': req.get('user-agent'), }; if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') { diff --git a/apps/api/src/app/routes/route.middleware.ts b/apps/api/src/app/routes/route.middleware.ts index 73e32f5ba..2665d89e2 100644 --- a/apps/api/src/app/routes/route.middleware.ts +++ b/apps/api/src/app/routes/route.middleware.ts @@ -11,6 +11,7 @@ import pino from 'pino'; import { v4 as uuid } from 'uuid'; import * as salesforceOrgsDb from '../db/salesforce-org.db'; import { AuthenticationError, NotFoundError, UserFacingError } from '../utils/error-handler'; +import { getApiAddressFromReq } from '../utils/route.utils'; export function addContextMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) { res.locals.requestId = res.locals.requestId || uuid(); @@ -59,7 +60,7 @@ export function notFoundMiddleware(req: express.Request, res: express.Response, * @returns */ export function blockBotByUserAgentMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) { - const userAgent = req.header('User-Agent'); + const userAgent = req.get('User-Agent'); if (userAgent?.toLocaleLowerCase().includes('python')) { logger.debug( { @@ -67,7 +68,7 @@ export function blockBotByUserAgentMiddleware(req: express.Request, res: express method: req.method, url: req.originalUrl, requestId: res.locals.requestId, - agent: req.header('User-Agent'), + agent: req.get('User-Agent'), referrer: req.get('Referrer'), ip: req.headers[HTTP.HEADERS.CF_Connecting_IP] || req.headers[HTTP.HEADERS.X_FORWARDED_FOR] || req.connection.remoteAddress, country: req.headers[HTTP.HEADERS.CF_IPCountry], @@ -303,7 +304,8 @@ export function verifyCaptcha(req: express.Request, res: express.Response, next: body: JSON.stringify({ secret: ENV.CAPTCHA_SECRET_KEY, response: token, - remoteip: req.ip, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + remoteip: res.locals.ipAddress || getApiAddressFromReq(req as any), }), method: 'POST', headers: { diff --git a/apps/api/src/app/types/types.ts b/apps/api/src/app/types/types.ts index 4a0104faf..e69956a8b 100644 --- a/apps/api/src/app/types/types.ts +++ b/apps/api/src/app/types/types.ts @@ -35,5 +35,6 @@ export type Response = ExpressResponse< * which simplifies having the same header specified twice */ cookies?: ResponseLocalsCookies; + ipAddress: string; } > & { log: pino.Logger }; diff --git a/apps/api/src/app/utils/route.utils.ts b/apps/api/src/app/utils/route.utils.ts index db845a7dd..948705090 100644 --- a/apps/api/src/app/utils/route.utils.ts +++ b/apps/api/src/app/utils/route.utils.ts @@ -1,4 +1,4 @@ -import { getExceptionLog, rollbarServer } from '@jetstream/api-config'; +import { getExceptionLog, logger, rollbarServer } from '@jetstream/api-config'; import { CookieOptions, UserProfileSession } from '@jetstream/auth/types'; import { ApiConnection } from '@jetstream/salesforce-api'; import { NextFunction } from 'express'; @@ -56,6 +56,7 @@ export function createRoute, res: Response, next: NextFunction) => { try { + res.locals.ipAddress = getApiAddressFromReq(req); res.locals.cookies = res.locals.cookies || {}; const data = { params: params ? params.parse(req.params) : undefined, @@ -116,3 +117,16 @@ export function createRoute) { + try { + const ipAddress = req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.socket.remoteAddress || req.ip; + if (Array.isArray(ipAddress)) { + return ipAddress[ipAddress.length - 1]; + } + return ipAddress; + } catch (ex) { + logger.error('Error fetching IP address', ex); + return `unknown-${new Date().getTime()}`; + } +} diff --git a/libs/auth/server/src/lib/auth-logging.db.service.ts b/libs/auth/server/src/lib/auth-logging.db.service.ts index b6c2c77c6..9e769237d 100644 --- a/libs/auth/server/src/lib/auth-logging.db.service.ts +++ b/libs/auth/server/src/lib/auth-logging.db.service.ts @@ -39,8 +39,9 @@ export async function createUserActivityFromReq( data: LoginActivity ) { try { - const ipAddress = req.ip; - const userAgent = req.headers['user-agent']; + const ipAddress = + res.locals.ipAddress || req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.socket.remoteAddress || req.ip; + const userAgent = req.get('user-agent'); const userId = data.userId || (req as any).session?.user?.id; const email = data.email || (req as any).session?.user?.email; const requestId = data.requestId || res.locals?.['requestId']; From 184915a26a847ee77d96873a04d64e0807c406a4 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sat, 9 Nov 2024 09:59:05 -0700 Subject: [PATCH 27/38] Update CSP header for cloudflare turnstile --- apps/api/src/main.ts | 5 ++--- apps/landing/components/auth/Captcha.tsx | 25 ++++++++++++------------ package.json | 2 +- yarn.lock | 10 +++++----- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 964981a14..1566d0afc 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -130,8 +130,7 @@ if (ENV.NODE_ENV === 'production' && !ENV.CI && cluster.isPrimary) { '*.rollbar.com', 'api.amplitude.com', 'api.cloudinary.com', - 'challenges.cloudflare.com', - 'ip-api.com', + 'https://challenges.cloudflare.com', ], baseUri: ["'self'"], blockAllMixedContent: [], @@ -163,7 +162,7 @@ if (ENV.NODE_ENV === 'production' && !ENV.CI && cluster.isPrimary) { '*.gstatic.com', '*.google-analytics.com', '*.googletagmanager.com', - 'challenges.cloudflare.com', + 'https://challenges.cloudflare.com', ], scriptSrcAttr: ["'none'"], styleSrc: ["'self'", 'https:', "'unsafe-inline'"], diff --git a/apps/landing/components/auth/Captcha.tsx b/apps/landing/components/auth/Captcha.tsx index bfe82e8c9..8884e3016 100644 --- a/apps/landing/components/auth/Captcha.tsx +++ b/apps/landing/components/auth/Captcha.tsx @@ -1,14 +1,15 @@ +import { Turnstile } from '@marsidev/react-turnstile'; import { useId } from 'react'; -import Turnstile from 'react-turnstile'; import { ENVIRONMENT } from '../../utils/environment'; interface CaptchaProps { action: string; formError?: string; + onLoad?: () => void; onChange: (token: string) => void; } -export function Captcha({ action, formError, onChange }: CaptchaProps) { +export function Captcha({ action, formError, onLoad, onChange }: CaptchaProps) { const id = useId(); // Skip rendering the captcha if we're running in Playwright or if the key is not set @@ -21,16 +22,16 @@ export function Captcha({ action, formError, onChange }: CaptchaProps) { <> onChange(token)} - // onError={} - onSuccess={(token, preClearanceObtained) => onChange(token)} + siteKey={ENVIRONMENT.CAPTCHA_KEY} + options={{ + action, + theme: 'light', + appearance: 'always', + size: 'flexible', + refreshExpired: 'auto', + }} + onWidgetLoad={onLoad} + onSuccess={(token) => onChange(token)} /> {formError && (
    - setValue('captchaToken', token)} formError={errors?.captchaToken?.message} /> + setValue('captchaToken', token)} + onFinished={() => setFinishedCaptcha(true)} + />
    diff --git a/apps/landing/components/auth/PasswordResetInit.tsx b/apps/landing/components/auth/PasswordResetInit.tsx index e0c8ba93e..b158a4b5d 100644 --- a/apps/landing/components/auth/PasswordResetInit.tsx +++ b/apps/landing/components/auth/PasswordResetInit.tsx @@ -27,6 +27,7 @@ export function PasswordResetInit({ csrfToken }: PasswordResetInitProps) { const router = useRouter(); const [isSaving, setIsSaving] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false); + const [finishedCaptcha, setFinishedCaptcha] = useState(false); const [error, setError] = useState(); const { @@ -126,16 +127,17 @@ export function PasswordResetInit({ csrfToken }: PasswordResetInitProps) { /> setValue('captchaToken', token)} - formError={errors?.captchaToken?.message} + onFinished={() => setFinishedCaptcha(true)} />
    diff --git a/apps/landing/components/auth/PasswordResetVerify.tsx b/apps/landing/components/auth/PasswordResetVerify.tsx index c0bb7f8fe..224600be0 100644 --- a/apps/landing/components/auth/PasswordResetVerify.tsx +++ b/apps/landing/components/auth/PasswordResetVerify.tsx @@ -139,7 +139,7 @@ export function PasswordResetVerify({ csrfToken, email, token }: PasswordResetVe
    diff --git a/apps/landing/components/auth/VerifyEmailOr2fa.tsx b/apps/landing/components/auth/VerifyEmailOr2fa.tsx index fad9ce9d4..5db8013f7 100644 --- a/apps/landing/components/auth/VerifyEmailOr2fa.tsx +++ b/apps/landing/components/auth/VerifyEmailOr2fa.tsx @@ -170,7 +170,7 @@ export function VerifyEmailOr2fa({ csrfToken, email, pendingVerifications }: Ver
    diff --git a/libs/auth/server/src/lib/auth.utils.ts b/libs/auth/server/src/lib/auth.utils.ts index 4188a5daf..fb44d3eac 100644 --- a/libs/auth/server/src/lib/auth.utils.ts +++ b/libs/auth/server/src/lib/auth.utils.ts @@ -92,8 +92,7 @@ export function getCookieConfig(useSecureCookies: boolean): CookieConfig { name: `${cookiePrefix}jetstream-auth.remember-device`, options: { httpOnly: true, - // Development is on a different port, using lax for sameSite ensures the cookie is sent - sameSite: useSecureCookies ? 'strict' : 'lax', + sameSite: 'lax', path: '/', secure: useSecureCookies, maxAge: TIME_30_DAYS, From 2083ab2ae2156a243103294df49ee70d9ed50697 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sun, 10 Nov 2024 09:29:33 -0700 Subject: [PATCH 29/38] Move token expiration to constants use constant values instead of hard-coding --- .../src/app/controllers/auth.controller.ts | 25 ++++++++++++------- .../src/app/controllers/user.controller.ts | 3 ++- libs/auth/server/src/index.ts | 1 + libs/auth/server/src/lib/auth.constants.ts | 5 ++++ libs/auth/server/src/lib/auth.db.service.ts | 13 +++++----- libs/email/src/lib/email.tsx | 12 ++++----- 6 files changed, 37 insertions(+), 22 deletions(-) create mode 100644 libs/auth/server/src/lib/auth.constants.ts diff --git a/apps/api/src/app/controllers/auth.controller.ts b/apps/api/src/app/controllers/auth.controller.ts index 9dbe32944..70d22d511 100644 --- a/apps/api/src/app/controllers/auth.controller.ts +++ b/apps/api/src/app/controllers/auth.controller.ts @@ -23,8 +23,10 @@ import { InvalidVerificationType, linkIdentityToUser, getProviders as listProviders, + PASSWORD_RESET_DURATION_MINUTES, resetUserPassword, setUserEmailVerified, + TOKEN_DURATION_MINUTES, validateCallback, verify2faTotpOrThrow, verifyCSRFFromRequestOrThrow, @@ -178,7 +180,7 @@ function initSession( req.session.pendingVerification = null; if (verificationRequired) { - const exp = addMinutes(new Date(), 10).getTime(); + const exp = addMinutes(new Date(), TOKEN_DURATION_MINUTES).getTime(); const token = generateRandomCode(6); if (isNewUser) { req.session.sendNewUserEmailAfterVerify = true; @@ -449,9 +451,9 @@ const callback = createRoute(routeDefinition.callback.validators, async ({ body, const initialVerification = req.session.pendingVerification[0]; if (initialVerification.type === 'email') { - await sendEmailVerification(req.session.user.email, initialVerification.token); + await sendEmailVerification(req.session.user.email, initialVerification.token, TOKEN_DURATION_MINUTES); } else if (initialVerification.type === '2fa-email') { - await sendVerificationCode(req.session.user.email, initialVerification.token); + await sendVerificationCode(req.session.user.email, initialVerification.token, TOKEN_DURATION_MINUTES); } await setCsrfCookie(res); @@ -596,7 +598,7 @@ const resendVerification = createRoute(routeDefinition.resendVerification.valida } await verifyCSRFFromRequestOrThrow(csrfToken, req.headers.cookie || ''); - const exp = addMinutes(new Date(), 10).getTime(); + const exp = addMinutes(new Date(), TOKEN_DURATION_MINUTES).getTime(); const token = generateRandomCode(6); // Refresh all pending verifications @@ -619,11 +621,11 @@ const resendVerification = createRoute(routeDefinition.resendVerification.valida switch (type) { case 'email': { - await sendEmailVerification(req.session.user.email, token); + await sendEmailVerification(req.session.user.email, token, TOKEN_DURATION_MINUTES); break; } case '2fa-email': { - await sendVerificationCode(req.session.user.email, token); + await sendVerificationCode(req.session.user.email, token, TOKEN_DURATION_MINUTES); break; } default: { @@ -654,13 +656,18 @@ const requestPasswordReset = createRoute(routeDefinition.requestPasswordReset.va const { csrfToken, email } = body; await verifyCSRFFromRequestOrThrow(csrfToken, req.headers.cookie || ''); + let success = true; + try { const { token } = await generatePasswordResetToken(email); - await sendPasswordReset(email, token); + await sendPasswordReset(email, token, PASSWORD_RESET_DURATION_MINUTES); sendJson(res, { error: false }); } catch (ex) { - res.log.warn('[AUTH][PASSWORD_RESET] Attempt to reset a password for an email that does not exist %o', { email }); + res.log.warn('[AUTH][PASSWORD_RESET] Attempt to reset a password for an email that does not exist or no password is set %o', { + email, + }); + success = false; sendJson(res, { error: false }); } @@ -668,7 +675,7 @@ const requestPasswordReset = createRoute(routeDefinition.requestPasswordReset.va action: 'PASSWORD_RESET_REQUEST', method: 'UNAUTHENTICATED', email, - success: true, + success, }); } catch (ex) { createUserActivityFromReqWithError(req, res, ex, { diff --git a/apps/api/src/app/controllers/user.controller.ts b/apps/api/src/app/controllers/user.controller.ts index daf21dffd..212c45490 100644 --- a/apps/api/src/app/controllers/user.controller.ts +++ b/apps/api/src/app/controllers/user.controller.ts @@ -11,6 +11,7 @@ import { getAuthorizationUrl, getCookieConfig, getUserSessions, + PASSWORD_RESET_DURATION_MINUTES, removeIdentityFromUser, removePasswordFromUser, revokeAllUserSessions, @@ -198,7 +199,7 @@ const initPassword = createRoute(routeDefinition.initPassword.validators, async const initResetPassword = createRoute(routeDefinition.initResetPassword.validators, async ({ user }, req, res) => { const { email, token } = await generatePasswordResetToken(user.email); - await sendPasswordReset(email, token); + await sendPasswordReset(email, token, PASSWORD_RESET_DURATION_MINUTES); sendJson(res); createUserActivityFromReq(req, res, { action: 'PASSWORD_RESET_REQUEST', diff --git a/libs/auth/server/src/index.ts b/libs/auth/server/src/index.ts index 6c401cd29..aca9c6be1 100644 --- a/libs/auth/server/src/index.ts +++ b/libs/auth/server/src/index.ts @@ -1,4 +1,5 @@ export * from './lib/auth-logging.db.service'; +export * from './lib/auth.constants'; export * from './lib/auth.db.service'; export * from './lib/auth.errors'; export * from './lib/auth.service'; diff --git a/libs/auth/server/src/lib/auth.constants.ts b/libs/auth/server/src/lib/auth.constants.ts new file mode 100644 index 000000000..7212f2786 --- /dev/null +++ b/libs/auth/server/src/lib/auth.constants.ts @@ -0,0 +1,5 @@ +export const PASSWORD_RESET_DURATION_MINUTES = 30; +export const TOKEN_DURATION_MINUTES = 15; + +export const DELETE_ACTIVITY_DAYS = 30; +export const DELETE_TOKEN_DAYS = 3; diff --git a/libs/auth/server/src/lib/auth.db.service.ts b/libs/auth/server/src/lib/auth.db.service.ts index 69acf2b95..8340a0604 100644 --- a/libs/auth/server/src/lib/auth.db.service.ts +++ b/libs/auth/server/src/lib/auth.db.service.ts @@ -17,6 +17,7 @@ import { Maybe } from '@jetstream/types'; import { Prisma } from '@prisma/client'; import { addDays, startOfDay } from 'date-fns'; import { addMinutes } from 'date-fns/addMinutes'; +import { DELETE_ACTIVITY_DAYS, DELETE_TOKEN_DAYS, PASSWORD_RESET_DURATION_MINUTES } from './auth.constants'; import { InvalidAction, InvalidCredentials, @@ -47,22 +48,22 @@ const userSelect = Prisma.validator()({ export async function pruneExpiredRecords() { await prisma.loginActivity.deleteMany({ where: { - createdAt: { lte: addDays(startOfDay(new Date()), -30) }, + createdAt: { lte: addDays(startOfDay(new Date()), -DELETE_ACTIVITY_DAYS) }, }, }); await prisma.emailActivity.deleteMany({ where: { - createdAt: { lte: addDays(startOfDay(new Date()), -30) }, + createdAt: { lte: addDays(startOfDay(new Date()), -DELETE_ACTIVITY_DAYS) }, }, }); await prisma.passwordResetToken.deleteMany({ where: { - expiresAt: { lte: addDays(startOfDay(new Date()), -3) }, + expiresAt: { lte: addDays(startOfDay(new Date()), -DELETE_TOKEN_DAYS) }, }, }); await prisma.rememberedDevice.deleteMany({ where: { - expiresAt: { lte: addDays(startOfDay(new Date()), -3) }, + expiresAt: { lte: addDays(startOfDay(new Date()), -DELETE_TOKEN_DAYS) }, }, }); } @@ -267,7 +268,7 @@ export async function getUserSessions(userId: string, omitLocationData?: boolean .then((sessions) => sessions.map((session): UserSession => { const { sid, sess, expire } = session; - const { ipAddress, loginTime, provider, user, userAgent } = sess as unknown as SessionData; + const { ipAddress, loginTime, provider, userAgent } = sess as unknown as SessionData; return { sessionId: sid, expires: expire.toISOString(), @@ -413,7 +414,7 @@ export const generatePasswordResetToken = async (email: string) => { data: { userId: user[0].id, email, - expiresAt: addMinutes(new Date(), 10), + expiresAt: addMinutes(new Date(), PASSWORD_RESET_DURATION_MINUTES), }, }); diff --git a/libs/email/src/lib/email.tsx b/libs/email/src/lib/email.tsx index 1d9e454cb..dbb7496cd 100644 --- a/libs/email/src/lib/email.tsx +++ b/libs/email/src/lib/email.tsx @@ -101,8 +101,8 @@ export async function sendInternalAccountDeletionEmail(userId: string, reason?: }); } -export async function sendEmailVerification(emailAddress: string, code: string) { - const component = ; +export async function sendEmailVerification(emailAddress: string, code: string, expMinutes: number) { + const component = ; const [html, text] = await renderComponent(component); await sendEmail({ @@ -113,8 +113,8 @@ export async function sendEmailVerification(emailAddress: string, code: string) }); } -export async function sendVerificationCode(emailAddress: string, code: string) { - const component = ; +export async function sendVerificationCode(emailAddress: string, code: string, expMinutes: number) { + const component = ; const [html, text] = await renderComponent(component); await sendEmail({ @@ -125,9 +125,9 @@ export async function sendVerificationCode(emailAddress: string, code: string) { }); } -export async function sendPasswordReset(emailAddress: string, code: string) { +export async function sendPasswordReset(emailAddress: string, code: string, expMinutes: number) { const component = ( - + ); const [html, text] = await renderComponent(component); From 23434f50c5da5c8fa1de2156804a6ede7f162eb7 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Thu, 14 Nov 2024 19:08:24 -0700 Subject: [PATCH 30/38] Add ability to migrate password from Auth0 on the fly If we have a user without any social identities and no password, then we attempt to login using the email and password with Auth0, and if successful then we hash the password and save it for future use. This is only to pick up users that were created after password hash data was exported from Auth0 --- libs/api-config/src/lib/env-config.ts | 6 +++ libs/auth/server/src/lib/auth.db.service.ts | 42 ++++++++++++++++++++- libs/auth/server/src/lib/auth.service.ts | 41 +++++++++++++++++++- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/libs/api-config/src/lib/env-config.ts b/libs/api-config/src/lib/env-config.ts index a2cfb8bc2..ecaccf2b3 100644 --- a/libs/api-config/src/lib/env-config.ts +++ b/libs/api-config/src/lib/env-config.ts @@ -104,6 +104,12 @@ const envSchema = z.object({ IP_API_KEY: z.string().optional().describe('API Key used to get location information from IP address'), GIT_VERSION: z.string().optional(), ROLLBAR_SERVER_TOKEN: z.string().optional(), + + // Legacy Auth0 - Used to allow JIT password migration + AUTH0_CLIENT_ID: z.string().nullish(), + AUTH0_CLIENT_SECRET: z.string().nullish(), + AUTH0_DOMAIN: z.string().nullish(), + // JETSTREAM JETSTREAM_AUTH_SECRET: z.string().describe('Used to sign authentication cookies.'), // Must be 32 characters diff --git a/libs/auth/server/src/lib/auth.db.service.ts b/libs/auth/server/src/lib/auth.db.service.ts index 8340a0604..5dccdddac 100644 --- a/libs/auth/server/src/lib/auth.db.service.ts +++ b/libs/auth/server/src/lib/auth.db.service.ts @@ -26,7 +26,7 @@ import { InvalidRegistration, LoginWithExistingIdentity, } from './auth.errors'; -import { ensureAuthError } from './auth.service'; +import { ensureAuthError, verifyAuth0CredentialsOrThrow_MIGRATION_TEMPORARY } from './auth.service'; import { hashPassword, verifyPassword } from './auth.utils'; const userSelect = Prisma.validator()({ @@ -482,8 +482,24 @@ async function getUserAndVerifyPassword(email: string, password: string) { select: { id: true, password: true }, where: { email, password: { not: null } }, }); + + // There is not a user with the email address that has a password set if (!UNSAFE_userWithPassword) { - return { error: new InvalidCredentials() }; + // FIXME: TEMPORARY This is temporary just to handle the users that signed up after we exported data + try { + const updatedUser = await migratePasswordFromAuth0(email, password); + return { + error: null, + user: updatedUser, + }; + } catch (ex) { + return { error: new InvalidCredentials() }; + } + + // Use this after code above is removed + // if (!UNSAFE_userWithPassword) { + // return { error: new InvalidCredentials() }; + // } } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (await verifyPassword(password, UNSAFE_userWithPassword.password!)) { @@ -498,6 +514,28 @@ async function getUserAndVerifyPassword(email: string, password: string) { return { error: new InvalidCredentials() }; } +async function migratePasswordFromAuth0(email: string, password: string) { + // If the user has a linked social identity, we have no way to confirm 100% that this is the correct account + // since we allowed same email on multiple accounts with Auth0 + const userWithoutSocialIdentities = await prisma.user.findFirst({ + select: { id: true, identities: true }, + where: { email, password: null, identities: { none: {} } }, + }); + + if (!userWithoutSocialIdentities || userWithoutSocialIdentities.identities.length > 0) { + logger.warn({ email }, 'Cannot migrate password on the fly from Auth0, user has linked social identity'); + throw new InvalidCredentials(); + } + + await verifyAuth0CredentialsOrThrow_MIGRATION_TEMPORARY({ email, password }); + + return await prisma.user.update({ + select: userSelect, + data: { password: await hashPassword(password), passwordUpdatedAt: new Date() }, + where: { id: userWithoutSocialIdentities.id }, + }); +} + async function addIdentityToUser(userId: string, providerUser: ProviderUser, provider: OauthProviderType) { await prisma.authIdentity.create({ data: { diff --git a/libs/auth/server/src/lib/auth.service.ts b/libs/auth/server/src/lib/auth.service.ts index c1185a71a..af4ae1a6a 100644 --- a/libs/auth/server/src/lib/auth.service.ts +++ b/libs/auth/server/src/lib/auth.service.ts @@ -5,7 +5,7 @@ import * as crypto from 'crypto'; import type { Response } from 'express'; import * as QRCode from 'qrcode'; import { OauthClientProvider, OauthClients } from './OauthClients'; -import { AuthError, InvalidCsrfToken, InvalidVerificationToken } from './auth.errors'; +import { AuthError, InvalidCredentials, InvalidCsrfToken, InvalidVerificationToken } from './auth.errors'; import { getCookieConfig, validateCSRFToken } from './auth.utils'; const oauthPromise = import('oauth4webapi'); @@ -269,3 +269,42 @@ async function getUserInfo({ authorizationServer, client }: OauthClientProvider, const userInfo = await oauth.processUserInfoResponse(authorizationServer, client, sub, response); return userInfo; } + +/** + * Log user into Auth0 using Username+Password + * + * This is used to verify a user's password if we do not have the password hash stored in Jetstream database. + * + * Once the user migration is complete, this function should be removed. + */ +export async function verifyAuth0CredentialsOrThrow_MIGRATION_TEMPORARY({ email, password }: { email: string; password: string }) { + if (!ENV.AUTH0_DOMAIN || !ENV.AUTH0_CLIENT_ID || !ENV.AUTH0_CLIENT_SECRET) { + logger.error('Auth0 credentials are not set, unable to check for password migration'); + throw new InvalidCredentials(); + } + + const response = await fetch(`https://${ENV.AUTH0_DOMAIN}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'password', + username: email, + password, + scope: 'email', + client_id: ENV.AUTH0_CLIENT_ID, + client_secret: ENV.AUTH0_CLIENT_SECRET, + }), + }); + + if (!response.ok) { + throw new InvalidCredentials(); + } + + const data = (await response.json()) as { access_token: string; scope: string; expires_in: number; token_type: 'Bearer' }; + + if (!data.access_token) { + throw new InvalidCredentials(); + } +} From 89b841ec0121e671d35e55a4e794f78d29644749 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Thu, 14 Nov 2024 21:19:36 -0700 Subject: [PATCH 31/38] Update migration script --- .../components/profile/2fa/Profile2faOtp.tsx | 2 +- scripts/auth-migration/auth-migration.ts | 95 +++++++++++++++---- 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/apps/jetstream/src/app/components/profile/2fa/Profile2faOtp.tsx b/apps/jetstream/src/app/components/profile/2fa/Profile2faOtp.tsx index bebe9e2b8..c775fdea5 100644 --- a/apps/jetstream/src/app/components/profile/2fa/Profile2faOtp.tsx +++ b/apps/jetstream/src/app/components/profile/2fa/Profile2faOtp.tsx @@ -149,7 +149,7 @@ export const Profile2faOtp: FunctionComponent = ({ isConfigu
    Scan the QR code with your authenticator app
    qr code

    - Or enter the following code in your authenticator app: {otp2fa.secretToken} + Or enter the following secret in your authenticator app: {otp2fa.secretToken}

    setTimeout(resolve, milliseconds)); } +/** + * ********************************************** + * PARSE PASSWORD EXPORT DATA + * ********************************************** + */ +function parsePasswordExportData(): Record { + try { + return (convertJsonLinesToJson(readFileSync(inputPasswordHashData, 'utf8')) as Auth0UserWithPassword[]).reduce( + (acc: Record, item) => { + acc[item._id] = item; + return acc; + }, + {} + ); + } catch (ex) { + console.error('Error parsing password export data', ex); + return {}; + } +} + /** * ********************************************** * EXPORT USERS FUNCTION * ********************************************** */ -async function exportUsers() { +async function exportUsers(userByIdWithPasswordHash: Record) { let response = await management.jobs.exportUsers({ format: 'json', fields: [ @@ -207,6 +242,15 @@ async function exportUsers() { const jsonLines = results.toString('utf8'); let users = convertJsonLinesToJson(jsonLines) as Auth0User[]; + // add passwordHas to users + users.forEach((user) => { + user.identities.forEach((identity) => { + if (userByIdWithPasswordHash[identity.user_id]) { + user.passwordHash = userByIdWithPasswordHash[identity.user_id].passwordHash; + } + }); + }); + console.log('Saving auth0 users to', outputAuth0PathJson); writeFileSync(outputAuth0PathJson, JSON.stringify(users, null, 2)); @@ -224,6 +268,11 @@ async function exportUsers() { * ********************************************** */ async function updateUsersInJetstreamDatabase(users: Auth0User[]) { + const output = { + total: users.length, + success: 0, + failed: 0, + }; console.log('Preparing users for import'); const userUpdateInput = users.map((user) => { const jetstreamUser: Prisma.UserUpdateInput = { @@ -234,21 +283,19 @@ async function updateUsersInJetstreamDatabase(users: Auth0User[]) { nickname: (user.nickname || user.name || user.email).trim(), picture: user.picture || null, updatedAt: new Date(), - password: null, // TODO: if there is a password, set this - passwordUpdatedAt: null, // TODO: if there is a password, set this + password: user.passwordHash, + passwordUpdatedAt: user.passwordHash ? (user.last_password_reset ? new Date(user.last_password_reset) : new Date()) : null, }; const jetstreamAuthFactors: Prisma.AuthFactorsCreateWithoutUserInput[] = []; const jetstreamAuthIdentity: Prisma.AuthIdentityCreateWithoutUserInput[] = []; - if (jetstreamUser.password) { - jetstreamAuthFactors.push({ - enabled: true, // Users can choose "remember this device" or disable in settings - secret: null, - type: '2fa-email', - createdAt: new Date(), - updatedAt: new Date(), - }); - } + jetstreamAuthFactors.push({ + enabled: !!jetstreamUser.password || !jetstreamUser.emailVerified, + secret: null, + type: '2fa-email', + createdAt: new Date(), + updatedAt: new Date(), + }); let identities = user.identities; let isFirstItemPrimary = true; @@ -335,8 +382,7 @@ async function updateUsersInJetstreamDatabase(users: Auth0User[]) { throw new Error(`User not found with id: ${userInput.userId}`); } - // TODO: I could calculate if we need to update the authFactors here? - + output.success++; results.push({ success: true, user: await prisma.user.update({ @@ -349,6 +395,7 @@ async function updateUsersInJetstreamDatabase(users: Auth0User[]) { }), }); } catch (ex) { + output.failed++; console.error('Error updating user', ex); results.push({ success: false, @@ -377,13 +424,23 @@ async function updateUsersInJetstreamDatabase(users: Auth0User[]) { ) ); writeFileSync(outputPathAllJson, JSON.stringify(results, null, 2)); + return output; } (async () => { console.log('Starting export process'); - // TODO: ask user if they want to continue - const users = await exportUsers(); - // TODO: ask user if they want to continue - await updateUsersInJetstreamDatabase(users); + const userByIdWithPasswordHash = parsePasswordExportData(); + const users = await exportUsers(userByIdWithPasswordHash); + + const results = await updateUsersInJetstreamDatabase(users); + + console.log('Exported users: ', users.length); + console.log('Users with password: ', users.filter((user) => user.passwordHash).length); + console.log( + 'Users without password that should have one: ', + users.filter((user) => !user.passwordHash && user.identities.some((user) => user.provider === 'auth0')).length + ); + console.log('Successfully updated: ', results.success); + console.log('Failed updated: ', results.failed); console.log('Done'); })(); From fbba49a478c46e2de734ba57a1a8fe65f6b17748 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Fri, 15 Nov 2024 17:31:00 -0700 Subject: [PATCH 32/38] Fix test - properly select toast close button --- apps/jetstream-e2e/src/tests/authentication/login2.spec.ts | 2 +- libs/ui/src/lib/toast/AppToast.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts b/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts index 087c38ee2..45642adb1 100644 --- a/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts +++ b/apps/jetstream-e2e/src/tests/authentication/login2.spec.ts @@ -56,7 +56,7 @@ test.describe('Login 2', () => { await page.getByTestId('settings-page').getByRole('textbox').fill('123456'); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Failed to save 2fa settings')).toBeVisible(); - await page.getByRole('button', { name: 'Close' }).click(); + await page.getByTestId('toast-notify-container').getByRole('button', { name: 'Close' }).click(); // save a valid token await page.getByTestId('settings-page').getByRole('textbox').click(); diff --git a/libs/ui/src/lib/toast/AppToast.tsx b/libs/ui/src/lib/toast/AppToast.tsx index 60952c7fa..a3fdb4676 100644 --- a/libs/ui/src/lib/toast/AppToast.tsx +++ b/libs/ui/src/lib/toast/AppToast.tsx @@ -48,7 +48,7 @@ export const AppToast: FunctionComponent = () => { } return ( -
    +
    {activeMessages .filter((_, i) => i < 3) .map(({ id, message, type }) => ( From 8cd8bec97404ad891e5a6a5211fa25acabb99ba7 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sat, 16 Nov 2024 08:42:48 -0700 Subject: [PATCH 33/38] Use render id header as request id if available --- apps/api/src/app/routes/route.middleware.ts | 2 +- libs/api-config/src/lib/api-logger.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/api/src/app/routes/route.middleware.ts b/apps/api/src/app/routes/route.middleware.ts index 2665d89e2..69be81c59 100644 --- a/apps/api/src/app/routes/route.middleware.ts +++ b/apps/api/src/app/routes/route.middleware.ts @@ -14,7 +14,7 @@ import { AuthenticationError, NotFoundError, UserFacingError } from '../utils/er import { getApiAddressFromReq } from '../utils/route.utils'; export function addContextMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) { - res.locals.requestId = res.locals.requestId || uuid(); + res.locals.requestId = res.locals.requestId || req.get('rndr-id') || uuid(); const clientReqId = req.header(HTTP.HEADERS.X_CLIENT_REQUEST_ID); if (clientReqId) { res.setHeader(HTTP.HEADERS.X_CLIENT_REQUEST_ID, clientReqId); diff --git a/libs/api-config/src/lib/api-logger.ts b/libs/api-config/src/lib/api-logger.ts index 01fab6522..e80edee1c 100644 --- a/libs/api-config/src/lib/api-logger.ts +++ b/libs/api-config/src/lib/api-logger.ts @@ -21,7 +21,8 @@ export const httpLogger = pinoHttp({ genReqId: (req, res) => res.locals.requestId || uuid(), autoLogging: { // ignore static files based on file extension - ignore: (req) => ignoreLogsFileExtensions.test(req.url) || req.url === '/healthz' || req.url === '/api/heartbeat', + ignore: (req) => + ignoreLogsFileExtensions.test(req.url) || req.url === '/healthz' || req.url === '/api/heartbeat' || req.url === '/api/analytics', }, customLogLevel: function (req, res, error) { if (res.statusCode > 400) { @@ -46,6 +47,8 @@ export const httpLogger = pinoHttp({ host: req.raw.headers.host, 'user-agent': req.raw.headers['user-agent'], referer: req.raw.headers.referer, + 'cf-ray': req.raw.headers['cf-ray'], + 'rndr-id': req.raw.headers['rndr-id'], 'x-sfdc-id': req.raw.headers['x-sfdc-id'], 'x-client-request-id': req.raw.headers['x-client-request-id'], 'x-retry': req.raw.headers['x-retry'], From 2c9ff38281e280b50294341d6d21b3b303bb23ef Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sat, 16 Nov 2024 09:00:10 -0700 Subject: [PATCH 34/38] Upgrade SFDC API version to 62.0 --- .env.example | 2 +- .github/workflows/ci.yml | 2 +- apps/api/.env.production | 4 ++-- apps/jetstream-web-extension/src/utils/api-client.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 9f81280a0..7a3d05f94 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ ENVIRONMENT='development' # SFDC API VERSION TO USE -NX_SFDC_API_VERSION='61.0' +NX_SFDC_API_VERSION='62.0' # trace, debug (default), info, warn, error, fatal, silent LOG_LEVEL='trace' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71c020633..899a1a878 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: JETSTREAM_SERVER_DOMAIN: localhost:3333 JETSTREAM_SERVER_URL: http://localhost:3333 JETSTREAM_SESSION_SECRET: ${{ secrets.JETSTREAM_SESSION_SECRET }} - SFDC_API_VERSION: '61.0' + SFDC_API_VERSION: '62.0' SFDC_CALLBACK_URL: http://localhost:3333/oauth/sfdc/callback SFDC_CONSUMER_KEY: ${{ secrets.SFDC_CONSUMER_KEY }} SFDC_CONSUMER_SECRET: ${{ secrets.SFDC_CONSUMER_SECRET }} diff --git a/apps/api/.env.production b/apps/api/.env.production index f9542a4ff..75d17a523 100644 --- a/apps/api/.env.production +++ b/apps/api/.env.production @@ -11,7 +11,7 @@ JETSTREAM_SERVER_DOMAIN="getjetstream.app" JETSTREAM_SERVER_URL="https://getjetstream.app" NX_BRANCH="main" -NX_SFDC_API_VERSION="61.0" +NX_SFDC_API_VERSION="62.0" -SFDC_API_VERSION="61.0" +SFDC_API_VERSION="62.0" SFDC_CALLBACK_URL="https://getjetstream.app/oauth/sfdc/callback" diff --git a/apps/jetstream-web-extension/src/utils/api-client.ts b/apps/jetstream-web-extension/src/utils/api-client.ts index 990f790b5..b6de62828 100644 --- a/apps/jetstream-web-extension/src/utils/api-client.ts +++ b/apps/jetstream-web-extension/src/utils/api-client.ts @@ -9,7 +9,7 @@ export function initApiClient({ key: accessToken, hostname }: SessionInfo): ApiC userId: 'unknown', organizationId: 'unknown', accessToken, - apiVersion: '61.0', + apiVersion: '62.0', instanceUrl, // refreshToken: refresh_token, logging: true, // TODO: make dynamic from options From fcc62c76470e76ad8f59979830b1ac457a95b75b Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sat, 16 Nov 2024 09:16:14 -0700 Subject: [PATCH 35/38] remove unused env vars from example --- .env.example | 1 - 1 file changed, 1 deletion(-) diff --git a/.env.example b/.env.example index 7a3d05f94..774ecdf7d 100644 --- a/.env.example +++ b/.env.example @@ -83,7 +83,6 @@ GOOGLE_API_KEY='' GOOGLE_CLIENT_ID='' GOOGLE_CLIENT_SECRET='' GOOGLE_REDIRECT_URI='http://localhost:3333/oauth/google/callback' -GOOGLE_ENC_KEY='' ROLLBAR_SERVER_TOKEN='' From 7d7641961b06770c1cc3ae3667ac17bb6dd70f23 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sat, 16 Nov 2024 09:42:47 -0700 Subject: [PATCH 36/38] Upgrade NX to 20.1.2 --- package.json | 40 +-- yarn.lock | 701 +++++++++++++++++++++++++++++++-------------------- 2 files changed, 447 insertions(+), 294 deletions(-) diff --git a/package.json b/package.json index 47c0f865f..ac766d60d 100644 --- a/package.json +++ b/package.json @@ -101,23 +101,23 @@ "@babel/preset-typescript": "^7.24.1", "@contentful/rich-text-react-renderer": "^15.12.1", "@emotion/babel-plugin": "11.11.0", - "@nx/devkit": "20.0.12", - "@nx/esbuild": "20.0.12", - "@nx/eslint": "20.0.12", - "@nx/eslint-plugin": "20.0.12", - "@nx/express": "20.0.12", - "@nx/jest": "20.0.12", - "@nx/js": "20.0.12", - "@nx/next": "20.0.12", - "@nx/node": "20.0.12", - "@nx/playwright": "20.0.12", - "@nx/plugin": "20.0.12", - "@nx/react": "20.0.12", - "@nx/storybook": "20.0.12", - "@nx/vite": "20.0.12", - "@nx/web": "20.0.12", - "@nx/webpack": "20.0.12", - "@nx/workspace": "20.0.12", + "@nx/devkit": "20.1.2", + "@nx/esbuild": "20.1.2", + "@nx/eslint": "20.1.2", + "@nx/eslint-plugin": "20.1.2", + "@nx/express": "20.1.2", + "@nx/jest": "20.1.2", + "@nx/js": "20.1.2", + "@nx/next": "20.1.2", + "@nx/node": "20.1.2", + "@nx/playwright": "20.1.2", + "@nx/plugin": "20.1.2", + "@nx/react": "20.1.2", + "@nx/storybook": "20.1.2", + "@nx/vite": "20.1.2", + "@nx/web": "20.1.2", + "@nx/webpack": "20.1.2", + "@nx/workspace": "20.1.2", "@playwright/test": "^1.48.2", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", "@release-it/bumper": "^6.0.1", @@ -188,10 +188,10 @@ "eslint-config-next": "14.2.3", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "2.31.0", - "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-jsx-a11y": "6.10.1", "eslint-plugin-playwright": "^0.15.3", "eslint-plugin-react": "^7.34.1", - "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-hooks": "5.0.0", "eslint-plugin-storybook": "^0.8.0", "git-revision-webpack-plugin": "^5.0.0", "html-webpack-plugin": "^5.6.0", @@ -202,7 +202,7 @@ "jsonc-eslint-parser": "^2.1.0", "next-sitemap": "^4.2.3", "npm-run-all": "^4.1.5", - "nx": "20.0.12", + "nx": "20.1.2", "postcss": "^8.4.48", "postcss-preset-env": "7", "prettier": "2.7.0", diff --git a/yarn.lock b/yarn.lock index ab29dfb76..5562b8fd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5587,12 +5587,12 @@ "@types/semver" "7.5.8" semver "7.6.3" -"@module-federation/bridge-react-webpack-plugin@0.6.6": - version "0.6.6" - resolved "https://registry.yarnpkg.com/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-0.6.6.tgz#4b026915695d419ce4f69b578d2e7e9638f37ffb" - integrity sha512-NANaSOKem+1t/Fbd1GjXnStJRe7O33ya+FR/yYkTUd1H5hmlzVDNo/lYxYuUl3O/gH9Lnlr2Gf9unyWoIW0wHw== +"@module-federation/bridge-react-webpack-plugin@0.6.9": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@module-federation/bridge-react-webpack-plugin/-/bridge-react-webpack-plugin-0.6.9.tgz#ff255f1506d997afaf64efb0168a7a595343da7b" + integrity sha512-KXTPO0vkrtHEIcthU3TIQEkPxoytcmdyNXRwOojZEVQhqEefykAek48ndFiVTmyOu2LW2EuzP49Le8zY7nESWQ== dependencies: - "@module-federation/sdk" "0.6.6" + "@module-federation/sdk" "0.6.9" "@types/semver" "7.5.8" semver "7.6.3" @@ -5605,13 +5605,13 @@ "@module-federation/sdk" "0.6.16" fs-extra "9.1.0" -"@module-federation/data-prefetch@0.6.6": - version "0.6.6" - resolved "https://registry.yarnpkg.com/@module-federation/data-prefetch/-/data-prefetch-0.6.6.tgz#b00835491dcfed5b30c8847bed7b2b1f9ed0e7e1" - integrity sha512-rakEHrg2pqbOqJ3uWT2p3kgTCOxBQdEIqmew3XBAXTZ0NblZtkXeMHupcW/W6+ccvbPdn/T/PSICx9HHSvfEVg== +"@module-federation/data-prefetch@0.6.9": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@module-federation/data-prefetch/-/data-prefetch-0.6.9.tgz#17e3a7b91cfdd69c4b902f54db42d61d857a7ace" + integrity sha512-rpHxfHNkIiPA441GzXI6TMYjSrUjRWDwxJTvRQopX/P0jK5vKtNwT1UBTNF2DJkbtO1idljfhbrIufEg0OY72w== dependencies: - "@module-federation/runtime" "0.6.6" - "@module-federation/sdk" "0.6.6" + "@module-federation/runtime" "0.6.9" + "@module-federation/sdk" "0.6.9" fs-extra "9.1.0" "@module-federation/dts-plugin@0.6.16": @@ -5636,14 +5636,14 @@ rambda "^9.1.0" ws "8.18.0" -"@module-federation/dts-plugin@0.6.6": - version "0.6.6" - resolved "https://registry.yarnpkg.com/@module-federation/dts-plugin/-/dts-plugin-0.6.6.tgz#43d55bffed45c989f6bb7a10f8f9a44fcc9c63a4" - integrity sha512-sNCghGgrpCOOVk2xpzgAGAFeo2ONcv6eAnEfe7Q2gD7R6NrGgOrB5KVhN/uWIzFJG8tqNfSSjam+woTyrrayfg== +"@module-federation/dts-plugin@0.6.9": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@module-federation/dts-plugin/-/dts-plugin-0.6.9.tgz#8c1642711d0244e3b8b542a9d77c72c28b8e134f" + integrity sha512-uiMjjEFcMlOvRtNu8/tt7sJ5y7WTosTVym0V7lMQjgoeX0QesvZqRhgzw5gQcPcFvbk54RwTUI2rS8OEGScCFw== dependencies: - "@module-federation/managers" "0.6.6" - "@module-federation/sdk" "0.6.6" - "@module-federation/third-party-dts-extractor" "0.6.6" + "@module-federation/managers" "0.6.9" + "@module-federation/sdk" "0.6.9" + "@module-federation/third-party-dts-extractor" "0.6.9" adm-zip "^0.5.10" ansi-colors "^4.1.3" axios "^1.7.4" @@ -5657,19 +5657,19 @@ rambda "^9.1.0" ws "8.17.1" -"@module-federation/enhanced@0.6.6": - version "0.6.6" - resolved "https://registry.yarnpkg.com/@module-federation/enhanced/-/enhanced-0.6.6.tgz#2fe8a61e83ca757f3289cc93b0e4363468955820" - integrity sha512-gGU1tjaksk5Q5X2zpVb/OmlwvKwVVjTXreuFwkK0Z+9QKM9jbu0B/tPSh6sqibPFeu1yM2HOFlOHJhvFs1PmsA== - dependencies: - "@module-federation/bridge-react-webpack-plugin" "0.6.6" - "@module-federation/data-prefetch" "0.6.6" - "@module-federation/dts-plugin" "0.6.6" - "@module-federation/managers" "0.6.6" - "@module-federation/manifest" "0.6.6" - "@module-federation/rspack" "0.6.6" - "@module-federation/runtime-tools" "0.6.6" - "@module-federation/sdk" "0.6.6" +"@module-federation/enhanced@0.6.9": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@module-federation/enhanced/-/enhanced-0.6.9.tgz#1fb97b3e365b11e3558dc921a7ac1751b2f59ec6" + integrity sha512-4bEGQSE6zJ2FMdBTOrRiVjNNzWhUqzWEJGWbsr0bpLNAl4BVx2ah5MyKTrSYqaW//BRA2qc8rmrIreaIawr3kQ== + dependencies: + "@module-federation/bridge-react-webpack-plugin" "0.6.9" + "@module-federation/data-prefetch" "0.6.9" + "@module-federation/dts-plugin" "0.6.9" + "@module-federation/managers" "0.6.9" + "@module-federation/manifest" "0.6.9" + "@module-federation/rspack" "0.6.9" + "@module-federation/runtime-tools" "0.6.9" + "@module-federation/sdk" "0.6.9" btoa "^1.2.1" upath "2.0.1" @@ -5703,12 +5703,12 @@ find-pkg "2.0.0" fs-extra "9.1.0" -"@module-federation/managers@0.6.6": - version "0.6.6" - resolved "https://registry.yarnpkg.com/@module-federation/managers/-/managers-0.6.6.tgz#37fb77dbb8e7e0690681e4d2e10fa706d1c3ab97" - integrity sha512-ryj2twbQmo2KhwKn1xYivpaW94l5wfplDU9FwVvW0wc8hC2lJnuGhoiZqXKL7lNaBrZXge3b43Zlgx5OnFfr6A== +"@module-federation/managers@0.6.9": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@module-federation/managers/-/managers-0.6.9.tgz#edd5a64e3b669e04d29b5d7b83dfe72d1bdf39df" + integrity sha512-q3AOQXcWWpdUZI1gDIi9j/UqcP+FJBYXj/e4pNp3QAteJwS/Ve9UP3y0hW27bIbAWZSSajWsYbf/+YLnktA/kQ== dependencies: - "@module-federation/sdk" "0.6.6" + "@module-federation/sdk" "0.6.9" find-pkg "2.0.0" fs-extra "9.1.0" @@ -5723,14 +5723,14 @@ chalk "3.0.0" find-pkg "2.0.0" -"@module-federation/manifest@0.6.6": - version "0.6.6" - resolved "https://registry.yarnpkg.com/@module-federation/manifest/-/manifest-0.6.6.tgz#09b28e36903dbb2666776e9ef800c2440f68cdbe" - integrity sha512-45ol0fC8RS2d+0iEt5zdp0vctE2CiOfA2kCmOFz79K33occi8sKmyevfSeZGckZy54NiMnLFteIYBsyIa+g7gg== +"@module-federation/manifest@0.6.9": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@module-federation/manifest/-/manifest-0.6.9.tgz#d350106d3f6b9c3ca106bb50ae1f31ea6c65d7ab" + integrity sha512-JMSPDpHODXOmTyJes8GJ950mbN7tqjQzqgFVUubDOVFOmlC0/MYaRzRPmkApz6d8nUfMbLZYzxNSaBHx8GP0/Q== dependencies: - "@module-federation/dts-plugin" "0.6.6" - "@module-federation/managers" "0.6.6" - "@module-federation/sdk" "0.6.6" + "@module-federation/dts-plugin" "0.6.9" + "@module-federation/managers" "0.6.9" + "@module-federation/sdk" "0.6.9" chalk "3.0.0" find-pkg "2.0.0" @@ -5746,17 +5746,17 @@ "@module-federation/runtime-tools" "0.6.16" "@module-federation/sdk" "0.6.16" -"@module-federation/rspack@0.6.6": - version "0.6.6" - resolved "https://registry.yarnpkg.com/@module-federation/rspack/-/rspack-0.6.6.tgz#494fc7ec4c99b4d398ce171b6a2643aa0f31dba1" - integrity sha512-30X6QPrJ/eCcmUL4GQ06Z9bQwURBnJI0607Fw2ufmAbhDA0PJFtg7NFFfXzsdChms1ACVbgvgfBH8SJg8j3wBg== +"@module-federation/rspack@0.6.9": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@module-federation/rspack/-/rspack-0.6.9.tgz#e49613f64c0fa3c8a0d65dcad1a046f45399a020" + integrity sha512-N5yBqN8ijSRZKd0kbIvpZNil0y8rFa8cREKI1QsW1+EYUKwOUBFwF55tFdTmNCKmpZqSEBtcNjRGZXknsYPQxg== dependencies: - "@module-federation/bridge-react-webpack-plugin" "0.6.6" - "@module-federation/dts-plugin" "0.6.6" - "@module-federation/managers" "0.6.6" - "@module-federation/manifest" "0.6.6" - "@module-federation/runtime-tools" "0.6.6" - "@module-federation/sdk" "0.6.6" + "@module-federation/bridge-react-webpack-plugin" "0.6.9" + "@module-federation/dts-plugin" "0.6.9" + "@module-federation/managers" "0.6.9" + "@module-federation/manifest" "0.6.9" + "@module-federation/runtime-tools" "0.6.9" + "@module-federation/sdk" "0.6.9" "@module-federation/runtime-tools@0.6.16": version "0.6.16" @@ -5766,13 +5766,13 @@ "@module-federation/runtime" "0.6.16" "@module-federation/webpack-bundler-runtime" "0.6.16" -"@module-federation/runtime-tools@0.6.6": - version "0.6.6" - resolved "https://registry.yarnpkg.com/@module-federation/runtime-tools/-/runtime-tools-0.6.6.tgz#35d946516bf841941feccf491ab17df8e84eb2e9" - integrity sha512-w2qHa41p6rADWMS1yBjpqNhaLZ4R5oRy9OYGPe6ywjh+8oqbiBl1CfQglcgEBIpHktEjV/upsgsnjHSdJBdeZw== +"@module-federation/runtime-tools@0.6.9": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@module-federation/runtime-tools/-/runtime-tools-0.6.9.tgz#b874da2a280d29ed54900f058e32d4736153e6dc" + integrity sha512-AhsEBXo8IW1ATMKS1xfJaxBiHu9n5z6WUOAIWdPpWXXBJhTFgOs0K1xAod0xLJY4YH/B5cwEcHRPN3FEs2/0Ww== dependencies: - "@module-federation/runtime" "0.6.6" - "@module-federation/webpack-bundler-runtime" "0.6.6" + "@module-federation/runtime" "0.6.9" + "@module-federation/webpack-bundler-runtime" "0.6.9" "@module-federation/runtime@0.6.16": version "0.6.16" @@ -5782,12 +5782,12 @@ "@module-federation/error-codes" "0.6.14" "@module-federation/sdk" "0.6.16" -"@module-federation/runtime@0.6.6": - version "0.6.6" - resolved "https://registry.yarnpkg.com/@module-federation/runtime/-/runtime-0.6.6.tgz#fd6b9216a4d7dcb3fe5b2a06517c95b65578106e" - integrity sha512-QsKHUV2HALRzL6mPCdJEZTDuPReKC8MMXf+/VMCtQPp6JhLEjZIO06bfEZqXMbTbTYlMzntIwu1tGCbtJRZDOQ== +"@module-federation/runtime@0.6.9": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@module-federation/runtime/-/runtime-0.6.9.tgz#3e38325dcecccf44a8fe737a3c284d0a6f7b9bbe" + integrity sha512-G1x+6jyW5sW1X+TtWaKigGhwqiHE8MESvi3ntE9ICxwELAGBonmsqDqnLSrdEy6poBKslvPANPJr0Nn9pvW9lg== dependencies: - "@module-federation/sdk" "0.6.6" + "@module-federation/sdk" "0.6.9" "@module-federation/sdk@0.6.16", "@module-federation/sdk@^0.6.0": version "0.6.16" @@ -5796,10 +5796,10 @@ dependencies: isomorphic-rslog "0.0.5" -"@module-federation/sdk@0.6.6": - version "0.6.6" - resolved "https://registry.yarnpkg.com/@module-federation/sdk/-/sdk-0.6.6.tgz#5c29e3728f906df0d6eaf7b36e6220a32b5aeebe" - integrity sha512-tUv2kPi0FvplcpGi/g4nITAYVAR1RUZ6QvP71T8inmRZSrfcvk1QpGJiL36IjuS67SM3VAoXS0iJ2WX1Rgjvhg== +"@module-federation/sdk@0.6.9": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@module-federation/sdk/-/sdk-0.6.9.tgz#e1e26b408e68f2804e27a36a340b5a5b07054f55" + integrity sha512-xmTxb9LgncxPGsBrN6AT/+aHnFGv8swbeNl0PcSeVbXTGLu3Gp7j+5J+AhJoWNB++SLguRwBd8LjB1d8mNKLDg== "@module-federation/third-party-dts-extractor@0.6.16": version "0.6.16" @@ -5810,10 +5810,10 @@ fs-extra "9.1.0" resolve "1.22.8" -"@module-federation/third-party-dts-extractor@0.6.6": - version "0.6.6" - resolved "https://registry.yarnpkg.com/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-0.6.6.tgz#67b21ed170040638db0d738bbe0cd9b4e07352f7" - integrity sha512-xX9p17PpElzATNEulwlJJT731xST7T7OUIDSkkIghp/ICDmZd6WhYJvNBto7xbpaj5SIB7Ocdj4chNGv0xdYPw== +"@module-federation/third-party-dts-extractor@0.6.9": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@module-federation/third-party-dts-extractor/-/third-party-dts-extractor-0.6.9.tgz#7ebee7e7e7b3d294f37d74452b91acaf969224a0" + integrity sha512-im00IQyX/siJz+SaAmJo6vGmMBig7UYzcrPD1N5NeiZonxdT1RZk9iXUP419UESgovYy4hM6w4qdCq6PMMl2bw== dependencies: find-pkg "2.0.0" fs-extra "9.1.0" @@ -5827,13 +5827,13 @@ "@module-federation/runtime" "0.6.16" "@module-federation/sdk" "0.6.16" -"@module-federation/webpack-bundler-runtime@0.6.6": - version "0.6.6" - resolved "https://registry.yarnpkg.com/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.6.6.tgz#8c02c74b6e9536921115308389fafdb2cc7dcf1b" - integrity sha512-0UnY9m1fBgHwTpacYWbht1jB5X4Iqspiu1q8kfjUrv6y+R224//ydUFYYO8xfWx4V9SGQFKlU8XFH0FP/r0Hng== +"@module-federation/webpack-bundler-runtime@0.6.9": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.6.9.tgz#9e403a2c59c55d2deba0a43b18dde92e8fe73c56" + integrity sha512-ME1MjNT/a4MFI3HaJDM06olJ+/+H8lk4oDOdwwEZI2JSH3UoqCDrMcjSKCjBNMGzza57AowGobo1LHQeY8yZ8Q== dependencies: - "@module-federation/runtime" "0.6.6" - "@module-federation/sdk" "0.6.6" + "@module-federation/runtime" "0.6.9" + "@module-federation/sdk" "0.6.9" "@mole-inc/bin-wrapper@^8.0.1": version "8.0.1" @@ -5955,22 +5955,22 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@nx/cypress@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/cypress/-/cypress-20.0.12.tgz#d83c91cb0fb3961c4fbb68943db05b26e72d67c8" - integrity sha512-bBA2yMKKWNaYXhBJGVgOY8BM6n0t3X02X4+nlWusPkPQFS7DNnnYTOjUznj+0TNbxcPQ1Au5rfCowuxm2Z9rcA== +"@nx/cypress@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/cypress/-/cypress-20.1.2.tgz#63efc1f94c67d0cb71f8fcec8ce758fc1996fb94" + integrity sha512-kT/vXWqD4DxYawtVBA3E1EYlFi6ba6XvEnh+Ac5A1EX0PmVqBxhtBxpDlLjJxDOEgpIWbZDFdkJ41twYQgYDGA== dependencies: - "@nx/devkit" "20.0.12" - "@nx/eslint" "20.0.12" - "@nx/js" "20.0.12" + "@nx/devkit" "20.1.2" + "@nx/eslint" "20.1.2" + "@nx/js" "20.1.2" "@phenomnomnominal/tsquery" "~5.0.1" detect-port "^1.5.1" tslib "^2.3.0" -"@nx/devkit@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/devkit/-/devkit-20.0.12.tgz#9f9e7bb59f329fdaaa0c654e471ddeb38e85cf6e" - integrity sha512-HsaDoAmzLPE2vHal2eNYvH7x6NCfHjUblm8WDD12Q/uCdTBvDTZqd7P+bukEH+2FhY89Dn/1fy59vKkA+rcB/g== +"@nx/devkit@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/devkit/-/devkit-20.1.2.tgz#10280e90aad081d7ba7b9366f4bc0abb46e65397" + integrity sha512-MTEWiEST7DhzZ2QmrixLnHfYVDZk7QN9omLL8m+5Etcn/3ZKa1aAo9Amd2MkUM+0MPoTKnxoGdw0fQUpAy21Mg== dependencies: ejs "^3.1.7" enquirer "~2.3.6" @@ -5981,25 +5981,25 @@ tslib "^2.3.0" yargs-parser "21.1.1" -"@nx/esbuild@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/esbuild/-/esbuild-20.0.12.tgz#8392627f6738ad4f52647d2518549ed3d889d695" - integrity sha512-ooGegtH18owgOL3ODYjPB0tcz43w82ZCZafdCZb9FwM6eJ6PbGt3thuvEJ7t1TXf1sUcYI95sEMvH5O4+CMvWg== +"@nx/esbuild@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/esbuild/-/esbuild-20.1.2.tgz#8dd0ba43bd59d4afff9c94ec268cc6eac0e7cdd9" + integrity sha512-RfM1abxiJnI1EOStEltJ0H4aM+v8WMfSBoUhn0n1kTr4YBxVfd7vwR4w2zBoCDNx8oCvehl6GwEHIyRjrtRLnQ== dependencies: - "@nx/devkit" "20.0.12" - "@nx/js" "20.0.12" + "@nx/devkit" "20.1.2" + "@nx/js" "20.1.2" fast-glob "3.2.7" picocolors "^1.1.0" tsconfig-paths "^4.1.2" tslib "^2.3.0" -"@nx/eslint-plugin@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/eslint-plugin/-/eslint-plugin-20.0.12.tgz#b983fe3541b69e3bd2dae1ebab484e90d7b8294e" - integrity sha512-DZVKKaDSXYTIYG0vIb/GX6kZ5wEN4X/alx7/Q5k5IAeTjF2yOHz3V+jxqLWShdT3RXfl5QNZSbefs/OBC9ljQA== +"@nx/eslint-plugin@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/eslint-plugin/-/eslint-plugin-20.1.2.tgz#3f2d4eb722f698b5fb59b2b36bfb88b3db857e23" + integrity sha512-eLOVzaBPwS71Bb07jhJFZYtkvD33fZb3ObwLDXG5DmfpNpYBGOD4XX0qj6eq/5cfsIck6n8n7RKVm+7ZyqYowg== dependencies: - "@nx/devkit" "20.0.12" - "@nx/js" "20.0.12" + "@nx/devkit" "20.1.2" + "@nx/js" "20.1.2" "@typescript-eslint/type-utils" "^8.0.0" "@typescript-eslint/utils" "^8.0.0" chalk "^4.1.0" @@ -6009,36 +6009,36 @@ semver "^7.5.3" tslib "^2.3.0" -"@nx/eslint@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/eslint/-/eslint-20.0.12.tgz#631513a199e566c3413600e049488d41d3755f1c" - integrity sha512-w0V6Yx++6A4t8xbJqlZMfjGqQpSivV4pnJPdUz64hgMjWCJ14yH8VViD/9d1Zf2qpFLzlbZtm4+tJF8slMYboA== +"@nx/eslint@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/eslint/-/eslint-20.1.2.tgz#741b4d06345e8bdd40b25469ed703add9468513a" + integrity sha512-VMJ65E0jUEjup8hxz6LtqYbYnk2TUoLCM7ZV4rZdPqm0rLvlHDmb7BfdY2u2sZa3dwRDtupeDMlbyPX/Eb8Rcw== dependencies: - "@nx/devkit" "20.0.12" - "@nx/js" "20.0.12" + "@nx/devkit" "20.1.2" + "@nx/js" "20.1.2" semver "^7.5.3" tslib "^2.3.0" typescript "~5.4.2" -"@nx/express@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/express/-/express-20.0.12.tgz#d3c73f5f6f3021574338e2a26bda8a8f9ff7ae4f" - integrity sha512-49x6HkI/tfiTdwzu6RlWI+07/kKoQ3EqY+QPuOvmI72ATWDdTbLOQx376VV5tY6yLBjAL8odZz7Whh19d08mRw== +"@nx/express@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/express/-/express-20.1.2.tgz#efa047b558f9d42a64260ea3c38ca1c5940536ac" + integrity sha512-zaAXKEmAOEjKEvGbaGh4WQGlvjT3iQdzdUYA0rr+6M8644SmCHGpax5LeZs3RkwMHjpntXgHMyWTggGDS3sxRw== dependencies: - "@nx/devkit" "20.0.12" - "@nx/js" "20.0.12" - "@nx/node" "20.0.12" + "@nx/devkit" "20.1.2" + "@nx/js" "20.1.2" + "@nx/node" "20.1.2" tslib "^2.3.0" -"@nx/jest@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/jest/-/jest-20.0.12.tgz#e8231e83fd84b173b3dfbb343c95c6fa9bb11cd4" - integrity sha512-+eyYuTN2R/5EQBJq4N4Kg7Tr7+W+y/bFdNF5cFZ+CkHi9u+lfI6y4WmjToPisoa9ZL99DxXkPQiuhtGOr6MY9A== +"@nx/jest@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/jest/-/jest-20.1.2.tgz#e3d4ec4dc0781e989383388f0d573feeb9a906bd" + integrity sha512-KUHm+NcH4Iq/Pk6GpaRhACEHd8Gt28dbXUAErxo/T9b+a3ir/6uUb4Sr+aXf63uYSePDhUmYbrYxGf/KzS2I8w== dependencies: "@jest/reporters" "^29.4.1" "@jest/test-result" "^29.4.1" - "@nx/devkit" "20.0.12" - "@nx/js" "20.0.12" + "@nx/devkit" "20.1.2" + "@nx/js" "20.1.2" "@phenomnomnominal/tsquery" "~5.0.1" chalk "^4.1.0" identity-obj-proxy "3.0.0" @@ -6051,10 +6051,10 @@ tslib "^2.3.0" yargs-parser "21.1.1" -"@nx/js@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/js/-/js-20.0.12.tgz#9ca8f0b2efc0097473af0b9a2c53b40085507842" - integrity sha512-4lfetz92z+AkN7fUanKoac45TA8TBDzgfMfBjPVh2zWMzLTmxyn8BZdjd8eZ45pf3k73N30HMvhfO/+hS/nstA== +"@nx/js@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/js/-/js-20.1.2.tgz#93f4e4fc5fa78bfbee48bab5bdd8cbdffb01e557" + integrity sha512-+ULLy0vuAUyRicQqjMsG3JmgEylZdciJJOuOanwrmmG/+jv64nUJYycZbwPmGsioViHk/0WB1d5SWWfH7cZ+Ww== dependencies: "@babel/core" "^7.23.2" "@babel/plugin-proposal-decorators" "^7.22.7" @@ -6063,8 +6063,8 @@ "@babel/preset-env" "^7.23.2" "@babel/preset-typescript" "^7.22.5" "@babel/runtime" "^7.22.6" - "@nx/devkit" "20.0.12" - "@nx/workspace" "20.0.12" + "@nx/devkit" "20.1.2" + "@nx/workspace" "20.1.2" "@zkochan/js-yaml" "0.0.7" babel-plugin-const-enum "^1.0.1" babel-plugin-macros "^2.8.0" @@ -6087,18 +6087,18 @@ tsconfig-paths "^4.1.2" tslib "^2.3.0" -"@nx/next@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/next/-/next-20.0.12.tgz#b896d37b5e5eb8ee97480910184533331981612b" - integrity sha512-xaK87xhZGocoQZqo0xzYIGNCAuR+GhVxjNXU54/sOJg5fmBb/hQYA1d/4bu/e58xviOqhHxYdcUqAsEbW8ej+A== +"@nx/next@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/next/-/next-20.1.2.tgz#f120d6ac10cd77b7bf42e8fcf4c3357ccae4b480" + integrity sha512-VWsMjLfHo4mXCtKm/hzRNHxZvDdz4kvznmBIbrK+H9zmM1hh7T0lzhP/d8LI6sik3DvGp1jz8tRk/cZjwKmVZQ== dependencies: "@babel/plugin-proposal-decorators" "^7.22.7" - "@nx/devkit" "20.0.12" - "@nx/eslint" "20.0.12" - "@nx/js" "20.0.12" - "@nx/react" "20.0.12" - "@nx/web" "20.0.12" - "@nx/webpack" "20.0.12" + "@nx/devkit" "20.1.2" + "@nx/eslint" "20.1.2" + "@nx/js" "20.1.2" + "@nx/react" "20.1.2" + "@nx/web" "20.1.2" + "@nx/webpack" "20.1.2" "@phenomnomnominal/tsquery" "~5.0.1" "@svgr/webpack" "^8.0.1" copy-webpack-plugin "^10.2.4" @@ -6108,102 +6108,102 @@ tslib "^2.3.0" webpack-merge "^5.8.0" -"@nx/node@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/node/-/node-20.0.12.tgz#5b9de921a38e8c99030419a7d9986707c69b0a1c" - integrity sha512-n9wWZnmOWRwM3D8eSpD15E748atXw0195TNiQq7MV7ouGpk5uZjfNTM5tDvF7NzPj45GjLpI1cfRPv3Q2cXEGw== +"@nx/node@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/node/-/node-20.1.2.tgz#c1cf049ab928332ab3d6bb31b5e359b3a9a9e322" + integrity sha512-PGPSXkzTJc97GnsRNSBcekH5L5BM/SCSWA8lH/bBV/N8HBFUWppsv0Nj+UUcGGH3O3kjEMrhtbG9iJijX7+9kw== dependencies: - "@nx/devkit" "20.0.12" - "@nx/eslint" "20.0.12" - "@nx/jest" "20.0.12" - "@nx/js" "20.0.12" + "@nx/devkit" "20.1.2" + "@nx/eslint" "20.1.2" + "@nx/jest" "20.1.2" + "@nx/js" "20.1.2" tslib "^2.3.0" -"@nx/nx-darwin-arm64@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/nx-darwin-arm64/-/nx-darwin-arm64-20.0.12.tgz#95a5c1990ed1801c2e3d4a8e3e7ddfe577cd57db" - integrity sha512-iwEDUTKx0n2S6Nz9gc9ShrfBw0MG87U0YIu2x/09tKOSkcsw90QKy54qN/6WNoFIE41Kt3U+dYtWi+NdLRE9kw== - -"@nx/nx-darwin-x64@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/nx-darwin-x64/-/nx-darwin-x64-20.0.12.tgz#a75497d047ad25ea18f19b8d4229ca274a677ecf" - integrity sha512-JYFNf0yPReejaooQAAIMsjWDGENT777wDXj45e7JQUMM4t6NOMpGBj4qUFyc6a/jXT+/bCGEj4N7VDZDZiogGA== - -"@nx/nx-freebsd-x64@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/nx-freebsd-x64/-/nx-freebsd-x64-20.0.12.tgz#d466a1f2207aca677bbb99123e29213034121aba" - integrity sha512-892n8o7vxdmE7pol3ggV78YHlP25p6Y/Z2x69nnC3BBTpWmesyd6lbEmamANofD5KcKCmT1HquC3m6rCT7akHw== - -"@nx/nx-linux-arm-gnueabihf@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-20.0.12.tgz#8c1e95609be2d72834fc2e3809933413cbd2b2c0" - integrity sha512-ZPcdYIVAc5JMtmvroJOloI9CJgtwBOGr7E7mO1eT44zs5av0j/QMIj6GSDdvJ7fx+I7TmT4mDiu3s6rLO+/JjA== - -"@nx/nx-linux-arm64-gnu@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-20.0.12.tgz#f146074a038e5c271d5240dedc37815c97bd4e9a" - integrity sha512-TadGwwUKS5WQg2YOMb2WuuVG1k14miSdB9qJOcAX5MGdOiQ1fpV00ph+kMWZSsCCo6N7sKxmvXXXdsUUFSDGjg== - -"@nx/nx-linux-arm64-musl@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-20.0.12.tgz#2bb1729f314dc8ce73d6190b45a30321802ce0b6" - integrity sha512-EE2HQjgY87/s9+PQ27vbYyDEXFZ4Qot+O8ThVDVuMI/2dosmWs6C4+YEm3VYG+CT31MVwe/vHKXbDlZgkROMuA== - -"@nx/nx-linux-x64-gnu@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-20.0.12.tgz#a444f88d42fe126d05ccc1759de2c37fe6e0637f" - integrity sha512-gITJ2g6dH2qvGrI2CHHRyd3soVrJyQQGkqtJnWq04ge+YDy/KniXR2ThQ93LI/QLAxKrKOe3qmIIaNdcdDYnjA== - -"@nx/nx-linux-x64-musl@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-20.0.12.tgz#512016069d67b090d6c7c66057b68681dfe7768d" - integrity sha512-vOoCrjL44nFZ5N8a4UAIYELnf/tq1dRaLEhSV+P0hKTEtwONj4k8crfU/2HifG1iU7p3AWJLEyaddMoINhB/2g== - -"@nx/nx-win32-arm64-msvc@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-20.0.12.tgz#4472c648c680e633c596c2766ad048b25596eba5" - integrity sha512-gKdaul23bdRnh493iAd6pSLPSW54VBuEv2zPL86cgprLOcEZiGM5BLJWQguKHCib6dYKaIP4CUIs7i7vhEID+A== - -"@nx/nx-win32-x64-msvc@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-20.0.12.tgz#d2b820e33da40b2130e6f4f3a7849e777ffa2a58" - integrity sha512-R1pz4kAG0Ok0EDxXhHwKM3ZZcK2nLycuR9SDrq2Ldp2knvbFf4quSjWyAQaiofJXo179+noa7o5tZDZbNjBYMw== - -"@nx/playwright@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/playwright/-/playwright-20.0.12.tgz#f036180dc03873f328bab3f0bea50f360f12addc" - integrity sha512-ipKLndvND0eAgFCeamBSgBwc4HlKjp22ibzJEOw7nJuwRUhhyWqwiT9ZVmAXwpGVWEG06s4R0oFJXt+u79Sh3g== - dependencies: - "@nx/devkit" "20.0.12" - "@nx/eslint" "20.0.12" - "@nx/js" "20.0.12" - "@nx/vite" "20.0.12" - "@nx/webpack" "20.0.12" +"@nx/nx-darwin-arm64@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/nx-darwin-arm64/-/nx-darwin-arm64-20.1.2.tgz#880dc02d2256c3f6ec98e9ab51e7c88ceedf3610" + integrity sha512-PJ91TQhd28kitDBubKUOXMYvrtSDrG+rr8MsIe9cHo1CvU9smcGVBwuHBxniq0DXsyOX/5GL6ngq7hjN2nQ3XQ== + +"@nx/nx-darwin-x64@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/nx-darwin-x64/-/nx-darwin-x64-20.1.2.tgz#e9dc306affcb18a9900efbdbcadd514ecd5fbda5" + integrity sha512-1fopau7nxIhTF26vDTIzMxl15AtW4FvUSdy+r1mNRKrKyjjpqnlu00SQBW7JzGV0agDD1B/61yYei5Q2aMOt7Q== + +"@nx/nx-freebsd-x64@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/nx-freebsd-x64/-/nx-freebsd-x64-20.1.2.tgz#ca967f9da06f93e0039b92c0c072d0c9a93b7776" + integrity sha512-55YgIp3v4zz7xMzJO93dtglbOTER2XdS6jrCt8GbKaWGFl5drRrBoNGONtiGNU7C3hLx1VsorbynCkJT18PjKQ== + +"@nx/nx-linux-arm-gnueabihf@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-20.1.2.tgz#c9bad02ecbf0c47e1cf1e49bc348e962665074bd" + integrity sha512-sMhNA8uAV43UYVEXEa8TZ8Fjpom4CGq1umTptEGOF4TTtdNn2AUBreg+0bVODM8MMSzRWGI1VbkZzHESnAPwqw== + +"@nx/nx-linux-arm64-gnu@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-20.1.2.tgz#713e83f11a20bb1dc3832faf9b08c938b2cf4c31" + integrity sha512-bsevarNHglaYLmIvPNQOdHrBnBgaW3EOUM0flwaXdWuZbL1bWx8GoVwHp9yJpZOAOfIF/Nhq5iTpaZB2nYFrAA== + +"@nx/nx-linux-arm64-musl@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-20.1.2.tgz#5091e70d20603b7cd70b0bd6022b07c0a9779d79" + integrity sha512-GFZTptkhZPL/iZ3tYDmspIcPEaXyy/L/o59gyp33GoFAAyDhiXIF7J1Lz81Xn8VKrX6TvEY8/9qSh86pb7qzDQ== + +"@nx/nx-linux-x64-gnu@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-20.1.2.tgz#8c0089fd7e797950bbe6e6898c0762bbfa55716d" + integrity sha512-yqEW/iglKT4d9lgfnwSNhmDzPxCkRhtdmZqOYpGDM0eZFwYwJF+WRGjW8xIqMj8PA1yrGItzXZOmyFjJqHAF2w== + +"@nx/nx-linux-x64-musl@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-20.1.2.tgz#121d6c276b97017f62168c0a018e709ef679ca01" + integrity sha512-SP6PpWT4cQVrC4WJQdpfADrYJQzkbhgmcGleWbpr7II1HJgOsAcvoDwQGpPQX+3Wo+VBiNecvUAOzacMQkXPGw== + +"@nx/nx-win32-arm64-msvc@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-20.1.2.tgz#37a57b4c21e23bb377a5ddffa034939f2e465211" + integrity sha512-JZQx9gr39LY3D7uleiXlpxUsavuOrOQNBocwKHkAMnykaT/e1VCxTnm/hk+2b4foWwfURTqoRiFEba70iiCdYg== + +"@nx/nx-win32-x64-msvc@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-20.1.2.tgz#8b24ea16d06e5ebf97b9b9438bae5e3c2f57a0d3" + integrity sha512-6GmT8iswDiCvJaCtW9DpWeAQmLS/kfAuRLYBisfzlONuLPaDdjhgVIxZBqqUSFfclwcVz+NhIOGvdr0aGFZCtQ== + +"@nx/playwright@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/playwright/-/playwright-20.1.2.tgz#1ec624a19229f5db4db784b2dc597f30adf18698" + integrity sha512-s8bXBYsECbebMOs1m/HqreFtcKrYIeh5WCWpIfB6pDFU+YQ97pSswsxVoH8cXqIj6RaCiDTs/Rl2A5EdsDgAtg== + dependencies: + "@nx/devkit" "20.1.2" + "@nx/eslint" "20.1.2" + "@nx/js" "20.1.2" + "@nx/vite" "20.1.2" + "@nx/webpack" "20.1.2" "@phenomnomnominal/tsquery" "~5.0.1" minimatch "9.0.3" tslib "^2.3.0" -"@nx/plugin@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/plugin/-/plugin-20.0.12.tgz#952cac49d542d128aedc047dcf18268323f9c326" - integrity sha512-MjNnxPPrGQp2NSduUsXHj305jDnOAjVD8JKsfiQxBanJQ4yWK4pPi4jBBh2dZ4hcd/XuBPhXHkyIO/GRAhvuKA== +"@nx/plugin@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/plugin/-/plugin-20.1.2.tgz#390fc492244abb92b44af1d2c15568584a06a244" + integrity sha512-AxUytVIYZTekEqeQfc/jnSpgVlujRsnQC+k37BiUMvxw10wChWoJVBW7O03QsJHVJJ4nI7L5f63LFmhwfsNJjA== dependencies: - "@nx/devkit" "20.0.12" - "@nx/eslint" "20.0.12" - "@nx/jest" "20.0.12" - "@nx/js" "20.0.12" + "@nx/devkit" "20.1.2" + "@nx/eslint" "20.1.2" + "@nx/jest" "20.1.2" + "@nx/js" "20.1.2" tslib "^2.3.0" -"@nx/react@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/react/-/react-20.0.12.tgz#c507088e29e254b62d49690c43a0e7baef857094" - integrity sha512-LMqOp1e6ZXblQD4CGPdF0zkhG3sSZf6VwqxhdcS6mXtTlvrKVKTEvfacRPZbQU3nwDaYJXcE9Bfgf9u9geg/1w== +"@nx/react@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/react/-/react-20.1.2.tgz#e541c3b70d8d2d3905bd3e1f8100c9df103af848" + integrity sha512-hcTstvqfaCURuRoARF5SnJd9H1HNG6agVDNpxNTnScviUQTA7cCHAoVZwNFcqlk4dRS5KPwIehRcVF6WHu9S7w== dependencies: - "@module-federation/enhanced" "0.6.6" - "@nx/devkit" "20.0.12" - "@nx/eslint" "20.0.12" - "@nx/js" "20.0.12" - "@nx/web" "20.0.12" + "@module-federation/enhanced" "0.6.9" + "@nx/devkit" "20.1.2" + "@nx/eslint" "20.1.2" + "@nx/js" "20.1.2" + "@nx/web" "20.1.2" "@phenomnomnominal/tsquery" "~5.0.1" "@svgr/webpack" "^8.0.1" express "^4.19.2" @@ -6213,54 +6213,54 @@ picocolors "^1.1.0" tslib "^2.3.0" -"@nx/storybook@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/storybook/-/storybook-20.0.12.tgz#c33b0868b354a3ee98be94132d8aa9dc48d89087" - integrity sha512-kS7k+jJUNaKUyybFqc1KV5tbsohAujWhc0YPTsqzMovd2Mk9AdJfE7/m7st4xf7SAL3M3tTNmKWrsFRTcOnIcA== +"@nx/storybook@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/storybook/-/storybook-20.1.2.tgz#c35d693b7dfe5eb59493bbf36e9a19ec7ef6dc90" + integrity sha512-M3ymcFuMYgZ2GT6hPjvVbtSCyfVPGmDy7DY1oHOYBkLqywkjzTcpjmN6Kqm5ZQUZfKYFWgIkNs2J5VL9Knn3cg== dependencies: - "@nx/cypress" "20.0.12" - "@nx/devkit" "20.0.12" - "@nx/eslint" "20.0.12" - "@nx/js" "20.0.12" + "@nx/cypress" "20.1.2" + "@nx/devkit" "20.1.2" + "@nx/eslint" "20.1.2" + "@nx/js" "20.1.2" "@phenomnomnominal/tsquery" "~5.0.1" semver "^7.5.3" tslib "^2.3.0" -"@nx/vite@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/vite/-/vite-20.0.12.tgz#c465faf3228ce29fea2f60372c270b854921938d" - integrity sha512-8oKQRfX6z1Sh1UWoAa9kW0pPxvQMcX1at8y+oqviQyPrSdhMVovfAGiK9wxG4TLNYj7Wk4xefTr6/qFqe2aVVQ== +"@nx/vite@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/vite/-/vite-20.1.2.tgz#d956626d00484ecde0b5c9f266ef4c198debca66" + integrity sha512-zcguviaEvudGS5rpPBosRP3gyniQa+/blNgEorE09GMyKedO7cbvacxV21iRH1l++D8a5bnx9Up3f66kZuRoeA== dependencies: - "@nx/devkit" "20.0.12" - "@nx/js" "20.0.12" + "@nx/devkit" "20.1.2" + "@nx/js" "20.1.2" "@phenomnomnominal/tsquery" "~5.0.1" "@swc/helpers" "~0.5.0" enquirer "~2.3.6" minimatch "9.0.3" tsconfig-paths "^4.1.2" -"@nx/web@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/web/-/web-20.0.12.tgz#9032d66c0302e413ba03ba5e6a88a9a7cd535771" - integrity sha512-lGvx0eecjfH2lsUy6fCjMIOPE8sg7trPB/aENvu/EEJ9jabrnHezxHW82AYjEpRxQVzyPgmZ7UKGjXL2GcsQyg== +"@nx/web@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/web/-/web-20.1.2.tgz#5cee534b8b6fa3181b0b46867d3956fcb2cf802f" + integrity sha512-CRMAJXwj375J+/GI9hRfOt2SJ0DQ5prCzOcmXJvQIfHy3CT5chrkSj2qc7IgKkkMiqZojr4VCTUHmJ2WAR3sCw== dependencies: - "@nx/devkit" "20.0.12" - "@nx/js" "20.0.12" + "@nx/devkit" "20.1.2" + "@nx/js" "20.1.2" detect-port "^1.5.1" http-server "^14.1.0" picocolors "^1.1.0" tslib "^2.3.0" -"@nx/webpack@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/webpack/-/webpack-20.0.12.tgz#e8f167b38723d0e69d978f12d3284feaf3354137" - integrity sha512-eFF0TLyFRe1Zfk96HnDJK5hv+eR9tzlCBSE6IE4jEyl0pXLlCA/jvmmTEiEiG5TUOM18h7GzWLdYgeRoEwi7GA== +"@nx/webpack@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/webpack/-/webpack-20.1.2.tgz#30242791fc2afd86f5e8adb0165f81bddc49ce15" + integrity sha512-H67DkdpaGnUwYbz4u31+2/TSRmkvBQHX742FNKJAc1/D0uzHH6GI3am0h0QF9wrJyc/fXGVNfRZLEh9ScU70Jw== dependencies: "@babel/core" "^7.23.2" "@module-federation/enhanced" "^0.6.0" "@module-federation/sdk" "^0.6.0" - "@nx/devkit" "20.0.12" - "@nx/js" "20.0.12" + "@nx/devkit" "20.1.2" + "@nx/js" "20.1.2" "@phenomnomnominal/tsquery" "~5.0.1" ajv "^8.12.0" autoprefixer "^10.4.9" @@ -6298,15 +6298,15 @@ webpack-node-externals "^3.0.0" webpack-subresource-integrity "^5.1.0" -"@nx/workspace@20.0.12": - version "20.0.12" - resolved "https://registry.yarnpkg.com/@nx/workspace/-/workspace-20.0.12.tgz#171fd766360d6035511bb8caea429d15b8386275" - integrity sha512-MCig+vBs6+eg/RWVB2q30cOXwfPj0cthXf/azaAT7Y+ukxhBJKKzSS4lJfOwmA4dST3+dvaQihGOkfxasqS9Aw== +"@nx/workspace@20.1.2": + version "20.1.2" + resolved "https://registry.yarnpkg.com/@nx/workspace/-/workspace-20.1.2.tgz#22de2fe10f1501dbcb17a4787f262dc123f09bb1" + integrity sha512-YZiBwHU+NsJvJ7e7AZnyk5cP523AIHmHFf28nEpBY3zhxLghx/s9C99Swbw+uUyWlUf7JtTO9jB6OsEfMc38Uw== dependencies: - "@nx/devkit" "20.0.12" + "@nx/devkit" "20.1.2" chalk "^4.1.0" enquirer "~2.3.6" - nx "20.0.12" + nx "20.1.2" tslib "^2.3.0" yargs-parser "21.1.1" @@ -10439,6 +10439,11 @@ aria-query@^5.0.0: resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz" integrity sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg== +aria-query@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" + integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw== + array-buffer-byte-length@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz#1e5583ec16763540a27ae52eed99ff899223568f" @@ -10777,6 +10782,11 @@ axe-core@=4.7.0: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.7.0.tgz#34ba5a48a8b564f67e103f0aa5768d76e15bbbbf" integrity sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ== +axe-core@^4.10.0: + version "4.10.2" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df" + integrity sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w== + axios@^0.21.1: version "0.21.4" resolved "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz" @@ -10816,6 +10826,11 @@ axobject-query@^3.2.1: dependencies: dequal "^2.0.3" +axobject-query@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee" + integrity sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ== + babel-core@^7.0.0-bridge.0: version "7.0.0-bridge.0" resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece" @@ -13654,6 +13669,58 @@ es-abstract@^1.23.0, es-abstract@^1.23.2: unbox-primitive "^1.0.2" which-typed-array "^1.1.15" +es-abstract@^1.23.3: + version "1.23.5" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.5.tgz#f4599a4946d57ed467515ed10e4f157289cd52fb" + integrity sha512-vlmniQ0WNPwXqA0BnmwV3Ng7HxiGlh6r5U6JcTMNx8OilcAGqVJBHJcPjqOMaczU9fRuRK5Px2BdVyPRnKMMVQ== + dependencies: + array-buffer-byte-length "^1.0.1" + arraybuffer.prototype.slice "^1.0.3" + available-typed-arrays "^1.0.7" + call-bind "^1.0.7" + data-view-buffer "^1.0.1" + data-view-byte-length "^1.0.1" + data-view-byte-offset "^1.0.0" + es-define-property "^1.0.0" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-set-tostringtag "^2.0.3" + es-to-primitive "^1.2.1" + function.prototype.name "^1.1.6" + get-intrinsic "^1.2.4" + get-symbol-description "^1.0.2" + globalthis "^1.0.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + has-proto "^1.0.3" + has-symbols "^1.0.3" + hasown "^2.0.2" + internal-slot "^1.0.7" + is-array-buffer "^3.0.4" + is-callable "^1.2.7" + is-data-view "^1.0.1" + is-negative-zero "^2.0.3" + is-regex "^1.1.4" + is-shared-array-buffer "^1.0.3" + is-string "^1.0.7" + is-typed-array "^1.1.13" + is-weakref "^1.0.2" + object-inspect "^1.13.3" + object-keys "^1.1.1" + object.assign "^4.1.5" + regexp.prototype.flags "^1.5.3" + safe-array-concat "^1.1.2" + safe-regex-test "^1.0.3" + string.prototype.trim "^1.2.9" + string.prototype.trimend "^1.0.8" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.2" + typed-array-byte-length "^1.0.1" + typed-array-byte-offset "^1.0.2" + typed-array-length "^1.0.6" + unbox-primitive "^1.0.2" + which-typed-array "^1.1.15" + es-array-method-boxes-properly@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz" @@ -13692,6 +13759,27 @@ es-iterator-helpers@^1.0.15, es-iterator-helpers@^1.0.17: iterator.prototype "^1.1.2" safe-array-concat "^1.1.0" +es-iterator-helpers@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.2.0.tgz#2f1a3ab998b30cb2d10b195b587c6d9ebdebf152" + integrity sha512-tpxqxncxnpw3c93u8n3VOzACmRFoVmWJqbWXvX/JfKbkhBw1oslgPrUfeSt2psuqyEJFD6N/9lg5i7bsKpoq+Q== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.3" + es-errors "^1.3.0" + es-set-tostringtag "^2.0.3" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + globalthis "^1.0.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + has-proto "^1.0.3" + has-symbols "^1.0.3" + internal-slot "^1.0.7" + iterator.prototype "^1.1.3" + safe-array-concat "^1.1.2" + es-module-lexer@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.2.1.tgz#ba303831f63e6a394983fde2f97ad77b22324527" @@ -14051,7 +14139,29 @@ eslint-plugin-import@^2.28.1: semver "^6.3.1" tsconfig-paths "^3.15.0" -eslint-plugin-jsx-a11y@^6.7.1, eslint-plugin-jsx-a11y@^6.8.0: +eslint-plugin-jsx-a11y@6.10.1: + version "6.10.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.1.tgz#87003835bad8875e023aa5db26f41a0c9e6a8fa9" + integrity sha512-zHByM9WTUMnfsDTafGXRiqxp6lFtNoSOWBY6FonVRn3A+BUwN1L/tdBXT40BcBJi0cZjOGTXZ0eD/rTG9fEJ0g== + dependencies: + aria-query "^5.3.2" + array-includes "^3.1.8" + array.prototype.flatmap "^1.3.2" + ast-types-flow "^0.0.8" + axe-core "^4.10.0" + axobject-query "^4.1.0" + damerau-levenshtein "^1.0.8" + emoji-regex "^9.2.2" + es-iterator-helpers "^1.1.0" + hasown "^2.0.2" + jsx-ast-utils "^3.3.5" + language-tags "^1.0.9" + minimatch "^3.1.2" + object.fromentries "^2.0.8" + safe-regex-test "^1.0.3" + string.prototype.includes "^2.0.1" + +eslint-plugin-jsx-a11y@^6.7.1: version "6.8.0" resolved "https://registry.yarnpkg.com/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.8.0.tgz#2fa9c701d44fcd722b7c771ec322432857fcbad2" integrity sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA== @@ -14078,16 +14188,16 @@ eslint-plugin-playwright@^0.15.3: resolved "https://registry.yarnpkg.com/eslint-plugin-playwright/-/eslint-plugin-playwright-0.15.3.tgz#9fd8753688351bcaf41797eb6a7df8807fd5eb1b" integrity sha512-LQMW5y0DLK5Fnpya7JR1oAYL2/7Y9wDiYw6VZqlKqcRGSgjbVKNqxraphk7ra1U3Bb5EK444xMgUlQPbMg2M1g== +eslint-plugin-react-hooks@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0.tgz#72e2eefbac4b694f5324154619fee44f5f60f101" + integrity sha512-hIOwI+5hYGpJEc4uPRmz2ulCjAGD/N13Lukkh8cLV0i2IRk/bdZDYjgLVHj+U9Z704kLIdIO6iueGvxNur0sgw== + "eslint-plugin-react-hooks@^4.5.0 || 5.0.0-canary-7118f5dd7-20230705": version "4.6.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3" integrity sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g== -eslint-plugin-react-hooks@^4.6.2: - version "4.6.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz#c829eb06c0e6f484b3fbb85a97e57784f328c596" - integrity sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ== - eslint-plugin-react@^7.33.2: version "7.34.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.34.0.tgz#ab71484d54fc409c37025c5eca00eb4177a5e88c" @@ -15585,6 +15695,14 @@ globalthis@^1.0.3: dependencies: define-properties "^1.1.3" +globalthis@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" + integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== + dependencies: + define-properties "^1.2.1" + gopd "^1.0.1" + globalyzer@0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/globalyzer/-/globalyzer-0.1.0.tgz#cb76da79555669a1519d5a8edf093afaa0bf1465" @@ -16899,6 +17017,17 @@ iterator.prototype@^1.1.2: reflect.getprototypeof "^1.0.4" set-function-name "^2.0.1" +iterator.prototype@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/iterator.prototype/-/iterator.prototype-1.1.3.tgz#016c2abe0be3bbdb8319852884f60908ac62bf9c" + integrity sha512-FW5iMbeQ6rBGm/oKgzq2aW4KvAGpxPzYES8N4g4xNXUKpL1mclMvOe+76AcLDTvD+Ze+sOpVhgdAQEKF4L9iGQ== + dependencies: + define-properties "^1.2.1" + get-intrinsic "^1.2.1" + has-symbols "^1.0.3" + reflect.getprototypeof "^1.0.4" + set-function-name "^2.0.1" + jackspeak@^2.0.3, jackspeak@^2.3.5: version "2.3.6" resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" @@ -19408,10 +19537,10 @@ nwsapi@^2.2.4: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30" integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ== -nx@20.0.12: - version "20.0.12" - resolved "https://registry.yarnpkg.com/nx/-/nx-20.0.12.tgz#d7d6d4c629c8bed75eef4dbeae96834ac566d70d" - integrity sha512-pQ7Rwb2Qlhr+fEamd0qc4VsL/aKjVJ0MXPsosuhdZobLJQOKHefe+nXSSZ1Jy19VM3RRpxUKFneD/V2jvs3qDA== +nx@20.1.2: + version "20.1.2" + resolved "https://registry.yarnpkg.com/nx/-/nx-20.1.2.tgz#acb5e7f01c0eb89ff21a645e383b09c75c68180c" + integrity sha512-CvjmuQmI0RWLYZxRSIgQZmzsQv6dPp9oI0YZE3L1dagBPfTf5Cun65I0GLt7bdkDnVx2PGYkDbIoJSv2/V+83Q== dependencies: "@napi-rs/wasm-runtime" "0.2.4" "@yarnpkg/lockfile" "^1.1.0" @@ -19446,16 +19575,16 @@ nx@20.0.12: yargs "^17.6.2" yargs-parser "21.1.1" optionalDependencies: - "@nx/nx-darwin-arm64" "20.0.12" - "@nx/nx-darwin-x64" "20.0.12" - "@nx/nx-freebsd-x64" "20.0.12" - "@nx/nx-linux-arm-gnueabihf" "20.0.12" - "@nx/nx-linux-arm64-gnu" "20.0.12" - "@nx/nx-linux-arm64-musl" "20.0.12" - "@nx/nx-linux-x64-gnu" "20.0.12" - "@nx/nx-linux-x64-musl" "20.0.12" - "@nx/nx-win32-arm64-msvc" "20.0.12" - "@nx/nx-win32-x64-msvc" "20.0.12" + "@nx/nx-darwin-arm64" "20.1.2" + "@nx/nx-darwin-x64" "20.1.2" + "@nx/nx-freebsd-x64" "20.1.2" + "@nx/nx-linux-arm-gnueabihf" "20.1.2" + "@nx/nx-linux-arm64-gnu" "20.1.2" + "@nx/nx-linux-arm64-musl" "20.1.2" + "@nx/nx-linux-x64-gnu" "20.1.2" + "@nx/nx-linux-x64-musl" "20.1.2" + "@nx/nx-win32-arm64-msvc" "20.1.2" + "@nx/nx-win32-x64-msvc" "20.1.2" oauth-sign@~0.9.0: version "0.9.0" @@ -19492,6 +19621,11 @@ object-inspect@^1.13.1: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== +object-inspect@^1.13.3: + version "1.13.3" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.3.tgz#f14c183de51130243d6d18ae149375ff50ea488a" + integrity sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA== + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz" @@ -22026,6 +22160,16 @@ regexp.prototype.flags@^1.5.0, regexp.prototype.flags@^1.5.2: es-errors "^1.3.0" set-function-name "^2.0.1" +regexp.prototype.flags@^1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz#b3ae40b1d2499b8350ab2c3fe6ef3845d3a96f42" + integrity sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-errors "^1.3.0" + set-function-name "^2.0.2" + regexpu-core@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.0.1.tgz" @@ -22811,7 +22955,7 @@ set-function-length@^1.2.1: gopd "^1.0.1" has-property-descriptors "^1.0.2" -set-function-name@^2.0.0, set-function-name@^2.0.1: +set-function-name@^2.0.0, set-function-name@^2.0.1, set-function-name@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== @@ -23470,6 +23614,15 @@ string-width@^7.2.0: get-east-asian-width "^1.0.0" strip-ansi "^7.1.0" +string.prototype.includes@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz#eceef21283640761a81dbe16d6c7171a4edf7d92" + integrity sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.3" + string.prototype.matchall@^4.0.10: version "4.0.10" resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz#a1553eb532221d4180c51581d6072cd65d1ee100" From e1766e5da7f90238d9896c3f3d1ec36304c09acc Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sat, 16 Nov 2024 09:43:15 -0700 Subject: [PATCH 37/38] Cleanup type names Rename fetchFn type to FetchFn --- apps/api/src/app/routes/auth.routes.ts | 2 +- libs/api-config/src/lib/env-config.ts | 1 + libs/salesforce-api/src/lib/callout-adapter.ts | 6 +++--- libs/salesforce-api/src/lib/types.ts | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/api/src/app/routes/auth.routes.ts b/apps/api/src/app/routes/auth.routes.ts index 8f663d1b5..8275d5728 100644 --- a/apps/api/src/app/routes/auth.routes.ts +++ b/apps/api/src/app/routes/auth.routes.ts @@ -9,7 +9,7 @@ import { verifyCaptcha } from './route.middleware'; */ function getMaxRequests(value: number) { - return ENV.CI ? 10000 : value; + return ENV.CI || ENV.ENVIRONMENT === 'development' ? 10000 : value; } export const LAX_AuthRateLimit = createRateLimit('auth_lax', { diff --git a/libs/api-config/src/lib/env-config.ts b/libs/api-config/src/lib/env-config.ts index ecaccf2b3..4d7de9d23 100644 --- a/libs/api-config/src/lib/env-config.ts +++ b/libs/api-config/src/lib/env-config.ts @@ -98,6 +98,7 @@ const envSchema = z.object({ .optional() .transform((value) => value ?? 'production'), PORT: numberSchema.default(3333), + // Set based on environment and server url protocol USE_SECURE_COOKIES: booleanSchema, CAPTCHA_SECRET_KEY: z.string().optional(), CAPTCHA_PROPERTY: z.literal('captchaToken').optional().default('captchaToken'), diff --git a/libs/salesforce-api/src/lib/callout-adapter.ts b/libs/salesforce-api/src/lib/callout-adapter.ts index c8849b9cd..59df2558c 100644 --- a/libs/salesforce-api/src/lib/callout-adapter.ts +++ b/libs/salesforce-api/src/lib/callout-adapter.ts @@ -1,7 +1,7 @@ import { ERROR_MESSAGES, HTTP } from '@jetstream/shared/constants'; import isObject from 'lodash/isObject'; import { convert as xmlConverter } from 'xmlbuilder2'; -import { ApiRequestOptions, ApiRequestOutputType, BulkXmlErrorResponse, FetchResponse, Logger, SoapErrorResponse, fetchFn } from './types'; +import { ApiRequestOptions, ApiRequestOutputType, BulkXmlErrorResponse, FetchFn, FetchResponse, Logger, SoapErrorResponse } from './types'; const SOAP_API_AUTH_ERROR_REGEX = /[a-zA-Z]+:INVALID_SESSION_ID<\/faultcode>/; // Shows up for certain API requests, such as Identity @@ -30,7 +30,7 @@ export class ApiRequestError extends Error { * Factory function to get api request * Requires a fetch compatible function to avoid relying any specific fetch implementation */ -export function getApiRequestFactoryFn(fetch: fetchFn) { +export function getApiRequestFactoryFn(fetch: FetchFn) { return (onRefresh?: (accessToken: string) => void, enableLogging?: boolean, logger: Logger = console) => { const apiRequest = async (options: ApiRequestOptions, attemptRefresh = true): Promise => { let { url, body, outputType } = options; @@ -165,7 +165,7 @@ function handleSalesforceApiError(outputType: ApiRequestOutputType, responseText return output; } -function exchangeRefreshToken(fetch: fetchFn, sessionInfo: ApiRequestOptions['sessionInfo']): Promise<{ access_token: string }> { +function exchangeRefreshToken(fetch: FetchFn, sessionInfo: ApiRequestOptions['sessionInfo']): Promise<{ access_token: string }> { return fetch(`${sessionInfo.instanceUrl}/services/oauth2/token`, { method: 'POST', body: new URLSearchParams({ diff --git a/libs/salesforce-api/src/lib/types.ts b/libs/salesforce-api/src/lib/types.ts index 443c8a184..cd7fda019 100644 --- a/libs/salesforce-api/src/lib/types.ts +++ b/libs/salesforce-api/src/lib/types.ts @@ -63,7 +63,7 @@ export interface FetchResponse { body?: ReadableStream | null; } -export type fetchFn = (url: string, options: FetchOptions) => Promise; +export type FetchFn = (url: string, options: FetchOptions) => Promise; export interface ApiRequestOptions { method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; From fc44a6a8cc25c8b2b2c69e4a5a3e401655363da4 Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Sat, 16 Nov 2024 09:53:15 -0700 Subject: [PATCH 38/38] Update CSP to include inline scripts Don't set amplitude version if we do not have a version initialized --- apps/api/src/main.ts | 2 ++ apps/jetstream/index.html | 2 ++ libs/shared/ui-core/src/analytics.tsx | 4 +++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 1566d0afc..c96a4bbfe 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -157,6 +157,8 @@ if (ENV.NODE_ENV === 'production' && !ENV.CI && cluster.isPrimary) { "'self'", "'sha256-AS526U4qXJy7/SohgsysWUxi77DtcgSmP0hNfTo6/Hs='", // Google Analytics (Docs) "'sha256-pOkCIUf8FXwCoKWPXTEJAC2XGbyg3ftSrE+IES4aqEY='", // Google Analytics (Next/React) + "'sha256-7mNBpJaHD4L73RpSf1pEaFD17uW3H/9+P1AYhm+j/Dg='", // Monaco unhandledrejection script + "'sha256-djX4iruGclmwOFqyJyEvkkFU0dkSDNqkDpKOJMUO70E='", // __IS_CHROME_EXTENSION__ script 'blob:', '*.google.com', '*.gstatic.com', diff --git a/apps/jetstream/index.html b/apps/jetstream/index.html index 852bdd7df..ff7fa91a5 100644 --- a/apps/jetstream/index.html +++ b/apps/jetstream/index.html @@ -35,6 +35,7 @@ + + diff --git a/libs/shared/ui-core/src/analytics.tsx b/libs/shared/ui-core/src/analytics.tsx index 9ed790f8c..eb6039df7 100644 --- a/libs/shared/ui-core/src/analytics.tsx +++ b/libs/shared/ui-core/src/analytics.tsx @@ -31,7 +31,9 @@ function init(appCookie: ApplicationCookie, version: string) { forceHttps: false, }; amplitude.getInstance().init(amplitudeToken, undefined, config); - amplitude.getInstance().setVersionName(version); + if (version) { + amplitude.getInstance().setVersionName(version); + } } export function useAmplitude(optOut?: boolean) {