[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:
Wyatt Johnson
2019-06-28 22:51:42 +00:00
committed by GitHub
co-authored by Vinh
parent 76033118e5
commit 51b142035e
96 changed files with 3647 additions and 847 deletions
@@ -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;
+2
View File
@@ -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;
+2
View File
@@ -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);
});
});
+50
View File
@@ -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";
+1
View File
@@ -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>
+1
View File
@@ -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,
})
)}`;
}
+4
View File
@@ -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";
+23 -3
View File
@@ -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;
}
+10 -2
View File
@@ -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>
`;
+1
View File
@@ -39,6 +39,7 @@ export {
Navigation as AppBarNavigation,
NavigationItem as AppBarNavigationItem,
} from "./AppBar";
export { CheckIcon } from "./Check";
export {
SubBar,
Navigation as SubBarNavigation,
+11
View File
@@ -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(
+15 -5
View File
@@ -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;
}
+39 -18
View File
@@ -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 %}
+8
View File
@@ -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;
+22
View File
@@ -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,
});
}
}
+2
View File
@@ -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.
+6
View File
@@ -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>.
+2
View File
@@ -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.
+109
View File
@@ -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,
});
}
+25 -1
View File
@@ -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;
}
+4
View File
@@ -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.
+51 -5
View File
@@ -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 &lt;Facebook&gt;”.
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.
+4 -4
View File
@@ -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