[next] Email (#2261)

* feat: suspending, banning, now propogation

* feat: added email rendering + localization support

* fix: fix related to lib

* refactor: moved juicer to queue task

* refactor: cleanup of job processor

* refactor: improved error messaging around failed email

* feat: initial forgot passwor impl

* fix: fixed rebase errors

* feat: send back Content-Language header with requests

* feat: added ban email

* feat: implemented forgotten password API

* fix: linting

* feat: support more emails

* fix: promise patches

* feat: initial confirm email API

* feat: added rate limiting

* feat: added URL support

* feat: added email docs

* fix: updated docs

* chore: documentation review

* fix: fixed build bug

* feat: implement forgot password in auth popup

* test: add tests + fixes

* chore: rename StatelessComponent to FunctionComponent

* fix: types and test fixes

* chore: upgrade deps

* fix: THANK YOU TESTS FOR SAVING MY A**

* chore: reorder imports

* chore: remove obsolete !

* feat: implement accounts bundle

* refactor: review suggestion

* fix: rebase upgrade error

* fix: rebase bug

* feat: reset password link support

* test: add tests for account password reset page

* fix: remove redirect uri

* fix: revert local state changes
This commit is contained in:
Wyatt Johnson
2019-05-09 22:54:56 +02:00
committed by Kiwi
parent 945bd7f2b0
commit df57b4eb17
487 changed files with 6794 additions and 2431 deletions
@@ -1,5 +1,5 @@
import React from "react";
import { StatelessComponent } from "react";
import { FunctionComponent } from "react";
import { PropTypesOf } from "talk-framework/types";
import { Button } from "talk-ui/components";
@@ -26,7 +26,7 @@ const facebookIcon = (
</svg>
);
const FacebookButton: StatelessComponent<Props> = props => (
const FacebookButton: FunctionComponent<Props> = props => (
<Button
classes={styles}
variant="filled"
@@ -1,5 +1,5 @@
import React from "react";
import { StatelessComponent } from "react";
import { FunctionComponent } from "react";
import { PropTypesOf } from "talk-framework/types";
import { Button } from "talk-ui/components";
@@ -26,7 +26,7 @@ const googleIcon = (
</svg>
);
const GoogleButton: StatelessComponent<Props> = props => (
const GoogleButton: FunctionComponent<Props> = props => (
<Button
classes={styles}
variant="filled"
@@ -1,5 +1,5 @@
import React from "react";
import { StatelessComponent } from "react";
import { FunctionComponent } from "react";
import { PropTypesOf } from "talk-framework/types";
import { Button } from "talk-ui/components";
@@ -11,7 +11,7 @@ interface Props {
children: React.ReactNode;
}
const OIDCButton: StatelessComponent<Props> = props => (
const OIDCButton: FunctionComponent<Props> = props => (
<Button
classes={styles}
variant="filled"
@@ -1,5 +1,5 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import React, { FunctionComponent } from "react";
import { Omit, PropTypesOf } from "talk-framework/types";
import { PasswordField as PasswordFieldUI } from "talk-ui/components";
@@ -10,7 +10,7 @@ export interface Props
"showPasswordTitle" | "hidePasswordTitle"
> {}
const PasswordField: StatelessComponent<Props> = props => (
const PasswordField: FunctionComponent<Props> = props => (
<Localized
id="framework-passwordField"
attrs={{ showPasswordTitle: true, hidePasswordTitle: true }}
@@ -19,7 +19,7 @@ function createContextHOC<Context>(
): InferableComponentEnhancer<T> {
return hoistStatics<T>(
<U extends T>(WrappedComponent: React.ComponentType<U>) => {
const Component: React.StatelessComponent<any> = props => (
const Component: React.FunctionComponent<any> = props => (
<Consumer>
{context => (
<WrappedComponent {...props} {...propsCallback(context)} />
+8 -17
View File
@@ -1,17 +1,8 @@
export default (process.env.NODE_ENV !== "development"
? {
admin: "/admin",
embed: {
stream: "/embed/stream",
auth: "/embed/auth",
authCallback: "/embed/auth/callback",
},
}
: {
admin: "/admin",
embed: {
stream: "/stream.html",
auth: "/auth.html",
authCallback: "/auth-callback.html",
},
});
export default {
admin: "/admin",
embed: {
stream: "/embed/stream",
auth: "/embed/auth",
authCallback: "/embed/auth/callback",
},
};
@@ -2,7 +2,7 @@ import { EventEmitter2 } from "eventemitter2";
import { LocalizationProvider } from "fluent-react/compat";
import { FluentBundle } from "fluent/compat";
import { Child as PymChild } from "pym.js";
import React, { StatelessComponent } from "react";
import React, { FunctionComponent } from "react";
import { MediaQueryMatchers } from "react-responsive";
import { Formatter } from "react-timeago";
import { Environment } from "relay-runtime";
@@ -37,7 +37,7 @@ export interface TalkContext {
/** media query values for testing purposes */
mediaQueryValues?: MediaQueryMatchers;
/** Rest client */
/** Rest Client */
rest: RestClient;
/** postMessage service */
@@ -81,7 +81,7 @@ export const TalkContextConsumer = TalkReactContext.Consumer;
* In addition to just providing the context, TalkContextProvider also
* renders the `LocalizationProvider` with the appropite data.
*/
export const TalkContextProvider: StatelessComponent<{
export const TalkContextProvider: FunctionComponent<{
value: TalkContext;
}> = ({ value, children }) => (
<TalkReactContext.Provider value={value}>
@@ -104,7 +104,7 @@ function createRelayEnvironment() {
return { environment, tokenGetter };
}
function createRestAPI(tokenGetter: () => string) {
function createRestClient(tokenGetter: () => string) {
return new RestClient("/api", tokenGetter);
}
@@ -144,7 +144,7 @@ function createMangedTalkContextProvider(
const newContext = {
...this.state.context,
relayEnvironment: newEnvironment,
rest: createRestAPI(newTokenGetter),
rest: createRestClient(newTokenGetter),
};
// Initialize local state.
@@ -244,7 +244,7 @@ export default async function createManaged({
pym,
eventEmitter,
registerClickFarAway,
rest: createRestAPI(tokenGetter),
rest: createRestClient(tokenGetter),
postMessage: new PostMessageService(),
localStorage,
sessionStorage,
@@ -1,8 +1,8 @@
import React, { StatelessComponent } from "react";
import React, { FunctionComponent } from "react";
import styles from "./ExternalLink.css";
const ExternalLink: StatelessComponent<{
const ExternalLink: FunctionComponent<{
href?: string;
children?: string;
}> = ({ href, children }) => (
@@ -1,12 +1,12 @@
import { Middleware } from "react-relay-network-modern/es";
import extractError from "./extractError";
import extractGraphQLError from "./extractGraphQLError";
const customErrorMiddleware: Middleware = next => async req => {
const res = await next(req);
if (res.errors) {
// Extract custom error.
const error = extractError(res.errors);
const error = extractGraphQLError(res.errors);
if (error) {
throw error;
}
@@ -6,21 +6,28 @@ import {
UnknownServerError,
} from "../errors";
export default function extractError(errors: Error[]): Error | null {
if (errors.length > 1 || !(errors[0] as any).extensions) {
// Multiple errors are GraphQL errors.
// TODO: (cvle) Is this assumption correct?
// No extensions == GraphQL error.
// TODO: (cvle) harmonize with server.
return null;
interface TalkError {
type: string;
code: string;
message: string;
}
function isTalkError(err: any): err is TalkError {
return err.type && err.code && err.message;
}
export default function extractError(
err: TalkError,
unknownErrorMessage: string = "Unknown error"
): Error {
if (!isTalkError(err)) {
return new UnknownServerError(unknownErrorMessage, err);
}
// Handle custom errors here.
const err = errors[0];
if ((err as any).extensions.type === ERROR_TYPES.INVALID_REQUEST_ERROR) {
return new InvalidRequestError((err as any).extensions);
if (err.type === ERROR_TYPES.INVALID_REQUEST_ERROR) {
return new InvalidRequestError(err as any);
}
if ((err as any).extensions.type === ERROR_TYPES.MODERATION_NUDGE_ERROR) {
return new ModerationNudgeError((err as any).extensions);
if (err.type === ERROR_TYPES.MODERATION_NUDGE_ERROR) {
return new ModerationNudgeError(err as any);
}
return new UnknownServerError(err.message, (err as any).extensions);
return new UnknownServerError(err.message, err);
}
@@ -0,0 +1,26 @@
import extractError from "./extractError";
interface GraphQLErrorWithExtension {
extensions: any;
}
function isGraphQLErrorWithExtension(
err: any
): err is GraphQLErrorWithExtension {
return Boolean(err && err.extensions);
}
export default function extractGraphQLError(errors: Error[]): Error | null {
if (errors.length > 1) {
// Multiple errors are GraphQL errors.
// TODO: (cvle) Is this assumption correct?
return null;
}
const err = errors[0];
if (!isGraphQLErrorWithExtension(err)) {
// No extensions == GraphQL error.
// TODO: (cvle) harmonize with server.
return null;
}
return extractError(err.extensions, err.message);
}
@@ -1,2 +1,3 @@
export { default as createNetwork, TokenGetter } from "./createNetwork";
export { default as extractGraphQLError } from "./extractGraphQLError";
export { default as extractError } from "./extractError";
@@ -1,9 +1,9 @@
import React, { ComponentType, StatelessComponent } from "react";
import React, { ComponentType, FunctionComponent } from "react";
// TODO: (cvle) This currently a workaround to hide ForwardRef from
// enzyme which does not support it well yet.
export default function hideForwardRef(component: ComponentType) {
const wrapped: StatelessComponent = (props: any) =>
const wrapped: FunctionComponent = (props: any) =>
React.createElement(component, props);
wrapped.displayName = component.displayName;
return wrapped;
+16 -5
View File
@@ -1,6 +1,8 @@
import { merge } from "lodash";
import { Overwrite } from "talk-framework/types";
import { extractError } from "./network";
const buildOptions = (inputOptions: RequestInit = {}) => {
const defaultOptions: RequestInit = {
method: "GET",
@@ -26,8 +28,14 @@ const handleResp = async (res: Response) => {
}
if (!res.ok) {
const response = await res.json();
throw new Error(response.error.message);
const ctype = res.headers.get("content-type");
if (ctype && ctype.includes("application/json")) {
const response = await res.json();
throw extractError(response.error);
} else {
const response = await res.text();
throw new Error(response);
}
}
if (res.status === 204) {
@@ -37,7 +45,9 @@ const handleResp = async (res: Response) => {
return res.json();
};
type PartialRequestInit = Overwrite<Partial<RequestInit>, { body?: any }>;
type PartialRequestInit = Overwrite<Partial<RequestInit>, { body?: any }> & {
token?: string;
};
export class RestClient {
public readonly uri: string;
@@ -53,10 +63,11 @@ export class RestClient {
options: PartialRequestInit
): Promise<T> {
let opts = options;
if (this.tokenGetter) {
const token = options.token || (this.tokenGetter && this.tokenGetter());
if (token) {
opts = merge({}, options, {
headers: {
Authorization: `Bearer ${this.tokenGetter()}`,
Authorization: `Bearer ${token}`,
},
});
}
@@ -1,13 +1,7 @@
import { RouteMatch, RouteProps } from "found";
import * as React from "react";
interface InjectedProps<T> {
error?: Error | null;
data: T | null | undefined;
retry?: Error | null;
}
type RouteConfig<QueryResponse> = Partial<
type RouteConfig<Props = any, QueryResponse = undefined> = Partial<
Pick<RouteProps, "query" | "getQuery">
> &
Partial<Pick<RouteProps, "data" | "getData" | "defer">> & {
@@ -23,14 +17,17 @@ type RouteConfig<QueryResponse> = Partial<
data: QueryResponse | null;
retry: () => void;
match: RouteMatch;
Component: React.ComponentType<any>;
Component: React.ComponentType<Partial<Props>>;
}) => React.ReactElement;
};
function withRouteConfig<QueryResponse>(config: RouteConfig<QueryResponse>) {
const hoc = <T extends InjectedProps<QueryResponse>>(
component: React.ComponentType<T>
) => {
function withRouteConfig<
Props = any,
QueryResponse = Props extends { data: infer T | null | undefined }
? T
: undefined
>(config: RouteConfig<Props, QueryResponse>) {
const hoc = <T>(component: React.ComponentType<T>) => {
(component as any).routeConfig = {
...config,
Component: component,
@@ -9,7 +9,7 @@ import {
createRelayEnvironment,
} from "talk-framework/testHelpers";
import { commit } from "./SetAccessTokenMutation";
import SetAccessTokenMutation from "./SetAccessTokenMutation";
let environment: Environment;
const source: RecordSource = new RecordSource();
@@ -28,7 +28,11 @@ it("Sets auth token to localStorage", async () => {
localStorage: createPromisifiedStorage(),
clearSession: clearSessionStub,
};
await commit(environment, { accessToken }, context as any);
await SetAccessTokenMutation.commit(
environment,
{ accessToken },
context as any
);
expect(source.get(LOCAL_ID)!.accessToken).toEqual(accessToken);
await expect(context.localStorage!.getItem("accessToken")).resolves.toEqual(
accessToken
@@ -43,7 +47,11 @@ it("Removes auth token from localStorage", async () => {
clearSession: clearSessionStub,
};
localStorage.setItem("accessToken", accessToken);
await commit(environment, { accessToken: null }, context as any);
await SetAccessTokenMutation.commit(
environment,
{ accessToken: null },
context as any
);
await expect(
context.localStorage!.getItem("accessToken")
).resolves.toBeNull();
@@ -3,36 +3,32 @@ import { Environment } from "relay-runtime";
import { TalkContext } from "talk-framework/lib/bootstrap";
import {
commitLocalUpdatePromisified,
createMutationContainer,
createMutation,
setAccessTokenInLocalState,
} from "talk-framework/lib/relay";
export interface SetAccessTokenInput {
interface SetAccessTokenInput {
accessToken: string | null;
}
export type SetAccessTokenMutation = (
input: SetAccessTokenInput
) => Promise<void>;
export async function commit(
environment: Environment,
input: SetAccessTokenInput,
{ localStorage, clearSession }: TalkContext
) {
await commitLocalUpdatePromisified(environment, async store => {
setAccessTokenInLocalState(input.accessToken, store);
if (input.accessToken) {
await localStorage.setItem("accessToken", input.accessToken);
} else {
await localStorage.removeItem("accessToken");
}
// Clear current session, as we are starting a new one.
await clearSession();
});
}
export const withSetAccessTokenMutation = createMutationContainer(
const SetAccessTokenMutation = createMutation(
"setAccessToken",
commit
async (
environment: Environment,
input: SetAccessTokenInput,
{ localStorage, clearSession }: TalkContext
) => {
await commitLocalUpdatePromisified(environment, async store => {
setAccessTokenInLocalState(input.accessToken, store);
if (input.accessToken) {
await localStorage.setItem("accessToken", input.accessToken);
} else {
await localStorage.removeItem("accessToken");
}
// Clear current session, as we are starting a new one.
await clearSession();
});
}
);
export default SetAccessTokenMutation;
@@ -2,7 +2,7 @@ import { Environment } from "relay-runtime";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { createMutationContainer } from "talk-framework/lib/relay";
import signOut from "../rest/signOut";
import { commit as setAccessToken } from "./SetAccessTokenMutation";
import SetAccessTokenMutation from "./SetAccessTokenMutation";
export type SignOutMutation = () => Promise<void>;
@@ -12,7 +12,7 @@ export async function commit(
ctx: TalkContext
) {
await signOut(ctx.rest);
await setAccessToken(environment, { accessToken: "" }, ctx);
await SetAccessTokenMutation.commit(environment, { accessToken: "" }, ctx);
}
export const withSignOutMutation = createMutationContainer("signOut", commit);
+1 -5
View File
@@ -1,8 +1,4 @@
export {
withSetAccessTokenMutation,
SetAccessTokenMutation,
SetAccessTokenInput,
} from "./SetAccessTokenMutation";
export { default as SetAccessTokenMutation } from "./SetAccessTokenMutation";
export { withSignOutMutation, SignOutMutation } from "./SignOutMutation";
export {
withSetNetworkStatusMutation,
@@ -0,0 +1,15 @@
import { RestClient } from "../lib/rest";
export interface ForgotPasswordInput {
email: string;
}
export default function forgotPassword(
rest: RestClient,
input: ForgotPasswordInput
) {
return rest.fetch<void>("/auth/local/forgot", {
method: "POST",
body: input,
});
}
+4
View File
@@ -2,3 +2,7 @@ export { default as signIn, SignInInput } from "./signIn";
export { default as signUp, SignUpInput } from "./signUp";
export { default as signOut } from "./signOut";
export { default as install, InstallInput } from "./install";
export {
default as forgotPassword,
ForgotPasswordInput,
} from "./forgotPassword";
@@ -30,7 +30,7 @@ const matcher = (pattern: TextMatchPattern, options?: TextMatchOptions) => (
};
interface SelectorOptions {
selector?: string | React.ComponentClass<any> | React.StatelessComponent<any>;
selector?: string | React.ComponentClass<any> | React.FunctionComponent<any>;
}
export function getByText(
@@ -5,7 +5,7 @@ export default function findParentWithType(
selector:
| string
| React.ComponentClass<any>
| React.StatelessComponent<any> = "*"
| React.FunctionComponent<any> = "*"
): ReactTestInstance | null {
if (selector === "*") {
return instance;
@@ -7,7 +7,7 @@ export default function findParentsWithType(
selector:
| string
| React.ComponentClass<any>
| React.StatelessComponent<any> = "*"
| React.FunctionComponent<any> = "*"
): ReactTestInstance[] {
return instances
.map(i => findParentWithType(i, selector))
+1
View File
@@ -2,3 +2,4 @@ export { default as buildURL } from "./buildURL";
export { default as parseURL } from "./parseURL";
export { default as modifyQuery } from "./modifyQuery";
export { default as areWeInIframe } from "./areWeInIframe";
export { default as parseHashQuery } from "./parseHashQuery";
@@ -1,6 +1,8 @@
import { parseQuery } from "talk-common/utils";
export default function parseQueryHash(hash: string): Record<string, string> {
export default function parseQueryHash(
hash: string
): Record<string, string | undefined> {
let normalized = hash;
if (normalized[0] === "#") {
normalized = normalized.substr(1);