mirror of
https://github.com/wassname/talk.git
synced 2026-08-13 12:40:11 +08:00
[CORL-183] Invite Users (#2349)
* feat: initial UI impl * feat: attach react devtools hook in development * feat: working mutations * feat: polished the invite modal with mutation Co-authored-by: Vinh <vinh@wikiwi.io> * feat: added check * feat: improve the invite server impl * feat: admin invite interface improvements * fix: update tests * feat: moved invite UI to admin * fix: include email enabled as condition for invite * feat: added admin tests * feat: added tests for invite complete flow * fix: review
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { Delay, Flex, Spinner } from "coral-ui/components";
|
||||
|
||||
const Loading: FunctionComponent = () => (
|
||||
<Flex justifyContent="center">
|
||||
<Delay>
|
||||
<Spinner />
|
||||
</Delay>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export default Loading;
|
||||
@@ -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<void>("/account/confirm", {
|
||||
method: "GET",
|
||||
token: variables.token,
|
||||
})
|
||||
);
|
||||
|
||||
export default CheckConfirmTokenFetch;
|
||||
@@ -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<void>("/account/confirm", {
|
||||
method: "GET",
|
||||
token: variables.token,
|
||||
})
|
||||
);
|
||||
|
||||
interface Props {
|
||||
token: string | undefined;
|
||||
}
|
||||
|
||||
const ConfirmRoute: React.FunctionComponent<Props> = ({ token }) => {
|
||||
const [suceeded, setSuceeded] = useState<boolean>(false);
|
||||
const [finished, setFinished] = useState(false);
|
||||
const onSuccess = useCallback(() => {
|
||||
setSuceeded(true);
|
||||
setFinished(true);
|
||||
}, []);
|
||||
return (
|
||||
<ConfirmTokenChecker token={token}>
|
||||
{!suceeded && <ConfirmForm token={token!} onSuccess={onSuccess} />}
|
||||
{suceeded && <Success />}
|
||||
</ConfirmTokenChecker>
|
||||
const [state, error] = useToken(fetcher, token);
|
||||
|
||||
if (state === "UNCHECKED") {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (state !== "VALID" || error) {
|
||||
return <Sorry reason={error} />;
|
||||
}
|
||||
|
||||
return !finished ? (
|
||||
<ConfirmForm token={token!} onSuccess={onSuccess} />
|
||||
) : (
|
||||
<Success />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Props> = ({
|
||||
token,
|
||||
children,
|
||||
}) => {
|
||||
const checkConfirmToken = useFetch(CheckConfirmTokenFetch);
|
||||
const [tokenState, setTokenState] = useState<TokenState>("UNCHECKED");
|
||||
const [reason, setReason] = useState<string>("");
|
||||
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 (
|
||||
<Flex justifyContent="center">
|
||||
<Delay>
|
||||
<Spinner />
|
||||
</Delay>
|
||||
</Flex>
|
||||
);
|
||||
case "MISSING":
|
||||
return (
|
||||
<Sorry
|
||||
reason={
|
||||
<Localized id="confirmEmail-missingConfirmToken">
|
||||
<span>The Confirm Token seems to be missing.</span>
|
||||
</Localized>
|
||||
}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Sorry reason={reason} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default ConfirmTokenChecker;
|
||||
@@ -14,7 +14,16 @@ const Sorry: React.FunctionComponent<Props> = ({ reason }) => {
|
||||
<Typography variant="heading1">Oops Sorry!</Typography>
|
||||
</Localized>
|
||||
<CallOut color="error" fullWidth>
|
||||
{reason}
|
||||
{reason ? (
|
||||
reason
|
||||
) : (
|
||||
<Localized id="account-tokenNotFound">
|
||||
<span data-testid="invalid-link">
|
||||
The specified link is invalid, check to see if it was copied
|
||||
correctly.
|
||||
</span>
|
||||
</Localized>
|
||||
)}
|
||||
</CallOut>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
|
||||
@@ -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<void>("/auth/local/forgot", {
|
||||
method: "GET",
|
||||
token: variables.token,
|
||||
})
|
||||
);
|
||||
|
||||
export default CheckResetTokenFetch;
|
||||
@@ -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<void>("/auth/local/forgot", {
|
||||
method: "GET",
|
||||
token: variables.token,
|
||||
})
|
||||
);
|
||||
|
||||
interface Props {
|
||||
token: string | undefined;
|
||||
}
|
||||
|
||||
const ResetRoute: React.FunctionComponent<Props> = ({ token }) => {
|
||||
const [suceeded, setSuceeded] = useState<boolean>(false);
|
||||
const [finished, setFinished] = useState(false);
|
||||
const onSuccess = useCallback(() => {
|
||||
setSuceeded(true);
|
||||
setFinished(true);
|
||||
}, []);
|
||||
return (
|
||||
<ResetTokenChecker token={token}>
|
||||
{!suceeded && <ResetPasswordForm token={token!} onSuccess={onSuccess} />}
|
||||
{suceeded && <Success />}
|
||||
</ResetTokenChecker>
|
||||
const [state, error] = useToken(fetcher, token);
|
||||
|
||||
if (state === "UNCHECKED") {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (state !== "VALID" || error) {
|
||||
return <Sorry reason={error} />;
|
||||
}
|
||||
|
||||
return !finished ? (
|
||||
<ResetPasswordForm token={token!} onSuccess={onSuccess} />
|
||||
) : (
|
||||
<Success />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Props> = ({
|
||||
token,
|
||||
children,
|
||||
}) => {
|
||||
const checkResetToken = useFetch(CheckResetTokenFetch);
|
||||
const [tokenState, setTokenState] = useState<TokenState>("UNCHECKED");
|
||||
const [reason, setReason] = useState<string>("");
|
||||
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 (
|
||||
<Flex justifyContent="center">
|
||||
<Delay>
|
||||
<Spinner />
|
||||
</Delay>
|
||||
</Flex>
|
||||
);
|
||||
case "MISSING":
|
||||
return (
|
||||
<Sorry
|
||||
reason={
|
||||
<Localized id="resetPassword-missingResetToken">
|
||||
<span>The Reset Token seems to be missing.</span>
|
||||
</Localized>
|
||||
}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Sorry reason={reason} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default ResetTokenChecker;
|
||||
@@ -14,7 +14,16 @@ const Sorry: React.FunctionComponent<Props> = ({ reason }) => {
|
||||
<Typography variant="heading1">Oops Sorry!</Typography>
|
||||
</Localized>
|
||||
<CallOut color="error" fullWidth>
|
||||
{reason}
|
||||
{reason ? (
|
||||
reason
|
||||
) : (
|
||||
<Localized id="account-tokenNotFound">
|
||||
<span data-testid="invalid-link">
|
||||
The specified link is invalid, check to see if it was copied
|
||||
correctly.
|
||||
</span>
|
||||
</Localized>
|
||||
)}
|
||||
</CallOut>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
|
||||
@@ -77,8 +77,10 @@ exports[`renders missing confirm token 1`] = `
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
>
|
||||
<div>
|
||||
<span>
|
||||
The Confirm Token seems to be missing.
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
>
|
||||
The specified link is invalid, check to see if it was copied correctly.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,8 +124,10 @@ exports[`renders missing reset token 1`] = `
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
>
|
||||
<div>
|
||||
<span>
|
||||
The Reset Token seems to be missing.
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
>
|
||||
The specified link is invalid, check to see if it was copied correctly.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="invite" {...InviteRoute.routeConfig} />
|
||||
<Route path="login" {...LoginRoute.routeConfig} />
|
||||
</Route>
|
||||
);
|
||||
|
||||
@@ -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<Props> = ({ index, disabled }) => (
|
||||
<FieldSet>
|
||||
<Field name={`emails.${index}`} validate={validateEmail}>
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="community-invite-emailAddressLabel">
|
||||
<InputLabel
|
||||
container="legend"
|
||||
variant="bodyCopyBold"
|
||||
htmlFor={input.name}
|
||||
>
|
||||
Email Address:
|
||||
</InputLabel>
|
||||
</Localized>
|
||||
<TextField
|
||||
data-testid={`invite-users-email.${index}`}
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
color={
|
||||
meta.touched && (meta.error || meta.submitError)
|
||||
? "error"
|
||||
: "regular"
|
||||
}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
/>
|
||||
{meta.touched && (meta.error || meta.submitError) && (
|
||||
<ValidationMessage fullWidth>
|
||||
{meta.error || meta.submitError}
|
||||
</ValidationMessage>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
</FieldSet>
|
||||
);
|
||||
|
||||
export default EmailField;
|
||||
@@ -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 (
|
||||
<div data-testid="invite-users">
|
||||
<Localized id="community-invite-invite">
|
||||
<Button
|
||||
variant="filled"
|
||||
color="primary"
|
||||
type="button"
|
||||
data-testid="invite-users-button"
|
||||
onClick={show}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
</Localized>
|
||||
<Modal open={open}>
|
||||
{({ firstFocusableRef, lastFocusableRef }) => (
|
||||
<InviteUsersModal
|
||||
onHide={hide}
|
||||
firstFocusableRef={firstFocusableRef}
|
||||
lastFocusableRef={lastFocusableRef}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteUsers;
|
||||
@@ -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<Props> = ({
|
||||
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 <InviteUsers />;
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
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;
|
||||
@@ -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<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
const InviteForm: FunctionComponent<Props> = ({ 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 (
|
||||
<Form onSubmit={onSubmit} initialValues={{ role: GQLUSER_ROLE.STAFF }}>
|
||||
{({ handleSubmit, submitting, submitError }) => (
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={handleSubmit}
|
||||
id="community-invite-form"
|
||||
>
|
||||
<HorizontalGutter spacing={3}>
|
||||
{submitError && (
|
||||
<CallOut color="error" fullWidth>
|
||||
{submitError}
|
||||
</CallOut>
|
||||
)}
|
||||
{Array(emailFieldCount)
|
||||
.fill(0)
|
||||
.map((_, idx) => (
|
||||
<EmailField key={idx} index={idx} disabled={submitting} />
|
||||
))}
|
||||
<Flex justifyContent="center">
|
||||
<Localized id="community-invite-inviteMore">
|
||||
<Button
|
||||
variant="underlined"
|
||||
color="primary"
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setEmailFieldCount(emailFieldCount + 1);
|
||||
}}
|
||||
>
|
||||
Invite more
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
<RoleField disabled={submitting} />
|
||||
<FormSpy subscription={{ values: true }}>
|
||||
{({ values: { role } }) => {
|
||||
switch (role) {
|
||||
case GQLUSER_ROLE.STAFF:
|
||||
return (
|
||||
<Localized
|
||||
id="community-invite-role-staff"
|
||||
strong={<strong />}
|
||||
>
|
||||
<Typography>
|
||||
Staff role: Receives a “Staff” badge, and comments are
|
||||
automatically approved. Cannot moderate or change any
|
||||
Coral configuration.
|
||||
</Typography>
|
||||
</Localized>
|
||||
);
|
||||
case GQLUSER_ROLE.MODERATOR:
|
||||
return (
|
||||
<Localized
|
||||
id="community-invite-role-moderator"
|
||||
strong={<strong />}
|
||||
>
|
||||
<Typography>
|
||||
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.
|
||||
</Typography>
|
||||
</Localized>
|
||||
);
|
||||
case GQLUSER_ROLE.ADMIN:
|
||||
return (
|
||||
<Localized
|
||||
id="community-invite-role-admin"
|
||||
strong={<strong />}
|
||||
>
|
||||
<Typography>
|
||||
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.
|
||||
</Typography>
|
||||
</Localized>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}}
|
||||
</FormSpy>
|
||||
<Flex direction="row" justifyContent="flex-end">
|
||||
<Localized id="community-invite-sendInvitations">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="filled"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
ref={lastRef}
|
||||
>
|
||||
Send invitations
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteForm;
|
||||
@@ -0,0 +1,9 @@
|
||||
.root {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.clearfix:after {
|
||||
content: "";
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
@@ -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<HTMLButtonElement>;
|
||||
lastFocusableRef: React.Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
const InviteUsersModal: FunctionComponent<Props> = ({
|
||||
onHide,
|
||||
firstFocusableRef,
|
||||
lastFocusableRef,
|
||||
}) => {
|
||||
const [finished, setFinished] = useState(false);
|
||||
const finish = useCallback(() => setFinished(true), []);
|
||||
|
||||
return (
|
||||
<Card className={styles.root} data-testid="invite-users-modal">
|
||||
{!finished ? (
|
||||
<div>
|
||||
<Box className={styles.clearfix} marginBottom={3}>
|
||||
<CardCloseButton onClick={onHide} ref={firstFocusableRef} />
|
||||
<Localized id="community-invite-inviteMember">
|
||||
<Typography variant="header2">
|
||||
Invite members to your organization
|
||||
</Typography>
|
||||
</Localized>
|
||||
</Box>
|
||||
<InviteForm onFinish={finish} lastRef={lastFocusableRef} />
|
||||
</div>
|
||||
) : (
|
||||
<Success onClose={onHide} lastFocusableRef={lastFocusableRef} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteUsersModal;
|
||||
@@ -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<InviteUsersMutation>) =>
|
||||
commitMutationPromiseNormalized<InviteUsersMutation>(environment, {
|
||||
mutation: graphql`
|
||||
mutation InviteUsersMutation($input: InviteUsersInput!) {
|
||||
inviteUsers(input: $input) {
|
||||
clientMutationId
|
||||
invites {
|
||||
id
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export default InviteUsersMutation;
|
||||
@@ -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<Props> = ({ disabled }) => (
|
||||
<FieldSet>
|
||||
<Localized id="community-invite-inviteAsLabel">
|
||||
<Typography container="legend" variant="bodyCopyBold">
|
||||
Invite as:
|
||||
</Typography>
|
||||
</Localized>
|
||||
<div>
|
||||
<Field name="role" type="radio" value={GQLUSER_ROLE.STAFF}>
|
||||
{({ input }) => (
|
||||
<Localized id="role-staff">
|
||||
<RadioButton
|
||||
id={`${input.name}-staff`}
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
onFocus={input.onFocus}
|
||||
onBlur={input.onBlur}
|
||||
checked={input.checked}
|
||||
value={input.value}
|
||||
disabled={disabled}
|
||||
>
|
||||
Staff
|
||||
</RadioButton>
|
||||
</Localized>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="role" type="radio" value={GQLUSER_ROLE.MODERATOR}>
|
||||
{({ input }) => (
|
||||
<Localized id="role-moderator">
|
||||
<RadioButton
|
||||
id={`${input.name}-moderator`}
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
onFocus={input.onFocus}
|
||||
onBlur={input.onBlur}
|
||||
checked={input.checked}
|
||||
value={input.value}
|
||||
disabled={disabled}
|
||||
>
|
||||
Moderator
|
||||
</RadioButton>
|
||||
</Localized>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="role" type="radio" value={GQLUSER_ROLE.ADMIN}>
|
||||
{({ input }) => (
|
||||
<Localized id="role-admin">
|
||||
<RadioButton
|
||||
id={`${input.name}-admin`}
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
onFocus={input.onFocus}
|
||||
onBlur={input.onBlur}
|
||||
checked={input.checked}
|
||||
value={input.value}
|
||||
disabled={disabled}
|
||||
>
|
||||
Admin
|
||||
</RadioButton>
|
||||
</Localized>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</FieldSet>
|
||||
);
|
||||
|
||||
export default RoleField;
|
||||
@@ -0,0 +1,3 @@
|
||||
.box {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -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<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
const Success: FunctionComponent<Props> = ({ lastFocusableRef, onClose }) => (
|
||||
<div>
|
||||
<Flex justifyContent="center" direction="column" alignItems="flex-end">
|
||||
<Flex
|
||||
justifyContent="center"
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
className={styles.box}
|
||||
>
|
||||
<Box marginTop={7} marginBottom={5}>
|
||||
<CheckIcon />
|
||||
</Box>
|
||||
<Box marginBottom={7}>
|
||||
<Localized id="community-invite-invitationsSent">
|
||||
<Typography variant="header2">
|
||||
Your invitations have been sent!
|
||||
</Typography>
|
||||
</Localized>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Localized id="community-invite-close">
|
||||
<Button
|
||||
variant="filled"
|
||||
color="primary"
|
||||
onClick={onClose}
|
||||
ref={lastFocusableRef}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Success;
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
default,
|
||||
default as InviteUsersContainer,
|
||||
} from "./InviteUsersContainer";
|
||||
@@ -47,6 +47,8 @@ const UserTableContainer: FunctionComponent<Props> = props => {
|
||||
statusFilter={statusFilter}
|
||||
onSetSearchFilter={setSearchFilter}
|
||||
searchFilter={searchFilter}
|
||||
viewer={props.query && props.query.viewer}
|
||||
settings={props.query && props.query.settings}
|
||||
/>
|
||||
<UserTable
|
||||
viewer={props.query && props.query.viewer}
|
||||
@@ -81,6 +83,10 @@ const enhanced = withPaginationContainer<
|
||||
) {
|
||||
viewer {
|
||||
...UserRowContainer_viewer
|
||||
...InviteUsersContainer_viewer
|
||||
}
|
||||
settings {
|
||||
...InviteUsersContainer_settings
|
||||
}
|
||||
users(
|
||||
first: $count
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
import { PropTypesOf } from "coral-ui/types";
|
||||
|
||||
import { InviteUsersContainer } from "./InviteUsers";
|
||||
|
||||
import styles from "./UserTableFilter.css";
|
||||
|
||||
@@ -29,147 +32,160 @@ interface Props {
|
||||
onSetStatusFilter: (role: GQLUSER_STATUS_RL) => void;
|
||||
searchFilter: string;
|
||||
onSetSearchFilter: (search: string) => void;
|
||||
viewer: PropTypesOf<typeof InviteUsersContainer>["viewer"];
|
||||
settings: PropTypesOf<typeof InviteUsersContainer>["settings"];
|
||||
}
|
||||
|
||||
const UserTableFilter: FunctionComponent<Props> = props => (
|
||||
<Flex itemGutter="double">
|
||||
<FieldSet>
|
||||
<Localized id="community-filter-search">
|
||||
<Typography
|
||||
container="legend"
|
||||
className={styles.legend}
|
||||
variant="bodyCopyBold"
|
||||
<Flex
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-end"
|
||||
itemGutter="double"
|
||||
>
|
||||
<Flex>
|
||||
<FieldSet>
|
||||
<Localized id="community-filter-search">
|
||||
<Typography
|
||||
container="legend"
|
||||
className={styles.legend}
|
||||
variant="bodyCopyBold"
|
||||
>
|
||||
Search
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Form
|
||||
onSubmit={({ search }: { search: string }) =>
|
||||
props.onSetSearchFilter(search)
|
||||
}
|
||||
>
|
||||
Search
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Form
|
||||
onSubmit={({ search }: { search: string }) =>
|
||||
props.onSetSearchFilter(search)
|
||||
}
|
||||
>
|
||||
{({ handleSubmit }) => (
|
||||
<form autoComplete="off" onSubmit={handleSubmit} id="configure-form">
|
||||
<Field name="search">
|
||||
{({ input }) => (
|
||||
<Localized
|
||||
id="community-filter-searchField"
|
||||
attrs={{ placeholder: true, "aria-label": true }}
|
||||
>
|
||||
<TextField
|
||||
className={styles.textField}
|
||||
placeholder="Search by username or email address..."
|
||||
aria-label="Search by username or email address"
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
variant="seamlessAdornment"
|
||||
adornment={
|
||||
<Localized
|
||||
id="community-filter-searchButton"
|
||||
attrs={{ "aria-label": true }}
|
||||
>
|
||||
<Button
|
||||
className={styles.adornment}
|
||||
variant="adornment"
|
||||
type="submit"
|
||||
color="dark"
|
||||
aria-label="Search"
|
||||
{({ handleSubmit }) => (
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={handleSubmit}
|
||||
id="configure-form"
|
||||
>
|
||||
<Field name="search">
|
||||
{({ input }) => (
|
||||
<Localized
|
||||
id="community-filter-searchField"
|
||||
attrs={{ placeholder: true, "aria-label": true }}
|
||||
>
|
||||
<TextField
|
||||
className={styles.textField}
|
||||
placeholder="Search by username or email address..."
|
||||
aria-label="Search by username or email address"
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
variant="seamlessAdornment"
|
||||
adornment={
|
||||
<Localized
|
||||
id="community-filter-searchButton"
|
||||
attrs={{ "aria-label": true }}
|
||||
>
|
||||
<Icon size="md">search</Icon>
|
||||
</Button>
|
||||
</Localized>
|
||||
}
|
||||
/>
|
||||
</Localized>
|
||||
)}
|
||||
</Field>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</FieldSet>
|
||||
<FieldSet>
|
||||
<Localized id="community-filter-showMe">
|
||||
<Typography
|
||||
className={styles.legend}
|
||||
container="legend"
|
||||
variant="bodyCopyBold"
|
||||
>
|
||||
Show Me
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Flex itemGutter>
|
||||
<Localized
|
||||
id="community-filter-roleSelectField"
|
||||
attrs={{ "aria-label": true }}
|
||||
>
|
||||
<SelectField
|
||||
aria-label="Search by role"
|
||||
value={props.roleFilter || ""}
|
||||
className={styles.selectField}
|
||||
onChange={e =>
|
||||
props.onSetRoleFilter((e.target.value as any) || null)
|
||||
}
|
||||
<Button
|
||||
className={styles.adornment}
|
||||
variant="adornment"
|
||||
type="submit"
|
||||
color="dark"
|
||||
aria-label="Search"
|
||||
>
|
||||
<Icon size="md">search</Icon>
|
||||
</Button>
|
||||
</Localized>
|
||||
}
|
||||
/>
|
||||
</Localized>
|
||||
)}
|
||||
</Field>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</FieldSet>
|
||||
<FieldSet>
|
||||
<Localized id="community-filter-showMe">
|
||||
<Typography
|
||||
className={styles.legend}
|
||||
container="legend"
|
||||
variant="bodyCopyBold"
|
||||
>
|
||||
<Localized id="community-filter-allRoles">
|
||||
<Option value="">All Roles</Option>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="community-filter-optGroupAudience"
|
||||
attrs={{ label: true }}
|
||||
>
|
||||
<OptGroup label="Audience">
|
||||
<Localized id="role-plural-commenter">
|
||||
<Option value={GQLUSER_ROLE.COMMENTER}>Commenters</Option>
|
||||
</Localized>
|
||||
</OptGroup>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="community-filter-optGroupOrganization"
|
||||
attrs={{ label: true }}
|
||||
>
|
||||
<OptGroup label="Organization">
|
||||
<Localized id="role-plural-admin">
|
||||
<Option value={GQLUSER_ROLE.ADMIN}>Admins</Option>
|
||||
</Localized>
|
||||
<Localized id="role-plural-moderator">
|
||||
<Option value={GQLUSER_ROLE.MODERATOR}>Moderators</Option>
|
||||
</Localized>
|
||||
<Localized id="role-plural-staff">
|
||||
<Option value={GQLUSER_ROLE.STAFF}>Staff</Option>
|
||||
</Localized>
|
||||
</OptGroup>
|
||||
</Localized>
|
||||
</SelectField>
|
||||
Show Me
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="community-filter-statusSelectField"
|
||||
attrs={{ "aria-label": true }}
|
||||
>
|
||||
<SelectField
|
||||
aria-label="Search by status"
|
||||
value={props.statusFilter || ""}
|
||||
className={styles.selectField}
|
||||
onChange={e =>
|
||||
props.onSetStatusFilter((e.target.value as any) || null)
|
||||
}
|
||||
<Flex itemGutter>
|
||||
<Localized
|
||||
id="community-filter-roleSelectField"
|
||||
attrs={{ "aria-label": true }}
|
||||
>
|
||||
<Localized id="community-filter-allStatuses">
|
||||
<Option value="">All Statuses</Option>
|
||||
</Localized>
|
||||
<Localized id="userStatus-active">
|
||||
<Option value={GQLUSER_STATUS.ACTIVE}>Active</Option>
|
||||
</Localized>
|
||||
<Localized id="userStatus-suspended">
|
||||
<Option value={GQLUSER_STATUS.SUSPENDED}>Suspended</Option>
|
||||
</Localized>
|
||||
<Localized id="userStatus-banned">
|
||||
<Option value={GQLUSER_STATUS.BANNED}>Banned</Option>
|
||||
</Localized>
|
||||
</SelectField>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</FieldSet>
|
||||
<SelectField
|
||||
aria-label="Search by role"
|
||||
value={props.roleFilter || ""}
|
||||
className={styles.selectField}
|
||||
onChange={e =>
|
||||
props.onSetRoleFilter((e.target.value as any) || null)
|
||||
}
|
||||
>
|
||||
<Localized id="community-filter-allRoles">
|
||||
<Option value="">All Roles</Option>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="community-filter-optGroupAudience"
|
||||
attrs={{ label: true }}
|
||||
>
|
||||
<OptGroup label="Audience">
|
||||
<Localized id="role-plural-commenter">
|
||||
<Option value={GQLUSER_ROLE.COMMENTER}>Commenters</Option>
|
||||
</Localized>
|
||||
</OptGroup>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="community-filter-optGroupOrganization"
|
||||
attrs={{ label: true }}
|
||||
>
|
||||
<OptGroup label="Organization">
|
||||
<Localized id="role-plural-admin">
|
||||
<Option value={GQLUSER_ROLE.ADMIN}>Admins</Option>
|
||||
</Localized>
|
||||
<Localized id="role-plural-moderator">
|
||||
<Option value={GQLUSER_ROLE.MODERATOR}>Moderators</Option>
|
||||
</Localized>
|
||||
<Localized id="role-plural-staff">
|
||||
<Option value={GQLUSER_ROLE.STAFF}>Staff</Option>
|
||||
</Localized>
|
||||
</OptGroup>
|
||||
</Localized>
|
||||
</SelectField>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="community-filter-statusSelectField"
|
||||
attrs={{ "aria-label": true }}
|
||||
>
|
||||
<SelectField
|
||||
aria-label="Search by status"
|
||||
value={props.statusFilter || ""}
|
||||
className={styles.selectField}
|
||||
onChange={e =>
|
||||
props.onSetStatusFilter((e.target.value as any) || null)
|
||||
}
|
||||
>
|
||||
<Localized id="community-filter-allStatuses">
|
||||
<Option value="">All Statuses</Option>
|
||||
</Localized>
|
||||
<Localized id="userStatus-active">
|
||||
<Option value={GQLUSER_STATUS.ACTIVE}>Active</Option>
|
||||
</Localized>
|
||||
<Localized id="userStatus-suspended">
|
||||
<Option value={GQLUSER_STATUS.SUSPENDED}>Suspended</Option>
|
||||
</Localized>
|
||||
<Localized id="userStatus-banned">
|
||||
<Option value={GQLUSER_STATUS.BANNED}>Banned</Option>
|
||||
</Localized>
|
||||
</SelectField>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</FieldSet>
|
||||
</Flex>
|
||||
<InviteUsersContainer viewer={props.viewer} settings={props.settings} />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.root {
|
||||
max-width: calc(42 * var(--mini-unit));
|
||||
margin: calc(3 * var(--mini-unit)) auto 0;
|
||||
}
|
||||
@@ -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<Props> = ({
|
||||
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 (
|
||||
<div data-testid="invite-complete-form">
|
||||
<Flex
|
||||
direction="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
spacing={3}
|
||||
>
|
||||
<Localized
|
||||
id="invite-youHaveBeenInvited"
|
||||
$organizationName={organizationName}
|
||||
>
|
||||
<Typography variant="heading1">
|
||||
You've been invited to join {organizationName}
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized id="invite-finishSettingUpAccount">
|
||||
<Typography variant="bodyCopy">
|
||||
Finish setting up the account for:
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Typography variant="heading2">{email}</Typography>
|
||||
</Flex>
|
||||
<Form onSubmit={onSubmit}>
|
||||
{({ handleSubmit, submitting, submitError }) => (
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<HorizontalGutter
|
||||
size="double"
|
||||
className={styles.root}
|
||||
paddingTop={4}
|
||||
>
|
||||
{submitError && (
|
||||
<CallOut color="error" fullWidth>
|
||||
{submitError}
|
||||
</CallOut>
|
||||
)}
|
||||
<SetUsernameField disabled={submitting} />
|
||||
<SetPasswordField disabled={submitting} />
|
||||
<Localized id="invite-createAccount">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="filled"
|
||||
color="brand"
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
>
|
||||
Create Account
|
||||
</Button>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteCompleteForm;
|
||||
@@ -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<Props> = ({
|
||||
onSuccess,
|
||||
token,
|
||||
settings,
|
||||
}) => {
|
||||
return (
|
||||
<InviteCompleteForm
|
||||
token={token}
|
||||
organizationName={settings.organization.name}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment InviteCompleteFormContainer_settings on Settings {
|
||||
organization {
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(InviteCompleteFormContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -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<void>("/account/invite", {
|
||||
method: "PUT",
|
||||
token: variables.token,
|
||||
body: {
|
||||
username: variables.username,
|
||||
password: variables.password,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export default InviteCompleteMutation;
|
||||
@@ -0,0 +1,6 @@
|
||||
.root {
|
||||
}
|
||||
|
||||
.logoContainer {
|
||||
position: relative;
|
||||
}
|
||||
@@ -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 }) => (
|
||||
<div className={styles.root} data-testid="invite-complete-container">
|
||||
<AppBar gutterBegin gutterEnd>
|
||||
<Begin itemGutter="double">
|
||||
<div className={styles.logoContainer}>
|
||||
<Logo />
|
||||
<Version />
|
||||
</div>
|
||||
</Begin>
|
||||
</AppBar>
|
||||
<Flex
|
||||
margin={8}
|
||||
direction="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default InviteLayout;
|
||||
@@ -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<void>("/account/invite", {
|
||||
method: "GET",
|
||||
token: variables.token,
|
||||
})
|
||||
);
|
||||
|
||||
interface Props {
|
||||
data: InviteRouteQueryResponse | null;
|
||||
token: string | undefined;
|
||||
}
|
||||
|
||||
const InviteRoute: React.FunctionComponent<Props> = ({ token, data }) => {
|
||||
const [finished, setFinished] = useState(false);
|
||||
const onSuccess = useCallback(() => {
|
||||
setFinished(true);
|
||||
}, []);
|
||||
const [tokenState, tokenError] = useToken(fetcher, token);
|
||||
|
||||
if (!data || tokenState === "UNCHECKED") {
|
||||
return (
|
||||
<Flex
|
||||
margin={8}
|
||||
direction="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Delay>
|
||||
<Spinner />
|
||||
</Delay>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (tokenState !== "VALID" || tokenError) {
|
||||
return (
|
||||
<InviteLayout>
|
||||
<Sorry reason={tokenError} />
|
||||
</InviteLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InviteLayout>
|
||||
{!finished ? (
|
||||
<InviteCompleteFormContainer
|
||||
token={token!}
|
||||
onSuccess={onSuccess}
|
||||
settings={data.settings}
|
||||
/>
|
||||
) : (
|
||||
<SuccessContainer token={token!} settings={data.settings} />
|
||||
)}
|
||||
</InviteLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withRouteConfig<Props>({
|
||||
query: graphql`
|
||||
query InviteRouteQuery {
|
||||
settings {
|
||||
...InviteCompleteFormContainer_settings
|
||||
...SuccessContainer_settings
|
||||
}
|
||||
}
|
||||
`,
|
||||
render: ({ match, Component, ...rest }) => (
|
||||
<Component
|
||||
token={parseHashQuery(match.location.hash).inviteToken}
|
||||
{...rest}
|
||||
/>
|
||||
),
|
||||
})(InviteRoute);
|
||||
|
||||
export default enhanced;
|
||||
@@ -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> = props => (
|
||||
<Field
|
||||
name="password"
|
||||
validate={composeValidators(required, validatePassword)}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="invite-passwordLabel">
|
||||
<InputLabel htmlFor={input.name}>Password</InputLabel>
|
||||
</Localized>
|
||||
<Localized id="invite-passwordDescription" $minLength={8}>
|
||||
<InputDescription>
|
||||
{"Must be at least {$minLength} characters"}
|
||||
</InputDescription>
|
||||
</Localized>
|
||||
<Localized id="invite-passwordTextField" attrs={{ placeholder: true }}>
|
||||
<PasswordField
|
||||
id={input.name}
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
placeholder="Password"
|
||||
color={
|
||||
meta.touched && (meta.error || meta.submitError)
|
||||
? "error"
|
||||
: "regular"
|
||||
}
|
||||
disabled={props.disabled}
|
||||
fullWidth
|
||||
/>
|
||||
</Localized>
|
||||
{meta.touched && (meta.error || meta.submitError) && (
|
||||
<ValidationMessage fullWidth>
|
||||
{meta.error || meta.submitError}
|
||||
</ValidationMessage>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
|
||||
export default SetPasswordField;
|
||||
@@ -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> = props => (
|
||||
<Field
|
||||
name="username"
|
||||
validate={composeValidators(required, validateUsername)}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="invite-usernameLabel">
|
||||
<InputLabel htmlFor={input.name}>Username</InputLabel>
|
||||
</Localized>
|
||||
<Localized id="invite-usernameDescription">
|
||||
<InputDescription>You may use “_” and “.”</InputDescription>
|
||||
</Localized>
|
||||
<Localized id="invite-usernameTextField" attrs={{ placeholder: true }}>
|
||||
<TextField
|
||||
id={input.name}
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
placeholder="Username"
|
||||
color={
|
||||
meta.touched && (meta.error || meta.submitError)
|
||||
? "error"
|
||||
: "regular"
|
||||
}
|
||||
disabled={props.disabled}
|
||||
fullWidth
|
||||
/>
|
||||
</Localized>
|
||||
{meta.touched && (meta.error || meta.submitError) && (
|
||||
<ValidationMessage fullWidth>
|
||||
{meta.error || meta.submitError}
|
||||
</ValidationMessage>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
|
||||
export default SetUsernameField;
|
||||
@@ -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<Props> = ({ reason }) => {
|
||||
return (
|
||||
<HorizontalGutter size="double" data-testid="invite-complete-sorry">
|
||||
<Localized id="invite-oopsSorry">
|
||||
<Typography variant="heading1">Oops Sorry!</Typography>
|
||||
</Localized>
|
||||
<CallOut color="error" fullWidth>
|
||||
{reason ? (
|
||||
reason
|
||||
) : (
|
||||
<Localized id="invite-tokenNotFound">
|
||||
<span data-testid="invalid-link">
|
||||
The specified link is invalid, check to see if it was copied
|
||||
correctly.
|
||||
</span>
|
||||
</Localized>
|
||||
)}
|
||||
</CallOut>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sorry;
|
||||
@@ -0,0 +1,9 @@
|
||||
.root {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
font-size: calc(24rem / var(--rem-base));
|
||||
font-family: var(--font-family-serif);
|
||||
}
|
||||
@@ -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<Props> = ({
|
||||
token,
|
||||
organizationName,
|
||||
organizationURL,
|
||||
}) => {
|
||||
const email = useMemo(() => parseJWT(token).payload.email, [token]);
|
||||
|
||||
return (
|
||||
<HorizontalGutter
|
||||
spacing={3}
|
||||
className={styles.root}
|
||||
data-testid="invite-complete-success"
|
||||
>
|
||||
<Localized id="invite-successful">
|
||||
<Typography variant="heading1">
|
||||
Your account has been created
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized id="invite-youMayNowSignIn">
|
||||
<Typography variant="bodyCopy">
|
||||
You may now sign-in to Coral using:
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Typography variant="heading2">{email}</Typography>
|
||||
<HorizontalGutter paddingTop={4} spacing={3}>
|
||||
<Localized id="invite-goToAdmin">
|
||||
<Link to="/admin" className={styles.link}>
|
||||
Go to Coral Admin
|
||||
</Link>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="invite-goToOrganization"
|
||||
$organizationName={organizationName}
|
||||
>
|
||||
<ExternalLink href={organizationURL} className={styles.link}>
|
||||
{"Go to {$organizationName}"}
|
||||
</ExternalLink>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
export default Success;
|
||||
@@ -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<Props> = ({ token, settings }) => {
|
||||
return (
|
||||
<Success
|
||||
token={token}
|
||||
organizationName={settings.organization.name}
|
||||
organizationURL={settings.organization.url}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment SuccessContainer_settings on Settings {
|
||||
organization {
|
||||
name
|
||||
url
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(SuccessContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1 @@
|
||||
export { default, default as InviteRoute } from "./InviteRoute";
|
||||
@@ -13,89 +13,105 @@ exports[`ban user 1`] = `
|
||||
onClick={[Function]}
|
||||
/>
|
||||
<div
|
||||
onFocus={[Function]}
|
||||
tabIndex={0}
|
||||
/>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div
|
||||
className="Card-root BanModal-card"
|
||||
className="Modal-scroll"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root CloseButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md CloseButton-icon"
|
||||
>
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
className="Modal-alignContainer1"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
className="Modal-alignContainer2"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-header2 Typography-colorTextPrimary"
|
||||
id="banModal-title"
|
||||
<div
|
||||
className="Modal-wrapper"
|
||||
>
|
||||
Are you sure you want to ban
|
||||
<strong>
|
||||
Isabelle
|
||||
</strong>
|
||||
?
|
||||
</h1>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
|
||||
>
|
||||
Once banned, this user will no longer be able to comment, use
|
||||
<div
|
||||
onFocus={[Function]}
|
||||
tabIndex={0}
|
||||
/>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div
|
||||
className="Card-root BanModal-card"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root CloseButton-root"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md CloseButton-icon"
|
||||
>
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-header2 Typography-colorTextPrimary"
|
||||
id="banModal-title"
|
||||
>
|
||||
Are you sure you want to ban
|
||||
<strong>
|
||||
Isabelle
|
||||
</strong>
|
||||
?
|
||||
</h1>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
|
||||
>
|
||||
Once banned, this user will no longer be able to comment, use
|
||||
reactions, or report comments.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-halfItemGutter Flex-justifyFlexEnd gutter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantOutlined"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Ban User
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-halfItemGutter Flex-justifyFlexEnd gutter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantOutlined"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Ban User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onFocus={[Function]}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onFocus={[Function]}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -108,171 +124,192 @@ exports[`renders community 1`] = `
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-doubleItemGutter gutter"
|
||||
className="Box-root Flex-root Flex-flex Flex-doubleItemGutter Flex-justifySpaceBetween Flex-alignFlexEnd gutter"
|
||||
>
|
||||
<fieldset
|
||||
className="FieldSet-root"
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<legend
|
||||
className="Box-root Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
|
||||
<fieldset
|
||||
className="FieldSet-root"
|
||||
>
|
||||
Search
|
||||
</legend>
|
||||
<form
|
||||
autoComplete="off"
|
||||
id="configure-form"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<div
|
||||
className="TextField-root UserTableFilter-textField"
|
||||
<legend
|
||||
className="Box-root Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
|
||||
>
|
||||
Search
|
||||
</legend>
|
||||
<form
|
||||
autoComplete="off"
|
||||
id="configure-form"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<input
|
||||
aria-label="Search by username or email address"
|
||||
className="TextField-input TextField-colorRegular TextField-seamlessAdornment"
|
||||
name="search"
|
||||
onChange={[Function]}
|
||||
placeholder="Search by username or email address..."
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="TextField-adornment"
|
||||
className="TextField-root UserTableFilter-textField"
|
||||
>
|
||||
<button
|
||||
aria-label="Search"
|
||||
className="BaseButton-root Button-root UserTableFilter-adornment Button-sizeRegular Button-colorDark Button-variantAdornment"
|
||||
<input
|
||||
aria-label="Search by username or email address"
|
||||
className="TextField-input TextField-colorRegular TextField-seamlessAdornment"
|
||||
name="search"
|
||||
onChange={[Function]}
|
||||
placeholder="Search by username or email address..."
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="TextField-adornment"
|
||||
>
|
||||
<button
|
||||
aria-label="Search"
|
||||
className="BaseButton-root Button-root UserTableFilter-adornment Button-sizeRegular Button-colorDark Button-variantAdornment"
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
>
|
||||
search
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
className="FieldSet-root"
|
||||
>
|
||||
<legend
|
||||
className="Box-root Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
|
||||
>
|
||||
Show Me
|
||||
</legend>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<span
|
||||
className="SelectField-root UserTableFilter-selectField"
|
||||
>
|
||||
<select
|
||||
aria-label="Search by role"
|
||||
className="SelectField-select"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
value=""
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All Roles
|
||||
</option>
|
||||
<optgroup
|
||||
label="Audience"
|
||||
>
|
||||
<option
|
||||
value="COMMENTER"
|
||||
>
|
||||
Commenters
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
label="Organization"
|
||||
>
|
||||
<option
|
||||
value="ADMIN"
|
||||
>
|
||||
Admins
|
||||
</option>
|
||||
<option
|
||||
value="MODERATOR"
|
||||
>
|
||||
Moderators
|
||||
</option>
|
||||
<option
|
||||
value="STAFF"
|
||||
>
|
||||
Staff
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="SelectField-afterWrapper"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
search
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="SelectField-root UserTableFilter-selectField"
|
||||
>
|
||||
<select
|
||||
aria-label="Search by user status"
|
||||
className="SelectField-select"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
value=""
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All Statuses
|
||||
</option>
|
||||
<option
|
||||
value="ACTIVE"
|
||||
>
|
||||
Active
|
||||
</option>
|
||||
<option
|
||||
value="SUSPENDED"
|
||||
>
|
||||
Suspended
|
||||
</option>
|
||||
<option
|
||||
value="BANNED"
|
||||
>
|
||||
Banned
|
||||
</option>
|
||||
</select>
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="SelectField-afterWrapper"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
className="FieldSet-root"
|
||||
</fieldset>
|
||||
</div>
|
||||
<div
|
||||
data-testid="invite-users"
|
||||
>
|
||||
<legend
|
||||
className="Box-root Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
|
||||
data-testid="invite-users-button"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Show Me
|
||||
</legend>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<span
|
||||
className="SelectField-root UserTableFilter-selectField"
|
||||
>
|
||||
<select
|
||||
aria-label="Search by role"
|
||||
className="SelectField-select"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
value=""
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All Roles
|
||||
</option>
|
||||
<optgroup
|
||||
label="Audience"
|
||||
>
|
||||
<option
|
||||
value="COMMENTER"
|
||||
>
|
||||
Commenters
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
label="Organization"
|
||||
>
|
||||
<option
|
||||
value="ADMIN"
|
||||
>
|
||||
Admins
|
||||
</option>
|
||||
<option
|
||||
value="MODERATOR"
|
||||
>
|
||||
Moderators
|
||||
</option>
|
||||
<option
|
||||
value="STAFF"
|
||||
>
|
||||
Staff
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="SelectField-afterWrapper"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="SelectField-root UserTableFilter-selectField"
|
||||
>
|
||||
<select
|
||||
aria-label="Search by user status"
|
||||
className="SelectField-select"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
value=""
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All Statuses
|
||||
</option>
|
||||
<option
|
||||
value="ACTIVE"
|
||||
>
|
||||
Active
|
||||
</option>
|
||||
<option
|
||||
value="SUSPENDED"
|
||||
>
|
||||
Suspended
|
||||
</option>
|
||||
<option
|
||||
value="BANNED"
|
||||
>
|
||||
Banned
|
||||
</option>
|
||||
</select>
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="SelectField-afterWrapper"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
@@ -704,171 +741,192 @@ exports[`renders empty community 1`] = `
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-doubleItemGutter gutter"
|
||||
className="Box-root Flex-root Flex-flex Flex-doubleItemGutter Flex-justifySpaceBetween Flex-alignFlexEnd gutter"
|
||||
>
|
||||
<fieldset
|
||||
className="FieldSet-root"
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex"
|
||||
>
|
||||
<legend
|
||||
className="Box-root Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
|
||||
<fieldset
|
||||
className="FieldSet-root"
|
||||
>
|
||||
Search
|
||||
</legend>
|
||||
<form
|
||||
autoComplete="off"
|
||||
id="configure-form"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<div
|
||||
className="TextField-root UserTableFilter-textField"
|
||||
<legend
|
||||
className="Box-root Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
|
||||
>
|
||||
Search
|
||||
</legend>
|
||||
<form
|
||||
autoComplete="off"
|
||||
id="configure-form"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<input
|
||||
aria-label="Search by username or email address"
|
||||
className="TextField-input TextField-colorRegular TextField-seamlessAdornment"
|
||||
name="search"
|
||||
onChange={[Function]}
|
||||
placeholder="Search by username or email address..."
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="TextField-adornment"
|
||||
className="TextField-root UserTableFilter-textField"
|
||||
>
|
||||
<button
|
||||
aria-label="Search"
|
||||
className="BaseButton-root Button-root UserTableFilter-adornment Button-sizeRegular Button-colorDark Button-variantAdornment"
|
||||
<input
|
||||
aria-label="Search by username or email address"
|
||||
className="TextField-input TextField-colorRegular TextField-seamlessAdornment"
|
||||
name="search"
|
||||
onChange={[Function]}
|
||||
placeholder="Search by username or email address..."
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="TextField-adornment"
|
||||
>
|
||||
<button
|
||||
aria-label="Search"
|
||||
className="BaseButton-root Button-root UserTableFilter-adornment Button-sizeRegular Button-colorDark Button-variantAdornment"
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
>
|
||||
search
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
className="FieldSet-root"
|
||||
>
|
||||
<legend
|
||||
className="Box-root Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
|
||||
>
|
||||
Show Me
|
||||
</legend>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<span
|
||||
className="SelectField-root UserTableFilter-selectField"
|
||||
>
|
||||
<select
|
||||
aria-label="Search by role"
|
||||
className="SelectField-select"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
value=""
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All Roles
|
||||
</option>
|
||||
<optgroup
|
||||
label="Audience"
|
||||
>
|
||||
<option
|
||||
value="COMMENTER"
|
||||
>
|
||||
Commenters
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
label="Organization"
|
||||
>
|
||||
<option
|
||||
value="ADMIN"
|
||||
>
|
||||
Admins
|
||||
</option>
|
||||
<option
|
||||
value="MODERATOR"
|
||||
>
|
||||
Moderators
|
||||
</option>
|
||||
<option
|
||||
value="STAFF"
|
||||
>
|
||||
Staff
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="SelectField-afterWrapper"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
search
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="SelectField-root UserTableFilter-selectField"
|
||||
>
|
||||
<select
|
||||
aria-label="Search by user status"
|
||||
className="SelectField-select"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
value=""
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All Statuses
|
||||
</option>
|
||||
<option
|
||||
value="ACTIVE"
|
||||
>
|
||||
Active
|
||||
</option>
|
||||
<option
|
||||
value="SUSPENDED"
|
||||
>
|
||||
Suspended
|
||||
</option>
|
||||
<option
|
||||
value="BANNED"
|
||||
>
|
||||
Banned
|
||||
</option>
|
||||
</select>
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="SelectField-afterWrapper"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
className="FieldSet-root"
|
||||
</fieldset>
|
||||
</div>
|
||||
<div
|
||||
data-testid="invite-users"
|
||||
>
|
||||
<legend
|
||||
className="Box-root Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
|
||||
data-testid="invite-users-button"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Show Me
|
||||
</legend>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-itemGutter gutter"
|
||||
>
|
||||
<span
|
||||
className="SelectField-root UserTableFilter-selectField"
|
||||
>
|
||||
<select
|
||||
aria-label="Search by role"
|
||||
className="SelectField-select"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
value=""
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All Roles
|
||||
</option>
|
||||
<optgroup
|
||||
label="Audience"
|
||||
>
|
||||
<option
|
||||
value="COMMENTER"
|
||||
>
|
||||
Commenters
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup
|
||||
label="Organization"
|
||||
>
|
||||
<option
|
||||
value="ADMIN"
|
||||
>
|
||||
Admins
|
||||
</option>
|
||||
<option
|
||||
value="MODERATOR"
|
||||
>
|
||||
Moderators
|
||||
</option>
|
||||
<option
|
||||
value="STAFF"
|
||||
>
|
||||
Staff
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="SelectField-afterWrapper"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="SelectField-root UserTableFilter-selectField"
|
||||
>
|
||||
<select
|
||||
aria-label="Search by user status"
|
||||
className="SelectField-select"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
value=""
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
>
|
||||
All Statuses
|
||||
</option>
|
||||
<option
|
||||
value="ACTIVE"
|
||||
>
|
||||
Active
|
||||
</option>
|
||||
<option
|
||||
value="SUSPENDED"
|
||||
>
|
||||
Suspended
|
||||
</option>
|
||||
<option
|
||||
value="BANNED"
|
||||
>
|
||||
Banned
|
||||
</option>
|
||||
</select>
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="SelectField-afterWrapper"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import TestRenderer from "react-test-renderer";
|
||||
import uuid from "uuid/v1";
|
||||
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import {
|
||||
GQLResolver,
|
||||
GQLUSER_ROLE,
|
||||
GQLUSER_STATUS,
|
||||
QueryToSettingsResolver,
|
||||
QueryToUsersResolver,
|
||||
} from "coral-framework/schema";
|
||||
import {
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
CreateTestRendererParams,
|
||||
findParentWithType,
|
||||
replaceHistoryLocation,
|
||||
wait,
|
||||
waitForElement,
|
||||
waitUntilThrow,
|
||||
within,
|
||||
@@ -22,6 +25,10 @@ import {
|
||||
import create from "../create";
|
||||
import {
|
||||
communityUsers,
|
||||
disabledEmail,
|
||||
disabledLocalAuth,
|
||||
disabledLocalAuthAdminTargetFilter,
|
||||
disabledLocalRegistration,
|
||||
emptyCommunityUsers,
|
||||
settings,
|
||||
users,
|
||||
@@ -81,6 +88,94 @@ it("renders empty community", async () => {
|
||||
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<GQLResolver>({
|
||||
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<GQLResolver>({
|
||||
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<GQLResolver>({
|
||||
Query: {
|
||||
settings: createQueryResolverStub<QueryToSettingsResolver>(
|
||||
() => 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<GQLResolver>({
|
||||
Query: {
|
||||
settings: createQueryResolverStub<QueryToSettingsResolver>(
|
||||
() => disabledLocalAuthAdminTargetFilter
|
||||
),
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(within(container).queryByTestID("invite-users")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders without invite button when local auth disabled", async () => {
|
||||
const { container } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: createQueryResolverStub<QueryToSettingsResolver>(
|
||||
() => 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<GQLResolver>({
|
||||
Query: {
|
||||
settings: createQueryResolverStub<QueryToSettingsResolver>(
|
||||
() => disabledLocalRegistration
|
||||
),
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(within(container).queryByTestID("invite-users")).toBeNull();
|
||||
});
|
||||
|
||||
it("filter by role", async () => {
|
||||
const { container } = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
@@ -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<GQLResolver>({
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import {
|
||||
GQLComment,
|
||||
GQLCOMMENT_FLAG_REASON,
|
||||
@@ -40,6 +41,9 @@ export const settings = createFixture<GQLSettings>({
|
||||
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<GQLUsersConnection>({
|
||||
edges: [],
|
||||
pageInfo: { endCursor: null, hasNextPage: false },
|
||||
});
|
||||
|
||||
export const disabledEmail = createFixture<GQLSettings>(
|
||||
pureMerge(settings, {
|
||||
email: {
|
||||
enabled: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export const disabledLocalAuth = createFixture<GQLSettings>(
|
||||
pureMerge(settings, {
|
||||
auth: {
|
||||
integrations: {
|
||||
local: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export const disabledLocalAuthAdminTargetFilter = createFixture<GQLSettings>(
|
||||
pureMerge(settings, {
|
||||
auth: {
|
||||
integrations: {
|
||||
local: {
|
||||
targetFilter: {
|
||||
admin: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export const disabledLocalRegistration = createFixture<GQLSettings>(
|
||||
pureMerge(settings, {
|
||||
auth: {
|
||||
integrations: {
|
||||
local: {
|
||||
allowRegistration: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders form 1`] = `
|
||||
<div
|
||||
className="InviteLayout-root"
|
||||
data-testid="invite-complete-container"
|
||||
>
|
||||
<div
|
||||
className="AppBar-root AppBar-gutterBegin AppBar-gutterEnd"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root AppBar-container Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Begin-root Flex-flex Flex-doubleItemGutter Flex-alignCenter gutter"
|
||||
>
|
||||
<div
|
||||
className="InviteLayout-logoContainer"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Logo-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<svg
|
||||
className="BrandIcon-base Logo-icon BrandIcon-md"
|
||||
data-name="Layer 1"
|
||||
id="Layer_1"
|
||||
viewBox="0 0 541.77 557.72"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<clipPath
|
||||
id="clip-path"
|
||||
>
|
||||
<rect
|
||||
fill="none"
|
||||
height="570"
|
||||
width="554"
|
||||
/>
|
||||
</clipPath>
|
||||
<clipPath
|
||||
id="clip-path-2"
|
||||
transform="translate(-8.12 -8.14)"
|
||||
>
|
||||
<path
|
||||
clipPath="url(#clip-path)"
|
||||
clipRule="evenodd"
|
||||
d="M61.63,350.45c-.67,10.22,1.34,21.13,6,32.21a95.36,95.36,0,0,0,27.22,36.41c26,21.16,52.14,17.65,77.43,14.24,21.22-2.85,43.17-5.8,69,5.43,33.54,14.57,59.71,45.39,66.65,78.46a99.16,99.16,0,0,1,.11,38.66H219.65a16.89,16.89,0,0,0-.71-2.44c-16.54-44.09-38.46-69.52-67.09-77.78-9.37-2.66-18-3-25.68-3.27-9.91-.37-17.75-.66-26-5.6s-14.93-13.28-19.59-24.42a17,17,0,0,0-22.12-9.07,16.69,16.69,0,0,0-9.17,21.88c7.45,17.8,18.6,31.31,33.25,40.21,15.85,9.48,30.54,10,42.35,10.48,6.55.24,12.2.45,17.55,2,15.64,4.51,29.43,20.65,41.07,48H63.07a45,45,0,0,1-44.95-45V322.78a144.51,144.51,0,0,0,43.51,27.67Zm13-31.55a109.22,109.22,0,0,1-56.48-52.22v-66a88.41,88.41,0,0,0,35.59,10.37c18.52,1.13,34.77-4.64,47-16.71,11.63-11.51,14.6-24.24,18.05-39,2.88-12.32,6.46-27.66,16.66-48.7,10.44-21.72,17.13-30,24.69-30.51,11-.79,29.3,13.46,32.09,34.75,2,14.93-3.58,25.14-10.07,37C175,161,166,177.28,172.4,198.15c6.81,22.16,25.44,31.45,40.4,38.91,15.61,7.78,25.42,13.21,30.37,26.2,4.26,11.27,4.87,27.9-.71,33.32-3.74,3.66-16.43.64-29.86-2.55-23-5.47-54.5-12.95-91.49-1.57-9.77,3-32.07,9.72-46.51,26.44ZM357.73,18.14c-15,17.37-28.76,40.23-34.85,69-4.25,19.33-13.09,59.57,13.51,88.26A71.68,71.68,0,0,0,388.11,198a61.12,61.12,0,0,0,22.65-4.23c31.63-12.61,46.84-43.4,49.25-56.36,3.24-17.51,5-35.76,6.33-51.32a11.43,11.43,0,0,1,5.38-8.61l.5-.31a12.23,12.23,0,0,1,13.33,1.63c19.82,16.56,36.73,31.82,51.69,46.63a17.07,17.07,0,0,0,2.64,2.14v93.21a16.92,16.92,0,0,0-3.87,4.69c-9.23,16.43-23.71,36.42-40.11,38.31-6.55.7-10.3-1.49-20.21-7.9S452.37,240.8,431,236c-23.2-5.22-38.56-2.06-50.9.48-10.61,2.18-17.62,3.62-28.9,0-27.43-9-42.31-36.22-51.21-52.47l-.24-.45c-19.81-36.54-18.23-71.85-16.84-103l0-.58a287.16,287.16,0,0,1,9.81-61.77Zm49.14,0h88.06a45,45,0,0,1,44.95,45V80.79c-10.08-9.22-21-18.69-32.79-28.58a45.72,45.72,0,0,0-50.93-5.38,19.1,19.1,0,0,0-2.55,1.52,46.1,46.1,0,0,0-21,34.46,1.49,1.49,0,0,0,0,.21c-1.24,14.7-2.91,31.88-5.86,47.9-.88,3.66-9.12,23.11-28.33,30.76-12.81,5.1-29.11-1.09-37.41-10-14.22-15.33-8.26-42.48-5-57.19C363.43,59.29,386.3,35,404.17,20.82a18,18,0,0,0,2.7-2.68Zm-149.42,0a319.88,319.88,0,0,0-8.67,60.21l0,.62c-1.43,32-3.38,75.91,21,120.82l.28.52c9.81,17.93,30.25,55.25,70.55,68.52,19.95,6.48,34,3.59,46.44,1,11-2.26,20.48-4.21,36.49-.61,15.43,3.48,24.69,9.46,33.63,15.25C467,290.87,478,298,493.8,298a54,54,0,0,0,5.86-.32c14.5-1.67,27.83-8.41,40.22-20.42v45.53a179.81,179.81,0,0,1-47.68.48c-17-2.18-28.9-6.21-40.39-10.1-16.13-5.46-31.37-10.63-54.46-8.79-12.34.86-49.5,3.52-68.67,32.46-21.27,32-4.79,71.47-1.27,79.05,0,.06,0,.11.07.17,11.15,23.37,28,33.16,44.25,42.63,9.57,5.57,19.46,11.33,29.45,20.4C421,497.23,435.05,523,443,555.86H342.61a132,132,0,0,0-1.29-45.63c-9.16-43.62-43.1-84-86.46-102.82-34.51-15-63.68-11.11-87.12-8-24.18,3.25-37.44,4.42-51.35-6.91-15-12.14-24-32.83-19.67-45.1,4.74-13.34,25.44-19.62,34.35-22.32,28.19-8.67,52.33-2.93,73.64,2.14,21.83,5.18,44.4,10.55,61.57-6.21,17.7-17.22,17-48.29,8.81-69.92-10-26.15-30.53-36.41-47.06-44.65-13.09-6.53-20.6-10.6-23-18.37-2-6.43.36-11.62,7-23.75,7.41-13.48,17.55-31.93,14-58-4.77-36.43-36.65-66.5-68.28-64.3-30.19,2.1-44.53,32-53.08,49.73-11.83,24.42-16.07,42.54-19.16,55.78-2.9,12.39-4.37,18.06-8.79,22.43-5.21,5.13-12.26,7.47-21,6.93-13-.8-27.57-8-37.68-18.45V63.15a45,45,0,0,1,45-45Zm282.43,449v43.76a45,45,0,0,1-44.95,45H477.46a16.09,16.09,0,0,0-.36-2.56c-4.89-22.41-12.24-42.37-22-59.73a120.79,120.79,0,0,1,39-21,119.51,119.51,0,0,1,45.77-5.45Zm0-34.82a152.6,152.6,0,0,0-56,7.12,154.85,154.85,0,0,0-48.66,25.94,153.26,153.26,0,0,0-11.29-11.5c-12.68-11.52-24.67-18.5-35.24-24.65-14.45-8.41-24-14-30.62-27.78-.87-1.9-12.66-28.42-1.23-45.62,10-15.1,33.94-16.77,42.95-17.4l.17,0c16.24-1.3,26.66,2.23,41.07,7.11,12.45,4.22,26.57,9,46.95,11.61a212.66,212.66,0,0,0,51.92.08v75.11Z"
|
||||
fill="none"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<title>
|
||||
logo mark2018
|
||||
</title>
|
||||
<g
|
||||
clipPath="url(#clip-path-2)"
|
||||
>
|
||||
<rect
|
||||
fill="#f7705f"
|
||||
height="557.72"
|
||||
width="541.77"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary Typography-alignLeft BrandName-root BrandName-md"
|
||||
>
|
||||
Coral
|
||||
</h1>
|
||||
</div>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-detail Typography-colorTextPrimary Version-version"
|
||||
>
|
||||
vTest
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-justifyCenter Flex-alignCenter Flex-directionColumn Box-ml-8 Box-mr-8 Box-mt-8 Box-mb-8"
|
||||
>
|
||||
<div
|
||||
data-testid="invite-complete-form"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-justifyCenter Flex-alignCenter Flex-directionColumn gutter Flex-spacing-3"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
You've been invited to join Coral
|
||||
</h1>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
|
||||
>
|
||||
Finish setting up the account for:
|
||||
</p>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading2 Typography-colorTextPrimary"
|
||||
>
|
||||
lukas@test.com
|
||||
</h1>
|
||||
</div>
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root InviteCompleteForm-root HorizontalGutter-double Box-pt-4"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
|
||||
>
|
||||
<label
|
||||
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
|
||||
htmlFor="username"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-detail Typography-colorTextSecondary"
|
||||
>
|
||||
You may use “_” and “.”
|
||||
</p>
|
||||
<div
|
||||
className="TextField-root TextField-fullWidth"
|
||||
>
|
||||
<input
|
||||
className="TextField-input TextField-colorRegular"
|
||||
disabled={false}
|
||||
id="username"
|
||||
name="username"
|
||||
onChange={[Function]}
|
||||
placeholder="Username"
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
|
||||
>
|
||||
<label
|
||||
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
|
||||
htmlFor="password"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-detail Typography-colorTextSecondary"
|
||||
>
|
||||
Must be at least 8 characters
|
||||
</p>
|
||||
<div
|
||||
className="PasswordField-fullWidth PasswordField-root"
|
||||
>
|
||||
<div
|
||||
className="PasswordField-wrapper"
|
||||
>
|
||||
<input
|
||||
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
|
||||
disabled={false}
|
||||
id="password"
|
||||
name="password"
|
||||
onChange={[Function]}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="PasswordField-icon"
|
||||
onClick={[Function]}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Hide password"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
visibility
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorBrand Button-variantFilled Button-fullWidth"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
Create Account
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`renders missing the token 1`] = `
|
||||
<div
|
||||
className="InviteLayout-root"
|
||||
data-testid="invite-complete-container"
|
||||
>
|
||||
<div
|
||||
className="AppBar-root AppBar-gutterBegin AppBar-gutterEnd"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root AppBar-container Flex-flex"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Begin-root Flex-flex Flex-doubleItemGutter Flex-alignCenter gutter"
|
||||
>
|
||||
<div
|
||||
className="InviteLayout-logoContainer"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Logo-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<svg
|
||||
className="BrandIcon-base Logo-icon BrandIcon-md"
|
||||
data-name="Layer 1"
|
||||
id="Layer_1"
|
||||
viewBox="0 0 541.77 557.72"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<clipPath
|
||||
id="clip-path"
|
||||
>
|
||||
<rect
|
||||
fill="none"
|
||||
height="570"
|
||||
width="554"
|
||||
/>
|
||||
</clipPath>
|
||||
<clipPath
|
||||
id="clip-path-2"
|
||||
transform="translate(-8.12 -8.14)"
|
||||
>
|
||||
<path
|
||||
clipPath="url(#clip-path)"
|
||||
clipRule="evenodd"
|
||||
d="M61.63,350.45c-.67,10.22,1.34,21.13,6,32.21a95.36,95.36,0,0,0,27.22,36.41c26,21.16,52.14,17.65,77.43,14.24,21.22-2.85,43.17-5.8,69,5.43,33.54,14.57,59.71,45.39,66.65,78.46a99.16,99.16,0,0,1,.11,38.66H219.65a16.89,16.89,0,0,0-.71-2.44c-16.54-44.09-38.46-69.52-67.09-77.78-9.37-2.66-18-3-25.68-3.27-9.91-.37-17.75-.66-26-5.6s-14.93-13.28-19.59-24.42a17,17,0,0,0-22.12-9.07,16.69,16.69,0,0,0-9.17,21.88c7.45,17.8,18.6,31.31,33.25,40.21,15.85,9.48,30.54,10,42.35,10.48,6.55.24,12.2.45,17.55,2,15.64,4.51,29.43,20.65,41.07,48H63.07a45,45,0,0,1-44.95-45V322.78a144.51,144.51,0,0,0,43.51,27.67Zm13-31.55a109.22,109.22,0,0,1-56.48-52.22v-66a88.41,88.41,0,0,0,35.59,10.37c18.52,1.13,34.77-4.64,47-16.71,11.63-11.51,14.6-24.24,18.05-39,2.88-12.32,6.46-27.66,16.66-48.7,10.44-21.72,17.13-30,24.69-30.51,11-.79,29.3,13.46,32.09,34.75,2,14.93-3.58,25.14-10.07,37C175,161,166,177.28,172.4,198.15c6.81,22.16,25.44,31.45,40.4,38.91,15.61,7.78,25.42,13.21,30.37,26.2,4.26,11.27,4.87,27.9-.71,33.32-3.74,3.66-16.43.64-29.86-2.55-23-5.47-54.5-12.95-91.49-1.57-9.77,3-32.07,9.72-46.51,26.44ZM357.73,18.14c-15,17.37-28.76,40.23-34.85,69-4.25,19.33-13.09,59.57,13.51,88.26A71.68,71.68,0,0,0,388.11,198a61.12,61.12,0,0,0,22.65-4.23c31.63-12.61,46.84-43.4,49.25-56.36,3.24-17.51,5-35.76,6.33-51.32a11.43,11.43,0,0,1,5.38-8.61l.5-.31a12.23,12.23,0,0,1,13.33,1.63c19.82,16.56,36.73,31.82,51.69,46.63a17.07,17.07,0,0,0,2.64,2.14v93.21a16.92,16.92,0,0,0-3.87,4.69c-9.23,16.43-23.71,36.42-40.11,38.31-6.55.7-10.3-1.49-20.21-7.9S452.37,240.8,431,236c-23.2-5.22-38.56-2.06-50.9.48-10.61,2.18-17.62,3.62-28.9,0-27.43-9-42.31-36.22-51.21-52.47l-.24-.45c-19.81-36.54-18.23-71.85-16.84-103l0-.58a287.16,287.16,0,0,1,9.81-61.77Zm49.14,0h88.06a45,45,0,0,1,44.95,45V80.79c-10.08-9.22-21-18.69-32.79-28.58a45.72,45.72,0,0,0-50.93-5.38,19.1,19.1,0,0,0-2.55,1.52,46.1,46.1,0,0,0-21,34.46,1.49,1.49,0,0,0,0,.21c-1.24,14.7-2.91,31.88-5.86,47.9-.88,3.66-9.12,23.11-28.33,30.76-12.81,5.1-29.11-1.09-37.41-10-14.22-15.33-8.26-42.48-5-57.19C363.43,59.29,386.3,35,404.17,20.82a18,18,0,0,0,2.7-2.68Zm-149.42,0a319.88,319.88,0,0,0-8.67,60.21l0,.62c-1.43,32-3.38,75.91,21,120.82l.28.52c9.81,17.93,30.25,55.25,70.55,68.52,19.95,6.48,34,3.59,46.44,1,11-2.26,20.48-4.21,36.49-.61,15.43,3.48,24.69,9.46,33.63,15.25C467,290.87,478,298,493.8,298a54,54,0,0,0,5.86-.32c14.5-1.67,27.83-8.41,40.22-20.42v45.53a179.81,179.81,0,0,1-47.68.48c-17-2.18-28.9-6.21-40.39-10.1-16.13-5.46-31.37-10.63-54.46-8.79-12.34.86-49.5,3.52-68.67,32.46-21.27,32-4.79,71.47-1.27,79.05,0,.06,0,.11.07.17,11.15,23.37,28,33.16,44.25,42.63,9.57,5.57,19.46,11.33,29.45,20.4C421,497.23,435.05,523,443,555.86H342.61a132,132,0,0,0-1.29-45.63c-9.16-43.62-43.1-84-86.46-102.82-34.51-15-63.68-11.11-87.12-8-24.18,3.25-37.44,4.42-51.35-6.91-15-12.14-24-32.83-19.67-45.1,4.74-13.34,25.44-19.62,34.35-22.32,28.19-8.67,52.33-2.93,73.64,2.14,21.83,5.18,44.4,10.55,61.57-6.21,17.7-17.22,17-48.29,8.81-69.92-10-26.15-30.53-36.41-47.06-44.65-13.09-6.53-20.6-10.6-23-18.37-2-6.43.36-11.62,7-23.75,7.41-13.48,17.55-31.93,14-58-4.77-36.43-36.65-66.5-68.28-64.3-30.19,2.1-44.53,32-53.08,49.73-11.83,24.42-16.07,42.54-19.16,55.78-2.9,12.39-4.37,18.06-8.79,22.43-5.21,5.13-12.26,7.47-21,6.93-13-.8-27.57-8-37.68-18.45V63.15a45,45,0,0,1,45-45Zm282.43,449v43.76a45,45,0,0,1-44.95,45H477.46a16.09,16.09,0,0,0-.36-2.56c-4.89-22.41-12.24-42.37-22-59.73a120.79,120.79,0,0,1,39-21,119.51,119.51,0,0,1,45.77-5.45Zm0-34.82a152.6,152.6,0,0,0-56,7.12,154.85,154.85,0,0,0-48.66,25.94,153.26,153.26,0,0,0-11.29-11.5c-12.68-11.52-24.67-18.5-35.24-24.65-14.45-8.41-24-14-30.62-27.78-.87-1.9-12.66-28.42-1.23-45.62,10-15.1,33.94-16.77,42.95-17.4l.17,0c16.24-1.3,26.66,2.23,41.07,7.11,12.45,4.22,26.57,9,46.95,11.61a212.66,212.66,0,0,0,51.92.08v75.11Z"
|
||||
fill="none"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<title>
|
||||
logo mark2018
|
||||
</title>
|
||||
<g
|
||||
clipPath="url(#clip-path-2)"
|
||||
>
|
||||
<rect
|
||||
fill="#f7705f"
|
||||
height="557.72"
|
||||
width="541.77"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary Typography-alignLeft BrandName-root BrandName-md"
|
||||
>
|
||||
Coral
|
||||
</h1>
|
||||
</div>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-detail Typography-colorTextPrimary Version-version"
|
||||
>
|
||||
vTest
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-justifyCenter Flex-alignCenter Flex-directionColumn Box-ml-8 Box-mr-8 Box-mt-8 Box-mb-8"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
data-testid="invite-complete-sorry"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
Oops Sorry!
|
||||
</h1>
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
>
|
||||
The specified link is invalid, check to see if it was copied correctly.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -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<GQLResolver> = {}
|
||||
) => {
|
||||
const { testRenderer, context } = create({
|
||||
...params,
|
||||
resolvers: pureMerge(
|
||||
createResolversStub<GQLResolver>({
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export default function clearHash() {
|
||||
if (window.location.hash) {
|
||||
window.history.replaceState(null, document.title, location.pathname);
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, Variables, Promise<void>>,
|
||||
token: string | undefined
|
||||
): [TokenState, string] {
|
||||
const checkToken = useFetch(fetcher);
|
||||
const [tokenState, setTokenState] = useState<TokenState>("UNCHECKED");
|
||||
const [tokenError, setTokenError] = useState<string>("");
|
||||
|
||||
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];
|
||||
}
|
||||
@@ -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 }) => (
|
||||
<a
|
||||
href={href || children}
|
||||
target="_blank"
|
||||
@@ -15,7 +17,7 @@ const ExternalLink: FunctionComponent<{
|
||||
* https://www.jitbit.com/alexblog/256-targetblank---the-most-underestimated-vulnerability-ever/
|
||||
*/
|
||||
rel="noopener noreferrer"
|
||||
className={styles.root}
|
||||
className={cn(styles.root, className)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface JWT {
|
||||
typ: string;
|
||||
};
|
||||
payload: {
|
||||
[_: string]: any;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
iss?: string;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
)}`;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
// This is only loaded in development, so include the React devtools hooks.
|
||||
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = window.parent.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
</script>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Check
|
||||
menu: UI Kit
|
||||
---
|
||||
|
||||
import { Playground, PropsTable } from "docz";
|
||||
import { CheckIcon } from "./";
|
||||
import Flex from "../Flex";
|
||||
|
||||
# Check
|
||||
|
||||
## Basic usage
|
||||
|
||||
<Playground>
|
||||
<Flex justifyContent="center">
|
||||
<CheckIcon />
|
||||
</Flex>
|
||||
</Playground>
|
||||
@@ -0,0 +1,2 @@
|
||||
.base {
|
||||
}
|
||||
@@ -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<Props> = ({
|
||||
className,
|
||||
classes,
|
||||
...rest
|
||||
}) => (
|
||||
<svg
|
||||
{...rest}
|
||||
className={cn(classes.base, className)}
|
||||
width="62"
|
||||
height="62"
|
||||
viewBox="0 0 62 62"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9 9.125C15.0833 3.04167 22.4167 0 31 0C39.5833 0 46.875 3.04167 52.875 9.125C58.9583 15.125 62 22.4167 62 31C62 39.5833 58.9583 46.9167 52.875 53C46.875 59 39.5833 62 31 62C22.4167 62 15.0833 59 9 53C3 46.9167 0 39.5833 0 31C0 22.4167 3 15.125 9 9.125ZM48.625 13.375C43.7917 8.45833 37.9167 6 31 6C24.0833 6 18.1667 8.45833 13.25 13.375C8.41667 18.2083 6 24.0833 6 31C6 37.9167 8.41667 43.8333 13.25 48.75C18.1667 53.5833 24.0833 56 31 56C37.9167 56 43.7917 53.5833 48.625 48.75C53.5417 43.8333 56 37.9167 56 31C56 24.0833 53.5417 18.2083 48.625 13.375ZM48.5 22.25C49.25 23 49.25 23.7083 48.5 24.375L27 45.75C26.25 46.5 25.5417 46.5 24.875 45.75L13.5 34.375C12.75 33.625 12.75 32.9167 13.5 32.25L16.375 29.375C17.0417 28.7083 17.75 28.7083 18.5 29.375L25.875 37L43.625 19.375C44.2917 18.7083 45 18.75 45.75 19.5L48.5 22.25Z"
|
||||
fill={variables.palette.success.main}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default withStyles(styles)(CheckIcon);
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CheckIcon } from "./CheckIcon";
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -109,10 +109,18 @@ const Modal: FunctionComponent<Props> = ({
|
||||
<NoScroll active={open} />
|
||||
<Backdrop
|
||||
active={open}
|
||||
onClick={handleBackdropClick}
|
||||
data-testid="backdrop"
|
||||
onClick={handleBackdropClick}
|
||||
/>
|
||||
<TrapFocus>{children}</TrapFocus>
|
||||
<div className={styles.scroll}>
|
||||
<div className={styles.alignContainer1}>
|
||||
<div className={styles.alignContainer2}>
|
||||
<div className={styles.wrapper}>
|
||||
<TrapFocus>{children}</TrapFocus>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
modalDOMNode
|
||||
);
|
||||
|
||||
@@ -12,18 +12,34 @@ exports[`renders correctly 1`] = `
|
||||
onClick={[Function]}
|
||||
/>
|
||||
<div
|
||||
onFocus={[Function]}
|
||||
tabIndex={0}
|
||||
/>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div>
|
||||
Test
|
||||
className="Modal-scroll"
|
||||
>
|
||||
<div
|
||||
className="Modal-alignContainer1"
|
||||
>
|
||||
<div
|
||||
className="Modal-alignContainer2"
|
||||
>
|
||||
<div
|
||||
className="Modal-wrapper"
|
||||
>
|
||||
<div
|
||||
onFocus={[Function]}
|
||||
tabIndex={0}
|
||||
/>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div>
|
||||
Test
|
||||
</div>
|
||||
<div
|
||||
onFocus={[Function]}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onFocus={[Function]}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -39,6 +39,7 @@ export {
|
||||
Navigation as AppBarNavigation,
|
||||
NavigationItem as AppBarNavigationItem,
|
||||
} from "./AppBar";
|
||||
export { CheckIcon } from "./Check";
|
||||
export {
|
||||
SubBar,
|
||||
Navigation as SubBarNavigation,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./confirm";
|
||||
export * from "./invite";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<number> {
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
{% import "macros.html" as macros %} {% extends "templates/base.html" %} {%
|
||||
block title %}Coral{% endblock %} {% block meta %}
|
||||
<script type="application/javascript" id="config">
|
||||
{{ staticURI | dump | safe }}
|
||||
</script>
|
||||
{% 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 %}
|
||||
<div id="app"></div>
|
||||
{% 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 %}
|
||||
<script type="application/javascript" id="config">
|
||||
{{ staticURI | dump | safe }}
|
||||
</script>
|
||||
{% 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 %}
|
||||
<div id="app"></div>
|
||||
{% 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 %}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,4 +46,6 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -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<Readonly<User> | null> =>
|
||||
|
||||
@@ -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<invite.Invite> = {
|
||||
createdBy: ({ createdBy }, args, ctx) =>
|
||||
ctx.loaders.Users.user.load(createdBy),
|
||||
};
|
||||
@@ -107,6 +107,10 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <a data-l10n-name="invite">here</a>.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Readonly<Invite>>("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<Invite, CreateInviteInput> = {
|
||||
id,
|
||||
tenantID,
|
||||
createdBy,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Merge the defaults and the input together.
|
||||
const invite: Readonly<Invite> = {
|
||||
...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,
|
||||
});
|
||||
}
|
||||
@@ -363,7 +363,13 @@ function hashPassword(password: string): Promise<string> {
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 <a data-l10n-name="invite" href="{{ context.inviteURL }}">here</a>.
|
||||
{% endblock %}
|
||||
@@ -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<void>;
|
||||
|
||||
const indexes: Array<[string, IndexCreationFunction]> = [
|
||||
["users", createUserIndexes],
|
||||
["invites", createInviteIndexes],
|
||||
["tenants", createTenantIndexes],
|
||||
["comments", createCommentIndexes],
|
||||
["stories", createStoryIndexes],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<StandardClaims> {
|
||||
// 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<Pick<Invite, "id" | "email" | "expiresAt">>,
|
||||
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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 =
|
||||
<strong>Staff role:</strong> Receives a “Staff” badge, and
|
||||
comments are automatically approved. Cannot moderate
|
||||
or change any { -product-name } configuration.
|
||||
community-invite-role-moderator =
|
||||
<strong>Moderator role:</strong> 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 =
|
||||
<strong>Admin role:</strong> 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user