From 065cb4b03aa8d2f9ef5917c7314afd74eea7dbba Mon Sep 17 00:00:00 2001 From: Kiwi Date: Thu, 20 Dec 2018 22:32:04 +0100 Subject: [PATCH] [next] Auth Popup v2 (#2101) * feat: Implement new Sign In view * feat: Move forgot + resetPassword to new design * feat: Implement sign up with new design * fix: narrow gutter * test: add unit tests * test: integration tests * feat: support show / hide password * feat: support oauth2 flow * feat: add views for user completion * feat: implement oauth2 sign up * test: fix snapshots * fix: lint * fix: get more complete mutation response * fix: removed array of OIDC integrations * fix: renamed resolver function * fix: adapt oidc client implementation * fix: targetFilter should be stream on signup * fix: removed unneeded message * fix: moved password into local profile * fix: made username optional, removed valid null value * fix: linting * fix: respect targetFilter * feat: support user registration mutations - Added `setUsername` - Added `setEmail` - Added `setPassword` - Added `permit` to `@auth` - Added `email` to `User` * fix: fixed issue with query * feat: added user password update * feat: complete sign in mutation * fix: adapt some rebasing gitches * test: improve tests * test: unittest for setting auth token * fix: failing tests * test: move most tests from enzyme to react-test-renderer * fix: remove schema warnings in tests * test: improve window mock * test: test different social login configurations * test: test social logins for sign up * fix: use htmlFor instead of for * test: more feature tests * feat: always go through account completion * test: feature test account completion * feat: addtional account completion test * Update start.ts * chore: refactor auth token retrieval logic --- package.json | 2 +- scripts/start.ts | 66 +- src/core/build/config.ts | 7 + src/core/client/admin/components/App.spec.tsx | 8 +- .../components/DecisionHistoryButton.tsx | 4 +- .../admin/components/MainLayout.spec.tsx | 8 +- .../admin/components/Navigation.spec.tsx | 7 +- .../admin/components/NavigationLink.spec.tsx | 7 +- .../admin/components/SignOutButton.spec.tsx | 7 +- .../CreateOIDCAuthIntegrationMutation.ts | 56 - .../UpdateOIDCAuthIntegrationMutation.ts | 56 - .../admin/mutations/UpdateSettingsMutation.ts | 4 +- src/core/client/admin/mutations/index.ts | 8 - .../community/components/Community.spec.tsx | 7 +- .../configure/components/ConfigBox.spec.tsx | 7 +- .../configure/components/Configure.spec.tsx | 7 +- .../routes/configure/components/Configure.tsx | 6 +- .../configure/components/Header.spec.tsx | 7 +- .../components/HorizontalRule.spec.tsx | 7 +- .../configure/components/Layout.spec.tsx | 7 +- .../routes/configure/components/Main.spec.tsx | 7 +- .../configure/components/Moderation.spec.tsx | 7 +- .../configure/components/Navigation/Link.css | 2 +- .../components/Navigation/Link.spec.tsx | 7 +- .../components/Navigation/Navigation.spec.tsx | 7 +- .../configure/components/SideBar.spec.tsx | 7 +- .../__snapshots__/Configure.spec.tsx.snap | 4 +- .../containers/ConfigureContainer.tsx | 4 +- .../components/AuthIntegrationsConfig.tsx | 8 +- .../auth/components/FacebookConfig.tsx | 2 +- .../sections/auth/components/OIDCConfig.css | 4 - .../sections/auth/components/OIDCConfig.tsx | 48 +- .../sections/auth/components/SSOKeyField.tsx | 2 +- .../auth/containers/AuthContainer.tsx | 6 +- .../auth/containers/OIDCConfigContainer.tsx | 62 +- .../containers/OIDCConfigListContainer.tsx | 154 - .../routes/login/components/Login.spec.tsx | 7 +- .../routes/login/components/SignInForm.tsx | 5 +- .../moderate/components/AcceptButton.spec.tsx | 12 +- .../components/CommentContent.spec.tsx | 12 +- .../moderate/components/InReplyTo.spec.tsx | 7 +- .../moderate/components/LoadingQueue.tsx | 2 +- .../moderate/components/Moderate.spec.tsx | 12 +- .../routes/moderate/components/Moderate.tsx | 6 +- .../moderate/components/ModerateCard.spec.tsx | 27 +- .../moderate/components/ModerateCard.tsx | 2 +- .../moderate/components/Navigation.spec.tsx | 12 +- .../routes/moderate/components/Navigation.tsx | 6 +- .../components/NavigationLink.spec.tsx | 7 +- .../routes/moderate/components/Queue.spec.tsx | 12 +- .../moderate/components/RejectButton.spec.tsx | 12 +- .../components/SingleModerate.spec.tsx | 7 +- .../moderate/components/SingleModerate.tsx | 2 +- .../moderate/components/Timestamp.spec.tsx | 7 +- .../__snapshots__/Moderate.spec.tsx.snap | 12 +- .../__snapshots__/ModerateCard.spec.tsx.snap | 10 +- .../__snapshots__/Navigation.spec.tsx.snap | 6 +- .../SingleModerate.spec.tsx.snap | 2 +- .../containers/MarkersContainer.spec.tsx | 12 +- .../MarkersContainer.spec.tsx.snap | 22 +- .../stories/components/Stories.spec.tsx | 7 +- .../test/__snapshots__/login.spec.tsx.snap | 360 +- .../__snapshots__/auth.spec.tsx.snap | 260 +- .../client/admin/test/configure/auth.spec.tsx | 169 +- .../decisionHistory.spec.tsx.snap | 10 +- .../decisionHistory/decisionHistory.spec.tsx | 2 +- src/core/client/admin/test/fixtures.ts | 10 +- src/core/client/admin/test/login.spec.tsx | 16 +- .../__snapshots__/moderate.spec.tsx.snap | 46 +- .../admin/test/moderate/moderate.spec.tsx | 4 +- .../components/AcceptedComment.spec.tsx | 7 +- .../components/AcceptedIcon.spec.tsx | 7 +- .../components/DecisionHistory.spec.tsx | 22 +- .../components/DecisionHistory.tsx | 2 +- .../DecisionHistoryLoading.spec.tsx | 7 +- .../components/DecisionHistoryLoading.tsx | 2 +- .../components/DecisionItem.spec.tsx | 7 +- .../components/DecisionList.spec.tsx | 7 +- .../components/DotDivider.spec.tsx | 7 +- .../decisionHistory/components/Empty.spec.tsx | 7 +- .../components/Footer.spec.tsx | 7 +- .../components/GoToCommentLink.spec.tsx | 7 +- .../decisionHistory/components/Info.spec.tsx | 7 +- .../decisionHistory/components/Main.spec.tsx | 7 +- .../components/RejectedComment.spec.tsx | 7 +- .../components/RejectedIcon.spec.tsx | 7 +- .../components/ShowMoreButton.spec.tsx | 7 +- .../components/Timestamp.spec.tsx | 7 +- .../decisionHistory/components/Title.spec.tsx | 7 +- .../DecisionHistory.spec.tsx.snap | 17 +- .../DecisionHistoryLoading.spec.tsx.snap | 2 +- src/core/client/auth/components/App.css | 13 - src/core/client/auth/components/App.spec.tsx | 13 +- src/core/client/auth/components/App.tsx | 35 +- .../auth/components/ConfirmEmailField.tsx | 61 + .../auth/components/ConfirmPasswordField.tsx | 63 + .../client/auth/components/EmailField.tsx | 58 + .../client/auth/components/FacebookButton.css | 16 + .../auth/components/FacebookButton.spec.tsx | 17 + .../client/auth/components/FacebookButton.tsx | 42 + .../client/auth/components/ForgotPassword.tsx | 110 - .../client/auth/components/GoogleButton.css | 16 + .../auth/components/GoogleButton.spec.tsx | 17 + .../client/auth/components/GoogleButton.tsx | 42 + .../client/auth/components/Header/Bar.css | 4 + .../auth/components/Header/Bar.spec.tsx | 15 + .../client/auth/components/Header/Bar.tsx | 18 + .../client/auth/components/Header/SubBar.css | 4 + .../auth/components/Header/SubBar.spec.tsx | 15 + .../client/auth/components/Header/SubBar.tsx | 18 + .../auth/components/Header/Subtitle.css | 3 + .../auth/components/Header/Subtitle.spec.tsx | 15 + .../auth/components/Header/Subtitle.tsx | 17 + .../client/auth/components/Header/Title.css | 3 + .../auth/components/Header/Title.spec.tsx | 15 + .../client/auth/components/Header/Title.tsx | 17 + .../Header/__snapshots__/Bar.spec.tsx.snap | 13 + .../Header/__snapshots__/SubBar.spec.tsx.snap | 13 + .../__snapshots__/Subtitle.spec.tsx.snap | 11 + .../Header/__snapshots__/Title.spec.tsx.snap | 11 + .../client/auth/components/Header/index.ts | 4 + src/core/client/auth/components/Main.css | 5 + src/core/client/auth/components/Main.spec.tsx | 15 + src/core/client/auth/components/Main.tsx | 21 + .../client/auth/components/OIDCButton.css | 16 + .../auth/components/OIDCButton.spec.tsx | 17 + .../client/auth/components/OIDCButton.tsx | 26 + .../client/auth/components/OrSeparator.css | 16 + .../auth/components/OrSeparator.spec.tsx | 10 + .../client/auth/components/OrSeparator.tsx | 19 + .../client/auth/components/ResetPassword.tsx | 154 - .../auth/components/SetPasswordField.tsx | 64 + src/core/client/auth/components/SignIn.tsx | 179 - src/core/client/auth/components/SignUp.tsx | 253 - .../client/auth/components/UsernameField.tsx | 62 + .../__snapshots__/App.spec.tsx.snap | 8 +- .../FacebookButton.spec.tsx.snap | 35 + .../__snapshots__/GoogleButton.spec.tsx.snap | 35 + .../__snapshots__/Main.spec.tsx.snap | 9 + .../__snapshots__/OIDCButton.spec.tsx.snap | 23 + .../__snapshots__/OrSeparator.spec.tsx.snap | 22 + .../containers/AccountCompletionContainer.tsx | 122 + .../client/auth/containers/AppContainer.tsx | 49 +- .../auth/containers/SignUpContainer.tsx | 33 - src/core/client/auth/helpers/index.ts | 1 + .../client/auth/helpers/redirectOAuth2.ts | 4 + src/core/client/auth/index.tsx | 4 +- .../__snapshots__/initLocalState.spec.ts.snap | 40 + .../client/auth/local/initLocalState.spec.ts | 22 +- src/core/client/auth/local/initLocalState.ts | 35 +- src/core/client/auth/local/local.graphql | 3 + .../auth/mutations/CompleteAccountMutation.ts | 25 + .../client/auth/mutations/SetEmailMutation.ts | 46 + .../auth/mutations/SetPasswordMutation.ts | 51 + .../auth/mutations/SetUsernameMutation.ts | 49 + .../client/auth/mutations/SetViewMutation.ts | 9 +- .../client/auth/mutations/SignInMutation.ts | 16 +- .../client/auth/mutations/SignUpMutation.ts | 21 +- src/core/client/auth/mutations/index.ts | 13 + src/core/client/auth/queries/AppQuery.tsx | 37 + .../addEmailAddress.spec.tsx.snap | 1066 ++++ .../createPassword.spec.tsx.snap | 446 ++ .../createUsername.spec.tsx.snap | 385 ++ .../__snapshots__/navigation.spec.tsx.snap | 422 -- .../test/__snapshots__/signIn.spec.tsx.snap | 1991 +++---- .../test/__snapshots__/signUp.spec.tsx.snap | 4572 ++++++++--------- .../auth/test/accountCompletion.spec.tsx | 142 + .../client/auth/test/addEmailAddress.spec.tsx | 195 + src/core/client/auth/test/create.tsx | 6 +- .../client/auth/test/createEnvironment.ts | 2 + .../client/auth/test/createPassword.spec.tsx | 147 + .../client/auth/test/createUsername.spec.tsx | 154 + src/core/client/auth/test/fixtures.tsx | 51 + src/core/client/auth/test/mockWindow.ts | 18 + src/core/client/auth/test/navigation.spec.tsx | 70 +- src/core/client/auth/test/signIn.spec.tsx | 289 +- src/core/client/auth/test/signUp.spec.tsx | 405 +- .../components/AddEmailAddress.tsx | 103 + .../components/UnorderedList/ListItem.css | 10 + .../components/UnorderedList/ListItem.tsx | 23 + .../UnorderedList/UnorderedList.css | 11 + .../UnorderedList/UnorderedList.spec.tsx | 16 + .../UnorderedList/UnorderedList.tsx | 10 + .../__snapshots__/UnorderedList.spec.tsx.snap | 26 + .../components/UnorderedList/index.ts | 2 + .../containers/AddEmailAddressContainer.tsx | 35 + .../components/CreatePassword.tsx | 73 + .../containers/CreatePasswordContainer.tsx | 36 + .../components/CreateUsername.tsx | 73 + .../containers/CreateUsernameContainer.tsx | 36 + .../components/ForgotPassword.tsx | 114 + .../containers/ForgotPasswordContainer.tsx | 0 .../components/ResetPassword.tsx | 63 + .../containers/ResetPasswordContainer.tsx | 0 .../views/signIn/components/SignIn.spec.tsx | 38 + .../auth/views/signIn/components/SignIn.tsx | 84 + .../signIn/components/SignInWithEmail.tsx | 113 + .../__snapshots__/SignIn.spec.tsx.snap | 117 + .../signIn/containers/SignInContainer.tsx | 85 + .../containers/SignInWithEmailContainer.tsx} | 16 +- .../SignInWithFacebookContainer.tsx | 41 + .../containers/SignInWithGoogleContainer.tsx | 41 + .../containers/SignInWithOIDCContainer.tsx | 43 + .../views/signUp/components/SignUp.spec.tsx | 38 + .../auth/views/signUp/components/SignUp.tsx | 84 + .../signUp/components/SignUpWithEmail.tsx | 65 + .../__snapshots__/SignUp.spec.tsx.snap | 117 + .../signUp/containers/SignUpContainer.tsx | 88 + .../containers/SignUpWithEmailContainer.tsx | 35 + .../SignUpWithFacebookContainer.tsx | 41 + .../containers/SignUpWithGoogleContainer.tsx | 41 + .../containers/SignUpWithOIDCContainer.tsx | 43 + .../embed/decorators/withPymStorage.spec.ts | 7 + .../{CopyButton => }/CopyButton.tsx | 4 +- .../framework/components/CopyButton/index.ts | 1 - .../framework/components/PasswordField.tsx | 22 + src/core/client/framework/components/index.ts | 1 + src/core/client/framework/lib/messages.tsx | 6 + .../lib/relay/wrapFetchWithLogger.ts | 10 +- src/core/client/framework/lib/validation.tsx | 13 +- .../mutations/SetAuthTokenMutation.spec.ts | 17 +- .../framework/testHelpers/byLabelText.ts | 89 +- .../client/framework/testHelpers/byTestID.ts | 34 +- .../client/framework/testHelpers/byText.ts | 30 +- .../client/framework/testHelpers/byType.ts | 56 + .../framework/testHelpers/createAuthToken.ts | 12 + .../testHelpers/createRelayEnvironment.ts | 10 +- .../client/framework/testHelpers/index.ts | 16 +- .../framework/testHelpers/limitSnapshotTo.ts | 2 +- .../client/framework/testHelpers/within.ts | 10 + .../client/install/components/App.spec.tsx | 7 +- .../client/stream/components/App.spec.tsx | 7 +- .../stream/components/Timestamp.spec.tsx | 7 +- .../components/UserBoxAuthenticated.spec.tsx | 12 +- .../UserBoxUnauthenticated.spec.tsx | 12 +- .../containers/UserBoxContainer.spec.tsx | 114 +- .../stream/containers/UserBoxContainer.tsx | 45 +- .../UserBoxContainer.spec.tsx.snap | 22 +- src/core/client/stream/index.tsx | 2 - .../client/stream/listeners/OnEvents.spec.tsx | 6 +- .../listeners/OnPostMessageAuthError.tsx | 28 - .../OnPostMessageSetAuthToken.spec.tsx | 4 +- .../stream/listeners/OnPymLogin.spec.tsx | 4 +- .../stream/listeners/OnPymLogout.spec.tsx | 4 +- .../listeners/OnPymSetCommentID.spec.tsx | 6 +- src/core/client/stream/listeners/index.ts | 1 - .../stream/local/initLocalState.spec.ts | 13 +- .../components/Comment/ButtonsBar.spec.tsx | 7 +- .../components/Comment/Comment.spec.tsx | 7 +- .../components/Comment/EditedMarker.spec.tsx | 7 +- .../components/Comment/InReplyTo.spec.tsx | 7 +- .../Comment/IndentedComment.spec.tsx | 7 +- .../components/Comment/ReplyButton.spec.tsx | 7 +- .../Comment/ShowConversationLink.spec.tsx | 7 +- .../comments/components/CommentsPane.spec.tsx | 12 +- .../components/ConversationThread.spec.tsx | 17 +- .../tabs/comments/components/Indent.spec.tsx | 17 +- .../components/PermalinkView.spec.tsx | 12 +- .../components/PostCommentFormFake.spec.tsx | 7 +- .../comments/components/PoweredBy.spec.tsx | 7 +- .../tabs/comments/components/RTE.spec.tsx | 7 +- .../tabs/comments/components/ReplyTo.spec.tsx | 7 +- .../components/Timeline/Circle.spec.tsx | 7 +- .../components/Timeline/Line.spec.tsx | 7 +- .../containers/CommentContainer.spec.tsx | 27 +- .../ReplyCommentFormContainer.spec.tsx | 13 +- .../queries/PermalinkViewQuery.spec.tsx | 17 +- .../comments/queries/StreamQuery.spec.tsx | 17 +- .../components/CommentHistory.spec.tsx | 17 +- .../components/HistoryComment.spec.tsx | 7 +- .../tabs/profile/components/Profile.spec.tsx | 7 +- .../CommentHistory.spec.tsx.snap | 6 - src/core/client/stream/test/fixtures.ts | 20 +- .../ui/components/AppBar/AppBar.spec.tsx | 7 +- .../ui/components/AppBar/Begin.spec.tsx | 7 +- .../ui/components/AppBar/Divider.spec.tsx | 7 +- .../client/ui/components/AppBar/End.spec.tsx | 7 +- .../ui/components/AppBar/Navigation.spec.tsx | 7 +- .../ui/components/AppBar/NavigationItem.css | 2 +- .../components/AppBar/NavigationItem.spec.tsx | 7 +- .../ui/components/Brand/BrandIcon.spec.tsx | 7 +- .../client/ui/components/Brand/BrandName.css | 2 +- .../ui/components/Brand/BrandName.spec.tsx | 7 +- .../client/ui/components/Brand/Logo.spec.tsx | 7 +- .../client/ui/components/Button/Button.css | 67 +- .../client/ui/components/Button/Button.tsx | 3 +- .../ui/components/Counter/Counter.spec.tsx | 7 +- .../ui/components/InputLabel/InputLabel.tsx | 2 + .../components/MatchMedia/MatchMedia.spec.tsx | 18 +- .../PasswordField/PasswordField.css | 60 + .../PasswordField/PasswordField.mdx | 25 + .../PasswordField/PasswordField.spec.tsx | 18 + .../PasswordField/PasswordField.tsx | 148 + .../__snapshots__/PasswordField.spec.tsx.snap | 63 + .../ui/components/PasswordField/index.ts | 2 + .../ui/components/SubBar/Navigation.spec.tsx | 7 +- .../components/SubBar/NavigationItem.spec.tsx | 7 +- .../ui/components/SubBar/SubBar.spec.tsx | 7 +- src/core/client/ui/components/index.ts | 1 + src/core/client/ui/theme/variables.ts | 6 +- src/core/common/graphql/loadSchema.ts | 14 +- .../app/handlers/api/tenant/auth/local.ts | 2 +- .../server/app/handlers/api/tenant/install.ts | 6 +- .../server/app/middleware/passport/index.ts | 84 + .../passport/strategies/facebook.ts | 1 - .../middleware/passport/strategies/google.ts | 1 - .../middleware/passport/strategies/oauth2.ts | 4 +- .../passport/strategies/oidc/index.ts | 83 +- .../passport/strategies/verifiers/sso.ts | 2 +- src/core/server/app/router/api/auth.ts | 13 +- src/core/server/app/url.ts | 2 +- src/core/server/config.ts | 7 + .../server/graph/common/directives/auth.ts | 52 +- .../server/graph/tenant/mutators/Settings.ts | 34 +- src/core/server/graph/tenant/mutators/User.ts | 33 + .../server/graph/tenant/mutators/index.ts | 2 + .../resolvers/FacebookAuthIntegration.ts | 21 +- .../tenant/resolvers/GoogleAuthIntegration.ts | 19 +- .../server/graph/tenant/resolvers/Mutation.ts | 52 +- .../tenant/resolvers/OIDCAuthIntegration.ts | 19 +- .../server/graph/tenant/resolvers/Profile.ts | 4 + .../server/graph/tenant/resolvers/util.ts | 19 + .../server/graph/tenant/schema/schema.graphql | 586 +-- src/core/server/models/settings.ts | 91 +- src/core/server/models/tenant.ts | 175 +- src/core/server/models/user.ts | 346 +- src/core/server/services/tenant/index.ts | 88 +- src/core/server/services/users/index.ts | 81 +- src/locales/en-US/admin.ftl | 2 - src/locales/en-US/auth.ftl | 99 +- src/locales/en-US/framework.ftl | 4 + 331 files changed, 12476 insertions(+), 7163 deletions(-) delete mode 100644 src/core/client/admin/mutations/CreateOIDCAuthIntegrationMutation.ts delete mode 100644 src/core/client/admin/mutations/UpdateOIDCAuthIntegrationMutation.ts delete mode 100644 src/core/client/admin/routes/configure/sections/auth/components/OIDCConfig.css delete mode 100644 src/core/client/admin/routes/configure/sections/auth/containers/OIDCConfigListContainer.tsx create mode 100644 src/core/client/auth/components/ConfirmEmailField.tsx create mode 100644 src/core/client/auth/components/ConfirmPasswordField.tsx create mode 100644 src/core/client/auth/components/EmailField.tsx create mode 100644 src/core/client/auth/components/FacebookButton.css create mode 100644 src/core/client/auth/components/FacebookButton.spec.tsx create mode 100644 src/core/client/auth/components/FacebookButton.tsx delete mode 100644 src/core/client/auth/components/ForgotPassword.tsx create mode 100644 src/core/client/auth/components/GoogleButton.css create mode 100644 src/core/client/auth/components/GoogleButton.spec.tsx create mode 100644 src/core/client/auth/components/GoogleButton.tsx create mode 100644 src/core/client/auth/components/Header/Bar.css create mode 100644 src/core/client/auth/components/Header/Bar.spec.tsx create mode 100644 src/core/client/auth/components/Header/Bar.tsx create mode 100644 src/core/client/auth/components/Header/SubBar.css create mode 100644 src/core/client/auth/components/Header/SubBar.spec.tsx create mode 100644 src/core/client/auth/components/Header/SubBar.tsx create mode 100644 src/core/client/auth/components/Header/Subtitle.css create mode 100644 src/core/client/auth/components/Header/Subtitle.spec.tsx create mode 100644 src/core/client/auth/components/Header/Subtitle.tsx create mode 100644 src/core/client/auth/components/Header/Title.css create mode 100644 src/core/client/auth/components/Header/Title.spec.tsx create mode 100644 src/core/client/auth/components/Header/Title.tsx create mode 100644 src/core/client/auth/components/Header/__snapshots__/Bar.spec.tsx.snap create mode 100644 src/core/client/auth/components/Header/__snapshots__/SubBar.spec.tsx.snap create mode 100644 src/core/client/auth/components/Header/__snapshots__/Subtitle.spec.tsx.snap create mode 100644 src/core/client/auth/components/Header/__snapshots__/Title.spec.tsx.snap create mode 100644 src/core/client/auth/components/Header/index.ts create mode 100644 src/core/client/auth/components/Main.css create mode 100644 src/core/client/auth/components/Main.spec.tsx create mode 100644 src/core/client/auth/components/Main.tsx create mode 100644 src/core/client/auth/components/OIDCButton.css create mode 100644 src/core/client/auth/components/OIDCButton.spec.tsx create mode 100644 src/core/client/auth/components/OIDCButton.tsx create mode 100644 src/core/client/auth/components/OrSeparator.css create mode 100644 src/core/client/auth/components/OrSeparator.spec.tsx create mode 100644 src/core/client/auth/components/OrSeparator.tsx delete mode 100644 src/core/client/auth/components/ResetPassword.tsx create mode 100644 src/core/client/auth/components/SetPasswordField.tsx delete mode 100644 src/core/client/auth/components/SignIn.tsx delete mode 100644 src/core/client/auth/components/SignUp.tsx create mode 100644 src/core/client/auth/components/UsernameField.tsx create mode 100644 src/core/client/auth/components/__snapshots__/FacebookButton.spec.tsx.snap create mode 100644 src/core/client/auth/components/__snapshots__/GoogleButton.spec.tsx.snap create mode 100644 src/core/client/auth/components/__snapshots__/Main.spec.tsx.snap create mode 100644 src/core/client/auth/components/__snapshots__/OIDCButton.spec.tsx.snap create mode 100644 src/core/client/auth/components/__snapshots__/OrSeparator.spec.tsx.snap create mode 100644 src/core/client/auth/containers/AccountCompletionContainer.tsx delete mode 100644 src/core/client/auth/containers/SignUpContainer.tsx create mode 100644 src/core/client/auth/helpers/index.ts create mode 100644 src/core/client/auth/helpers/redirectOAuth2.ts create mode 100644 src/core/client/auth/mutations/CompleteAccountMutation.ts create mode 100644 src/core/client/auth/mutations/SetEmailMutation.ts create mode 100644 src/core/client/auth/mutations/SetPasswordMutation.ts create mode 100644 src/core/client/auth/mutations/SetUsernameMutation.ts create mode 100644 src/core/client/auth/queries/AppQuery.tsx create mode 100644 src/core/client/auth/test/__snapshots__/addEmailAddress.spec.tsx.snap create mode 100644 src/core/client/auth/test/__snapshots__/createPassword.spec.tsx.snap create mode 100644 src/core/client/auth/test/__snapshots__/createUsername.spec.tsx.snap delete mode 100644 src/core/client/auth/test/__snapshots__/navigation.spec.tsx.snap create mode 100644 src/core/client/auth/test/accountCompletion.spec.tsx create mode 100644 src/core/client/auth/test/addEmailAddress.spec.tsx create mode 100644 src/core/client/auth/test/createPassword.spec.tsx create mode 100644 src/core/client/auth/test/createUsername.spec.tsx create mode 100644 src/core/client/auth/test/fixtures.tsx create mode 100644 src/core/client/auth/test/mockWindow.ts create mode 100644 src/core/client/auth/views/addEmailAddress/components/AddEmailAddress.tsx create mode 100644 src/core/client/auth/views/addEmailAddress/components/UnorderedList/ListItem.css create mode 100644 src/core/client/auth/views/addEmailAddress/components/UnorderedList/ListItem.tsx create mode 100644 src/core/client/auth/views/addEmailAddress/components/UnorderedList/UnorderedList.css create mode 100644 src/core/client/auth/views/addEmailAddress/components/UnorderedList/UnorderedList.spec.tsx create mode 100644 src/core/client/auth/views/addEmailAddress/components/UnorderedList/UnorderedList.tsx create mode 100644 src/core/client/auth/views/addEmailAddress/components/UnorderedList/__snapshots__/UnorderedList.spec.tsx.snap create mode 100644 src/core/client/auth/views/addEmailAddress/components/UnorderedList/index.ts create mode 100644 src/core/client/auth/views/addEmailAddress/containers/AddEmailAddressContainer.tsx create mode 100644 src/core/client/auth/views/createPassword/components/CreatePassword.tsx create mode 100644 src/core/client/auth/views/createPassword/containers/CreatePasswordContainer.tsx create mode 100644 src/core/client/auth/views/createUsername/components/CreateUsername.tsx create mode 100644 src/core/client/auth/views/createUsername/containers/CreateUsernameContainer.tsx create mode 100644 src/core/client/auth/views/forgotPassword/components/ForgotPassword.tsx rename src/core/client/auth/{ => views/forgotPassword}/containers/ForgotPasswordContainer.tsx (100%) create mode 100644 src/core/client/auth/views/resetPassword/components/ResetPassword.tsx rename src/core/client/auth/{ => views/resetPassword}/containers/ResetPasswordContainer.tsx (100%) create mode 100644 src/core/client/auth/views/signIn/components/SignIn.spec.tsx create mode 100644 src/core/client/auth/views/signIn/components/SignIn.tsx create mode 100644 src/core/client/auth/views/signIn/components/SignInWithEmail.tsx create mode 100644 src/core/client/auth/views/signIn/components/__snapshots__/SignIn.spec.tsx.snap create mode 100644 src/core/client/auth/views/signIn/containers/SignInContainer.tsx rename src/core/client/auth/{containers/SignInContainer.tsx => views/signIn/containers/SignInWithEmailContainer.tsx} (71%) create mode 100644 src/core/client/auth/views/signIn/containers/SignInWithFacebookContainer.tsx create mode 100644 src/core/client/auth/views/signIn/containers/SignInWithGoogleContainer.tsx create mode 100644 src/core/client/auth/views/signIn/containers/SignInWithOIDCContainer.tsx create mode 100644 src/core/client/auth/views/signUp/components/SignUp.spec.tsx create mode 100644 src/core/client/auth/views/signUp/components/SignUp.tsx create mode 100644 src/core/client/auth/views/signUp/components/SignUpWithEmail.tsx create mode 100644 src/core/client/auth/views/signUp/components/__snapshots__/SignUp.spec.tsx.snap create mode 100644 src/core/client/auth/views/signUp/containers/SignUpContainer.tsx create mode 100644 src/core/client/auth/views/signUp/containers/SignUpWithEmailContainer.tsx create mode 100644 src/core/client/auth/views/signUp/containers/SignUpWithFacebookContainer.tsx create mode 100644 src/core/client/auth/views/signUp/containers/SignUpWithGoogleContainer.tsx create mode 100644 src/core/client/auth/views/signUp/containers/SignUpWithOIDCContainer.tsx rename src/core/client/framework/components/{CopyButton => }/CopyButton.tsx (93%) delete mode 100644 src/core/client/framework/components/CopyButton/index.ts create mode 100644 src/core/client/framework/components/PasswordField.tsx create mode 100644 src/core/client/framework/testHelpers/byType.ts create mode 100644 src/core/client/framework/testHelpers/createAuthToken.ts delete mode 100644 src/core/client/stream/listeners/OnPostMessageAuthError.tsx create mode 100644 src/core/client/ui/components/PasswordField/PasswordField.css create mode 100644 src/core/client/ui/components/PasswordField/PasswordField.mdx create mode 100644 src/core/client/ui/components/PasswordField/PasswordField.spec.tsx create mode 100644 src/core/client/ui/components/PasswordField/PasswordField.tsx create mode 100644 src/core/client/ui/components/PasswordField/__snapshots__/PasswordField.spec.tsx.snap create mode 100644 src/core/client/ui/components/PasswordField/index.ts create mode 100644 src/core/server/graph/tenant/mutators/User.ts diff --git a/package.json b/package.json index 22be86f6b..953b81d76 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "lint:server": "tslint --project ./src/tsconfig.json", "lint:client": "tslint --project ./src/core/client/tsconfig.json", "lint:scripts": "tslint --project ./tsconfig.json", - "lint-fix": "npm run lint:server -- --fix && npm run lint:client -- --fix && npm run lint:client-embed -- --fix && npm run lint:scripts -- --fix", + "lint-fix": "npm run lint:server -- --fix && npm run lint:client -- --fix && npm run lint:scripts -- --fix", "test": "node scripts/test.js --env=jsdom", "tscheck": "npm-run-all --parallel tscheck:*", "tscheck:server": "tsc --project ./src/tsconfig.json --noEmit", diff --git a/scripts/start.ts b/scripts/start.ts index cbcc58aa5..78b1f5fa2 100644 --- a/scripts/start.ts +++ b/scripts/start.ts @@ -7,7 +7,6 @@ dotenv.config(); import chalk from "chalk"; import { - choosePort, createCompiler, prepareUrls, } from "react-dev-utils/WebpackDevServerUtils"; @@ -34,7 +33,7 @@ process.on("unhandledRejection", err => { throw err; }); -const PORT = parseInt(process.env.DEV_SERVER_PORT!, 10) || 8080; +const PORT = config.get("dev_port"); const HOST = "0.0.0.0"; if (process.env.HOST) { @@ -52,45 +51,28 @@ if (process.env.HOST) { console.log(); } -// We attempt to use the default port but if it is busy, we offer the user to -// run on a different port. `choosePort()` Promise resolves to the next free port. -choosePort(HOST, PORT) - .then((port: number) => { - if (port == null) { - // We have not found a port. - return; - } - const protocol = "http"; - const appName = "Talk"; - const urls = prepareUrls(protocol, HOST, port); - const webpackConfig = createWebpackConfig(config); - // Create a webpack compiler that is configured with custom messages. - const compiler = createCompiler(webpack, webpackConfig, appName, urls); - // Serve webpack assets generated by the compiler over a web sever. - const serverConfig = createDevServerConfig({ - allowedHost: urls.lanUrlForConfig, - serverPort: config.get("port"), - publicPath: webpackConfig[0].output!.publicPath!, - }); - const devServer = new WebpackDevServer(compiler, serverConfig); - // Launch WebpackDevServer. - devServer.listen(port, HOST, (err: Error) => { - if (err) { - return console.log(err); - } - console.log(chalk.cyan("Starting the development server...\n")); - }); +const urls = prepareUrls("http", HOST, PORT); +const webpackConfig = createWebpackConfig(config); +// Create a webpack compiler that is configured with custom messages. +const compiler = createCompiler(webpack, webpackConfig, "Talk", urls); +// Serve webpack assets generated by the compiler over a web sever. +const serverConfig = createDevServerConfig({ + allowedHost: urls.lanUrlForConfig, + serverPort: config.get("port"), + publicPath: webpackConfig[0].output!.publicPath!, +}); +const devServer = new WebpackDevServer(compiler, serverConfig); +// Launch WebpackDevServer. +devServer.listen(PORT, HOST, (err: Error) => { + if (err) { + return console.log(err); + } + console.log(chalk.cyan("Starting the development server...\n")); +}); - ["SIGINT", "SIGTERM"].forEach((sig: any) => { - process.once(sig, () => { - devServer.close(); - process.exit(); - }); - }); - }) - .catch((err: Error) => { - if (err.message) { - console.log(err.message); - } - process.exit(1); +["SIGINT", "SIGTERM"].forEach((sig: any) => { + process.once(sig, () => { + devServer.close(); + process.exit(); }); +}); diff --git a/src/core/build/config.ts b/src/core/build/config.ts index 7d7bf5619..b93ec9861 100644 --- a/src/core/build/config.ts +++ b/src/core/build/config.ts @@ -14,6 +14,13 @@ const config = convict({ env: "PORT", arg: "port", }, + dev_port: { + doc: "The port to bind for the Webpack Dev Server.", + format: "port", + default: 8080, + env: "DEV_PORT", + arg: "dev-port", + }, generateReport: { doc: "Generate a report using webpack-bundle-analyzer", format: Boolean, diff --git a/src/core/client/admin/components/App.spec.tsx b/src/core/client/admin/components/App.spec.tsx index 1a1c6f411..5331f9cb8 100644 --- a/src/core/client/admin/components/App.spec.tsx +++ b/src/core/client/admin/components/App.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -9,6 +9,8 @@ it("renders correctly", () => { const props: PropTypesOf = { children: "child", }; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/admin/components/DecisionHistoryButton.tsx b/src/core/client/admin/components/DecisionHistoryButton.tsx index f43611233..bd51cc8db 100644 --- a/src/core/client/admin/components/DecisionHistoryButton.tsx +++ b/src/core/client/admin/components/DecisionHistoryButton.tsx @@ -20,7 +20,7 @@ class DecisionHistoryButton extends React.Component { const popoverID = `decision-history-popover`; return ( history diff --git a/src/core/client/admin/components/MainLayout.spec.tsx b/src/core/client/admin/components/MainLayout.spec.tsx index 0e54f040e..a203a9b5d 100644 --- a/src/core/client/admin/components/MainLayout.spec.tsx +++ b/src/core/client/admin/components/MainLayout.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -9,6 +9,8 @@ it("renders correctly", () => { const props: PropTypesOf = { children: "content", }; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/admin/components/Navigation.spec.tsx b/src/core/client/admin/components/Navigation.spec.tsx index 49038dc6d..df82a1ccd 100644 --- a/src/core/client/admin/components/Navigation.spec.tsx +++ b/src/core/client/admin/components/Navigation.spec.tsx @@ -1,9 +1,10 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import Navigation from "./Navigation"; it("renders correctly", () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/admin/components/NavigationLink.spec.tsx b/src/core/client/admin/components/NavigationLink.spec.tsx index 285457c89..42d57e1f2 100644 --- a/src/core/client/admin/components/NavigationLink.spec.tsx +++ b/src/core/client/admin/components/NavigationLink.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -10,6 +10,7 @@ it("renders correctly", () => { to: "/moderate", children: "link", }; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/admin/components/SignOutButton.spec.tsx b/src/core/client/admin/components/SignOutButton.spec.tsx index d5275c83a..91d761df7 100644 --- a/src/core/client/admin/components/SignOutButton.spec.tsx +++ b/src/core/client/admin/components/SignOutButton.spec.tsx @@ -1,6 +1,6 @@ -import { shallow } from "enzyme"; import { noop } from "lodash"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -11,6 +11,7 @@ it("renders correctly", () => { id: "id", onClick: noop, }; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/admin/mutations/CreateOIDCAuthIntegrationMutation.ts b/src/core/client/admin/mutations/CreateOIDCAuthIntegrationMutation.ts deleted file mode 100644 index 8664945b6..000000000 --- a/src/core/client/admin/mutations/CreateOIDCAuthIntegrationMutation.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { graphql } from "react-relay"; -import { Environment } from "relay-runtime"; - -import { - commitMutationPromiseNormalized, - createMutationContainer, -} from "talk-framework/lib/relay"; -import { Omit } from "talk-framework/types"; - -import { CreateOIDCAuthIntegrationMutation as MutationTypes } from "talk-admin/__generated__/CreateOIDCAuthIntegrationMutation.graphql"; - -export type CreateOIDCAuthIntegrationInput = Omit< - MutationTypes["variables"]["input"], - "clientMutationId" ->; - -const mutation = graphql` - mutation CreateOIDCAuthIntegrationMutation( - $input: CreateOIDCAuthIntegrationInput! - ) { - createOIDCAuthIntegration(input: $input) { - settings { - auth { - ...OIDCConfigListContainer_authReadOnly - } - } - clientMutationId - } - } -`; - -let clientMutationId = 0; - -function commit( - environment: Environment, - input: CreateOIDCAuthIntegrationInput -) { - return commitMutationPromiseNormalized(environment, { - mutation, - variables: { - input: { - ...input, - clientMutationId: (clientMutationId++).toString(), - }, - }, - }); -} - -export const withCreateOIDCAuthIntegrationMutation = createMutationContainer( - "createOIDCAuthIntegration", - commit -); - -export type CreateOIDCAuthIntegrationMutation = ( - input: CreateOIDCAuthIntegrationInput -) => Promise; diff --git a/src/core/client/admin/mutations/UpdateOIDCAuthIntegrationMutation.ts b/src/core/client/admin/mutations/UpdateOIDCAuthIntegrationMutation.ts deleted file mode 100644 index c1d71cc2d..000000000 --- a/src/core/client/admin/mutations/UpdateOIDCAuthIntegrationMutation.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { graphql } from "react-relay"; -import { Environment } from "relay-runtime"; - -import { - commitMutationPromiseNormalized, - createMutationContainer, -} from "talk-framework/lib/relay"; -import { Omit } from "talk-framework/types"; - -import { UpdateOIDCAuthIntegrationMutation as MutationTypes } from "talk-admin/__generated__/UpdateOIDCAuthIntegrationMutation.graphql"; - -export type UpdateOIDCAuthIntegrationInput = Omit< - MutationTypes["variables"]["input"], - "clientMutationId" ->; - -const mutation = graphql` - mutation UpdateOIDCAuthIntegrationMutation( - $input: UpdateOIDCAuthIntegrationInput! - ) { - updateOIDCAuthIntegration(input: $input) { - settings { - auth { - ...OIDCConfigListContainer_authReadOnly - } - } - clientMutationId - } - } -`; - -let clientMutationId = 0; - -function commit( - environment: Environment, - input: UpdateOIDCAuthIntegrationInput -) { - return commitMutationPromiseNormalized(environment, { - mutation, - variables: { - input: { - ...input, - clientMutationId: (clientMutationId++).toString(), - }, - }, - }); -} - -export const withUpdateOIDCAuthIntegrationMutation = createMutationContainer( - "updateOIDCAuthIntegration", - commit -); - -export type UpdateOIDCAuthIntegrationMutation = ( - input: UpdateOIDCAuthIntegrationInput -) => Promise; diff --git a/src/core/client/admin/mutations/UpdateSettingsMutation.ts b/src/core/client/admin/mutations/UpdateSettingsMutation.ts index 5e52dd8f1..162adb704 100644 --- a/src/core/client/admin/mutations/UpdateSettingsMutation.ts +++ b/src/core/client/admin/mutations/UpdateSettingsMutation.ts @@ -25,8 +25,8 @@ const mutation = graphql` ...GoogleConfigContainer_authReadOnly ...SSOConfigContainer_auth ...SSOConfigContainer_authReadOnly - ...OIDCConfigListContainer_auth - ...OIDCConfigListContainer_authReadOnly + ...OIDCConfigContainer_auth + ...OIDCConfigContainer_authReadOnly ...DisplayNamesConfigContainer_auth } } diff --git a/src/core/client/admin/mutations/index.ts b/src/core/client/admin/mutations/index.ts index e57ebc2a0..90f0b0787 100644 --- a/src/core/client/admin/mutations/index.ts +++ b/src/core/client/admin/mutations/index.ts @@ -20,11 +20,3 @@ export { withRegenerateSSOKeyMutation, RegenerateSSOKeyMutation, } from "./RegenerateSSOKeyMutation"; -export { - withCreateOIDCAuthIntegrationMutation, - CreateOIDCAuthIntegrationMutation, -} from "./CreateOIDCAuthIntegrationMutation"; -export { - withUpdateOIDCAuthIntegrationMutation, - UpdateOIDCAuthIntegrationMutation, -} from "./UpdateOIDCAuthIntegrationMutation"; diff --git a/src/core/client/admin/routes/community/components/Community.spec.tsx b/src/core/client/admin/routes/community/components/Community.spec.tsx index 9b0a2c23c..7edd1408d 100644 --- a/src/core/client/admin/routes/community/components/Community.spec.tsx +++ b/src/core/client/admin/routes/community/components/Community.spec.tsx @@ -1,9 +1,10 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import Community from "./Community"; it("renders correctly", () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/admin/routes/configure/components/ConfigBox.spec.tsx b/src/core/client/admin/routes/configure/components/ConfigBox.spec.tsx index 42502e05d..c84da0bf0 100644 --- a/src/core/client/admin/routes/configure/components/ConfigBox.spec.tsx +++ b/src/core/client/admin/routes/configure/components/ConfigBox.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -11,6 +11,7 @@ it("renders correctly", () => { title: title, children: "child", }; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/admin/routes/configure/components/Configure.spec.tsx b/src/core/client/admin/routes/configure/components/Configure.spec.tsx index 6f1b81fc0..117d7ff5f 100644 --- a/src/core/client/admin/routes/configure/components/Configure.spec.tsx +++ b/src/core/client/admin/routes/configure/components/Configure.spec.tsx @@ -1,6 +1,6 @@ -import { shallow } from "enzyme"; import { noop } from "lodash"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -11,6 +11,7 @@ it("renders correctly", () => { onSave: noop, onChange: noop, }; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/admin/routes/configure/components/Configure.tsx b/src/core/client/admin/routes/configure/components/Configure.tsx index a753540a8..0f0a68a7c 100644 --- a/src/core/client/admin/routes/configure/components/Configure.tsx +++ b/src/core/client/admin/routes/configure/components/Configure.tsx @@ -21,7 +21,7 @@ const Configure: StatelessComponent = ({ onChange, children, }) => ( - +
{({ handleSubmit, submitting, pristine, form, submitError }) => ( @@ -39,7 +39,7 @@ const Configure: StatelessComponent = ({ + + + + )} + + + + ); +}; + +export default AddEmailAddress; diff --git a/src/core/client/auth/views/addEmailAddress/components/UnorderedList/ListItem.css b/src/core/client/auth/views/addEmailAddress/components/UnorderedList/ListItem.css new file mode 100644 index 000000000..c717baccf --- /dev/null +++ b/src/core/client/auth/views/addEmailAddress/components/UnorderedList/ListItem.css @@ -0,0 +1,10 @@ +.root { + list-style-type: none; + display: flex; +} + +.leftCol { + flex-grow: 0; + flex-shrink: 0; + width: 20px; +} diff --git a/src/core/client/auth/views/addEmailAddress/components/UnorderedList/ListItem.tsx b/src/core/client/auth/views/addEmailAddress/components/UnorderedList/ListItem.tsx new file mode 100644 index 000000000..69cc9e3ac --- /dev/null +++ b/src/core/client/auth/views/addEmailAddress/components/UnorderedList/ListItem.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { StatelessComponent } from "react"; + +import { Icon } from "talk-ui/components"; + +import styles from "./ListItem.css"; + +interface Props { + icon?: React.ReactNode; +} + +const ListItem: StatelessComponent = props => ( +
  • +
    {props.icon}
    +
    {props.children}
    +
  • +); + +ListItem.defaultProps = { + icon: keyboard_arrow_right, +}; + +export default ListItem; diff --git a/src/core/client/auth/views/addEmailAddress/components/UnorderedList/UnorderedList.css b/src/core/client/auth/views/addEmailAddress/components/UnorderedList/UnorderedList.css new file mode 100644 index 000000000..220361f29 --- /dev/null +++ b/src/core/client/auth/views/addEmailAddress/components/UnorderedList/UnorderedList.css @@ -0,0 +1,11 @@ +.root { + margin: 0; + padding: 0; + + & > * { + margin: 0 0 calc(1 * var(--spacing-unit)) 0 !important; + } + & > *:last-child { + margin: 0 !important; + } +} diff --git a/src/core/client/auth/views/addEmailAddress/components/UnorderedList/UnorderedList.spec.tsx b/src/core/client/auth/views/addEmailAddress/components/UnorderedList/UnorderedList.spec.tsx new file mode 100644 index 000000000..101d3792c --- /dev/null +++ b/src/core/client/auth/views/addEmailAddress/components/UnorderedList/UnorderedList.spec.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; + +import ListItem from "./ListItem"; +import UnorderedList from "./UnorderedList"; + +it("renders correctly", () => { + const renderer = createRenderer(); + renderer.render( + + -}>1 + 2 + + ); + expect(renderer.getRenderOutput()).toMatchSnapshot(); +}); diff --git a/src/core/client/auth/views/addEmailAddress/components/UnorderedList/UnorderedList.tsx b/src/core/client/auth/views/addEmailAddress/components/UnorderedList/UnorderedList.tsx new file mode 100644 index 000000000..16f3c7a7f --- /dev/null +++ b/src/core/client/auth/views/addEmailAddress/components/UnorderedList/UnorderedList.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import { StatelessComponent } from "react"; + +import styles from "./UnorderedList.css"; + +const UnorderedList: StatelessComponent = props => ( +
      {props.children}
    +); + +export default UnorderedList; diff --git a/src/core/client/auth/views/addEmailAddress/components/UnorderedList/__snapshots__/UnorderedList.spec.tsx.snap b/src/core/client/auth/views/addEmailAddress/components/UnorderedList/__snapshots__/UnorderedList.spec.tsx.snap new file mode 100644 index 000000000..699da0f02 --- /dev/null +++ b/src/core/client/auth/views/addEmailAddress/components/UnorderedList/__snapshots__/UnorderedList.spec.tsx.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` +
      + + - + + } + > + 1 + + + keyboard_arrow_right + + } + > + 2 + +
    +`; diff --git a/src/core/client/auth/views/addEmailAddress/components/UnorderedList/index.ts b/src/core/client/auth/views/addEmailAddress/components/UnorderedList/index.ts new file mode 100644 index 000000000..180e3a8d5 --- /dev/null +++ b/src/core/client/auth/views/addEmailAddress/components/UnorderedList/index.ts @@ -0,0 +1,2 @@ +export { default as UnorderedList } from "./UnorderedList"; +export { default as ListItem } from "./ListItem"; diff --git a/src/core/client/auth/views/addEmailAddress/containers/AddEmailAddressContainer.tsx b/src/core/client/auth/views/addEmailAddress/containers/AddEmailAddressContainer.tsx new file mode 100644 index 000000000..330e04a97 --- /dev/null +++ b/src/core/client/auth/views/addEmailAddress/containers/AddEmailAddressContainer.tsx @@ -0,0 +1,35 @@ +import { FORM_ERROR } from "final-form"; +import React, { Component } from "react"; + +import { + SetEmailMutation, + withSetEmailMutation, +} from "talk-auth/mutations/SetEmailMutation"; +import { PropTypesOf } from "talk-framework/types"; + +import AddEmailAddress from "../components/AddEmailAddress"; + +interface Props { + setEmail: SetEmailMutation; +} + +class AddEmailAddressContainer extends Component { + private handleSubmit: PropTypesOf< + typeof AddEmailAddress + >["onSubmit"] = async (input, form) => { + try { + await this.props.setEmail({ email: input.email }); + return form.reset(); + } catch (error) { + return { [FORM_ERROR]: error.message }; + } + }; + + public render() { + // tslint:disable-next-line:no-empty + return ; + } +} + +const enhanced = withSetEmailMutation(AddEmailAddressContainer); +export default enhanced; diff --git a/src/core/client/auth/views/createPassword/components/CreatePassword.tsx b/src/core/client/auth/views/createPassword/components/CreatePassword.tsx new file mode 100644 index 000000000..50909cd0f --- /dev/null +++ b/src/core/client/auth/views/createPassword/components/CreatePassword.tsx @@ -0,0 +1,73 @@ +import { Localized } from "fluent-react/compat"; +import React, { StatelessComponent } from "react"; +import { Form } from "react-final-form"; + +import { Bar, Title } from "talk-auth/components//Header"; +import Main from "talk-auth/components/Main"; +import AutoHeightContainer from "talk-auth/containers/AutoHeightContainer"; +import { OnSubmit } from "talk-framework/lib/form"; +import { + Button, + CallOut, + HorizontalGutter, + Typography, +} from "talk-ui/components"; + +import SetPasswordField from "talk-auth/components/SetPasswordField"; + +interface FormProps { + password: string; +} + +export interface CreatePasswordForm { + onSubmit: OnSubmit; +} + +const CreatePassword: StatelessComponent = props => { + return ( +
    + + + Create Password + + +
    +
    + {({ handleSubmit, submitting, submitError }) => ( + + + + + + To protect against unauthorized changes to your account, we + require users to create a password. + + + {submitError && ( + + {submitError} + + )} + + + + + + + )} + +
    +
    + ); +}; + +export default CreatePassword; diff --git a/src/core/client/auth/views/createPassword/containers/CreatePasswordContainer.tsx b/src/core/client/auth/views/createPassword/containers/CreatePasswordContainer.tsx new file mode 100644 index 000000000..633860f37 --- /dev/null +++ b/src/core/client/auth/views/createPassword/containers/CreatePasswordContainer.tsx @@ -0,0 +1,36 @@ +import { FORM_ERROR } from "final-form"; +import React, { Component } from "react"; + +import { + SetPasswordMutation, + withSetPasswordMutation, +} from "talk-auth/mutations/SetPasswordMutation"; +import { PropTypesOf } from "talk-framework/types"; + +import CreatePassword from "../components/CreatePassword"; + +interface Props { + setPassword: SetPasswordMutation; +} + +class CreatePasswordContainer extends Component { + private handleSubmit: PropTypesOf["onSubmit"] = async ( + input, + form + ) => { + try { + await this.props.setPassword({ password: input.password }); + return form.reset(); + } catch (error) { + return { [FORM_ERROR]: error.message }; + } + }; + + public render() { + // tslint:disable-next-line:no-empty + return ; + } +} + +const enhanced = withSetPasswordMutation(CreatePasswordContainer); +export default enhanced; diff --git a/src/core/client/auth/views/createUsername/components/CreateUsername.tsx b/src/core/client/auth/views/createUsername/components/CreateUsername.tsx new file mode 100644 index 000000000..8f1db09f3 --- /dev/null +++ b/src/core/client/auth/views/createUsername/components/CreateUsername.tsx @@ -0,0 +1,73 @@ +import { Localized } from "fluent-react/compat"; +import React, { StatelessComponent } from "react"; +import { Form } from "react-final-form"; + +import { Bar, Title } from "talk-auth/components//Header"; +import Main from "talk-auth/components/Main"; +import AutoHeightContainer from "talk-auth/containers/AutoHeightContainer"; +import { OnSubmit } from "talk-framework/lib/form"; +import { + Button, + CallOut, + HorizontalGutter, + Typography, +} from "talk-ui/components"; + +import UsernameField from "talk-auth/components/UsernameField"; + +interface FormProps { + username: string; +} + +export interface CreateUsernameForm { + onSubmit: OnSubmit; +} + +const CreateUsername: StatelessComponent = props => { + return ( +
    + + + Create Username + + +
    +
    + {({ handleSubmit, submitting, submitError }) => ( + + + + + + Your username is a unique identifier that will appear on all + of your comments. + + + {submitError && ( + + {submitError} + + )} + + + + + + + )} + +
    +
    + ); +}; + +export default CreateUsername; diff --git a/src/core/client/auth/views/createUsername/containers/CreateUsernameContainer.tsx b/src/core/client/auth/views/createUsername/containers/CreateUsernameContainer.tsx new file mode 100644 index 000000000..276f1086d --- /dev/null +++ b/src/core/client/auth/views/createUsername/containers/CreateUsernameContainer.tsx @@ -0,0 +1,36 @@ +import { FORM_ERROR } from "final-form"; +import React, { Component } from "react"; + +import { + SetUsernameMutation, + withSetUsernameMutation, +} from "talk-auth/mutations/SetUsernameMutation"; +import { PropTypesOf } from "talk-framework/types"; + +import CreateUsername from "../components/CreateUsername"; + +interface Props { + setUsername: SetUsernameMutation; +} + +class CreateUsernameContainer extends Component { + private handleSubmit: PropTypesOf["onSubmit"] = async ( + input, + form + ) => { + try { + await this.props.setUsername({ username: input.username }); + return form.reset(); + } catch (error) { + return { [FORM_ERROR]: error.message }; + } + }; + + public render() { + // tslint:disable-next-line:no-empty + return ; + } +} + +const enhanced = withSetUsernameMutation(CreateUsernameContainer); +export default enhanced; diff --git a/src/core/client/auth/views/forgotPassword/components/ForgotPassword.tsx b/src/core/client/auth/views/forgotPassword/components/ForgotPassword.tsx new file mode 100644 index 000000000..f44310ded --- /dev/null +++ b/src/core/client/auth/views/forgotPassword/components/ForgotPassword.tsx @@ -0,0 +1,114 @@ +import { Localized } from "fluent-react/compat"; +import React, { StatelessComponent } from "react"; +import { Field, Form } from "react-final-form"; + +import { Bar, Title } from "talk-auth/components//Header"; +import Main from "talk-auth/components/Main"; +import AutoHeightContainer from "talk-auth/containers/AutoHeightContainer"; +import { OnSubmit } from "talk-framework/lib/form"; +import { + composeValidators, + required, + validateEmail, +} from "talk-framework/lib/validation"; +import { + Button, + CallOut, + FormField, + HorizontalGutter, + InputLabel, + TextField, + Typography, + ValidationMessage, +} from "talk-ui/components"; + +interface FormProps { + email: string; +} + +export interface ForgotPasswordForm { + onSubmit: OnSubmit; +} + +const ForgotPassword: StatelessComponent = props => { + return ( +
    + + + Forgot Password + + +
    +
    + {({ handleSubmit, submitting, submitError }) => ( + + + + + + Enter your email address below and we will send you a link + to reset your password. + + + {submitError && ( + + {submitError} + + )} + + {({ input, meta }) => ( + + + Email Address + + + + + {meta.touched && + (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} + + )} + + + + + + + )} + +
    +
    + ); +}; + +export default ForgotPassword; diff --git a/src/core/client/auth/containers/ForgotPasswordContainer.tsx b/src/core/client/auth/views/forgotPassword/containers/ForgotPasswordContainer.tsx similarity index 100% rename from src/core/client/auth/containers/ForgotPasswordContainer.tsx rename to src/core/client/auth/views/forgotPassword/containers/ForgotPasswordContainer.tsx diff --git a/src/core/client/auth/views/resetPassword/components/ResetPassword.tsx b/src/core/client/auth/views/resetPassword/components/ResetPassword.tsx new file mode 100644 index 000000000..bb86ef14d --- /dev/null +++ b/src/core/client/auth/views/resetPassword/components/ResetPassword.tsx @@ -0,0 +1,63 @@ +import { Localized } from "fluent-react/compat"; +import React, { StatelessComponent } from "react"; +import { Form } from "react-final-form"; +import { OnSubmit } from "talk-framework/lib/form"; + +import { Bar, Title } from "talk-auth/components//Header"; +import ConfirmPasswordField from "talk-auth/components/ConfirmPasswordField"; +import Main from "talk-auth/components/Main"; +import SetPasswordField from "talk-auth/components/SetPasswordField"; +import AutoHeightContainer from "talk-auth/containers/AutoHeightContainer"; +import { Button, CallOut, HorizontalGutter } from "talk-ui/components"; + +interface FormProps { + password: string; + confirmPassword: string; +} + +export interface ResetPasswordForm { + onSubmit: OnSubmit; +} + +const ResetPassword: StatelessComponent = props => { + return ( +
    + + + Reset Password + + +
    +
    + {({ handleSubmit, submitting, submitError }) => ( + + + + {submitError && ( + + {submitError} + + )} + + + + + + + + )} + +
    +
    + ); +}; + +export default ResetPassword; diff --git a/src/core/client/auth/containers/ResetPasswordContainer.tsx b/src/core/client/auth/views/resetPassword/containers/ResetPasswordContainer.tsx similarity index 100% rename from src/core/client/auth/containers/ResetPasswordContainer.tsx rename to src/core/client/auth/views/resetPassword/containers/ResetPasswordContainer.tsx diff --git a/src/core/client/auth/views/signIn/components/SignIn.spec.tsx b/src/core/client/auth/views/signIn/components/SignIn.spec.tsx new file mode 100644 index 000000000..48a32bf1f --- /dev/null +++ b/src/core/client/auth/views/signIn/components/SignIn.spec.tsx @@ -0,0 +1,38 @@ +import { noop } from "lodash"; +import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; + +import { removeFragmentRefs } from "talk-framework/testHelpers"; +import { PropTypesOf } from "talk-framework/types"; + +import SignIn from "./SignIn"; + +const SignInN = removeFragmentRefs(SignIn); + +it("renders correctly", () => { + const props: PropTypesOf = { + onGotoSignUp: noop, + emailEnabled: true, + facebookEnabled: true, + googleEnabled: true, + oidcEnabled: true, + auth: {}, + }; + const renderer = createRenderer(); + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); +}); + +it("renders without email login", () => { + const props: PropTypesOf = { + onGotoSignUp: noop, + emailEnabled: false, + facebookEnabled: true, + googleEnabled: true, + oidcEnabled: true, + auth: {}, + }; + const renderer = createRenderer(); + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); +}); diff --git a/src/core/client/auth/views/signIn/components/SignIn.tsx b/src/core/client/auth/views/signIn/components/SignIn.tsx new file mode 100644 index 000000000..45eb9f8cc --- /dev/null +++ b/src/core/client/auth/views/signIn/components/SignIn.tsx @@ -0,0 +1,84 @@ +import { Localized } from "fluent-react/compat"; +import React, { StatelessComponent } from "react"; + +import { Bar, SubBar, Subtitle, Title } from "talk-auth/components//Header"; +import Main from "talk-auth/components/Main"; +import OrSeparator from "talk-auth/components/OrSeparator"; +import AutoHeightContainer from "talk-auth/containers/AutoHeightContainer"; +import { PropTypesOf } from "talk-framework/types"; +import { Button, Flex, HorizontalGutter, Typography } from "talk-ui/components"; + +import SignInWithEmailContainer from "../containers/SignInWithEmailContainer"; +import SignInWithFacebookContainer from "../containers/SignInWithFacebookContainer"; +import SignInWithGoogleContainer from "../containers/SignInWithGoogleContainer"; +import SignInWithOIDCContainer from "../containers/SignInWithOIDCContainer"; + +export interface SignInForm { + onGotoSignUp: () => void; + emailEnabled?: boolean; + facebookEnabled?: boolean; + googleEnabled?: boolean; + oidcEnabled?: boolean; + auth: PropTypesOf["auth"] & + PropTypesOf["auth"] & + PropTypesOf["auth"]; +} + +const SignIn: StatelessComponent = ({ + onGotoSignUp, + emailEnabled, + facebookEnabled, + googleEnabled, + oidcEnabled, + auth, +}) => { + const oneClickIntegrationEnabled = + facebookEnabled || googleEnabled || oidcEnabled; + return ( +
    + + } + subtitle={} + > + + { + "Sign Into join the conversation" + } + + + + + } + > + + {"Don't have an account? "} + + + +
    + + {emailEnabled && } + {emailEnabled && oneClickIntegrationEnabled && } + + {facebookEnabled && } + {googleEnabled && } + {oidcEnabled && } + + +
    +
    + ); +}; + +export default SignIn; diff --git a/src/core/client/auth/views/signIn/components/SignInWithEmail.tsx b/src/core/client/auth/views/signIn/components/SignInWithEmail.tsx new file mode 100644 index 000000000..c4d26ffb1 --- /dev/null +++ b/src/core/client/auth/views/signIn/components/SignInWithEmail.tsx @@ -0,0 +1,113 @@ +import { Localized } from "fluent-react/compat"; +import React, { StatelessComponent } from "react"; +import { Field, Form } from "react-final-form"; +import { OnSubmit } from "talk-framework/lib/form"; + +import EmailField from "talk-auth/components/EmailField"; +import AutoHeightContainer from "talk-auth/containers/AutoHeightContainer"; +import { PasswordField } from "talk-framework/components"; +import { composeValidators, required } from "talk-framework/lib/validation"; +import { + Button, + ButtonIcon, + CallOut, + Flex, + FormField, + HorizontalGutter, + InputLabel, + ValidationMessage, +} from "talk-ui/components"; + +interface FormProps { + email: string; + password: string; +} + +export interface SignInWithEmailForm { + onSubmit: OnSubmit; + onGotoForgotPassword: () => void; +} + +const SignInWithEmail: StatelessComponent = props => { + return ( +
    + {({ handleSubmit, submitting, submitError }) => ( + + + + {submitError && ( + + {submitError} + + )} + + + + {({ input, meta }) => ( + + + Password + + + + + {meta.touched && + (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} + + + + + + + )} + + + + + )} + + ); +}; + +export default SignInWithEmail; diff --git a/src/core/client/auth/views/signIn/components/__snapshots__/SignIn.spec.tsx.snap b/src/core/client/auth/views/signIn/components/__snapshots__/SignIn.spec.tsx.snap new file mode 100644 index 000000000..831661adf --- /dev/null +++ b/src/core/client/auth/views/signIn/components/__snapshots__/SignIn.spec.tsx.snap @@ -0,0 +1,117 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` +
    + + } + title={} + > + <Bar> + <title>Sign In</title><subtitle>to join the conversation</subtitle> + </Bar> + </Localized> + <SubBar> + <Localized + button={ + <withPropsOnChange(Button) + color="primary" + data-testid="gotoSignUpButton" + onClick={[Function]} + size="small" + variant="underlined" + /> + } + id="signIn-noAccountSignUp" + > + <withPropsOnChange(Typography) + container={[Function]} + variant="bodyCopy" + > + Don't have an account? <button>Sign Up</button> + </withPropsOnChange(Typography)> + </Localized> + </SubBar> + <Main + data-testid="signIn-main" + > + <withPropsOnChange(HorizontalGutter) + size="oneAndAHalf" + > + <withContext(createMutationContainer(withContext(createMutationContainer(SignInContainer)))) /> + <OrSeparator /> + <withPropsOnChange(HorizontalGutter)> + <Relay(SignInWithFacebookContainer) + auth={Object {}} + /> + <Relay(SignInWithGoogleContainer) + auth={Object {}} + /> + <Relay(SignInWithOIDCContainer) + auth={Object {}} + /> + </withPropsOnChange(HorizontalGutter)> + </withPropsOnChange(HorizontalGutter)> + </Main> +</div> +`; + +exports[`renders without email login 1`] = ` +<div + data-testid="signIn-container" +> + <AutoHeightContainer /> + <Localized + id="signIn-signInToJoinHeader" + subtitle={<Subtitle />} + title={<Title />} + > + <Bar> + <title>Sign In</title><subtitle>to join the conversation</subtitle> + </Bar> + </Localized> + <SubBar> + <Localized + button={ + <withPropsOnChange(Button) + color="primary" + data-testid="gotoSignUpButton" + onClick={[Function]} + size="small" + variant="underlined" + /> + } + id="signIn-noAccountSignUp" + > + <withPropsOnChange(Typography) + container={[Function]} + variant="bodyCopy" + > + Don't have an account? <button>Sign Up</button> + </withPropsOnChange(Typography)> + </Localized> + </SubBar> + <Main + data-testid="signIn-main" + > + <withPropsOnChange(HorizontalGutter) + size="oneAndAHalf" + > + <withPropsOnChange(HorizontalGutter)> + <Relay(SignInWithFacebookContainer) + auth={Object {}} + /> + <Relay(SignInWithGoogleContainer) + auth={Object {}} + /> + <Relay(SignInWithOIDCContainer) + auth={Object {}} + /> + </withPropsOnChange(HorizontalGutter)> + </withPropsOnChange(HorizontalGutter)> + </Main> +</div> +`; diff --git a/src/core/client/auth/views/signIn/containers/SignInContainer.tsx b/src/core/client/auth/views/signIn/containers/SignInContainer.tsx new file mode 100644 index 000000000..e5612d4bb --- /dev/null +++ b/src/core/client/auth/views/signIn/containers/SignInContainer.tsx @@ -0,0 +1,85 @@ +import React, { Component } from "react"; + +import { SignInContainer_auth as AuthData } from "talk-auth/__generated__/SignInContainer_auth.graphql"; +import { + SetViewMutation, + SignInMutation, + withSetViewMutation, + withSignInMutation, +} from "talk-auth/mutations"; +import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; + +import SignIn from "../components/SignIn"; + +interface Props { + auth: AuthData; + signIn: SignInMutation; + setView: SetViewMutation; +} + +class SignInContainer extends Component<Props> { + private goToSignUp = () => this.props.setView({ view: "SIGN_UP" }); + public render() { + const integrations = this.props.auth.integrations; + return ( + <SignIn + auth={this.props.auth} + onGotoSignUp={this.goToSignUp} + emailEnabled={ + integrations.local.enabled && integrations.local.targetFilter.stream + } + facebookEnabled={ + integrations.facebook.enabled && + integrations.facebook.targetFilter.stream + } + googleEnabled={ + integrations.google.enabled && integrations.google.targetFilter.stream + } + oidcEnabled={ + integrations.oidc.enabled && integrations.oidc.targetFilter.stream + } + /> + ); + } +} + +const enhanced = withSetViewMutation( + withSignInMutation( + withFragmentContainer<Props>({ + auth: graphql` + fragment SignInContainer_auth on Auth { + ...SignInWithOIDCContainer_auth + ...SignInWithGoogleContainer_auth + ...SignInWithFacebookContainer_auth + integrations { + local { + enabled + targetFilter { + stream + } + } + facebook { + enabled + targetFilter { + stream + } + } + google { + enabled + targetFilter { + stream + } + } + oidc { + enabled + targetFilter { + stream + } + } + } + } + `, + })(SignInContainer) + ) +); +export default enhanced; diff --git a/src/core/client/auth/containers/SignInContainer.tsx b/src/core/client/auth/views/signIn/containers/SignInWithEmailContainer.tsx similarity index 71% rename from src/core/client/auth/containers/SignInContainer.tsx rename to src/core/client/auth/views/signIn/containers/SignInWithEmailContainer.tsx index fe2b87949..434e8bb25 100644 --- a/src/core/client/auth/containers/SignInContainer.tsx +++ b/src/core/client/auth/views/signIn/containers/SignInWithEmailContainer.tsx @@ -1,12 +1,16 @@ import { FORM_ERROR } from "final-form"; import React, { Component } from "react"; -import SignIn, { SignInForm } from "../components/SignIn"; + import { SetViewMutation, SignInMutation, withSetViewMutation, withSignInMutation, -} from "../mutations"; +} from "talk-auth/mutations"; + +import SignInWithEmail, { + SignInWithEmailForm, +} from "../components/SignInWithEmail"; interface SignInContainerProps { signIn: SignInMutation; @@ -14,9 +18,9 @@ interface SignInContainerProps { } class SignInContainer extends Component<SignInContainerProps> { - private onSubmit: SignInForm["onSubmit"] = async (input, form) => { + private onSubmit: SignInWithEmailForm["onSubmit"] = async (input, form) => { try { - await this.props.signIn(input); + await this.props.signIn({ email: input.email, password: input.password }); return form.reset(); } catch (error) { return { [FORM_ERROR]: error.message }; @@ -24,13 +28,11 @@ class SignInContainer extends Component<SignInContainerProps> { }; private goToForgotPassword = () => this.props.setView({ view: "FORGOT_PASSWORD" }); - private goToSignUp = () => this.props.setView({ view: "SIGN_UP" }); public render() { return ( - <SignIn + <SignInWithEmail onSubmit={this.onSubmit} onGotoForgotPassword={this.goToForgotPassword} - onGotoSignUp={this.goToSignUp} /> ); } diff --git a/src/core/client/auth/views/signIn/containers/SignInWithFacebookContainer.tsx b/src/core/client/auth/views/signIn/containers/SignInWithFacebookContainer.tsx new file mode 100644 index 000000000..a675f07da --- /dev/null +++ b/src/core/client/auth/views/signIn/containers/SignInWithFacebookContainer.tsx @@ -0,0 +1,41 @@ +import { Localized } from "fluent-react/compat"; +import React, { Component } from "react"; + +import { SignInWithFacebookContainer_auth as AuthData } from "talk-auth/__generated__/SignInWithFacebookContainer_auth.graphql"; +import FacebookButton from "talk-auth/components/FacebookButton"; +import { redirectOAuth2 } from "talk-auth/helpers"; +import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; + +interface Props { + auth: AuthData; +} + +class SignInWithFacebookContainer extends Component<Props> { + private handleOnClick = () => { + redirectOAuth2(this.props.auth.integrations.facebook.redirectURL); + }; + + public render() { + return ( + <Localized id="signIn-signInWithFacebook"> + <FacebookButton onClick={this.handleOnClick}> + Sign in with Facebook + </FacebookButton> + </Localized> + ); + } +} + +const enhanced = withFragmentContainer<Props>({ + auth: graphql` + fragment SignInWithFacebookContainer_auth on Auth { + integrations { + facebook { + redirectURL + } + } + } + `, +})(SignInWithFacebookContainer); + +export default enhanced; diff --git a/src/core/client/auth/views/signIn/containers/SignInWithGoogleContainer.tsx b/src/core/client/auth/views/signIn/containers/SignInWithGoogleContainer.tsx new file mode 100644 index 000000000..40f742e4c --- /dev/null +++ b/src/core/client/auth/views/signIn/containers/SignInWithGoogleContainer.tsx @@ -0,0 +1,41 @@ +import { Localized } from "fluent-react/compat"; +import React, { Component } from "react"; + +import { SignInWithGoogleContainer_auth as AuthData } from "talk-auth/__generated__/SignInWithGoogleContainer_auth.graphql"; +import GoogleButton from "talk-auth/components/GoogleButton"; +import { redirectOAuth2 } from "talk-auth/helpers"; +import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; + +interface Props { + auth: AuthData; +} + +class SignInWithGoogleContainer extends Component<Props> { + private handleOnClick = () => { + redirectOAuth2(this.props.auth.integrations.google.redirectURL); + }; + + public render() { + return ( + <Localized id="signIn-signInWithGoogle"> + <GoogleButton onClick={this.handleOnClick}> + Sign in with Google + </GoogleButton> + </Localized> + ); + } +} + +const enhanced = withFragmentContainer<Props>({ + auth: graphql` + fragment SignInWithGoogleContainer_auth on Auth { + integrations { + google { + redirectURL + } + } + } + `, +})(SignInWithGoogleContainer); + +export default enhanced; diff --git a/src/core/client/auth/views/signIn/containers/SignInWithOIDCContainer.tsx b/src/core/client/auth/views/signIn/containers/SignInWithOIDCContainer.tsx new file mode 100644 index 000000000..1eb669948 --- /dev/null +++ b/src/core/client/auth/views/signIn/containers/SignInWithOIDCContainer.tsx @@ -0,0 +1,43 @@ +import { Localized } from "fluent-react/compat"; +import React, { Component } from "react"; + +import { SignInWithOIDCContainer_auth as AuthData } from "talk-auth/__generated__/SignInWithOIDCContainer_auth.graphql"; +import OIDCButton from "talk-auth/components/OIDCButton"; +import { redirectOAuth2 } from "talk-auth/helpers"; +import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; + +interface Props { + auth: AuthData; +} + +class SignInWithOIDCContainer extends Component<Props> { + private handleOnClick = () => { + redirectOAuth2(this.props.auth.integrations.oidc.redirectURL!); + }; + + public render() { + return ( + <Localized + id="signIn-signInWithOIDC" + $name={this.props.auth.integrations.oidc.name} + > + <OIDCButton onClick={this.handleOnClick}>Sign in with $name</OIDCButton> + </Localized> + ); + } +} + +const enhanced = withFragmentContainer<Props>({ + auth: graphql` + fragment SignInWithOIDCContainer_auth on Auth { + integrations { + oidc { + name + redirectURL + } + } + } + `, +})(SignInWithOIDCContainer); + +export default enhanced; diff --git a/src/core/client/auth/views/signUp/components/SignUp.spec.tsx b/src/core/client/auth/views/signUp/components/SignUp.spec.tsx new file mode 100644 index 000000000..bfa2e688b --- /dev/null +++ b/src/core/client/auth/views/signUp/components/SignUp.spec.tsx @@ -0,0 +1,38 @@ +import { noop } from "lodash"; +import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; + +import { removeFragmentRefs } from "talk-framework/testHelpers"; +import { PropTypesOf } from "talk-framework/types"; + +import SignUp from "./SignUp"; + +const SignUpN = removeFragmentRefs(SignUp); + +it("renders correctly", () => { + const props: PropTypesOf<typeof SignUpN> = { + onGotoSignIn: noop, + emailEnabled: true, + facebookEnabled: true, + googleEnabled: true, + oidcEnabled: true, + auth: {}, + }; + const renderer = createRenderer(); + renderer.render(<SignUpN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); +}); + +it("renders without email sign up", () => { + const props: PropTypesOf<typeof SignUpN> = { + onGotoSignIn: noop, + emailEnabled: false, + facebookEnabled: true, + googleEnabled: true, + oidcEnabled: true, + auth: {}, + }; + const renderer = createRenderer(); + renderer.render(<SignUpN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); +}); diff --git a/src/core/client/auth/views/signUp/components/SignUp.tsx b/src/core/client/auth/views/signUp/components/SignUp.tsx new file mode 100644 index 000000000..8be6b2cce --- /dev/null +++ b/src/core/client/auth/views/signUp/components/SignUp.tsx @@ -0,0 +1,84 @@ +import { Localized } from "fluent-react/compat"; +import React, { StatelessComponent } from "react"; + +import { Bar, SubBar, Subtitle, Title } from "talk-auth/components//Header"; +import Main from "talk-auth/components/Main"; +import OrSeparator from "talk-auth/components/OrSeparator"; +import AutoHeightContainer from "talk-auth/containers/AutoHeightContainer"; +import { PropTypesOf } from "talk-framework/types"; +import { Button, Flex, HorizontalGutter, Typography } from "talk-ui/components"; + +import SignUpWithEmailContainer from "../containers/SignUpWithEmailContainer"; +import SignUpWithFacebookContainer from "../containers/SignUpWithFacebookContainer"; +import SignUpWithGoogleContainer from "../containers/SignUpWithGoogleContainer"; +import SignUpWithOIDCContainer from "../containers/SignUpWithOIDCContainer"; + +interface Props { + onGotoSignIn: () => void; + emailEnabled?: boolean; + facebookEnabled?: boolean; + googleEnabled?: boolean; + oidcEnabled?: boolean; + auth: PropTypesOf<typeof SignUpWithOIDCContainer>["auth"] & + PropTypesOf<typeof SignUpWithFacebookContainer>["auth"] & + PropTypesOf<typeof SignUpWithGoogleContainer>["auth"]; +} + +const SignUp: StatelessComponent<Props> = ({ + onGotoSignIn, + emailEnabled, + facebookEnabled, + googleEnabled, + oidcEnabled, + auth, +}) => { + const oneClickUptegrationEnabled = + facebookEnabled || googleEnabled || oidcEnabled; + return ( + <div data-testid="signUp-container"> + <AutoHeightContainer /> + <Localized + id="signUp-signUpToJoinHeader" + title={<Title />} + subtitle={<Subtitle />} + > + <Bar> + { + "<title>Sign Upto join the conversation" + } + + + + + } + > + + {"Already have an account? "} + + + +
    + + {emailEnabled && } + {emailEnabled && oneClickUptegrationEnabled && } + + {facebookEnabled && } + {googleEnabled && } + {oidcEnabled && } + + +
    +
    + ); +}; + +export default SignUp; diff --git a/src/core/client/auth/views/signUp/components/SignUpWithEmail.tsx b/src/core/client/auth/views/signUp/components/SignUpWithEmail.tsx new file mode 100644 index 000000000..45286f9c6 --- /dev/null +++ b/src/core/client/auth/views/signUp/components/SignUpWithEmail.tsx @@ -0,0 +1,65 @@ +import { Localized } from "fluent-react/compat"; +import * as React from "react"; +import { StatelessComponent } from "react"; +import { Form } from "react-final-form"; + +import EmailField from "talk-auth/components/EmailField"; +import SetPasswordField from "talk-auth/components/SetPasswordField"; +import UsernameField from "talk-auth/components/UsernameField"; +import { OnSubmit } from "talk-framework/lib/form"; +import { + Button, + ButtonIcon, + CallOut, + HorizontalGutter, +} from "talk-ui/components"; + +import AutoHeightContainer from "talk-auth/containers/AutoHeightContainer"; + +interface FormProps { + email: string; + username: string; + password: string; + confirmPassword: string; +} + +interface Props { + onSubmit: OnSubmit; +} + +const SignUp: StatelessComponent = props => { + return ( +
    + {({ handleSubmit, submitting, submitError }) => ( + + + + {submitError && ( + + {submitError} + + )} + + + + + + + )} + + ); +}; + +export default SignUp; diff --git a/src/core/client/auth/views/signUp/components/__snapshots__/SignUp.spec.tsx.snap b/src/core/client/auth/views/signUp/components/__snapshots__/SignUp.spec.tsx.snap new file mode 100644 index 000000000..ff40af71e --- /dev/null +++ b/src/core/client/auth/views/signUp/components/__snapshots__/SignUp.spec.tsx.snap @@ -0,0 +1,117 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` +
    + + } + title={} + > + <Bar> + <title>Sign Up</title><subtitle>to join the conversation</subtitle> + </Bar> + </Localized> + <SubBar> + <Localized + button={ + <withPropsOnChange(Button) + color="primary" + data-testid="gotoSignInButton" + onClick={[Function]} + size="small" + variant="underlined" + /> + } + id="signUp-accountAvailableSignIn" + > + <withPropsOnChange(Typography) + container={[Function]} + variant="bodyCopy" + > + Already have an account? <button>Sign In</button> + </withPropsOnChange(Typography)> + </Localized> + </SubBar> + <Main + data-testid="signUp-main" + > + <withPropsOnChange(HorizontalGutter) + size="oneAndAHalf" + > + <withContext(createMutationContainer(SignUpContainer)) /> + <OrSeparator /> + <withPropsOnChange(HorizontalGutter)> + <Relay(SignUpWithFacebookContainer) + auth={Object {}} + /> + <Relay(SignUpWithGoogleContainer) + auth={Object {}} + /> + <Relay(SignUpWithOIDCContainer) + auth={Object {}} + /> + </withPropsOnChange(HorizontalGutter)> + </withPropsOnChange(HorizontalGutter)> + </Main> +</div> +`; + +exports[`renders without email sign up 1`] = ` +<div + data-testid="signUp-container" +> + <AutoHeightContainer /> + <Localized + id="signUp-signUpToJoinHeader" + subtitle={<Subtitle />} + title={<Title />} + > + <Bar> + <title>Sign Up</title><subtitle>to join the conversation</subtitle> + </Bar> + </Localized> + <SubBar> + <Localized + button={ + <withPropsOnChange(Button) + color="primary" + data-testid="gotoSignInButton" + onClick={[Function]} + size="small" + variant="underlined" + /> + } + id="signUp-accountAvailableSignIn" + > + <withPropsOnChange(Typography) + container={[Function]} + variant="bodyCopy" + > + Already have an account? <button>Sign In</button> + </withPropsOnChange(Typography)> + </Localized> + </SubBar> + <Main + data-testid="signUp-main" + > + <withPropsOnChange(HorizontalGutter) + size="oneAndAHalf" + > + <withPropsOnChange(HorizontalGutter)> + <Relay(SignUpWithFacebookContainer) + auth={Object {}} + /> + <Relay(SignUpWithGoogleContainer) + auth={Object {}} + /> + <Relay(SignUpWithOIDCContainer) + auth={Object {}} + /> + </withPropsOnChange(HorizontalGutter)> + </withPropsOnChange(HorizontalGutter)> + </Main> +</div> +`; diff --git a/src/core/client/auth/views/signUp/containers/SignUpContainer.tsx b/src/core/client/auth/views/signUp/containers/SignUpContainer.tsx new file mode 100644 index 000000000..b89d6fef0 --- /dev/null +++ b/src/core/client/auth/views/signUp/containers/SignUpContainer.tsx @@ -0,0 +1,88 @@ +import React, { Component } from "react"; + +import { SignUpContainer_auth as AuthData } from "talk-auth/__generated__/SignUpContainer_auth.graphql"; +import { SetViewMutation, withSetViewMutation } from "talk-auth/mutations"; +import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; + +import SignUp from "../components/SignUp"; + +interface Props { + auth: AuthData; + setView: SetViewMutation; +} + +class SignUpContainer extends Component<Props> { + private goToSignIn = () => this.props.setView({ view: "SIGN_IN" }); + public render() { + const integrations = this.props.auth.integrations; + return ( + <SignUp + auth={this.props.auth} + onGotoSignIn={this.goToSignIn} + emailEnabled={ + integrations.local.enabled && + integrations.local.targetFilter.stream && + integrations.local.allowRegistration + } + facebookEnabled={ + integrations.facebook.enabled && + integrations.facebook.targetFilter.stream && + integrations.facebook.allowRegistration + } + googleEnabled={ + integrations.google.enabled && + integrations.google.targetFilter.stream && + integrations.google.allowRegistration + } + oidcEnabled={ + integrations.oidc.enabled && + integrations.oidc.targetFilter.stream && + integrations.oidc.allowRegistration + } + /> + ); + } +} + +const enhanced = withSetViewMutation( + withFragmentContainer<Props>({ + auth: graphql` + fragment SignUpContainer_auth on Auth { + ...SignUpWithOIDCContainer_auth + ...SignUpWithGoogleContainer_auth + ...SignUpWithFacebookContainer_auth + integrations { + local { + enabled + targetFilter { + stream + } + allowRegistration + } + facebook { + enabled + targetFilter { + stream + } + allowRegistration + } + google { + enabled + targetFilter { + stream + } + allowRegistration + } + oidc { + enabled + targetFilter { + stream + } + allowRegistration + } + } + } + `, + })(SignUpContainer) +); +export default enhanced; diff --git a/src/core/client/auth/views/signUp/containers/SignUpWithEmailContainer.tsx b/src/core/client/auth/views/signUp/containers/SignUpWithEmailContainer.tsx new file mode 100644 index 000000000..3c4e91f43 --- /dev/null +++ b/src/core/client/auth/views/signUp/containers/SignUpWithEmailContainer.tsx @@ -0,0 +1,35 @@ +import { FORM_ERROR } from "final-form"; +import React, { Component } from "react"; + +import { SignUpMutation, withSignUpMutation } from "talk-auth/mutations"; +import { PropTypesOf } from "talk-framework/types"; + +import SignUp from "../components/SignUpWithEmail"; + +interface SignUpContainerProps { + signUp: SignUpMutation; +} + +class SignUpContainer extends Component<SignUpContainerProps> { + private handleSubmit: PropTypesOf<typeof SignUp>["onSubmit"] = async ( + input, + form + ) => { + try { + await this.props.signUp({ + email: input.email, + password: input.password, + username: input.username, + }); + return form.reset(); + } catch (error) { + return { [FORM_ERROR]: error.message }; + } + }; + public render() { + return <SignUp onSubmit={this.handleSubmit} />; + } +} + +const enhanced = withSignUpMutation(SignUpContainer); +export default enhanced; diff --git a/src/core/client/auth/views/signUp/containers/SignUpWithFacebookContainer.tsx b/src/core/client/auth/views/signUp/containers/SignUpWithFacebookContainer.tsx new file mode 100644 index 000000000..8cddc89ac --- /dev/null +++ b/src/core/client/auth/views/signUp/containers/SignUpWithFacebookContainer.tsx @@ -0,0 +1,41 @@ +import { Localized } from "fluent-react/compat"; +import React, { Component } from "react"; + +import { SignUpWithFacebookContainer_auth as AuthData } from "talk-auth/__generated__/SignUpWithFacebookContainer_auth.graphql"; +import FacebookButton from "talk-auth/components/FacebookButton"; +import { redirectOAuth2 } from "talk-auth/helpers"; +import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; + +interface Props { + auth: AuthData; +} + +class SignUpWithFacebookContainer extends Component<Props> { + private handleOnClick = () => { + redirectOAuth2(this.props.auth.integrations.facebook.redirectURL); + }; + + public render() { + return ( + <Localized id="signUp-signUpWithFacebook"> + <FacebookButton onClick={this.handleOnClick}> + Sign up with Facebook + </FacebookButton> + </Localized> + ); + } +} + +const enhanced = withFragmentContainer<Props>({ + auth: graphql` + fragment SignUpWithFacebookContainer_auth on Auth { + integrations { + facebook { + redirectURL + } + } + } + `, +})(SignUpWithFacebookContainer); + +export default enhanced; diff --git a/src/core/client/auth/views/signUp/containers/SignUpWithGoogleContainer.tsx b/src/core/client/auth/views/signUp/containers/SignUpWithGoogleContainer.tsx new file mode 100644 index 000000000..8d5e27307 --- /dev/null +++ b/src/core/client/auth/views/signUp/containers/SignUpWithGoogleContainer.tsx @@ -0,0 +1,41 @@ +import { Localized } from "fluent-react/compat"; +import React, { Component } from "react"; + +import { SignUpWithGoogleContainer_auth as AuthData } from "talk-auth/__generated__/SignUpWithGoogleContainer_auth.graphql"; +import GoogleButton from "talk-auth/components/GoogleButton"; +import { redirectOAuth2 } from "talk-auth/helpers"; +import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; + +interface Props { + auth: AuthData; +} + +class SignUpWithGoogleContainer extends Component<Props> { + private handleOnClick = () => { + redirectOAuth2(this.props.auth.integrations.google.redirectURL); + }; + + public render() { + return ( + <Localized id="signUp-signUpWithGoogle"> + <GoogleButton onClick={this.handleOnClick}> + Sign up with Google + </GoogleButton> + </Localized> + ); + } +} + +const enhanced = withFragmentContainer<Props>({ + auth: graphql` + fragment SignUpWithGoogleContainer_auth on Auth { + integrations { + google { + redirectURL + } + } + } + `, +})(SignUpWithGoogleContainer); + +export default enhanced; diff --git a/src/core/client/auth/views/signUp/containers/SignUpWithOIDCContainer.tsx b/src/core/client/auth/views/signUp/containers/SignUpWithOIDCContainer.tsx new file mode 100644 index 000000000..ce5f6c0af --- /dev/null +++ b/src/core/client/auth/views/signUp/containers/SignUpWithOIDCContainer.tsx @@ -0,0 +1,43 @@ +import { Localized } from "fluent-react/compat"; +import React, { Component } from "react"; + +import { SignUpWithOIDCContainer_auth as AuthData } from "talk-auth/__generated__/SignUpWithOIDCContainer_auth.graphql"; +import OIDCButton from "talk-auth/components/OIDCButton"; +import { redirectOAuth2 } from "talk-auth/helpers"; +import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; + +interface Props { + auth: AuthData; +} + +class SignUpWithOIDCContainer extends Component<Props> { + private handleOnClick = () => { + redirectOAuth2(this.props.auth.integrations.oidc.redirectURL!); + }; + + public render() { + return ( + <Localized + id="signUp-signUpWithOIDC" + $name={this.props.auth.integrations.oidc.name} + > + <OIDCButton onClick={this.handleOnClick}>Sign in with $name</OIDCButton> + </Localized> + ); + } +} + +const enhanced = withFragmentContainer<Props>({ + auth: graphql` + fragment SignUpWithOIDCContainer_auth on Auth { + integrations { + oidc { + name + redirectURL + } + } + } + `, +})(SignUpWithOIDCContainer); + +export default enhanced; diff --git a/src/core/client/embed/decorators/withPymStorage.spec.ts b/src/core/client/embed/decorators/withPymStorage.spec.ts index 720147d7d..69e7cee9f 100644 --- a/src/core/client/embed/decorators/withPymStorage.spec.ts +++ b/src/core/client/embed/decorators/withPymStorage.spec.ts @@ -1,3 +1,4 @@ +import mockConsole from "jest-mock-console"; import sinon from "sinon"; import { createInMemoryStorage } from "talk-framework/lib/storage"; @@ -110,6 +111,7 @@ describe("withPymStorage", () => { expect(pym.messages).toMatchSnapshot(); }); it("should handle unknown method", () => { + mockConsole(); const pym = new PymStub("localStorage"); const storage = createInMemoryStorage(); withPymStorage(storage, "localStorage", "talk:")(pym as any); @@ -121,8 +123,11 @@ describe("withPymStorage", () => { }) ); expect(JSON.stringify(pym.messages)).toMatchSnapshot(); + // tslint:disable-next-line: no-console + expect(console.error).toHaveBeenCalled(); }); it("should handle handle errors", () => { + mockConsole(); const pym = new PymStub("localStorage"); const storage = createInMemoryStorage(); sinon @@ -138,5 +143,7 @@ describe("withPymStorage", () => { }) ); expect(pym.messages).toMatchSnapshot(); + // tslint:disable-next-line: no-console + expect(console.error).toHaveBeenCalled(); }); }); diff --git a/src/core/client/framework/components/CopyButton/CopyButton.tsx b/src/core/client/framework/components/CopyButton.tsx similarity index 93% rename from src/core/client/framework/components/CopyButton/CopyButton.tsx rename to src/core/client/framework/components/CopyButton.tsx index d23e664c8..1eec8c724 100644 --- a/src/core/client/framework/components/CopyButton/CopyButton.tsx +++ b/src/core/client/framework/components/CopyButton.tsx @@ -13,7 +13,7 @@ interface State { copied: boolean; } -class PermalinkPopover extends React.Component<InnerProps> { +class CopyButton extends React.Component<InnerProps> { private timeout: any = null; public state: State = { @@ -59,4 +59,4 @@ class PermalinkPopover extends React.Component<InnerProps> { } } -export default PermalinkPopover; +export default CopyButton; diff --git a/src/core/client/framework/components/CopyButton/index.ts b/src/core/client/framework/components/CopyButton/index.ts deleted file mode 100644 index a58fe3206..000000000 --- a/src/core/client/framework/components/CopyButton/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./CopyButton"; diff --git a/src/core/client/framework/components/PasswordField.tsx b/src/core/client/framework/components/PasswordField.tsx new file mode 100644 index 000000000..fdf795456 --- /dev/null +++ b/src/core/client/framework/components/PasswordField.tsx @@ -0,0 +1,22 @@ +import { Localized } from "fluent-react/compat"; +import React, { StatelessComponent } from "react"; + +import { Omit, PropTypesOf } from "talk-framework/types"; +import { PasswordField as PasswordFieldUI } from "talk-ui/components"; + +export interface Props + extends Omit< + PropTypesOf<typeof PasswordFieldUI>, + "showPasswordTitle" | "hidePasswordTitle" + > {} + +const PasswordField: StatelessComponent<Props> = props => ( + <Localized + id="framework-passwordField" + attrs={{ showPasswordTitle: true, hidePasswordTitle: true }} + > + <PasswordFieldUI {...props} /> + </Localized> +); + +export default PasswordField; diff --git a/src/core/client/framework/components/index.ts b/src/core/client/framework/components/index.ts index 087db8cd4..6c9a3e80d 100644 --- a/src/core/client/framework/components/index.ts +++ b/src/core/client/framework/components/index.ts @@ -1 +1,2 @@ export { default as CopyButton } from "./CopyButton"; +export { default as PasswordField } from "./PasswordField"; diff --git a/src/core/client/framework/lib/messages.tsx b/src/core/client/framework/lib/messages.tsx index 6bbe0a8f4..861864643 100644 --- a/src/core/client/framework/lib/messages.tsx +++ b/src/core/client/framework/lib/messages.tsx @@ -54,6 +54,12 @@ export const PASSWORDS_DO_NOT_MATCH = () => ( </Localized> ); +export const EMAILS_DO_NOT_MATCH = () => ( + <Localized id="framework-validation-emailsDoNotMatch"> + <span>Emails do not match. Try again.</span> + </Localized> +); + export const INVALID_URL = () => ( <Localized id="framework-validation-invalidURL"> <span>Invalid URL</span> diff --git a/src/core/client/framework/lib/relay/wrapFetchWithLogger.ts b/src/core/client/framework/lib/relay/wrapFetchWithLogger.ts index d320e8ab0..80a746624 100644 --- a/src/core/client/framework/lib/relay/wrapFetchWithLogger.ts +++ b/src/core/client/framework/lib/relay/wrapFetchWithLogger.ts @@ -6,19 +6,21 @@ import { FetchFunction } from "relay-runtime"; */ export default function wrapFetchWithLogger( fetch: FetchFunction, - logResult?: boolean + options: { logResult?: boolean; muteErrors?: boolean } = {} ): FetchFunction { return async (...args: any[]) => { try { const result = await (fetch as any)(...args); - if (logResult) { + if (options.logResult) { // tslint:disable-next-line:no-console console.log(JSON.stringify(result)); } return result; } catch (err) { - // tslint:disable-next-line:no-console - console.error(err); + if (!options.muteErrors) { + // tslint:disable-next-line:no-console + console.error(err); + } throw err; } }; diff --git a/src/core/client/framework/lib/validation.tsx b/src/core/client/framework/lib/validation.tsx index 9c7840664..3021b14e8 100644 --- a/src/core/client/framework/lib/validation.tsx +++ b/src/core/client/framework/lib/validation.tsx @@ -1,5 +1,6 @@ import { ReactNode } from "react"; import { + EMAILS_DO_NOT_MATCH, INVALID_CHARACTERS, INVALID_EMAIL, INVALID_URL, @@ -101,10 +102,18 @@ export const validatePassword = createValidator( PASSWORD_TOO_SHORT(8) ); -/**s - * validateUsername is a Validator that checks that the value is a valid username. +/** + * validateEqualPasswords is a Validator that checks for correct password confirmation. */ export const validateEqualPasswords = createValidator( (v, values) => v === values.password, PASSWORDS_DO_NOT_MATCH() ); + +/** + * validateEqualEmails is a Validator that checks for correct email confirmation. + */ +export const validateEqualEmails = createValidator( + (v, values) => v === values.email, + EMAILS_DO_NOT_MATCH() +); diff --git a/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts b/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts index d0f5e2b3f..bc0c5c2c1 100644 --- a/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts +++ b/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts @@ -4,7 +4,10 @@ import sinon from "sinon"; import { TalkContext } from "talk-framework/lib/bootstrap"; import { LOCAL_ID } from "talk-framework/lib/relay"; import { createPromisifiedStorage } from "talk-framework/lib/storage"; -import { createRelayEnvironment } from "talk-framework/testHelpers"; +import { + createAuthToken, + createRelayEnvironment, +} from "talk-framework/testHelpers"; import { commit } from "./SetAuthTokenMutation"; @@ -17,17 +20,7 @@ beforeAll(() => { }); }); -const authToken = `${btoa( - JSON.stringify({ - alg: "HS256", - typ: "JWT", - }) -)}.${btoa( - JSON.stringify({ - exp: 1540503165, - jti: "31b26591-4e9a-4388-a7ff-e1bdc5d97cce", - }) -)}`; +const authToken = createAuthToken(); it("Sets auth token to localStorage", async () => { const clearSessionStub = sinon.stub(); diff --git a/src/core/client/framework/testHelpers/byLabelText.ts b/src/core/client/framework/testHelpers/byLabelText.ts index 2450ac1a5..ed69f90c8 100644 --- a/src/core/client/framework/testHelpers/byLabelText.ts +++ b/src/core/client/framework/testHelpers/byLabelText.ts @@ -1,10 +1,12 @@ import { ReactTestInstance } from "react-test-renderer"; +import { queryAllByText } from "./byText"; import matchText, { TextMatchOptions, TextMatchPattern } from "./matchText"; -const matcher = (pattern: TextMatchPattern, options?: TextMatchOptions) => ( - i: ReactTestInstance -) => { +const ariaLabelMatcher = ( + pattern: TextMatchPattern, + options?: TextMatchOptions +) => (i: ReactTestInstance) => { // Only look at dom components. if (typeof i.type !== "string" || !i.props["aria-label"]) { return false; @@ -20,31 +22,14 @@ export function getByLabelText( pattern: TextMatchPattern, options?: TextMatchOptions ) { - return container.find(matcher(pattern, options)); -} - -export function queryByLabelText( - container: ReactTestInstance, - pattern: TextMatchPattern, - options?: TextMatchOptions -) { - try { - return container.find(matcher(pattern, options)); - } catch { - return null; + const results = queryAllByLabelText(container, pattern, options); + if (results.length === 1) { + return results[0]; } -} - -export function queryAllByLabelText( - container: ReactTestInstance, - pattern: TextMatchPattern, - options?: TextMatchOptions -) { - try { - return container.findAll(matcher(pattern, options)); - } catch { - return []; + if (results.length === 0) { + throw new Error(`Could't find element with label text ${pattern}`); } + throw new Error(`Found multiple elements with label text ${pattern}`); } export function getAllByLabelText( @@ -52,5 +37,55 @@ export function getAllByLabelText( pattern: TextMatchPattern, options?: TextMatchOptions ) { - return container.findAll(matcher(pattern, options)); + const results = queryAllByLabelText(container, pattern, options); + if (results.length) { + return results; + } + throw new Error(`Could't find element with label text ${pattern}`); +} + +export function queryByLabelText( + container: ReactTestInstance, + pattern: TextMatchPattern, + options?: TextMatchOptions +) { + const results = queryAllByLabelText(container, pattern, options); + if (results.length) { + return results[0]; + } + return null; +} + +export function queryAllByLabelText( + container: ReactTestInstance, + pattern: TextMatchPattern, + options?: TextMatchOptions +) { + const matches = container.findAll(ariaLabelMatcher(pattern, options)); + queryAllByText(container, pattern).forEach(i => { + if (typeof i.type !== "string") { + return; + } + if (i.props.id) { + try { + matches.push( + container.find( + x => + typeof x.type === "string" && + x.props["aria-labelledby"] === i.props.id + ) + ); + } catch {} // tslint:disable-line:no-empty + } + if (i.type === "label" && i.props.htmlFor) { + try { + matches.push( + container.find( + x => typeof x.type === "string" && x.props.id === i.props.htmlFor + ) + ); + } catch {} // tslint:disable-line:no-empty + } + }); + return matches; } diff --git a/src/core/client/framework/testHelpers/byTestID.ts b/src/core/client/framework/testHelpers/byTestID.ts index 8034fd0ef..8eb75a3cf 100644 --- a/src/core/client/framework/testHelpers/byTestID.ts +++ b/src/core/client/framework/testHelpers/byTestID.ts @@ -6,10 +6,10 @@ const matcher = (pattern: TextMatchPattern, options?: TextMatchOptions) => ( i: ReactTestInstance ) => { // Only look at dom components. - if (typeof i.type !== "string" || !i.props["data-test"]) { + if (typeof i.type !== "string" || !i.props["data-testid"]) { return false; } - return matchText(pattern, i.props["data-test"], { + return matchText(pattern, i.props["data-testid"], { collapseWhitespace: false, ...options, }); @@ -23,34 +23,34 @@ export function getByTestID( return container.find(matcher(pattern, options)); } +export function getAllByTestID( + container: ReactTestInstance, + pattern: TextMatchPattern, + options?: TextMatchOptions +) { + const results = container.findAll(matcher(pattern, options)); + if (!results.length) { + throw new Error(`Couldn't find test id ${pattern}`); + } + return results; +} + export function queryByTestID( container: ReactTestInstance, pattern: TextMatchPattern, options?: TextMatchOptions ) { - try { - return container.find(matcher(pattern, options)); - } catch { + const results = container.findAll(matcher(pattern, options)); + if (!results.length) { return null; } + return results[0]; } export function queryAllByTestID( container: ReactTestInstance, pattern: TextMatchPattern, options?: TextMatchOptions -) { - try { - return container.findAll(matcher(pattern, options)); - } catch { - return []; - } -} - -export function getAllByTestID( - container: ReactTestInstance, - pattern: TextMatchPattern, - options?: TextMatchOptions ) { return container.findAll(matcher(pattern, options)); } diff --git a/src/core/client/framework/testHelpers/byText.ts b/src/core/client/framework/testHelpers/byText.ts index c4b0a518a..104aacbd9 100644 --- a/src/core/client/framework/testHelpers/byText.ts +++ b/src/core/client/framework/testHelpers/byText.ts @@ -30,34 +30,34 @@ export function getByText( return container.find(matcher(pattern, options)); } +export function getAllByText( + container: ReactTestInstance, + pattern: TextMatchPattern, + options?: TextMatchOptions +) { + const results = container.findAll(matcher(pattern, options)); + if (!results.length) { + throw new Error(`Couldn't find text ${pattern}`); + } + return results; +} + export function queryByText( container: ReactTestInstance, pattern: TextMatchPattern, options?: TextMatchOptions ) { - try { - return container.find(matcher(pattern, options)); - } catch { + const results = container.findAll(matcher(pattern, options)); + if (!results.length) { return null; } + return results[0]; } export function queryAllByText( container: ReactTestInstance, pattern: TextMatchPattern, options?: TextMatchOptions -) { - try { - return container.findAll(matcher(pattern, options)); - } catch { - return []; - } -} - -export function getAllByText( - container: ReactTestInstance, - pattern: TextMatchPattern, - options?: TextMatchOptions ) { return container.findAll(matcher(pattern, options)); } diff --git a/src/core/client/framework/testHelpers/byType.ts b/src/core/client/framework/testHelpers/byType.ts new file mode 100644 index 000000000..2e4c1bb79 --- /dev/null +++ b/src/core/client/framework/testHelpers/byType.ts @@ -0,0 +1,56 @@ +import { ReactTestInstance } from "react-test-renderer"; + +import matchText, { TextMatchOptions, TextMatchPattern } from "./matchText"; + +const matcher = (pattern: TextMatchPattern, options?: TextMatchOptions) => ( + i: ReactTestInstance +) => { + // Only look at dom components. + if (typeof i.type !== "string") { + return false; + } + return matchText(pattern, i.type, { + collapseWhitespace: false, + ...options, + }); +}; + +export function getByType( + container: ReactTestInstance, + pattern: TextMatchPattern, + options?: TextMatchOptions +) { + return container.find(matcher(pattern, options)); +} + +export function getAllByType( + container: ReactTestInstance, + pattern: TextMatchPattern, + options?: TextMatchOptions +) { + const results = container.findAll(matcher(pattern, options)); + if (!results.length) { + throw new Error(`Couldn't find test id ${pattern}`); + } + return results; +} + +export function queryByType( + container: ReactTestInstance, + pattern: TextMatchPattern, + options?: TextMatchOptions +) { + const results = container.findAll(matcher(pattern, options)); + if (!results.length) { + return null; + } + return results[0]; +} + +export function queryAllByType( + container: ReactTestInstance, + pattern: TextMatchPattern, + options?: TextMatchOptions +) { + return container.findAll(matcher(pattern, options)); +} diff --git a/src/core/client/framework/testHelpers/createAuthToken.ts b/src/core/client/framework/testHelpers/createAuthToken.ts new file mode 100644 index 000000000..34823df87 --- /dev/null +++ b/src/core/client/framework/testHelpers/createAuthToken.ts @@ -0,0 +1,12 @@ +export default function createAuthToken() { + return `${btoa( + JSON.stringify({ + alg: "HS256", + typ: "JWT", + }) + )}.${btoa( + JSON.stringify({ + jti: "31b26591-4e9a-4388-a7ff-e1bdc5d97cce", + }) + )}`; +} diff --git a/src/core/client/framework/testHelpers/createRelayEnvironment.ts b/src/core/client/framework/testHelpers/createRelayEnvironment.ts index f7e035ddc..17b00841c 100644 --- a/src/core/client/framework/testHelpers/createRelayEnvironment.ts +++ b/src/core/client/framework/testHelpers/createRelayEnvironment.ts @@ -26,6 +26,8 @@ export interface CreateRelayEnvironmentNetworkParams { resolvers: IResolvers<any, any>; /** If enabled, graphql responses will be logged to the console */ logNetwork?: boolean; + /** If enabled, graphql errors will be muted */ + muteNetworkErrors?: boolean; } export interface CreateRelayEnvironmentParams { @@ -58,10 +60,14 @@ export default function createRelayEnvironment( if (params.network) { const schema = loadSchema( params.network.projectName, - params.network.resolvers + params.network.resolvers, + { requireResolversForResolveType: false } ); network = Network.create( - wrapFetchWithLogger(createFetch({ schema }), params.network.logNetwork) + wrapFetchWithLogger(createFetch({ schema }), { + logResult: params.network.logNetwork, + muteErrors: params.network.muteNetworkErrors, + }) ); } const environment = new Environment({ diff --git a/src/core/client/framework/testHelpers/index.ts b/src/core/client/framework/testHelpers/index.ts index 7558a293a..b8b00057e 100644 --- a/src/core/client/framework/testHelpers/index.ts +++ b/src/core/client/framework/testHelpers/index.ts @@ -10,25 +10,13 @@ export { } from "./removeFragmentRefs"; export { default as createUUIDGenerator } from "./createUUIDGenerator"; export * from "./denormalize"; -export { default as replaceHistoryLocation } from "./replaceHistoryLocation"; export { default as limitSnapshotTo } from "./limitSnapshotTo"; export { default as inputPredicate } from "./inputPredicate"; -export { - getByTestID, - getAllByTestID, - queryByTestID, - queryAllByTestID, -} from "./byTestID"; -export { getByText, getAllByText, queryByText, queryAllByText } from "./byText"; -export { - getByLabelText, - getAllByLabelText, - queryByLabelText, - queryAllByLabelText, -} from "./byLabelText"; export { default as within } from "./within"; export { default as wait } from "./wait"; export { default as waitForElement } from "./waitForElement"; export { default as waitUntilThrow } from "./waitUntilThrow"; export { default as matchText } from "./matchText"; export { default as toJSON } from "./toJSON"; +export { default as replaceHistoryLocation } from "./replaceHistoryLocation"; +export { default as createAuthToken } from "./createAuthToken"; diff --git a/src/core/client/framework/testHelpers/limitSnapshotTo.ts b/src/core/client/framework/testHelpers/limitSnapshotTo.ts index faf0fb62d..a353537d4 100644 --- a/src/core/client/framework/testHelpers/limitSnapshotTo.ts +++ b/src/core/client/framework/testHelpers/limitSnapshotTo.ts @@ -1,5 +1,5 @@ export default function limitSnapshotTo(dataTest: string, node: any) { - if (node.props && node.props["data-test"] === dataTest) { + if (node.props && node.props["data-testid"] === dataTest) { return node; } if (node.children) { diff --git a/src/core/client/framework/testHelpers/within.ts b/src/core/client/framework/testHelpers/within.ts index 477a24b3d..1131eea1a 100644 --- a/src/core/client/framework/testHelpers/within.ts +++ b/src/core/client/framework/testHelpers/within.ts @@ -13,6 +13,12 @@ import { queryByTestID, } from "./byTestID"; import { getAllByText, getByText, queryAllByText, queryByText } from "./byText"; +import { + getAllByType, + getByType, + queryAllByType, + queryByType, +} from "./byType"; import toJSON from "./toJSON"; type Func0<R> = () => R; @@ -49,6 +55,10 @@ export default function within(container: ReactTestInstance) { getAllByLabelText: applyContainer(container, getAllByLabelText), queryByLabelText: applyContainer(container, queryByLabelText), queryAllByLabelText: applyContainer(container, queryAllByLabelText), + getByType: applyContainer(container, getByType), + getAllByType: applyContainer(container, getAllByType), + queryByType: applyContainer(container, queryByType), + queryAllByType: applyContainer(container, queryAllByType), toJSON: () => toJSON(container), } } diff --git a/src/core/client/install/components/App.spec.tsx b/src/core/client/install/components/App.spec.tsx index 50b2eb712..e437770b1 100644 --- a/src/core/client/install/components/App.spec.tsx +++ b/src/core/client/install/components/App.spec.tsx @@ -1,9 +1,10 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import App from "./App"; it("renders correctly", () => { - const wrapper = shallow(<App />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<App />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/components/App.spec.tsx b/src/core/client/stream/components/App.spec.tsx index 942a67b78..0b2241f71 100644 --- a/src/core/client/stream/components/App.spec.tsx +++ b/src/core/client/stream/components/App.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -9,6 +9,7 @@ it("renders correctly", () => { const props: PropTypesOf<typeof App> = { activeTab: "COMMENTS", }; - const wrapper = shallow(<App {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<App {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/components/Timestamp.spec.tsx b/src/core/client/stream/components/Timestamp.spec.tsx index 4a276b762..5cc93c1f0 100644 --- a/src/core/client/stream/components/Timestamp.spec.tsx +++ b/src/core/client/stream/components/Timestamp.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -9,6 +9,7 @@ it("renders correctly", () => { const props: PropTypesOf<typeof Timestamp> = { children: "1995-12-17T03:24:00.000Z", }; - const wrapper = shallow(<Timestamp {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<Timestamp {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/components/UserBoxAuthenticated.spec.tsx b/src/core/client/stream/components/UserBoxAuthenticated.spec.tsx index cf7ae17f4..d901eec04 100644 --- a/src/core/client/stream/components/UserBoxAuthenticated.spec.tsx +++ b/src/core/client/stream/components/UserBoxAuthenticated.spec.tsx @@ -1,6 +1,6 @@ -import { shallow } from "enzyme"; import { noop } from "lodash"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -12,8 +12,9 @@ it("renders correctly with logout button", () => { username: "Username", showLogoutButton: true, }; - const wrapper = shallow(<UserBoxAuthenticated {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<UserBoxAuthenticated {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders correctly without logout button", () => { @@ -22,6 +23,7 @@ it("renders correctly without logout button", () => { username: "Username", showLogoutButton: false, }; - const wrapper = shallow(<UserBoxAuthenticated {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<UserBoxAuthenticated {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/components/UserBoxUnauthenticated.spec.tsx b/src/core/client/stream/components/UserBoxUnauthenticated.spec.tsx index 2e45eba2e..3a2d1d1ae 100644 --- a/src/core/client/stream/components/UserBoxUnauthenticated.spec.tsx +++ b/src/core/client/stream/components/UserBoxUnauthenticated.spec.tsx @@ -1,6 +1,6 @@ -import { shallow } from "enzyme"; import { noop } from "lodash"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -12,8 +12,9 @@ it("renders correctly", () => { onRegister: noop, showRegisterButton: true, }; - const wrapper = shallow(<UserBoxUnauthenticated {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<UserBoxUnauthenticated {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders correctly hides showRegisterButton", () => { @@ -22,6 +23,7 @@ it("renders correctly hides showRegisterButton", () => { onRegister: noop, showRegisterButton: false, }; - const wrapper = shallow(<UserBoxUnauthenticated {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<UserBoxUnauthenticated {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/containers/UserBoxContainer.spec.tsx b/src/core/client/stream/containers/UserBoxContainer.spec.tsx index 2d6d3600a..4876247d8 100644 --- a/src/core/client/stream/containers/UserBoxContainer.spec.tsx +++ b/src/core/client/stream/containers/UserBoxContainer.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { removeFragmentRefs } from "talk-framework/testHelpers"; import { PropTypesOf } from "talk-framework/types"; @@ -26,20 +26,31 @@ it("renders fully", () => { facebook: { enabled: true, allowRegistration: true, + targetFilter: { + stream: true, + }, }, google: { enabled: false, allowRegistration: true, + targetFilter: { + stream: true, + }, }, - sso: { + oidc: { enabled: false, allowRegistration: true, + targetFilter: { + stream: true, + }, }, local: { enabled: true, allowRegistration: true, + targetFilter: { + stream: true, + }, }, - oidc: [], }, }, }, @@ -50,8 +61,9 @@ it("renders fully", () => { // tslint:disable-next-line:no-empty signOut: async () => {}, }; - const wrapper = shallow(<UserBoxContainerN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<UserBoxContainerN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders without logout button", () => { @@ -71,20 +83,31 @@ it("renders without logout button", () => { facebook: { enabled: true, allowRegistration: true, + targetFilter: { + stream: true, + }, }, google: { enabled: false, allowRegistration: true, + targetFilter: { + stream: true, + }, }, - sso: { + oidc: { enabled: false, allowRegistration: true, + targetFilter: { + stream: true, + }, }, local: { enabled: true, allowRegistration: true, + targetFilter: { + stream: true, + }, }, - oidc: [], }, }, }, @@ -95,8 +118,9 @@ it("renders without logout button", () => { // tslint:disable-next-line:no-empty signOut: async () => {}, }; - const wrapper = shallow(<UserBoxContainerN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<UserBoxContainerN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders sso only", () => { @@ -116,20 +140,31 @@ it("renders sso only", () => { facebook: { enabled: false, allowRegistration: true, + targetFilter: { + stream: true, + }, }, google: { - enabled: false, - allowRegistration: true, - }, - sso: { enabled: true, allowRegistration: true, + targetFilter: { + stream: false, + }, + }, + oidc: { + enabled: false, + allowRegistration: true, + targetFilter: { + stream: true, + }, }, local: { enabled: false, allowRegistration: true, + targetFilter: { + stream: true, + }, }, - oidc: [], }, }, }, @@ -140,11 +175,12 @@ it("renders sso only", () => { // tslint:disable-next-line:no-empty signOut: async () => {}, }; - const wrapper = shallow(<UserBoxContainerN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<UserBoxContainerN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); -it("renders sso only without logut button", () => { +it("renders sso only without logout button", () => { const props: PropTypesOf<typeof UserBoxContainerN> = { local: { authPopup: { @@ -161,20 +197,31 @@ it("renders sso only without logut button", () => { facebook: { enabled: false, allowRegistration: true, + targetFilter: { + stream: true, + }, }, google: { enabled: false, allowRegistration: true, + targetFilter: { + stream: true, + }, }, - sso: { - enabled: true, + oidc: { + enabled: false, allowRegistration: true, + targetFilter: { + stream: true, + }, }, local: { enabled: false, allowRegistration: true, + targetFilter: { + stream: true, + }, }, - oidc: [], }, }, }, @@ -185,8 +232,9 @@ it("renders sso only without logut button", () => { // tslint:disable-next-line:no-empty signOut: async () => {}, }; - const wrapper = shallow(<UserBoxContainerN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<UserBoxContainerN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders without register button", () => { @@ -206,20 +254,31 @@ it("renders without register button", () => { facebook: { enabled: true, allowRegistration: false, + targetFilter: { + stream: true, + }, }, google: { enabled: false, - allowRegistration: false, + allowRegistration: true, + targetFilter: { + stream: false, + }, }, - sso: { + oidc: { enabled: false, allowRegistration: true, + targetFilter: { + stream: true, + }, }, local: { enabled: true, allowRegistration: false, + targetFilter: { + stream: true, + }, }, - oidc: [], }, }, }, @@ -230,6 +289,7 @@ it("renders without register button", () => { // tslint:disable-next-line:no-empty signOut: async () => {}, }; - const wrapper = shallow(<UserBoxContainerN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<UserBoxContainerN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/containers/UserBoxContainer.tsx b/src/core/client/stream/containers/UserBoxContainer.tsx index 0dcf97f63..5d9c60f35 100644 --- a/src/core/client/stream/containers/UserBoxContainer.tsx +++ b/src/core/client/stream/containers/UserBoxContainer.tsx @@ -45,23 +45,22 @@ export class UserBoxContainer extends Component<InnerProps> { private get supportsRegister() { const integrations = this.props.settings.auth.integrations; - return ( - (integrations.facebook.allowRegistration && - integrations.facebook.enabled) || - (integrations.google.allowRegistration && integrations.google.enabled) || - (integrations.local.allowRegistration && integrations.local.enabled) || - integrations.oidc.some(c => c.allowRegistration && c.enabled) - ); + return [ + integrations.facebook, + integrations.google, + integrations.local, + integrations.oidc, + ].some(i => i.allowRegistration && i.enabled && i.targetFilter.stream); } private get weControlAuth() { const integrations = this.props.settings.auth.integrations; - return ( - integrations.facebook.enabled || - integrations.google.enabled || - integrations.local.enabled || - integrations.oidc.some(c => c.enabled) - ); + return [ + integrations.facebook, + integrations.google, + integrations.local, + integrations.oidc, + ].some(i => i.enabled && i.targetFilter.stream); } public render() { @@ -75,7 +74,7 @@ export class UserBoxContainer extends Component<InnerProps> { if (me) { return ( <UserBoxAuthenticated - onSignOut={(this.weControlAuth && this.handleSignOut) || undefined} + onSignOut={this.handleSignOut} username={me.username!} showLogoutButton={this.supportsLogout} /> @@ -91,7 +90,7 @@ export class UserBoxContainer extends Component<InnerProps> { <Popup href={`${urls.embed.auth}?view=${view}`} title="Talk Auth" - features="menubar=0,resizable=0,width=350,height=395,top=200,left=500" + features="menubar=0,resizable=0,width=350,height=450,top=100,left=500" open={open} focus={focus} onFocus={this.handleFocus} @@ -138,22 +137,30 @@ const enhanced = withSignOutMutation( local { enabled allowRegistration - } - sso { - enabled - allowRegistration + targetFilter { + stream + } } oidc { enabled allowRegistration + targetFilter { + stream + } } google { enabled allowRegistration + targetFilter { + stream + } } facebook { enabled allowRegistration + targetFilter { + stream + } } } } diff --git a/src/core/client/stream/containers/__snapshots__/UserBoxContainer.spec.tsx.snap b/src/core/client/stream/containers/__snapshots__/UserBoxContainer.spec.tsx.snap index 02095fe21..747c2b003 100644 --- a/src/core/client/stream/containers/__snapshots__/UserBoxContainer.spec.tsx.snap +++ b/src/core/client/stream/containers/__snapshots__/UserBoxContainer.spec.tsx.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`renders fully 1`] = ` -<Fragment> +<React.Fragment> <Popup - features="menubar=0,resizable=0,width=350,height=395,top=200,left=500" + features="menubar=0,resizable=0,width=350,height=450,top=100,left=500" focus={false} href="/embed/auth?view=SIGN_IN" onBlur={[Function]} @@ -17,17 +17,17 @@ exports[`renders fully 1`] = ` onSignIn={[Function]} showRegisterButton={true} /> -</Fragment> +</React.Fragment> `; -exports[`renders sso only 1`] = `""`; +exports[`renders sso only 1`] = `null`; -exports[`renders sso only without logut button 1`] = `""`; +exports[`renders sso only without logout button 1`] = `null`; exports[`renders without logout button 1`] = ` -<Fragment> +<React.Fragment> <Popup - features="menubar=0,resizable=0,width=350,height=395,top=200,left=500" + features="menubar=0,resizable=0,width=350,height=450,top=100,left=500" focus={false} href="/embed/auth?view=SIGN_IN" onBlur={[Function]} @@ -41,13 +41,13 @@ exports[`renders without logout button 1`] = ` onSignIn={[Function]} showRegisterButton={true} /> -</Fragment> +</React.Fragment> `; exports[`renders without register button 1`] = ` -<Fragment> +<React.Fragment> <Popup - features="menubar=0,resizable=0,width=350,height=395,top=200,left=500" + features="menubar=0,resizable=0,width=350,height=450,top=100,left=500" focus={false} href="/embed/auth?view=SIGN_IN" onBlur={[Function]} @@ -60,5 +60,5 @@ exports[`renders without register button 1`] = ` onSignIn={[Function]} showRegisterButton={false} /> -</Fragment> +</React.Fragment> `; diff --git a/src/core/client/stream/index.tsx b/src/core/client/stream/index.tsx index 8cc11ceb6..dcc35c77b 100644 --- a/src/core/client/stream/index.tsx +++ b/src/core/client/stream/index.tsx @@ -8,7 +8,6 @@ import AppContainer from "talk-stream/containers/AppContainer"; import { OnEvents, - OnPostMessageAuthError, OnPostMessageSetAuthToken, OnPymLogin, OnPymLogout, @@ -23,7 +22,6 @@ const listeners = ( <OnPymLogout /> <OnPymSetCommentID /> <OnPostMessageSetAuthToken /> - <OnPostMessageAuthError /> <OnEvents /> </> ); diff --git a/src/core/client/stream/listeners/OnEvents.spec.tsx b/src/core/client/stream/listeners/OnEvents.spec.tsx index 28c5be2fe..d1faab561 100644 --- a/src/core/client/stream/listeners/OnEvents.spec.tsx +++ b/src/core/client/stream/listeners/OnEvents.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { createSinonStub } from "talk-framework/testHelpers"; @@ -22,6 +22,8 @@ it("Broadcasts events to pym", () => { ), }; - shallow(<OnEvents pym={pym as any} eventEmitter={eventEmitter} />); + createRenderer().render( + <OnEvents pym={pym as any} eventEmitter={eventEmitter} /> + ); expect(pym.sendMessage.calledOnce).toBe(true); }); diff --git a/src/core/client/stream/listeners/OnPostMessageAuthError.tsx b/src/core/client/stream/listeners/OnPostMessageAuthError.tsx deleted file mode 100644 index 3bb851965..000000000 --- a/src/core/client/stream/listeners/OnPostMessageAuthError.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Component } from "react"; - -import { withContext } from "talk-framework/lib/bootstrap"; -import { PostMessageService } from "talk-framework/lib/postMessage"; - -interface Props { - postMessage: PostMessageService; -} - -class OnPostMessageAuthError extends Component<Props> { - constructor(props: Props) { - super(props); - // Auth popup will use this to send back errors during login. - props.postMessage!.on("authError", error => { - // tslint:disable-next-line:no-console - console.error(error); - }); - } - - public render() { - return null; - } -} - -const enhanced = withContext(({ postMessage }) => ({ postMessage }))( - OnPostMessageAuthError -); -export default enhanced; diff --git a/src/core/client/stream/listeners/OnPostMessageSetAuthToken.spec.tsx b/src/core/client/stream/listeners/OnPostMessageSetAuthToken.spec.tsx index 92eec2050..53f28e26f 100644 --- a/src/core/client/stream/listeners/OnPostMessageSetAuthToken.spec.tsx +++ b/src/core/client/stream/listeners/OnPostMessageSetAuthToken.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { createSinonStub } from "talk-framework/testHelpers"; @@ -19,7 +19,7 @@ it("Listens to event and sets auth token", () => { s => s.withArgs({ authToken: token }).returns(null) ); - shallow( + createRenderer().render( <OnPostMessageSetAuthToken postMessage={postMessage} setAuthToken={setAuthToken} diff --git a/src/core/client/stream/listeners/OnPymLogin.spec.tsx b/src/core/client/stream/listeners/OnPymLogin.spec.tsx index 478d106a7..9e8ffdff6 100644 --- a/src/core/client/stream/listeners/OnPymLogin.spec.tsx +++ b/src/core/client/stream/listeners/OnPymLogin.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { createSinonStub } from "talk-framework/testHelpers"; @@ -19,6 +19,6 @@ it("Listens to event and calls setAuthToken", () => { s => s.withArgs({ authToken }).returns(null) ); - shallow(<OnPymLogin pym={pym} setAuthToken={setAuthToken} />); + createRenderer().render(<OnPymLogin pym={pym} setAuthToken={setAuthToken} />); expect(setAuthToken.calledOnce); }); diff --git a/src/core/client/stream/listeners/OnPymLogout.spec.tsx b/src/core/client/stream/listeners/OnPymLogout.spec.tsx index b2f1325cf..2deee8c29 100644 --- a/src/core/client/stream/listeners/OnPymLogout.spec.tsx +++ b/src/core/client/stream/listeners/OnPymLogout.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import sinon from "sinon"; import { OnPymLogout } from "./OnPymLogout"; @@ -15,6 +15,6 @@ it("Listens to event and calls signOut", () => { const signOut = sinon.stub(); - shallow(<OnPymLogout pym={pym} signOut={signOut} />); + createRenderer().render(<OnPymLogout pym={pym} signOut={signOut} />); expect(signOut.calledOnce); }); diff --git a/src/core/client/stream/listeners/OnPymSetCommentID.spec.tsx b/src/core/client/stream/listeners/OnPymSetCommentID.spec.tsx index a0f6e55dd..0cc8f7938 100644 --- a/src/core/client/stream/listeners/OnPymSetCommentID.spec.tsx +++ b/src/core/client/stream/listeners/OnPymSetCommentID.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { Environment, RecordSource } from "relay-runtime"; import { parseQuery } from "talk-common/utils"; @@ -36,7 +36,7 @@ it("Sets comment id", () => { } as any, relayEnvironment, }; - shallow(<OnPymSetCommentID {...props} />); + createRenderer().render(<OnPymSetCommentID {...props} />); expect(source.get(LOCAL_ID)!.commentID).toEqual(id); expect(parseQuery(location.search).commentID).toEqual(id); }); @@ -52,7 +52,7 @@ it("Sets comment id to null when empty", () => { } as any, relayEnvironment, }; - shallow(<OnPymSetCommentID {...props} />); + createRenderer().render(<OnPymSetCommentID {...props} />); expect(source.get(LOCAL_ID)!.commentID).toEqual(null); expect(parseQuery(location.search).commentID).toBeUndefined(); }); diff --git a/src/core/client/stream/listeners/index.ts b/src/core/client/stream/listeners/index.ts index 273e68663..a66e271b2 100644 --- a/src/core/client/stream/listeners/index.ts +++ b/src/core/client/stream/listeners/index.ts @@ -2,7 +2,6 @@ export { default as OnPymSetCommentID } from "./OnPymSetCommentID"; export { default as OnPostMessageSetAuthToken, } from "./OnPostMessageSetAuthToken"; -export { default as OnPostMessageAuthError } from "./OnPostMessageAuthError"; export { default as OnEvents } from "./OnEvents"; export { default as OnPymLogin } from "./OnPymLogin"; export { default as OnPymLogout } from "./OnPymLogout"; diff --git a/src/core/client/stream/local/initLocalState.spec.ts b/src/core/client/stream/local/initLocalState.spec.ts index cee7eee7e..088b2beef 100644 --- a/src/core/client/stream/local/initLocalState.spec.ts +++ b/src/core/client/stream/local/initLocalState.spec.ts @@ -5,6 +5,7 @@ import { TalkContext } from "talk-framework/lib/bootstrap"; import { LOCAL_ID } from "talk-framework/lib/relay"; import { createPromisifiedStorage } from "talk-framework/lib/storage"; import { + createAuthToken, createRelayEnvironment, replaceHistoryLocation, } from "talk-framework/testHelpers"; @@ -61,17 +62,7 @@ it("set authToken from localStorage", async () => { const context: Partial<TalkContext> = { localStorage: createPromisifiedStorage(), }; - const authToken = `${btoa( - JSON.stringify({ - alg: "HS256", - typ: "JWT", - }) - )}.${btoa( - JSON.stringify({ - exp: 1540503165, - jti: "31b26591-4e9a-4388-a7ff-e1bdc5d97cce", - }) - )}`; + const authToken = createAuthToken(); context.localStorage!.setItem("authToken", authToken); await initLocalState(environment, context as any); expect(source.get(LOCAL_ID)!.authToken).toBe(authToken); diff --git a/src/core/client/stream/tabs/comments/components/Comment/ButtonsBar.spec.tsx b/src/core/client/stream/tabs/comments/components/Comment/ButtonsBar.spec.tsx index ef21fa796..ef7102bbb 100644 --- a/src/core/client/stream/tabs/comments/components/Comment/ButtonsBar.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/Comment/ButtonsBar.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -9,6 +9,7 @@ it("renders correctly", () => { const props: PropTypesOf<typeof ButtonsBar> = { children: "children", }; - const wrapper = shallow(<ButtonsBar {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<ButtonsBar {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/Comment/Comment.spec.tsx b/src/core/client/stream/tabs/comments/components/Comment/Comment.spec.tsx index 03b5f99a9..11636d01c 100644 --- a/src/core/client/stream/tabs/comments/components/Comment/Comment.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/Comment/Comment.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -15,6 +15,7 @@ it("renders username and body", () => { footer: "footer", showEditedMarker: true, }; - const wrapper = shallow(<Comment {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<Comment {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/Comment/EditedMarker.spec.tsx b/src/core/client/stream/tabs/comments/components/Comment/EditedMarker.spec.tsx index 4017b77d1..e8a0526b7 100644 --- a/src/core/client/stream/tabs/comments/components/Comment/EditedMarker.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/Comment/EditedMarker.spec.tsx @@ -1,9 +1,10 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import EditedMarker from "./EditedMarker"; it("renders correctly", () => { - const wrapper = shallow(<EditedMarker />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<EditedMarker />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/Comment/InReplyTo.spec.tsx b/src/core/client/stream/tabs/comments/components/Comment/InReplyTo.spec.tsx index 71391cf1d..c0fbcf91c 100644 --- a/src/core/client/stream/tabs/comments/components/Comment/InReplyTo.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/Comment/InReplyTo.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -9,6 +9,7 @@ it("renders correctly", () => { const props: PropTypesOf<typeof InReplyTo> = { username: "Username", }; - const wrapper = shallow(<InReplyTo {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<InReplyTo {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/Comment/IndentedComment.spec.tsx b/src/core/client/stream/tabs/comments/components/Comment/IndentedComment.spec.tsx index 6417f9d9a..cecf5e81c 100644 --- a/src/core/client/stream/tabs/comments/components/Comment/IndentedComment.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/Comment/IndentedComment.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -12,6 +12,7 @@ it("renders correctly", () => { body: "Woof", createdAt: "1995-12-17T03:24:00.000Z", }; - const wrapper = shallow(<IndentedComment {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<IndentedComment {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/Comment/ReplyButton.spec.tsx b/src/core/client/stream/tabs/comments/components/Comment/ReplyButton.spec.tsx index f1e2a4267..174068018 100644 --- a/src/core/client/stream/tabs/comments/components/Comment/ReplyButton.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/Comment/ReplyButton.spec.tsx @@ -1,6 +1,6 @@ -import { shallow } from "enzyme"; import { noop } from "lodash"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -12,6 +12,7 @@ it("renders correctly", () => { onClick: noop, active: true, }; - const wrapper = shallow(<ReplyButton {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<ReplyButton {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/Comment/ShowConversationLink.spec.tsx b/src/core/client/stream/tabs/comments/components/Comment/ShowConversationLink.spec.tsx index 6ec618b47..4f7ff5670 100644 --- a/src/core/client/stream/tabs/comments/components/Comment/ShowConversationLink.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/Comment/ShowConversationLink.spec.tsx @@ -1,6 +1,6 @@ -import { shallow } from "enzyme"; import { noop } from "lodash"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -12,6 +12,7 @@ it("renders correctly", () => { onClick: noop, href: "http://localhost/comment", }; - const wrapper = shallow(<ShowConversationLink {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<ShowConversationLink {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/CommentsPane.spec.tsx b/src/core/client/stream/tabs/comments/components/CommentsPane.spec.tsx index cd04ecd31..c88d1eb54 100644 --- a/src/core/client/stream/tabs/comments/components/CommentsPane.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/CommentsPane.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -9,14 +9,16 @@ it("renders stream", () => { const props: PropTypesOf<typeof CommentsPane> = { showPermalinkView: false, }; - const wrapper = shallow(<CommentsPane {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<CommentsPane {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders permalink view", () => { const props: PropTypesOf<typeof CommentsPane> = { showPermalinkView: true, }; - const wrapper = shallow(<CommentsPane {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<CommentsPane {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/ConversationThread.spec.tsx b/src/core/client/stream/tabs/comments/components/ConversationThread.spec.tsx index 94a85d86a..f9669c1ab 100644 --- a/src/core/client/stream/tabs/comments/components/ConversationThread.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/ConversationThread.spec.tsx @@ -1,6 +1,6 @@ -import { shallow } from "enzyme"; import { noop } from "lodash"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -27,8 +27,9 @@ describe("with 2 remaining parent comments", () => { username: "parentAuthor", }, }; - const wrapper = shallow(<ConversationThreadN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<ConversationThreadN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders with disabled load more", () => { const props: PropTypesOf<typeof ConversationThreadN> = { @@ -47,8 +48,9 @@ describe("with 2 remaining parent comments", () => { username: "parentAuthor", }, }; - const wrapper = shallow(<ConversationThreadN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<ConversationThreadN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); }); @@ -65,6 +67,7 @@ it("renders with no parent comments", () => { parents: [], rootParent: null, }; - const wrapper = shallow(<ConversationThreadN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<ConversationThreadN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/Indent.spec.tsx b/src/core/client/stream/tabs/comments/components/Indent.spec.tsx index 9654922a7..dc16290e6 100644 --- a/src/core/client/stream/tabs/comments/components/Indent.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/Indent.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -9,8 +9,9 @@ it("renders level0", () => { const props: PropTypesOf<typeof Indent> = { children: <div>Hello World</div>, }; - const wrapper = shallow(<Indent {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<Indent {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders level1", () => { @@ -18,8 +19,9 @@ it("renders level1", () => { level: 1, children: <div>Hello World</div>, }; - const wrapper = shallow(<Indent {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<Indent {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders without border", () => { @@ -28,6 +30,7 @@ it("renders without border", () => { noBorder: true, children: <div>Hello World</div>, }; - const wrapper = shallow(<Indent {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<Indent {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/PermalinkView.spec.tsx b/src/core/client/stream/tabs/comments/components/PermalinkView.spec.tsx index 270d86db7..fac493e6c 100644 --- a/src/core/client/stream/tabs/comments/components/PermalinkView.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/PermalinkView.spec.tsx @@ -1,6 +1,6 @@ -import { shallow } from "enzyme"; import { noop } from "lodash"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -18,8 +18,9 @@ it("renders correctly", () => { showAllCommentsHref: "http://localhost/link", onShowAllComments: noop, }; - const wrapper = shallow(<PermalinkViewN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<PermalinkViewN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders comment not found", () => { @@ -31,6 +32,7 @@ it("renders comment not found", () => { showAllCommentsHref: "http://localhost/link", onShowAllComments: noop, }; - const wrapper = shallow(<PermalinkViewN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<PermalinkViewN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/PostCommentFormFake.spec.tsx b/src/core/client/stream/tabs/comments/components/PostCommentFormFake.spec.tsx index 0721cd6fe..ba8362f0c 100644 --- a/src/core/client/stream/tabs/comments/components/PostCommentFormFake.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/PostCommentFormFake.spec.tsx @@ -1,9 +1,10 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import PostCommentFormFake from "./PostCommentFormFake"; it("renders correctly", () => { - const wrapper = shallow(<PostCommentFormFake />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<PostCommentFormFake />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/PoweredBy.spec.tsx b/src/core/client/stream/tabs/comments/components/PoweredBy.spec.tsx index f31db2f20..8440a759f 100644 --- a/src/core/client/stream/tabs/comments/components/PoweredBy.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/PoweredBy.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -9,6 +9,7 @@ it("renders correctly", () => { const props: PropTypesOf<typeof PoweredBy> = { className: "custom", }; - const wrapper = shallow(<PoweredBy {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<PoweredBy {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/RTE.spec.tsx b/src/core/client/stream/tabs/comments/components/RTE.spec.tsx index 5c083ac34..9503624d9 100644 --- a/src/core/client/stream/tabs/comments/components/RTE.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/RTE.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -11,6 +11,7 @@ it("renders correctly", () => { placeholder: "Post a comment", value: "Hello world", }; - const wrapper = shallow(<RTE {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<RTE {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/ReplyTo.spec.tsx b/src/core/client/stream/tabs/comments/components/ReplyTo.spec.tsx index 44f987cc6..8e278a969 100644 --- a/src/core/client/stream/tabs/comments/components/ReplyTo.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/ReplyTo.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -9,6 +9,7 @@ it("renders correctly", () => { const props: PropTypesOf<typeof ReplyTo> = { username: "ParentAuthor", }; - const wrapper = shallow(<ReplyTo {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<ReplyTo {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/Timeline/Circle.spec.tsx b/src/core/client/stream/tabs/comments/components/Timeline/Circle.spec.tsx index 3f564f88f..884745521 100644 --- a/src/core/client/stream/tabs/comments/components/Timeline/Circle.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/Timeline/Circle.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -11,6 +11,7 @@ it("renders correctly", () => { hollow: true, end: true, }; - const wrapper = shallow(<Circle {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<Circle {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/components/Timeline/Line.spec.tsx b/src/core/client/stream/tabs/comments/components/Timeline/Line.spec.tsx index 0231bb0c8..d7d2565b4 100644 --- a/src/core/client/stream/tabs/comments/components/Timeline/Line.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/Timeline/Line.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -10,6 +10,7 @@ it("renders correctly", () => { className: "root", dotted: true, }; - const wrapper = shallow(<Line {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<Line {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx index 35cf6bc73..2aa194c6e 100644 --- a/src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx +++ b/src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx @@ -1,6 +1,6 @@ -import { shallow } from "enzyme"; import { noop } from "lodash"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { removeFragmentRefs } from "talk-framework/testHelpers"; import { PropTypesOf } from "talk-framework/types"; @@ -44,8 +44,9 @@ it("renders username and body", () => { disableReplies: false, }; - const wrapper = shallow(<CommentContainerN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<CommentContainerN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders body only", () => { @@ -80,8 +81,9 @@ it("renders body only", () => { setCommentID: noop as any, }; - const wrapper = shallow(<CommentContainerN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<CommentContainerN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("hide reply button", () => { @@ -118,8 +120,9 @@ it("hide reply button", () => { disableReplies: true, }; - const wrapper = shallow(<CommentContainerN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<CommentContainerN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("shows conversation link", () => { @@ -158,8 +161,9 @@ it("shows conversation link", () => { showConversationLink: true, }; - const wrapper = shallow(<CommentContainerN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<CommentContainerN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders in reply to", () => { @@ -200,6 +204,7 @@ it("renders in reply to", () => { disableReplies: false, }; - const wrapper = shallow(<CommentContainerN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<CommentContainerN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.spec.tsx index 9042a3866..d5b83a454 100644 --- a/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.spec.tsx +++ b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.spec.tsx @@ -1,6 +1,7 @@ import { shallow } from "enzyme"; import { noop } from "lodash"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import sinon from "sinon"; import { timeout } from "talk-common/utils"; @@ -36,10 +37,10 @@ it("renders correctly", async () => { autofocus: false, }; - const wrapper = shallow(<ReplyCommentFormContainerN {...props} />); + const renderer = createRenderer(); + renderer.render(<ReplyCommentFormContainerN {...props} />); await timeout(); - wrapper.update(); - expect(wrapper).toMatchSnapshot(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders with initialValues", async () => { @@ -66,10 +67,10 @@ it("renders with initialValues", async () => { "Hello World!" ); - const wrapper = shallow(<ReplyCommentFormContainerN {...props} />); + const renderer = createRenderer(); + renderer.render(<ReplyCommentFormContainerN {...props} />); await timeout(); - wrapper.update(); - expect(wrapper).toMatchSnapshot(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("save values", async () => { diff --git a/src/core/client/stream/tabs/comments/queries/PermalinkViewQuery.spec.tsx b/src/core/client/stream/tabs/comments/queries/PermalinkViewQuery.spec.tsx index 3f95a8b6d..7200e2284 100644 --- a/src/core/client/stream/tabs/comments/queries/PermalinkViewQuery.spec.tsx +++ b/src/core/client/stream/tabs/comments/queries/PermalinkViewQuery.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { render } from "./PermalinkViewQuery"; @@ -11,8 +11,9 @@ it("renders permalink view container", () => { } as any, error: null, }; - const wrapper = shallow(React.createElement(() => render(data))); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(React.createElement(() => render(data))); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders loading", () => { @@ -20,8 +21,9 @@ it("renders loading", () => { props: null, error: null, }; - const wrapper = shallow(React.createElement(() => render(data))); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(React.createElement(() => render(data))); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders error", () => { @@ -29,6 +31,7 @@ it("renders error", () => { props: null, error: new Error("error"), }; - const wrapper = shallow(React.createElement(() => render(data))); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(React.createElement(() => render(data))); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/comments/queries/StreamQuery.spec.tsx b/src/core/client/stream/tabs/comments/queries/StreamQuery.spec.tsx index 1d992d4ee..921392c73 100644 --- a/src/core/client/stream/tabs/comments/queries/StreamQuery.spec.tsx +++ b/src/core/client/stream/tabs/comments/queries/StreamQuery.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { render } from "./StreamQuery"; @@ -10,8 +10,9 @@ it("renders stream container", () => { } as any, error: null, }; - const wrapper = shallow(React.createElement(() => render(data))); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(React.createElement(() => render(data))); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders loading", () => { @@ -19,8 +20,9 @@ it("renders loading", () => { props: null, error: null, }; - const wrapper = shallow(React.createElement(() => render(data))); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(React.createElement(() => render(data))); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders error", () => { @@ -28,6 +30,7 @@ it("renders error", () => { props: null, error: new Error("error"), }; - const wrapper = shallow(React.createElement(() => render(data))); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(React.createElement(() => render(data))); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/profile/components/CommentHistory.spec.tsx b/src/core/client/stream/tabs/profile/components/CommentHistory.spec.tsx index 78db55a30..b91e40422 100644 --- a/src/core/client/stream/tabs/profile/components/CommentHistory.spec.tsx +++ b/src/core/client/stream/tabs/profile/components/CommentHistory.spec.tsx @@ -1,6 +1,6 @@ -import { shallow } from "enzyme"; import { noop } from "lodash"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { removeFragmentRefs } from "talk-framework/testHelpers"; import { PropTypesOf } from "talk-framework/types"; @@ -17,8 +17,9 @@ it("renders correctly", () => { hasMore: false, disableLoadMore: false, }; - const wrapper = shallow(<CommentHistoryN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<CommentHistoryN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); describe("has more", () => { @@ -30,8 +31,9 @@ describe("has more", () => { hasMore: true, disableLoadMore: false, }; - const wrapper = shallow(<CommentHistoryN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<CommentHistoryN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("disables load more", () => { const props: PropTypesOf<typeof CommentHistoryN> = { @@ -41,7 +43,8 @@ describe("has more", () => { hasMore: true, disableLoadMore: true, }; - const wrapper = shallow(<CommentHistoryN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<CommentHistoryN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); }); diff --git a/src/core/client/stream/tabs/profile/components/HistoryComment.spec.tsx b/src/core/client/stream/tabs/profile/components/HistoryComment.spec.tsx index 4bf0c7b9d..f525e88f3 100644 --- a/src/core/client/stream/tabs/profile/components/HistoryComment.spec.tsx +++ b/src/core/client/stream/tabs/profile/components/HistoryComment.spec.tsx @@ -1,6 +1,6 @@ -import { shallow } from "enzyme"; import { noop } from "lodash"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { removeFragmentRefs } from "talk-framework/testHelpers"; import { PropTypesOf } from "talk-framework/types"; @@ -22,6 +22,7 @@ it("renders correctly", () => { conversationURL: "http://localhost/conversation", onGotoConversation: noop, }; - const wrapper = shallow(<HistoryCommentN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<HistoryCommentN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/profile/components/Profile.spec.tsx b/src/core/client/stream/tabs/profile/components/Profile.spec.tsx index f9e3ab0cb..e4df8a664 100644 --- a/src/core/client/stream/tabs/profile/components/Profile.spec.tsx +++ b/src/core/client/stream/tabs/profile/components/Profile.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { removeFragmentRefs } from "talk-framework/testHelpers"; import { PropTypesOf } from "talk-framework/types"; @@ -14,6 +14,7 @@ it("renders correctly", () => { me: {}, settings: {}, }; - const wrapper = shallow(<ProfileN {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<ProfileN {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/stream/tabs/profile/components/__snapshots__/CommentHistory.spec.tsx.snap b/src/core/client/stream/tabs/profile/components/__snapshots__/CommentHistory.spec.tsx.snap index 6eae11185..1e9a31d87 100644 --- a/src/core/client/stream/tabs/profile/components/__snapshots__/CommentHistory.spec.tsx.snap +++ b/src/core/client/stream/tabs/profile/components/__snapshots__/CommentHistory.spec.tsx.snap @@ -19,7 +19,6 @@ exports[`has more disables load more 1`] = ` "id": "comment-1", } } - key="comment-1" story={Object {}} /> <withContext(createMutationContainer(Relay(HistoryCommentContainer))) @@ -28,7 +27,6 @@ exports[`has more disables load more 1`] = ` "id": "comment-2", } } - key="comment-2" story={Object {}} /> <Localized @@ -67,7 +65,6 @@ exports[`has more renders correctly 1`] = ` "id": "comment-1", } } - key="comment-1" story={Object {}} /> <withContext(createMutationContainer(Relay(HistoryCommentContainer))) @@ -76,7 +73,6 @@ exports[`has more renders correctly 1`] = ` "id": "comment-2", } } - key="comment-2" story={Object {}} /> <Localized @@ -115,7 +111,6 @@ exports[`renders correctly 1`] = ` "id": "comment-1", } } - key="comment-1" story={Object {}} /> <withContext(createMutationContainer(Relay(HistoryCommentContainer))) @@ -124,7 +119,6 @@ exports[`renders correctly 1`] = ` "id": "comment-2", } } - key="comment-2" story={Object {}} /> </withPropsOnChange(HorizontalGutter)> diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index 634f3c200..805b848cd 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -12,20 +12,38 @@ export const settings = { facebook: { enabled: false, allowRegistration: true, + targetFilter: { + stream: true, + }, }, google: { enabled: false, allowRegistration: true, + targetFilter: { + stream: true, + }, + }, + oidc: { + enabled: false, + allowRegistration: true, + targetFilter: { + stream: true, + }, }, sso: { enabled: false, allowRegistration: true, + targetFilter: { + stream: true, + }, }, local: { enabled: true, allowRegistration: true, + targetFilter: { + stream: true, + }, }, - oidc: [], }, }, reaction: { diff --git a/src/core/client/ui/components/AppBar/AppBar.spec.tsx b/src/core/client/ui/components/AppBar/AppBar.spec.tsx index a5ae0c527..aa0926860 100644 --- a/src/core/client/ui/components/AppBar/AppBar.spec.tsx +++ b/src/core/client/ui/components/AppBar/AppBar.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -12,6 +12,7 @@ it("renders correctly", () => { gutterEnd: true, className: "custom", }; - const wrapper = shallow(<AppBar {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<AppBar {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/ui/components/AppBar/Begin.spec.tsx b/src/core/client/ui/components/AppBar/Begin.spec.tsx index caec225c2..1fb4c94dd 100644 --- a/src/core/client/ui/components/AppBar/Begin.spec.tsx +++ b/src/core/client/ui/components/AppBar/Begin.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -11,6 +11,7 @@ it("renders correctly", () => { className: "custom", itemGutter: true, }; - const wrapper = shallow(<End {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<End {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/ui/components/AppBar/Divider.spec.tsx b/src/core/client/ui/components/AppBar/Divider.spec.tsx index 3f065a18d..0489e8451 100644 --- a/src/core/client/ui/components/AppBar/Divider.spec.tsx +++ b/src/core/client/ui/components/AppBar/Divider.spec.tsx @@ -1,9 +1,10 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import Divider from "./Divider"; it("renders correctly", () => { - const wrapper = shallow(<Divider />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<Divider />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/ui/components/AppBar/End.spec.tsx b/src/core/client/ui/components/AppBar/End.spec.tsx index caec225c2..1fb4c94dd 100644 --- a/src/core/client/ui/components/AppBar/End.spec.tsx +++ b/src/core/client/ui/components/AppBar/End.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -11,6 +11,7 @@ it("renders correctly", () => { className: "custom", itemGutter: true, }; - const wrapper = shallow(<End {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<End {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/ui/components/AppBar/Navigation.spec.tsx b/src/core/client/ui/components/AppBar/Navigation.spec.tsx index 350771ed3..ec9bda2fe 100644 --- a/src/core/client/ui/components/AppBar/Navigation.spec.tsx +++ b/src/core/client/ui/components/AppBar/Navigation.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -9,6 +9,7 @@ it("renders correctly", () => { const props: PropTypesOf<typeof Navigation> = { children: "children", }; - const wrapper = shallow(<Navigation {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<Navigation {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/ui/components/AppBar/NavigationItem.css b/src/core/client/ui/components/AppBar/NavigationItem.css index d2044dd46..d291397a2 100644 --- a/src/core/client/ui/components/AppBar/NavigationItem.css +++ b/src/core/client/ui/components/AppBar/NavigationItem.css @@ -17,7 +17,7 @@ .active { composes: navItemActive from "talk-ui/shared/typography.css"; - background-color: var(--palette-brand); + background-color: var(--palette-brand-main); text-decoration: none; color: var(--palette-text-light); } diff --git a/src/core/client/ui/components/AppBar/NavigationItem.spec.tsx b/src/core/client/ui/components/AppBar/NavigationItem.spec.tsx index ee12b315c..13c5158bc 100644 --- a/src/core/client/ui/components/AppBar/NavigationItem.spec.tsx +++ b/src/core/client/ui/components/AppBar/NavigationItem.spec.tsx @@ -1,6 +1,6 @@ -import { shallow } from "enzyme"; import { noop } from "lodash"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -14,6 +14,7 @@ it("renders correctly", () => { active: true, className: "custom", }; - const wrapper = shallow(<NavigationItem {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<NavigationItem {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/ui/components/Brand/BrandIcon.spec.tsx b/src/core/client/ui/components/Brand/BrandIcon.spec.tsx index 26993b470..352b5e97d 100644 --- a/src/core/client/ui/components/Brand/BrandIcon.spec.tsx +++ b/src/core/client/ui/components/Brand/BrandIcon.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -10,6 +10,7 @@ it("renders correctly", () => { className: "custom", size: "lg", }; - const wrapper = shallow(<BrandIcon {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<BrandIcon {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/ui/components/Brand/BrandName.css b/src/core/client/ui/components/Brand/BrandName.css index cf0fc4610..13fcd972a 100644 --- a/src/core/client/ui/components/Brand/BrandName.css +++ b/src/core/client/ui/components/Brand/BrandName.css @@ -1,7 +1,7 @@ .root { font-weight: var(--font-weight-bold); font-family: var(--font-family-sans-serif); - color: var(--palette-brand); + color: var(--palette-brand-main); margin: 0; } diff --git a/src/core/client/ui/components/Brand/BrandName.spec.tsx b/src/core/client/ui/components/Brand/BrandName.spec.tsx index 3f38c5b3e..cb2cf660f 100644 --- a/src/core/client/ui/components/Brand/BrandName.spec.tsx +++ b/src/core/client/ui/components/Brand/BrandName.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -11,6 +11,7 @@ it("renders correctly", () => { className: "custom", size: "lg", }; - const wrapper = shallow(<BrandName {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<BrandName {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/ui/components/Brand/Logo.spec.tsx b/src/core/client/ui/components/Brand/Logo.spec.tsx index 74fec6784..49b378f1d 100644 --- a/src/core/client/ui/components/Brand/Logo.spec.tsx +++ b/src/core/client/ui/components/Brand/Logo.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -9,6 +9,7 @@ it("renders correctly", () => { const props: PropTypesOf<typeof Logo> = { className: "custom", }; - const wrapper = shallow(<Logo {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<Logo {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/ui/components/Button/Button.css b/src/core/client/ui/components/Button/Button.css index 8aad930af..5ddfe456a 100644 --- a/src/core/client/ui/components/Button/Button.css +++ b/src/core/client/ui/components/Button/Button.css @@ -24,7 +24,7 @@ } .fullWidth { - display: block; + display: flex; width: 100%; box-sizing: border-box; } @@ -57,6 +57,9 @@ &.colorSuccess { color: var(--palette-success-main); } + &.colorBrand { + color: var(--palette-brand-main); + } &:not(.disabled) { &.colorRegular { @@ -95,6 +98,15 @@ color: var(--palette-success-lighter); } } + &.colorBrand { + &.mouseHover { + color: var(--palette-brand-light); + } + &:active, + &.active { + color: var(--palette-brand-lighter); + } + } } } @@ -112,6 +124,9 @@ &.colorSuccess { background-color: var(--palette-success-main); } + &.colorBrand { + background-color: var(--palette-brand-main); + } &:not(.disabled) { &.colorRegular { @@ -150,6 +165,15 @@ background-color: var(--palette-success-lighter); } } + &.colorBrand { + &.mouseHover { + background-color: var(--palette-brand-light); + } + &:active, + &.active { + background-color: var(--palette-brand-lighter); + } + } } } @@ -171,6 +195,10 @@ color: var(--palette-success-main); border: 1px solid currentColor; } + &.colorBrand { + color: var(--palette-brand-main); + border: 1px solid currentColor; + } &:not(.disabled) { &.colorRegular { @@ -217,6 +245,17 @@ border: 1px solid currentColor; } } + &.colorBrand { + &.mouseHover { + color: var(--palette-brand-light); + border: 1px solid currentColor; + } + &:active, + &.active { + color: var(--palette-brand-lighter); + border: 1px solid currentColor; + } + } } } @@ -234,6 +273,9 @@ &.colorSuccess { color: var(--palette-success-main); } + &.colorBrand { + color: var(--palette-brand-main); + } &:not(.disabled) { &.colorRegular { @@ -280,6 +322,17 @@ background-color: var(--palette-success-main); } } + &.colorBrand { + &.mouseHover { + border: 1px solid currentColor; + } + &:active, + &.active { + border: 1px solid currentColor; + color: var(--palette-text-light); + background-color: var(--palette-brand-main); + } + } } } @@ -302,6 +355,9 @@ &.colorSuccess { color: var(--palette-success-main); } + &.colorBrand { + color: var(--palette-brand-main); + } &:not(.disabled) { &.colorRegular { @@ -340,5 +396,14 @@ color: var(--palette-success-lighter); } } + &.colorBrand { + &.mouseHover { + color: var(--palette-brand-light); + } + &:active, + &.active { + color: var(--palette-brand-lighter); + } + } } } diff --git a/src/core/client/ui/components/Button/Button.tsx b/src/core/client/ui/components/Button/Button.tsx index a825aad8d..dfd6b5d02 100644 --- a/src/core/client/ui/components/Button/Button.tsx +++ b/src/core/client/ui/components/Button/Button.tsx @@ -26,7 +26,7 @@ interface InnerProps extends BaseButtonProps { size?: "small" | "regular" | "large"; /** Color of the button */ - color?: "regular" | "primary" | "error" | "success"; + color?: "regular" | "primary" | "error" | "success" | "brand"; /** Variant of the button */ variant?: "regular" | "filled" | "outlined" | "ghost" | "underlined"; @@ -72,6 +72,7 @@ export class Button extends React.Component<InnerProps> { [classes.colorPrimary]: color === "primary", [classes.colorError]: color === "error", [classes.colorSuccess]: color === "success", + [classes.colorBrand]: color === "brand", [classes.variantRegular]: variant === "regular", [classes.variantFilled]: variant === "filled", [classes.variantOutlined]: variant === "outlined", diff --git a/src/core/client/ui/components/Counter/Counter.spec.tsx b/src/core/client/ui/components/Counter/Counter.spec.tsx index d8e963b97..a1747e753 100644 --- a/src/core/client/ui/components/Counter/Counter.spec.tsx +++ b/src/core/client/ui/components/Counter/Counter.spec.tsx @@ -1,9 +1,10 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import Counter from "./Counter"; it("renders correctly", () => { - const wrapper = shallow(<Counter>20</Counter>); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<Counter>20</Counter>); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/ui/components/InputLabel/InputLabel.tsx b/src/core/client/ui/components/InputLabel/InputLabel.tsx index a13832b51..d2fb2ad83 100644 --- a/src/core/client/ui/components/InputLabel/InputLabel.tsx +++ b/src/core/client/ui/components/InputLabel/InputLabel.tsx @@ -7,6 +7,8 @@ import Typography from "../Typography"; import styles from "./InputLabel.css"; export interface InputLabelProps { + id?: string; + htmlFor?: string; /** * The content of the component. */ diff --git a/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx b/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx index 1b4ded65e..b711e0908 100644 --- a/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx +++ b/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx @@ -1,6 +1,7 @@ -import { mount, shallow } from "enzyme"; +import { mount } from "enzyme"; import React from "react"; import { MediaQueryMatchers } from "react-responsive"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-ui/types"; @@ -15,8 +16,9 @@ it("renders correctly", () => { screen: true, children: <div>Hello World</div>, }; - const wrapper = shallow(<MatchMedia {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<MatchMedia {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("renders less than and great than correctly", () => { @@ -25,8 +27,9 @@ it("renders less than and great than correctly", () => { gtWidth: "sm", children: <div>Hello World</div>, }; - const wrapper = shallow(<MatchMedia {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<MatchMedia {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("map new speech prop to older aural prop", () => { @@ -34,8 +37,9 @@ it("map new speech prop to older aural prop", () => { speech: true, children: <div>Hello World</div>, }; - const wrapper = shallow(<MatchMedia {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<MatchMedia {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); it("should get mediaQueryValues from context", () => { diff --git a/src/core/client/ui/components/PasswordField/PasswordField.css b/src/core/client/ui/components/PasswordField/PasswordField.css new file mode 100644 index 000000000..7c2c5edb9 --- /dev/null +++ b/src/core/client/ui/components/PasswordField/PasswordField.css @@ -0,0 +1,60 @@ +.root { + width: calc(29 * var(--spacing-unit)); + height: 36px; + width: 100%; + line-height: 36px; + box-sizing: border-box; +} + +.colorRegular { + background-color: var(--palette-common-white); + color: var(--palette-common-black); + border: 1px solid var(--palette-grey-light); +} + +.colorError { + background-color: var(--palette-common-white); + border-color: var(--palette-error-main); + border: 2px solid var(--palette-error-darkest); +} + +.fullWidth { + width: 100%; +} + +.wrapper { + position: relative; + display: inline-flex; + align-items: center; + width: 100%; + height: 100%; + box-sizing: border-box; +} + +.icon { + position: absolute; + display: inline-block; + right: 0px; + padding: 4px calc(1 * var(--spacing-unit)); + cursor: pointer; + line-height: 0; +} + +.input { + composes: inputText placeholderPseudo from "talk-ui/shared/typography.css"; + display: block; + padding: calc(0.5 * var(--spacing-unit)) calc(3 * var(--spacing-unit)) + calc(0.5 * var(--spacing-unit)) calc(0.5 * var(--spacing-unit)); + border-radius: var(--round-corners); + width: 100%; + height: 100%; + box-sizing: border-box; + + &:read-only { + background-color: var(--palette-grey-lightest); + } + &:disabled { + color: var(--palette-text-secondary); + background-color: var(--palette-grey-lightest); + } +} diff --git a/src/core/client/ui/components/PasswordField/PasswordField.mdx b/src/core/client/ui/components/PasswordField/PasswordField.mdx new file mode 100644 index 000000000..965d2dd97 --- /dev/null +++ b/src/core/client/ui/components/PasswordField/PasswordField.mdx @@ -0,0 +1,25 @@ +--- +name: PasswordField +menu: UI Kit +--- + +import { Playground, PropsTable } from "docz"; +import PasswordField from "./PasswordField.tsx"; +import HorizontalGutter from "../HorizontalGutter"; + +# PasswordField + +## Basic Use + +<Playground> + <HorizontalGutter> + <PasswordField placeholder="This is a placeholder" /> + <PasswordField defaultValue="This is an input field" /> + <PasswordField color="error" defaultValue="A PasswordField with an error" /> + <PasswordField + color="error" + defaultValue="A PasswordField with an error" + fullWidth + /> + </HorizontalGutter> +</Playground> diff --git a/src/core/client/ui/components/PasswordField/PasswordField.spec.tsx b/src/core/client/ui/components/PasswordField/PasswordField.spec.tsx new file mode 100644 index 000000000..f2a8d87f0 --- /dev/null +++ b/src/core/client/ui/components/PasswordField/PasswordField.spec.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import TestRenderer from "react-test-renderer"; + +import { PropTypesOf } from "talk-ui/types"; + +import PasswordField from "./PasswordField"; + +it("renders correctly", () => { + const props: PropTypesOf<typeof PasswordField> = { + className: "custom", + defaultValue: "Hello World", + }; + const renderer = TestRenderer.create(<PasswordField {...props} />); + expect(renderer.toJSON()).toMatchSnapshot(); + + renderer.root.findByProps({ role: "button" }).props.onClick(); + expect(renderer.toJSON()).toMatchSnapshot(); +}); diff --git a/src/core/client/ui/components/PasswordField/PasswordField.tsx b/src/core/client/ui/components/PasswordField/PasswordField.tsx new file mode 100644 index 000000000..a23d54ed6 --- /dev/null +++ b/src/core/client/ui/components/PasswordField/PasswordField.tsx @@ -0,0 +1,148 @@ +import cn from "classnames"; +import React, { + AllHTMLAttributes, + ChangeEvent, + Component, + EventHandler, + MouseEvent, +} from "react"; +import { withStyles } from "talk-ui/hocs"; + +import Icon from "../Icon"; +import styles from "./PasswordField.css"; + +export interface PasswordFieldProps { + id?: string; + /** + * The content value of the component. + */ + defaultValue?: string; + /** + * The content value of the component. + */ + value?: string; + /** + * Convenient prop to override the root styling. + */ + className?: string; + /** + * Override or extend the styles applied to the component. + */ + classes: typeof styles; + /** + * Color of the PasswordField + */ + color?: "regular" | "error"; + /* + * If set renders a full width button + */ + fullWidth?: boolean; + /** + * Placeholder + */ + placeholder?: string; + /** + * Mark as readonly + */ + readOnly?: boolean; + /** + * Name + */ + name?: string; + /** + * onChange + */ + onChange?: EventHandler<ChangeEvent<HTMLInputElement>>; + + disabled?: boolean; + + autoComplete?: AllHTMLAttributes<HTMLInputElement>["autoComplete"]; + autoCorrect?: AllHTMLAttributes<HTMLInputElement>["autoCorrect"]; + autoCapitalize?: AllHTMLAttributes<HTMLInputElement>["autoCapitalize"]; + spellCheck?: AllHTMLAttributes<HTMLInputElement>["spellCheck"]; + + showPasswordTitle?: string; + hidePasswordTitle?: string; +} + +interface State { + reveal: boolean; +} + +class PasswordField extends Component<PasswordFieldProps, State> { + public static defaultProps: Partial<PasswordFieldProps> = { + color: "regular", + placeholder: "", + showPasswordTitle: "Hide password", + hidePasswordTitle: "Show password", + }; + + public state = { + reveal: false, + }; + + private handleToggleVisibility = (e: MouseEvent) => { + this.setState(state => ({ + reveal: !state.reveal, + })); + }; + + public render() { + const { + className, + classes, + color, + fullWidth, + value, + placeholder, + hidePasswordTitle, + showPasswordTitle, + ...rest + } = this.props; + + const rootClassName = cn( + { + [classes.fullWidth]: fullWidth, + }, + classes.root, + className + ); + + const inputClassName = cn( + { + [classes.colorRegular]: color === "regular", + [classes.colorError]: color === "error", + [classes.fullWidth]: fullWidth, + }, + classes.input + ); + + const reveal = this.state.reveal; + + return ( + <div className={rootClassName}> + <div className={classes.wrapper}> + <input + className={inputClassName} + placeholder={placeholder} + value={value} + type={reveal ? "text" : "password"} + {...rest} + /> + <div + role="button" + className={styles.icon} + title={reveal ? hidePasswordTitle : showPasswordTitle} + onClick={this.handleToggleVisibility} + tabIndex={0} + > + <Icon>{reveal ? "visibility_off" : "visibility"}</Icon> + </div> + </div> + </div> + ); + } +} + +const enhanced = withStyles(styles)(PasswordField); +export default enhanced; diff --git a/src/core/client/ui/components/PasswordField/__snapshots__/PasswordField.spec.tsx.snap b/src/core/client/ui/components/PasswordField/__snapshots__/PasswordField.spec.tsx.snap new file mode 100644 index 000000000..7120c761e --- /dev/null +++ b/src/core/client/ui/components/PasswordField/__snapshots__/PasswordField.spec.tsx.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` +<div + className="PasswordField-root custom" +> + <div + className="PasswordField-wrapper" + > + <input + className="PasswordField-colorRegular PasswordField-input" + defaultValue="Hello World" + placeholder="" + type="password" + /> + <div + className="PasswordField-icon" + onClick={[Function]} + role="button" + tabIndex={0} + title="Hide password" + > + <span + aria-hidden="true" + className="Icon-root Icon-sm" + > + visibility + </span> + </div> + </div> +</div> +`; + +exports[`renders correctly 2`] = ` +<div + className="PasswordField-root custom" +> + <div + className="PasswordField-wrapper" + > + <input + className="PasswordField-colorRegular PasswordField-input" + defaultValue="Hello World" + placeholder="" + type="text" + /> + <div + className="PasswordField-icon" + onClick={[Function]} + role="button" + tabIndex={0} + title="Show password" + > + <span + aria-hidden="true" + className="Icon-root Icon-sm" + > + visibility_off + </span> + </div> + </div> +</div> +`; diff --git a/src/core/client/ui/components/PasswordField/index.ts b/src/core/client/ui/components/PasswordField/index.ts new file mode 100644 index 000000000..7cb96a599 --- /dev/null +++ b/src/core/client/ui/components/PasswordField/index.ts @@ -0,0 +1,2 @@ +export * from "./PasswordField"; +export { default } from "./PasswordField"; diff --git a/src/core/client/ui/components/SubBar/Navigation.spec.tsx b/src/core/client/ui/components/SubBar/Navigation.spec.tsx index 350771ed3..ec9bda2fe 100644 --- a/src/core/client/ui/components/SubBar/Navigation.spec.tsx +++ b/src/core/client/ui/components/SubBar/Navigation.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -9,6 +9,7 @@ it("renders correctly", () => { const props: PropTypesOf<typeof Navigation> = { children: "children", }; - const wrapper = shallow(<Navigation {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<Navigation {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/ui/components/SubBar/NavigationItem.spec.tsx b/src/core/client/ui/components/SubBar/NavigationItem.spec.tsx index ee12b315c..13c5158bc 100644 --- a/src/core/client/ui/components/SubBar/NavigationItem.spec.tsx +++ b/src/core/client/ui/components/SubBar/NavigationItem.spec.tsx @@ -1,6 +1,6 @@ -import { shallow } from "enzyme"; import { noop } from "lodash"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -14,6 +14,7 @@ it("renders correctly", () => { active: true, className: "custom", }; - const wrapper = shallow(<NavigationItem {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<NavigationItem {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/ui/components/SubBar/SubBar.spec.tsx b/src/core/client/ui/components/SubBar/SubBar.spec.tsx index 2f908fc5c..a6860b250 100644 --- a/src/core/client/ui/components/SubBar/SubBar.spec.tsx +++ b/src/core/client/ui/components/SubBar/SubBar.spec.tsx @@ -1,5 +1,5 @@ -import { shallow } from "enzyme"; import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; import { PropTypesOf } from "talk-framework/types"; @@ -12,6 +12,7 @@ it("renders correctly", () => { gutterEnd: true, className: "custom", }; - const wrapper = shallow(<SubBar {...props} />); - expect(wrapper).toMatchSnapshot(); + const renderer = createRenderer(); + renderer.render(<SubBar {...props} />); + expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/ui/components/index.ts b/src/core/client/ui/components/index.ts index 01af92f25..6818fb653 100644 --- a/src/core/client/ui/components/index.ts +++ b/src/core/client/ui/components/index.ts @@ -45,3 +45,4 @@ export { BrandIcon, BrandName, Logo } from "./Brand"; export { default as Counter } from "./Counter"; export { Marker, Count as MarkerCount } from "./Marker"; export { default as Card } from "./Card"; +export { default as PasswordField } from "./PasswordField"; diff --git a/src/core/client/ui/theme/variables.ts b/src/core/client/ui/theme/variables.ts index 2f31a357f..cb45811ac 100644 --- a/src/core/client/ui/theme/variables.ts +++ b/src/core/client/ui/theme/variables.ts @@ -61,8 +61,12 @@ const variables = { }, /* Divider */ divider: "rgba(0, 0, 0, 0.12)", - brand: "#F77160", highlight: "#FFD863", + brand: { + main: "#f77160", + light: "#f97f70", + lighter: "#fc9e92", + }, }, /* gitter and spacing */ spacingUnitSmall: 5, diff --git a/src/core/common/graphql/loadSchema.ts b/src/core/common/graphql/loadSchema.ts index 28d5899e5..97eba100e 100644 --- a/src/core/common/graphql/loadSchema.ts +++ b/src/core/common/graphql/loadSchema.ts @@ -1,7 +1,15 @@ import { getGraphQLProjectConfig } from "graphql-config"; -import { addResolveFunctionsToSchema, IResolvers } from "graphql-tools"; +import { + addResolveFunctionsToSchema, + IResolvers, + IResolverValidationOptions, +} from "graphql-tools"; -export default function loadSchema(projectName: string, resolvers: IResolvers) { +export default function loadSchema( + projectName: string, + resolvers: IResolvers, + resolverValidationOptions?: IResolverValidationOptions +) { // Load the configuration from the provided `.graphqlconfig` file. const config = getGraphQLProjectConfig(__dirname, projectName); @@ -9,7 +17,7 @@ export default function loadSchema(projectName: string, resolvers: IResolvers) { const schema = config.getSchema(); // Attach the resolvers to the schema. - addResolveFunctionsToSchema({ schema, resolvers }); + addResolveFunctionsToSchema({ schema, resolvers, resolverValidationOptions }); return schema; } diff --git a/src/core/server/app/handlers/api/tenant/auth/local.ts b/src/core/server/app/handlers/api/tenant/auth/local.ts index 94f8fc7f9..4b497253b 100644 --- a/src/core/server/app/handlers/api/tenant/auth/local.ts +++ b/src/core/server/app/handlers/api/tenant/auth/local.ts @@ -67,13 +67,13 @@ export const signupHandler = (options: SignupOptions): RequestHandler => async ( const profile: LocalProfile = { id: email, type: "local", + password, }; // Create the new user. const user = await upsert(options.db, tenant, { email, username, - password, profiles: [profile], // New users signing up via local auth will have the commenter role to // start with. diff --git a/src/core/server/app/handlers/api/tenant/install.ts b/src/core/server/app/handlers/api/tenant/install.ts index b9e3cf629..cf31fc6dc 100644 --- a/src/core/server/app/handlers/api/tenant/install.ts +++ b/src/core/server/app/handlers/api/tenant/install.ts @@ -14,7 +14,7 @@ import { Request } from "talk-server/types/express"; export interface TenantInstallBody { tenant: Omit<InstallTenant, "domain">; - user: Required<Pick<UpsertUser, "username" | "email" | "password">>; + user: Required<Pick<UpsertUser, "username" | "email"> & { password: string }>; } const TenantInstallBodySchema = Joi.object().keys({ @@ -79,15 +79,15 @@ export const tenantInstallHandler = ({ // Configure with profile. const profile: LocalProfile = { - id: email, type: "local", + id: email, + password, }; // Create the first admin user. await upsert(mongo, tenant, { email, username, - password, profiles: [profile], role: GQLUSER_ROLE.ADMIN, }); diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 4cca7769f..3db1bc8f6 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -137,6 +137,90 @@ export async function handleSuccessfulLogin( } } +export async function handleOAuth2Callback( + user: User, + signingConfig: JWTSigningConfig, + req: Request, + res: Response, + next: NextFunction +) { + try { + // Talk is guaranteed at this point. + const { tenant } = req.talk!; + + const options: SigningTokenOptions = {}; + + if (tenant) { + // Attach the tenant's id to the issued token as a `iss` claim. + options.issuer = tenant.id; + } + + // Grab the token. + const token = await signTokenString(signingConfig, user, options); + + // Set the cache control headers. + res.header("Cache-Control", "private, no-cache, no-store, must-revalidate"); + res.header("Expires", "-1"); + res.header("Pragma", "no-cache"); + + // Send back the details! + res.send( + `<html> + <head></head> + <body> + <script type="text/javascript"> + const redirect = sessionStorage.getItem("authRedirectBackTo"); + if (!redirect) { + var textnode = document.createTextNode("'authRedirectBackTo' not set in Session Storage"); + document.body.appendChild(textnode); + } + else if (redirect[0] !== '/') { + var textnode = document.createTextNode("'authRedirectBackTo' must begin with '/'"); + document.body.appendChild(textnode); + } + else { + location.href = \`\${redirect}#${token}\`; + } + </script> + </body> + </html>` + ); + } catch (err) { + return next(err); + } +} + +/** + * wrapCallbackAuthn will wrap a authenticators authenticate method with one that + * will render a redirect script for a valid login by a compatible strategy. + * + * @param authenticator the base authenticator instance + * @param signingConfig used to sign the tokens that are issued. + * @param name the name of the authenticator to use + * @param options any options to be passed to the authenticate call + */ +export const wrapOAuth2Authn = ( + authenticator: passport.Authenticator, + signingConfig: JWTSigningConfig, + name: string, + options?: any +): RequestHandler => (req: Request, res, next) => + authenticator.authenticate( + name, + { ...options, session: false }, + (err: Error | null, user: User | null) => { + if (err) { + return next(err); + } + if (!user) { + // TODO: (wyattjoh) replace with better error. + return next(new Error("no user on request")); + } + + handleOAuth2Callback(user, signingConfig, req, res, next); + } + )(req, res, next); + /** * wrapAuthn will wrap a authenticators authenticate method with one that * will return a valid login token for a valid login by a compatible strategy. diff --git a/src/core/server/app/middleware/passport/strategies/facebook.ts b/src/core/server/app/middleware/passport/strategies/facebook.ts index 0034b345a..e79a6ff24 100644 --- a/src/core/server/app/middleware/passport/strategies/facebook.ts +++ b/src/core/server/app/middleware/passport/strategies/facebook.ts @@ -67,7 +67,6 @@ export default class FacebookStrategy extends OAuth2Strategy< } user = await upsert(this.mongo, tenant, { - username: null, displayName, role: GQLUSER_ROLE.COMMENTER, email, diff --git a/src/core/server/app/middleware/passport/strategies/google.ts b/src/core/server/app/middleware/passport/strategies/google.ts index bc46c754c..bac03a4af 100644 --- a/src/core/server/app/middleware/passport/strategies/google.ts +++ b/src/core/server/app/middleware/passport/strategies/google.ts @@ -80,7 +80,6 @@ export default class GoogleStrategy extends OAuth2Strategy< } user = await upsert(this.mongo, tenant, { - username: null, displayName, role: GQLUSER_ROLE.COMMENTER, email, diff --git a/src/core/server/app/middleware/passport/strategies/oauth2.ts b/src/core/server/app/middleware/passport/strategies/oauth2.ts index e47d985b9..e12031f99 100644 --- a/src/core/server/app/middleware/passport/strategies/oauth2.ts +++ b/src/core/server/app/middleware/passport/strategies/oauth2.ts @@ -4,7 +4,7 @@ import { Strategy } from "passport-strategy"; import { Profile } from "passport"; import { VerifyCallback } from "passport-oauth2"; import { Config } from "talk-server/config"; -import { GQLAuthIntegrations } from "talk-server/graph/tenant/schema/__generated__/types"; +import { AuthIntegrations } from "talk-server/models/settings"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; import TenantCache from "talk-server/services/tenant/cache"; @@ -42,7 +42,7 @@ export default abstract class OAuth2Strategy< this.scope = scope; } - protected abstract getIntegration(integrations: GQLAuthIntegrations): T; + protected abstract getIntegration(integrations: AuthIntegrations): T; protected abstract createStrategy( tenant: Tenant, diff --git a/src/core/server/app/middleware/passport/strategies/oidc/index.ts b/src/core/server/app/middleware/passport/strategies/oidc/index.ts index 4f531478c..181e5f76a 100644 --- a/src/core/server/app/middleware/passport/strategies/oidc/index.ts +++ b/src/core/server/app/middleware/passport/strategies/oidc/index.ts @@ -39,13 +39,10 @@ export interface OIDCIDToken { nickname?: string; } -export type StrategyItem = Record< - string, - { - strategy: OAuth2Strategy; - jwksClient?: JwksClient; - } ->; +export interface StrategyItem { + strategy: OAuth2Strategy; + jwksClient?: JwksClient; +} export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken { if ( @@ -88,24 +85,10 @@ const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => ( }; function getEnabledIntegration( - tenant: Tenant, - oidcID: string + tenant: Tenant ): Required<GQLOIDCAuthIntegration> { - if (!tenant.auth.integrations.oidc) { - // TODO: return a better error. - throw new Error("integration not found"); - } - - // Grab the OIDC Integration from the list of integrations. - const integration = tenant.auth.integrations.oidc.find( - ({ id }) => id === oidcID - ); - if (!integration) { - // TODO: return a better error. - throw new Error("integration not found"); - } - - // Handle when the integration is enabled/disabled. + // Grab the OIDC Integration. + const integration = tenant.auth.integrations.oidc; if (!integration.enabled) { // TODO: return a better error. throw new Error("integration not enabled"); @@ -181,7 +164,6 @@ export async function findOrCreateOIDCUser( // Create the new user, as one didn't exist before! user = await upsert(db, tenant, { - username: null, displayName, role: GQLUSER_ROLE.COMMENTER, email, @@ -224,24 +206,19 @@ export default class OIDCStrategy extends Strategy { tenantID: string, oidc: Required<GQLOIDCAuthIntegration> ): jwks.JwksClient { - let tenantIntegrations = this.cache.get(tenantID); - if (!tenantIntegrations || !tenantIntegrations[oidc.id]) { + let tenantIntegration = this.cache.get(tenantID); + if (!tenantIntegration) { const strategy = this.createStrategy(req, oidc); // Create the entry. - tenantIntegrations = { - [oidc.id]: { - strategy, - }, - ...(tenantIntegrations || {}), + tenantIntegration = { + strategy, }; // We don't reset the entry in the cache here because if we just created // it, we'll be creating the jwksClient anyways, so we'll update it there. } - const tenantIntegration = tenantIntegrations[oidc.id]; - if (!tenantIntegration.jwksClient) { // Create the new JWKS client. const jwksClient = jwks({ @@ -252,13 +229,7 @@ export default class OIDCStrategy extends Strategy { tenantIntegration.jwksClient = jwksClient; // Update the cached entry. - this.cache.set(tenantID, { - [oidc.id]: { - ...tenantIntegration, - jwksClient, - }, - ...tenantIntegrations, - }); + this.cache.set(tenantID, tenantIntegration); } return tenantIntegration.jwksClient; @@ -287,14 +258,11 @@ export default class OIDCStrategy extends Strategy { return done(new Error("tenant not found")); } - // Grab the OIDC ID from the request. - const { oidcID }: { oidcID: string } = req.params; - // Get the integration from the tenant. If needed, it will be used to create // a new strategy. let integration: Required<GQLOIDCAuthIntegration>; try { - integration = getEnabledIntegration(tenant, oidcID); + integration = getEnabledIntegration(tenant); } catch (err) { // TODO: wrap error? return done(err); @@ -338,10 +306,7 @@ export default class OIDCStrategy extends Strategy { const { clientID, clientSecret, authorizationURL, tokenURL } = integration; // Construct the callbackURL from the request. - const callbackURL = reconstructURL( - req, - `/api/tenant/auth/oidc/${integration.id}/callback` - ); + const callbackURL = reconstructURL(req, `/api/tenant/auth/oidc/callback`); // Create a new OAuth2Strategy, where we pass the verify callback bound to // this OIDCStrategy instance. @@ -365,32 +330,26 @@ export default class OIDCStrategy extends Strategy { throw new Error("tenant not found"); } - // Get the OIDC ID. - const { oidcID }: { oidcID: string } = req.params; - // Get the integration from the tenant. If needed, it will be used to create // a new strategy. - const integration = getEnabledIntegration(tenant, oidcID); + const integration = getEnabledIntegration(tenant); // Try to get the Tenant's cached integrations. - let tenantIntegrations = this.cache.get(tenant.id); - if (!tenantIntegrations || !tenantIntegrations[oidcID]) { + let tenantIntegration = this.cache.get(tenant.id); + if (!tenantIntegration) { // Create the strategy. const strategy = this.createStrategy(req, integration); // Reset the entry. - tenantIntegrations = { - [oidcID]: { - strategy, - }, - ...(tenantIntegrations || {}), + tenantIntegration = { + strategy, }; // Update the cached integrations value. - this.cache.set(tenant.id, tenantIntegrations); + this.cache.set(tenant.id, tenantIntegration); } - return tenantIntegrations[oidcID].strategy; + return tenantIntegration.strategy; } public authenticate(req: Request) { diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts index e48fdd1b7..a34ac2b8f 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts @@ -18,7 +18,7 @@ export interface SSOStrategyOptions { export interface SSOUserProfile { id: string; email: string; - username: string; + username?: string; avatar?: string; displayName?: string; } diff --git a/src/core/server/app/router/api/auth.ts b/src/core/server/app/router/api/auth.ts index e29748e5a..caed6ae74 100644 --- a/src/core/server/app/router/api/auth.ts +++ b/src/core/server/app/router/api/auth.ts @@ -5,7 +5,10 @@ import { logoutHandler, signupHandler, } from "talk-server/app/handlers/api/tenant/auth/local"; -import { wrapAuthn } from "talk-server/app/middleware/passport"; +import { + wrapAuthn, + wrapOAuth2Authn, +} from "talk-server/app/middleware/passport"; import { RouterOptions } from "talk-server/app/router/types"; function wrapPath( @@ -15,7 +18,11 @@ function wrapPath( strategy: string, path: string = `/${strategy}` ) { - const handler = wrapAuthn(options.passport, app.signingConfig, strategy); + const handler = wrapOAuth2Authn( + options.passport, + app.signingConfig, + strategy + ); router.get(path, handler); router.get(path + "/callback", handler); @@ -46,7 +53,7 @@ export function createNewAuthRouter(app: AppOptions, options: RouterOptions) { // Mount the external auth integrations with middleware/handle wrappers. wrapPath(app, options, router, "facebook"); wrapPath(app, options, router, "google"); - wrapPath(app, options, router, "oidc", "/oidc/:oidc"); + wrapPath(app, options, router, "oidc"); return router; } diff --git a/src/core/server/app/url.ts b/src/core/server/app/url.ts index 9733fd4ae..69937d794 100644 --- a/src/core/server/app/url.ts +++ b/src/core/server/app/url.ts @@ -23,7 +23,7 @@ export function constructTenantURL( ): string { let url: URL = new URL(path, `https://${tenant.domain}`); if (config.get("env") === "development") { - url = new URL(path, `http://${tenant.domain}:${config.get("port")}`); + url = new URL(path, `http://${tenant.domain}:${config.get("dev_port")}`); } return url.href; diff --git a/src/core/server/config.ts b/src/core/server/config.ts index 6ea751f30..0f3d3720a 100644 --- a/src/core/server/config.ts +++ b/src/core/server/config.ts @@ -70,6 +70,13 @@ const config = convict({ env: "PORT", arg: "port", }, + dev_port: { + doc: "The port to bind for the Webpack Dev Server.", + format: "port", + default: 8080, + env: "DEV_PORT", + arg: "dev-port", + }, mongodb: { doc: "The MongoDB database to connect to.", format: "mongo-uri", diff --git a/src/core/server/graph/common/directives/auth.ts b/src/core/server/graph/common/directives/auth.ts index 2b21a6366..d8690221e 100644 --- a/src/core/server/graph/common/directives/auth.ts +++ b/src/core/server/graph/common/directives/auth.ts @@ -1,19 +1,67 @@ import { DirectiveResolverFn } from "graphql-tools"; +import { memoize } from "lodash"; import CommonContext from "talk-server/graph/common/context"; -import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { + GQLUSER_AUTH_CONDITIONS, + GQLUSER_ROLE, +} from "talk-server/graph/tenant/schema/__generated__/types"; +import { User } from "talk-server/models/user"; + +// Replace `memoize.Cache`. +memoize.Cache = WeakMap; export interface AuthDirectiveArgs { roles?: GQLUSER_ROLE[]; userIDField?: string; + permit?: GQLUSER_AUTH_CONDITIONS[]; } +function calculateAuthConditions(user: User): GQLUSER_AUTH_CONDITIONS[] { + const conditions: GQLUSER_AUTH_CONDITIONS[] = []; + + if (!user.username && !user.displayName) { + conditions.push(GQLUSER_AUTH_CONDITIONS.MISSING_NAME); + } + + if (!user.email) { + conditions.push(GQLUSER_AUTH_CONDITIONS.MISSING_EMAIL); + } + + return conditions.sort(); +} + +const calculateAuthConditionsMemoized = memoize(calculateAuthConditions); + const auth: DirectiveResolverFn< Record<string, string | undefined>, CommonContext -> = (next, src, { roles, userIDField }: AuthDirectiveArgs, { user }) => { +> = ( + next, + src, + { roles, userIDField, permit }: AuthDirectiveArgs, + { user } +) => { // If there is a user on the request. if (user) { + // If the permit was not specified, then no conditions can exist on the + // User, if they do error. + const conditions = calculateAuthConditionsMemoized(user); + if (!permit && conditions.length > 0) { + // TODO: return better error. + throw new Error("not authorized"); + } + + // If the permit was specified, and some of the conditions for the user + // aren't in the list of permitted conditions, then error. + if ( + permit && + conditions.some(condition => permit.indexOf(condition) === -1) + ) { + // TODO: return better error. + throw new Error("not authorized"); + } + // If the role and user owner checks are disabled, then allow them based on // their authenticated status. if (!roles && !userIDField) { diff --git a/src/core/server/graph/tenant/mutators/Settings.ts b/src/core/server/graph/tenant/mutators/Settings.ts index 912d92361..9ef7a5ff3 100644 --- a/src/core/server/graph/tenant/mutators/Settings.ts +++ b/src/core/server/graph/tenant/mutators/Settings.ts @@ -1,20 +1,9 @@ import { isNull, omitBy } from "lodash"; import TenantContext from "talk-server/graph/tenant/context"; -import { - GQLCreateOIDCAuthIntegrationInput, - GQLRemoveOIDCAuthIntegrationInput, - GQLSettingsInput, - GQLUpdateOIDCAuthIntegrationInput, -} from "talk-server/graph/tenant/schema/__generated__/types"; +import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types"; import { Tenant } from "talk-server/models/tenant"; -import { - createOIDCAuthIntegration, - regenerateSSOKey, - removeOIDCAuthIntegration, - update, - updateOIDCAuthIntegration, -} from "talk-server/services/tenant"; +import { regenerateSSOKey, update } from "talk-server/services/tenant"; export const Settings = ({ mongo, @@ -26,23 +15,4 @@ export const Settings = ({ update(mongo, redis, tenantCache, tenant, omitBy(input, isNull)), regenerateSSOKey: (): Promise<Tenant | null> => regenerateSSOKey(mongo, redis, tenantCache, tenant), - createOIDCAuthIntegration: (input: GQLCreateOIDCAuthIntegrationInput) => - createOIDCAuthIntegration( - mongo, - redis, - tenantCache, - tenant, - input.configuration - ), - updateOIDCAuthIntegration: (input: GQLUpdateOIDCAuthIntegrationInput) => - updateOIDCAuthIntegration( - mongo, - redis, - tenantCache, - tenant, - input.id, - input.configuration - ), - removeOIDCAuthIntegration: (input: GQLRemoveOIDCAuthIntegrationInput) => - removeOIDCAuthIntegration(mongo, redis, tenantCache, tenant, input.id), }); diff --git a/src/core/server/graph/tenant/mutators/User.ts b/src/core/server/graph/tenant/mutators/User.ts new file mode 100644 index 000000000..04b594033 --- /dev/null +++ b/src/core/server/graph/tenant/mutators/User.ts @@ -0,0 +1,33 @@ +import TenantContext from "talk-server/graph/tenant/context"; +import * as user from "talk-server/models/user"; +import { + setEmail, + setPassword, + setUsername, + updatePassword, +} from "talk-server/services/users"; +import { + GQLSetEmailInput, + GQLSetPasswordInput, + GQLSetUsernameInput, + GQLUpdatePasswordInput, +} from "../schema/__generated__/types"; + +export const User = (ctx: TenantContext) => ({ + setUsername: async ( + input: GQLSetUsernameInput + ): Promise<Readonly<user.User> | null> => + setUsername(ctx.mongo, ctx.tenant, ctx.user!, input.username), + setEmail: async ( + input: GQLSetEmailInput + ): Promise<Readonly<user.User> | null> => + setEmail(ctx.mongo, ctx.tenant, ctx.user!, input.email), + setPassword: async ( + input: GQLSetPasswordInput + ): Promise<Readonly<user.User> | null> => + setPassword(ctx.mongo, ctx.tenant, ctx.user!, input.password), + updatePassword: async ( + input: GQLUpdatePasswordInput + ): Promise<Readonly<user.User> | null> => + updatePassword(ctx.mongo, ctx.tenant, ctx.user!, input.password), +}); diff --git a/src/core/server/graph/tenant/mutators/index.ts b/src/core/server/graph/tenant/mutators/index.ts index f4df98af9..a1a171417 100644 --- a/src/core/server/graph/tenant/mutators/index.ts +++ b/src/core/server/graph/tenant/mutators/index.ts @@ -4,10 +4,12 @@ import { Actions } from "./Actions"; import { Comment } from "./Comment"; import { Settings } from "./Settings"; import { Story } from "./Story"; +import { User } from "./User"; export default (ctx: TenantContext) => ({ Actions: Actions(ctx), Comment: Comment(ctx), Settings: Settings(ctx), Story: Story(ctx), + User: User(ctx), }); diff --git a/src/core/server/graph/tenant/resolvers/FacebookAuthIntegration.ts b/src/core/server/graph/tenant/resolvers/FacebookAuthIntegration.ts index 4fd05b8f0..e8c61b589 100644 --- a/src/core/server/graph/tenant/resolvers/FacebookAuthIntegration.ts +++ b/src/core/server/graph/tenant/resolvers/FacebookAuthIntegration.ts @@ -1,24 +1,15 @@ -import { constructTenantURL, reconstructURL } from "talk-server/app/url"; import { GQLFacebookAuthIntegration, GQLFacebookAuthIntegrationTypeResolver, } from "talk-server/graph/tenant/schema/__generated__/types"; +import { reconstructTenantURLResolver } from "./util"; + export const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver< GQLFacebookAuthIntegration > = { - callbackURL: (integration, args, ctx) => { - const path = `/api/tenant/auth/facebook/callback`; - - // If the request is available, then prefer it over building from the tenant - // as the tenant does not include the port number. This should only really - // be a problem if the graph API is called internally. - if (ctx.req) { - return reconstructURL(ctx.req, path); - } - - // Note that when constructing the callback url with the tenant, the port - // information is lost. - return constructTenantURL(ctx.config, ctx.tenant, path); - }, + callbackURL: reconstructTenantURLResolver( + "/api/tenant/auth/facebook/callback" + ), + redirectURL: reconstructTenantURLResolver("/api/tenant/auth/facebook"), }; diff --git a/src/core/server/graph/tenant/resolvers/GoogleAuthIntegration.ts b/src/core/server/graph/tenant/resolvers/GoogleAuthIntegration.ts index 879244131..e17566ed3 100644 --- a/src/core/server/graph/tenant/resolvers/GoogleAuthIntegration.ts +++ b/src/core/server/graph/tenant/resolvers/GoogleAuthIntegration.ts @@ -1,24 +1,13 @@ -import { constructTenantURL, reconstructURL } from "talk-server/app/url"; import { GQLGoogleAuthIntegration, GQLGoogleAuthIntegrationTypeResolver, } from "talk-server/graph/tenant/schema/__generated__/types"; +import { reconstructTenantURLResolver } from "./util"; + export const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver< GQLGoogleAuthIntegration > = { - callbackURL: (integration, args, ctx) => { - const path = `/api/tenant/auth/google/callback`; - - // If the request is available, then prefer it over building from the tenant - // as the tenant does not include the port number. This should only really - // be a problem if the graph API is called internally. - if (ctx.req) { - return reconstructURL(ctx.req, path); - } - - // Note that when constructing the callback url with the tenant, the port - // information is lost. - return constructTenantURL(ctx.config, ctx.tenant, path); - }, + callbackURL: reconstructTenantURLResolver("/api/tenant/auth/google/callback"), + redirectURL: reconstructTenantURLResolver("/api/tenant/auth/google"), }; diff --git a/src/core/server/graph/tenant/resolvers/Mutation.ts b/src/core/server/graph/tenant/resolvers/Mutation.ts index 3bf571c42..29a02856a 100644 --- a/src/core/server/graph/tenant/resolvers/Mutation.ts +++ b/src/core/server/graph/tenant/resolvers/Mutation.ts @@ -53,42 +53,6 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = { settings: await ctx.mutators.Settings.regenerateSSOKey(), clientMutationId: input.clientMutationId, }), - createOIDCAuthIntegration: async (source, { input }, ctx) => { - const result = await ctx.mutators.Settings.createOIDCAuthIntegration(input); - if (!result) { - return { clientMutationId: input.clientMutationId }; - } - - return { - integration: result.integration, - settings: result.tenant, - clientMutationId: input.clientMutationId, - }; - }, - updateOIDCAuthIntegration: async (source, { input }, ctx) => { - const result = await ctx.mutators.Settings.updateOIDCAuthIntegration(input); - if (!result) { - return { clientMutationId: input.clientMutationId }; - } - - return { - integration: result.integration, - settings: result.tenant, - clientMutationId: input.clientMutationId, - }; - }, - removeOIDCAuthIntegration: async (source, { input }, ctx) => { - const result = await ctx.mutators.Settings.removeOIDCAuthIntegration(input); - if (!result) { - return { clientMutationId: input.clientMutationId }; - } - - return { - integration: result.integration, - settings: result.tenant, - clientMutationId: input.clientMutationId, - }; - }, createStory: async (source, { input }, ctx) => ({ story: await ctx.mutators.Story.create(input), clientMutationId: input.clientMutationId, @@ -117,4 +81,20 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = { comment: await ctx.mutators.Actions.rejectComment(input), clientMutationId: input.clientMutationId, }), + setUsername: async (source, { input }, ctx) => ({ + user: await ctx.mutators.User.setUsername(input), + clientMutationId: input.clientMutationId, + }), + setEmail: async (source, { input }, ctx) => ({ + user: await ctx.mutators.User.setEmail(input), + clientMutationId: input.clientMutationId, + }), + setPassword: async (source, { input }, ctx) => ({ + user: await ctx.mutators.User.setPassword(input), + clientMutationId: input.clientMutationId, + }), + updatePassword: async (source, { input }, ctx) => ({ + user: await ctx.mutators.User.updatePassword(input), + clientMutationId: input.clientMutationId, + }), }; diff --git a/src/core/server/graph/tenant/resolvers/OIDCAuthIntegration.ts b/src/core/server/graph/tenant/resolvers/OIDCAuthIntegration.ts index bbf5b57b2..abfcf8071 100644 --- a/src/core/server/graph/tenant/resolvers/OIDCAuthIntegration.ts +++ b/src/core/server/graph/tenant/resolvers/OIDCAuthIntegration.ts @@ -1,24 +1,13 @@ -import { constructTenantURL, reconstructURL } from "talk-server/app/url"; import { GQLOIDCAuthIntegration, GQLOIDCAuthIntegrationTypeResolver, } from "talk-server/graph/tenant/schema/__generated__/types"; +import { reconstructTenantURLResolver } from "./util"; + export const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver< GQLOIDCAuthIntegration > = { - callbackURL: (integration, args, ctx) => { - const path = `/api/tenant/auth/oidc/${integration.id}`; - - // If the request is available, then prefer it over building from the tenant - // as the tenant does not include the port number. This should only really - // be a problem if the graph API is called internally. - if (ctx.req) { - return reconstructURL(ctx.req, path); - } - - // Note that when constructing the callback url with the tenant, the port - // information is lost. - return constructTenantURL(ctx.config, ctx.tenant, path); - }, + callbackURL: reconstructTenantURLResolver("/api/tenant/auth/oidc/callback"), + redirectURL: reconstructTenantURLResolver("/api/tenant/auth/oidc"), }; diff --git a/src/core/server/graph/tenant/resolvers/Profile.ts b/src/core/server/graph/tenant/resolvers/Profile.ts index 2101ecb80..198015d1e 100644 --- a/src/core/server/graph/tenant/resolvers/Profile.ts +++ b/src/core/server/graph/tenant/resolvers/Profile.ts @@ -10,6 +10,10 @@ const resolveType: GQLProfileTypeResolver<user.Profile> = profile => { return "OIDCProfile"; case "sso": return "SSOProfile"; + case "facebook": + return "FacebookProfile"; + case "google": + return "GoogleProfile"; default: // TODO: replace with better error. throw new Error("invalid profile type"); diff --git a/src/core/server/graph/tenant/resolvers/util.ts b/src/core/server/graph/tenant/resolvers/util.ts index c93f5c9eb..46d12773d 100644 --- a/src/core/server/graph/tenant/resolvers/util.ts +++ b/src/core/server/graph/tenant/resolvers/util.ts @@ -4,6 +4,10 @@ import { pull } from "lodash"; import { parseQuery, stringifyQuery } from "talk-common/utils"; import { URL } from "url"; +import { constructTenantURL, reconstructURL } from "talk-server/app/url"; + +import TenantContext from "../context"; + /** * getRequestedFields returns the fields in an array that are being queried for. * @@ -26,3 +30,18 @@ export function getURLWithCommentID(storyURL: string, commentID?: string) { return url.toString(); } + +export function reconstructTenantURLResolver<T = any>(path: string) { + return (parent: T, args: {}, ctx: TenantContext) => { + // If the request is available, then prefer it over building from the tenant + // as the tenant does not include the port number. This should only really + // be a problem if the graph API is called internally. + if (ctx.req) { + return reconstructURL(ctx.req, path); + } + + // Note that when constructing the callback url with the tenant, the port + // information is lost. + return constructTenantURL(ctx.config, ctx.tenant, path); + }; +} diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index f8b038724..aaed707d1 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -2,15 +2,41 @@ ## Custom Directives ################################################################################ +""" +USER_AUTH_CONDITIONS describes conditions that would prevent a given User to +execute any set of mutations or reserved queries. +""" +enum USER_AUTH_CONDITIONS { + """ + MISSING_NAME is provided when the User does not have an associated username or + display name. + """ + MISSING_NAME + + """ + MISSING_EMAIL is provided when the User does not have an associated email + address. + """ + MISSING_EMAIL +} + """ auth is a directive that will enforce authorization rules on the schema definition. It will restrict the viewer of the field based on roles or if the `userIDField` is specified, it will see if the current users ID equals the field specified. This allows users that own a specific resource (like a comment, or a flag) see their own content, but restrict it to everyone else. If the directive -is used without options, it simply requires a logged in user. +is used without options, it simply requires a logged in user. `permit` can be +used to allow specific `USER_AUTH_CONDITIONS` that normally (if present) would +deny access to any edge associated with the `@auth` directive. If a User has +only some of the conditions listed, they will pass, but if they have at least +one more that isn't in the list, the request will be denied. """ -directive @auth(roles: [USER_ROLE!], userIDField: String) on FIELD_DEFINITION +directive @auth( + roles: [USER_ROLE!] + userIDField: String + permit: [USER_AUTH_CONDITIONS!] +) on FIELD_DEFINITION ################################################################################ ## Custom Scalar Types @@ -413,11 +439,6 @@ OIDCAuthIntegration provides a way to store Open ID Connect credentials. This will be used in the admin to provide staff logins for users. """ type OIDCAuthIntegration { - """ - id is the identifier of the OIDCAuthIntegration. - """ - id: ID! - """ enabled, when true, allows the integration to be enabled. """ @@ -444,53 +465,60 @@ type OIDCAuthIntegration { name: String """ - callbackURL is the URL that the user should be redirected to in order to start - an authentication flow with the given integration. This field is not stored, + callbackURL is the URL that the user should be redirected to in order to continue + the authentication flow with the given integration. This field is not stored, and is instead computed from the Tenant. """ callbackURL: String! + """ + redirectURL is the URL that the user should be redirected to in order to start + an authentication flow with the given integration. This field is not stored, + and is instead computed from the Tenant. + """ + redirectURL: String + """ clientID is the Client Identifier as defined in: https://tools.ietf.org/html/rfc6749#section-2.2 """ - clientID: String! @auth(roles: [ADMIN]) + clientID: String @auth(roles: [ADMIN]) """ clientSecret is the Client Secret as defined in: https://tools.ietf.org/html/rfc6749#section-2.3.1 """ - clientSecret: String! @auth(roles: [ADMIN]) + clientSecret: String @auth(roles: [ADMIN]) """ authorizationURL is defined as the `authorization_endpoint` in: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata """ - authorizationURL: String! @auth(roles: [ADMIN]) + authorizationURL: String @auth(roles: [ADMIN]) """ tokenURL is defined as the `token_endpoint` in: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata """ - tokenURL: String! @auth(roles: [ADMIN]) + tokenURL: String @auth(roles: [ADMIN]) """ jwksURI is defined as the `jwks_uri` in: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata """ - jwksURI: String! @auth(roles: [ADMIN]) + jwksURI: String @auth(roles: [ADMIN]) """ issuer is defined as the `issuer` in: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata """ - issuer: String! @auth(roles: [ADMIN]) + issuer: String @auth(roles: [ADMIN]) } ########################## @@ -517,11 +545,18 @@ type GoogleAuthIntegration { clientSecret: String @auth(roles: [ADMIN]) """ - callbackURL is the URL that the user should be redirected to in order to start + callbackURL is the URL that the user should be redirected to in order to continue the authentication flow. This field is not stored, and is instead computed from the Tenant. """ callbackURL: String! + + """ + redirectURL is the URL that the user should be redirected to in order to start + an authentication flow with the given integration. This field is not stored, + and is instead computed from the Tenant. + """ + redirectURL: String! } ########################## @@ -548,11 +583,18 @@ type FacebookAuthIntegration { clientSecret: String @auth(roles: [ADMIN]) """ - callbackURL is the URL that the user should be redirected to in order to start + callbackURL is the URL that the user should be redirected to in order to continue the authentication flow. This field is not stored, and is instead computed from the Tenant. """ callbackURL: String! + + """ + redirectURL is the URL that the user should be redirected to in order to start + an authentication flow with the given integration. This field is not stored, + and is instead computed from the Tenant. + """ + redirectURL: String! } ########################## @@ -562,7 +604,7 @@ type FacebookAuthIntegration { type AuthIntegrations { local: LocalAuthIntegration! sso: SSOAuthIntegration! - oidc: [OIDCAuthIntegration!]! + oidc: OIDCAuthIntegration! google: GoogleAuthIntegration! facebook: FacebookAuthIntegration! } @@ -990,7 +1032,20 @@ type SSOProfile { id: String! } -union Profile = LocalProfile | OIDCProfile | SSOProfile +type FacebookProfile { + id: String! +} + +type GoogleProfile { + id: String! +} + +union Profile = + LocalProfile + | OIDCProfile + | SSOProfile + | FacebookProfile + | GoogleProfile """ User is someone that leaves Comments, and logs in. @@ -1011,10 +1066,25 @@ type User { """ displayName: String + """ + email is the current email address for the User. + """ + email: String + @auth( + roles: [ADMIN, MODERATOR] + userIDField: "id" + permit: [MISSING_NAME, MISSING_EMAIL] + ) + """ profiles is the array of profiles assigned to the user. """ - profiles: [Profile!] @auth(roles: [ADMIN, MODERATOR], userIDField: "id") + profiles: [Profile!]! + @auth( + roles: [ADMIN, MODERATOR] + userIDField: "id" + permit: [MISSING_NAME, MISSING_EMAIL] + ) """ role is the current role of the User. @@ -1747,6 +1817,75 @@ type EditCommentPayload { ## updateSettings ################## +input SettingsOIDCAuthIntegrationInput { + """ + enabled, when true, allows the integration to be enabled. + """ + enabled: Boolean + + """ + allowRegistration when true will allow users that have not signed up + before with this authentication integration to sign up. + """ + allowRegistration: Boolean + + """ + targetFilter will restrict where the authentication integration should be + displayed. If the value of targetFilter is null, then the authentication + integration should be displayed in all targets. + """ + targetFilter: SettingsAuthenticationTargetFilterInput + + """ + name is the label assigned to reference the provider of the OIDC integration, + and will be used in situations where the name of the provider needs to be + displayed, like the login button. + """ + name: String + + """ + clientID is the Client Identifier as defined in: + + https://tools.ietf.org/html/rfc6749#section-2.2 + """ + clientID: String + + """ + clientSecret is the Client Secret as defined in: + + https://tools.ietf.org/html/rfc6749#section-2.3.1 + """ + clientSecret: String + + """ + authorizationURL is defined as the `authorization_endpoint` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + authorizationURL: String + + """ + tokenURL is defined as the `token_endpoint` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + tokenURL: String + + """ + jwksURI is defined as the `jwks_uri` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + jwksURI: String + + """ + issuer is defined as the `issuer` in: + + https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + """ + issuer: String +} + input SettingsEmailInput { """ enabled when True, will enable the emailing functionality in Talk. @@ -1858,6 +1997,7 @@ input SettingsFacebookAuthIntegrationInput { input SettingsAuthIntegrationsInput { local: SettingsLocalAuthIntegrationInput sso: SettingsSSOAuthIntegrationInput + oidc: SettingsOIDCAuthIntegrationInput google: SettingsGoogleAuthIntegrationInput facebook: SettingsFacebookAuthIntegrationInput } @@ -2365,250 +2505,6 @@ type RegenerateSSOKeyPayload { clientMutationId: String! } -################## -# createOIDCAuthIntegration -################## - -input CreateOIDCAuthIntegrationConfigurationInput { - """ - enabled, when true, allows the integration to be enabled. - """ - enabled: Boolean - - """ - allowRegistration when true will allow users that have not signed up - before with this authentication integration to sign up. - """ - allowRegistration: Boolean - - """ - targetFilter will restrict where the authentication integration should be - displayed. If the value of targetFilter is null, then the authentication - integration should be displayed in all targets. - """ - targetFilter: SettingsAuthenticationTargetFilterInput - - """ - name is the label assigned to reference the provider of the OIDC integration, - and will be used in situations where the name of the provider needs to be - displayed, like the login button. - """ - name: String! - - """ - clientID is the Client Identifier as defined in: - - https://tools.ietf.org/html/rfc6749#section-2.2 - """ - clientID: String! - - """ - clientSecret is the Client Secret as defined in: - - https://tools.ietf.org/html/rfc6749#section-2.3.1 - """ - clientSecret: String! - - """ - authorizationURL is defined as the `authorization_endpoint` in: - - https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata - """ - authorizationURL: String! - - """ - tokenURL is defined as the `token_endpoint` in: - - https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata - """ - tokenURL: String! - - """ - jwksURI is defined as the `jwks_uri` in: - - https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata - """ - jwksURI: String! - - """ - issuer is defined as the `issuer` in: - - https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata - """ - issuer: String! -} - -input CreateOIDCAuthIntegrationInput { - """ - configuration contains the configuration to be used to create the auth integration. - """ - configuration: CreateOIDCAuthIntegrationConfigurationInput! - - """ - clientMutationId is required for Relay support. - """ - clientMutationId: String! -} - -type CreateOIDCAuthIntegrationPayload { - """ - integration is the OIDCAuthIntegration we just created. - """ - integration: OIDCAuthIntegration - - """ - settings is the Settings that the OIDCAuthIntegration was created on. - """ - settings: Settings - - """ - clientMutationId is required for Relay support. - """ - clientMutationId: String! -} - -################## -# updateOIDCAuthIntegration -################## - -input UpdateOIDCAuthIntegrationConfigurationInput { - """ - enabled, when true, allows the integration to be enabled. - """ - enabled: Boolean - - """ - allowRegistration when true will allow users that have not signed up - before with this authentication integration to sign up. - """ - allowRegistration: Boolean - - """ - targetFilter will restrict where the authentication integration should be - displayed. If the value of targetFilter is null, then the authentication - integration should be displayed in all targets. - """ - targetFilter: SettingsAuthenticationTargetFilterInput - - """ - name is the label assigned to reference the provider of the OIDC integration, - and will be used in situations where the name of the provider needs to be - displayed, like the login button. - """ - name: String - - """ - clientID is the Client Identifier as defined in: - - https://tools.ietf.org/html/rfc6749#section-2.2 - """ - clientID: String - - """ - clientSecret is the Client Secret as defined in: - - https://tools.ietf.org/html/rfc6749#section-2.3.1 - """ - clientSecret: String - - """ - authorizationURL is defined as the `authorization_endpoint` in: - - https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata - """ - authorizationURL: String - - """ - tokenURL is defined as the `token_endpoint` in: - - https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata - """ - tokenURL: String - - """ - jwksURI is defined as the `jwks_uri` in: - - https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata - """ - jwksURI: String - - """ - issuer is defined as the `issuer` in: - - https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata - """ - issuer: String -} - -input UpdateOIDCAuthIntegrationInput { - """ - id is the ID of the specific OpenID Connect integration that we are updating. - """ - id: ID! - - """ - configuration contains the configuration to be used to update the OpenID - Connect integration. - """ - configuration: UpdateOIDCAuthIntegrationConfigurationInput! - - """ - clientMutationId is required for Relay support. - """ - clientMutationId: String! -} - -type UpdateOIDCAuthIntegrationPayload { - """ - integration is the OIDCAuthIntegration we just updated. - """ - integration: OIDCAuthIntegration - - """ - settings is the Settings that the OIDCAuthIntegration was updated on. - """ - settings: Settings - - """ - clientMutationId is required for Relay support. - """ - clientMutationId: String! -} - -################## -# removeOIDCAuthIntegration -################## - -input RemoveOIDCAuthIntegrationInput { - """ - id is the ID of the specific OpenID Connect integration that we are deleting. - """ - id: ID! - - """ - clientMutationId is required for Relay support. - """ - clientMutationId: String! -} - -type RemoveOIDCAuthIntegrationPayload { - """ - integration is the OIDCAuthIntegration we just removed. - """ - integration: OIDCAuthIntegration - - """ - settings is the Settings that the OIDCAuthIntegration was removed on with the - OIDCAuthIntegration removed from it. - """ - settings: Settings - - """ - clientMutationId is required for Relay support. - """ - clientMutationId: String! -} - ################## ## createStory ################## @@ -2935,6 +2831,118 @@ type RejectCommentPayload { clientMutationId: String! } +################## +# setUsername +################## + +input SetUsernameInput { + """ + username is the desired username that should be set to the current User. + """ + username: String! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type SetUsernamePayload { + """ + user is the possibly modified User. + """ + user: User! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# setEmail +################## + +input SetEmailInput { + """ + email is the email address to be associated with the User. + """ + email: String! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type SetEmailPayload { + """ + user is the possibly modified User. + """ + user: User! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# setPassword +################## + +input SetPasswordInput { + """ + password is the password that should be associated with the User. + """ + password: String! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type SetPasswordPayload { + """ + user is the possibly modified User. + """ + user: User! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# updatePassword +################## + +input UpdatePasswordInput { + """ + password is the new password that should be associated with the User. + """ + password: String! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type UpdatePasswordPayload { + """ + user is the possibly modified User. + """ + user: User! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## ## Mutation ################## @@ -2943,7 +2951,7 @@ type Mutation { """ createComment will create a Comment as the current logged in User. """ - createComment(input: CreateCommentInput!): CreateCommentPayload + createComment(input: CreateCommentInput!): CreateCommentPayload @auth """ createCommentReply will create a Comment as the current logged in User that is @@ -2970,28 +2978,6 @@ type Mutation { regenerateSSOKey(input: RegenerateSSOKeyInput!): RegenerateSSOKeyPayload @auth(roles: [ADMIN]) - """ - createOIDCAuthIntegration will create a OpenID Connect auth integration. - """ - createOIDCAuthIntegration( - input: CreateOIDCAuthIntegrationInput! - ): CreateOIDCAuthIntegrationPayload @auth(roles: [ADMIN]) - - """ - updateOIDCAuthIntegration will update a given OpenID Connect auth integration. - """ - updateOIDCAuthIntegration( - input: UpdateOIDCAuthIntegrationInput! - ): UpdateOIDCAuthIntegrationPayload @auth(roles: [ADMIN]) - - """ - removeOIDCAuthIntegration will remove the specified OpenID Connect auth - integration. - """ - removeOIDCAuthIntegration( - input: RemoveOIDCAuthIntegrationInput! - ): RemoveOIDCAuthIntegrationPayload @auth(roles: [ADMIN]) - """ createCommentReaction will create a Reaction authored by the current logged in User on a Comment. @@ -3073,6 +3059,32 @@ type Mutation { """ rejectComment(input: RejectCommentInput!): RejectCommentPayload @auth(roles: [MODERATOR, ADMIN]) + + """ + setUsername will set the username on the current User if they have not set one + before. This mutation will fail if the username is already set. + """ + setUsername(input: SetUsernameInput!): SetUsernamePayload + @auth(permit: [MISSING_NAME, MISSING_EMAIL]) + + """ + setEmail will set the email address on the current User if they have not set + one already. This mutation will fail if the email address is already set. + """ + setEmail(input: SetEmailInput!): SetEmailPayload + @auth(permit: [MISSING_EMAIL]) + + """ + setPassword will set the password on the current User if they have not set + one already. This mutation will fail if the password is already set. + """ + setPassword(input: SetPasswordInput!): SetPasswordPayload @auth + + """ + updatePassword allows the current logged in User to change their password if + they already have one associated with them. + """ + updatePassword(input: UpdatePasswordInput!): UpdatePasswordPayload @auth } ################################################################################ diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings.ts index 4091b8f37..82e8e2abf 100644 --- a/src/core/server/models/settings.ts +++ b/src/core/server/models/settings.ts @@ -1,55 +1,20 @@ +import { Omit } from "talk-common/types"; import { - GQLAuth, + GQLAuthDisplayNameConfiguration, GQLCharCount, GQLEmail, GQLExternalIntegrations, + GQLFacebookAuthIntegration, + GQLGoogleAuthIntegration, GQLKarma, + GQLLocalAuthIntegration, GQLMODERATION_MODE, + GQLOIDCAuthIntegration, GQLReactionConfiguration, + GQLSSOAuthIntegration, GQLWordList, } from "talk-server/graph/tenant/schema/__generated__/types"; -// export interface EmailDomainRuleCondition { -// /** -// * emailDomain is the domain name component of the email addresses that should -// * match for this condition. -// */ -// emailDomain: string; -// /** -// * emailVerifiedRequired stipulates that this rule only applies when the user -// * account has been marked as having their email address already verified. -// */ -// emailVerifiedRequired: boolean; -// } - -// /** -// * RoleRule describes the role assignment for when a user logs into Talk, how -// * they can have their account automatically upgraded to a specific role when -// * the domain for their email matches the one provided. -// */ -// export interface RoleRule extends Partial<EmailDomainRuleCondition> { -// /** -// * role is the specific GQLUSER_ROLE that should be assigned to the newly -// * created user depending on their email address. -// */ -// role: GQLUSER_ROLE; -// } - -// export interface AuthRules { -// /** -// * roles allow the configuration of automatic role assignment based on the -// * user's email address. -// */ -// roles?: RoleRule[]; - -// /** -// * restrictTo when populated, will restrict which users can login using this -// * integration. If a user successfully logs in using the OIDCStrategy, but -// * does not match the following rules, the user will not be created. -// */ -// restrictTo?: EmailDomainRuleCondition[]; -// } - export interface ModerationSettings { moderation: GQLMODERATION_MODE; requireEmailConfirmation: boolean; @@ -67,6 +32,46 @@ export interface ModerationSettings { charCount: GQLCharCount; } +export type LocalAuthIntegration = GQLLocalAuthIntegration; +export type SSOAuthIntegration = GQLSSOAuthIntegration; + +export type OIDCAuthIntegration = Omit< + GQLOIDCAuthIntegration, + "callbackURL" | "redirectURL" +>; + +export type GoogleAuthIntegration = Omit< + GQLGoogleAuthIntegration, + "callbackURL" | "redirectURL" +>; + +export type FacebookAuthIntegration = Omit< + GQLFacebookAuthIntegration, + "callbackURL" | "redirectURL" +>; + +export interface AuthIntegrations { + local: LocalAuthIntegration; + sso: SSOAuthIntegration; + oidc: OIDCAuthIntegration; + google: GoogleAuthIntegration; + facebook: FacebookAuthIntegration; +} + +export interface Auth { + /** + * integrations are the set of configurations for the variations of + * authentication solutions. + */ + integrations: AuthIntegrations; + + /** + * displayName contains configuration related to the use of Display Names + * across AuthIntegrations. + */ + displayName: GQLAuthDisplayNameConfiguration; +} + export interface Settings extends ModerationSettings { customCssUrl?: string; @@ -96,7 +101,7 @@ export interface Settings extends ModerationSettings { /** * Set of configured authentication integrations. */ - auth: GQLAuth; + auth: Auth; /** * Various integrations with external services. diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index dea8849fe..9cd0e0762 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -5,10 +5,7 @@ import uuid from "uuid"; import { DeepPartial, Omit, Sub } from "talk-common/types"; import { dotize, DotizeOptions } from "talk-common/utils/dotize"; -import { - GQLMODERATION_MODE, - GQLOIDCAuthIntegration, -} from "talk-server/graph/tenant/schema/__generated__/types"; +import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated__/types"; import { Settings } from "talk-server/models/settings"; function collection(db: Db) { @@ -114,11 +111,17 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) { key: generateSSOKey(), keyGeneratedAt: new Date(), }, - oidc: [], + oidc: { + enabled: false, + allowRegistration: false, + targetFilter: { + admin: true, + stream: true, + }, + }, google: { enabled: false, allowRegistration: false, - callbackURL: "" as any, // FIXME: this should not be required targetFilter: { admin: true, stream: true, @@ -127,7 +130,6 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) { facebook: { enabled: false, allowRegistration: false, - callbackURL: "", // FIXME: this should not be required targetFilter: { admin: true, stream: true, @@ -282,162 +284,3 @@ export async function regenerateTenantSSOKey(db: Db, id: string) { return result.value || null; } - -export type CreateTenantOIDCAuthIntegrationInput = Omit< - GQLOIDCAuthIntegration, - "id" | "callbackURL" ->; - -export interface CreateTenantOIDCAuthIntegrationResultObject { - tenant?: Tenant; - integration?: Omit<GQLOIDCAuthIntegration, "callbackURL">; - wasCreated: boolean; -} - -export async function createTenantOIDCAuthIntegration( - mongo: Db, - id: string, - input: CreateTenantOIDCAuthIntegrationInput -): Promise<CreateTenantOIDCAuthIntegrationResultObject> { - // Add the ID to the integration. - const integration = { - id: uuid.v4(), - ...input, - }; - - const result = await collection(mongo).findOneAndUpdate( - { id }, - // Serialize the deep update into the Tenant. - { - $push: { "auth.integrations.oidc": integration }, - }, - // False to return the updated document instead of the original - // document. - { returnOriginal: false } - ); - if (!result.value) { - return { - wasCreated: false, - }; - } - - const wasCreated = - result.value.auth.integrations.oidc.findIndex( - ({ id: integrationID }) => integrationID === integration.id - ) !== -1; - - return { - tenant: result.value, - integration, - wasCreated, - }; -} - -export type UpdateTenantOIDCAuthIntegrationInput = Partial< - Omit<GQLOIDCAuthIntegration, "id"> ->; - -export interface UpdateTenantOIDCAuthIntegrationResultObject { - tenant?: Tenant; - integration?: Omit<GQLOIDCAuthIntegration, "callbackURL">; - wasUpdated: boolean; -} - -export async function updateTenantOIDCAuthIntegration( - mongo: Db, - id: string, - oidcID: string, - input: UpdateTenantOIDCAuthIntegrationInput -): Promise<UpdateTenantOIDCAuthIntegrationResultObject> { - const result = await collection(mongo).findOneAndUpdate( - { id }, - { - $set: dotizeDropNull({ - "auth.integrations.oidc.$[oidc]": input, - }), - }, - { - // Add an ArrayFilter to only update one of the OpenID Connect - // integrations. - arrayFilters: [{ "oidc.id": oidcID }], - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - return { - wasUpdated: false, - }; - } - - const integration = result.value.auth.integrations.oidc.find( - ({ id: integrationID }) => integrationID === oidcID - ); - - const wasUpdated = Boolean(integration); - - return { - tenant: result.value, - integration, - wasUpdated, - }; -} - -export interface RemoveTenantOIDCAuthIntegrationResultObject { - tenant?: Tenant; - integration?: Omit<GQLOIDCAuthIntegration, "callbackURL">; - wasRemoved: boolean; -} - -/** - * removeTenantOIDCAuthIntegration will delete the specific OpenID Connect Auth - * Integration on the Tenant. - * - * @param mongo MongoDB Database handle - * @param id the id of the Tenant - * @param oidcID the id of the OpenID Connect Auth Integration we're deleting - */ -export async function removeTenantOIDCAuthIntegration( - mongo: Db, - id: string, - oidcID: string -): Promise<RemoveTenantOIDCAuthIntegrationResultObject> { - const result = await collection(mongo).findOneAndUpdate( - { id }, - { - $pull: { "auth.integrations.oidc": { id: oidcID } }, - }, - { - // True to return the document before we modified it. This gives us the - // opportunity to return the original document and asertain if the - // integration was/could be removed. - returnOriginal: true, - } - ); - if (!result.value) { - return { wasRemoved: false }; - } - - // Find the integration that we wanted to delete. - const integration = result.value.auth.integrations.oidc.find( - ({ id: integrationID }) => integrationID === oidcID - ); - if (!integration) { - // The integration was not in the original document, so we could not have - // possibly deleted it! - return { wasRemoved: false }; - } - - // The integration was found, we should pull that integration out of the - // resulting Tenant. - result.value.auth.integrations.oidc.filter( - ({ id: integrationID }) => integrationID !== integration.id - ); - - return { - tenant: result.value, - integration, - wasRemoved: true, - }; -} diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index dc78afb8b..5a0860629 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -17,6 +17,7 @@ function collection(db: Db) { export interface LocalProfile { type: "local"; id: string; + password: string; } export interface OIDCProfile { @@ -74,9 +75,9 @@ export interface UserStatus { export interface User extends TenantResource { readonly id: string; - username: string | null; + username?: string; + lowercaseUsername?: string; displayName?: string; - password?: string; avatar?: string; email?: string; emailVerified?: boolean; @@ -88,9 +89,19 @@ export interface User extends TenantResource { createdAt: Date; } +function hashPassword(password: string): Promise<string> { + return bcrypt.hash(password, 10); +} + export type UpsertUserInput = Omit< User, - "id" | "tenantID" | "tokens" | "status" | "ignoredUserIDs" | "createdAt" + | "id" + | "tenantID" + | "tokens" + | "status" + | "ignoredUserIDs" + | "createdAt" + | "lowercaseUsername" >; export async function upsertUser( @@ -129,20 +140,39 @@ export async function upsertUser( createdAt: now, }; - let hashedPassword; - if (input.password) { - // Hash the user's password with bcrypt. - hashedPassword = await bcrypt.hash(input.password, 10); + // Mutate the profiles to ensure we mask handle any secrets. + const profiles: Profile[] = []; + for (let profile of input.profiles) { + switch (profile.type) { + case "local": + // FIXME: (wyattjoh) add password validation here. + + // Hash the user's password with bcrypt. + const password = await hashPassword(profile.password); + profile = { + ...profile, + password, + }; + break; + } + // Save a copy. + profiles.push(profile); } + // Add in the lowercase username if it was sent. + if (input.username) { + defaults.lowercaseUsername = input.username.toLowerCase(); + + // FIXME: (wyattjoh) add username checking regex here. + } + + // FIXME: (wyattjoh) add email validation here. + // Merge the defaults and the input together. const user: Readonly<User> = { ...defaults, ...input, - - // Specified last in the merge call, it will override any existing password - // entry if it is defined. - password: hashedPassword, + profiles, }; // Create a query that will utilize a findOneAndUpdate to facilitate an upsert @@ -216,7 +246,7 @@ export async function retrieveManyUsers( export async function retrieveUserWithProfile( db: Db, tenantID: string, - profile: Profile + profile: Partial<Profile> ) { return collection(db).findOne({ tenantID, @@ -235,16 +265,300 @@ export async function updateUserRole( const result = await collection(db).findOneAndUpdate( { id, tenantID }, { $set: { role } }, - { returnOriginal: false } + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } ); return result.value || null; } export async function verifyUserPassword(user: User, password: string) { - if (user.password) { - return bcrypt.compare(password, user.password); + const profile: LocalProfile | undefined = user.profiles.find( + ({ type }) => type === "local" + ) as LocalProfile | undefined; + if (!profile) { + throw new Error("no local profile exists for this user"); } - return false; + return bcrypt.compare(password, profile.password); +} + +export async function updateUserPassword( + mongo: Db, + tenantID: string, + id: string, + password: string +) { + // FIXME: (wyattjoh) add password validation here. + + // Hash the password. + const hashedPassword = await hashPassword(password); + + // Update the user with the new password. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + // This ensures that the document we're updating already has a local + // profile associated with them. + "profiles.type": "local", + }, + { + $set: { + "profiles.$[profiles].password": hashedPassword, + }, + }, + { + arrayFilters: [{ "profiles.type": "local" }], + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + // TODO: (wyattjoh) return better error + throw new Error("user not found"); + } + + if ( + !user.profiles.some( + profile => profile.type === "local" && profile.id === user.email + ) + ) { + // TODO: (wyattjoh) return better error + throw new Error("user does not have a local profile"); + } + + // TODO: (wyattjoh) return better error + throw new Error("unexpected error occurred"); + } + + return result.value || null; +} + +/** + * setUserUsername will set the username of the User if the username hasn't + * already been used before. + * + * @param mongo the database handle + * @param tenantID the ID to the Tenant + * @param id the ID of the User where we are setting the username on + * @param username the username that we want to set + */ +export async function setUserUsername( + mongo: Db, + tenantID: string, + id: string, + username: string +) { + // Lowercase the username. + const lowercaseUsername = username.toLowerCase(); + + // FIXME: (wyattjoh) add username checking regex here. + + // Search to see if this username has been used before. + let user = await collection(mongo).findOne({ + tenantID, + lowercaseUsername, + }); + if (user) { + // TODO: (wyattjoh) return better error + throw new Error("duplicate username found"); + } + + // The username wasn't found, so add it to the user. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + username: null, + }, + { + $set: { + username, + lowercaseUsername, + "status.username.status": GQLUSER_USERNAME_STATUS.SET, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Try to get the current user to discover what happened. + user = await retrieveUser(mongo, tenantID, id); + if (!user) { + // TODO: (wyattjoh) return better error + throw new Error("user not found"); + } + + if (user.username) { + // TODO: (wyattjoh) return better error + throw new Error("user already has username"); + } + + // TODO: (wyattjoh) return better error + throw new Error("unexpected error occurred"); + } + + return result.value; +} + +/** + * setUserEmail will set the email address of the User if they don't already + * have one associated with them, and it hasn't been used before. + * + * @param mongo the database handle + * @param tenantID the ID to the Tenant + * @param id the ID of the User where we are setting the email address on + * @param emailAddress the email address we want to set + */ +export async function setUserEmail( + mongo: Db, + tenantID: string, + id: string, + emailAddress: string +) { + // Lowercase the email address. + const email = emailAddress.toLowerCase(); + + // FIXME: (wyattjoh) add email validation here. + + // Search to see if this email has been used before. + let user = await collection(mongo).findOne({ + tenantID, + email, + }); + if (user) { + // TODO: (wyattjoh) return better error + throw new Error("duplicate email found"); + } + + // The email wasn't found, so try to update the User. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + email: null, + }, + { + $set: { + email, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Try to get the current user to discover what happened. + user = await retrieveUser(mongo, tenantID, id); + if (!user) { + // TODO: (wyattjoh) return better error + throw new Error("user not found"); + } + + if (user.email) { + // TODO: (wyattjoh) return better error + throw new Error("user already has email"); + } + + // TODO: (wyattjoh) return better error + throw new Error("unexpected error occurred"); + } + + return result.value; +} + +/** + * setUserLocalProfile will set the local profile for a User if they don't + * already have one associated with them and the profile doesn't exist on any + * other User already. + * + * @param mongo the database handle + * @param tenantID the ID to the Tenant + * @param id the ID of the User where we are setting the local profile on + * @param emailAddress the email address we want to set + * @param password the password we want to set + */ +export async function setUserLocalProfile( + mongo: Db, + tenantID: string, + id: string, + emailAddress: string, + password: string +) { + // Lowercase the email address. + const email = emailAddress.toLowerCase(); + + // FIXME: (wyattjoh) add email validation here. + // FIXME: (wyattjoh) add password validation here. + + // Try to see if this local profile already exists on a User. + let user = await retrieveUserWithProfile(mongo, tenantID, { + type: "local", + id: email, + }); + if (user) { + // TODO: (wyattjoh) return better error + throw new Error("duplicate profile found"); + } + + // Hash the password. + const hashedPassword = await hashPassword(password); + + // Create the profile that we'll use. + const profile: LocalProfile = { + type: "local", + id: email, + password: hashedPassword, + }; + + // The profile wasn't found, so add it to the User. + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id, + // This ensures that the document we're updating does not contain a local + // profile. + "profiles.type": { $ne: "local" }, + }, + { + $push: { + profiles: profile, + }, + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Try to get the current user to discover what happened. + user = await retrieveUser(mongo, tenantID, id); + if (!user) { + // TODO: (wyattjoh) return better error + throw new Error("user not found"); + } + + if (user.profiles.some(({ type }) => type === "local")) { + // TODO: (wyattjoh) return better error + throw new Error("user already has local profile"); + } + + // TODO: (wyattjoh) return better error + throw new Error("unexpected error occurred"); + } + + return result.value; } diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts index ca9efae78..4e9305780 100644 --- a/src/core/server/services/tenant/index.ts +++ b/src/core/server/services/tenant/index.ts @@ -2,20 +2,13 @@ import { Redis } from "ioredis"; import { Db } from "mongodb"; import { URL } from "url"; -import { - GQLCreateOIDCAuthIntegrationConfigurationInput, - GQLSettingsInput, - GQLUpdateOIDCAuthIntegrationConfigurationInput, -} from "talk-server/graph/tenant/schema/__generated__/types"; +import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types"; import { createTenant, CreateTenantInput, - createTenantOIDCAuthIntegration, regenerateTenantSSOKey, - removeTenantOIDCAuthIntegration, Tenant, updateTenant, - updateTenantOIDCAuthIntegration, } from "talk-server/models/tenant"; import { discover } from "talk-server/app/middleware/passport/strategies/oidc/discover"; @@ -123,82 +116,3 @@ export async function discoverOIDCConfiguration(issuerString: string) { // Discover the configuration. return discover(issuer); } - -export type CreateOIDCAuthIntegration = GQLCreateOIDCAuthIntegrationConfigurationInput; - -export async function createOIDCAuthIntegration( - mongo: Db, - redis: Redis, - cache: TenantCache, - tenant: Tenant, - input: CreateOIDCAuthIntegration -) { - // Create the integration. By default, the integration is disabled. - const result = await createTenantOIDCAuthIntegration(mongo, tenant.id, { - enabled: false, - allowRegistration: false, - targetFilter: { - admin: true, - stream: true, - }, - ...input, - }); - if (!result.wasCreated || !result.tenant) { - return null; - } - - // Update the tenant cache. - await cache.update(redis, result.tenant); - - return result; -} - -export type UpdateOIDCAuthIntegration = GQLUpdateOIDCAuthIntegrationConfigurationInput; - -export async function updateOIDCAuthIntegration( - mongo: Db, - redis: Redis, - cache: TenantCache, - tenant: Tenant, - oidcID: string, - input: UpdateOIDCAuthIntegration -) { - // Update the integration. By default, the integration is disabled. - const result = await updateTenantOIDCAuthIntegration( - mongo, - tenant.id, - oidcID, - input - ); - if (!result.wasUpdated || !result.tenant) { - return null; - } - - // Update the tenant cache. - await cache.update(redis, result.tenant); - - return result; -} - -export async function removeOIDCAuthIntegration( - mongo: Db, - redis: Redis, - cache: TenantCache, - tenant: Tenant, - oidcID: string -) { - // Delete the integration. By default, the integration is disabled. - const result = await removeTenantOIDCAuthIntegration( - mongo, - tenant.id, - oidcID - ); - if (!result.wasRemoved || !result.tenant) { - return null; - } - - // Update the tenant cache. - await cache.update(redis, result.tenant); - - return result; -} diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index 1a0a62b4f..c50ae8f1e 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -1,7 +1,15 @@ import { Db } from "mongodb"; import { Tenant } from "talk-server/models/tenant"; -import { upsertUser, UpsertUserInput } from "talk-server/models/user"; +import { + setUserEmail, + setUserLocalProfile, + setUserUsername, + updateUserPassword, + upsertUser, + UpsertUserInput, + User, +} from "talk-server/models/user"; export type UpsertUser = UpsertUserInput; @@ -10,3 +18,74 @@ export async function upsert(db: Db, tenant: Tenant, input: UpsertUser) { return user; } + +export async function setUsername( + mongo: Db, + tenant: Tenant, + user: User, + username: string +) { + // We require that the username is not defined in order to use this method. + if (user.username) { + throw new Error("username already associated with user"); + } + + return setUserUsername(mongo, tenant.id, user.id, username); +} + +export async function setEmail( + mongo: Db, + tenant: Tenant, + user: User, + email: string +) { + // We requires that the email address is not defined in order to use this + // method. + if (user.email) { + throw new Error("email address already associated with user"); + } + + return setUserEmail(mongo, tenant.id, user.id, email); +} + +export async function setPassword( + mongo: Db, + tenant: Tenant, + user: User, + password: string +) { + // We require that the email address for the user be defined for this method. + if (!user.email) { + throw new Error("no email address associated with user"); + } + + // We also don't allow this method to be used by users that already have a + // local profile. + if (user.profiles.some(({ type }) => type === "local")) { + throw new Error("user already has local profile"); + } + + return setUserLocalProfile(mongo, tenant.id, user.id, user.email, password); +} + +export async function updatePassword( + mongo: Db, + tenant: Tenant, + user: User, + password: string +) { + // We require that the email address for the user be defined for this method. + if (!user.email) { + throw new Error("no email address associated with user"); + } + + // We also don't allow this method to be used by users that don't have a local + // profile already. + if ( + !user.profiles.some(({ id, type }) => type === "local" && id === user.email) + ) { + throw new Error("user does not have a local profile"); + } + + return updateUserPassword(mongo, tenant.id, user.id, password); +} diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 271611af5..3dd57066a 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -95,8 +95,6 @@ configure-auth-displayNamesConfig-hideDisplayNames = Hide Display Names (if avai configure-auth-oidc-loginWith = Login with OpenID Connect configure-auth-oidc-toLearnMore = To learn more: <link></link> -configure-auth-oidc-redirectDescription = - For OpenID Connect, your Redirect URI will not appear until you after you save this integration. configure-auth-oidc-providerName = Provider Name configure-auth-oidc-providerNameDescription = The provider of the OpenID Connect integration. This will be used when the name of the provider diff --git a/src/locales/en-US/auth.ftl b/src/locales/en-US/auth.ftl index 81b40429d..f6ef7bd20 100644 --- a/src/locales/en-US/auth.ftl +++ b/src/locales/en-US/auth.ftl @@ -1,14 +1,35 @@ ### Localization for the Auth Popup +## General +general-orSeparator = Or + +general-usernameLabel = Username +general-usernameDescription = You may use “_” and “.” Spaces not permitted. +general-usernameTextField = + .placeholder = Username +general-emailAddressLabel = Email Address +general-emailAddressTextField = + .placeholder = Email Address +general-passwordLabel = Password +general-passwordDescription = Must be at least {$minLength} characters +general-passwordTextField = + .placeholder = Password +general-confirmPasswordLabel = Confirm Password +general-confirmPasswordTextField = + .placeholder = Confirm Password +general-confirmEmailAddressLabel = Confirm Email Address +general-confirmEmailAddressTextField = + .placeholder = Confirm Email Address + ## Sign In -signIn-signInToJoinHeader = Sign in to join the conversation +signIn-signInToJoinHeader = + <title>Sign Into join the conversation -signIn-signInAndJoinButton = Sign in to Join the Conversation - -signIn-emailAddressLabel = Email Address -signIn-emailAddressTextField = - .placeholder = Email Address +signIn-signInWithEmail = Sign in with Email +signIn-signInWithFacebook = Sign in with Facebook +signIn-signInWithGoogle = Sign in with Google +signIn-signInWithOIDC = Sign in with { $name } signIn-passwordLabel = Password signIn-passwordTextField = @@ -20,27 +41,13 @@ signIn-noAccountSignUp = Don't have an account? ## Sign Up -signUp-signUpToJoinHeader = Sign up to join the conversation +signUp-signUpToJoinHeader = + Sign Upto join the conversation -signUp-signUpAndJoinButton = Sign up and Join the Conversation - -signUp-emailAddressLabel = Email Address -signUp-emailAddressTextField = - .placeholder = Email Address - -signUp-usernameLabel = Username -signUp-usernameDescription = A unique identifier displayed on your comments. You may use “_” and “.” -signUp-usernameTextField = - .placeholder = Username - -signUp-passwordLabel = Password -signUp-passwordDescription = Must be at least {$minLength} characters -signUp-passwordTextField = - .placeholder = Password - -signUp-confirmPasswordLabel = Confirm Password -signUp-confirmPasswordTextField = - .placeholder = Confirm Password +signUp-signUpWithEmail = Sign up with Email +signUp-signUpWithFacebook = Sign up with Facebook +signUp-signUpWithGoogle = Sign up with Google +signUp-signUpWithOIDC = Sign up with { $name } signUp-accountAvailableSignIn = Already have an account? @@ -62,11 +69,37 @@ forgotPassword-emailAddressTextField = resetPassword-resetPasswordHeader = Reset Password resetPassword-resetPasswordButton = Reset Password -resetPassword-passwordLabel = Password -resetPassword-passwordDescription = Must be at least {$minLength} characters -resetPassword-passwordTextField = - .placeholder = Password +## Create Username -resetPassword-confirmPasswordLabel = Confirm Password -resetPassword-confirmPasswordTextField = - .placeholder = Confirm Password +createUsername-createUsernameHeader = Create Username +createUsername-whatItIs = + Your username is a unique identifier that will appear on all of your comments. +createUsername-createUsernameButton = Create Username + +## Add Email Address +addEmailAddress-addEmailAddressHeader = Add Email Address + +addEmailAddress-whatItIs = + For your added security, we require users to add an email address to their accounts. + Your email address will be used to: + +addEmailAddress-receiveUpdates = + Receive updates regarding any changes to your account + (email address, username, password, etc.) + +addEmailAddress-allowDownload = + Allow you to download your comments. + +addEmailAddress-sendNotifications = + Send comment notifications that you have chosen to receive. + +addEmailAddress-addEmailAddressButton = + Add Email Address + +## Create Password +createPassword-createPasswordHeader = Create Password +createPassword-whatItIs = + To protect against unauthorized changes to your account, + we require users to create a password. +createPassword-createPasswordButton = + Create Password diff --git a/src/locales/en-US/framework.ftl b/src/locales/en-US/framework.ftl index 13cbd0fee..0c962e00f 100644 --- a/src/locales/en-US/framework.ftl +++ b/src/locales/en-US/framework.ftl @@ -26,6 +26,7 @@ framework-validation-invalidEmail = Please enter a valid email address. framework-validation-passwordTooShort = Password must contain at least {$minLength} characters. framework-validation-passwordsDoNotMatch = Passwords do not match. Try again. framework-validation-invalidURL = Invalid URL +framework-validation-emailsDoNotMatch = Emails do not match. Try again. framework-timeago-just-now = Just now @@ -73,3 +74,6 @@ framework-timeago = framework-copyButton-copy = Copy framework-copyButton-copied = Copied +framework-passwordField = + .showPasswordTitle = Show password + .hidePasswordTitle = Hide password