diff --git a/src/core/client/account/components/Loading.tsx b/src/core/client/account/components/Loading.tsx new file mode 100644 index 000000000..b59e92dac --- /dev/null +++ b/src/core/client/account/components/Loading.tsx @@ -0,0 +1,13 @@ +import React, { FunctionComponent } from "react"; + +import { Delay, Flex, Spinner } from "coral-ui/components"; + +const Loading: FunctionComponent = () => ( + + + + + +); + +export default Loading; diff --git a/src/core/client/account/routes/email/Confirm/CheckConfirmTokenFetch.tsx b/src/core/client/account/routes/email/Confirm/CheckConfirmTokenFetch.tsx deleted file mode 100644 index 94596ccc2..000000000 --- a/src/core/client/account/routes/email/Confirm/CheckConfirmTokenFetch.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Environment } from "relay-runtime"; - -import { createFetch } from "coral-framework/lib/relay"; - -const CheckConfirmTokenFetch = createFetch( - "confirm", - async (environment: Environment, variables: { token: string }, { rest }) => - await rest.fetch("/account/confirm", { - method: "GET", - token: variables.token, - }) -); - -export default CheckConfirmTokenFetch; diff --git a/src/core/client/account/routes/email/Confirm/ConfirmRoute.tsx b/src/core/client/account/routes/email/Confirm/ConfirmRoute.tsx index d7796118c..aef5dfb98 100644 --- a/src/core/client/account/routes/email/Confirm/ConfirmRoute.tsx +++ b/src/core/client/account/routes/email/Confirm/ConfirmRoute.tsx @@ -1,26 +1,48 @@ import React, { useCallback, useState } from "react"; +import { Environment } from "relay-runtime"; +import Loading from "coral-account/components/Loading"; +import { useToken } from "coral-framework/hooks"; +import { createFetch } from "coral-framework/lib/relay"; import { withRouteConfig } from "coral-framework/lib/router"; import { parseHashQuery } from "coral-framework/utils"; import ConfirmForm from "./ConfirmForm"; -import ConfirmTokenChecker from "./ConfirmTokenChecker"; +import Sorry from "./Sorry"; import Success from "./Success"; +const fetcher = createFetch( + "confirmToken", + async (environment: Environment, variables: { token: string }, { rest }) => + await rest.fetch("/account/confirm", { + method: "GET", + token: variables.token, + }) +); + interface Props { token: string | undefined; } const ConfirmRoute: React.FunctionComponent = ({ token }) => { - const [suceeded, setSuceeded] = useState(false); + const [finished, setFinished] = useState(false); const onSuccess = useCallback(() => { - setSuceeded(true); + setFinished(true); }, []); - return ( - - {!suceeded && } - {suceeded && } - + const [state, error] = useToken(fetcher, token); + + if (state === "UNCHECKED") { + return ; + } + + if (state !== "VALID" || error) { + return ; + } + + return !finished ? ( + + ) : ( + ); }; diff --git a/src/core/client/account/routes/email/Confirm/ConfirmTokenChecker.tsx b/src/core/client/account/routes/email/Confirm/ConfirmTokenChecker.tsx deleted file mode 100644 index 6bb1b581f..000000000 --- a/src/core/client/account/routes/email/Confirm/ConfirmTokenChecker.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { ERROR_CODES } from "coral-common/errors"; -import { InvalidRequestError } from "coral-framework/lib/errors"; -import { useFetch } from "coral-framework/lib/relay"; -import { Delay, Flex, Spinner } from "coral-ui/components"; -import { Localized } from "fluent-react/compat"; -import React, { useEffect, useState } from "react"; - -import CheckConfirmTokenFetch from "./CheckConfirmTokenFetch"; -import Sorry from "./Sorry"; - -interface Props { - token: string | undefined; -} - -type TokenState = - | "VALID" - | "INVALID" - | "EXPIRED" - | "MISSING" - | "RATE_LIMIT_EXCEEDED" - | "UNKNOWN" - | "UNCHECKED"; - -const ConfirmTokenChecker: React.FunctionComponent = ({ - token, - children, -}) => { - const checkConfirmToken = useFetch(CheckConfirmTokenFetch); - const [tokenState, setTokenState] = useState("UNCHECKED"); - const [reason, setReason] = useState(""); - useEffect(() => { - if (token) { - async function setAndCheckToken() { - try { - await checkConfirmToken({ token: token! }); - setTokenState("VALID"); - } catch (e) { - setReason(e.message); - if (e instanceof InvalidRequestError) { - switch (e.code) { - case ERROR_CODES.RATE_LIMIT_EXCEEDED: - setTokenState("RATE_LIMIT_EXCEEDED"); - return; - case ERROR_CODES.EMAIL_CONFIRM_TOKEN_EXPIRED: - setTokenState("EXPIRED"); - return; - case ERROR_CODES.INTEGRATION_DISABLED: - case ERROR_CODES.USER_NOT_FOUND: - case ERROR_CODES.TOKEN_INVALID: - setTokenState("INVALID"); - return; - default: - setTokenState("UNKNOWN"); - return; - } - } - setTokenState("UNKNOWN"); - } - } - setAndCheckToken(); - } else { - setTokenState("MISSING"); - } - return; - }, [token]); - - switch (tokenState) { - case "VALID": - return <>{children}; - case "UNCHECKED": - return ( - - - - - - ); - case "MISSING": - return ( - - The Confirm Token seems to be missing. - - } - /> - ); - default: - return ; - } -}; - -export default ConfirmTokenChecker; diff --git a/src/core/client/account/routes/email/Confirm/Sorry.tsx b/src/core/client/account/routes/email/Confirm/Sorry.tsx index fc276f79e..7a9776fcb 100644 --- a/src/core/client/account/routes/email/Confirm/Sorry.tsx +++ b/src/core/client/account/routes/email/Confirm/Sorry.tsx @@ -14,7 +14,16 @@ const Sorry: React.FunctionComponent = ({ reason }) => { Oops Sorry! - {reason} + {reason ? ( + reason + ) : ( + + + The specified link is invalid, check to see if it was copied + correctly. + + + )} ); diff --git a/src/core/client/account/routes/password/Reset/CheckResetTokenFetch.tsx b/src/core/client/account/routes/password/Reset/CheckResetTokenFetch.tsx deleted file mode 100644 index 934f13f4d..000000000 --- a/src/core/client/account/routes/password/Reset/CheckResetTokenFetch.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Environment } from "relay-runtime"; - -import { createFetch } from "coral-framework/lib/relay"; - -const CheckResetTokenFetch = createFetch( - "resetToken", - async (environment: Environment, variables: { token: string }, { rest }) => - await rest.fetch("/auth/local/forgot", { - method: "GET", - token: variables.token, - }) -); - -export default CheckResetTokenFetch; diff --git a/src/core/client/account/routes/password/Reset/ResetRoute.tsx b/src/core/client/account/routes/password/Reset/ResetRoute.tsx index 21100b85a..b6986954a 100644 --- a/src/core/client/account/routes/password/Reset/ResetRoute.tsx +++ b/src/core/client/account/routes/password/Reset/ResetRoute.tsx @@ -1,26 +1,48 @@ import React, { useCallback, useState } from "react"; +import { Environment } from "relay-runtime"; +import Loading from "coral-account/components/Loading"; +import { useToken } from "coral-framework/hooks"; +import { createFetch } from "coral-framework/lib/relay"; import { withRouteConfig } from "coral-framework/lib/router"; - import { parseHashQuery } from "coral-framework/utils"; + import ResetPasswordForm from "./ResetPasswordForm"; -import ResetTokenChecker from "./ResetTokenChecker"; +import Sorry from "./Sorry"; import Success from "./Success"; +const fetcher = createFetch( + "resetToken", + async (environment: Environment, variables: { token: string }, { rest }) => + await rest.fetch("/auth/local/forgot", { + method: "GET", + token: variables.token, + }) +); + interface Props { token: string | undefined; } const ResetRoute: React.FunctionComponent = ({ token }) => { - const [suceeded, setSuceeded] = useState(false); + const [finished, setFinished] = useState(false); const onSuccess = useCallback(() => { - setSuceeded(true); + setFinished(true); }, []); - return ( - - {!suceeded && } - {suceeded && } - + const [state, error] = useToken(fetcher, token); + + if (state === "UNCHECKED") { + return ; + } + + if (state !== "VALID" || error) { + return ; + } + + return !finished ? ( + + ) : ( + ); }; diff --git a/src/core/client/account/routes/password/Reset/ResetTokenChecker.tsx b/src/core/client/account/routes/password/Reset/ResetTokenChecker.tsx deleted file mode 100644 index 2628e64d9..000000000 --- a/src/core/client/account/routes/password/Reset/ResetTokenChecker.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { ERROR_CODES } from "coral-common/errors"; -import { InvalidRequestError } from "coral-framework/lib/errors"; -import { useFetch } from "coral-framework/lib/relay"; -import { Delay, Flex, Spinner } from "coral-ui/components"; -import { Localized } from "fluent-react/compat"; -import React, { useEffect, useState } from "react"; - -import CheckResetTokenFetch from "./CheckResetTokenFetch"; -import Sorry from "./Sorry"; - -interface Props { - token: string | undefined; -} - -type TokenState = - | "VALID" - | "INVALID" - | "EXPIRED" - | "MISSING" - | "RATE_LIMIT_EXCEEDED" - | "UNKNOWN" - | "UNCHECKED"; - -const ResetTokenChecker: React.FunctionComponent = ({ - token, - children, -}) => { - const checkResetToken = useFetch(CheckResetTokenFetch); - const [tokenState, setTokenState] = useState("UNCHECKED"); - const [reason, setReason] = useState(""); - useEffect(() => { - if (token) { - async function setAndCheckToken() { - try { - await checkResetToken({ token: token! }); - setTokenState("VALID"); - } catch (e) { - setReason(e.message); - if (e instanceof InvalidRequestError) { - switch (e.code) { - case ERROR_CODES.RATE_LIMIT_EXCEEDED: - setTokenState("RATE_LIMIT_EXCEEDED"); - return; - case ERROR_CODES.PASSWORD_RESET_TOKEN_EXPIRED: - setTokenState("EXPIRED"); - return; - case ERROR_CODES.INTEGRATION_DISABLED: - case ERROR_CODES.USER_NOT_FOUND: - case ERROR_CODES.TOKEN_INVALID: - setTokenState("INVALID"); - return; - default: - setTokenState("UNKNOWN"); - return; - } - } - setTokenState("UNKNOWN"); - } - } - setAndCheckToken(); - } else { - setTokenState("MISSING"); - } - return; - }, [token]); - - switch (tokenState) { - case "VALID": - return <>{children}; - case "UNCHECKED": - return ( - - - - - - ); - case "MISSING": - return ( - - The Reset Token seems to be missing. - - } - /> - ); - default: - return ; - } -}; - -export default ResetTokenChecker; diff --git a/src/core/client/account/routes/password/Reset/Sorry.tsx b/src/core/client/account/routes/password/Reset/Sorry.tsx index 0fe9b1e4e..e17640f63 100644 --- a/src/core/client/account/routes/password/Reset/Sorry.tsx +++ b/src/core/client/account/routes/password/Reset/Sorry.tsx @@ -14,7 +14,16 @@ const Sorry: React.FunctionComponent = ({ reason }) => { Oops Sorry! - {reason} + {reason ? ( + reason + ) : ( + + + The specified link is invalid, check to see if it was copied + correctly. + + + )} ); diff --git a/src/core/client/account/test/__snapshots__/confirmEmail.spec.tsx.snap b/src/core/client/account/test/__snapshots__/confirmEmail.spec.tsx.snap index 2250b6180..ebc886112 100644 --- a/src/core/client/account/test/__snapshots__/confirmEmail.spec.tsx.snap +++ b/src/core/client/account/test/__snapshots__/confirmEmail.spec.tsx.snap @@ -77,8 +77,10 @@ exports[`renders missing confirm token 1`] = ` className="CallOut-root CallOut-colorError CallOut-fullWidth" >
- - The Confirm Token seems to be missing. + + The specified link is invalid, check to see if it was copied correctly.
diff --git a/src/core/client/account/test/__snapshots__/resetPassword.spec.tsx.snap b/src/core/client/account/test/__snapshots__/resetPassword.spec.tsx.snap index 834696f42..8d229091f 100644 --- a/src/core/client/account/test/__snapshots__/resetPassword.spec.tsx.snap +++ b/src/core/client/account/test/__snapshots__/resetPassword.spec.tsx.snap @@ -124,8 +124,10 @@ exports[`renders missing reset token 1`] = ` className="CallOut-root CallOut-colorError CallOut-fullWidth" >
- - The Reset Token seems to be missing. + + The specified link is invalid, check to see if it was copied correctly.
diff --git a/src/core/client/account/test/confirmEmail.spec.tsx b/src/core/client/account/test/confirmEmail.spec.tsx index 5e0fc1354..3396c300d 100644 --- a/src/core/client/account/test/confirmEmail.spec.tsx +++ b/src/core/client/account/test/confirmEmail.spec.tsx @@ -30,11 +30,7 @@ async function createTestRenderer( it("renders missing confirm token", async () => { replaceHistoryLocation("http://localhost/account/email/confirm"); const { root } = await createTestRenderer(); - await waitForElement(() => - within(root).getByText("The Confirm Token seems to be missing", { - exact: false, - }) - ); + await waitForElement(() => within(root).getByTestID("invalid-link")); expect(within(root).toJSON()).toMatchSnapshot(); }); diff --git a/src/core/client/account/test/resetPassword.spec.tsx b/src/core/client/account/test/resetPassword.spec.tsx index 77df5fccd..c7839eb5f 100644 --- a/src/core/client/account/test/resetPassword.spec.tsx +++ b/src/core/client/account/test/resetPassword.spec.tsx @@ -30,11 +30,7 @@ async function createTestRenderer( it("renders missing reset token", async () => { replaceHistoryLocation("http://localhost/account/password/reset"); const { root } = await createTestRenderer(); - await waitForElement(() => - within(root).getByText("The Reset Token seems to be missing", { - exact: false, - }) - ); + await waitForElement(() => within(root).getByTestID("invalid-link")); expect(within(root).toJSON()).toMatchSnapshot(); }); diff --git a/src/core/client/admin/local/initLocalState.ts b/src/core/client/admin/local/initLocalState.ts index 25fa56f02..82a7dd234 100644 --- a/src/core/client/admin/local/initLocalState.ts +++ b/src/core/client/admin/local/initLocalState.ts @@ -1,7 +1,7 @@ import { commitLocalUpdate, Environment } from "relay-runtime"; import { REDIRECT_PATH_KEY } from "coral-admin/constants"; -import { getParamsFromHashAndClearIt } from "coral-framework/helpers"; +import { clearHash, getParamsFromHash } from "coral-framework/helpers"; import { CoralContext } from "coral-framework/lib/bootstrap"; import { initLocalBaseState, LOCAL_ID } from "coral-framework/lib/relay"; @@ -12,9 +12,12 @@ export default async function initLocalState( environment: Environment, context: CoralContext ) { - const { error = null, accessToken } = getParamsFromHashAndClearIt(); + const { error = null, accessToken } = getParamsFromHash(); let redirectPath: string | null = null; if (error || accessToken) { + // As there's an access token in the hash, let's clear it. + clearHash(); + // Keep redirect path as we are in the middle of an auth flow. redirectPath = (await context.localStorage.getItem(REDIRECT_PATH_KEY)) || null; diff --git a/src/core/client/admin/permissions.tsx b/src/core/client/admin/permissions.tsx index 9ba6b64e9..b4afcbc5b 100644 --- a/src/core/client/admin/permissions.tsx +++ b/src/core/client/admin/permissions.tsx @@ -20,6 +20,8 @@ const permissionMap = { // Mutation.openStory // Mutation.closeStory CHANGE_STORY_STATUS: [GQLUSER_ROLE.ADMIN], + // Mutation.inviteUsers + INVITE_USERS: [GQLUSER_ROLE.ADMIN], }; export type AbilityType = keyof typeof permissionMap; diff --git a/src/core/client/admin/routeConfig.tsx b/src/core/client/admin/routeConfig.tsx index daf71ec63..53d65b4e5 100644 --- a/src/core/client/admin/routeConfig.tsx +++ b/src/core/client/admin/routeConfig.tsx @@ -15,6 +15,7 @@ import { OrganizationConfigRoute, WordListConfigRoute, } from "./routes/Configure/sections"; +import InviteRoute from "./routes/Invite"; import LoginRoute from "./routes/Login"; import ModerateRoute from "./routes/Moderate"; import { @@ -74,6 +75,7 @@ export default makeRouteConfig( + ); diff --git a/src/core/client/admin/routes/Community/InviteUsers/EmailField.tsx b/src/core/client/admin/routes/Community/InviteUsers/EmailField.tsx new file mode 100644 index 000000000..97a20665c --- /dev/null +++ b/src/core/client/admin/routes/Community/InviteUsers/EmailField.tsx @@ -0,0 +1,57 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; +import { Field } from "react-final-form"; + +import { validateEmail } from "coral-framework/lib/validation"; +import { + FieldSet, + FormField, + InputLabel, + TextField, + ValidationMessage, +} from "coral-ui/components"; + +interface Props { + index: number; + disabled: boolean; +} + +const EmailField: FunctionComponent = ({ index, disabled }) => ( +
+ + {({ 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/Community/InviteUsers/InviteUsers.tsx b/src/core/client/admin/routes/Community/InviteUsers/InviteUsers.tsx new file mode 100644 index 000000000..8447e289c --- /dev/null +++ b/src/core/client/admin/routes/Community/InviteUsers/InviteUsers.tsx @@ -0,0 +1,40 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent, useCallback, useState } from "react"; + +import { Button, Modal } from "coral-ui/components"; + +import InviteUsersModal from "./InviteUsersModal"; + +const InviteUsers: FunctionComponent = () => { + const [open, setOpen] = useState(false); + + const show = useCallback(() => setOpen(true), []); + const hide = useCallback(() => setOpen(false), []); + + return ( +
+ + + + + {({ firstFocusableRef, lastFocusableRef }) => ( + + )} + +
+ ); +}; + +export default InviteUsers; diff --git a/src/core/client/admin/routes/Community/InviteUsers/InviteUsersContainer.tsx b/src/core/client/admin/routes/Community/InviteUsers/InviteUsersContainer.tsx new file mode 100644 index 000000000..88578dddd --- /dev/null +++ b/src/core/client/admin/routes/Community/InviteUsers/InviteUsersContainer.tsx @@ -0,0 +1,62 @@ +import React, { FunctionComponent } from "react"; + +import { InviteUsersContainer_settings } from "coral-admin/__generated__/InviteUsersContainer_settings.graphql"; +import { InviteUsersContainer_viewer } from "coral-admin/__generated__/InviteUsersContainer_viewer.graphql"; +import { Ability, can } from "coral-admin/permissions"; +import { graphql, withFragmentContainer } from "coral-framework/lib/relay"; + +import InviteUsers from "./InviteUsers"; + +interface Props { + viewer: InviteUsersContainer_viewer | null; + settings: InviteUsersContainer_settings | null; +} + +const InviteUsersContainer: FunctionComponent = ({ + viewer, + settings, +}) => { + if (!viewer || !can(viewer, Ability.INVITE_USERS)) { + return null; + } + + if ( + !settings || + !settings.auth.integrations.local.enabled || + !settings.auth.integrations.local.allowRegistration || + !settings.auth.integrations.local.targetFilter.admin || + !settings.email.enabled + ) { + return null; + } + + return ; +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment InviteUsersContainer_viewer on User { + role + } + `, + settings: graphql` + fragment InviteUsersContainer_settings on Settings { + email { + enabled + } + auth { + integrations { + local { + enabled + allowRegistration + targetFilter { + admin + } + } + } + } + } + `, +})(InviteUsersContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Community/InviteUsers/InviteUsersForm.tsx b/src/core/client/admin/routes/Community/InviteUsers/InviteUsersForm.tsx new file mode 100644 index 000000000..caa79f3d3 --- /dev/null +++ b/src/core/client/admin/routes/Community/InviteUsers/InviteUsersForm.tsx @@ -0,0 +1,149 @@ +import { FORM_ERROR } from "final-form"; +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent, useCallback, useState } from "react"; +import { Form, FormSpy } from "react-final-form"; + +import { useMutation } from "coral-framework/lib/relay"; +import { GQLUSER_ROLE } from "coral-framework/schema"; +import { + Button, + CallOut, + Flex, + HorizontalGutter, + Typography, +} from "coral-ui/components"; + +import EmailField from "./EmailField"; +import InviteUsersMutation from "./InviteUsersMutation"; +import RoleField from "./RoleField"; + +interface Props { + onFinish: () => void; + lastRef?: React.Ref; +} + +const InviteForm: FunctionComponent = ({ lastRef, onFinish }) => { + const [emailFieldCount, setEmailFieldCount] = useState(3); + const inviteUsers = useMutation(InviteUsersMutation); + const onSubmit = useCallback( + async ({ role, emails = [] }) => { + try { + await inviteUsers({ + role, + emails: emails.filter((email: string | null) => Boolean(email)), + }); + onFinish(); + } catch (error) { + return { [FORM_ERROR]: error.message }; + } + return; + }, + [inviteUsers, onFinish] + ); + + return ( +
+ {({ handleSubmit, submitting, submitError }) => ( + + + {submitError && ( + + {submitError} + + )} + {Array(emailFieldCount) + .fill(0) + .map((_, idx) => ( + + ))} + + + + + + + + {({ values: { role } }) => { + switch (role) { + case GQLUSER_ROLE.STAFF: + return ( + } + > + + Staff role: Receives a “Staff” badge, and comments are + automatically approved. Cannot moderate or change any + Coral configuration. + + + ); + case GQLUSER_ROLE.MODERATOR: + return ( + } + > + + Moderator role: Moderator role: Receives a “Staff” + badge, and comments are automatically approved. Has + full moderation privileges (approve, reject and + feature comments). Can configure individual articles + but no site-wide configuration privileges. + + + ); + case GQLUSER_ROLE.ADMIN: + return ( + } + > + + Admin role: Receives a “Staff” badge, and comments are + automatically approved. Has full moderation privileges + (approve, reject and feature comments). Can configure + individual articles and has site-wide configuration + privileges. + + + ); + default: + return null; + } + }} + + + + + + + +
+ )} + + ); +}; + +export default InviteForm; diff --git a/src/core/client/admin/routes/Community/InviteUsers/InviteUsersModal.css b/src/core/client/admin/routes/Community/InviteUsers/InviteUsersModal.css new file mode 100644 index 000000000..4275b7da7 --- /dev/null +++ b/src/core/client/admin/routes/Community/InviteUsers/InviteUsersModal.css @@ -0,0 +1,9 @@ +.root { + width: 400px; +} + +.clearfix:after { + content: ""; + display: table; + clear: both; +} diff --git a/src/core/client/admin/routes/Community/InviteUsers/InviteUsersModal.tsx b/src/core/client/admin/routes/Community/InviteUsers/InviteUsersModal.tsx new file mode 100644 index 000000000..01d0bf8f2 --- /dev/null +++ b/src/core/client/admin/routes/Community/InviteUsers/InviteUsersModal.tsx @@ -0,0 +1,46 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent, useCallback, useState } from "react"; + +import { Box, Card, CardCloseButton, Typography } from "coral-ui/components"; + +import InviteForm from "./InviteUsersForm"; +import Success from "./Success"; + +import * as styles from "./InviteUsersModal.css"; + +interface Props { + onHide: () => void; + firstFocusableRef: React.Ref; + lastFocusableRef: React.Ref; +} + +const InviteUsersModal: FunctionComponent = ({ + onHide, + firstFocusableRef, + lastFocusableRef, +}) => { + const [finished, setFinished] = useState(false); + const finish = useCallback(() => setFinished(true), []); + + return ( + + {!finished ? ( +
+ + + + + Invite members to your organization + + + + +
+ ) : ( + + )} +
+ ); +}; + +export default InviteUsersModal; diff --git a/src/core/client/admin/routes/Community/InviteUsers/InviteUsersMutation.ts b/src/core/client/admin/routes/Community/InviteUsers/InviteUsersMutation.ts new file mode 100644 index 000000000..62db09523 --- /dev/null +++ b/src/core/client/admin/routes/Community/InviteUsers/InviteUsersMutation.ts @@ -0,0 +1,37 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { InviteUsersMutation } from "coral-admin/__generated__/InviteUsersMutation.graphql"; +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +let clientMutationId = 0; + +const InviteUsersMutation = createMutation( + "inviteUsers", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation InviteUsersMutation($input: InviteUsersInput!) { + inviteUsers(input: $input) { + clientMutationId + invites { + id + email + } + } + } + `, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default InviteUsersMutation; diff --git a/src/core/client/admin/routes/Community/InviteUsers/RoleField.tsx b/src/core/client/admin/routes/Community/InviteUsers/RoleField.tsx new file mode 100644 index 000000000..99d1b4db9 --- /dev/null +++ b/src/core/client/admin/routes/Community/InviteUsers/RoleField.tsx @@ -0,0 +1,78 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; +import { Field } from "react-final-form"; + +import { GQLUSER_ROLE } from "coral-framework/schema"; +import { FieldSet, RadioButton, Typography } from "coral-ui/components"; + +interface Props { + disabled: boolean; +} + +const RoleField: FunctionComponent = ({ disabled }) => ( +
+ + + Invite as: + + +
+ + {({ input }) => ( + + + Staff + + + )} + + + {({ input }) => ( + + + Moderator + + + )} + + + {({ input }) => ( + + + Admin + + + )} + +
+
+); + +export default RoleField; diff --git a/src/core/client/admin/routes/Community/InviteUsers/Success.css b/src/core/client/admin/routes/Community/InviteUsers/Success.css new file mode 100644 index 000000000..8eaa4e7ed --- /dev/null +++ b/src/core/client/admin/routes/Community/InviteUsers/Success.css @@ -0,0 +1,3 @@ +.box { + width: 100%; +} diff --git a/src/core/client/admin/routes/Community/InviteUsers/Success.tsx b/src/core/client/admin/routes/Community/InviteUsers/Success.tsx new file mode 100644 index 000000000..5e7b0e6b8 --- /dev/null +++ b/src/core/client/admin/routes/Community/InviteUsers/Success.tsx @@ -0,0 +1,47 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; + +import { Box, Button, CheckIcon, Flex, Typography } from "coral-ui/components"; + +import * as styles from "./Success.css"; + +interface Props { + onClose: () => void; + lastFocusableRef: React.Ref; +} + +const Success: FunctionComponent = ({ lastFocusableRef, onClose }) => ( +
+ + + + + + + + + Your invitations have been sent! + + + + + + + + +
+); + +export default Success; diff --git a/src/core/client/admin/routes/Community/InviteUsers/index.ts b/src/core/client/admin/routes/Community/InviteUsers/index.ts new file mode 100644 index 000000000..29dfdbaa9 --- /dev/null +++ b/src/core/client/admin/routes/Community/InviteUsers/index.ts @@ -0,0 +1,4 @@ +export { + default, + default as InviteUsersContainer, +} from "./InviteUsersContainer"; diff --git a/src/core/client/admin/routes/Community/UserTableContainer.tsx b/src/core/client/admin/routes/Community/UserTableContainer.tsx index 1259ee9c5..5bdbfd51e 100644 --- a/src/core/client/admin/routes/Community/UserTableContainer.tsx +++ b/src/core/client/admin/routes/Community/UserTableContainer.tsx @@ -47,6 +47,8 @@ const UserTableContainer: FunctionComponent = props => { statusFilter={statusFilter} onSetSearchFilter={setSearchFilter} searchFilter={searchFilter} + viewer={props.query && props.query.viewer} + settings={props.query && props.query.settings} /> void; searchFilter: string; onSetSearchFilter: (search: string) => void; + viewer: PropTypesOf["viewer"]; + settings: PropTypesOf["settings"]; } const UserTableFilter: FunctionComponent = props => ( - -
- - + +
+ + + Search + + +
+ props.onSetSearchFilter(search) + } > - Search - - - - props.onSetSearchFilter(search) - } - > - {({ handleSubmit }) => ( - - - {({ input }) => ( - - - - - } - /> - - )} - -
- )} - -
-
- - - Show Me - - - - - - props.onSetRoleFilter((e.target.value as any) || null) - } + + + } + /> + + )} + + + )} + +
+
+ + - - - - - - - - - - - - - - - - - - - - - - - - + Show Me + - - - props.onSetStatusFilter((e.target.value as any) || null) - } + + - - - - - - - - - - - - - - - -
+ + props.onSetRoleFilter((e.target.value as any) || null) + } + > + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + props.onSetStatusFilter((e.target.value as any) || null) + } + > + + + + + + + + + + + + + + + +
+
+ ); diff --git a/src/core/client/admin/routes/Invite/InviteCompleteForm.css b/src/core/client/admin/routes/Invite/InviteCompleteForm.css new file mode 100644 index 000000000..f014c4400 --- /dev/null +++ b/src/core/client/admin/routes/Invite/InviteCompleteForm.css @@ -0,0 +1,4 @@ +.root { + max-width: calc(42 * var(--mini-unit)); + margin: calc(3 * var(--mini-unit)) auto 0; +} diff --git a/src/core/client/admin/routes/Invite/InviteCompleteForm.tsx b/src/core/client/admin/routes/Invite/InviteCompleteForm.tsx new file mode 100644 index 000000000..d3730cbe3 --- /dev/null +++ b/src/core/client/admin/routes/Invite/InviteCompleteForm.tsx @@ -0,0 +1,114 @@ +import { FORM_ERROR } from "final-form"; +import { Localized } from "fluent-react/compat"; +import React, { useCallback, useMemo } from "react"; +import { Form } from "react-final-form"; + +import { InvalidRequestError } from "coral-framework/lib/errors"; +import { parseJWT } from "coral-framework/lib/jwt"; +import { useMutation } from "coral-framework/lib/relay"; +import { + Button, + CallOut, + Flex, + HorizontalGutter, + Typography, +} from "coral-ui/components"; + +import InviteCompleteMutation from "./InviteCompleteMutation"; +import SetPasswordField from "./SetPasswordField"; +import SetUsernameField from "./SetUsernameField"; + +import styles from "./InviteCompleteForm.css"; + +interface Props { + token: string; + organizationName: string; + onSuccess: () => void; +} + +interface FormProps { + username: string; + password: string; +} + +const InviteCompleteForm: React.FunctionComponent = ({ + onSuccess, + token, + organizationName, +}) => { + const completeInvite = useMutation(InviteCompleteMutation); + const onSubmit = useCallback( + async ({ username, password }: FormProps) => { + try { + await completeInvite({ username, token, password }); + onSuccess(); + } catch (error) { + if (error instanceof InvalidRequestError) { + return error.invalidArgs; + } + return { [FORM_ERROR]: error.message }; + } + return; + }, + [token] + ); + const email = useMemo(() => parseJWT(token).payload.email, [token]); + + return ( +
+ + + + You've been invited to join {organizationName} + + + + + Finish setting up the account for: + + + {email} + +
+ {({ handleSubmit, submitting, submitError }) => ( + + + {submitError && ( + + {submitError} + + )} + + + + + + +
+ )} + +
+ ); +}; + +export default InviteCompleteForm; diff --git a/src/core/client/admin/routes/Invite/InviteCompleteFormContainer.tsx b/src/core/client/admin/routes/Invite/InviteCompleteFormContainer.tsx new file mode 100644 index 000000000..3ad49a8eb --- /dev/null +++ b/src/core/client/admin/routes/Invite/InviteCompleteFormContainer.tsx @@ -0,0 +1,39 @@ +import React, { FunctionComponent } from "react"; + +import { InviteCompleteFormContainer_settings } from "coral-admin/__generated__/InviteCompleteFormContainer_settings.graphql"; +import { graphql, withFragmentContainer } from "coral-framework/lib/relay"; + +import InviteCompleteForm from "./InviteCompleteForm"; + +interface Props { + token: string; + settings: InviteCompleteFormContainer_settings; + disabled?: boolean; + onSuccess: () => void; +} + +const InviteCompleteFormContainer: FunctionComponent = ({ + onSuccess, + token, + settings, +}) => { + return ( + + ); +}; + +const enhanced = withFragmentContainer({ + settings: graphql` + fragment InviteCompleteFormContainer_settings on Settings { + organization { + name + } + } + `, +})(InviteCompleteFormContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Invite/InviteCompleteMutation.tsx b/src/core/client/admin/routes/Invite/InviteCompleteMutation.tsx new file mode 100644 index 000000000..603c94f0d --- /dev/null +++ b/src/core/client/admin/routes/Invite/InviteCompleteMutation.tsx @@ -0,0 +1,22 @@ +import { Environment } from "relay-runtime"; + +import { createMutation } from "coral-framework/lib/relay"; + +const InviteCompleteMutation = createMutation( + "invite", + async ( + environment: Environment, + variables: { token: string; username: string; password: string }, + { rest } + ) => + await rest.fetch("/account/invite", { + method: "PUT", + token: variables.token, + body: { + username: variables.username, + password: variables.password, + }, + }) +); + +export default InviteCompleteMutation; diff --git a/src/core/client/admin/routes/Invite/InviteLayout.css b/src/core/client/admin/routes/Invite/InviteLayout.css new file mode 100644 index 000000000..295d37428 --- /dev/null +++ b/src/core/client/admin/routes/Invite/InviteLayout.css @@ -0,0 +1,6 @@ +.root { +} + +.logoContainer { + position: relative; +} diff --git a/src/core/client/admin/routes/Invite/InviteLayout.tsx b/src/core/client/admin/routes/Invite/InviteLayout.tsx new file mode 100644 index 000000000..9246c7482 --- /dev/null +++ b/src/core/client/admin/routes/Invite/InviteLayout.tsx @@ -0,0 +1,30 @@ +import React, { FunctionComponent } from "react"; + +import Version from "coral-admin/App/Version"; +import { AppBar, Flex, Logo } from "coral-ui/components"; +import { Begin } from "coral-ui/components/AppBar"; + +import styles from "./InviteLayout.css"; + +const InviteLayout: FunctionComponent = ({ children }) => ( +
+ + +
+ + +
+
+
+ + {children} + +
+); + +export default InviteLayout; diff --git a/src/core/client/admin/routes/Invite/InviteRoute.tsx b/src/core/client/admin/routes/Invite/InviteRoute.tsx new file mode 100644 index 000000000..e370c1112 --- /dev/null +++ b/src/core/client/admin/routes/Invite/InviteRoute.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useState } from "react"; +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { InviteRouteQueryResponse } from "coral-admin/__generated__/InviteRouteQuery.graphql"; +import { useToken } from "coral-framework/hooks"; +import { createFetch } from "coral-framework/lib/relay"; +import { withRouteConfig } from "coral-framework/lib/router"; +import { parseHashQuery } from "coral-framework/utils"; +import { Delay, Flex, Spinner } from "coral-ui/components"; + +import InviteCompleteFormContainer from "./InviteCompleteFormContainer"; +import InviteLayout from "./InviteLayout"; +import Sorry from "./Sorry"; +import SuccessContainer from "./SuccessContainer"; + +const fetcher = createFetch( + "inviteToken", + async (environment: Environment, variables: { token: string }, { rest }) => + await rest.fetch("/account/invite", { + method: "GET", + token: variables.token, + }) +); + +interface Props { + data: InviteRouteQueryResponse | null; + token: string | undefined; +} + +const InviteRoute: React.FunctionComponent = ({ token, data }) => { + const [finished, setFinished] = useState(false); + const onSuccess = useCallback(() => { + setFinished(true); + }, []); + const [tokenState, tokenError] = useToken(fetcher, token); + + if (!data || tokenState === "UNCHECKED") { + return ( + + + + + + ); + } + + if (tokenState !== "VALID" || tokenError) { + return ( + + + + ); + } + + return ( + + {!finished ? ( + + ) : ( + + )} + + ); +}; + +const enhanced = withRouteConfig({ + query: graphql` + query InviteRouteQuery { + settings { + ...InviteCompleteFormContainer_settings + ...SuccessContainer_settings + } + } + `, + render: ({ match, Component, ...rest }) => ( + + ), +})(InviteRoute); + +export default enhanced; diff --git a/src/core/client/admin/routes/Invite/SetPasswordField.tsx b/src/core/client/admin/routes/Invite/SetPasswordField.tsx new file mode 100644 index 000000000..72314ea6c --- /dev/null +++ b/src/core/client/admin/routes/Invite/SetPasswordField.tsx @@ -0,0 +1,63 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; +import { Field } from "react-final-form"; + +import { + composeValidators, + required, + validatePassword, +} from "coral-framework/lib/validation"; +import { + FormField, + InputDescription, + InputLabel, + PasswordField, + ValidationMessage, +} from "coral-ui/components"; + +interface Props { + disabled: boolean; +} + +const SetPasswordField: FunctionComponent = 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/Invite/SetUsernameField.tsx b/src/core/client/admin/routes/Invite/SetUsernameField.tsx new file mode 100644 index 000000000..6354ba445 --- /dev/null +++ b/src/core/client/admin/routes/Invite/SetUsernameField.tsx @@ -0,0 +1,61 @@ +import { + composeValidators, + required, + validateUsername, +} from "coral-framework/lib/validation"; +import { + FormField, + InputDescription, + InputLabel, + TextField, + ValidationMessage, +} from "coral-ui/components"; +import { Localized } from "fluent-react/compat"; +import * as React from "react"; +import { FunctionComponent } from "react"; +import { Field } from "react-final-form"; + +interface Props { + disabled: boolean; +} + +const SetUsernameField: FunctionComponent = props => ( + + {({ input, meta }) => ( + + + Username + + + You may use “_” and “.” + + + + + {meta.touched && (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} + + )} + +); + +export default SetUsernameField; diff --git a/src/core/client/admin/routes/Invite/Sorry.tsx b/src/core/client/admin/routes/Invite/Sorry.tsx new file mode 100644 index 000000000..7f90287e7 --- /dev/null +++ b/src/core/client/admin/routes/Invite/Sorry.tsx @@ -0,0 +1,32 @@ +import { Localized } from "fluent-react/compat"; +import React from "react"; + +import { CallOut, HorizontalGutter, Typography } from "coral-ui/components"; + +interface Props { + reason: React.ReactNode; +} + +const Sorry: React.FunctionComponent = ({ reason }) => { + return ( + + + Oops Sorry! + + + {reason ? ( + reason + ) : ( + + + The specified link is invalid, check to see if it was copied + correctly. + + + )} + + + ); +}; + +export default Sorry; diff --git a/src/core/client/admin/routes/Invite/Success.css b/src/core/client/admin/routes/Invite/Success.css new file mode 100644 index 000000000..77512eaac --- /dev/null +++ b/src/core/client/admin/routes/Invite/Success.css @@ -0,0 +1,9 @@ +.root { + text-align: center; +} + +.link { + display: block; + font-size: calc(24rem / var(--rem-base)); + font-family: var(--font-family-serif); +} diff --git a/src/core/client/admin/routes/Invite/Success.tsx b/src/core/client/admin/routes/Invite/Success.tsx new file mode 100644 index 000000000..a27a98ee7 --- /dev/null +++ b/src/core/client/admin/routes/Invite/Success.tsx @@ -0,0 +1,60 @@ +import { Localized } from "fluent-react/compat"; +import { Link } from "found"; +import React, { useMemo } from "react"; + +import { ExternalLink } from "coral-framework/lib/i18n/components"; +import { parseJWT } from "coral-framework/lib/jwt"; +import { HorizontalGutter, Typography } from "coral-ui/components"; + +import styles from "./Success.css"; + +interface Props { + token: string; + organizationName: string; + organizationURL: string; +} + +const Success: React.FunctionComponent = ({ + token, + organizationName, + organizationURL, +}) => { + const email = useMemo(() => parseJWT(token).payload.email, [token]); + + return ( + + + + Your account has been created + + + + + You may now sign-in to Coral using: + + + {email} + + + + Go to Coral Admin + + + + + {"Go to {$organizationName}"} + + + + + ); +}; + +export default Success; diff --git a/src/core/client/admin/routes/Invite/SuccessContainer.tsx b/src/core/client/admin/routes/Invite/SuccessContainer.tsx new file mode 100644 index 000000000..9c5e51355 --- /dev/null +++ b/src/core/client/admin/routes/Invite/SuccessContainer.tsx @@ -0,0 +1,34 @@ +import { graphql, withFragmentContainer } from "coral-framework/lib/relay"; +import React, { FunctionComponent } from "react"; + +import { SuccessContainer_settings } from "coral-admin/__generated__/SuccessContainer_settings.graphql"; + +import Success from "./Success"; + +interface Props { + settings: SuccessContainer_settings; + token: string; +} + +const SuccessContainer: FunctionComponent = ({ token, settings }) => { + return ( + + ); +}; + +const enhanced = withFragmentContainer({ + settings: graphql` + fragment SuccessContainer_settings on Settings { + organization { + name + url + } + } + `, +})(SuccessContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Invite/index.ts b/src/core/client/admin/routes/Invite/index.ts new file mode 100644 index 000000000..04819a822 --- /dev/null +++ b/src/core/client/admin/routes/Invite/index.ts @@ -0,0 +1 @@ +export { default, default as InviteRoute } from "./InviteRoute"; diff --git a/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap b/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap index f17d5e619..01a7fd51d 100644 --- a/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap +++ b/src/core/client/admin/test/community/__snapshots__/community.spec.tsx.snap @@ -13,89 +13,105 @@ exports[`ban user 1`] = ` onClick={[Function]} />
-
-
-
-

- Are you sure you want to ban - - Isabelle - - ? -

-

- Once banned, this user will no longer be able to comment, use +

+
+
+ +
+
+

+ Are you sure you want to ban + + Isabelle + + ? +

+

+ Once banned, this user will no longer be able to comment, use reactions, or report comments. -

-
-
- - +

+
+
+ + +
+
+
+
+
-
`; @@ -108,171 +124,192 @@ exports[`renders community 1`] = ` className="Box-root HorizontalGutter-root HorizontalGutter-double" >
-
- - Search - -
-
+ Search + + -
- +
+
+
+
+
+ + Show Me + +
+ + + - -
+ + + + + + + +
- - -
+
+
- - Show Me - -
- - - - - - - - - - - - -
- + Invite + +
-
- - Search - -
-
+ Search + + -
- +
+
+
+
+
+ + Show Me + +
+ + + - -
+ + + + + + + +
- - -
+
+
- - Show Me - -
- - - - - - - - - - - - -
- + Invite + +
{ expect(within(container).toJSON()).toMatchSnapshot(); }); +it("renders the invite button when clicked", async () => { + const { container } = await createTestRenderer(); + + await act(async () => + within(container) + .getByTestID("invite-users-button") + .props.onClick() + ); + + expect(within(container).getByTestID("invite-users-modal")).toBeDefined(); +}); + +it("renders with invite button when viewed with admin user", async () => { + const admin = users.admins[0]; + const { container } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + viewer: () => admin, + }, + }), + }); + expect(within(container).queryByTestID("invite-users")).toBeDefined(); +}); + +it("renders without invite button when viewed with non-admin user", async () => { + const moderator = users.moderators[0]; + const { container } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + viewer: () => moderator, + }, + }), + }); + expect(within(container).queryByTestID("invite-users")).toBeNull(); +}); + +it("renders without invite button when email disabled", async () => { + const { container } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + settings: createQueryResolverStub( + () => disabledEmail + ), + }, + }), + }); + expect(within(container).queryByTestID("invite-users")).toBeNull(); +}); + +it("renders without invite button when admin target filter disabled", async () => { + const { container } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + settings: createQueryResolverStub( + () => disabledLocalAuthAdminTargetFilter + ), + }, + }), + }); + expect(within(container).queryByTestID("invite-users")).toBeNull(); +}); + +it("renders without invite button when local auth disabled", async () => { + const { container } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + settings: createQueryResolverStub( + () => disabledLocalAuth + ), + }, + }), + }); + expect(within(container).queryByTestID("invite-users")).toBeNull(); +}); + +it("renders without invite button when local auth registration disabled", async () => { + const { container } = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + settings: createQueryResolverStub( + () => disabledLocalRegistration + ), + }, + }), + }); + expect(within(container).queryByTestID("invite-users")).toBeNull(); +}); + it("filter by role", async () => { const { container } = await createTestRenderer({ resolvers: createResolversStub({ @@ -434,3 +529,40 @@ it("remove user ban", async () => { within(userRow).getByText("Active"); expect(resolvers.Mutation!.removeUserBan!.called).toBe(true); }); + +it("invites user", async () => { + const resolvers = createResolversStub({ + Mutation: { + inviteUsers: ({ variables }) => ({ + invites: variables.emails.map((email, idx) => ({ + id: uuid(), + email, + })), + }), + }, + }); + const { container } = await createTestRenderer({ resolvers }); + + // Find the invite button. + const inviteButton = within(container).getByTestID("invite-users-button"); + + // Let's click the button. + act(() => inviteButton.props.onClick()); + + // Find the form. + const modal = within(container).getByTestID("invite-users-modal"); + const form = within(modal).getByType("form"); + + // Find the first email field. + const field = within(form).getByTestID("invite-users-email.0"); + + // Submit the form. + await act(async () => { + field.props.onChange("test@email.com"); + return form.props.onSubmit(); + }); + + await wait(() => { + expect(resolvers.Mutation!.inviteUsers!.called).toBe(true); + }); +}); diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index f438b764b..30733a340 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -1,3 +1,4 @@ +import { pureMerge } from "coral-common/utils"; import { GQLComment, GQLCOMMENT_FLAG_REASON, @@ -40,6 +41,9 @@ export const settings = createFixture({ timeout: 604800, message: "Comments are closed on this story.", }, + email: { + enabled: true, + }, customCSSURL: "", allowedDomains: ["localhost:8080"], editCommentWindowLength: 30000, @@ -655,3 +659,49 @@ export const emptyCommunityUsers = createFixture({ edges: [], pageInfo: { endCursor: null, hasNextPage: false }, }); + +export const disabledEmail = createFixture( + pureMerge(settings, { + email: { + enabled: false, + }, + }) +); + +export const disabledLocalAuth = createFixture( + pureMerge(settings, { + auth: { + integrations: { + local: { + enabled: false, + }, + }, + }, + }) +); + +export const disabledLocalAuthAdminTargetFilter = createFixture( + pureMerge(settings, { + auth: { + integrations: { + local: { + targetFilter: { + admin: false, + }, + }, + }, + }, + }) +); + +export const disabledLocalRegistration = createFixture( + pureMerge(settings, { + auth: { + integrations: { + local: { + allowRegistration: false, + }, + }, + }, + }) +); diff --git a/src/core/client/admin/test/invite/__snapshots__/invite.spec.tsx.snap b/src/core/client/admin/test/invite/__snapshots__/invite.spec.tsx.snap new file mode 100644 index 000000000..03b61707f --- /dev/null +++ b/src/core/client/admin/test/invite/__snapshots__/invite.spec.tsx.snap @@ -0,0 +1,310 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders form 1`] = ` +
+
+
+
+
+
+ + + + + + + + + + + logo mark2018 + + + + + +

+ Coral +

+
+

+ vTest +

+
+
+
+
+
+
+
+

+ You've been invited to join Coral +

+

+ Finish setting up the account for: +

+

+ lukas@test.com +

+
+
+
+
+ +

+ You may use “_” and “.” +

+
+ +
+
+
+ +

+ Must be at least 8 characters +

+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+`; + +exports[`renders missing the token 1`] = ` +
+
+
+
+
+
+ + + + + + + + + + + logo mark2018 + + + + + +

+ Coral +

+
+

+ vTest +

+
+
+
+
+
+
+

+ Oops Sorry! +

+
+
+ + The specified link is invalid, check to see if it was copied correctly. + +
+
+
+
+
+`; diff --git a/src/core/client/admin/test/invite/invite.spec.tsx b/src/core/client/admin/test/invite/invite.spec.tsx new file mode 100644 index 000000000..cbf345bec --- /dev/null +++ b/src/core/client/admin/test/invite/invite.spec.tsx @@ -0,0 +1,180 @@ +import sinon from "sinon"; + +import { ERROR_CODES } from "coral-common/errors"; +import { pureMerge } from "coral-common/utils"; +import { InvalidRequestError } from "coral-framework/lib/errors"; +import { GQLResolver } from "coral-framework/schema"; +import { + act, + createAccessToken, + createResolversStub, + CreateTestRendererParams, + replaceHistoryLocation, + waitForElement, + within, +} from "coral-framework/testHelpers"; + +import create from "../create"; +import { settings, users } from "../fixtures"; + +const user = users.moderators[0]; + +const token = createAccessToken({ email: user.email! }); + +const createTestRenderer = async ( + params: CreateTestRendererParams = {} +) => { + const { testRenderer, context } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + return { context, root: testRenderer.root }; +}; + +it("renders missing the token", async () => { + replaceHistoryLocation("http://localhost/admin/invite"); + const { root } = await createTestRenderer(); + await waitForElement(() => within(root).getByTestID("invalid-link")); + expect(within(root).toJSON()).toMatchSnapshot(); +}); + +it("renders form", async () => { + replaceHistoryLocation(`http://localhost/admin/invite#inviteToken=${token}`); + const { root, context } = await createTestRenderer(); + const mock = sinon.mock(context.rest); + + mock + .expects("fetch") + .withArgs("/account/invite", { + method: "GET", + token, + }) + .once(); + + await act(async () => { + await waitForElement(() => + within(root).getByTestID("invite-complete-form") + ); + }); + + expect(within(root).toJSON()).toMatchSnapshot(); + mock.verify(); +}); + +it("renders error from server", async () => { + replaceHistoryLocation(`http://localhost/admin/invite#inviteToken=${token}`); + const codes = [ + ERROR_CODES.RATE_LIMIT_EXCEEDED, + ERROR_CODES.INVITE_TOKEN_EXPIRED, + ERROR_CODES.INTEGRATION_DISABLED, + ERROR_CODES.USER_NOT_FOUND, + ERROR_CODES.TOKEN_INVALID, + ]; + + for (const code of codes) { + const { root, context } = await createTestRenderer(); + + const mock = sinon.mock(context.rest); + + mock + .expects("fetch") + .withArgs("/account/invite", { + method: "GET", + token, + }) + .once() + .throwsException( + new InvalidRequestError({ + code, + }) + ); + + await act(async () => { + await waitForElement(() => + within(root).getByTestID("invite-complete-sorry") + ); + }); + + mock.verify(); + } +}); + +it("submits form", async () => { + replaceHistoryLocation(`http://localhost/admin/invite#inviteToken=${token}`); + const { context, root } = await createTestRenderer(); + const mock = sinon.mock(context.rest); + + mock + .expects("fetch") + .withArgs("/account/invite", { + method: "GET", + token, + }) + .once(); + + mock + .expects("fetch") + .withArgs("/account/invite", { + method: "PUT", + token, + body: { + username: user.username!, + password: "testtest", + }, + }) + .once(); + + await act(async () => { + await waitForElement(() => + within(root).getByTestID("invite-complete-form") + ); + await waitForElement(() => + within(root).getByText(user.email!, { exact: false }) + ); + }); + + const form = within(root).getByType("form"); + const usernameField = within(root).getByLabelText("Username"); + const passwordField = within(root).getByLabelText("Password"); + + // Submit an empty form. + await act(async () => { + await form.props.onSubmit(); + }); + within(root).getAllByText("field is required", { + exact: false, + }); + + // Password too short. + act(() => { + usernameField.props.onChange(user.username!); + passwordField.props.onChange("test"); + }); + within(root).getByText("Password must contain at least 8 characters", { + exact: false, + }); + + // Submit valid form. + await act(() => { + passwordField.props.onChange("testtest"); + return form.props.onSubmit(); + }); + + await waitForElement(() => + within(root).getByTestID("invite-complete-success") + ); + + mock.verify(); +}); diff --git a/src/core/client/framework/helpers/clearHash.ts b/src/core/client/framework/helpers/clearHash.ts new file mode 100644 index 000000000..263c9ebc2 --- /dev/null +++ b/src/core/client/framework/helpers/clearHash.ts @@ -0,0 +1,5 @@ +export default function clearHash() { + if (window.location.hash) { + window.history.replaceState(null, document.title, location.pathname); + } +} diff --git a/src/core/client/framework/helpers/getParamsFromHash.ts b/src/core/client/framework/helpers/getParamsFromHash.ts new file mode 100644 index 000000000..58da86523 --- /dev/null +++ b/src/core/client/framework/helpers/getParamsFromHash.ts @@ -0,0 +1,14 @@ +import { parseQuery } from "coral-common/utils"; + +export default function getParamsFromHash() { + try { + const params = window.location.hash + ? parseQuery(window.location.hash.substr(1)) + : {}; + + return params; + } catch (err) { + window.console.error(err); + return {}; + } +} diff --git a/src/core/client/framework/helpers/getParamsFromHashAndClearIt.ts b/src/core/client/framework/helpers/getParamsFromHashAndClearIt.ts index c0d97c2f7..90b027cc0 100644 --- a/src/core/client/framework/helpers/getParamsFromHashAndClearIt.ts +++ b/src/core/client/framework/helpers/getParamsFromHashAndClearIt.ts @@ -1,15 +1,12 @@ -import { parseQuery } from "coral-common/utils"; +import clearHash from "./clearHash"; +import getParamsFromHash from "./getParamsFromHash"; export default function getParamsFromHashAndClearIt() { try { - const params = window.location.hash - ? parseQuery(window.location.hash.substr(1)) - : {}; + const params = getParamsFromHash(); - // Remove hash with token. - if (window.location.hash) { - window.history.replaceState(null, document.title, location.pathname); - } + // Clear the hash contents. + clearHash(); return params; } catch (err) { diff --git a/src/core/client/framework/helpers/index.ts b/src/core/client/framework/helpers/index.ts index 5b83af761..c903e8efa 100644 --- a/src/core/client/framework/helpers/index.ts +++ b/src/core/client/framework/helpers/index.ts @@ -7,4 +7,6 @@ export { default as redirectOAuth2 } from "./redirectOAuth2"; export { default as getParamsFromHashAndClearIt, } from "./getParamsFromHashAndClearIt"; +export { default as getParamsFromHash } from "./getParamsFromHash"; +export { default as clearHash } from "./clearHash"; export { default as roleIsAtLeast } from "./roleIsAtLeast"; diff --git a/src/core/client/framework/hooks/index.ts b/src/core/client/framework/hooks/index.ts index 4c6c57a6e..561e5c58f 100644 --- a/src/core/client/framework/hooks/index.ts +++ b/src/core/client/framework/hooks/index.ts @@ -2,3 +2,4 @@ export { default as useEffectAfterMount } from "./useEffectAfterMount"; export { default as usePrevious } from "./usePrevious"; export { default as useEffectWhenChanged } from "./useEffectWhenChanged"; export { default as useUUID } from "./useUUID"; +export { default as useToken } from "./useToken"; diff --git a/src/core/client/framework/hooks/useToken.ts b/src/core/client/framework/hooks/useToken.ts new file mode 100644 index 000000000..26a9e90d9 --- /dev/null +++ b/src/core/client/framework/hooks/useToken.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; + +import { InvalidRequestError } from "coral-framework/lib/errors"; +import { useFetch } from "coral-framework/lib/relay"; +import { Fetch } from "coral-framework/lib/relay/fetch"; + +type TokenState = "VALID" | "INVALID" | "MISSING" | "UNKNOWN" | "UNCHECKED"; + +interface Variables { + token: string; +} + +export default function useToken( + fetcher: Fetch>, + token: string | undefined +): [TokenState, string] { + const checkToken = useFetch(fetcher); + const [tokenState, setTokenState] = useState("UNCHECKED"); + const [tokenError, setTokenError] = useState(""); + + useEffect(() => { + let disposed = false; + + function handleTokenState(state: TokenState, error?: Error) { + if (disposed) { + return; + } + + setTokenState(state); + if (error) { + setTokenError(error.message); + } + } + + if (token) { + checkToken({ token }) + .then(() => { + handleTokenState("VALID"); + }) + .catch(error => { + if (error instanceof InvalidRequestError) { + handleTokenState("INVALID", error); + } else { + handleTokenState("UNKNOWN", error); + } + }); + } else { + handleTokenState("MISSING"); + } + + return () => { + disposed = true; + }; + }, [token]); + + return [tokenState, tokenError]; +} diff --git a/src/core/client/framework/lib/i18n/components/ExternalLink.tsx b/src/core/client/framework/lib/i18n/components/ExternalLink.tsx index 8996ed43a..b0d541ea0 100644 --- a/src/core/client/framework/lib/i18n/components/ExternalLink.tsx +++ b/src/core/client/framework/lib/i18n/components/ExternalLink.tsx @@ -1,3 +1,4 @@ +import cn from "classnames"; import React, { FunctionComponent } from "react"; import styles from "./ExternalLink.css"; @@ -5,7 +6,8 @@ import styles from "./ExternalLink.css"; const ExternalLink: FunctionComponent<{ href?: string; children?: string; -}> = ({ href, children }) => ( + className?: string; +}> = ({ href, children, className }) => ( {children} diff --git a/src/core/client/framework/lib/jwt.ts b/src/core/client/framework/lib/jwt.ts index 382e28f20..6e09a172b 100644 --- a/src/core/client/framework/lib/jwt.ts +++ b/src/core/client/framework/lib/jwt.ts @@ -4,6 +4,7 @@ export interface JWT { typ: string; }; payload: { + [_: string]: any; iat?: number; exp?: number; iss?: string; diff --git a/src/core/client/framework/testHelpers/createAccessToken.ts b/src/core/client/framework/testHelpers/createAccessToken.ts index 13ff89756..ee5b845cd 100644 --- a/src/core/client/framework/testHelpers/createAccessToken.ts +++ b/src/core/client/framework/testHelpers/createAccessToken.ts @@ -1,4 +1,4 @@ -export default function createAccessToken() { +export default function createAccessToken(payload = {}) { return `${btoa( JSON.stringify({ alg: "HS256", @@ -7,6 +7,7 @@ export default function createAccessToken() { )}.${btoa( JSON.stringify({ jti: "31b26591-4e9a-4388-a7ff-e1bdc5d97cce", + ...payload, }) )}`; } diff --git a/src/core/client/stream/index.html b/src/core/client/stream/index.html index 8891de80d..b94082ab1 100644 --- a/src/core/client/stream/index.html +++ b/src/core/client/stream/index.html @@ -8,6 +8,10 @@ +
diff --git a/src/core/client/ui/components/Check/Check.mdx b/src/core/client/ui/components/Check/Check.mdx new file mode 100644 index 000000000..f233c0294 --- /dev/null +++ b/src/core/client/ui/components/Check/Check.mdx @@ -0,0 +1,18 @@ +--- +name: Check +menu: UI Kit +--- + +import { Playground, PropsTable } from "docz"; +import { CheckIcon } from "./"; +import Flex from "../Flex"; + +# Check + +## Basic usage + + + + + + diff --git a/src/core/client/ui/components/Check/CheckIcon.css b/src/core/client/ui/components/Check/CheckIcon.css new file mode 100644 index 000000000..878265611 --- /dev/null +++ b/src/core/client/ui/components/Check/CheckIcon.css @@ -0,0 +1,2 @@ +.base { +} diff --git a/src/core/client/ui/components/Check/CheckIcon.tsx b/src/core/client/ui/components/Check/CheckIcon.tsx new file mode 100644 index 000000000..df843671d --- /dev/null +++ b/src/core/client/ui/components/Check/CheckIcon.tsx @@ -0,0 +1,35 @@ +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import { withStyles } from "coral-ui/hocs"; +import variables from "coral-ui/theme/variables"; + +import styles from "./CheckIcon.css"; + +interface Props { + className?: string; + classes: typeof styles; +} + +const CheckIcon: FunctionComponent = ({ + className, + classes, + ...rest +}) => ( + + + +); + +export default withStyles(styles)(CheckIcon); diff --git a/src/core/client/ui/components/Check/index.ts b/src/core/client/ui/components/Check/index.ts new file mode 100644 index 000000000..6654aafec --- /dev/null +++ b/src/core/client/ui/components/Check/index.ts @@ -0,0 +1 @@ +export { default as CheckIcon } from "./CheckIcon"; diff --git a/src/core/client/ui/components/Modal/Modal.css b/src/core/client/ui/components/Modal/Modal.css index db4fe10e4..92b04ca37 100644 --- a/src/core/client/ui/components/Modal/Modal.css +++ b/src/core/client/ui/components/Modal/Modal.css @@ -2,11 +2,31 @@ position: fixed; top: 0px; left: 0px; - display: flex; width: 100%; height: 100%; box-sizing: border-box; - justify-content: center; - align-items: center; z-index: var(--zindex-modal); } + +.scroll { + pointer-events: none; + position: relative; + overflow-y: auto; + width: 100%; + height: 100%; +} + +.alignContainer1 { + display: table; + margin: 0 auto; + height: 100%; +} + +.alignContainer2 { + display: table-cell; + vertical-align: middle; +} + +.wrapper { + pointer-events: all; +} diff --git a/src/core/client/ui/components/Modal/Modal.tsx b/src/core/client/ui/components/Modal/Modal.tsx index c0f61cd0f..24f7a7828 100644 --- a/src/core/client/ui/components/Modal/Modal.tsx +++ b/src/core/client/ui/components/Modal/Modal.tsx @@ -109,10 +109,18 @@ const Modal: FunctionComponent = ({ - {children} +
+
+
+
+ {children} +
+
+
+
, modalDOMNode ); diff --git a/src/core/client/ui/components/Modal/__snapshots__/Modal.spec.tsx.snap b/src/core/client/ui/components/Modal/__snapshots__/Modal.spec.tsx.snap index e2ef85d7b..fcf9c9e76 100644 --- a/src/core/client/ui/components/Modal/__snapshots__/Modal.spec.tsx.snap +++ b/src/core/client/ui/components/Modal/__snapshots__/Modal.spec.tsx.snap @@ -12,18 +12,34 @@ exports[`renders correctly 1`] = ` onClick={[Function]} />
-
-
- Test + className="Modal-scroll" + > +
+
+
+
+
+
+ Test +
+
+
+
+
-
`; diff --git a/src/core/client/ui/components/index.ts b/src/core/client/ui/components/index.ts index b24a0cddd..62eac0d0e 100644 --- a/src/core/client/ui/components/index.ts +++ b/src/core/client/ui/components/index.ts @@ -39,6 +39,7 @@ export { Navigation as AppBarNavigation, NavigationItem as AppBarNavigationItem, } from "./AppBar"; +export { CheckIcon } from "./Check"; export { SubBar, Navigation as SubBarNavigation, diff --git a/src/core/common/errors.ts b/src/core/common/errors.ts index 7be60adc1..86cbba177 100644 --- a/src/core/common/errors.ts +++ b/src/core/common/errors.ts @@ -256,6 +256,11 @@ export enum ERROR_CODES { */ EMAIL_CONFIRM_TOKEN_EXPIRED = "EMAIL_CONFIRM_TOKEN_EXPIRED", + /** + * INVITE_TOKEN_EXPIRED is returned when a given invite token has expired. + */ + INVITE_TOKEN_EXPIRED = "INVITE_TOKEN_EXPIRED", + /** * RATE_LIMIT_EXCEEDED is returned when an operation is performed too many * times by the same user. @@ -266,4 +271,10 @@ export enum ERROR_CODES { * JWT_REVOKED is returned when the token referenced has been revoked. */ JWT_REVOKED = "JWT_REVOKED", + + /* + * INVITE_REQUIRES_EMAIL_ADDRESSES is returned when an invite is requested + * without any email addresses specified. + */ + INVITE_REQUIRES_EMAIL_ADDRESSES = "INVITE_REQUIRES_EMAIL_ADDRESSES", } diff --git a/src/core/server/app/handlers/api/account/confirm.ts b/src/core/server/app/handlers/api/account/confirm.ts index f7caeacf5..7d229f74a 100644 --- a/src/core/server/app/handlers/api/account/confirm.ts +++ b/src/core/server/app/handlers/api/account/confirm.ts @@ -34,23 +34,25 @@ export const ConfirmRequestBodySchema = Joi.object() .optionalKeys(["userID"]); export const confirmRequestHandler = ({ - redis: client, + redis, config, mongo, mailerQueue, signingConfig, }: ConfirmRequestOptions): RequestHandler => { const ipLimiter = new RequestLimiter({ - client, + redis, ttl: "10m", max: 10, prefix: "ip", + config, }); const userIDLimiter = new RequestLimiter({ - client, + redis, ttl: "10m", max: 10, prefix: "userID", + config, }); return async (req, res, next) => { @@ -132,25 +134,28 @@ export const confirmRequestHandler = ({ export type ConfirmCheckOptions = Pick< AppOptions, - "mongo" | "signingConfig" | "redis" + "mongo" | "signingConfig" | "redis" | "config" >; export const confirmCheckHandler = ({ - redis: client, + redis, mongo, signingConfig, + config, }: ConfirmCheckOptions): RequestHandler => { const ipLimiter = new RequestLimiter({ - client, + redis, ttl: "10m", max: 10, prefix: "ip", + config, }); const subLimiter = new RequestLimiter({ - client, + redis, ttl: "5m", max: 10, prefix: "sub", + config, }); return async (req, res, next) => { @@ -194,25 +199,28 @@ export const confirmCheckHandler = ({ export type ConfirmOptions = Pick< AppOptions, - "mongo" | "signingConfig" | "redis" + "mongo" | "signingConfig" | "redis" | "config" >; export const confirmHandler = ({ - redis: client, + redis, mongo, signingConfig, + config, }: ConfirmOptions): RequestHandler => { const ipLimiter = new RequestLimiter({ - client, + redis, ttl: "10m", max: 10, prefix: "ip", + config, }); const subLimiter = new RequestLimiter({ - client, + redis, ttl: "5m", max: 10, prefix: "sub", + config, }); return async (req, res, next) => { diff --git a/src/core/server/app/handlers/api/account/index.ts b/src/core/server/app/handlers/api/account/index.ts index 7b054e392..ea68cb5a2 100644 --- a/src/core/server/app/handlers/api/account/index.ts +++ b/src/core/server/app/handlers/api/account/index.ts @@ -1 +1,2 @@ export * from "./confirm"; +export * from "./invite"; diff --git a/src/core/server/app/handlers/api/account/invite.ts b/src/core/server/app/handlers/api/account/invite.ts new file mode 100644 index 000000000..a2b17f075 --- /dev/null +++ b/src/core/server/app/handlers/api/account/invite.ts @@ -0,0 +1,154 @@ +import Joi from "joi"; + +import { AppOptions } from "coral-server/app"; +import { validate } from "coral-server/app/request/body"; +import { RequestLimiter } from "coral-server/app/request/limiter"; +import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt"; +import { + redeem, + verifyInviteTokenString, +} from "coral-server/services/users/auth/invite"; +import { RequestHandler } from "coral-server/types/express"; + +export type InviteCheckOptions = Pick< + AppOptions, + "mongo" | "signingConfig" | "redis" | "config" +>; + +export const inviteCheckHandler = ({ + redis, + signingConfig, + mongo, + config, +}: InviteCheckOptions): RequestHandler => { + const ipLimiter = new RequestLimiter({ + redis, + ttl: "10m", + max: 10, + prefix: "ip", + config, + }); + const subLimiter = new RequestLimiter({ + redis, + ttl: "5m", + max: 10, + prefix: "sub", + config, + }); + + return async (req, res, next) => { + try { + // Rate limit based on the IP address and user agent. + await ipLimiter.test(req, req.ip); + + // Tenant is guaranteed at this point. + const coral = req.coral!; + const tenant = coral.tenant!; + + // Grab the token from the request. + const tokenString = extractTokenFromRequest(req, true); + if (!tokenString) { + return res.sendStatus(400); + } + // Decode the token so we can rate limit based on the user's ID. + const { sub } = decodeJWT(tokenString); + if (sub) { + await subLimiter.test(req, sub); + } + + // Verify the token. + await verifyInviteTokenString( + mongo, + tenant, + signingConfig, + tokenString, + coral.now + ); + + return res.sendStatus(204); + } catch (err) { + return next(err); + } + }; +}; + +export interface InviteBody { + username: string; + password: string; +} + +export const InviteBodySchema = Joi.object().keys({ + username: Joi.string().trim(), + password: Joi.string(), +}); + +export type InviteOptions = Pick< + AppOptions, + "mongo" | "signingConfig" | "redis" | "config" +>; + +export const inviteHandler = ({ + redis, + mongo, + signingConfig, + config, +}: InviteOptions): RequestHandler => { + const ipLimiter = new RequestLimiter({ + redis, + ttl: "10m", + max: 10, + prefix: "ip", + config, + }); + const subLimiter = new RequestLimiter({ + redis, + ttl: "5m", + max: 10, + prefix: "sub", + config, + }); + + return async (req, res, next) => { + try { + // Rate limit based on the IP address and user agent. + await ipLimiter.test(req, req.ip); + + // Tenant is guaranteed at this point. + const coral = req.coral!; + const tenant = coral.tenant!; + + // Grab the token from the request. + const tokenString = extractTokenFromRequest(req, true); + if (!tokenString) { + return res.sendStatus(400); + } + + // Decode the token so we can rate limit based on the user's ID. + const { sub } = decodeJWT(tokenString); + if (sub) { + await subLimiter.test(req, sub); + } + + // Get the fields from the body. Validate will throw an error if the body + // does not conform to the specification. + const { username, password }: InviteBody = validate( + InviteBodySchema, + req.body + ); + + // Redeem the invite to create the new user. + await redeem( + mongo, + tenant, + signingConfig, + tokenString, + { username, password }, + coral.now + ); + + return res.sendStatus(204); + } catch (err) { + return next(err); + } + }; +}; diff --git a/src/core/server/app/handlers/api/auth/local/forgot.ts b/src/core/server/app/handlers/api/auth/local/forgot.ts index 5c4fc257b..f1fa5d6c8 100644 --- a/src/core/server/app/handlers/api/auth/local/forgot.ts +++ b/src/core/server/app/handlers/api/auth/local/forgot.ts @@ -32,22 +32,24 @@ export type ForgotOptions = Pick< export const forgotHandler = ({ config, - redis: client, + redis, mongo, signingConfig, mailerQueue, }: ForgotOptions): RequestHandler => { const ipLimiter = new RequestLimiter({ - client, + redis, ttl: "10m", max: 10, prefix: "ip", + config, }); const emailLimiter = new RequestLimiter({ - client, + redis, ttl: "10m", max: 1, prefix: "email", + config, }); return async (req, res, next) => { @@ -139,25 +141,28 @@ export const ForgotResetBodySchema = Joi.object().keys({ export type ForgotResetOptions = Pick< AppOptions, - "mongo" | "signingConfig" | "mailerQueue" | "redis" + "mongo" | "signingConfig" | "mailerQueue" | "redis" | "config" >; export const forgotResetHandler = ({ - redis: client, + redis, mongo, signingConfig, + config, }: ForgotResetOptions): RequestHandler => { const ipLimiter = new RequestLimiter({ - client, + redis, ttl: "10m", max: 10, prefix: "ip", + config, }); const subLimiter = new RequestLimiter({ - client, + redis, ttl: "5m", max: 10, prefix: "sub", + config, }); return async (req, res, next) => { @@ -212,25 +217,28 @@ export const forgotResetHandler = ({ export type ForgotCheckOptions = Pick< AppOptions, - "mongo" | "signingConfig" | "redis" + "mongo" | "signingConfig" | "redis" | "config" >; export const forgotCheckHandler = ({ - redis: client, + redis, mongo, signingConfig, + config, }: ForgotCheckOptions): RequestHandler => { const ipLimiter = new RequestLimiter({ - client, + redis, ttl: "10m", max: 100, prefix: "ip", + config, }); const subLimiter = new RequestLimiter({ - client, + redis, ttl: "5m", max: 100, prefix: "sub", + config, }); return async (req, res, next) => { diff --git a/src/core/server/app/handlers/api/auth/local/signup.ts b/src/core/server/app/handlers/api/auth/local/signup.ts index 3d4785ac7..dd57358a1 100644 --- a/src/core/server/app/handlers/api/auth/local/signup.ts +++ b/src/core/server/app/handlers/api/auth/local/signup.ts @@ -33,16 +33,17 @@ export type SignupOptions = Pick< export const signupHandler = ({ config, - redis: client, + redis, mongo, signingConfig, mailerQueue, }: SignupOptions): RequestHandler => { const ipLimiter = new RequestLimiter({ - client, + redis, ttl: "10m", max: 10, prefix: "ip", + config, }); return async (req, res, next) => { diff --git a/src/core/server/app/middleware/passport/strategies/local.ts b/src/core/server/app/middleware/passport/strategies/local.ts index 6fcef1bfe..3960d75c8 100644 --- a/src/core/server/app/middleware/passport/strategies/local.ts +++ b/src/core/server/app/middleware/passport/strategies/local.ts @@ -3,6 +3,7 @@ import { Strategy as LocalStrategy } from "passport-local"; import { VerifyCallback } from "coral-server/app/middleware/passport"; import { RequestLimiter } from "coral-server/app/request/limiter"; +import { Config } from "coral-server/config"; import { InvalidCredentialsError } from "coral-server/errors"; import { retrieveUserWithProfile, @@ -53,23 +54,27 @@ const verifyFactory = ( export interface LocalStrategyOptions { mongo: Db; redis: Redis; + config: Config; } export function createLocalStrategy({ mongo, - redis: client, + redis, + config, }: LocalStrategyOptions) { const ipLimiter = new RequestLimiter({ - client, + redis, ttl: "10m", max: 10, prefix: "ip", + config, }); const emailLimiter = new RequestLimiter({ - client, + redis, ttl: "10m", max: 10, prefix: "email", + config, }); return new LocalStrategy( diff --git a/src/core/server/app/request/limiter.ts b/src/core/server/app/request/limiter.ts index 10d8cef12..5188c4517 100644 --- a/src/core/server/app/request/limiter.ts +++ b/src/core/server/app/request/limiter.ts @@ -4,33 +4,39 @@ import { Redis } from "ioredis"; import ms from "ms"; import { Omit } from "coral-common/types"; +import { Config } from "coral-server/config"; import { RateLimitExceeded } from "coral-server/errors"; import { Request } from "coral-server/types/express"; export interface LimiterOptions { - client: Redis; + redis: Redis; ttl: string; max: number; resource: string; operation: string; prefix: string; + config: Config; } export class Limiter { - private client: Redis; + private redis: Redis; private ttl: number; private max: number; private prefix: string; private resource: string; private operation: string; + private disabled: boolean; constructor(options: LimiterOptions) { - this.client = options.client; + this.redis = options.redis; this.ttl = Math.floor(ms(options.ttl) / 1000); this.max = options.max; this.prefix = options.prefix; this.resource = options.resource; this.operation = options.operation; + this.disabled = + options.config.get("env") === "development" && + options.config.get("disable_rate_limiters"); } private key(key: string, resource?: string, operation?: string): string { @@ -43,9 +49,13 @@ export class Limiter { resource?: string, operation?: string ): Promise { + if (this.disabled) { + return 0; + } + const key = this.key(value, resource, operation); - const [[, tries], [, expiry]] = await this.client + const [[, tries], [, expiry]] = await this.redis .multi() .incr(key) .expire(key, this.ttl) @@ -54,7 +64,7 @@ export class Limiter { // if this is new or has no expiry if (tries === 1 || expiry === -1) { // then expire it after the timeout - this.client.expire(key, this.ttl); + this.redis.expire(key, this.ttl); } if (tries > this.max) { diff --git a/src/core/server/app/router/api/account.ts b/src/core/server/app/router/api/account.ts index ecadbc807..9a7133bcd 100644 --- a/src/core/server/app/router/api/account.ts +++ b/src/core/server/app/router/api/account.ts @@ -5,6 +5,8 @@ import { confirmCheckHandler, confirmHandler, confirmRequestHandler, + inviteCheckHandler, + inviteHandler, } from "coral-server/app/handlers"; import { jsonMiddleware } from "coral-server/app/middleware/json"; import { authenticate } from "coral-server/app/middleware/passport"; @@ -25,5 +27,8 @@ export function createNewAccountRouter( router.get("/confirm", confirmCheckHandler(app)); router.put("/confirm", confirmHandler(app)); + router.get("/invite", inviteCheckHandler(app)); + router.put("/invite", jsonMiddleware, inviteHandler(app)); + return router; } diff --git a/src/core/server/app/views/client.html b/src/core/server/app/views/client.html index 31e0a0501..cc3f6dc57 100644 --- a/src/core/server/app/views/client.html +++ b/src/core/server/app/views/client.html @@ -1,18 +1,39 @@ -{% import "macros.html" as macros %} {% extends "templates/base.html" %} {% -block title %}Coral{% endblock %} {% block meta %} - -{% endblock %} {# Include all the styles from the entrypoint #} {% if -entrypoint.css or enableCustomCSS %} {% block css %} {% if entrypoint.css %} {% -for asset in entrypoint.css %} -{{ macros.css(asset.src, asset.integrity, staticURI) }} -{% endfor %} {% endif %} {% if enableCustomCSS %} {# Custom CSS is included -after the CSS block so that its overrides will apply #} {% include -"partials/customCSS.html" %} {% endif %} {% endblock %} {% endif %} {% block -html %} -
-{% endblock %} {# Include all the scripts from the entrypoint #} {% if -entrypoint.js %} {% block js %} {% for asset in entrypoint.js %} -{{ macros.js(asset.src, asset.integrity, staticURI) }} -{% endfor %} {% endblock %} {% endif %} +{% import "macros.html" as macros %} +{% extends "templates/base.html" %} + +{% block title %}Coral{% endblock %} + +{% block meta %} + +{% endblock %} + +{# Include all the styles from the entrypoint #} +{% if entrypoint.css or enableCustomCSS %} + {% block css %} + {% if entrypoint.css %} + {% for asset in entrypoint.css %} + {{ macros.css(asset.src, asset.integrity, staticURI) }} + {% endfor %} + {% endif %} + + {% if enableCustomCSS %} + {# Custom CSS is included after the CSS block so that its overrides will apply #} + {% include "partials/customCSS.html" %} + {% endif %} + {% endblock %} +{% endif %} + +{% block html %} +
+{% endblock %} + +{# Include all the scripts from the entrypoint #} +{% if entrypoint.js %} + {% block js %} + {% for asset in entrypoint.js %} + {{ macros.js(asset.src, asset.integrity, staticURI) }} + {% endfor %} + {% endblock %} +{% endif %} diff --git a/src/core/server/config.ts b/src/core/server/config.ts index 94affb87e..6def27bc1 100644 --- a/src/core/server/config.ts +++ b/src/core/server/config.ts @@ -196,6 +196,14 @@ const config = convict({ env: "DISABLE_CLIENT_ROUTES", arg: "disableClientRoutes", }, + disable_rate_limiters: { + doc: + "Disables the rate limiters in development. This will only work when also set to a development environment", + format: Boolean, + default: false, + env: "DISABLE_RATE_LIMITERS", + arg: "disableRateLimiters", + }, }); export type Config = typeof config; diff --git a/src/core/server/errors/index.ts b/src/core/server/errors/index.ts index 968a9e4a9..dfab602b9 100644 --- a/src/core/server/errors/index.ts +++ b/src/core/server/errors/index.ts @@ -467,6 +467,9 @@ export class InternalDevelopmentError extends CoralError { extensions.message = cause.message; } + // Prefix this error message. + extensions.message = "InternalDevelopmentError: " + extensions.message; + return extensions; } } @@ -612,6 +615,17 @@ export class ConfirmEmailTokenExpired extends CoralError { } } +export class InviteTokenExpired extends CoralError { + constructor(reason: string, cause?: Error) { + super({ + code: ERROR_CODES.INVITE_TOKEN_EXPIRED, + cause, + status: 400, + context: { pvt: { reason } }, + }); + } +} + export class RateLimitExceeded extends CoralError { constructor(resource: string, max: number, tries: number) { super({ @@ -621,3 +635,11 @@ export class RateLimitExceeded extends CoralError { }); } } + +export class InviteRequiresEmailAddresses extends CoralError { + constructor() { + super({ + code: ERROR_CODES.INVITE_REQUIRES_EMAIL_ADDRESSES, + }); + } +} diff --git a/src/core/server/errors/translations.ts b/src/core/server/errors/translations.ts index 9a30e45f2..4fd6a13ee 100644 --- a/src/core/server/errors/translations.ts +++ b/src/core/server/errors/translations.ts @@ -46,4 +46,6 @@ export const ERROR_TRANSLATIONS: Record = { EMAIL_CONFIRM_TOKEN_EXPIRED: "error-emailConfirmTokenExpired", RATE_LIMIT_EXCEEDED: "error-rateLimitExceeded", JWT_REVOKED: "error-jwtRevoked", + INVITE_TOKEN_EXPIRED: "error-inviteTokenExpired", + INVITE_REQUIRES_EMAIL_ADDRESSES: "error-inviteRequiresEmailAddresses", }; diff --git a/src/core/server/graph/tenant/mutators/Users.ts b/src/core/server/graph/tenant/mutators/Users.ts index 0dc392e91..715e0081b 100644 --- a/src/core/server/graph/tenant/mutators/Users.ts +++ b/src/core/server/graph/tenant/mutators/Users.ts @@ -20,12 +20,14 @@ import { updateRole, updateUsername, } from "coral-server/services/users"; +import { invite } from "coral-server/services/users/auth/invite"; import { GQLBanUserInput, GQLCreateTokenInput, GQLDeactivateTokenInput, GQLIgnoreUserInput, + GQLInviteUsersInput, GQLRemoveUserBanInput, GQLRemoveUserIgnoreInput, GQLRemoveUserSuspensionInput, @@ -41,6 +43,25 @@ import { } from "../schema/__generated__/types"; export const Users = (ctx: TenantContext) => ({ + invite: async ({ role, emails }: GQLInviteUsersInput) => + mapFieldsetToErrorCodes( + invite( + ctx.mongo, + ctx.tenant, + ctx.config, + ctx.mailerQueue, + ctx.signingConfig!, + { role, emails }, + ctx.user!, + ctx.now + ), + { + "input.emails": [ + ERROR_CODES.EMAIL_INVALID_FORMAT, + ERROR_CODES.EMAIL_EXCEEDS_MAX_LENGTH, + ], + } + ), setUsername: async ( input: GQLSetUsernameInput ): Promise | null> => diff --git a/src/core/server/graph/tenant/resolvers/Invite.ts b/src/core/server/graph/tenant/resolvers/Invite.ts new file mode 100644 index 000000000..bf03813c9 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/Invite.ts @@ -0,0 +1,7 @@ +import { GQLInviteTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; +import * as invite from "coral-server/models/invite"; + +export const Invite: GQLInviteTypeResolver = { + createdBy: ({ createdBy }, args, ctx) => + ctx.loaders.Users.user.load(createdBy), +}; diff --git a/src/core/server/graph/tenant/resolvers/Mutation.ts b/src/core/server/graph/tenant/resolvers/Mutation.ts index 2ef9025f1..82cad12a0 100644 --- a/src/core/server/graph/tenant/resolvers/Mutation.ts +++ b/src/core/server/graph/tenant/resolvers/Mutation.ts @@ -107,6 +107,10 @@ export const Mutation: Required> = { comment: await ctx.mutators.Actions.rejectComment(input), clientMutationId: input.clientMutationId, }), + inviteUsers: async (source, { input }, ctx) => ({ + invites: await ctx.mutators.Users.invite(input), + clientMutationId: input.clientMutationId, + }), setUsername: async (source, { input }, ctx) => ({ user: await ctx.mutators.Users.setUsername(input), clientMutationId: input.clientMutationId, diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index 7ff541270..a69f6d44a 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -16,6 +16,7 @@ import { FacebookAuthIntegration } from "./FacebookAuthIntegration"; import { FeatureCommentPayload } from "./FeatureCommentPayload"; import { Flag } from "./Flag"; import { GoogleAuthIntegration } from "./GoogleAuthIntegration"; +import { Invite } from "./Invite"; import { ModerationQueue } from "./ModerationQueue"; import { ModerationQueues } from "./ModerationQueues"; import { Mutation } from "./Mutation"; @@ -48,6 +49,7 @@ const Resolvers: GQLResolver = { FeatureCommentPayload, Flag, GoogleAuthIntegration, + Invite, ModerationQueue, ModerationQueues, Mutation, diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 087999c94..c71c97d1c 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -881,7 +881,7 @@ type Email { """ smtpURI is the SMTP connection url to send emails on. """ - smtpURI: String + smtpURI: String @auth(roles: [ADMIN]) """ fromAddress is the email address that will be used to send emails from. @@ -1137,7 +1137,7 @@ type Settings { """ email is the set of credentials and settings associated with the organization. """ - email: Email! @auth(roles: [ADMIN]) + email: Email! @auth(roles: [ADMIN, MODERATOR]) """ wordList will return a given list of words. @@ -1228,6 +1228,39 @@ type Token { createdAt: Time! } +""" +Invite represents a given User that is pending registration that has been +invited by an Administrator. +""" +type Invite { + """ + id is the identifier for the Invite. + """ + id: ID! + + """ + email is the email address that will be assigned and used for the + invited User. + """ + email: String! + + """ + role is the USER_ROLE that the User will be assigned upon + account creation. + """ + role: USER_ROLE! + + """ + createdBy is the User that created the Invite. + """ + createdBy: User! + + """ + createdAt is the time that the Invite was created on. + """ + createdAt: Time! +} + """ BanStatusHistory is the list of all ban events against a specific User. """ @@ -3748,6 +3781,38 @@ type SetUsernamePayload { """ clientMutationId: String! } +################## +# inviteUser +################## + +input InviteUsersInput { + """ + emails is the email addresses of the Users to be invited. + """ + emails: [String!]! + + """ + role is the designated role of the User being invited. + """ + role: USER_ROLE! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type InviteUsersPayload { + """ + invites is the references to the invited Users. + """ + invites: [Invite]! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} ################## # setEmail @@ -4362,6 +4427,13 @@ type Mutation { rejectComment(input: RejectCommentInput!): RejectCommentPayload! @auth(roles: [MODERATOR, ADMIN]) + """ + inviteUsers will send emails to the users with a new account at the designated + role. + """ + inviteUsers(input: InviteUsersInput!): InviteUsersPayload! + @auth(roles: [ADMIN]) + """ setUsername will set the username on the current User if they have not set one before. This mutation will fail if the username is already set. diff --git a/src/core/server/locales/en-US/email.ftl b/src/core/server/locales/en-US/email.ftl index 57aef36ff..9aa835461 100644 --- a/src/core/server/locales/en-US/email.ftl +++ b/src/core/server/locales/en-US/email.ftl @@ -45,3 +45,9 @@ email-notification-template-confirmEmail = { $organizationName }, you can safely ignore this email. email-subject-confirmEmail = Confirm Email + +email-subject-invite = Coral Team invite + +email-notification-template-invite = + You have been invited to join the { $organizationName } team on Coral. Finish + setting up your account here. diff --git a/src/core/server/locales/en-US/errors.ftl b/src/core/server/locales/en-US/errors.ftl index fc3ccecf2..b02ca36f0 100644 --- a/src/core/server/locales/en-US/errors.ftl +++ b/src/core/server/locales/en-US/errors.ftl @@ -51,3 +51,5 @@ error-integrationDisabled = Specified integration is disabled. error-passwordResetTokenExpired = Password reset link expired. error-emailConfirmTokenExpired = Email confirmation link expired. error-rateLimitExceeded = Rate limit exceeded. +error-inviteTokenExpired = Invite link has expired. +error-inviteRequiresEmailAddresses = Please add an email address to send invitations. diff --git a/src/core/server/models/invite.ts b/src/core/server/models/invite.ts new file mode 100644 index 000000000..89222f28c --- /dev/null +++ b/src/core/server/models/invite.ts @@ -0,0 +1,109 @@ +import { Db } from "mongodb"; +import uuid from "uuid"; + +import { Omit, Sub } from "coral-common/types"; +import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types"; +import { createIndexFactory } from "coral-server/models/helpers/indexing"; +import { TenantResource } from "coral-server/models/tenant"; + +function collection(mongo: Db) { + return mongo.collection>("invites"); +} + +export async function createInviteIndexes(mongo: Db) { + const createIndex = createIndexFactory(collection(mongo)); + + // UNIQUE { id } + await createIndex({ tenantID: 1, id: 1 }, { unique: true }); + + // UNIQUE { email } + await createIndex({ tenantID: 1, email: 1 }, { unique: true }); +} + +export interface Invite extends TenantResource { + readonly id: string; + email: string; + role: GQLUSER_ROLE; + expiresAt: Date; + createdBy: string; + createdAt: Date; +} + +export type CreateInviteInput = Omit< + Invite, + "id" | "createdAt" | "tenantID" | "createdBy" +>; + +export async function createInvite( + mongo: Db, + tenantID: string, + { email, ...input }: CreateInviteInput, + createdBy: string, + now = new Date() +) { + // Create an ID for the Invite. + const id = uuid.v4(); + + // defaults are the properties set by the application when a new Invite is + // created. + const defaults: Sub = { + id, + tenantID, + createdBy, + createdAt: now, + }; + + // Merge the defaults and the input together. + const invite: Readonly = { + ...defaults, + ...input, + email: email.toLowerCase(), + }; + + // Insert it into the database. This may throw an error. + await collection(mongo).insert(invite); + + return invite; +} + +export async function redeemInvite(mongo: Db, tenantID: string, id: string) { + // Try to snag the invite from the database safely. + const result = await collection(mongo).findOneAndDelete({ id, tenantID }, {}); + if (!result.value) { + throw new Error("an unexpected error occurred"); + } + + return result.value; +} + +export async function redeemInviteFromEmail( + mongo: Db, + tenantID: string, + email: string +) { + // Try to snag the invite from the database safely. + const result = await collection(mongo).findOneAndDelete( + { email, tenantID }, + {} + ); + + return result.value || null; +} + +export async function retrieveInviteFromEmail( + mongo: Db, + tenantID: string, + email: string +) { + return collection(mongo).findOne({ + tenantID, + email: email.toLowerCase(), + }); +} + +export async function retrieveInvite(mongo: Db, tenantID: string, id: string) { + return collection(mongo).findOne({ + tenantID, + id, + }); +} diff --git a/src/core/server/models/user/index.ts b/src/core/server/models/user/index.ts index 0622f9b1e..f910cc12f 100644 --- a/src/core/server/models/user/index.ts +++ b/src/core/server/models/user/index.ts @@ -363,7 +363,13 @@ function hashPassword(password: string): Promise { export type InsertUserInput = Omit< User, - "id" | "tenantID" | "tokens" | "status" | "ignoredUsers" | "createdAt" + | "id" + | "tenantID" + | "tokens" + | "status" + | "ignoredUsers" + | "emailVerificationID" + | "createdAt" >; export async function insertUser( @@ -472,6 +478,24 @@ export async function retrieveUserWithProfile( }); } +export async function retrieveUserWithEmail( + mongo: Db, + tenantID: string, + email: string +) { + return collection(mongo).findOne({ + tenantID, + $or: [ + { + profiles: { + $elemMatch: { id: email, type: "local" }, + }, + }, + { email }, + ], + }); +} + /** * updateUserRole updates a given User's role. * diff --git a/src/core/server/queue/tasks/mailer/templates/index.ts b/src/core/server/queue/tasks/mailer/templates/index.ts index f797beb55..aba4c7dd0 100644 --- a/src/core/server/queue/tasks/mailer/templates/index.ts +++ b/src/core/server/queue/tasks/mailer/templates/index.ts @@ -53,11 +53,19 @@ export type ConfirmEmailTemplate = UserNotificationContext< } >; +export type InviteEmailTemplate = UserNotificationContext< + "invite", + { + inviteURL: string; + } +>; + type Templates = - | ForgotPasswordTemplate | BanTemplate - | SuspendTemplate + | ConfirmEmailTemplate + | ForgotPasswordTemplate + | InviteEmailTemplate | PasswordChangeTemplate - | ConfirmEmailTemplate; + | SuspendTemplate; export { Templates as Template }; diff --git a/src/core/server/queue/tasks/mailer/templates/invite.html b/src/core/server/queue/tasks/mailer/templates/invite.html new file mode 100644 index 000000000..fc035316a --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/invite.html @@ -0,0 +1,6 @@ +{% extends "layouts/user-notification.html" %} + +{% block content %} + You have been invited to join the {{ context.organizationName }} team on Coral. Finish + setting up your account here. +{% endblock %} diff --git a/src/core/server/services/mongodb/indexes.ts b/src/core/server/services/mongodb/indexes.ts index 74fe290b3..6aa4908f8 100644 --- a/src/core/server/services/mongodb/indexes.ts +++ b/src/core/server/services/mongodb/indexes.ts @@ -4,6 +4,7 @@ import logger from "coral-server/logger"; import { createCommentActionIndexes } from "coral-server/models/action/comment"; import { createCommentModerationActionIndexes } from "coral-server/models/action/moderation/comment"; import { createCommentIndexes } from "coral-server/models/comment"; +import { createInviteIndexes } from "coral-server/models/invite"; import { createStoryCountIndexes, createStoryIndexes, @@ -15,6 +16,7 @@ type IndexCreationFunction = (mongo: Db) => Promise; const indexes: Array<[string, IndexCreationFunction]> = [ ["users", createUserIndexes], + ["invites", createInviteIndexes], ["tenants", createTenantIndexes], ["comments", createCommentIndexes], ["stories", createStoryIndexes], diff --git a/src/core/server/services/users/auth/confirm.ts b/src/core/server/services/users/auth/confirm.ts index 725664bd1..b6d4b0695 100644 --- a/src/core/server/services/users/auth/confirm.ts +++ b/src/core/server/services/users/auth/confirm.ts @@ -138,7 +138,7 @@ export async function verifyConfirmTokenString( } // Unpack some of the token. - const { sub: userID, email, evid: emailVerificationID } = token; + const { sub: userID, email, evid: emailVerificationID, iss } = token; // TODO: (wyattjoh) verify that the token has not been revoked. @@ -148,6 +148,10 @@ export async function verifyConfirmTokenString( throw new UserNotFoundError(userID); } + if (iss !== tenant.id) { + throw new TokenInvalidError(tokenString, "invalid tenant"); + } + // Check to see if the email address being verified matches the one that's // been provided. if (user.email !== email) { diff --git a/src/core/server/services/users/auth/invite.ts b/src/core/server/services/users/auth/invite.ts new file mode 100644 index 000000000..f5f66d780 --- /dev/null +++ b/src/core/server/services/users/auth/invite.ts @@ -0,0 +1,326 @@ +import Joi from "joi"; +import { isNull, uniq } from "lodash"; +import { DateTime } from "luxon"; +import { Db } from "mongodb"; +import uuid from "uuid"; + +import { Config } from "coral-server/config"; +import { + createInvite, + Invite, + redeemInvite, + redeemInviteFromEmail, + retrieveInvite, +} from "coral-server/models/invite"; +import { Tenant } from "coral-server/models/tenant"; +import { + insertUser, + LocalProfile, + retrieveUserWithEmail, + User, +} from "coral-server/models/user"; +import { MailerQueue } from "coral-server/queue/tasks/mailer"; +import { + JWTSigningConfig, + signString, + StandardClaims, + StandardClaimsSchema, + verifyJWT, +} from "coral-server/services/jwt"; + +import { constructTenantURL } from "coral-server/app/url"; +import { + IntegrationDisabled, + InviteRequiresEmailAddresses, + InviteTokenExpired, + TokenInvalidError, +} from "coral-server/errors"; +import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types"; +import { validateEmail, validatePassword, validateUsername } from "../helpers"; + +export interface InviteToken extends Required { + // aud specifies `invite` as the audience to indicate that this is an invite + // token. + aud: "invite"; + + /** + * email is the email address being confirmed. + */ + email: string; +} + +const InviteTokenSchema = StandardClaimsSchema.keys({ + aud: Joi.string().only("invite"), + email: Joi.string().email(), +}); + +export function validateInviteToken(token: InviteToken | object): Error | null { + const { error } = Joi.validate(token, InviteTokenSchema, { + presence: "required", + }); + return error || null; +} + +export function isInviteToken( + token: InviteToken | object +): token is InviteToken { + return isNull(validateInviteToken(token)); +} + +export async function generateInviteURL( + tenant: Tenant, + config: Config, + signingConfig: JWTSigningConfig, + user: Required>, + now: Date +) { + // Pull some stuff out of the user. + const { id } = user; + + // Change the JS Date to a DateTime for ease of use. + const nowDate = DateTime.fromJSDate(now); + const nowSeconds = Math.round(nowDate.toSeconds()); + + // Generate a token. + const inviteToken: InviteToken = { + jti: uuid.v4(), + iss: tenant.id, + sub: id, + exp: Math.floor(user.expiresAt.valueOf() / 1000), + iat: nowSeconds, + nbf: nowSeconds, + aud: "invite", + email: user.email, + }; + + // Sign it with the signing config. + const token = await signString(signingConfig, inviteToken); + + // Generate the invite url. + return constructTenantURL( + config, + tenant, + `/admin/invite#inviteToken=${token}` + ); +} + +export async function verifyInviteTokenString( + mongo: Db, + tenant: Tenant, + signingConfig: JWTSigningConfig, + tokenString: string, + now: Date +) { + const token = verifyJWT(tokenString, signingConfig, now, { + // Verify that the token is for this Tenant. + issuer: tenant.id, + // Verify that this is a confirm token based on the audience. + audience: "invite", + }); + + // Validate that this is indeed a reset token. + if (!isInviteToken(token)) { + // TODO: (wyattjoh) look into a way of pulling the error into this one + throw new TokenInvalidError( + tokenString, + "does not conform to the invite token schema" + ); + } + + // Unpack some of the token. + const { sub: inviteID, iss } = token; + + if (iss !== tenant.id) { + throw new TokenInvalidError(tokenString, "invalid tenant"); + } + + // Verify that the invite is still valid. + const inv = await retrieveInvite(mongo, tenant.id, inviteID); + if (!inv) { + throw new InviteTokenExpired("invite not found"); + } + + // Now that we've verified that the token is valid, we're good to go! + return token; +} + +export interface InviteUser { + emails: string[]; + role: GQLUSER_ROLE; +} + +export async function invite( + mongo: Db, + tenant: Tenant, + config: Config, + mailerQueue: MailerQueue, + signingConfig: JWTSigningConfig, + { role, ...input }: InviteUser, + invitingUser: User, + now = new Date() +) { + if ( + !tenant.auth.integrations.local.enabled || + !tenant.auth.integrations.local.allowRegistration || + !tenant.auth.integrations.local.targetFilter.admin + ) { + // TODO: (wyattjoh) investigate throwing a different error for when the target filter is turned off for admin + throw new IntegrationDisabled("local"); + } + + // Validate all the email addresses before we start. + const emails = input.emails.map((email, idx) => { + // Validate the user payload. + validateEmail(email); + + // Ensure the email address is lowercase. + return email.toLowerCase(); + }); + + if (emails.length === 0) { + throw new InviteRequiresEmailAddresses(); + } + + // Change the JS Date to a DateTime for ease of use. + const nowDate = DateTime.fromJSDate(now); + + // The expiry of this token is linked as 1 week after issuance. + const expiresAt = nowDate.plus({ weeks: 1 }).toJSDate(); + + const payloads: Array<{ + email: string; + inviteURL?: string; + invitedNow?: Invite; + }> = []; + for (const email of uniq(emails)) { + // Check to see if the user with the specified email already has an account. + const userAlready = await retrieveUserWithEmail(mongo, tenant.id, email); + if (userAlready) { + payloads.push({ email }); + continue; + } + + // Check to see that the user has not been invited before, if they have, + // redeem it and create a new one. + await redeemInviteFromEmail(mongo, tenant.id, email); + + // Create the User invite record. + const invitedNow = await createInvite( + mongo, + tenant.id, + { + role, + email, + expiresAt, + }, + invitingUser.id, + now + ); + + // Generate the invite URL. + const inviteURL = await generateInviteURL( + tenant, + config, + signingConfig, + invitedNow, + now + ); + + payloads.push({ email, inviteURL, invitedNow }); + } + + for (const { email, inviteURL } of payloads) { + if (!inviteURL) { + // There was no associated inviteURL generated for this user, do not send + // anything. + continue; + } + + // Send the invited user an email with the invite token. + await mailerQueue.add({ + template: { + name: "invite", + context: { + organizationName: tenant.organization.name, + organizationURL: tenant.organization.url, + inviteURL, + }, + }, + tenantID: tenant.id, + message: { + to: email, + }, + }); + } + + return emails.map(email => { + const result = payloads.find(payload => payload.email === email); + if (!result) { + return null; + } + + return result.invitedNow || null; + }); +} + +export interface RedeemInvite { + username: string; + password: string; +} + +export async function redeem( + mongo: Db, + tenant: Tenant, + signingConfig: JWTSigningConfig, + tokenString: string, + { username, password }: RedeemInvite, + now: Date +) { + if ( + !tenant.auth.integrations.local.enabled || + !tenant.auth.integrations.local.allowRegistration || + !tenant.auth.integrations.local.targetFilter.admin + ) { + // TODO: (wyattjoh) investigate throwing a different error for when the target filter is turned off for admin + throw new IntegrationDisabled("local"); + } + + // Verify the local user data. + validateUsername(username); + validatePassword(password); + + // Verify that the token is valid. + const { sub: inviteID } = await verifyInviteTokenString( + mongo, + tenant, + signingConfig, + tokenString, + now + ); + + // Redeem the invite from the database. + const { role, email } = await redeemInvite(mongo, tenant.id, inviteID); + + // Configure the login profile. + const profile: LocalProfile = { + id: email, + type: "local", + password, + }; + + // Create the new user based on the invite. + const user = await insertUser( + mongo, + tenant.id, + { + username, + email, + emailVerified: true, // Verified because the invite link was clicked. + profiles: [profile], + role, + }, + now + ); + + return user; +} diff --git a/src/locales/en-US/account.ftl b/src/locales/en-US/account.ftl index 2f6ff16ca..7843e54ed 100644 --- a/src/locales/en-US/account.ftl +++ b/src/locales/en-US/account.ftl @@ -1,5 +1,8 @@ ### Localization for Account +account-tokenNotFound = + The specified link is invalid, check to see if it was copied correctly. + ## Password Reset resetPassword-resetPassword = Reset Password @@ -27,3 +30,4 @@ confirmEmail-missingConfirmToken = The Confirm Token seems to be missing. confirmEmail-successfullyConfirmed = Email successfully confirmed confirmEmail-youMayClose = You may now close this window. + diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index c0b1b7fd0..0a005c43a 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -144,7 +144,7 @@ configure-general-closedStreamMessage-explanation = Write a message to appear af ### Organization configure-organization-name = Organization Name configure-organization-nameExplanation = - Your organization name will appear on emails sent by Coral to your community and organization members. + Your organization name will appear on emails sent by { -product-name } to your community and organization members. configure-organization-email = Organization Email configure-organization-emailExplanation = This email address will be used as in emails and across @@ -158,7 +158,7 @@ configure-auth-authIntegrations = Authentication Integrations configure-auth-clientID = Client ID configure-auth-clientSecret = Client Secret configure-auth-configBoxEnabled = Enabled -configure-auth-targetFilterCoralAdmin = Coral Admin +configure-auth-targetFilterCoralAdmin = { -product-name } Admin configure-auth-targetFilterCommentStream = Comment Stream configure-auth-redirectURI = Redirect URI configure-auth-registration = Registration @@ -167,7 +167,7 @@ configure-auth-registrationDescription = integration to register for a new account. configure-auth-registrationCheckBox = Allow Registration configure-auth-pleaseEnableAuthForAdmin = - Please enable at least one authentication integration for Coral Admin + Please enable at least one authentication integration for { -product-name } Admin configure-auth-confirmNoAuthForCommentStream = No authentication integration has been enabled for the Comment Stream. Do you really want to continue? @@ -207,7 +207,7 @@ configure-auth-oidc-providerNameDescription = needs to be displayed, e.g. “Log in with <Facebook>”. configure-auth-oidc-issuer = Issuer configure-auth-oidc-issuerDescription = - After entering your Issuer information, click the Discover button to have Coral complete + After entering your Issuer information, click the Discover button to have { -product-name } complete the remaining fields. You may also enter the information manually. configure-auth-oidc-authorizationURL = Authorization URL configure-auth-oidc-tokenURL = Token URL @@ -288,7 +288,7 @@ configure-advanced-customCSS-explanation = URL of a CSS stylesheet that will override default Embed Stream styles. Can be internal or external. configure-advanced-permittedDomains = Permitted Domains configure-advanced-permittedDomains-explanation = - Domains where your Coral instance is allowed to be embedded. + Domains where your { -product-name } instance is allowed to be embedded. Typical use is localhost, staging.yourdomain.com, yourdomain.com, etc. @@ -469,6 +469,31 @@ community-banModal-consequence = community-banModal-cancel = Cancel community-banModal-banUser = Ban User +community-invite-inviteMember = Invite members to your organization +community-invite-emailAddressLabel = Email address: +community-invite-inviteMore = Invite more +community-invite-inviteAsLabel = Invite as: +community-invite-sendInvitations = Send invitations +community-invite-role-staff = + Staff role: Receives a “Staff” badge, and + comments are automatically approved. Cannot moderate + or change any { -product-name } configuration. +community-invite-role-moderator = + Moderator role: Moderator role: Receives a + “Staff” badge, and comments are automatically + approved. Has full moderation privileges (approve, + reject and feature comments). Can configure individual + articles but no site-wide configuration privileges. +community-invite-role-admin = + Admin role: Receives a “Staff” badge, and + comments are automatically approved. Has full + moderation privileges (approve, reject and feature + comments). Can configure individual articles and has + site-wide configuration privileges. +community-invite-invitationsSent = Your invitations have been sent! +community-invite-close = Close +community-invite-invite = Invite + ## Stories stories-emptyMessage = There are currently no published stories. stories-noMatchMessage = We could not find any stories matching your criteria. @@ -499,3 +524,24 @@ stories-column-clickToModerate = Click title to moderate story stories-status-popover = .description = A dropdown to change the story status + +## Invite + +invite-youHaveBeenInvited = You've been invited to join { $organizationName } +invite-finishSettingUpAccount = Finish setting up the account for: +invite-createAccount = Create Account +invite-passwordLabel = Password +invite-passwordDescription = Must be at least { $minLength } characters +invite-passwordTextField = + .placeholder = Password +invite-usernameLabel = Username +invite-usernameDescription = You may use “_” and “.” +invite-usernameTextField = + .placeholder = Username +invite-oopsSorry = Oops Sorry! +invite-successful = Your account has been created +invite-youMayNowSignIn = You may now sign-in to { -product-name } using: +invite-goToAdmin = Go to { -product-name } Admin +invite-goToOrganization = Go to { $organizationName } +invite-tokenNotFound = + The specified link is invalid, check to see if it was copied correctly. diff --git a/src/locales/en-US/install.ftl b/src/locales/en-US/install.ftl index 48111cef2..6e23b391c 100644 --- a/src/locales/en-US/install.ftl +++ b/src/locales/en-US/install.ftl @@ -3,9 +3,9 @@ install-backButton-back = Back install-nextButton-next = Next install-permittedDomains-finishInstall = Finish Install -install-header-title = Coral Installation Wizard +install-header-title = { -product-name } Installation Wizard -install-initialStep-copy = The remainder of the Coral installation will take about ten minutes. Once you complete the following three steps, you will have a free installation and provision Mongo and Redis. +install-initialStep-copy = The remainder of the { -product-name } installation will take about ten minutes. Once you complete the following three steps, you will have a free installation and provision Mongo and Redis. install-initialStep-getStarted = Get Started install-addOrganization-stepTitle = Add Organization Details @@ -41,12 +41,12 @@ install-createYourAccount-confirmPasswordTextField = install-permittedDomains-stepTitle = Add Permitted Domains install-permittedDomains-title = Permitted Domains -install-permittedDomains-description = Enter the domains you would like to permit for Coral, e.g. your local, staging and production environments (ex. localhost:3000, staging.domain.com, domain.com). +install-permittedDomains-description = Enter the domains you would like to permit for { -product-name }, e.g. your local, staging and production environments (ex. localhost:3000, staging.domain.com, domain.com). install-permittedDomains-permittedDomains = Permitted Domains install-permittedDomains-permittedDomainsTextField = .placeholder = Domains install-permittedDomains-permittedDomainsDescription = Insert domains separated by comma -install-finalStep-description = Thanks for installing Coral! We sent an email to verify your email address. While you finish setting up the account, you can start engaging with your readers now. +install-finalStep-description = Thanks for installing { -product-name }! We sent an email to verify your email address. While you finish setting up the account, you can start engaging with your readers now. install-finalStep-goToTheDocs = Go to the Docs install-finalStep-goToAdmin = Go to Admin