From 7e8ef2189ddf833fe11d8921b60955dd11cccafb Mon Sep 17 00:00:00 2001 From: Kiwi Date: Mon, 4 Feb 2019 22:26:41 +0100 Subject: [PATCH] [next] Admin Authentication (#2155) * feat: Support sign in via E-Mail, and social logins + permission check * feat: no permission info * test: unit test * test: fix remaining tests * test: add integration tests * feat: add admin account completion * test: auth completion feature tests * fix: translation * fix: comment marker design * fix: linting issues * chore: address pr review comments * chore: add comment --- package-lock.json | 43 +- .../Login.css => components/AuthBox.css} | 2 +- .../client/admin/components/AuthBox.spec.tsx | 17 + src/core/client/admin/components/AuthBox.tsx | 41 + .../__snapshots__/AuthBox.spec.tsx.snap} | 33 +- src/core/client/admin/constants.ts | 1 + .../admin/containers/AuthCheckContainer.tsx | 124 ++ .../admin/containers/RedirectAppContainer.tsx | 45 - .../containers/RedirectLoginContainer.tsx | 87 +- src/core/client/admin/local/initLocalState.ts | 27 +- src/core/client/admin/local/local.graphql | 9 + .../admin/mutations/ClearAuthErrorMutation.ts | 18 + .../mutations/CompleteAccountMutation.ts | 25 + .../mutations/SetAuthViewMutation.spec.ts | 21 + .../admin/mutations/SetAuthViewMutation.ts | 28 + .../admin/mutations/SetEmailMutation.ts | 46 + .../admin/mutations/SetPasswordMutation.ts | 51 + .../mutations/SetRedirectPathMutation.spec.ts | 22 +- .../mutations/SetRedirectPathMutation.ts | 11 +- .../admin/mutations/SetUsernameMutation.ts | 49 + .../client/admin/mutations/SignInMutation.ts | 9 +- src/core/client/admin/mutations/index.ts | 21 + src/core/client/admin/routeConfig.tsx | 15 +- .../sections/auth/components/Auth.tsx | 2 +- .../auth/containers/AuthContainer.tsx | 63 +- .../auth/containers/AuthRouteContainer.tsx | 49 + .../login/components/CompleteAccountBox.css | 23 + .../components/CompleteAccountBox.spec.tsx | 17 + .../login/components/CompleteAccountBox.tsx | 37 + .../routes/login/components/EmailField.tsx | 58 + .../admin/routes/login/components/Login.tsx | 74 +- .../routes/login/components/SignInForm.tsx | 131 -- .../CompleteAccountBox.spec.tsx.snap | 40 + .../containers/AccountCompletionContainer.tsx | 141 ++ .../login/containers/LoginContainer.tsx | 63 + .../components/AddEmailAddress.tsx | 71 + .../components/ConfirmEmailField.tsx | 61 + .../addEmailAddress/components/EmailField.tsx | 58 + .../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 | 69 + .../components/SetPasswordField.tsx | 67 + .../containers/CreatePasswordContainer.tsx | 36 + .../components/CreateUsername.tsx | 69 + .../components/UsernameField.tsx | 65 + .../containers/CreateUsernameContainer.tsx | 36 + .../signIn/components/HorizontalSeparator.css | 16 + .../components/HorizontalSeparator.spec.tsx} | 4 +- .../signIn/components/HorizontalSeparator.tsx | 19 + .../views/signIn/components/OrSeparator.tsx | 13 + .../views/signIn/components/SignIn.spec.tsx | 19 + .../login/views/signIn/components/SignIn.tsx | 61 + .../signIn/components/SignInWithEmail.tsx | 96 ++ .../HorizontalSeparator.spec.tsx.snap} | 16 +- .../__snapshots__/SignIn.spec.tsx.snap | 21 + .../signIn/containers/SignInContainer.tsx | 106 ++ .../containers/SignInWithEmailContainer.tsx} | 17 +- .../SignInWithFacebookContainer.tsx | 41 + .../containers/SignInWithGoogleContainer.tsx | 41 + .../containers/SignInWithOIDCContainer.tsx | 43 + .../moderate/containers/ModerateContainer.tsx | 28 +- .../test/__snapshots__/login.spec.tsx.snap | 1515 ----------------- .../addEmailAddress.spec.tsx.snap | 595 +++++++ .../createPassword.spec.tsx.snap | 458 +++++ .../createUsername.spec.tsx.snap | 397 +++++ .../__snapshots__/restricted.spec.tsx.snap | 134 ++ .../signInWithEmail.spec.tsx.snap | 735 ++++++++ .../test/auth/accountCompletion.spec.tsx | 127 ++ .../admin/test/auth/addEmailAddress.spec.tsx | 187 ++ .../admin/test/auth/createPassword.spec.tsx | 139 ++ .../admin/test/auth/createUsername.spec.tsx | 145 ++ .../test/{ => auth}/redirectLoggedIn.spec.tsx | 27 +- .../{ => auth}/redirectLoggedOut.spec.tsx | 25 +- .../admin/test/auth/restricted.spec.tsx | 107 ++ .../signInWithEmail.spec.tsx} | 92 +- .../admin/test/{ => auth}/signOut.spec.tsx | 38 +- .../__snapshots__/auth.spec.tsx.snap | 1 + .../client/admin/test/configure/auth.spec.tsx | 38 +- src/core/client/admin/test/create.tsx | 5 +- .../client/admin/test/createEnvironment.ts | 2 + .../decisionHistory/decisionHistory.spec.tsx | 4 +- src/core/client/admin/test/fixtures.ts | 78 + .../admin/test/moderate/moderate.spec.tsx | 2 + .../restricted/components/Restricted.css | 9 + .../restricted/components/Restricted.spec.tsx | 18 + .../restricted/components/Restricted.tsx | 80 + .../__snapshots__/Restricted.spec.tsx.snap | 83 + .../containers/RestrictedContainer.tsx | 56 + ...rSeparator.css => HorizontalSeparator.css} | 0 .../components/HorizontalSeparator.spec.tsx} | 4 +- .../auth/components/HorizontalSeparator.tsx | 19 + .../client/auth/components/OrSeparator.tsx | 16 +- .../HorizontalSeparator.spec.tsx.snap | 18 + .../containers/AccountCompletionContainer.tsx | 14 +- src/core/client/auth/helpers/index.ts | 1 - src/core/client/auth/local/initLocalState.ts | 19 +- .../client/auth/mutations/SignInMutation.ts | 4 +- .../client/auth/mutations/SignUpMutation.ts | 2 +- src/core/client/auth/mutations/index.ts | 8 +- .../test/__snapshots__/signIn.spec.tsx.snap | 6 +- .../test/__snapshots__/signUp.spec.tsx.snap | 6 +- src/core/client/auth/test/create.tsx | 3 +- .../containers/CreatePasswordContainer.tsx | 1 - .../SignInWithFacebookContainer.tsx | 4 +- .../containers/SignInWithGoogleContainer.tsx | 4 +- .../containers/SignInWithOIDCContainer.tsx | 4 +- .../SignUpWithFacebookContainer.tsx | 4 +- .../containers/SignUpWithGoogleContainer.tsx | 4 +- .../containers/SignUpWithOIDCContainer.tsx | 4 +- .../components/FacebookButton.css | 0 .../components/FacebookButton.spec.tsx | 0 .../components/FacebookButton.tsx | 0 .../components/GoogleButton.css | 0 .../components/GoogleButton.spec.tsx | 0 .../components/GoogleButton.tsx | 0 .../components/OIDCButton.css | 0 .../components/OIDCButton.spec.tsx | 0 .../components/OIDCButton.tsx | 0 .../FacebookButton.spec.tsx.snap | 0 .../__snapshots__/GoogleButton.spec.tsx.snap | 0 .../__snapshots__/OIDCButton.spec.tsx.snap | 0 src/core/client/framework/components/index.ts | 3 + .../helpers/getParamsFromHashAndClearIt.ts | 19 + src/core/client/framework/helpers/index.ts | 4 + .../helpers/redirectOAuth2.ts | 0 .../framework/lib/bootstrap/TalkContext.tsx | 2 +- .../framework/lib/bootstrap/createManaged.tsx | 2 +- src/core/client/framework/lib/router/index.ts | 1 + .../framework/lib/router/withRouteConfig.ts | 33 + .../mutations/SetAuthTokenMutation.ts | 2 +- src/core/client/stream/test/create.tsx | 3 +- .../client/ui/components/Marker/Count.css | 4 +- .../ui/components/Typography/Typography.css | 4 + .../ui/components/Typography/Typography.tsx | 2 + .../server/graph/common/directives/auth.ts | 4 +- src/locales/en-US/admin.ftl | 64 +- src/locales/en-US/stream.ftl | 1 + 143 files changed, 5991 insertions(+), 2048 deletions(-) rename src/core/client/admin/{routes/login/components/Login.css => components/AuthBox.css} (96%) create mode 100644 src/core/client/admin/components/AuthBox.spec.tsx create mode 100644 src/core/client/admin/components/AuthBox.tsx rename src/core/client/admin/{routes/login/components/__snapshots__/Login.spec.tsx.snap => components/__snapshots__/AuthBox.spec.tsx.snap} (57%) create mode 100644 src/core/client/admin/constants.ts create mode 100644 src/core/client/admin/containers/AuthCheckContainer.tsx delete mode 100644 src/core/client/admin/containers/RedirectAppContainer.tsx create mode 100644 src/core/client/admin/mutations/ClearAuthErrorMutation.ts create mode 100644 src/core/client/admin/mutations/CompleteAccountMutation.ts create mode 100644 src/core/client/admin/mutations/SetAuthViewMutation.spec.ts create mode 100644 src/core/client/admin/mutations/SetAuthViewMutation.ts create mode 100644 src/core/client/admin/mutations/SetEmailMutation.ts create mode 100644 src/core/client/admin/mutations/SetPasswordMutation.ts create mode 100644 src/core/client/admin/mutations/SetUsernameMutation.ts create mode 100644 src/core/client/admin/routes/configure/sections/auth/containers/AuthRouteContainer.tsx create mode 100644 src/core/client/admin/routes/login/components/CompleteAccountBox.css create mode 100644 src/core/client/admin/routes/login/components/CompleteAccountBox.spec.tsx create mode 100644 src/core/client/admin/routes/login/components/CompleteAccountBox.tsx create mode 100644 src/core/client/admin/routes/login/components/EmailField.tsx delete mode 100644 src/core/client/admin/routes/login/components/SignInForm.tsx create mode 100644 src/core/client/admin/routes/login/components/__snapshots__/CompleteAccountBox.spec.tsx.snap create mode 100644 src/core/client/admin/routes/login/containers/AccountCompletionContainer.tsx create mode 100644 src/core/client/admin/routes/login/containers/LoginContainer.tsx create mode 100644 src/core/client/admin/routes/login/views/addEmailAddress/components/AddEmailAddress.tsx create mode 100644 src/core/client/admin/routes/login/views/addEmailAddress/components/ConfirmEmailField.tsx create mode 100644 src/core/client/admin/routes/login/views/addEmailAddress/components/EmailField.tsx create mode 100644 src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/ListItem.css create mode 100644 src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/ListItem.tsx create mode 100644 src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/UnorderedList.css create mode 100644 src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/UnorderedList.spec.tsx create mode 100644 src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/UnorderedList.tsx create mode 100644 src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/__snapshots__/UnorderedList.spec.tsx.snap create mode 100644 src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/index.ts create mode 100644 src/core/client/admin/routes/login/views/addEmailAddress/containers/AddEmailAddressContainer.tsx create mode 100644 src/core/client/admin/routes/login/views/createPassword/components/CreatePassword.tsx create mode 100644 src/core/client/admin/routes/login/views/createPassword/components/SetPasswordField.tsx create mode 100644 src/core/client/admin/routes/login/views/createPassword/containers/CreatePasswordContainer.tsx create mode 100644 src/core/client/admin/routes/login/views/createUsername/components/CreateUsername.tsx create mode 100644 src/core/client/admin/routes/login/views/createUsername/components/UsernameField.tsx create mode 100644 src/core/client/admin/routes/login/views/createUsername/containers/CreateUsernameContainer.tsx create mode 100644 src/core/client/admin/routes/login/views/signIn/components/HorizontalSeparator.css rename src/core/client/{auth/components/OrSeparator.spec.tsx => admin/routes/login/views/signIn/components/HorizontalSeparator.spec.tsx} (64%) create mode 100644 src/core/client/admin/routes/login/views/signIn/components/HorizontalSeparator.tsx create mode 100644 src/core/client/admin/routes/login/views/signIn/components/OrSeparator.tsx create mode 100644 src/core/client/admin/routes/login/views/signIn/components/SignIn.spec.tsx create mode 100644 src/core/client/admin/routes/login/views/signIn/components/SignIn.tsx create mode 100644 src/core/client/admin/routes/login/views/signIn/components/SignInWithEmail.tsx rename src/core/client/{auth/components/__snapshots__/OrSeparator.spec.tsx.snap => admin/routes/login/views/signIn/components/__snapshots__/HorizontalSeparator.spec.tsx.snap} (51%) create mode 100644 src/core/client/admin/routes/login/views/signIn/components/__snapshots__/SignIn.spec.tsx.snap create mode 100644 src/core/client/admin/routes/login/views/signIn/containers/SignInContainer.tsx rename src/core/client/admin/routes/login/{containers/SignInFormContainer.tsx => views/signIn/containers/SignInWithEmailContainer.tsx} (62%) create mode 100644 src/core/client/admin/routes/login/views/signIn/containers/SignInWithFacebookContainer.tsx create mode 100644 src/core/client/admin/routes/login/views/signIn/containers/SignInWithGoogleContainer.tsx create mode 100644 src/core/client/admin/routes/login/views/signIn/containers/SignInWithOIDCContainer.tsx delete mode 100644 src/core/client/admin/test/__snapshots__/login.spec.tsx.snap create mode 100644 src/core/client/admin/test/auth/__snapshots__/addEmailAddress.spec.tsx.snap create mode 100644 src/core/client/admin/test/auth/__snapshots__/createPassword.spec.tsx.snap create mode 100644 src/core/client/admin/test/auth/__snapshots__/createUsername.spec.tsx.snap create mode 100644 src/core/client/admin/test/auth/__snapshots__/restricted.spec.tsx.snap create mode 100644 src/core/client/admin/test/auth/__snapshots__/signInWithEmail.spec.tsx.snap create mode 100644 src/core/client/admin/test/auth/accountCompletion.spec.tsx create mode 100644 src/core/client/admin/test/auth/addEmailAddress.spec.tsx create mode 100644 src/core/client/admin/test/auth/createPassword.spec.tsx create mode 100644 src/core/client/admin/test/auth/createUsername.spec.tsx rename src/core/client/admin/test/{ => auth}/redirectLoggedIn.spec.tsx (65%) rename src/core/client/admin/test/{ => auth}/redirectLoggedOut.spec.tsx (69%) create mode 100644 src/core/client/admin/test/auth/restricted.spec.tsx rename src/core/client/admin/test/{login.spec.tsx => auth/signInWithEmail.spec.tsx} (60%) rename src/core/client/admin/test/{ => auth}/signOut.spec.tsx (62%) create mode 100644 src/core/client/admin/views/restricted/components/Restricted.css create mode 100644 src/core/client/admin/views/restricted/components/Restricted.spec.tsx create mode 100644 src/core/client/admin/views/restricted/components/Restricted.tsx create mode 100644 src/core/client/admin/views/restricted/components/__snapshots__/Restricted.spec.tsx.snap create mode 100644 src/core/client/admin/views/restricted/containers/RestrictedContainer.tsx rename src/core/client/auth/components/{OrSeparator.css => HorizontalSeparator.css} (100%) rename src/core/client/{admin/routes/login/components/Login.spec.tsx => auth/components/HorizontalSeparator.spec.tsx} (64%) create mode 100644 src/core/client/auth/components/HorizontalSeparator.tsx create mode 100644 src/core/client/auth/components/__snapshots__/HorizontalSeparator.spec.tsx.snap delete mode 100644 src/core/client/auth/helpers/index.ts rename src/core/client/{auth => framework}/components/FacebookButton.css (100%) rename src/core/client/{auth => framework}/components/FacebookButton.spec.tsx (100%) rename src/core/client/{auth => framework}/components/FacebookButton.tsx (100%) rename src/core/client/{auth => framework}/components/GoogleButton.css (100%) rename src/core/client/{auth => framework}/components/GoogleButton.spec.tsx (100%) rename src/core/client/{auth => framework}/components/GoogleButton.tsx (100%) rename src/core/client/{auth => framework}/components/OIDCButton.css (100%) rename src/core/client/{auth => framework}/components/OIDCButton.spec.tsx (100%) rename src/core/client/{auth => framework}/components/OIDCButton.tsx (100%) rename src/core/client/{auth => framework}/components/__snapshots__/FacebookButton.spec.tsx.snap (100%) rename src/core/client/{auth => framework}/components/__snapshots__/GoogleButton.spec.tsx.snap (100%) rename src/core/client/{auth => framework}/components/__snapshots__/OIDCButton.spec.tsx.snap (100%) create mode 100644 src/core/client/framework/helpers/getParamsFromHashAndClearIt.ts rename src/core/client/{auth => framework}/helpers/redirectOAuth2.ts (100%) create mode 100644 src/core/client/framework/lib/router/index.ts create mode 100644 src/core/client/framework/lib/router/withRouteConfig.ts diff --git a/package-lock.json b/package-lock.json index 359d2bdeb..81923c4a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11378,7 +11378,7 @@ "integrity": "sha1-ETOUSrJHeINHOZVZaIPg05z4hc8=", "dev": true, "requires": { - "intl-pluralrules": "github:projectfluent/IntlPluralRules#94cb0fa1c23ad943bc5aafef43cea132fa51d68b" + "intl-pluralrules": "github:projectfluent/IntlPluralRules#module" } }, "fluent-langneg": { @@ -11672,7 +11672,8 @@ "ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "optional": true }, "aproba": { "version": "1.2.0", @@ -11693,12 +11694,14 @@ "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "optional": true }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -11713,17 +11716,20 @@ "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "optional": true }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "optional": true }, "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -11840,7 +11846,8 @@ "inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "optional": true }, "ini": { "version": "1.3.5", @@ -11852,6 +11859,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -11866,6 +11874,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -11873,12 +11882,14 @@ "minimist": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "optional": true }, "minipass": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.2.4.tgz", "integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==", + "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -11897,6 +11908,7 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "optional": true, "requires": { "minimist": "0.0.8" } @@ -11977,7 +11989,8 @@ "number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "optional": true }, "object-assign": { "version": "4.1.1", @@ -11989,6 +12002,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "optional": true, "requires": { "wrappy": "1" } @@ -12074,7 +12088,8 @@ "safe-buffer": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", - "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -12110,6 +12125,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -12129,6 +12145,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -12172,12 +12189,14 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "optional": true }, "yallist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz", - "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=" + "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=", + "optional": true } } }, diff --git a/src/core/client/admin/routes/login/components/Login.css b/src/core/client/admin/components/AuthBox.css similarity index 96% rename from src/core/client/admin/routes/login/components/Login.css rename to src/core/client/admin/components/AuthBox.css index 4e127bea3..19701d1bc 100644 --- a/src/core/client/admin/routes/login/components/Login.css +++ b/src/core/client/admin/components/AuthBox.css @@ -1,4 +1,4 @@ -.loginContainer { +.container { width: 350px; background-color: #f5f5f5; border: 1px solid var(--palette-grey-lighter); diff --git a/src/core/client/admin/components/AuthBox.spec.tsx b/src/core/client/admin/components/AuthBox.spec.tsx new file mode 100644 index 000000000..30221b1f0 --- /dev/null +++ b/src/core/client/admin/components/AuthBox.spec.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; + +import { PropTypesOf } from "talk-framework/types"; + +import AuthBox from "./AuthBox"; + +it("renders correctly", () => { + const props: PropTypesOf = { + title: "title", + children: "content", + }; + const renderer = createRenderer(); + + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); +}); diff --git a/src/core/client/admin/components/AuthBox.tsx b/src/core/client/admin/components/AuthBox.tsx new file mode 100644 index 000000000..94864c614 --- /dev/null +++ b/src/core/client/admin/components/AuthBox.tsx @@ -0,0 +1,41 @@ +import React, { StatelessComponent } from "react"; + +import { + BrandIcon, + BrandName, + Flex, + HorizontalGutter, + Typography, +} from "talk-ui/components"; + +import styles from "./AuthBox.css"; + +interface Props { + title: React.ReactNode; + children: React.ReactNode; +} + +const AuthBox: StatelessComponent = ({ title, children }) => { + return ( +
+ + + +
+ +
+
+
+ + {title} + + +
+ {children} +
+
+
+ ); +}; + +export default AuthBox; diff --git a/src/core/client/admin/routes/login/components/__snapshots__/Login.spec.tsx.snap b/src/core/client/admin/components/__snapshots__/AuthBox.spec.tsx.snap similarity index 57% rename from src/core/client/admin/routes/login/components/__snapshots__/Login.spec.tsx.snap rename to src/core/client/admin/components/__snapshots__/AuthBox.spec.tsx.snap index d9047af69..5c94d7c95 100644 --- a/src/core/client/admin/routes/login/components/__snapshots__/Login.spec.tsx.snap +++ b/src/core/client/admin/components/__snapshots__/AuthBox.spec.tsx.snap @@ -1,19 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`renders correctly 1`] = ` -
+
- - - Sign in to - - + title +
- - - Enter your account details below - - - + content
diff --git a/src/core/client/admin/constants.ts b/src/core/client/admin/constants.ts new file mode 100644 index 000000000..1cec8b46c --- /dev/null +++ b/src/core/client/admin/constants.ts @@ -0,0 +1 @@ +export const REDIRECT_PATH_KEY = "adminRedirectPath"; diff --git a/src/core/client/admin/containers/AuthCheckContainer.tsx b/src/core/client/admin/containers/AuthCheckContainer.tsx new file mode 100644 index 000000000..cae232356 --- /dev/null +++ b/src/core/client/admin/containers/AuthCheckContainer.tsx @@ -0,0 +1,124 @@ +import { Match, Router, withRouter } from "found"; +import React from "react"; + +import { AuthCheckContainerQueryResponse } from "talk-admin/__generated__/AuthCheckContainerQuery.graphql"; +import { + SetRedirectPathMutation, + withSetRedirectPathMutation, +} from "talk-admin/mutations"; +import RestrictedContainer from "talk-admin/views/restricted/containers/RestrictedContainer"; +import { graphql } from "talk-framework/lib/relay"; +import { withRouteConfig } from "talk-framework/lib/router"; + +interface Props { + match: Match; + router: Router; + setRedirectPath: SetRedirectPathMutation; + data: AuthCheckContainerQueryResponse | null; +} + +class AuthCheckContainer extends React.Component { + private wasLoggedIn = false; + constructor(props: Props) { + super(props); + this.redirectIfNotLoggedIn(); + } + + public componentWillReceiveProps(nextProps: Props) { + if (nextProps.data && nextProps.data.me) { + this.wasLoggedIn = true; + } + this.redirectIfNotLoggedIn(nextProps, this.props); + if (nextProps.data && !nextProps.data.me) { + this.wasLoggedIn = false; + } + } + + private shouldRedirectTo(props: Props = this.props) { + if (!props.data || props.data.me) { + return false; + } + return true; + } + + private hasAccess(props: Props = this.props) { + const { + me, + settings: { auth }, + } = props.data!; + if (me) { + if (me.role === "COMMENTER") { + return false; + } else if ( + !me.email || + !me.username || + (!me.profiles.some(p => p.__typename === "LocalProfile") && + auth.integrations.local.enabled && + (auth.integrations.local.targetFilter.admin || + auth.integrations.local.targetFilter.stream)) + ) { + return false; + } + return true; + } + return false; + } + + private async redirectIfNotLoggedIn( + props: Props = this.props, + prevProps: Props | null = null + ) { + if (!this.shouldRedirectTo(props)) { + return; + } + // If I was previously logged in then logged out, we don't need to set the redirect path. + if (!this.wasLoggedIn) { + const location = props.match.location; + await props.setRedirectPath({ + path: location.pathname + location.search + location.hash, + }); + } + props.router.replace("/admin/login"); + } + + public render() { + if (!this.props.data || this.shouldRedirectTo()) { + return null; + } + if (this.hasAccess()) { + return this.props.children; + } + return ; + } +} + +const enhanced = withRouteConfig({ + query: graphql` + query AuthCheckContainerQuery { + me { + ...RestrictedContainer_me + username + email + profiles { + __typename + } + role + } + settings { + auth { + integrations { + local { + enabled + targetFilter { + admin + stream + } + } + } + } + } + } + `, +})(withRouter(withSetRedirectPathMutation(AuthCheckContainer))); + +export default enhanced; diff --git a/src/core/client/admin/containers/RedirectAppContainer.tsx b/src/core/client/admin/containers/RedirectAppContainer.tsx deleted file mode 100644 index 71e3af2d5..000000000 --- a/src/core/client/admin/containers/RedirectAppContainer.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Match, Router, withRouter } from "found"; -import React from "react"; - -import { RedirectAppContainerLocal as Local } from "talk-admin/__generated__/RedirectAppContainerLocal.graphql"; -import { graphql, withLocalStateContainer } from "talk-framework/lib/relay"; - -interface Props { - local: Local; - match: Match; - router: Router; -} - -class RedirectAppContainer extends React.Component { - constructor(props: Props) { - super(props); - this.redirectIfLoggedIn(); - } - - public componentWillReceiveProps() { - this.redirectIfLoggedIn(); - } - - private redirectIfLoggedIn() { - if (this.props.local.loggedIn) { - this.props.router.replace(this.props.local.redirectPath || "/admin"); - } - } - - public render() { - return this.props.children; - } -} - -const enhanced = withRouter( - withLocalStateContainer( - graphql` - fragment RedirectAppContainerLocal on Local { - loggedIn - redirectPath - } - ` - )(RedirectAppContainer) -); - -export default enhanced; diff --git a/src/core/client/admin/containers/RedirectLoginContainer.tsx b/src/core/client/admin/containers/RedirectLoginContainer.tsx index 3df2da920..475a28aad 100644 --- a/src/core/client/admin/containers/RedirectLoginContainer.tsx +++ b/src/core/client/admin/containers/RedirectLoginContainer.tsx @@ -1,18 +1,19 @@ import { Match, Router, withRouter } from "found"; import React from "react"; -import { RedirectLoginContainerLocal as Local } from "talk-admin/__generated__/RedirectLoginContainerLocal.graphql"; +import { RedirectLoginContainerQueryResponse } from "talk-admin/__generated__/RedirectLoginContainerQuery.graphql"; import { SetRedirectPathMutation, withSetRedirectPathMutation, } from "talk-admin/mutations"; -import { graphql, withLocalStateContainer } from "talk-framework/lib/relay"; +import { graphql } from "talk-framework/lib/relay"; +import { withRouteConfig } from "talk-framework/lib/router"; interface Props { - local: Local; match: Match; router: Router; setRedirectPath: SetRedirectPathMutation; + data: RedirectLoginContainerQueryResponse | null; } class RedirectLoginContainer extends React.Component { @@ -21,35 +22,81 @@ class RedirectLoginContainer extends React.Component { this.redirectIfNotLoggedIn(); } - public componentWillReceiveProps() { - this.redirectIfNotLoggedIn(); + public componentWillReceiveProps(nextProps: Props) { + this.redirectIfNotLoggedIn(nextProps); } - private redirectIfNotLoggedIn() { - if (!this.props.local.loggedIn) { - const location = this.props.match.location; - this.props.setRedirectPath({ + private shouldRedirectTo(props: Props = this.props): string | null { + if (!props.data) { + return null; + } + const { + me, + settings: { auth }, + } = props.data!; + if (me) { + if (me.role === "COMMENTER") { + return "/admin/login"; + } else if ( + !me.email || + !me.username || + (!me.profiles.some(p => p.__typename === "LocalProfile") && + auth.integrations.local.enabled && + (auth.integrations.local.targetFilter.admin || + auth.integrations.local.targetFilter.stream)) + ) { + return "/admin/login"; + } + return ""; + } + return "/admin/login"; + } + + private redirectIfNotLoggedIn(props: Props = this.props) { + const redirect = this.shouldRedirectTo(props); + if (redirect) { + const location = props.match.location; + props.setRedirectPath({ path: location.pathname + location.search + location.hash, }); - this.props.router.replace("/admin/login"); + props.router.replace(redirect); } } public render() { + if (this.shouldRedirectTo()) { + return null; + } return this.props.children; } } -const enhanced = withRouter( - withSetRedirectPathMutation( - withLocalStateContainer( - graphql` - fragment RedirectLoginContainerLocal on Local { - loggedIn +const enhanced = withRouteConfig({ + query: graphql` + query RedirectLoginContainerQuery { + me { + username + email + profiles { + __typename } - ` - )(RedirectLoginContainer) - ) -); + role + } + settings { + auth { + integrations { + local { + enabled + targetFilter { + admin + stream + } + } + } + } + } + } + `, +})(withRouter(withSetRedirectPathMutation(RedirectLoginContainer))); export default enhanced; diff --git a/src/core/client/admin/local/initLocalState.ts b/src/core/client/admin/local/initLocalState.ts index 50f8d3741..bdf39b035 100644 --- a/src/core/client/admin/local/initLocalState.ts +++ b/src/core/client/admin/local/initLocalState.ts @@ -1,7 +1,9 @@ -import { Environment } from "relay-runtime"; +import { commitLocalUpdate, Environment } from "relay-runtime"; +import { REDIRECT_PATH_KEY } from "talk-admin/constants"; +import { getParamsFromHashAndClearIt } from "talk-framework/helpers"; import { TalkContext } from "talk-framework/lib/bootstrap"; -import { initLocalBaseState } from "talk-framework/lib/relay"; +import { initLocalBaseState, LOCAL_ID } from "talk-framework/lib/relay"; /** * Initializes the local state, before we start the App. @@ -10,5 +12,24 @@ export default async function initLocalState( environment: Environment, context: TalkContext ) { - await initLocalBaseState(environment, context); + const { error = null, accessToken } = getParamsFromHashAndClearIt(); + let redirectPath: string | null = null; + if (error || accessToken) { + // Keep redirect path as we are in the middle of an auth flow. + redirectPath = + (await context.localStorage.getItem(REDIRECT_PATH_KEY)) || null; + } else { + // Remove redirect path from local storage as we start a new auth flow. + await context.localStorage.setItem(REDIRECT_PATH_KEY, ""); + } + + await initLocalBaseState(environment, context, accessToken); + + commitLocalUpdate(environment, s => { + const localRecord = s.get(LOCAL_ID)!; + + localRecord.setValue(redirectPath, "redirectPath"); + localRecord.setValue("SIGN_IN", "authView"); + localRecord.setValue(error, "authError"); + }); } diff --git a/src/core/client/admin/local/local.graphql b/src/core/client/admin/local/local.graphql index 54ac8b637..5cdb5dacd 100644 --- a/src/core/client/admin/local/local.graphql +++ b/src/core/client/admin/local/local.graphql @@ -3,6 +3,13 @@ type Network { isOffline: Boolean! } +enum View { + SIGN_IN + CREATE_USERNAME + CREATE_PASSWORD + ADD_EMAIL_ADDRESS +} + type Local { network: Network! authToken: String @@ -10,6 +17,8 @@ type Local { authJTI: String loggedIn: Boolean! redirectPath: String + authView: View + authError: String } extend type Query { diff --git a/src/core/client/admin/mutations/ClearAuthErrorMutation.ts b/src/core/client/admin/mutations/ClearAuthErrorMutation.ts new file mode 100644 index 000000000..d907e6d19 --- /dev/null +++ b/src/core/client/admin/mutations/ClearAuthErrorMutation.ts @@ -0,0 +1,18 @@ +import { commitLocalUpdate, Environment } from "relay-runtime"; + +import { createMutationContainer } from "talk-framework/lib/relay"; +import { LOCAL_ID } from "talk-framework/lib/relay/withLocalStateContainer"; + +export type ClearAuthErrorMutation = () => Promise; + +export async function commit(environment: Environment, input: undefined) { + return commitLocalUpdate(environment, store => { + const record = store.get(LOCAL_ID)!; + record.setValue(null, "authError"); + }); +} + +export const withClearAuthErrorMutation = createMutationContainer( + "clearAuthError", + commit +); diff --git a/src/core/client/admin/mutations/CompleteAccountMutation.ts b/src/core/client/admin/mutations/CompleteAccountMutation.ts new file mode 100644 index 000000000..1856794a9 --- /dev/null +++ b/src/core/client/admin/mutations/CompleteAccountMutation.ts @@ -0,0 +1,25 @@ +import { Environment } from "relay-runtime"; + +import { TalkContext } from "talk-framework/lib/bootstrap"; +import { createMutationContainer } from "talk-framework/lib/relay"; +import { commit as setAuthToken } from "talk-framework/mutations/SetAuthTokenMutation"; + +interface CompleteAccountInput { + authToken: string; +} +export type CompleteAccountMutation = ( + input: CompleteAccountInput +) => Promise; + +export async function commit( + environment: Environment, + input: CompleteAccountInput, + context: TalkContext +) { + await setAuthToken(environment, { authToken: input.authToken }, context); +} + +export const withCompleteAccountMutation = createMutationContainer( + "completeAccount", + commit +); diff --git a/src/core/client/admin/mutations/SetAuthViewMutation.spec.ts b/src/core/client/admin/mutations/SetAuthViewMutation.spec.ts new file mode 100644 index 000000000..177659366 --- /dev/null +++ b/src/core/client/admin/mutations/SetAuthViewMutation.spec.ts @@ -0,0 +1,21 @@ +import { Environment, RecordSource } from "relay-runtime"; + +import { LOCAL_ID } from "talk-framework/lib/relay"; +import { createRelayEnvironment } from "talk-framework/testHelpers"; + +import { commit } from "./SetAuthViewMutation"; + +let environment: Environment; +const source: RecordSource = new RecordSource(); + +beforeAll(() => { + environment = createRelayEnvironment({ + source, + }); +}); + +it("Sets view", () => { + const view = "SIGN_IN"; + commit(environment, { view }, {} as any); + expect(source.get(LOCAL_ID)!.authView).toEqual(view); +}); diff --git a/src/core/client/admin/mutations/SetAuthViewMutation.ts b/src/core/client/admin/mutations/SetAuthViewMutation.ts new file mode 100644 index 000000000..e05ed7b0a --- /dev/null +++ b/src/core/client/admin/mutations/SetAuthViewMutation.ts @@ -0,0 +1,28 @@ +import { commitLocalUpdate, Environment } from "relay-runtime"; + +import { TalkContext } from "talk-framework/lib/bootstrap"; +import { createMutationContainer } from "talk-framework/lib/relay"; +import { LOCAL_ID } from "talk-framework/lib/relay/withLocalStateContainer"; + +export interface SetAuthViewInput { + // TODO: replace with generated typescript types. + view: "SIGN_IN" | "ADD_EMAIL_ADDRESS" | "CREATE_USERNAME" | "CREATE_PASSWORD"; +} + +export type SetAuthViewMutation = (input: SetAuthViewInput) => Promise; + +export async function commit( + environment: Environment, + input: SetAuthViewInput, + { pym }: TalkContext +) { + return commitLocalUpdate(environment, store => { + const record = store.get(LOCAL_ID)!; + record.setValue(input.view, "authView"); + }); +} + +export const withSetAuthViewMutation = createMutationContainer( + "setAuthView", + commit +); diff --git a/src/core/client/admin/mutations/SetEmailMutation.ts b/src/core/client/admin/mutations/SetEmailMutation.ts new file mode 100644 index 000000000..7479fcf4a --- /dev/null +++ b/src/core/client/admin/mutations/SetEmailMutation.ts @@ -0,0 +1,46 @@ +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 { SetEmailMutation as MutationTypes } from "talk-admin/__generated__/SetEmailMutation.graphql"; + +export type SetEmailInput = Omit< + MutationTypes["variables"]["input"], + "clientMutationId" +>; + +const mutation = graphql` + mutation SetEmailMutation($input: SetEmailInput!) { + setEmail(input: $input) { + user { + email + } + clientMutationId + } + } +`; + +let clientMutationId = 0; + +function commit(environment: Environment, input: SetEmailInput) { + return commitMutationPromiseNormalized(environment, { + mutation, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }); +} + +export const withSetEmailMutation = createMutationContainer("setEmail", commit); + +export type SetEmailMutation = ( + input: SetEmailInput +) => Promise; diff --git a/src/core/client/admin/mutations/SetPasswordMutation.ts b/src/core/client/admin/mutations/SetPasswordMutation.ts new file mode 100644 index 000000000..e9cb7bd38 --- /dev/null +++ b/src/core/client/admin/mutations/SetPasswordMutation.ts @@ -0,0 +1,51 @@ +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 { SetPasswordMutation as MutationTypes } from "talk-admin/__generated__/SetPasswordMutation.graphql"; + +export type SetPasswordInput = Omit< + MutationTypes["variables"]["input"], + "clientMutationId" +>; + +const mutation = graphql` + mutation SetPasswordMutation($input: SetPasswordInput!) { + setPassword(input: $input) { + user { + profiles { + __typename + } + } + clientMutationId + } + } +`; + +let clientMutationId = 0; + +function commit(environment: Environment, input: SetPasswordInput) { + return commitMutationPromiseNormalized(environment, { + mutation, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }); +} + +export const withSetPasswordMutation = createMutationContainer( + "setPassword", + commit +); + +export type SetPasswordMutation = ( + input: SetPasswordInput +) => Promise; diff --git a/src/core/client/admin/mutations/SetRedirectPathMutation.spec.ts b/src/core/client/admin/mutations/SetRedirectPathMutation.spec.ts index 1993ac7fe..f801e838b 100644 --- a/src/core/client/admin/mutations/SetRedirectPathMutation.spec.ts +++ b/src/core/client/admin/mutations/SetRedirectPathMutation.spec.ts @@ -1,8 +1,13 @@ import { Environment, RecordSource } from "relay-runtime"; +import { REDIRECT_PATH_KEY } from "talk-admin/constants"; import { LOCAL_ID } from "talk-framework/lib/relay"; import { createRelayEnvironment } from "talk-framework/testHelpers"; +import { + createInMemoryStorage, + createPromisifiedStorage, +} from "talk-framework/lib/storage"; import { commit } from "./SetRedirectPathMutation"; let environment: Environment; @@ -14,7 +19,20 @@ beforeAll(() => { }); }); -it("Sets redirectPath", () => { - commit(environment, { path: "/path" }); +it("Sets redirectPath", async () => { + const storage = createInMemoryStorage(); + await commit(environment, { path: "/path" }, { + localStorage: createPromisifiedStorage(storage), + } as any); expect(source.get(LOCAL_ID)!.redirectPath).toEqual("/path"); + expect(storage.getItem(REDIRECT_PATH_KEY)).toEqual("/path"); +}); + +it("Removes redirectPath", async () => { + const storage = createInMemoryStorage(); + await commit(environment, { path: null }, { + localStorage: createPromisifiedStorage(storage), + } as any); + expect(source.get(LOCAL_ID)!.redirectPath).toEqual(null); + expect(storage.getItem(REDIRECT_PATH_KEY)).toEqual(null); }); diff --git a/src/core/client/admin/mutations/SetRedirectPathMutation.ts b/src/core/client/admin/mutations/SetRedirectPathMutation.ts index 2fe29d1f1..635adafd4 100644 --- a/src/core/client/admin/mutations/SetRedirectPathMutation.ts +++ b/src/core/client/admin/mutations/SetRedirectPathMutation.ts @@ -1,5 +1,7 @@ import { commitLocalUpdate, Environment } from "relay-runtime"; +import { REDIRECT_PATH_KEY } from "talk-admin/constants"; +import { TalkContext } from "talk-framework/lib/bootstrap"; import { createMutationContainer, LOCAL_ID } from "talk-framework/lib/relay"; export interface SetRedirectPathInput { @@ -12,8 +14,15 @@ export type SetRedirectPathMutation = ( export async function commit( environment: Environment, - input: SetRedirectPathInput + input: SetRedirectPathInput, + { localStorage }: TalkContext ) { + if (!input.path) { + await localStorage.removeItem(REDIRECT_PATH_KEY); + } else { + await localStorage.setItem(REDIRECT_PATH_KEY, input.path); + } + return commitLocalUpdate(environment, store => { const record = store.get(LOCAL_ID)!; record.setValue(input.path, "redirectPath"); diff --git a/src/core/client/admin/mutations/SetUsernameMutation.ts b/src/core/client/admin/mutations/SetUsernameMutation.ts new file mode 100644 index 000000000..6f3a5b6d4 --- /dev/null +++ b/src/core/client/admin/mutations/SetUsernameMutation.ts @@ -0,0 +1,49 @@ +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 { SetUsernameMutation as MutationTypes } from "talk-admin/__generated__/SetUsernameMutation.graphql"; + +export type SetUsernameInput = Omit< + MutationTypes["variables"]["input"], + "clientMutationId" +>; + +const mutation = graphql` + mutation SetUsernameMutation($input: SetUsernameInput!) { + setUsername(input: $input) { + user { + username + } + clientMutationId + } + } +`; + +let clientMutationId = 0; + +function commit(environment: Environment, input: SetUsernameInput) { + return commitMutationPromiseNormalized(environment, { + mutation, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }); +} + +export const withSetUsernameMutation = createMutationContainer( + "setUsername", + commit +); + +export type SetUsernameMutation = ( + input: SetUsernameInput +) => Promise; diff --git a/src/core/client/admin/mutations/SignInMutation.ts b/src/core/client/admin/mutations/SignInMutation.ts index 74f2e1f0b..c5e5f80ae 100644 --- a/src/core/client/admin/mutations/SignInMutation.ts +++ b/src/core/client/admin/mutations/SignInMutation.ts @@ -2,7 +2,6 @@ import { Environment } from "relay-runtime"; import { TalkContext } from "talk-framework/lib/bootstrap"; import { createMutationContainer } from "talk-framework/lib/relay"; -import { commit as setAuthToken } from "talk-framework/mutations/SetAuthTokenMutation"; import { signIn, SignInInput } from "talk-framework/rest"; export type SignInMutation = (input: SignInInput) => Promise; @@ -13,7 +12,13 @@ export async function commit( context: TalkContext ) { const result = await signIn(context.rest, input); - setAuthToken(environment, { authToken: result.token }, context); + + // Put the token on the hash and clean the session. + // It'll be picked up by initLocalState. + location.hash = `accessToken=${result.token}`; + await context.clearSession(); + // TODO: (cvle) A better way would be if `context.clearSession` would return the new session and + // we set the accessToken directly in there. } export const withSignInMutation = createMutationContainer("signIn", commit); diff --git a/src/core/client/admin/mutations/index.ts b/src/core/client/admin/mutations/index.ts index 90f0b0787..65052f4e9 100644 --- a/src/core/client/admin/mutations/index.ts +++ b/src/core/client/admin/mutations/index.ts @@ -20,3 +20,24 @@ export { withRegenerateSSOKeyMutation, RegenerateSSOKeyMutation, } from "./RegenerateSSOKeyMutation"; +export { + withSetAuthViewMutation, + SetAuthViewMutation, +} from "./SetAuthViewMutation"; +export { + withClearAuthErrorMutation, + ClearAuthErrorMutation, +} from "./ClearAuthErrorMutation"; +export { + withCompleteAccountMutation, + CompleteAccountMutation, +} from "./CompleteAccountMutation"; +export { withSetEmailMutation, SetEmailMutation } from "./SetEmailMutation"; +export { + withSetUsernameMutation, + SetUsernameMutation, +} from "./SetUsernameMutation"; +export { + withSetPasswordMutation, + SetPasswordMutation, +} from "./SetPasswordMutation"; diff --git a/src/core/client/admin/routeConfig.tsx b/src/core/client/admin/routeConfig.tsx index aa7403617..3ae5a1d9e 100644 --- a/src/core/client/admin/routeConfig.tsx +++ b/src/core/client/admin/routeConfig.tsx @@ -2,13 +2,12 @@ import { makeRouteConfig, Redirect, Route } from "found"; import React from "react"; import App from "./components/App"; -import RedirectAppContainer from "./containers/RedirectAppContainer"; -import RedirectLoginContainer from "./containers/RedirectLoginContainer"; +import AuthCheckContainer from "./containers/AuthCheckContainer"; import Community from "./routes/community/components/Community"; import ConfigureModeration from "./routes/configure/components/Moderation"; import ConfigureContainer from "./routes/configure/containers/ConfigureContainer"; -import ConfigureAuthContainer from "./routes/configure/sections/auth/containers/AuthContainer"; -import Login from "./routes/login/components/Login"; +import ConfigureAuthRouteContainer from "./routes/configure/sections/auth/containers/AuthRouteContainer"; +import LoginContainer from "./routes/login/containers/LoginContainer"; import ModerateContainer from "./routes/moderate/containers/ModerateContainer"; import { PendingQueueContainer, @@ -21,7 +20,7 @@ import Stories from "./routes/stories/components/Stories"; export default makeRouteConfig( - + - + - - - + ); diff --git a/src/core/client/admin/routes/configure/sections/auth/components/Auth.tsx b/src/core/client/admin/routes/configure/sections/auth/components/Auth.tsx index 070858a93..a7dec0d59 100644 --- a/src/core/client/admin/routes/configure/sections/auth/components/Auth.tsx +++ b/src/core/client/admin/routes/configure/sections/auth/components/Auth.tsx @@ -14,7 +14,7 @@ interface Props { } const Auth: StatelessComponent = ({ disabled, auth, onInitValues }) => ( - + { +class AuthContainer extends React.Component { public static routeConfig: RouteProps; private initialValues = {}; private removeSubmitHook: RemoveSubmitHook; @@ -86,44 +87,32 @@ export default class AuthContainer extends React.Component { return ( ); } } -const enhanced = withSubmitHookContext(addSubmitHook => ({ addSubmitHook }))( - withContext(({ localeBundles }) => ({ localeBundles }))(AuthContainer) -); - -AuthContainer.routeConfig = { - Component: enhanced, - query: graphql` - query AuthContainerQuery { - settings { - auth { - ...FacebookConfigContainer_auth - ...FacebookConfigContainer_authReadOnly - ...GoogleConfigContainer_auth - ...GoogleConfigContainer_authReadOnly - ...SSOConfigContainer_auth - ...SSOConfigContainer_authReadOnly - ...LocalAuthConfigContainer_auth - ...DisplayNamesConfigContainer_auth - ...OIDCConfigContainer_auth - ...OIDCConfigContainer_authReadOnly - } - } +const enhanced = withFragmentContainer({ + auth: graphql` + fragment AuthContainer_auth on Auth { + ...FacebookConfigContainer_auth + ...FacebookConfigContainer_authReadOnly + ...GoogleConfigContainer_auth + ...GoogleConfigContainer_authReadOnly + ...SSOConfigContainer_auth + ...SSOConfigContainer_authReadOnly + ...LocalAuthConfigContainer_auth + ...DisplayNamesConfigContainer_auth + ...OIDCConfigContainer_auth + ...OIDCConfigContainer_authReadOnly } `, - cacheConfig: { force: true }, - render: ({ Component, props }) => - props && Component ? ( - - ) : ( - - - - ), -}; +})( + withSubmitHookContext(addSubmitHook => ({ addSubmitHook }))( + withContext(({ localeBundles }) => ({ localeBundles }))(AuthContainer) + ) +); + +export default enhanced; diff --git a/src/core/client/admin/routes/configure/sections/auth/containers/AuthRouteContainer.tsx b/src/core/client/admin/routes/configure/sections/auth/containers/AuthRouteContainer.tsx new file mode 100644 index 000000000..bf820ee50 --- /dev/null +++ b/src/core/client/admin/routes/configure/sections/auth/containers/AuthRouteContainer.tsx @@ -0,0 +1,49 @@ +import { FormApi } from "final-form"; +import React from "react"; +import { graphql } from "react-relay"; + +import { AuthRouteContainerQueryResponse } from "talk-admin/__generated__/AuthRouteContainerQuery.graphql"; +import { withRouteConfig } from "talk-framework/lib/router"; +import { Delay, Spinner } from "talk-ui/components"; + +import AuthContainer from ".//AuthContainer"; + +interface Props { + data: AuthRouteContainerQueryResponse | null; + form: FormApi; + submitting?: boolean; +} + +class AuthRouteContainer extends React.Component { + public render() { + if (!this.props.data) { + return ( + + + + ); + } + return ( + + ); + } +} + +const enhanced = withRouteConfig({ + query: graphql` + query AuthRouteContainerQuery { + settings { + auth { + ...AuthContainer_auth + } + } + } + `, + cacheConfig: { force: true }, +})(AuthRouteContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/login/components/CompleteAccountBox.css b/src/core/client/admin/routes/login/components/CompleteAccountBox.css new file mode 100644 index 000000000..3b95ee2e8 --- /dev/null +++ b/src/core/client/admin/routes/login/components/CompleteAccountBox.css @@ -0,0 +1,23 @@ +.container { + width: 350px; + background-color: var(--palette-common-white); + border: 1px solid var(--palette-divider); + margin-top: 70px; + box-sizing: border-box; + box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.25); +} + +.header { + background-color: var(--palette-text-primary); + padding: calc(1.5 * var(--spacing-unit)); + color: var(--palette-text-light); +} + +.heading3 { + margin-bottom: calc(0.25 * var(--spacing-unit)); +} + +.main { + padding: calc(2.5 * var(--spacing-unit)) calc(2 * var(--spacing-unit)) + calc(2 * var(--spacing-unit)) calc(2 * var(--spacing-unit)); +} diff --git a/src/core/client/admin/routes/login/components/CompleteAccountBox.spec.tsx b/src/core/client/admin/routes/login/components/CompleteAccountBox.spec.tsx new file mode 100644 index 000000000..e9ec23981 --- /dev/null +++ b/src/core/client/admin/routes/login/components/CompleteAccountBox.spec.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; + +import { PropTypesOf } from "talk-framework/types"; + +import CompleteAccountBox from "./CompleteAccountBox"; + +it("renders correctly", () => { + const props: PropTypesOf = { + title: "title", + children: "content", + }; + const renderer = createRenderer(); + + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); +}); diff --git a/src/core/client/admin/routes/login/components/CompleteAccountBox.tsx b/src/core/client/admin/routes/login/components/CompleteAccountBox.tsx new file mode 100644 index 000000000..ca5446a65 --- /dev/null +++ b/src/core/client/admin/routes/login/components/CompleteAccountBox.tsx @@ -0,0 +1,37 @@ +import React, { StatelessComponent } from "react"; + +import { Flex, Typography } from "talk-ui/components"; + +import styles from "./CompleteAccountBox.css"; + +interface Props { + title: React.ReactNode; + children: React.ReactNode; +} + +const CompleteAccountBox: StatelessComponent = ({ title, children }) => { + return ( +
+ +
+
+ + Finish Setting Up Your Account + + + {title} + +
+
{children}
+
+
+
+ ); +}; + +export default CompleteAccountBox; diff --git a/src/core/client/admin/routes/login/components/EmailField.tsx b/src/core/client/admin/routes/login/components/EmailField.tsx new file mode 100644 index 000000000..43eae4edc --- /dev/null +++ b/src/core/client/admin/routes/login/components/EmailField.tsx @@ -0,0 +1,58 @@ +import { Localized } from "fluent-react/compat"; +import * as React from "react"; +import { StatelessComponent } from "react"; +import { Field } from "react-final-form"; +import { + composeValidators, + required, + validateEmail, +} from "talk-framework/lib/validation"; +import { + FormField, + InputLabel, + TextField, + ValidationMessage, +} from "talk-ui/components"; + +interface Props { + disabled: boolean; +} + +const EmailField: StatelessComponent = props => ( + + {({ input, meta }) => ( + + + Email Address + + + + + {meta.touched && + (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} + + )} + +); + +export default EmailField; diff --git a/src/core/client/admin/routes/login/components/Login.tsx b/src/core/client/admin/routes/login/components/Login.tsx index 692939e21..470bd812b 100644 --- a/src/core/client/admin/routes/login/components/Login.tsx +++ b/src/core/client/admin/routes/login/components/Login.tsx @@ -1,44 +1,44 @@ -import { Localized } from "fluent-react/compat"; import React, { StatelessComponent } from "react"; -import { - BrandIcon, - BrandName, - Flex, - HorizontalGutter, - Typography, -} from "talk-ui/components"; +import { PropTypesOf } from "talk-framework/types"; -import SignInFormContainer from "../containers/SignInFormContainer"; +import AddEmailAddressContainer from "../views/addEmailAddress/containers/AddEmailAddressContainer"; +import CreatePasswordContainer from "../views/createPassword/containers/CreatePasswordContainer"; +import CreateUsernameContainer from "../views/createUsername/containers/CreateUsernameContainer"; +import SignInContainer from "../views/signIn/containers/SignInContainer"; -import styles from "./Login.css"; +export type View = + | "SIGN_UP" + | "SIGN_IN" + | "FORGOT_PASSWORD" + | "RESET_PASSWORD" + | "CREATE_USERNAME" + | "CREATE_PASSWORD" + | "ADD_EMAIL_ADDRESS" + | "%future added value"; -const Login: StatelessComponent = () => ( -
- - - -
- -
-
-
- - - Sign in to - - - -
- - - Enter your account details below - - - -
-
-
+export interface AppProps { + view: View; + auth: PropTypesOf["auth"]; +} + +const renderView = (view: AppProps["view"], auth: AppProps["auth"]) => { + switch (view) { + case "SIGN_IN": + return ; + case "CREATE_USERNAME": + return ; + case "CREATE_PASSWORD": + return ; + case "ADD_EMAIL_ADDRESS": + return ; + default: + throw new Error(`Unknown view ${view}`); + } +}; + +const App: StatelessComponent = ({ view, auth }) => ( +
{renderView(view, auth)}
); -export default Login; +export default App; diff --git a/src/core/client/admin/routes/login/components/SignInForm.tsx b/src/core/client/admin/routes/login/components/SignInForm.tsx deleted file mode 100644 index 242763c66..000000000 --- a/src/core/client/admin/routes/login/components/SignInForm.tsx +++ /dev/null @@ -1,131 +0,0 @@ -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 { PasswordField } from "talk-framework/components"; -import { - composeValidators, - required, - validateEmail, -} from "talk-framework/lib/validation"; -import { - Button, - CallOut, - FormField, - HorizontalGutter, - InputLabel, - TextField, - ValidationMessage, -} from "talk-ui/components"; - -interface FormProps { - email: string; - password: string; -} - -interface Props { - onSubmit: OnSubmit; -} - -const SignIn: StatelessComponent = props => ( -
- {({ handleSubmit, submitting, submitError }) => ( - - - {submitError && ( - - {submitError} - - )} - - - {({ input, meta }) => ( - - - Email Address - - - - - {meta.touched && - (meta.error || meta.submitError) && ( - - {meta.error || meta.submitError} - - )} - - )} - - - - {({ input, meta }) => ( - - - Password - - - - - {meta.touched && - (meta.error || meta.submitError) && ( - - {meta.error || meta.submitError} - - )} - - )} - - - - - -
- )} - -); - -export default SignIn; diff --git a/src/core/client/admin/routes/login/components/__snapshots__/CompleteAccountBox.spec.tsx.snap b/src/core/client/admin/routes/login/components/__snapshots__/CompleteAccountBox.spec.tsx.snap new file mode 100644 index 000000000..8033b27dc --- /dev/null +++ b/src/core/client/admin/routes/login/components/__snapshots__/CompleteAccountBox.spec.tsx.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` +
+ +
+
+ + Finish Setting Up Your Account + + + title + +
+
+ content +
+
+
+
+`; diff --git a/src/core/client/admin/routes/login/containers/AccountCompletionContainer.tsx b/src/core/client/admin/routes/login/containers/AccountCompletionContainer.tsx new file mode 100644 index 000000000..1b46dd810 --- /dev/null +++ b/src/core/client/admin/routes/login/containers/AccountCompletionContainer.tsx @@ -0,0 +1,141 @@ +import { withRouter, WithRouter } from "found"; +import * as React from "react"; +import { Component } from "react"; + +import { AccountCompletionContainer_auth as AuthData } from "talk-admin/__generated__/AccountCompletionContainer_auth.graphql"; +import { AccountCompletionContainer_me as UserData } from "talk-admin/__generated__/AccountCompletionContainer_me.graphql"; +import { AccountCompletionContainerLocal as Local } from "talk-admin/__generated__/AccountCompletionContainerLocal.graphql"; +import { + CompleteAccountMutation, + SetAuthViewMutation, + SetRedirectPathMutation, + withCompleteAccountMutation, + withSetAuthViewMutation, + withSetRedirectPathMutation, +} from "talk-admin/mutations"; +import { + graphql, + withFragmentContainer, + withLocalStateContainer, +} from "talk-framework/lib/relay"; + +type Props = { + completeAccount: CompleteAccountMutation; + setAuthView: SetAuthViewMutation; + local: Local; + auth: AuthData; + me: UserData | null; + setRedirectPath: SetRedirectPathMutation; +} & WithRouter; + +function handleAccountCompletion(props: Props) { + const { + local: { authView }, + me, + auth, + setAuthView, + } = props; + if (me) { + if (!me.email) { + if (authView !== "ADD_EMAIL_ADDRESS") { + setAuthView({ view: "ADD_EMAIL_ADDRESS" }); + } + } else if (!me.username) { + if (authView !== "CREATE_USERNAME") { + setAuthView({ view: "CREATE_USERNAME" }); + } + } else if ( + !me.profiles.some(p => p.__typename === "LocalProfile") && + auth.integrations.local.enabled && + (auth.integrations.local.targetFilter.admin || + auth.integrations.local.targetFilter.stream) + ) { + if (authView !== "CREATE_PASSWORD") { + setAuthView({ view: "CREATE_PASSWORD" }); + } + } else { + props.completeAccount({ authToken: props.local.authToken! }).then(() => { + props.router.replace(props.local.redirectPath || "/admin"); + }); + return true; + } + } + return false; +} + +interface State { + checkedCompletionStatus: boolean; + completed: boolean; +} + +class AccountCompletionContainer extends Component { + public state = { + checkedCompletionStatus: false, + completed: false, + }; + + public componentDidMount() { + const completed = handleAccountCompletion(this.props); + this.setState({ checkedCompletionStatus: true, completed }); + } + public componentDidUpdate() { + if (!this.state.completed) { + const completed = handleAccountCompletion(this.props); + if (completed) { + this.setState({ completed }); + } + } + } + + public render() { + const { children } = this.props; + // We skip first frame to check for completion status. + if (!this.state.checkedCompletionStatus || this.state.completed) { + return null; + } + return <>{children}; + } +} + +const enhanced = withLocalStateContainer( + graphql` + fragment AccountCompletionContainerLocal on Local { + authToken + authView + redirectPath + } + ` +)( + withFragmentContainer({ + auth: graphql` + fragment AccountCompletionContainer_auth on Auth { + integrations { + local { + enabled + targetFilter { + admin + stream + } + } + } + } + `, + me: graphql` + fragment AccountCompletionContainer_me on User { + username + email + profiles { + __typename + } + } + `, + })( + withSetAuthViewMutation( + withSetRedirectPathMutation( + withCompleteAccountMutation(withRouter(AccountCompletionContainer)) + ) + ) + ) +); + +export default enhanced; diff --git a/src/core/client/admin/routes/login/containers/LoginContainer.tsx b/src/core/client/admin/routes/login/containers/LoginContainer.tsx new file mode 100644 index 000000000..482523dc2 --- /dev/null +++ b/src/core/client/admin/routes/login/containers/LoginContainer.tsx @@ -0,0 +1,63 @@ +import { RouteProps } from "found"; +import React, { Component } from "react"; + +import { LoginContainerLocal as LocalData } from "talk-admin/__generated__/LoginContainerLocal.graphql"; +import { LoginContainerQueryResponse } from "talk-admin/__generated__/LoginContainerQuery.graphql"; +import { graphql, withLocalStateContainer } from "talk-framework/lib/relay"; +import { withRouteConfig } from "talk-framework/lib/router"; + +import Login from "../components/Login"; +import AccountCompletionContainer from "./AccountCompletionContainer"; + +interface Props { + local: LocalData; + data: LoginContainerQueryResponse; + error?: Error | null; +} + +class LoginContainer extends Component { + public static routeConfig: RouteProps; + + public render() { + if (!this.props.data) { + return null; + } + + return ( + + + + ); + } +} + +const enhanced = withRouteConfig({ + query: graphql` + query LoginContainerQuery { + me { + ...AccountCompletionContainer_me + } + settings { + auth { + ...AccountCompletionContainer_auth + ...SignInContainer_auth + } + } + } + `, +})( + withLocalStateContainer( + graphql` + fragment LoginContainerLocal on Local { + authView + } + ` + )(LoginContainer) +); +export default enhanced; diff --git a/src/core/client/admin/routes/login/views/addEmailAddress/components/AddEmailAddress.tsx b/src/core/client/admin/routes/login/views/addEmailAddress/components/AddEmailAddress.tsx new file mode 100644 index 000000000..901b28314 --- /dev/null +++ b/src/core/client/admin/routes/login/views/addEmailAddress/components/AddEmailAddress.tsx @@ -0,0 +1,71 @@ +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 { + Button, + CallOut, + HorizontalGutter, + Typography, +} from "talk-ui/components"; + +import CompleteAccountBox from "../../../components/CompleteAccountBox"; +import ConfirmEmailField from "./ConfirmEmailField"; +import EmailField from "./EmailField"; + +interface FormProps { + email: string; +} + +export interface AddEmailAddressForm { + onSubmit: OnSubmit; +} + +const AddEmailAddress: StatelessComponent = props => { + return ( + + Add Email Address + + } + > +
+ {({ handleSubmit, submitting, submitError }) => ( + + + + + For your added security, we require users to add an email + address to their accounts. Your email address will be used to: + + + {submitError && ( + + {submitError} + + )} + + + + + + +
+ )} + +
+ ); +}; + +export default AddEmailAddress; diff --git a/src/core/client/admin/routes/login/views/addEmailAddress/components/ConfirmEmailField.tsx b/src/core/client/admin/routes/login/views/addEmailAddress/components/ConfirmEmailField.tsx new file mode 100644 index 000000000..9700891bb --- /dev/null +++ b/src/core/client/admin/routes/login/views/addEmailAddress/components/ConfirmEmailField.tsx @@ -0,0 +1,61 @@ +import { Localized } from "fluent-react/compat"; +import * as React from "react"; +import { StatelessComponent } from "react"; +import { Field } from "react-final-form"; +import { + composeValidators, + required, + validateEqualEmails, +} from "talk-framework/lib/validation"; +import { + FormField, + InputLabel, + TextField, + ValidationMessage, +} from "talk-ui/components"; + +interface Props { + disabled: boolean; +} + +const ConfirmEmailField: StatelessComponent = props => ( + + {({ input, meta }) => ( + + + Confirm Email Address + + + + + {meta.touched && + (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} + + )} + +); + +export default ConfirmEmailField; diff --git a/src/core/client/admin/routes/login/views/addEmailAddress/components/EmailField.tsx b/src/core/client/admin/routes/login/views/addEmailAddress/components/EmailField.tsx new file mode 100644 index 000000000..a7d078049 --- /dev/null +++ b/src/core/client/admin/routes/login/views/addEmailAddress/components/EmailField.tsx @@ -0,0 +1,58 @@ +import { Localized } from "fluent-react/compat"; +import * as React from "react"; +import { StatelessComponent } from "react"; +import { Field } from "react-final-form"; +import { + composeValidators, + required, + validateEmail, +} from "talk-framework/lib/validation"; +import { + FormField, + InputLabel, + TextField, + ValidationMessage, +} from "talk-ui/components"; + +interface Props { + disabled: boolean; +} + +const EmailField: StatelessComponent = props => ( + + {({ input, meta }) => ( + + + Email Address + + + + + {meta.touched && + (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} + + )} + +); + +export default EmailField; diff --git a/src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/ListItem.css b/src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/ListItem.css new file mode 100644 index 000000000..c717baccf --- /dev/null +++ b/src/core/client/admin/routes/login/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/admin/routes/login/views/addEmailAddress/components/UnorderedList/ListItem.tsx b/src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/ListItem.tsx new file mode 100644 index 000000000..69cc9e3ac --- /dev/null +++ b/src/core/client/admin/routes/login/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/admin/routes/login/views/addEmailAddress/components/UnorderedList/UnorderedList.css b/src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/UnorderedList.css new file mode 100644 index 000000000..220361f29 --- /dev/null +++ b/src/core/client/admin/routes/login/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/admin/routes/login/views/addEmailAddress/components/UnorderedList/UnorderedList.spec.tsx b/src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/UnorderedList.spec.tsx new file mode 100644 index 000000000..101d3792c --- /dev/null +++ b/src/core/client/admin/routes/login/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/admin/routes/login/views/addEmailAddress/components/UnorderedList/UnorderedList.tsx b/src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/UnorderedList.tsx new file mode 100644 index 000000000..16f3c7a7f --- /dev/null +++ b/src/core/client/admin/routes/login/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/admin/routes/login/views/addEmailAddress/components/UnorderedList/__snapshots__/UnorderedList.spec.tsx.snap b/src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/__snapshots__/UnorderedList.spec.tsx.snap new file mode 100644 index 000000000..699da0f02 --- /dev/null +++ b/src/core/client/admin/routes/login/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/admin/routes/login/views/addEmailAddress/components/UnorderedList/index.ts b/src/core/client/admin/routes/login/views/addEmailAddress/components/UnorderedList/index.ts new file mode 100644 index 000000000..180e3a8d5 --- /dev/null +++ b/src/core/client/admin/routes/login/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/admin/routes/login/views/addEmailAddress/containers/AddEmailAddressContainer.tsx b/src/core/client/admin/routes/login/views/addEmailAddress/containers/AddEmailAddressContainer.tsx new file mode 100644 index 000000000..c3bf1987b --- /dev/null +++ b/src/core/client/admin/routes/login/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-admin/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/admin/routes/login/views/createPassword/components/CreatePassword.tsx b/src/core/client/admin/routes/login/views/createPassword/components/CreatePassword.tsx new file mode 100644 index 000000000..ad1ad303f --- /dev/null +++ b/src/core/client/admin/routes/login/views/createPassword/components/CreatePassword.tsx @@ -0,0 +1,69 @@ +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 { + Button, + CallOut, + HorizontalGutter, + Typography, +} from "talk-ui/components"; + +import CompleteAccountBox from "../../../components/CompleteAccountBox"; +import SetPasswordField from "./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/admin/routes/login/views/createPassword/components/SetPasswordField.tsx b/src/core/client/admin/routes/login/views/createPassword/components/SetPasswordField.tsx new file mode 100644 index 000000000..9d1c3f39d --- /dev/null +++ b/src/core/client/admin/routes/login/views/createPassword/components/SetPasswordField.tsx @@ -0,0 +1,67 @@ +import { Localized } from "fluent-react/compat"; +import * as React from "react"; +import { StatelessComponent } from "react"; +import { Field } from "react-final-form"; +import { PasswordField } from "talk-framework/components"; +import { + composeValidators, + required, + validatePassword, +} from "talk-framework/lib/validation"; +import { + FormField, + InputDescription, + InputLabel, + ValidationMessage, +} from "talk-ui/components"; + +interface Props { + disabled: boolean; +} + +const SetPasswordField: StatelessComponent = props => ( + + {({ input, meta }) => ( + + + Password + + + + {"Must be at least {$minLength} characters"} + + + + + + {meta.touched && + (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} + + )} + +); + +export default SetPasswordField; diff --git a/src/core/client/admin/routes/login/views/createPassword/containers/CreatePasswordContainer.tsx b/src/core/client/admin/routes/login/views/createPassword/containers/CreatePasswordContainer.tsx new file mode 100644 index 000000000..80c867ffa --- /dev/null +++ b/src/core/client/admin/routes/login/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-admin/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/admin/routes/login/views/createUsername/components/CreateUsername.tsx b/src/core/client/admin/routes/login/views/createUsername/components/CreateUsername.tsx new file mode 100644 index 000000000..f63933ae7 --- /dev/null +++ b/src/core/client/admin/routes/login/views/createUsername/components/CreateUsername.tsx @@ -0,0 +1,69 @@ +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 { + Button, + CallOut, + HorizontalGutter, + Typography, +} from "talk-ui/components"; + +import CompleteAccountBox from "../../../components/CompleteAccountBox"; +import UsernameField from "./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/admin/routes/login/views/createUsername/components/UsernameField.tsx b/src/core/client/admin/routes/login/views/createUsername/components/UsernameField.tsx new file mode 100644 index 000000000..e9b88019f --- /dev/null +++ b/src/core/client/admin/routes/login/views/createUsername/components/UsernameField.tsx @@ -0,0 +1,65 @@ +import { Localized } from "fluent-react/compat"; +import * as React from "react"; +import { StatelessComponent } from "react"; +import { Field } from "react-final-form"; +import { + composeValidators, + required, + validateUsername, +} from "talk-framework/lib/validation"; +import { + FormField, + InputDescription, + InputLabel, + TextField, + ValidationMessage, +} from "talk-ui/components"; + +interface Props { + disabled: boolean; +} + +const CreateUsernameField: StatelessComponent = props => ( + + {({ input, meta }) => ( + + + Username + + + You may use “_” and “.” + + + + + {meta.touched && + (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} + + )} + +); + +export default CreateUsernameField; diff --git a/src/core/client/admin/routes/login/views/createUsername/containers/CreateUsernameContainer.tsx b/src/core/client/admin/routes/login/views/createUsername/containers/CreateUsernameContainer.tsx new file mode 100644 index 000000000..126e2651a --- /dev/null +++ b/src/core/client/admin/routes/login/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-admin/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/admin/routes/login/views/signIn/components/HorizontalSeparator.css b/src/core/client/admin/routes/login/views/signIn/components/HorizontalSeparator.css new file mode 100644 index 000000000..70f1733a1 --- /dev/null +++ b/src/core/client/admin/routes/login/views/signIn/components/HorizontalSeparator.css @@ -0,0 +1,16 @@ +.root { + position: relative; +} +.hr { + position: absolute; + border: 0; + border-top: 1px solid var(--palette-divider); + width: 100%; + margin: 0; +} +.text { + composes: heading3 from "talk-ui/shared/typography.css"; + position: relative; + background-color: #f5f5f5; + padding: 0 var(--spacing-unit); +} diff --git a/src/core/client/auth/components/OrSeparator.spec.tsx b/src/core/client/admin/routes/login/views/signIn/components/HorizontalSeparator.spec.tsx similarity index 64% rename from src/core/client/auth/components/OrSeparator.spec.tsx rename to src/core/client/admin/routes/login/views/signIn/components/HorizontalSeparator.spec.tsx index 84afbaacc..043540aeb 100644 --- a/src/core/client/auth/components/OrSeparator.spec.tsx +++ b/src/core/client/admin/routes/login/views/signIn/components/HorizontalSeparator.spec.tsx @@ -1,10 +1,10 @@ import React from "react"; import { createRenderer } from "react-test-renderer/shallow"; -import OrSeparator from "./OrSeparator"; +import HorizontalSeparator from "./HorizontalSeparator"; it("renders correctly", () => { const renderer = createRenderer(); - renderer.render(); + renderer.render(Or); expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/admin/routes/login/views/signIn/components/HorizontalSeparator.tsx b/src/core/client/admin/routes/login/views/signIn/components/HorizontalSeparator.tsx new file mode 100644 index 000000000..405d5a10f --- /dev/null +++ b/src/core/client/admin/routes/login/views/signIn/components/HorizontalSeparator.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { StatelessComponent } from "react"; + +import { Flex } from "talk-ui/components"; + +import styles from "./HorizontalSeparator.css"; + +interface Props { + children: string; +} + +const HorizontalSeparator: StatelessComponent = props => ( + +
    +
    {props.children}
    +
    +); + +export default HorizontalSeparator; diff --git a/src/core/client/admin/routes/login/views/signIn/components/OrSeparator.tsx b/src/core/client/admin/routes/login/views/signIn/components/OrSeparator.tsx new file mode 100644 index 000000000..74c0156e6 --- /dev/null +++ b/src/core/client/admin/routes/login/views/signIn/components/OrSeparator.tsx @@ -0,0 +1,13 @@ +import { Localized } from "fluent-react/compat"; +import React from "react"; +import { StatelessComponent } from "react"; + +import HorizontalSeparator from "./HorizontalSeparator"; + +const OrSeparator: StatelessComponent = () => ( + + Or + +); + +export default OrSeparator; diff --git a/src/core/client/admin/routes/login/views/signIn/components/SignIn.spec.tsx b/src/core/client/admin/routes/login/views/signIn/components/SignIn.spec.tsx new file mode 100644 index 000000000..fbafcffb5 --- /dev/null +++ b/src/core/client/admin/routes/login/views/signIn/components/SignIn.spec.tsx @@ -0,0 +1,19 @@ +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 renderer = createRenderer(); + const props: PropTypesOf = { + error: null, + auth: {}, + }; + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); +}); diff --git a/src/core/client/admin/routes/login/views/signIn/components/SignIn.tsx b/src/core/client/admin/routes/login/views/signIn/components/SignIn.tsx new file mode 100644 index 000000000..fbe440a90 --- /dev/null +++ b/src/core/client/admin/routes/login/views/signIn/components/SignIn.tsx @@ -0,0 +1,61 @@ +import { Localized } from "fluent-react/compat"; +import React, { StatelessComponent } from "react"; + +import AuthBox from "talk-admin/components/AuthBox"; +import { PropTypesOf } from "talk-framework/types"; +import { CallOut, HorizontalGutter } from "talk-ui/components"; + +import SignInWithEmailContainer from "../containers/SignInWithEmailContainer"; +import SignInWithFacebookContainer from "../containers/SignInWithFacebookContainer"; +import SignInWithGoogleContainer from "../containers/SignInWithGoogleContainer"; +import SignInWithOIDCContainer from "../containers/SignInWithOIDCContainer"; +import OrSeparator from "./OrSeparator"; + +interface Props { + error: string | null; + emailEnabled?: boolean; + facebookEnabled?: boolean; + googleEnabled?: boolean; + oidcEnabled?: boolean; + auth: PropTypesOf["auth"] & + PropTypesOf["auth"] & + PropTypesOf["auth"]; +} + +const SignIn: StatelessComponent = ({ + emailEnabled, + facebookEnabled, + googleEnabled, + oidcEnabled, + auth, + error, +}) => { + const oneClickIntegrationEnabled = + facebookEnabled || googleEnabled || oidcEnabled; + return ( + + Sign in to + + } + > + + {error && ( + + {error} + + )} + {emailEnabled && } + {emailEnabled && oneClickIntegrationEnabled && } + + {facebookEnabled && } + {googleEnabled && } + {oidcEnabled && } + + + + ); +}; + +export default SignIn; diff --git a/src/core/client/admin/routes/login/views/signIn/components/SignInWithEmail.tsx b/src/core/client/admin/routes/login/views/signIn/components/SignInWithEmail.tsx new file mode 100644 index 000000000..cc0d60202 --- /dev/null +++ b/src/core/client/admin/routes/login/views/signIn/components/SignInWithEmail.tsx @@ -0,0 +1,96 @@ +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 { PasswordField } from "talk-framework/components"; +import { composeValidators, required } from "talk-framework/lib/validation"; +import { + Button, + ButtonIcon, + CallOut, + FormField, + HorizontalGutter, + InputLabel, + ValidationMessage, +} from "talk-ui/components"; + +import EmailField from "../../../components/EmailField"; + +interface FormProps { + email: string; + password: string; +} + +export interface SignInWithEmailForm { + onSubmit: OnSubmit; +} + +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/components/__snapshots__/OrSeparator.spec.tsx.snap b/src/core/client/admin/routes/login/views/signIn/components/__snapshots__/HorizontalSeparator.spec.tsx.snap similarity index 51% rename from src/core/client/auth/components/__snapshots__/OrSeparator.spec.tsx.snap rename to src/core/client/admin/routes/login/views/signIn/components/__snapshots__/HorizontalSeparator.spec.tsx.snap index 90cb6e3b4..2e40eeb35 100644 --- a/src/core/client/auth/components/__snapshots__/OrSeparator.spec.tsx.snap +++ b/src/core/client/admin/routes/login/views/signIn/components/__snapshots__/HorizontalSeparator.spec.tsx.snap @@ -3,20 +3,16 @@ exports[`renders correctly 1`] = `
    - -
    - Or -
    -
    + Or +
    `; diff --git a/src/core/client/admin/routes/login/views/signIn/components/__snapshots__/SignIn.spec.tsx.snap b/src/core/client/admin/routes/login/views/signIn/components/__snapshots__/SignIn.spec.tsx.snap new file mode 100644 index 000000000..dde818841 --- /dev/null +++ b/src/core/client/admin/routes/login/views/signIn/components/__snapshots__/SignIn.spec.tsx.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` + + + Sign in to + + + } +> + + + + +`; diff --git a/src/core/client/admin/routes/login/views/signIn/containers/SignInContainer.tsx b/src/core/client/admin/routes/login/views/signIn/containers/SignInContainer.tsx new file mode 100644 index 000000000..cfaea8ff6 --- /dev/null +++ b/src/core/client/admin/routes/login/views/signIn/containers/SignInContainer.tsx @@ -0,0 +1,106 @@ +import { RouteProps } from "found"; +import React, { Component } from "react"; + +import { SignInContainer_auth as AuthData } from "talk-admin/__generated__/SignInContainer_auth.graphql"; +import { SignInContainerLocal as LocalData } from "talk-admin/__generated__/SignInContainerLocal.graphql"; +import { + ClearAuthErrorMutation, + SignInMutation, + withClearAuthErrorMutation, + withSignInMutation, +} from "talk-admin/mutations"; +import { + graphql, + withFragmentContainer, + withLocalStateContainer, +} from "talk-framework/lib/relay"; + +import SignIn from "../components/SignIn"; + +interface Props { + local: LocalData; + auth: AuthData; + error?: Error | null; + signIn: SignInMutation; + clearAuthError: ClearAuthErrorMutation; +} + +class SignInContainer extends Component { + public static routeConfig: RouteProps; + + public componentWillUnmount() { + this.props.clearAuthError(); + } + + public render() { + const integrations = this.props.auth.integrations; + return ( + + ); + } +} + +const enhanced = withFragmentContainer({ + auth: graphql` + fragment SignInContainer_auth on Auth { + ...SignInWithOIDCContainer_auth + ...SignInWithGoogleContainer_auth + ...SignInWithFacebookContainer_auth + integrations { + local { + enabled + targetFilter { + admin + } + } + facebook { + enabled + targetFilter { + admin + } + } + google { + enabled + targetFilter { + admin + } + } + oidc { + enabled + targetFilter { + admin + } + } + } + } + `, +})( + withClearAuthErrorMutation( + withSignInMutation( + withLocalStateContainer( + graphql` + fragment SignInContainerLocal on Local { + authError + } + ` + )(SignInContainer) + ) + ) +); +export default enhanced; diff --git a/src/core/client/admin/routes/login/containers/SignInFormContainer.tsx b/src/core/client/admin/routes/login/views/signIn/containers/SignInWithEmailContainer.tsx similarity index 62% rename from src/core/client/admin/routes/login/containers/SignInFormContainer.tsx rename to src/core/client/admin/routes/login/views/signIn/containers/SignInWithEmailContainer.tsx index f3e0e086d..e63e03ba1 100644 --- a/src/core/client/admin/routes/login/containers/SignInFormContainer.tsx +++ b/src/core/client/admin/routes/login/views/signIn/containers/SignInWithEmailContainer.tsx @@ -1,28 +1,27 @@ import { FORM_ERROR } from "final-form"; import React, { Component } from "react"; -import { SignInMutation, withSignInMutation } from "talk-admin/mutations"; -import { PropTypesOf } from "talk-framework/types"; -import SignInForm from "../components/SignInForm"; +import { SignInMutation, withSignInMutation } from "talk-admin/mutations"; + +import SignInWithEmail, { + SignInWithEmailForm, +} from "../components/SignInWithEmail"; interface SignInContainerProps { signIn: SignInMutation; } class SignInContainer extends Component { - private onSubmit: PropTypesOf["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 }; } }; public render() { - return ; + return ; } } diff --git a/src/core/client/admin/routes/login/views/signIn/containers/SignInWithFacebookContainer.tsx b/src/core/client/admin/routes/login/views/signIn/containers/SignInWithFacebookContainer.tsx new file mode 100644 index 000000000..46cb61277 --- /dev/null +++ b/src/core/client/admin/routes/login/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-admin/__generated__/SignInWithFacebookContainer_auth.graphql"; +import FacebookButton from "talk-framework/components/FacebookButton"; +import { redirectOAuth2 } from "talk-framework/helpers"; +import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; + +interface Props { + auth: AuthData; +} + +class SignInWithFacebookContainer extends Component { + private handleOnClick = () => { + redirectOAuth2(this.props.auth.integrations.facebook.redirectURL); + }; + + public render() { + return ( + + + Sign in with Facebook + + + ); + } +} + +const enhanced = withFragmentContainer({ + auth: graphql` + fragment SignInWithFacebookContainer_auth on Auth { + integrations { + facebook { + redirectURL + } + } + } + `, +})(SignInWithFacebookContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/login/views/signIn/containers/SignInWithGoogleContainer.tsx b/src/core/client/admin/routes/login/views/signIn/containers/SignInWithGoogleContainer.tsx new file mode 100644 index 000000000..c76e0c28f --- /dev/null +++ b/src/core/client/admin/routes/login/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-admin/__generated__/SignInWithGoogleContainer_auth.graphql"; +import GoogleButton from "talk-framework/components/GoogleButton"; +import { redirectOAuth2 } from "talk-framework/helpers"; +import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; + +interface Props { + auth: AuthData; +} + +class SignInWithGoogleContainer extends Component { + private handleOnClick = () => { + redirectOAuth2(this.props.auth.integrations.google.redirectURL); + }; + + public render() { + return ( + + + Sign in with Google + + + ); + } +} + +const enhanced = withFragmentContainer({ + auth: graphql` + fragment SignInWithGoogleContainer_auth on Auth { + integrations { + google { + redirectURL + } + } + } + `, +})(SignInWithGoogleContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/login/views/signIn/containers/SignInWithOIDCContainer.tsx b/src/core/client/admin/routes/login/views/signIn/containers/SignInWithOIDCContainer.tsx new file mode 100644 index 000000000..2253a2705 --- /dev/null +++ b/src/core/client/admin/routes/login/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-admin/__generated__/SignInWithOIDCContainer_auth.graphql"; +import OIDCButton from "talk-framework/components/OIDCButton"; +import { redirectOAuth2 } from "talk-framework/helpers"; +import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; + +interface Props { + auth: AuthData; +} + +class SignInWithOIDCContainer extends Component { + private handleOnClick = () => { + redirectOAuth2(this.props.auth.integrations.oidc.redirectURL!); + }; + + public render() { + return ( + + Sign in with $name + + ); + } +} + +const enhanced = withFragmentContainer({ + auth: graphql` + fragment SignInWithOIDCContainer_auth on Auth { + integrations { + oidc { + name + redirectURL + } + } + } + `, +})(SignInWithOIDCContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/moderate/containers/ModerateContainer.tsx b/src/core/client/admin/routes/moderate/containers/ModerateContainer.tsx index aa73613c9..9e1af842c 100644 --- a/src/core/client/admin/routes/moderate/containers/ModerateContainer.tsx +++ b/src/core/client/admin/routes/moderate/containers/ModerateContainer.tsx @@ -3,23 +3,30 @@ import React from "react"; import { graphql } from "react-relay"; import { ModerateContainerQueryResponse } from "talk-admin/__generated__/ModerateContainerQuery.graphql"; +import { withRouteConfig } from "talk-framework/lib/router"; import Moderate from "../components/Moderate"; -type Props = ModerateContainerQueryResponse; +interface Props { + data: ModerateContainerQueryResponse; +} -export default class ModerateContainer extends React.Component { +class ModerateContainer extends React.Component { public static routeConfig: RouteProps; public render() { - if (!this.props.moderationQueues) { + if (!this.props.data) { + return null; + } + + if (!this.props.data.moderationQueues) { return ; } return ( {this.props.children} @@ -27,8 +34,7 @@ export default class ModerateContainer extends React.Component { } } -ModerateContainer.routeConfig = { - Component: ModerateContainer, +const enhanced = withRouteConfig({ query: graphql` query ModerateContainerQuery { moderationQueues { @@ -45,6 +51,6 @@ ModerateContainer.routeConfig = { } `, cacheConfig: { force: true }, - render: ({ Component, props }) => - Component ? : undefined, -}; +})(ModerateContainer); + +export default enhanced; diff --git a/src/core/client/admin/test/__snapshots__/login.spec.tsx.snap b/src/core/client/admin/test/__snapshots__/login.spec.tsx.snap deleted file mode 100644 index 12c5fbd72..000000000 --- a/src/core/client/admin/test/__snapshots__/login.spec.tsx.snap +++ /dev/null @@ -1,1515 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`accepts correct password 1`] = ` -
    -
    -
    -
    -
    - - - - - - - - - - - logo mark2018 - - - - - -
    -
    -
    -

    - Sign in to -

    -

    - Talk -

    -
    -

    - Enter your account details below -

    -
    -
    -
    - - -
    - - - This field is required. - -
    -
    -
    - -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    -
    -
    -
    -`; - -exports[`accepts valid email 1`] = ` -
    -
    -
    -
    -
    - - - - - - - - - - - logo mark2018 - - - - - -
    -
    -
    -

    - Sign in to -

    -

    - Talk -

    -
    -

    - Enter your account details below -

    -
    -
    -
    - - -
    -
    - -
    -
    - -
    - -
    -
    -
    -
    - - - This field is required. - -
    -
    - -
    -
    -
    -
    -
    -`; - -exports[`checks for invalid email 1`] = ` -
    -
    -
    -
    -
    - - - - - - - - - - - logo mark2018 - - - - - -
    -
    -
    -

    - Sign in to -

    -

    - Talk -

    -
    -

    - Enter your account details below -

    -
    -
    -
    - - -
    - - - Please enter a valid email address. - -
    -
    -
    - -
    -
    - -
    - -
    -
    -
    -
    - - - This field is required. - -
    -
    - -
    -
    -
    -
    -
    -`; - -exports[`renders sign in form 1`] = ` -
    -
    -
    -
    -
    - - - - - - - - - - - logo mark2018 - - - - - -
    -
    -
    -

    - Sign in to -

    -

    - Talk -

    -
    -

    - Enter your account details below -

    -
    -
    -
    - - -
    -
    - -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    -
    -
    -
    -`; - -exports[`shows error when submitting empty form 1`] = ` -
    -
    -
    -
    -
    - - - - - - - - - - - logo mark2018 - - - - - -
    -
    -
    -

    - Sign in to -

    -

    - Talk -

    -
    -

    - Enter your account details below -

    -
    -
    -
    - - -
    - - - This field is required. - -
    -
    -
    - -
    -
    - -
    - -
    -
    -
    -
    - - - This field is required. - -
    -
    - -
    -
    -
    -
    -
    -`; - -exports[`shows server error 1`] = ` -
    -
    -
    -
    -
    - - - - - - - - - - - logo mark2018 - - - - - -
    -
    -
    -

    - Sign in to -

    -

    - Talk -

    -
    -

    - Enter your account details below -

    -
    -
    -
    - - -
    -
    - -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    -
    -
    -
    -`; - -exports[`shows server error 2`] = ` -
    -
    -
    -
    -
    - - - - - - - - - - - logo mark2018 - - - - - -
    -
    -
    -

    - Sign in to -

    -

    - Talk -

    -
    -

    - Enter your account details below -

    -
    -
    -
    - Server Error -
    -
    - - -
    -
    - -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    -
    -
    -
    -`; - -exports[`submits form successfully 1`] = ` -
    -
    -
    -
    -
    - - - - - - - - - - - logo mark2018 - - - - - -
    -
    -
    -

    - Sign in to -

    -

    - Talk -

    -
    -

    - Enter your account details below -

    -
    -
    -
    - - -
    -
    - -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    -
    -
    -
    -`; - -exports[`submits form successfully 2`] = ` -
    -
    -
    -
    -
    - - - - - - - - - - - logo mark2018 - - - - - -
    -
    -
    -

    - Sign in to -

    -

    - Talk -

    -
    -

    - Enter your account details below -

    -
    -
    -
    - - -
    -
    - -
    -
    - -
    - -
    -
    -
    -
    - -
    -
    -
    -
    -
    -`; diff --git a/src/core/client/admin/test/auth/__snapshots__/addEmailAddress.spec.tsx.snap b/src/core/client/admin/test/auth/__snapshots__/addEmailAddress.spec.tsx.snap new file mode 100644 index 000000000..781d3c41b --- /dev/null +++ b/src/core/client/admin/test/auth/__snapshots__/addEmailAddress.spec.tsx.snap @@ -0,0 +1,595 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`accepts valid email 1`] = ` +
    +
    +

    + For your added security, we require users to add an email address to their accounts. +

    +
    + + +
    +
    + + +
    + + + This field is required. + +
    +
    + +
    +
    +`; + +exports[`accepts valid email confirmation 1`] = ` +
    +
    +

    + For your added security, we require users to add an email address to their accounts. +

    +
    + + +
    +
    + + +
    + +
    +
    +`; + +exports[`checks for invalid email 1`] = ` +
    +
    +

    + For your added security, we require users to add an email address to their accounts. +

    +
    + + +
    + + + Please enter a valid email address. + +
    +
    +
    + + +
    + + + Emails do not match. Try again. + +
    +
    + +
    +
    +`; + +exports[`renders addEmailAddress view 1`] = ` +
    +
    +
    +
    +
    +

    + Finish Setting Up Your Account +

    +

    + + Add Email Address + +

    +
    +
    +
    +
    +

    + For your added security, we require users to add an email address to their accounts. +

    +
    + + +
    +
    + + +
    + +
    +
    +
    +
    +
    +
    +
    +`; + +exports[`shows error when submitting empty form 1`] = ` +
    +
    +

    + For your added security, we require users to add an email address to their accounts. +

    +
    + + +
    + + + This field is required. + +
    +
    +
    + + +
    + + + This field is required. + +
    +
    + +
    +
    +`; + +exports[`shows server error 1`] = ` +
    +
    +

    + For your added security, we require users to add an email address to their accounts. +

    +
    + server error + +GraphQL request (4:3) +3: ) { +4: setEmail(input: $input) { + ^ +5: user { + +
    +
    + + +
    +
    + + +
    + +
    +
    +`; + +exports[`successfully sets email 1`] = ` +
    +
    +

    + For your added security, we require users to add an email address to their accounts. +

    +
    + + +
    +
    + + +
    + +
    +
    +`; diff --git a/src/core/client/admin/test/auth/__snapshots__/createPassword.spec.tsx.snap b/src/core/client/admin/test/auth/__snapshots__/createPassword.spec.tsx.snap new file mode 100644 index 000000000..a83a3c568 --- /dev/null +++ b/src/core/client/admin/test/auth/__snapshots__/createPassword.spec.tsx.snap @@ -0,0 +1,458 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`checks for invalid password 1`] = ` +
    +
    +

    + To protect against unauthorized changes to your account, +we require users to create a password. +

    +
    + +

    + Must be at least ⁨8⁩ characters +

    +
    +
    + +
    + +
    +
    +
    +
    + + + Password must contain at least ⁨8⁩ characters. + +
    +
    + +
    +
    +`; + +exports[`renders createPassword view 1`] = ` +
    +
    +
    +
    +
    +

    + Finish Setting Up Your Account +

    +

    + + Create Password + +

    +
    +
    +
    +
    +

    + To protect against unauthorized changes to your account, +we require users to create a password. +

    +
    + +

    + Must be at least ⁨8⁩ characters +

    +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +`; + +exports[`shows error when submitting empty form 1`] = ` +
    +
    +

    + To protect against unauthorized changes to your account, +we require users to create a password. +

    +
    + +

    + Must be at least ⁨8⁩ characters +

    +
    +
    + +
    + +
    +
    +
    +
    + + + This field is required. + +
    +
    + +
    +
    +`; + +exports[`shows server error 1`] = ` +
    +
    +

    + To protect against unauthorized changes to your account, +we require users to create a password. +

    +
    + server error + +GraphQL request (4:3) +3: ) { +4: setPassword(input: $input) { + ^ +5: user { + +
    +
    + +

    + Must be at least ⁨8⁩ characters +

    +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    +`; + +exports[`successfully sets password 1`] = ` +
    +
    +

    + To protect against unauthorized changes to your account, +we require users to create a password. +

    +
    + +

    + Must be at least ⁨8⁩ characters +

    +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    +`; diff --git a/src/core/client/admin/test/auth/__snapshots__/createUsername.spec.tsx.snap b/src/core/client/admin/test/auth/__snapshots__/createUsername.spec.tsx.snap new file mode 100644 index 000000000..193f14df9 --- /dev/null +++ b/src/core/client/admin/test/auth/__snapshots__/createUsername.spec.tsx.snap @@ -0,0 +1,397 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`accepts valid username 1`] = ` +
    +
    +

    + Your username is a unique identifier that will appear on all of your comments. +

    +
    + +

    + You may use “_” and “.” Spaces not permitted. +

    + +
    + +
    +
    +`; + +exports[`checks for invalid username 1`] = ` +
    +
    +

    + Your username is a unique identifier that will appear on all of your comments. +

    +
    + +

    + You may use “_” and “.” Spaces not permitted. +

    + +
    + + + Username must contain at least ⁨3⁩ characters. + +
    +
    + +
    +
    +`; + +exports[`renders createUsername view 1`] = ` +
    +
    +
    +
    +
    +

    + Finish Setting Up Your Account +

    +

    + + Create Username + +

    +
    +
    +
    +
    +

    + Your username is a unique identifier that will appear on all of your comments. +

    +
    + +

    + You may use “_” and “.” Spaces not permitted. +

    + +
    + +
    +
    +
    +
    +
    +
    +
    +`; + +exports[`shows error when submitting empty form 1`] = ` +
    +
    +

    + Your username is a unique identifier that will appear on all of your comments. +

    +
    + +

    + You may use “_” and “.” Spaces not permitted. +

    + +
    + + + This field is required. + +
    +
    + +
    +
    +`; + +exports[`shows server error 1`] = ` +
    +
    +

    + Your username is a unique identifier that will appear on all of your comments. +

    +
    + server error + +GraphQL request (4:3) +3: ) { +4: setUsername(input: $input) { + ^ +5: user { + +
    +
    + +

    + You may use “_” and “.” Spaces not permitted. +

    + +
    + +
    +
    +`; + +exports[`successfully sets username 1`] = ` +
    +
    +

    + Your username is a unique identifier that will appear on all of your comments. +

    +
    + +

    + You may use “_” and “.” Spaces not permitted. +

    + +
    + +
    +
    +`; diff --git a/src/core/client/admin/test/auth/__snapshots__/restricted.spec.tsx.snap b/src/core/client/admin/test/auth/__snapshots__/restricted.spec.tsx.snap new file mode 100644 index 000000000..7f3484586 --- /dev/null +++ b/src/core/client/admin/test/auth/__snapshots__/restricted.spec.tsx.snap @@ -0,0 +1,134 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`show restricted screen for commenters 1`] = ` +
    +
    +
    +
    +
    + + + + + + + + + + + logo mark2018 + + + + + +
    +
    +
    +

    + + Currently signed in to + +

    +

    + Talk +

    +
    +
    +
    +
    + +
    +

    + You do not have permission to access this page. +

    +
    +
    +
    + You are signed in as: +

    + Markus +

    +
    +
    +
    + +
    +

    + If you think this is an error, please contact your administrator for assistance. +

    +
    +
    +
    +
    +`; diff --git a/src/core/client/admin/test/auth/__snapshots__/signInWithEmail.spec.tsx.snap b/src/core/client/admin/test/auth/__snapshots__/signInWithEmail.spec.tsx.snap new file mode 100644 index 000000000..919c7641d --- /dev/null +++ b/src/core/client/admin/test/auth/__snapshots__/signInWithEmail.spec.tsx.snap @@ -0,0 +1,735 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`accepts correct password 1`] = ` +
    +
    +
    + + +
    + + + This field is required. + +
    +
    +
    + +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    +`; + +exports[`accepts valid email 1`] = ` +
    +
    +
    + + +
    +
    + +
    +
    + +
    + +
    +
    +
    +
    + + + This field is required. + +
    +
    + +
    +
    +`; + +exports[`checks for invalid email 1`] = ` +
    +
    +
    + + +
    + + + Please enter a valid email address. + +
    +
    +
    + +
    +
    + +
    + +
    +
    +
    +
    + + + This field is required. + +
    +
    + +
    +
    +`; + +exports[`renders sign in form 1`] = ` +
    +
    +
    +
    +
    +
    + + + + + + + + + + + logo mark2018 + + + + + +
    +
    +
    +

    + + Sign in to + +

    +

    + Talk +

    +
    +
    +
    +
    +
    + + +
    +
    + +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +`; + +exports[`shows error when submitting empty form 1`] = ` +
    +
    +
    + + +
    + + + This field is required. + +
    +
    +
    + +
    +
    + +
    + +
    +
    +
    +
    + + + This field is required. + +
    +
    + +
    +
    +`; + +exports[`shows server error 1`] = ` +
    +
    +
    + Server Error +
    +
    + + +
    +
    + +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    +`; + +exports[`submits form successfully 1`] = `"http://localhost/admin/login#accessToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzMWIyNjU5MS00ZTlhLTQzODgtYTdmZi1lMWJkYzVkOTdjY2UifQ=="`; diff --git a/src/core/client/admin/test/auth/accountCompletion.spec.tsx b/src/core/client/admin/test/auth/accountCompletion.spec.tsx new file mode 100644 index 000000000..84201a959 --- /dev/null +++ b/src/core/client/admin/test/auth/accountCompletion.spec.tsx @@ -0,0 +1,127 @@ +import { get, merge } from "lodash"; +import sinon from "sinon"; + +import { + createAuthToken, + replaceHistoryLocation, + wait, + waitForElement, + within, +} from "talk-framework/testHelpers"; + +import create from "../create"; +import { emptyModerationQueues, settings, users } from "../fixtures"; + +async function createTestRenderer( + customResolver: any = {}, + options: { muteNetworkErrors?: boolean; logNetwork?: boolean } = {} +) { + replaceHistoryLocation("http://localhost/admin/login"); + const resolvers = { + ...customResolver, + Query: { + ...customResolver.Query, + moderationQueues: sinon.stub().returns(emptyModerationQueues), + settings: sinon + .stub() + .returns(merge({}, settings, get(customResolver, "Query.settings"))), + me: sinon + .stub() + .returns( + merge( + { ...users[0], email: "", username: "", profiles: [] }, + get(customResolver, "Query.me") + ) + ), + }, + }; + + const { testRenderer, context } = create({ + // Set this to true, to see graphql responses. + logNetwork: options.logNetwork, + muteNetworkErrors: options.muteNetworkErrors, + resolvers, + initLocalState: localRecord => { + localRecord.setValue("SIGN_IN", "authView"); + localRecord.setValue(true, "loggedIn"); + localRecord.setValue(createAuthToken(), "authToken"); + }, + }); + + return { + context, + testRenderer, + root: testRenderer.root, + }; +} + +it("renders addEmailAddress view", async () => { + const { root } = await createTestRenderer(); + await waitForElement(() => within(root).queryByText("Add Email Address")); +}); + +it("renders createUsername view", async () => { + const { root } = await createTestRenderer({ + Query: { + me: { + email: "hans@test.com", + }, + }, + }); + await waitForElement(() => within(root).queryByText("Create Username")); +}); + +it("renders createPassword view", async () => { + const { root } = await createTestRenderer({ + Query: { + me: { + email: "hans@test.com", + username: "hans", + }, + }, + }); + await waitForElement(() => within(root).queryByText("Create Password")); +}); + +it("do not render createPassword view when local auth is disabled", async () => { + await createTestRenderer({ + Query: { + me: { + email: "hans@test.com", + username: "hans", + }, + settings: { + auth: { + integrations: { + local: { + enabled: false, + }, + }, + }, + }, + }, + }); + + await wait(() => + expect(window.location.toString()).toBe( + "http://localhost/admin/moderate/reported" + ) + ); +}); + +it("complete account", async () => { + await createTestRenderer({ + Query: { + me: { + email: "hans@test.com", + username: "hans", + profiles: [{ __typename: "LocalProfile" }], + }, + }, + }); + await wait(() => + expect(window.location.toString()).toBe( + "http://localhost/admin/moderate/reported" + ) + ); +}); diff --git a/src/core/client/admin/test/auth/addEmailAddress.spec.tsx b/src/core/client/admin/test/auth/addEmailAddress.spec.tsx new file mode 100644 index 000000000..f40216a61 --- /dev/null +++ b/src/core/client/admin/test/auth/addEmailAddress.spec.tsx @@ -0,0 +1,187 @@ +import { get, merge } from "lodash"; +import sinon from "sinon"; + +import { + createAuthToken, + replaceHistoryLocation, + toJSON, + wait, + waitForElement, + within, +} from "talk-framework/testHelpers"; + +import create from "../create"; +import { settings, users } from "../fixtures"; + +async function createTestRenderer( + customResolver: any = {}, + options: { muteNetworkErrors?: boolean; logNetwork?: boolean } = {} +) { + replaceHistoryLocation("http://localhost/admin/login"); + const resolvers = { + ...customResolver, + Query: { + ...customResolver.Query, + settings: sinon + .stub() + .returns(merge({}, settings, get(customResolver, "Query.settings"))), + me: sinon.stub().returns({ ...users[0], email: "" }), + }, + }; + + const { testRenderer, context } = create({ + // Set this to true, to see graphql responses. + logNetwork: options.logNetwork, + muteNetworkErrors: options.muteNetworkErrors, + resolvers, + initLocalState: localRecord => { + localRecord.setValue("ADD_EMAIL_ADDRESS", "authView"); + localRecord.setValue(true, "loggedIn"); + localRecord.setValue(createAuthToken(), "authToken"); + }, + }); + const container = await waitForElement(() => + within(testRenderer.root).getByTestID("completeAccountBox") + ); + const form = within(container).getByType("form"); + const emailAddressField = within(form).getByLabelText("Email Address"); + const confirmEmailAddressField = within(form).getByLabelText( + "Confirm Email Address" + ); + + return { + context, + testRenderer, + form, + root: testRenderer.root, + emailAddressField, + confirmEmailAddressField, + container, + }; +} + +it("renders addEmailAddress view", async () => { + const { root } = await createTestRenderer(); + expect(toJSON(root)).toMatchSnapshot(); +}); + +it("shows error when submitting empty form", async () => { + const { form } = await createTestRenderer(); + form.props.onSubmit(); + expect(toJSON(form)).toMatchSnapshot(); +}); + +it("checks for invalid email", async () => { + const { + form, + emailAddressField, + confirmEmailAddressField, + } = await createTestRenderer(); + emailAddressField.props.onChange({ target: { value: "invalid-email" } }); + confirmEmailAddressField.props.onChange({ + target: { value: "invalid-confirmation-email" }, + }); + form.props.onSubmit(); + expect(toJSON(form)).toMatchSnapshot(); +}); + +it("accepts valid email", async () => { + const { form, emailAddressField } = await createTestRenderer(); + emailAddressField.props.onChange({ target: { value: "hans@test.com" } }); + form.props.onSubmit(); + expect(toJSON(form)).toMatchSnapshot(); +}); + +it("accepts valid email confirmation", async () => { + const { + form, + emailAddressField, + confirmEmailAddressField, + } = await createTestRenderer(); + emailAddressField.props.onChange({ target: { value: "hans@test.com" } }); + confirmEmailAddressField.props.onChange({ + target: { value: "hans@test.com" }, + }); + form.props.onSubmit(); + expect(toJSON(form)).toMatchSnapshot(); +}); + +it("shows server error", async () => { + const email = "hans@test.com"; + const setEmail = sinon.stub().callsFake((_: any, data: any) => { + throw new Error("server error"); + }); + const { + form, + emailAddressField, + confirmEmailAddressField, + } = await createTestRenderer( + { + Mutation: { + setEmail, + }, + }, + { muteNetworkErrors: true } + ); + const submitButton = form.find( + i => i.type === "button" && i.props.type === "submit" + ); + + emailAddressField.props.onChange({ target: { value: email } }); + confirmEmailAddressField.props.onChange({ + target: { value: email }, + }); + + form.props.onSubmit(); + expect(emailAddressField.props.disabled).toBe(true); + expect(confirmEmailAddressField.props.disabled).toBe(true); + expect(submitButton.props.disabled).toBe(true); + + await wait(() => expect(submitButton.props.disabled).toBe(false)); + + expect(toJSON(form)).toMatchSnapshot(); +}); + +it("successfully sets email", async () => { + const email = "hans@test.com"; + const setEmail = sinon.stub().callsFake((_: any, data: any) => { + expect(data.input).toEqual({ + email, + clientMutationId: data.input.clientMutationId, + }); + return { + user: { + id: "me", + email, + }, + clientMutationId: data.input.clientMutationId, + }; + }); + const { + form, + emailAddressField, + confirmEmailAddressField, + } = await createTestRenderer({ + Mutation: { + setEmail, + }, + }); + const submitButton = form.find( + i => i.type === "button" && i.props.type === "submit" + ); + + emailAddressField.props.onChange({ target: { value: email } }); + confirmEmailAddressField.props.onChange({ + target: { value: email }, + }); + + form.props.onSubmit(); + expect(emailAddressField.props.disabled).toBe(true); + expect(confirmEmailAddressField.props.disabled).toBe(true); + expect(submitButton.props.disabled).toBe(true); + + await wait(() => expect(submitButton.props.disabled).toBe(false)); + + expect(toJSON(form)).toMatchSnapshot(); + expect(setEmail.called).toBe(true); +}); diff --git a/src/core/client/admin/test/auth/createPassword.spec.tsx b/src/core/client/admin/test/auth/createPassword.spec.tsx new file mode 100644 index 000000000..26cf46035 --- /dev/null +++ b/src/core/client/admin/test/auth/createPassword.spec.tsx @@ -0,0 +1,139 @@ +import { get, merge } from "lodash"; +import sinon from "sinon"; + +import { + createAuthToken, + replaceHistoryLocation, + toJSON, + wait, + waitForElement, + within, +} from "talk-framework/testHelpers"; + +import create from "../create"; +import { settings, users } from "../fixtures"; + +async function createTestRenderer( + customResolver: any = {}, + options: { muteNetworkErrors?: boolean; logNetwork?: boolean } = {} +) { + replaceHistoryLocation("http://localhost/admin/login"); + const resolvers = { + ...customResolver, + Query: { + ...customResolver.Query, + settings: sinon + .stub() + .returns(merge({}, settings, get(customResolver, "Query.settings"))), + me: sinon.stub().returns({ ...users[0], profiles: [] }), + }, + }; + + const { testRenderer, context } = create({ + // Set this to true, to see graphql responses. + logNetwork: options.logNetwork, + muteNetworkErrors: options.muteNetworkErrors, + resolvers, + initLocalState: localRecord => { + localRecord.setValue("CREATE_PASSWORD", "authView"); + localRecord.setValue(true, "loggedIn"); + localRecord.setValue(createAuthToken(), "authToken"); + }, + }); + const container = await waitForElement(() => + within(testRenderer.root).getByTestID("completeAccountBox") + ); + const form = within(container).getByType("form"); + const passwordField = within(form).getByLabelText("Password"); + + return { + context, + testRenderer, + form, + root: testRenderer.root, + passwordField, + container, + }; +} + +it("renders createPassword view", async () => { + const { root } = await createTestRenderer(); + expect(toJSON(root)).toMatchSnapshot(); +}); + +it("shows error when submitting empty form", async () => { + const { form } = await createTestRenderer(); + form.props.onSubmit(); + expect(toJSON(form)).toMatchSnapshot(); +}); + +it("checks for invalid password", async () => { + const { form, passwordField } = await createTestRenderer(); + passwordField.props.onChange({ target: { value: "x" } }); + form.props.onSubmit(); + expect(toJSON(form)).toMatchSnapshot(); +}); + +it("shows server error", async () => { + const password = "secretpassword"; + const setPassword = sinon.stub().callsFake((_: any, data: any) => { + throw new Error("server error"); + }); + const { form, passwordField } = await createTestRenderer( + { + Mutation: { + setPassword, + }, + }, + { muteNetworkErrors: true } + ); + const submitButton = form.find( + i => i.type === "button" && i.props.type === "submit" + ); + + passwordField.props.onChange({ target: { value: password } }); + + form.props.onSubmit(); + expect(passwordField.props.disabled).toBe(true); + expect(submitButton.props.disabled).toBe(true); + + await wait(() => expect(submitButton.props.disabled).toBe(false)); + + expect(toJSON(form)).toMatchSnapshot(); +}); + +it("successfully sets password", async () => { + const password = "secretpassword"; + const setPassword = sinon.stub().callsFake((_: any, data: any) => { + expect(data.input).toEqual({ + password, + clientMutationId: data.input.clientMutationId, + }); + return { + user: { + id: "me", + profiles: [], + }, + clientMutationId: data.input.clientMutationId, + }; + }); + const { form, passwordField } = await createTestRenderer({ + Mutation: { + setPassword, + }, + }); + const submitButton = form.find( + i => i.type === "button" && i.props.type === "submit" + ); + + passwordField.props.onChange({ target: { value: password } }); + + form.props.onSubmit(); + expect(passwordField.props.disabled).toBe(true); + expect(submitButton.props.disabled).toBe(true); + + await wait(() => expect(submitButton.props.disabled).toBe(false)); + + expect(toJSON(form)).toMatchSnapshot(); + expect(setPassword.called).toBe(true); +}); diff --git a/src/core/client/admin/test/auth/createUsername.spec.tsx b/src/core/client/admin/test/auth/createUsername.spec.tsx new file mode 100644 index 000000000..5466c497d --- /dev/null +++ b/src/core/client/admin/test/auth/createUsername.spec.tsx @@ -0,0 +1,145 @@ +import { get, merge } from "lodash"; +import sinon from "sinon"; + +import { + createAuthToken, + replaceHistoryLocation, + toJSON, + wait, + waitForElement, + within, +} from "talk-framework/testHelpers"; + +import create from "../create"; +import { settings } from "../fixtures"; + +async function createTestRenderer( + customResolver: any = {}, + options: { muteNetworkErrors?: boolean; logNetwork?: boolean } = {} +) { + replaceHistoryLocation("http://localhost/admin/login"); + const resolvers = { + ...customResolver, + Query: { + ...customResolver.Query, + settings: sinon + .stub() + .returns(merge({}, settings, get(customResolver, "Query.settings"))), + }, + }; + + const { testRenderer, context } = create({ + // Set this to true, to see graphql responses. + logNetwork: options.logNetwork, + muteNetworkErrors: options.muteNetworkErrors, + resolvers, + initLocalState: localRecord => { + localRecord.setValue("CREATE_USERNAME", "authView"); + localRecord.setValue(true, "loggedIn"); + localRecord.setValue(createAuthToken(), "authToken"); + }, + }); + const container = await waitForElement(() => + within(testRenderer.root).getByTestID("completeAccountBox") + ); + const form = within(container).getByType("form"); + const usernameField = within(form).getByLabelText("Username"); + + return { + context, + testRenderer, + form, + root: testRenderer.root, + usernameField, + container, + }; +} + +it("renders createUsername view", async () => { + const { root } = await createTestRenderer(); + expect(toJSON(root)).toMatchSnapshot(); +}); + +it("shows error when submitting empty form", async () => { + const { form } = await createTestRenderer(); + form.props.onSubmit(); + expect(toJSON(form)).toMatchSnapshot(); +}); + +it("checks for invalid username", async () => { + const { form, usernameField } = await createTestRenderer(); + usernameField.props.onChange({ target: { value: "x" } }); + form.props.onSubmit(); + expect(toJSON(form)).toMatchSnapshot(); +}); + +it("accepts valid username", async () => { + const { form, usernameField } = await createTestRenderer(); + usernameField.props.onChange({ target: { value: "hans" } }); + form.props.onSubmit(); + expect(toJSON(form)).toMatchSnapshot(); +}); + +it("shows server error", async () => { + const username = "hans"; + const setUsername = sinon.stub().callsFake((_: any, data: any) => { + throw new Error("server error"); + }); + const { form, usernameField } = await createTestRenderer( + { + Mutation: { + setUsername, + }, + }, + { muteNetworkErrors: true } + ); + const submitButton = form.find( + i => i.type === "button" && i.props.type === "submit" + ); + + usernameField.props.onChange({ target: { value: username } }); + + form.props.onSubmit(); + expect(usernameField.props.disabled).toBe(true); + expect(submitButton.props.disabled).toBe(true); + + await wait(() => expect(submitButton.props.disabled).toBe(false)); + + expect(toJSON(form)).toMatchSnapshot(); +}); + +it("successfully sets username", async () => { + const username = "hans"; + const setUsername = sinon.stub().callsFake((_: any, data: any) => { + expect(data.input).toEqual({ + username, + clientMutationId: data.input.clientMutationId, + }); + return { + user: { + id: "me", + username, + }, + clientMutationId: data.input.clientMutationId, + }; + }); + const { form, usernameField } = await createTestRenderer({ + Mutation: { + setUsername, + }, + }); + const submitButton = form.find( + i => i.type === "button" && i.props.type === "submit" + ); + + usernameField.props.onChange({ target: { value: username } }); + + form.props.onSubmit(); + expect(usernameField.props.disabled).toBe(true); + expect(submitButton.props.disabled).toBe(true); + + await wait(() => expect(submitButton.props.disabled).toBe(false)); + + expect(toJSON(form)).toMatchSnapshot(); + expect(setUsername.called).toBe(true); +}); diff --git a/src/core/client/admin/test/redirectLoggedIn.spec.tsx b/src/core/client/admin/test/auth/redirectLoggedIn.spec.tsx similarity index 65% rename from src/core/client/admin/test/redirectLoggedIn.spec.tsx rename to src/core/client/admin/test/auth/redirectLoggedIn.spec.tsx index e92f62255..55e1d5d39 100644 --- a/src/core/client/admin/test/redirectLoggedIn.spec.tsx +++ b/src/core/client/admin/test/auth/redirectLoggedIn.spec.tsx @@ -1,19 +1,24 @@ import sinon from "sinon"; -import { timeout } from "talk-common/utils"; -import { replaceHistoryLocation } from "talk-framework/testHelpers"; +import { + createAuthToken, + replaceHistoryLocation, + wait, +} from "talk-framework/testHelpers"; -import create from "./create"; +import create from "../create"; import { emptyModerationQueues, emptyRejectedComments, settings, -} from "./fixtures"; + users, +} from "../fixtures"; const resolvers = { Query: { settings: sinon.stub().returns(settings), moderationQueues: sinon.stub().returns(emptyModerationQueues), comments: sinon.stub().returns(emptyRejectedComments), + me: sinon.stub().returns(users[0]), }, }; @@ -25,11 +30,13 @@ it("redirect when already logged in", async () => { logNetwork: false, initLocalState: localRecord => { localRecord.setValue(true, "loggedIn"); + localRecord.setValue(createAuthToken(), "authToken"); }, }); - await timeout(); - expect(window.location.toString()).toBe( - "http://localhost/admin/moderate/reported" + await wait(() => + expect(window.location.toString()).toBe( + "http://localhost/admin/moderate/reported" + ) ); }); @@ -41,9 +48,11 @@ it("redirect to redirectPath when already logged in", async () => { logNetwork: false, initLocalState: localRecord => { localRecord.setValue(true, "loggedIn"); + localRecord.setValue(createAuthToken(), "authToken"); localRecord.setValue("/admin/community", "redirectPath"); }, }); - await timeout(); - expect(window.location.toString()).toBe("http://localhost/admin/community"); + await wait(() => + expect(window.location.toString()).toBe("http://localhost/admin/community") + ); }); diff --git a/src/core/client/admin/test/redirectLoggedOut.spec.tsx b/src/core/client/admin/test/auth/redirectLoggedOut.spec.tsx similarity index 69% rename from src/core/client/admin/test/redirectLoggedOut.spec.tsx rename to src/core/client/admin/test/auth/redirectLoggedOut.spec.tsx index d20852edd..35fda04e9 100644 --- a/src/core/client/admin/test/redirectLoggedOut.spec.tsx +++ b/src/core/client/admin/test/auth/redirectLoggedOut.spec.tsx @@ -1,17 +1,16 @@ import { ReactTestRenderer } from "react-test-renderer"; import sinon from "sinon"; -import { timeout } from "talk-common/utils"; import { TalkContext } from "talk-framework/lib/bootstrap"; import { LOCAL_ID } from "talk-framework/lib/relay"; -import { replaceHistoryLocation } from "talk-framework/testHelpers"; +import { replaceHistoryLocation, wait } from "talk-framework/testHelpers"; -import create from "./create"; +import create from "../create"; import { emptyModerationQueues, emptyRejectedComments, settings, -} from "./fixtures"; +} from "../fixtures"; function createTestRenderer(): { testRenderer: ReactTestRenderer; @@ -31,6 +30,7 @@ function createTestRenderer(): { logNetwork: false, initLocalState: localRecord => { localRecord.setValue(false, "loggedIn"); + localRecord.setValue("SIGN_IN", "authView"); }, }); return { testRenderer, context }; @@ -38,12 +38,13 @@ function createTestRenderer(): { it("redirect when not logged in", async () => { const { context } = createTestRenderer(); - await timeout(); - expect( - context.relayEnvironment - .getStore() - .getSource() - .get(LOCAL_ID)!.redirectPath - ).toBe("/admin/moderate/reported"); - expect(window.location.toString()).toBe("http://localhost/admin/login"); + await wait(() => { + expect( + context.relayEnvironment + .getStore() + .getSource() + .get(LOCAL_ID)!.redirectPath + ).toBe("/admin/moderate/reported"); + expect(window.location.toString()).toBe("http://localhost/admin/login"); + }); }); diff --git a/src/core/client/admin/test/auth/restricted.spec.tsx b/src/core/client/admin/test/auth/restricted.spec.tsx new file mode 100644 index 000000000..3cef1ba13 --- /dev/null +++ b/src/core/client/admin/test/auth/restricted.spec.tsx @@ -0,0 +1,107 @@ +import { ReactTestRenderer } from "react-test-renderer"; +import sinon from "sinon"; + +import { TalkContext } from "talk-framework/lib/bootstrap"; +import { LOCAL_ID } from "talk-framework/lib/relay"; +import { + createAuthToken, + replaceHistoryLocation, + wait, + waitForElement, + within, +} from "talk-framework/testHelpers"; + +import create from "../create"; +import { + emptyModerationQueues, + emptyRejectedComments, + settings, + users, +} from "../fixtures"; + +function createTestRenderer( + userDiff: any = {} +): { + testRenderer: ReactTestRenderer; + context: TalkContext; +} { + replaceHistoryLocation("http://localhost/admin/moderate/reported"); + const resolvers = { + Query: { + settings: sinon.stub().returns(settings), + moderationQueues: sinon.stub().returns(emptyModerationQueues), + comments: sinon.stub().returns(emptyRejectedComments), + me: sinon.stub().returns({ ...users[0], ...userDiff }), + }, + }; + const { testRenderer, context } = create({ + resolvers, + // Set this to true, to see graphql responses. + logNetwork: false, + initLocalState: localRecord => { + localRecord.setValue(true, "loggedIn"); + localRecord.setValue(createAuthToken(), "authToken"); + localRecord.setValue("SIGN_IN", "authView"); + }, + }); + return { testRenderer, context }; +} + +it("show restricted screen for commenters", async () => { + const { testRenderer } = createTestRenderer({ role: "COMMENTER" }); + const authBox = await waitForElement(() => + within(testRenderer.root).getByTestID("authBox") + ); + expect(within(authBox).toJSON()).toMatchSnapshot(); +}); + +it("show restricted screen when email is not set", async () => { + const { testRenderer } = createTestRenderer({ email: "" }); + await waitForElement(() => within(testRenderer.root).getByTestID("authBox")); +}); + +it("show restricted screen when username is not set", async () => { + const { testRenderer } = createTestRenderer({ username: "" }); + await waitForElement(() => within(testRenderer.root).getByTestID("authBox")); +}); + +it("show restricted screen local was not set (password)", async () => { + const { testRenderer } = createTestRenderer({ profiles: [] }); + await waitForElement(() => within(testRenderer.root).getByTestID("authBox")); +}); + +it("sign out when clicking on sign in as", async () => { + const { context, testRenderer } = createTestRenderer({ role: "COMMENTER" }); + const authBox = await waitForElement(() => + within(testRenderer.root).getByTestID("authBox") + ); + + const restMock = sinon.mock(context.rest); + restMock + .expects("fetch") + .withArgs("/tenant/auth", { + method: "DELETE", + }) + .once() + .returns({}); + + within(authBox) + .getByText("Sign in with a different account") + .props.onClick(); + + await wait(() => { + expect( + context.relayEnvironment + .getStore() + .getSource() + .get(LOCAL_ID)!.redirectPath + ).toBe("/admin/moderate/reported"); + }); + + expect( + context.relayEnvironment + .getStore() + .getSource() + .get(LOCAL_ID)!.loggedIn + ).toBeFalsy(); +}); diff --git a/src/core/client/admin/test/login.spec.tsx b/src/core/client/admin/test/auth/signInWithEmail.spec.tsx similarity index 60% rename from src/core/client/admin/test/login.spec.tsx rename to src/core/client/admin/test/auth/signInWithEmail.spec.tsx index 99ed93606..325bffd46 100644 --- a/src/core/client/admin/test/login.spec.tsx +++ b/src/core/client/admin/test/auth/signInWithEmail.spec.tsx @@ -1,21 +1,20 @@ -import { ReactTestInstance, ReactTestRenderer } from "react-test-renderer"; +import { ReactTestInstance } from "react-test-renderer"; import sinon from "sinon"; -import { timeout } from "talk-common/utils"; -import { TalkContext } from "talk-framework/lib/bootstrap"; - -import { LOCAL_ID } from "talk-framework/lib/relay"; import { createAuthToken, replaceHistoryLocation, + wait, + waitForElement, + within, } from "talk-framework/testHelpers"; -import create from "./create"; +import create from "../create"; import { emptyModerationQueues, emptyRejectedComments, settings, -} from "./fixtures"; +} from "../fixtures"; const resolvers = { Query: { @@ -29,65 +28,71 @@ const inputPredicate = (name: string) => (n: ReactTestInstance) => { return n.props.name === name && n.props.onChange; }; -let context: TalkContext; -let testRenderer: ReactTestRenderer; -let form: ReactTestInstance; -beforeEach(async () => { +async function createTestRenderer() { // deliberately setting to a different route, // it should be smart enough to reroute to /admin/login. replaceHistoryLocation("http://localhost/admin/moderate"); - ({ testRenderer, context } = create({ + const { testRenderer, context } = create({ resolvers, // Set this to true, to see graphql responses. logNetwork: false, initLocalState: localRecord => { localRecord.setValue(false, "loggedIn"); + localRecord.setValue("SIGN_IN", "authView"); }, - })); - await timeout(); - form = testRenderer.root.findByType("form"); -}); + }); + const form = await waitForElement(() => + within(testRenderer.root).getByType("form") + ); + return { testRenderer, form, context }; +} it("renders sign in form", async () => { + const { testRenderer } = await createTestRenderer(); expect(testRenderer.toJSON()).toMatchSnapshot(); }); it("shows error when submitting empty form", async () => { + const { form } = await createTestRenderer(); form.props.onSubmit(); - expect(testRenderer.toJSON()).toMatchSnapshot(); + expect(within(form).toJSON()).toMatchSnapshot(); }); it("checks for invalid email", async () => { - form - .find(inputPredicate("email")) + const { form } = await createTestRenderer(); + within(form) + .getByLabelText("Email Address") .props.onChange({ target: { value: "invalid-email" } }); form.props.onSubmit(); - expect(testRenderer.toJSON()).toMatchSnapshot(); + expect(within(form).toJSON()).toMatchSnapshot(); }); it("accepts valid email", async () => { - form - .find(inputPredicate("email")) + const { form } = await createTestRenderer(); + within(form) + .getByLabelText("Email Address") .props.onChange({ target: { value: "hans@test.com" } }); form.props.onSubmit(); - expect(testRenderer.toJSON()).toMatchSnapshot(); + expect(within(form).toJSON()).toMatchSnapshot(); }); it("accepts correct password", async () => { - form - .find(inputPredicate("password")) + const { form } = await createTestRenderer(); + within(form) + .getByLabelText("Password") .props.onChange({ target: { value: "testtest" } }); form.props.onSubmit(); - expect(testRenderer.toJSON()).toMatchSnapshot(); + expect(within(form).toJSON()).toMatchSnapshot(); }); it("shows server error", async () => { - form - .find(inputPredicate("email")) + const { form, context } = await createTestRenderer(); + within(form) + .getByLabelText("Email Address") .props.onChange({ target: { value: "hans@test.com" } }); - form - .find(inputPredicate("password")) + within(form) + .getByLabelText("Password") .props.onChange({ target: { value: "testtest" } }); const error = new Error("Server Error"); @@ -105,13 +110,17 @@ it("shows server error", async () => { .throws(error); form.props.onSubmit(); - expect(testRenderer.toJSON()).toMatchSnapshot(); - await timeout(); - expect(testRenderer.toJSON()).toMatchSnapshot(); + const submitButton = within(form).getByText("Sign in with Email", { + selector: "button", + }); + expect(submitButton.props.disabled).toBe(true); + await wait(() => expect(submitButton.props.disabled).toBe(false)); + expect(within(form).toJSON()).toMatchSnapshot(); restMock.verify(); }); it("submits form successfully", async () => { + const { form, context } = await createTestRenderer(); form .find(inputPredicate("email")) .props.onChange({ target: { value: "hans@test.com" } }); @@ -137,17 +146,12 @@ it("submits form successfully", async () => { const historyMock = sinon.mock(window.history); form.props.onSubmit(); - expect(testRenderer.toJSON()).toMatchSnapshot(); - await timeout(); - expect(testRenderer.toJSON()).toMatchSnapshot(); + const submitButton = within(form).getByText("Sign in with Email", { + selector: "button", + }); + expect(submitButton.props.disabled).toBe(true); + await wait(() => expect(submitButton.props.disabled).toBe(false)); + expect(location.toString()).toMatchSnapshot(); restMock.verify(); - await timeout(); - expect( - context.relayEnvironment - .getStore() - .getSource() - .get(LOCAL_ID)!.loggedIn - ).toBeTruthy(); - await timeout(); historyMock.verify(); }); diff --git a/src/core/client/admin/test/signOut.spec.tsx b/src/core/client/admin/test/auth/signOut.spec.tsx similarity index 62% rename from src/core/client/admin/test/signOut.spec.tsx rename to src/core/client/admin/test/auth/signOut.spec.tsx index faca10496..fe5964693 100644 --- a/src/core/client/admin/test/signOut.spec.tsx +++ b/src/core/client/admin/test/auth/signOut.spec.tsx @@ -1,21 +1,27 @@ import sinon from "sinon"; -import { timeout } from "talk-common/utils"; import { LOCAL_ID } from "talk-framework/lib/relay"; -import { replaceHistoryLocation } from "talk-framework/testHelpers"; +import { + replaceHistoryLocation, + wait, + waitForElement, + within, +} from "talk-framework/testHelpers"; -import create from "./create"; +import create from "../create"; import { emptyModerationQueues, emptyRejectedComments, settings, -} from "./fixtures"; + users, +} from "../fixtures"; const resolvers = { Query: { settings: sinon.stub().returns(settings), moderationQueues: sinon.stub().returns(emptyModerationQueues), comments: sinon.stub().returns(emptyRejectedComments), + me: sinon.stub().returns(users[0]), }, }; @@ -40,17 +46,17 @@ it("logs out", async () => { .once() .returns({}); - await timeout(); - testRenderer.root - .find(i => i.props.id === "navigation-signOutButton" && i.props.onClick) - .props.onClick(); + const signOutButton = await waitForElement(() => + within(testRenderer.root).getByText("Sign Out") + ); + signOutButton.props.onClick(); - await timeout(); - - expect( - context.relayEnvironment - .getStore() - .getSource() - .get(LOCAL_ID)!.loggedIn - ).toBeFalsy(); + await wait(() => { + expect( + context.relayEnvironment + .getStore() + .getSource() + .get(LOCAL_ID)!.loggedIn + ).toBeFalsy(); + }); }); diff --git a/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap index 4e8753e4c..8fb2b38b9 100644 --- a/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap @@ -1703,6 +1703,7 @@ exports[`renders configure auth 1`] = ` >
    { replaceHistoryLocation("http://localhost/admin/configure/auth"); @@ -23,7 +25,10 @@ const createTestRenderer = async (resolver: any = {}) => { ...resolver.Query, settings: sinon .stub() - .returns(merge({}, settings, get(resolver, "Query.settings"))), + .returns( + merge({}, settingsWithEmptyAuth, get(resolver, "Query.settings")) + ), + me: sinon.stub().returns(users[0]), }, }; const { testRenderer } = create({ @@ -34,24 +39,27 @@ const createTestRenderer = async (resolver: any = {}) => { localRecord.setValue(true, "loggedIn"); }, }); - await timeout(); - return testRenderer; + const configureContainer = await waitForElement(() => + within(testRenderer.root).getByTestID("configure-container") + ); + const authContainer = await waitForElement(() => + within(configureContainer).getByTestID("configure-authContainer") + ); + return { testRenderer, configureContainer, authContainer }; }; it("renders configure auth", async () => { - const testRenderer = await createTestRenderer(); - expect( - limitSnapshotTo("configure-container", testRenderer.toJSON()) - ).toMatchSnapshot(); + const { configureContainer } = await createTestRenderer(); + expect(within(configureContainer).toJSON()).toMatchSnapshot(); }); it("regenerate sso key", async () => { - const testRenderer = await createTestRenderer({ + const { testRenderer } = await createTestRenderer({ Mutation: { regenerateSSOKey: createSinonStub(s => s.callsFake((_: any, data: any) => { return { - settings: merge({}, settings, { + settings: merge({}, settingsWithEmptyAuth, { auth: { integrations: { sso: { @@ -83,7 +91,7 @@ it("regenerate sso key", async () => { }); it("prevents admin lock out", async () => { - const testRenderer = await createTestRenderer(); + const { testRenderer } = await createTestRenderer(); // Let's disable local auth. testRenderer.root @@ -99,8 +107,8 @@ it("prevents admin lock out", async () => { }); it("prevents stream lock out", async () => { - let settingsRecord = cloneDeep(settings); - const testRenderer = await createTestRenderer({ + let settingsRecord = cloneDeep(settingsWithEmptyAuth); + const { testRenderer } = await createTestRenderer({ Mutation: { updateSettings: createSinonStub(s => s.callsFake((_: any, data: any) => { @@ -159,8 +167,8 @@ it("prevents stream lock out", async () => { }); it("change settings", async () => { - let settingsRecord = cloneDeep(settings); - const testRenderer = await createTestRenderer({ + let settingsRecord = cloneDeep(settingsWithEmptyAuth); + const { testRenderer } = await createTestRenderer({ Query: { discoverOIDCConfiguration: createSinonStub(s => s.callsFake((_: any, data: any) => { diff --git a/src/core/client/admin/test/create.tsx b/src/core/client/admin/test/create.tsx index 62881117d..c21de5b96 100644 --- a/src/core/client/admin/test/create.tsx +++ b/src/core/client/admin/test/create.tsx @@ -1,6 +1,5 @@ import { EventEmitter2 } from "eventemitter2"; import { IResolvers } from "graphql-tools"; -import { noop } from "lodash"; import React from "react"; import TestRenderer from "react-test-renderer"; import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime"; @@ -19,6 +18,7 @@ import createNodeMock from "./createNodeMock"; interface CreateParams { logNetwork?: boolean; resolvers?: IResolvers; + muteNetworkErrors?: boolean; initLocalState?: ( local: RecordProxy, source: RecordSourceProxy, @@ -31,6 +31,7 @@ export default function create(params: CreateParams) { // Set this to true, to see graphql responses. logNetwork: params.logNetwork, resolvers: params.resolvers, + muteNetworkErrors: params.muteNetworkErrors, initLocalState: (localRecord, source, env) => { if (params.initLocalState) { params.initLocalState(localRecord, source, env); @@ -48,7 +49,7 @@ export default function create(params: CreateParams) { browserInfo: { ios: false }, uuidGenerator: createUUIDGenerator(), eventEmitter: new EventEmitter2({ wildcard: true, maxListeners: 20 }), - clearSession: noop, + clearSession: () => Promise.resolve(), }; const testRenderer = TestRenderer.create( diff --git a/src/core/client/admin/test/createEnvironment.ts b/src/core/client/admin/test/createEnvironment.ts index 700b354c9..b862f6d2b 100644 --- a/src/core/client/admin/test/createEnvironment.ts +++ b/src/core/client/admin/test/createEnvironment.ts @@ -5,6 +5,7 @@ import { createRelayEnvironment } from "talk-framework/testHelpers"; interface CreateEnvironmentParams { logNetwork?: boolean; resolvers?: IResolvers; + muteNetworkErrors?: boolean; initLocalState?: ( local: RecordProxy, source: RecordSourceProxy, @@ -16,6 +17,7 @@ export default function createEnvironment(params: CreateEnvironmentParams) { return createRelayEnvironment({ network: { logNetwork: params.logNetwork, + muteNetworkErrors: params.muteNetworkErrors, resolvers: params.resolvers || {}, projectName: "tenant", }, diff --git a/src/core/client/admin/test/decisionHistory/decisionHistory.spec.tsx b/src/core/client/admin/test/decisionHistory/decisionHistory.spec.tsx index db950c55a..39a817168 100644 --- a/src/core/client/admin/test/decisionHistory/decisionHistory.spec.tsx +++ b/src/core/client/admin/test/decisionHistory/decisionHistory.spec.tsx @@ -11,7 +11,7 @@ import { } from "talk-framework/testHelpers"; import create from "../create"; -import { moderationActions, settings } from "../fixtures"; +import { moderationActions, settings, users } from "../fixtures"; beforeEach(async () => { replaceHistoryLocation("http://localhost/admin/configure/auth"); @@ -62,7 +62,7 @@ const createTestRenderer = async (resolver: any = {}) => { Query: { ...resolver.Query, me: sinon.stub().returns({ - id: "me", + ...users[0], commentModerationActionHistory, }), settings: sinon diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index 256bfefc6..8810dc2c7 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -39,6 +39,7 @@ export const settings = { clientID: "", clientSecret: "", callbackURL: "http://localhost/google/callback", + redirectURL: "http://localhost/google", }, facebook: { enabled: false, @@ -50,6 +51,7 @@ export const settings = { clientID: "", clientSecret: "", callbackURL: "http://localhost/facebook/callback", + redirectURL: "http://localhost/facebook", }, oidc: { enabled: false, @@ -58,7 +60,74 @@ export const settings = { admin: true, stream: true, }, + name: "OIDC", callbackURL: "http://localhost/oidc/callback", + redirectURL: "http://localhost/oidc", + }, + }, + }, +}; + +export const settingsWithEmptyAuth = { + ...settings, + id: "settings", + auth: { + displayName: { + enabled: false, + }, + integrations: { + local: { + enabled: true, + allowRegistration: true, + targetFilter: { + admin: true, + stream: true, + }, + }, + sso: { + enabled: false, + allowRegistration: true, + targetFilter: { + admin: true, + stream: true, + }, + key: null, + keyGeneratedAt: null, + }, + google: { + enabled: false, + allowRegistration: true, + targetFilter: { + admin: true, + stream: true, + }, + clientID: "", + clientSecret: "", + callbackURL: "http://localhost/google/callback", + redirectURL: "http://localhost/google", + }, + facebook: { + enabled: false, + allowRegistration: true, + targetFilter: { + admin: true, + stream: true, + }, + clientID: "", + clientSecret: "", + callbackURL: "http://localhost/facebook/callback", + redirectURL: "http://localhost/facebook", + }, + oidc: { + enabled: false, + allowRegistration: false, + targetFilter: { + admin: true, + stream: true, + }, + name: "", + callbackURL: "http://localhost/oidc/callback", + redirectURL: "http://localhost/oidc", }, }, }, @@ -151,14 +220,23 @@ export const users = [ { id: "user-0", username: "Markus", + email: "markus@test.com", + role: "ADMIN", + profiles: [{ __typename: "LocalProfile" }], }, { id: "user-1", username: "Lukas", + email: "lukas@test.com", + role: "MODERATOR", + profiles: [{ __typename: "LocalProfile" }], }, { id: "user-2", username: "Isabelle", + email: "isabelle@test.com", + role: "COMMENTER", + profiles: [{ __typename: "LocalProfile" }], }, ]; diff --git a/src/core/client/admin/test/moderate/moderate.spec.tsx b/src/core/client/admin/test/moderate/moderate.spec.tsx index b2c3f5b97..ee61e0ceb 100644 --- a/src/core/client/admin/test/moderate/moderate.spec.tsx +++ b/src/core/client/admin/test/moderate/moderate.spec.tsx @@ -17,6 +17,7 @@ import { rejectedComments, reportedComments, settings, + users, } from "../fixtures"; beforeEach(async () => { @@ -43,6 +44,7 @@ const createTestRenderer = async (resolver: any = {}) => { comments: get(resolver, "Query.comments") || sinon.stub().returns(emptyRejectedComments), + me: sinon.stub().returns(users[0]), }, }; const { testRenderer } = create({ diff --git a/src/core/client/admin/views/restricted/components/Restricted.css b/src/core/client/admin/views/restricted/components/Restricted.css new file mode 100644 index 000000000..fae5c14e9 --- /dev/null +++ b/src/core/client/admin/views/restricted/components/Restricted.css @@ -0,0 +1,9 @@ +.lockIcon { + color: var(--palette-grey-dark); +} +.noPermission { + color: var(--palette-grey-dark); +} +.contactAdmin { + color: var(--palette-grey-main); +} diff --git a/src/core/client/admin/views/restricted/components/Restricted.spec.tsx b/src/core/client/admin/views/restricted/components/Restricted.spec.tsx new file mode 100644 index 000000000..c953766c7 --- /dev/null +++ b/src/core/client/admin/views/restricted/components/Restricted.spec.tsx @@ -0,0 +1,18 @@ +import { noop } from "lodash"; +import React from "react"; +import { createRenderer } from "react-test-renderer/shallow"; + +import { PropTypesOf } from "talk-framework/types"; + +import Restricted from "./Restricted"; + +it("renders correctly", () => { + const props: PropTypesOf = { + username: "User", + onSignInAs: noop, + }; + const renderer = createRenderer(); + + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); +}); diff --git a/src/core/client/admin/views/restricted/components/Restricted.tsx b/src/core/client/admin/views/restricted/components/Restricted.tsx new file mode 100644 index 000000000..4b40a177b --- /dev/null +++ b/src/core/client/admin/views/restricted/components/Restricted.tsx @@ -0,0 +1,80 @@ +import { Localized } from "fluent-react/compat"; +import React, { StatelessComponent } from "react"; + +import AuthBox from "talk-admin/components/AuthBox"; +import { + Button, + Flex, + HorizontalGutter, + Icon, + Typography, +} from "talk-ui/components"; + +import styles from "./Restricted.css"; + +interface Props { + username: string; + onSignInAs: React.MouseEventHandler; +} + +const SignIn: StatelessComponent = ({ username, onSignInAs }) => { + const Username = () => ( + + {username} + + ); + return ( + + Currently signed in to + + } + > + +
    + + + lock + + + + + You do not have permission to access this page. + + +
    +
    + }> + + {"You are signed in as: "} + + +
    + + + + + + + + If you think this is an error, please contact your administrator for + assistance. + + +
    +
    + ); +}; + +export default SignIn; diff --git a/src/core/client/admin/views/restricted/components/__snapshots__/Restricted.spec.tsx.snap b/src/core/client/admin/views/restricted/components/__snapshots__/Restricted.spec.tsx.snap new file mode 100644 index 000000000..65d4c3f04 --- /dev/null +++ b/src/core/client/admin/views/restricted/components/__snapshots__/Restricted.spec.tsx.snap @@ -0,0 +1,83 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` + + + Currently signed in to + + + } +> + +
    + + + lock + + + + + You do not have permission to access this page. + + +
    +
    + } + > + + You are signed in as: <username></username> + + +
    + + + + Sign in with a different account + + + + + + If you think this is an error, please contact your administrator for assistance. + + +
    +
    +`; diff --git a/src/core/client/admin/views/restricted/containers/RestrictedContainer.tsx b/src/core/client/admin/views/restricted/containers/RestrictedContainer.tsx new file mode 100644 index 000000000..370f3ba5c --- /dev/null +++ b/src/core/client/admin/views/restricted/containers/RestrictedContainer.tsx @@ -0,0 +1,56 @@ +import { RouteProps } from "found"; +import React, { Component } from "react"; + +import { RestrictedContainer_me as MeData } from "talk-admin/__generated__/RestrictedContainer_me.graphql"; +import { + SetRedirectPathMutation, + withSetRedirectPathMutation, +} from "talk-admin/mutations"; +import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; +import { SignOutMutation, withSignOutMutation } from "talk-framework/mutations"; + +import { timeout } from "talk-common/utils"; +import Restricted from "../components/Restricted"; + +interface Props { + me: MeData; + error?: Error | null; + signOut: SignOutMutation; + setRedirectPath: SetRedirectPathMutation; +} + +class RestrictedContainer extends Component { + public static routeConfig: RouteProps; + + private handleSignInAs = async () => { + await this.props.signOut(); + // Wait for new context to propagate. + await timeout(); + this.props.setRedirectPath({ + path: location.pathname + location.search + location.hash, + }); + }; + + public render() { + if (!this.props.me) { + return null; + } + + return ( + + ); + } +} + +const enhanced = withFragmentContainer({ + me: graphql` + fragment RestrictedContainer_me on User { + username + } + `, +})(withSetRedirectPathMutation(withSignOutMutation(RestrictedContainer))); + +export default enhanced; diff --git a/src/core/client/auth/components/OrSeparator.css b/src/core/client/auth/components/HorizontalSeparator.css similarity index 100% rename from src/core/client/auth/components/OrSeparator.css rename to src/core/client/auth/components/HorizontalSeparator.css diff --git a/src/core/client/admin/routes/login/components/Login.spec.tsx b/src/core/client/auth/components/HorizontalSeparator.spec.tsx similarity index 64% rename from src/core/client/admin/routes/login/components/Login.spec.tsx rename to src/core/client/auth/components/HorizontalSeparator.spec.tsx index fbc73bf4e..043540aeb 100644 --- a/src/core/client/admin/routes/login/components/Login.spec.tsx +++ b/src/core/client/auth/components/HorizontalSeparator.spec.tsx @@ -1,10 +1,10 @@ import React from "react"; import { createRenderer } from "react-test-renderer/shallow"; -import Login from "./Login"; +import HorizontalSeparator from "./HorizontalSeparator"; it("renders correctly", () => { const renderer = createRenderer(); - renderer.render(); + renderer.render(Or); expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/auth/components/HorizontalSeparator.tsx b/src/core/client/auth/components/HorizontalSeparator.tsx new file mode 100644 index 000000000..405d5a10f --- /dev/null +++ b/src/core/client/auth/components/HorizontalSeparator.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { StatelessComponent } from "react"; + +import { Flex } from "talk-ui/components"; + +import styles from "./HorizontalSeparator.css"; + +interface Props { + children: string; +} + +const HorizontalSeparator: StatelessComponent = props => ( + +
    +
    {props.children}
    +
    +); + +export default HorizontalSeparator; diff --git a/src/core/client/auth/components/OrSeparator.tsx b/src/core/client/auth/components/OrSeparator.tsx index dda3a86e5..87daf5259 100644 --- a/src/core/client/auth/components/OrSeparator.tsx +++ b/src/core/client/auth/components/OrSeparator.tsx @@ -2,18 +2,12 @@ import { Localized } from "fluent-react/compat"; import React from "react"; import { StatelessComponent } from "react"; -import { Flex } from "talk-ui/components"; +import HorizontalSeparator from "./HorizontalSeparator"; -import styles from "./OrSeparator.css"; - -const OrSeparator: StatelessComponent = props => ( - -
    - - -
    Or
    -
    -
    +const OrSeparator: StatelessComponent = () => ( + + Or + ); export default OrSeparator; diff --git a/src/core/client/auth/components/__snapshots__/HorizontalSeparator.spec.tsx.snap b/src/core/client/auth/components/__snapshots__/HorizontalSeparator.spec.tsx.snap new file mode 100644 index 000000000..2e40eeb35 --- /dev/null +++ b/src/core/client/auth/components/__snapshots__/HorizontalSeparator.spec.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` + +
    +
    + Or +
    +
    +`; diff --git a/src/core/client/auth/containers/AccountCompletionContainer.tsx b/src/core/client/auth/containers/AccountCompletionContainer.tsx index 573892d2d..d067a7ec9 100644 --- a/src/core/client/auth/containers/AccountCompletionContainer.tsx +++ b/src/core/client/auth/containers/AccountCompletionContainer.tsx @@ -37,11 +37,15 @@ function handleAccountCompletion(props: Props) { if (view !== "ADD_EMAIL_ADDRESS") { setView({ view: "ADD_EMAIL_ADDRESS" }); } - } else if (!me.username) { + return false; + } + if (!me.username) { if (view !== "CREATE_USERNAME") { setView({ view: "CREATE_USERNAME" }); } - } else if ( + return false; + } + if ( !me.profiles.some(p => p.__typename === "LocalProfile") && auth.integrations.local.enabled && auth.integrations.local.targetFilter.stream @@ -49,10 +53,10 @@ function handleAccountCompletion(props: Props) { if (view !== "CREATE_PASSWORD") { setView({ view: "CREATE_PASSWORD" }); } - } else { - completeAccount({ authToken: authToken! }); - return true; + return false; } + completeAccount({ authToken: authToken! }); + return true; } return false; } diff --git a/src/core/client/auth/helpers/index.ts b/src/core/client/auth/helpers/index.ts deleted file mode 100644 index 27e38ed2e..000000000 --- a/src/core/client/auth/helpers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as redirectOAuth2 } from "./redirectOAuth2"; diff --git a/src/core/client/auth/local/initLocalState.ts b/src/core/client/auth/local/initLocalState.ts index 1d7492bc9..799c18d88 100644 --- a/src/core/client/auth/local/initLocalState.ts +++ b/src/core/client/auth/local/initLocalState.ts @@ -1,27 +1,10 @@ import { commitLocalUpdate, Environment } from "relay-runtime"; import { parseQuery } from "talk-common/utils"; +import { getParamsFromHashAndClearIt } from "talk-framework/helpers"; import { TalkContext } from "talk-framework/lib/bootstrap"; import { initLocalBaseState, LOCAL_ID } from "talk-framework/lib/relay"; -function getParamsFromHashAndClearIt() { - try { - const params = window.location.hash - ? parseQuery(window.location.hash.substr(1)) - : {}; - - // Remove hash with token. - if (window.location.hash) { - window.history.replaceState(null, document.title, location.pathname); - } - - return params; - } catch (err) { - window.console.error(err); - return {}; - } -} - /** * Initializes the local state, before we start the App. */ diff --git a/src/core/client/auth/mutations/SignInMutation.ts b/src/core/client/auth/mutations/SignInMutation.ts index 3ced9d98a..cac6fd55c 100644 --- a/src/core/client/auth/mutations/SignInMutation.ts +++ b/src/core/client/auth/mutations/SignInMutation.ts @@ -16,7 +16,9 @@ export async function commit( // Put the token on the hash and clean the session. // It'll be picked up by initLocalState. location.hash = `accessToken=${result.token}`; - clearSession(); + await clearSession(); + // TODO: (cvle) A better way would be if `context.clearSession` would return the new session and + // we set the accessToken directly in there. } export const withSignInMutation = createMutationContainer("signIn", commit); diff --git a/src/core/client/auth/mutations/SignUpMutation.ts b/src/core/client/auth/mutations/SignUpMutation.ts index 1dd9de80f..8ee054fbe 100644 --- a/src/core/client/auth/mutations/SignUpMutation.ts +++ b/src/core/client/auth/mutations/SignUpMutation.ts @@ -19,7 +19,7 @@ export async function commit( // Put the token on the hash and clean the session. // It'll be picked up by initLocalState. location.hash = `accessToken=${result.token}`; - clearSession(); + await clearSession(); } export const withSignUpMutation = createMutationContainer("signUp", commit); diff --git a/src/core/client/auth/mutations/index.ts b/src/core/client/auth/mutations/index.ts index 8efffb077..86b3e924a 100644 --- a/src/core/client/auth/mutations/index.ts +++ b/src/core/client/auth/mutations/index.ts @@ -5,6 +5,10 @@ export { CompleteAccountMutation, } from "./CompleteAccountMutation"; export { withSignUpMutation, SignUpMutation } from "./SignUpMutation"; +export { + withClearErrorMutation, + ClearErrorMutation, +} from "./ClearErrorMutation"; export { withSetEmailMutation, SetEmailMutation } from "./SetEmailMutation"; export { withSetUsernameMutation, @@ -14,7 +18,3 @@ export { withSetPasswordMutation, SetPasswordMutation, } from "./SetPasswordMutation"; -export { - withClearErrorMutation, - ClearErrorMutation, -} from "./ClearErrorMutation"; diff --git a/src/core/client/auth/test/__snapshots__/signIn.spec.tsx.snap b/src/core/client/auth/test/__snapshots__/signIn.spec.tsx.snap index 700ae1661..1992de07c 100644 --- a/src/core/client/auth/test/__snapshots__/signIn.spec.tsx.snap +++ b/src/core/client/auth/test/__snapshots__/signIn.spec.tsx.snap @@ -368,13 +368,13 @@ exports[`auth configuration renders all auth enabled 1`] = `

    Or
    diff --git a/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap b/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap index 002015cd5..f60b45ad6 100644 --- a/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap +++ b/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap @@ -616,13 +616,13 @@ exports[`auth configuration renders all auth enabled 1`] = `

    Or
    diff --git a/src/core/client/auth/test/create.tsx b/src/core/client/auth/test/create.tsx index 2e8db2fd9..5a1f8bc0c 100644 --- a/src/core/client/auth/test/create.tsx +++ b/src/core/client/auth/test/create.tsx @@ -1,6 +1,5 @@ import { EventEmitter2 } from "eventemitter2"; import { IResolvers } from "graphql-tools"; -import { noop } from "lodash"; import React from "react"; import TestRenderer from "react-test-renderer"; import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime"; @@ -49,7 +48,7 @@ export default function create(params: CreateParams) { browserInfo: { ios: false }, uuidGenerator: createUUIDGenerator(), eventEmitter: new EventEmitter2({ wildcard: true, maxListeners: 20 }), - clearSession: noop, + clearSession: () => Promise.resolve(), }; const testRenderer = TestRenderer.create( diff --git a/src/core/client/auth/views/createPassword/containers/CreatePasswordContainer.tsx b/src/core/client/auth/views/createPassword/containers/CreatePasswordContainer.tsx index 633860f37..d60491cc9 100644 --- a/src/core/client/auth/views/createPassword/containers/CreatePasswordContainer.tsx +++ b/src/core/client/auth/views/createPassword/containers/CreatePasswordContainer.tsx @@ -27,7 +27,6 @@ class CreatePasswordContainer extends Component { }; public render() { - // tslint:disable-next-line:no-empty return ; } } diff --git a/src/core/client/auth/views/signIn/containers/SignInWithFacebookContainer.tsx b/src/core/client/auth/views/signIn/containers/SignInWithFacebookContainer.tsx index a675f07da..80343e6d9 100644 --- a/src/core/client/auth/views/signIn/containers/SignInWithFacebookContainer.tsx +++ b/src/core/client/auth/views/signIn/containers/SignInWithFacebookContainer.tsx @@ -2,8 +2,8 @@ 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 FacebookButton from "talk-framework/components/FacebookButton"; +import { redirectOAuth2 } from "talk-framework/helpers"; import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; interface Props { diff --git a/src/core/client/auth/views/signIn/containers/SignInWithGoogleContainer.tsx b/src/core/client/auth/views/signIn/containers/SignInWithGoogleContainer.tsx index 40f742e4c..c226c4f45 100644 --- a/src/core/client/auth/views/signIn/containers/SignInWithGoogleContainer.tsx +++ b/src/core/client/auth/views/signIn/containers/SignInWithGoogleContainer.tsx @@ -2,8 +2,8 @@ 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 GoogleButton from "talk-framework/components/GoogleButton"; +import { redirectOAuth2 } from "talk-framework/helpers"; import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; interface Props { diff --git a/src/core/client/auth/views/signIn/containers/SignInWithOIDCContainer.tsx b/src/core/client/auth/views/signIn/containers/SignInWithOIDCContainer.tsx index 1eb669948..7db78843c 100644 --- a/src/core/client/auth/views/signIn/containers/SignInWithOIDCContainer.tsx +++ b/src/core/client/auth/views/signIn/containers/SignInWithOIDCContainer.tsx @@ -2,8 +2,8 @@ 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 OIDCButton from "talk-framework/components/OIDCButton"; +import { redirectOAuth2 } from "talk-framework/helpers"; import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; interface Props { diff --git a/src/core/client/auth/views/signUp/containers/SignUpWithFacebookContainer.tsx b/src/core/client/auth/views/signUp/containers/SignUpWithFacebookContainer.tsx index 8cddc89ac..f370e0d18 100644 --- a/src/core/client/auth/views/signUp/containers/SignUpWithFacebookContainer.tsx +++ b/src/core/client/auth/views/signUp/containers/SignUpWithFacebookContainer.tsx @@ -2,8 +2,8 @@ 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 FacebookButton from "talk-framework/components/FacebookButton"; +import { redirectOAuth2 } from "talk-framework/helpers"; import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; interface Props { diff --git a/src/core/client/auth/views/signUp/containers/SignUpWithGoogleContainer.tsx b/src/core/client/auth/views/signUp/containers/SignUpWithGoogleContainer.tsx index 8d5e27307..cca960143 100644 --- a/src/core/client/auth/views/signUp/containers/SignUpWithGoogleContainer.tsx +++ b/src/core/client/auth/views/signUp/containers/SignUpWithGoogleContainer.tsx @@ -2,8 +2,8 @@ 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 GoogleButton from "talk-framework/components/GoogleButton"; +import { redirectOAuth2 } from "talk-framework/helpers"; import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; interface Props { diff --git a/src/core/client/auth/views/signUp/containers/SignUpWithOIDCContainer.tsx b/src/core/client/auth/views/signUp/containers/SignUpWithOIDCContainer.tsx index ce5f6c0af..985558383 100644 --- a/src/core/client/auth/views/signUp/containers/SignUpWithOIDCContainer.tsx +++ b/src/core/client/auth/views/signUp/containers/SignUpWithOIDCContainer.tsx @@ -2,8 +2,8 @@ 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 OIDCButton from "talk-framework/components/OIDCButton"; +import { redirectOAuth2 } from "talk-framework/helpers"; import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; interface Props { diff --git a/src/core/client/auth/components/FacebookButton.css b/src/core/client/framework/components/FacebookButton.css similarity index 100% rename from src/core/client/auth/components/FacebookButton.css rename to src/core/client/framework/components/FacebookButton.css diff --git a/src/core/client/auth/components/FacebookButton.spec.tsx b/src/core/client/framework/components/FacebookButton.spec.tsx similarity index 100% rename from src/core/client/auth/components/FacebookButton.spec.tsx rename to src/core/client/framework/components/FacebookButton.spec.tsx diff --git a/src/core/client/auth/components/FacebookButton.tsx b/src/core/client/framework/components/FacebookButton.tsx similarity index 100% rename from src/core/client/auth/components/FacebookButton.tsx rename to src/core/client/framework/components/FacebookButton.tsx diff --git a/src/core/client/auth/components/GoogleButton.css b/src/core/client/framework/components/GoogleButton.css similarity index 100% rename from src/core/client/auth/components/GoogleButton.css rename to src/core/client/framework/components/GoogleButton.css diff --git a/src/core/client/auth/components/GoogleButton.spec.tsx b/src/core/client/framework/components/GoogleButton.spec.tsx similarity index 100% rename from src/core/client/auth/components/GoogleButton.spec.tsx rename to src/core/client/framework/components/GoogleButton.spec.tsx diff --git a/src/core/client/auth/components/GoogleButton.tsx b/src/core/client/framework/components/GoogleButton.tsx similarity index 100% rename from src/core/client/auth/components/GoogleButton.tsx rename to src/core/client/framework/components/GoogleButton.tsx diff --git a/src/core/client/auth/components/OIDCButton.css b/src/core/client/framework/components/OIDCButton.css similarity index 100% rename from src/core/client/auth/components/OIDCButton.css rename to src/core/client/framework/components/OIDCButton.css diff --git a/src/core/client/auth/components/OIDCButton.spec.tsx b/src/core/client/framework/components/OIDCButton.spec.tsx similarity index 100% rename from src/core/client/auth/components/OIDCButton.spec.tsx rename to src/core/client/framework/components/OIDCButton.spec.tsx diff --git a/src/core/client/auth/components/OIDCButton.tsx b/src/core/client/framework/components/OIDCButton.tsx similarity index 100% rename from src/core/client/auth/components/OIDCButton.tsx rename to src/core/client/framework/components/OIDCButton.tsx diff --git a/src/core/client/auth/components/__snapshots__/FacebookButton.spec.tsx.snap b/src/core/client/framework/components/__snapshots__/FacebookButton.spec.tsx.snap similarity index 100% rename from src/core/client/auth/components/__snapshots__/FacebookButton.spec.tsx.snap rename to src/core/client/framework/components/__snapshots__/FacebookButton.spec.tsx.snap diff --git a/src/core/client/auth/components/__snapshots__/GoogleButton.spec.tsx.snap b/src/core/client/framework/components/__snapshots__/GoogleButton.spec.tsx.snap similarity index 100% rename from src/core/client/auth/components/__snapshots__/GoogleButton.spec.tsx.snap rename to src/core/client/framework/components/__snapshots__/GoogleButton.spec.tsx.snap diff --git a/src/core/client/auth/components/__snapshots__/OIDCButton.spec.tsx.snap b/src/core/client/framework/components/__snapshots__/OIDCButton.spec.tsx.snap similarity index 100% rename from src/core/client/auth/components/__snapshots__/OIDCButton.spec.tsx.snap rename to src/core/client/framework/components/__snapshots__/OIDCButton.spec.tsx.snap diff --git a/src/core/client/framework/components/index.ts b/src/core/client/framework/components/index.ts index 6c9a3e80d..a6342e8bf 100644 --- a/src/core/client/framework/components/index.ts +++ b/src/core/client/framework/components/index.ts @@ -1,2 +1,5 @@ export { default as CopyButton } from "./CopyButton"; export { default as PasswordField } from "./PasswordField"; +export { default as FacebookButton } from "./FacebookButton"; +export { default as GoogleButton } from "./GoogleButton"; +export { default as OIDCButton } from "./OIDCButton"; diff --git a/src/core/client/framework/helpers/getParamsFromHashAndClearIt.ts b/src/core/client/framework/helpers/getParamsFromHashAndClearIt.ts new file mode 100644 index 000000000..e8dd53166 --- /dev/null +++ b/src/core/client/framework/helpers/getParamsFromHashAndClearIt.ts @@ -0,0 +1,19 @@ +import { parseQuery } from "talk-common/utils"; + +export default function getParamsFromHashAndClearIt() { + try { + const params = window.location.hash + ? parseQuery(window.location.hash.substr(1)) + : {}; + + // Remove hash with token. + if (window.location.hash) { + window.history.replaceState(null, document.title, location.pathname); + } + + return params; + } catch (err) { + window.console.error(err); + return {}; + } +} diff --git a/src/core/client/framework/helpers/index.ts b/src/core/client/framework/helpers/index.ts index 68a105148..18d2013b0 100644 --- a/src/core/client/framework/helpers/index.ts +++ b/src/core/client/framework/helpers/index.ts @@ -3,3 +3,7 @@ export { default as getMeSourceID } from "./getMeSourceID"; export { default as getURLWithCommentID } from "./getURLWithCommentID"; export { default as urls } from "./urls"; export { default as createContextHOC } from "./createContextHOC"; +export { default as redirectOAuth2 } from "./redirectOAuth2"; +export { + default as getParamsFromHashAndClearIt, +} from "./getParamsFromHashAndClearIt"; diff --git a/src/core/client/auth/helpers/redirectOAuth2.ts b/src/core/client/framework/helpers/redirectOAuth2.ts similarity index 100% rename from src/core/client/auth/helpers/redirectOAuth2.ts rename to src/core/client/framework/helpers/redirectOAuth2.ts diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index ece409674..0c65e0b35 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -58,7 +58,7 @@ export interface TalkContext { eventEmitter: EventEmitter2; /** Clear session data. */ - clearSession: () => void; + clearSession: () => Promise; } const { Provider, Consumer } = React.createContext({} as any); diff --git a/src/core/client/framework/lib/bootstrap/createManaged.tsx b/src/core/client/framework/lib/bootstrap/createManaged.tsx index dfa716ff4..5245e0e3b 100644 --- a/src/core/client/framework/lib/bootstrap/createManaged.tsx +++ b/src/core/client/framework/lib/bootstrap/createManaged.tsx @@ -251,7 +251,7 @@ export default async function createManaged({ uuidGenerator: uuid, // Noop, this is later replaced by the // managed TalkContextProvider. - clearSession: noop, + clearSession: () => Promise.resolve(), }; // Initialize local state. diff --git a/src/core/client/framework/lib/router/index.ts b/src/core/client/framework/lib/router/index.ts new file mode 100644 index 000000000..0d4f69aee --- /dev/null +++ b/src/core/client/framework/lib/router/index.ts @@ -0,0 +1 @@ +export { default as withRouteConfig } from "./withRouteConfig"; diff --git a/src/core/client/framework/lib/router/withRouteConfig.ts b/src/core/client/framework/lib/router/withRouteConfig.ts new file mode 100644 index 000000000..eeafba57b --- /dev/null +++ b/src/core/client/framework/lib/router/withRouteConfig.ts @@ -0,0 +1,33 @@ +import { RouteProps } from "found"; +import * as React from "react"; + +interface InjectedProps { + error?: Error | null; + data: T | null | undefined; + retry?: Error | null; +} + +type RouteConfig = Pick & + Partial> & { + cacheConfig?: { + force?: boolean; + }; + }; + +function withRouteConfig(config: RouteConfig) { + const hoc = >( + component: React.ComponentType + ) => { + (component as any).routeConfig = { + ...config, + Component: component, + render: ({ error, props, retry, Component }: any) => { + return React.createElement(Component, { error, data: props, retry }); + }, + }; + return component as React.ComponentClass & { routeConfig: RouteConfig }; + }; + return hoc; +} + +export default withRouteConfig; diff --git a/src/core/client/framework/mutations/SetAuthTokenMutation.ts b/src/core/client/framework/mutations/SetAuthTokenMutation.ts index da0047add..5efc50e39 100644 --- a/src/core/client/framework/mutations/SetAuthTokenMutation.ts +++ b/src/core/client/framework/mutations/SetAuthTokenMutation.ts @@ -26,7 +26,7 @@ export async function commit( await localStorage.removeItem("authToken"); } // Clear current session, as we are starting a new one. - clearSession(); + await clearSession(); }); } diff --git a/src/core/client/stream/test/create.tsx b/src/core/client/stream/test/create.tsx index 432d9800a..55dfeb884 100644 --- a/src/core/client/stream/test/create.tsx +++ b/src/core/client/stream/test/create.tsx @@ -1,6 +1,5 @@ import { EventEmitter2 } from "eventemitter2"; import { IResolvers } from "graphql-tools"; -import { noop } from "lodash"; import React from "react"; import TestRenderer from "react-test-renderer"; import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime"; @@ -49,7 +48,7 @@ export default function create(params: CreateParams) { browserInfo: { ios: false }, uuidGenerator: createUUIDGenerator(), eventEmitter: new EventEmitter2({ wildcard: true, maxListeners: 20 }), - clearSession: noop, + clearSession: () => Promise.resolve(), }; const testRenderer = TestRenderer.create( diff --git a/src/core/client/ui/components/Marker/Count.css b/src/core/client/ui/components/Marker/Count.css index 415e63860..0aeb517e5 100644 --- a/src/core/client/ui/components/Marker/Count.css +++ b/src/core/client/ui/components/Marker/Count.css @@ -3,8 +3,8 @@ border-left: 1px solid currentColor; margin-left: calc(0.5 * var(--spacing-unit)); margin-right: calc(-0.5 * var(--spacing-unit)); - padding-top: 3px; - padding-bottom: 3px; + padding-top: 2px; + padding-bottom: 2px; padding-left: calc(0.5 * var(--spacing-unit)); white-space: nowrap; } diff --git a/src/core/client/ui/components/Typography/Typography.css b/src/core/client/ui/components/Typography/Typography.css index 7408e015b..aeb7c2cb3 100644 --- a/src/core/client/ui/components/Typography/Typography.css +++ b/src/core/client/ui/components/Typography/Typography.css @@ -98,6 +98,10 @@ color: var(--palette-text-secondary); } +.colorTextLight { + color: var(--palette-text-light); +} + .colorError { color: var(--palette-error-main); } diff --git a/src/core/client/ui/components/Typography/Typography.tsx b/src/core/client/ui/components/Typography/Typography.tsx index 9b7a5b36c..7c4bc4226 100644 --- a/src/core/client/ui/components/Typography/Typography.tsx +++ b/src/core/client/ui/components/Typography/Typography.tsx @@ -47,6 +47,7 @@ interface InnerProps extends HTMLAttributes { | "primary" | "textPrimary" | "textSecondary" + | "textLight" | "error" | "success"; /** @@ -104,6 +105,7 @@ const Typography: StatelessComponent = props => { { [classes.colorTextPrimary]: color === "textPrimary", [classes.colorTextSecondary]: color === "textSecondary", + [classes.colorTextLight]: color === "textLight", [classes.colorPrimary]: color === "primary", [classes.colorError]: color === "error", [classes.colorSuccess]: color === "success", diff --git a/src/core/server/graph/common/directives/auth.ts b/src/core/server/graph/common/directives/auth.ts index d8690221e..3f39ec9a1 100644 --- a/src/core/server/graph/common/directives/auth.ts +++ b/src/core/server/graph/common/directives/auth.ts @@ -49,7 +49,7 @@ const auth: DirectiveResolverFn< const conditions = calculateAuthConditionsMemoized(user); if (!permit && conditions.length > 0) { // TODO: return better error. - throw new Error("not authorized"); + throw new Error("not authorized 1"); } // If the permit was specified, and some of the conditions for the user @@ -59,7 +59,7 @@ const auth: DirectiveResolverFn< conditions.some(condition => permit.indexOf(condition) === -1) ) { // TODO: return better error. - throw new Error("not authorized"); + throw new Error("not authorized 2"); } // If the role and user owner checks are disabled, then allow them based on diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 3dd57066a..ee3906ce9 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -11,21 +11,33 @@ navigation-stories = Stories navigation-configure = Configure navigation-signOutButton = Sign Out +## Restricted +restricted-currentlySignedInTo = Currently signed in to +restricted-noPermissionInfo = You do not have permission to access this page. +restricted-signedInAs = You are signed in as: +restricted-signInWithADifferentAccount = Sign in with a different account +restricted-contactAdmin = If you think this is an error, please contact your administrator for assistance. + ## Login -login-login-signInTo = Sign in to -login-login-enterAccountDetailsBelow = Enter your account details below +# Sign In +login-signInTo = Sign in to +login-signIn-enterAccountDetailsBelow = Enter your account details below -login-signIn-emailAddressLabel = Email Address -login-signIn-emailAddressTextField = +login-emailAddressLabel = Email Address +login-emailAddressTextField = .placeholder = Email Address login-signIn-passwordLabel = Password login-signIn-passwordTextField = .placeholder = Password -login-signIn-signIn = Sign in +login-signIn-signInWithEmail = Sign in with Email +login-signIn-orSeparator = Or +login-signInWithFacebook = Sign in with Facebook +login-signInWithGoogle = Sign in with Google +login-signInWithOIDC = Sign in with { $name } ## Configure @@ -144,3 +156,45 @@ moderate-decision = Decision moderate-single-goToModerationQueues = Go to moderation queues moderate-single-singleCommentView = Single Comment View + + +## Create Username + +createUsername-createUsernameHeader = Create Username +createUsername-whatItIs = + Your username is a unique identifier that will appear on all of your comments. +createUsername-createUsernameButton = Create Username +createUsername-usernameLabel = Username +createUsername-usernameDescription = You may use “_” and “.” Spaces not permitted. +createUsername-usernameTextField = + .placeholder = Username + +## Add Email Address +addEmailAddress-addEmailAddressHeader = Add Email Address + +addEmailAddress-emailAddressLabel = Email Address +addEmailAddress-emailAddressTextField = + .placeholder = Email Address + +addEmailAddress-confirmEmailAddressLabel = Confirm Email Address +addEmailAddress-confirmEmailAddressTextField = + .placeholder = Confirm Email Address + +addEmailAddress-whatItIs = + For your added security, we require users to add an email address to their accounts. + +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 + +createPassword-passwordLabel = Password +createPassword-passwordDescription = Must be at least {$minLength} characters +createPassword-passwordTextField = + .placeholder = Password diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 276ed6635..2ce799658 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -118,3 +118,4 @@ comments-reportPopover-receivedMessage = We’ve received your message. Reports from members like you keep the community safe. comments-reportPopover-dismiss = Dismiss +