mirror of
https://github.com/wassname/talk.git
synced 2026-07-17 11:33:39 +08:00
Merge branch 'auth-views' of github.com:coralproject/talk into auth-views
* 'auth-views' of github.com:coralproject/talk: (27 commits) feat: added token blacklisting Compile schema when watching client (#1809) TSLint imports ordered Missing console.log Adding variable to the scope and contemplating undefined from the BE Tests updated Updated tests and latest changes User signedIn enters the stream Adding test for FormField, InputDescription and Spinner Updated docs Adding Spinner Component to UI and some corrections to the docs Working validation from the server using CallOuts Working validation from the server using CallOuts Handling error messages Working type submit for forms - Button Component Hitting the delete route and deleting the token SignOff Mutation Progress Working signin and comment Able to Sign In ...
This commit is contained in:
@@ -80,6 +80,7 @@ const config: Config = {
|
||||
"compileCSSTypes",
|
||||
"compileRelayStream",
|
||||
"compileRelayAuth",
|
||||
"compileSchema",
|
||||
],
|
||||
docz: ["runDocz", "compileCSSTypes"],
|
||||
compile: [
|
||||
|
||||
Generated
+280
-221
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,8 @@
|
||||
"lodash": "^4.17.10",
|
||||
"luxon": "^1.3.1",
|
||||
"mongodb": "^3.1.1",
|
||||
"ms": "^2.1.1",
|
||||
"node-fetch": "^2.2.0",
|
||||
"nunjucks": "^3.1.3",
|
||||
"passport": "^0.4.0",
|
||||
"passport-local": "^1.0.0",
|
||||
@@ -101,7 +103,9 @@
|
||||
"@types/luxon": "^0.5.3",
|
||||
"@types/mini-css-extract-plugin": "^0.2.0",
|
||||
"@types/mongodb": "^3.1.1",
|
||||
"@types/ms": "^0.7.30",
|
||||
"@types/node": "^10.5.2",
|
||||
"@types/node-fetch": "^2.1.2",
|
||||
"@types/nunjucks": "^3.0.0",
|
||||
"@types/passport": "^0.4.6",
|
||||
"@types/passport-local": "^1.0.33",
|
||||
|
||||
@@ -66,7 +66,7 @@ async function main() {
|
||||
file.types = await generateTSTypesAsString(schema, {
|
||||
tabSpaces: 2,
|
||||
typePrefix: "GQL",
|
||||
strictNulls: true,
|
||||
strictNulls: false,
|
||||
...file.config,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { Field, Form } from "react-final-form";
|
||||
import { OnSubmit } from "talk-framework/lib/form";
|
||||
import * as styles from "./SignIn.css";
|
||||
|
||||
import {
|
||||
composeValidators,
|
||||
@@ -17,14 +19,18 @@ import {
|
||||
ValidationMessage,
|
||||
} from "talk-ui/components";
|
||||
|
||||
import * as styles from "./ForgotPassword.css";
|
||||
interface FormProps {
|
||||
email: string;
|
||||
}
|
||||
|
||||
const ForgotPassword: StatelessComponent = props => {
|
||||
export interface ForgotPasswordForm {
|
||||
onSubmit: OnSubmit<FormProps>;
|
||||
}
|
||||
|
||||
const ForgotPassword: StatelessComponent<ForgotPasswordForm> = props => {
|
||||
return (
|
||||
// TODO (bc) add functionality when Forgot Password is done on the backend
|
||||
// tslint:disable-next-line:no-empty
|
||||
<Form onSubmit={() => {}}>
|
||||
{({ handleSubmit, submitting }) => (
|
||||
<Form onSubmit={props.onSubmit}>
|
||||
{({ handleSubmit }) => (
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<Flex itemGutter direction="column" className={styles.root}>
|
||||
<Typography variant="heading1" align="center">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { Field, Form } from "react-final-form";
|
||||
import { OnSubmit } from "talk-framework/lib/form";
|
||||
import * as styles from "./SignIn.css";
|
||||
|
||||
import {
|
||||
composeValidators,
|
||||
@@ -12,20 +14,26 @@ import {
|
||||
Button,
|
||||
Flex,
|
||||
FormField,
|
||||
InputDescription,
|
||||
InputLabel,
|
||||
TextField,
|
||||
Typography,
|
||||
ValidationMessage,
|
||||
} from "talk-ui/components";
|
||||
|
||||
import * as styles from "./ResetPassword.css";
|
||||
interface FormProps {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
const ResetPassword: StatelessComponent = props => {
|
||||
export interface ResetPasswordForm {
|
||||
onSubmit: OnSubmit<FormProps>;
|
||||
}
|
||||
|
||||
const ResetPassword: StatelessComponent<ResetPasswordForm> = props => {
|
||||
return (
|
||||
// TODO (bc) add functionality when Reset Password is done on the backend
|
||||
// tslint:disable-next-line:no-empty
|
||||
<Form onSubmit={() => {}}>
|
||||
{({ handleSubmit, submitting }) => (
|
||||
<Form onSubmit={props.onSubmit}>
|
||||
{({ handleSubmit }) => (
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<Flex itemGutter direction="column" className={styles.root}>
|
||||
<Typography variant="heading1" align="center">
|
||||
@@ -38,9 +46,9 @@ const ResetPassword: StatelessComponent = props => {
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<Typography variant="inputDescription">
|
||||
<InputDescription>
|
||||
Must be at least 8 characters
|
||||
</Typography>
|
||||
</InputDescription>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
@@ -69,9 +77,9 @@ const ResetPassword: StatelessComponent = props => {
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<InputLabel>Confirm Password</InputLabel>
|
||||
<Typography variant="inputDescription">
|
||||
<InputDescription>
|
||||
Must be at least 8 characters
|
||||
</Typography>
|
||||
</InputDescription>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { StatelessComponent } from "react";
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { Field, Form } from "react-final-form";
|
||||
import { OnSubmit } from "talk-framework/lib/form";
|
||||
import * as styles from "./SignIn.css";
|
||||
|
||||
import {
|
||||
composeValidators,
|
||||
@@ -13,6 +11,7 @@ import {
|
||||
|
||||
import {
|
||||
Button,
|
||||
CallOut,
|
||||
Flex,
|
||||
FormField,
|
||||
InputLabel,
|
||||
@@ -20,6 +19,8 @@ import {
|
||||
Typography,
|
||||
ValidationMessage,
|
||||
} from "talk-ui/components";
|
||||
import { View } from "../containers/SignInContainer";
|
||||
import * as styles from "./SignIn.css";
|
||||
|
||||
interface FormProps {
|
||||
email: string;
|
||||
@@ -28,6 +29,8 @@ interface FormProps {
|
||||
|
||||
export interface SignInForm {
|
||||
onSubmit: OnSubmit<FormProps>;
|
||||
setView: (view: View) => void;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const SignIn: StatelessComponent<SignInForm> = props => {
|
||||
@@ -39,7 +42,11 @@ const SignIn: StatelessComponent<SignInForm> = props => {
|
||||
<Typography variant="heading1" align="center">
|
||||
Sign in to join the conversation
|
||||
</Typography>
|
||||
|
||||
{props.error && (
|
||||
<CallOut color="error" fullWidth>
|
||||
{props.error}
|
||||
</CallOut>
|
||||
)}
|
||||
<Field
|
||||
name="email"
|
||||
validate={composeValidators(required, validateEmail)}
|
||||
@@ -70,9 +77,6 @@ const SignIn: StatelessComponent<SignInForm> = props => {
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<Typography variant="inputDescription">
|
||||
Must be at least 8 characters
|
||||
</Typography>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
@@ -87,7 +91,14 @@ const SignIn: StatelessComponent<SignInForm> = props => {
|
||||
</ValidationMessage>
|
||||
)}
|
||||
<span className={styles.forgotPassword}>
|
||||
<Button variant="underlined" color="primary" size="small">
|
||||
<Button
|
||||
variant="underlined"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
props.setView("FORGOT_PASSWORD");
|
||||
}}
|
||||
>
|
||||
Forgot your password?
|
||||
</Button>
|
||||
</span>
|
||||
@@ -111,7 +122,14 @@ const SignIn: StatelessComponent<SignInForm> = props => {
|
||||
className={styles.subFooter}
|
||||
>
|
||||
<Typography>Don't have an account?</Typography>
|
||||
<Button variant="underlined" size="small" color="primary">
|
||||
<Button
|
||||
variant="underlined"
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
props.setView("SIGN_UP");
|
||||
}}
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -10,17 +10,19 @@ import {
|
||||
validatePassword,
|
||||
validateUsername,
|
||||
} from "talk-framework/lib/validation";
|
||||
import * as styles from "./SignUp.css";
|
||||
|
||||
import {
|
||||
Button,
|
||||
CallOut,
|
||||
Flex,
|
||||
FormField,
|
||||
InputDescription,
|
||||
InputLabel,
|
||||
TextField,
|
||||
Typography,
|
||||
ValidationMessage,
|
||||
} from "talk-ui/components";
|
||||
import { View } from "../containers/SignUpContainer";
|
||||
import * as styles from "./SignUp.css";
|
||||
|
||||
interface FormProps {
|
||||
email: string;
|
||||
@@ -31,6 +33,8 @@ interface FormProps {
|
||||
|
||||
export interface SignUpForm {
|
||||
onSubmit: OnSubmit<FormProps>;
|
||||
setView: (view: View) => void;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const SignUp: StatelessComponent<SignUpForm> = props => {
|
||||
@@ -43,7 +47,11 @@ const SignUp: StatelessComponent<SignUpForm> = props => {
|
||||
<Typography variant="heading1" align="center">
|
||||
Sign up to join the conversation
|
||||
</Typography>
|
||||
|
||||
{props.error && (
|
||||
<CallOut color="error" fullWidth>
|
||||
{props.error}
|
||||
</CallOut>
|
||||
)}
|
||||
<Field
|
||||
name="email"
|
||||
validate={composeValidators(required, validateEmail)}
|
||||
@@ -74,10 +82,10 @@ const SignUp: StatelessComponent<SignUpForm> = props => {
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<InputLabel>Username</InputLabel>
|
||||
<Typography variant="inputDescription">
|
||||
<InputDescription>
|
||||
A unique identifier displayed on your comments. You may
|
||||
use “_” and “.”
|
||||
</Typography>
|
||||
</InputDescription>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
@@ -101,9 +109,9 @@ const SignUp: StatelessComponent<SignUpForm> = props => {
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<Typography variant="inputDescription">
|
||||
<InputDescription>
|
||||
Must be at least 8 characters
|
||||
</Typography>
|
||||
</InputDescription>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
@@ -132,9 +140,9 @@ const SignUp: StatelessComponent<SignUpForm> = props => {
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<InputLabel>Confirm Password</InputLabel>
|
||||
<Typography variant="inputDescription">
|
||||
<InputDescription>
|
||||
Must be at least 8 characters
|
||||
</Typography>
|
||||
</InputDescription>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
@@ -168,7 +176,14 @@ const SignUp: StatelessComponent<SignUpForm> = props => {
|
||||
className={styles.subFooter}
|
||||
>
|
||||
<Typography>Already have an account?</Typography>
|
||||
<Button variant="underlined" size="small" color="primary">
|
||||
<Button
|
||||
variant="underlined"
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
props.setView("SIGN_IN");
|
||||
}}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -3,7 +3,8 @@ import ForgotPassword from "../components/ForgotPassword";
|
||||
|
||||
class ForgotPasswordContainer extends Component {
|
||||
public render() {
|
||||
return <ForgotPassword />;
|
||||
// tslint:disable-next-line:no-empty
|
||||
return <ForgotPassword onSubmit={() => {}} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import ResetPassword from "../components/ResetPassword";
|
||||
|
||||
class ResetPasswordContainer extends Component {
|
||||
public render() {
|
||||
return <ResetPassword />;
|
||||
// tslint:disable-next-line:no-empty
|
||||
return <ResetPassword onSubmit={() => {}} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import { BadUserInputError } from "talk-framework/lib/errors";
|
||||
import SignIn, { SignInForm } from "../components/SignIn";
|
||||
|
||||
import React, { Component } from "react";
|
||||
import { SignInMutation, withSignInMutation } from "../mutations";
|
||||
import { BadUserInputError } from "talk-framework/lib/errors";
|
||||
|
||||
import SignIn, { SignInForm } from "../components/SignIn";
|
||||
import {
|
||||
SetViewMutation,
|
||||
SignInMutation,
|
||||
withSetViewMutation,
|
||||
withSignInMutation,
|
||||
} from "../mutations";
|
||||
|
||||
interface SignInContainerProps {
|
||||
signIn: SignInMutation;
|
||||
setView: SetViewMutation;
|
||||
}
|
||||
|
||||
class SignInContainer extends Component<SignInContainerProps> {
|
||||
interface SignUpContainerState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type View = "SIGN_UP" | "FORGOT_PASSWORD";
|
||||
|
||||
class SignInContainer extends Component<
|
||||
SignInContainerProps,
|
||||
SignUpContainerState
|
||||
> {
|
||||
public state = { error: null };
|
||||
private setView = (view: View) => {
|
||||
this.props.setView({
|
||||
view,
|
||||
});
|
||||
};
|
||||
private onSubmit: SignInForm["onSubmit"] = async (input, form) => {
|
||||
try {
|
||||
await this.props.signIn(input);
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
this.setState({ error: error.message });
|
||||
if (error instanceof BadUserInputError) {
|
||||
return error.invalidArgsLocalized;
|
||||
}
|
||||
@@ -23,9 +45,15 @@ class SignInContainer extends Component<SignInContainerProps> {
|
||||
return undefined;
|
||||
};
|
||||
public render() {
|
||||
return <SignIn onSubmit={this.onSubmit} />;
|
||||
return (
|
||||
<SignIn
|
||||
onSubmit={this.onSubmit}
|
||||
setView={this.setView}
|
||||
error={this.state.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withSignInMutation(SignInContainer);
|
||||
const enhanced = withSetViewMutation(withSignInMutation(SignInContainer));
|
||||
export default enhanced;
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import React, { Component } from "react";
|
||||
import { BadUserInputError } from "talk-framework/lib/errors";
|
||||
import SignUp, { SignUpForm } from "../components/SignUp";
|
||||
|
||||
import React, { Component } from "react";
|
||||
import { SignUpMutation, withSignUpMutation } from "../mutations";
|
||||
import {
|
||||
SetViewMutation,
|
||||
SignUpMutation,
|
||||
withSetViewMutation,
|
||||
withSignUpMutation,
|
||||
} from "../mutations";
|
||||
|
||||
interface SignUpContainerProps {
|
||||
signUp: SignUpMutation;
|
||||
setView: SetViewMutation;
|
||||
}
|
||||
|
||||
class SignUpContainer extends Component<SignUpContainerProps> {
|
||||
interface SignUpContainerState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export type View = "SIGN_IN";
|
||||
|
||||
class SignUpContainer extends Component<
|
||||
SignUpContainerProps,
|
||||
SignUpContainerState
|
||||
> {
|
||||
public state = { error: "" };
|
||||
private setView = (view: View) => {
|
||||
this.props.setView({
|
||||
view,
|
||||
});
|
||||
};
|
||||
private onSubmit: SignUpForm["onSubmit"] = async (input, form) => {
|
||||
try {
|
||||
await this.props.signUp(input);
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
this.setState({ error: error.message });
|
||||
if (error instanceof BadUserInputError) {
|
||||
return error.invalidArgsLocalized;
|
||||
}
|
||||
@@ -23,9 +45,15 @@ class SignUpContainer extends Component<SignUpContainerProps> {
|
||||
return undefined;
|
||||
};
|
||||
public render() {
|
||||
return <SignUp onSubmit={this.onSubmit} />;
|
||||
return (
|
||||
<SignUp
|
||||
onSubmit={this.onSubmit}
|
||||
setView={this.setView}
|
||||
error={this.state.error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withSignUpMutation(SignUpContainer);
|
||||
const enhanced = withSetViewMutation(withSignUpMutation(SignUpContainer));
|
||||
export default enhanced;
|
||||
|
||||
@@ -17,6 +17,7 @@ export async function commit(
|
||||
window.close();
|
||||
} catch (err) {
|
||||
postMessage.send("authError", err.toString(), window.opener);
|
||||
throw Error(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { commitLocalUpdate, Environment } from "relay-runtime";
|
||||
import { TalkContext } from "talk-framework/lib/bootstrap";
|
||||
import { createMutationContainer } from "talk-framework/lib/relay";
|
||||
import { LOCAL_ID } from "talk-framework/lib/relay/withLocalStateContainer";
|
||||
import { SignOffInput } from "talk-framework/rest";
|
||||
|
||||
export type SignOffMutation = (input: SignOffInput) => Promise<void>;
|
||||
|
||||
export async function commit(
|
||||
environment: Environment,
|
||||
input: SignOffInput,
|
||||
{ localStorage }: TalkContext
|
||||
) {
|
||||
return commitLocalUpdate(environment, store => {
|
||||
const record = store.get(LOCAL_ID)!;
|
||||
record.setValue("", "authToken");
|
||||
localStorage.removeItem("authToken");
|
||||
|
||||
// Force gc to trigger.
|
||||
environment
|
||||
.retain({
|
||||
dataID: "tmp",
|
||||
node: { selections: [] },
|
||||
variables: {},
|
||||
})
|
||||
.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
export const withSignOffMutation = createMutationContainer("signOff", commit);
|
||||
@@ -17,6 +17,7 @@ export async function commit(
|
||||
window.close();
|
||||
} catch (err) {
|
||||
postMessage.send("authError", err.toString(), window.opener);
|
||||
throw Error(err.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { withSetViewMutation, SetViewMutation } from "./SetViewMutation";
|
||||
export { withSignInMutation, SignInMutation } from "./SignInMutation";
|
||||
export { withSignUpMutation, SignUpMutation } from "./SignUpMutation";
|
||||
export { withSignOffMutation, SignOffMutation } from "./SignOffMutation";
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface ExceptionError {
|
||||
message: string;
|
||||
stack: string;
|
||||
}
|
||||
@@ -2,4 +2,6 @@ export { default as NetworkError } from "./networkError";
|
||||
export { default as UnknownServerError } from "./unknownServerError";
|
||||
export { default as BadUserInputError } from "./badUserInputError";
|
||||
export { default as GraphQLError } from "./graphqlError";
|
||||
export { default as ExceptionError } from "./exceptionError";
|
||||
|
||||
export * from "./graphqlError";
|
||||
|
||||
@@ -17,22 +17,25 @@ const buildOptions = (inputOptions: RequestInit = {}) => {
|
||||
return options;
|
||||
};
|
||||
|
||||
const handleResp = (res: Response) => {
|
||||
if (res.status > 399) {
|
||||
return res.json().then((err: any) => {
|
||||
// TODO: sync error handling with server.
|
||||
const message = err.message || err.error || res.status;
|
||||
const error = new Error(message);
|
||||
throw error;
|
||||
});
|
||||
} else if (res.status === 204) {
|
||||
return res.text();
|
||||
} else {
|
||||
return res.json();
|
||||
const handleResp = async (res: Response) => {
|
||||
if (res.status === 404) {
|
||||
const response = await res.text();
|
||||
throw new Error(response);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const response = await res.json();
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return res.text();
|
||||
}
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
type PartialRequestInit = Overwrite<Partial<RequestInit>, { body: any }>;
|
||||
type PartialRequestInit = Overwrite<Partial<RequestInit>, { body?: any }>;
|
||||
|
||||
export class RestClient {
|
||||
public readonly uri: string;
|
||||
@@ -43,7 +46,7 @@ export class RestClient {
|
||||
this.tokenGetter = tokenGetter;
|
||||
}
|
||||
|
||||
public fetch<T>(path: string, options: PartialRequestInit): Promise<T> {
|
||||
public async fetch<T>(path: string, options: PartialRequestInit): Promise<T> {
|
||||
let opts = options;
|
||||
if (this.tokenGetter) {
|
||||
opts = merge({}, options, {
|
||||
@@ -52,6 +55,7 @@ export class RestClient {
|
||||
},
|
||||
});
|
||||
}
|
||||
return fetch(`${this.uri}${path}`, buildOptions(opts)).then(handleResp);
|
||||
const response = await fetch(`${this.uri}${path}`, buildOptions(opts));
|
||||
return handleResp(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as signIn, SignInInput } from "./signIn";
|
||||
export { default as signUp, SignUpInput } from "./signUp";
|
||||
export { default as signOff, SignOffInput } from "./signOff";
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { RestClient } from "../lib/rest";
|
||||
|
||||
// tslint:disable-next-line:no-empty-interface
|
||||
export interface SignOffInput {}
|
||||
|
||||
// tslint:disable-next-line:no-empty-interface
|
||||
export interface SignOffResponse {}
|
||||
|
||||
export default function signOff(rest: RestClient, input: SignOffInput) {
|
||||
return rest.fetch<SignOffResponse>("/tenant/auth/local", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import * as React from "react";
|
||||
import { StatelessComponent } from "react";
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { Field, Form } from "react-final-form";
|
||||
|
||||
import { OnSubmit } from "talk-framework/lib/form";
|
||||
import { required } from "talk-framework/lib/validation";
|
||||
import PoweredBy from "talk-stream/components/PoweredBy";
|
||||
import { Button, Typography } from "talk-ui/components";
|
||||
import * as styles from "./PostCommentForm.css";
|
||||
import PoweredBy from "./PoweredBy";
|
||||
|
||||
interface FormProps {
|
||||
body: string;
|
||||
@@ -58,7 +56,7 @@ const PostCommentForm: StatelessComponent<PostCommentFormProps> = props => (
|
||||
fullWidth
|
||||
size="large"
|
||||
>
|
||||
Sign In and join the conversation
|
||||
Sign in and join the conversation
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -10,6 +10,8 @@ import ReplyListContainer from "../containers/ReplyListContainer";
|
||||
import UserBoxContainer from "../containers/UserBoxContainer";
|
||||
import * as styles from "./Stream.css";
|
||||
|
||||
import { User } from "../containers/UserBoxContainer";
|
||||
|
||||
export interface StreamProps {
|
||||
assetID: string;
|
||||
isClosed?: boolean;
|
||||
@@ -17,13 +19,17 @@ export interface StreamProps {
|
||||
onLoadMore?: () => void;
|
||||
hasMore?: boolean;
|
||||
disableLoadMore?: boolean;
|
||||
user: User | null | undefined;
|
||||
}
|
||||
|
||||
const Stream: StatelessComponent<StreamProps> = props => {
|
||||
return (
|
||||
<Flex className={styles.root} direction="column" itemGutter>
|
||||
<UserBoxContainer />
|
||||
<PostCommentFormContainer assetID={props.assetID} />
|
||||
<UserBoxContainer user={props.user} />
|
||||
<PostCommentFormContainer
|
||||
assetID={props.assetID}
|
||||
signedIn={!!props.user}
|
||||
/>
|
||||
<Flex
|
||||
direction="column"
|
||||
id="talk-comments-stream-log"
|
||||
|
||||
@@ -4,17 +4,19 @@ import React, { StatelessComponent } from "react";
|
||||
import { Button, Flex, Typography } from "talk-ui/components";
|
||||
|
||||
import MatchMedia from "talk-ui/components/MatchMedia";
|
||||
import { User } from "../containers/UserBoxContainer";
|
||||
import * as styles from "./UserBoxAuthenticated.css";
|
||||
|
||||
export interface UserBoxUnauthenticatedProps {
|
||||
onSignOut: () => void;
|
||||
export interface UserBoxAuthenticatedProps {
|
||||
onSignOff: () => void;
|
||||
user: User;
|
||||
}
|
||||
|
||||
const UserBoxAuthenticated: StatelessComponent<
|
||||
UserBoxUnauthenticatedProps
|
||||
UserBoxAuthenticatedProps
|
||||
> = props => {
|
||||
return (
|
||||
<Flex itemGutter>
|
||||
<Flex itemGutter="half">
|
||||
<Flex itemGutter="half" className={styles.child}>
|
||||
<MatchMedia gteWidth="sm">
|
||||
<Localized id="comments-userBoxAuthenticated-signedInAs">
|
||||
@@ -23,7 +25,7 @@ const UserBoxAuthenticated: StatelessComponent<
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Typography variant="bodyCopyBold" component="span">
|
||||
okbel
|
||||
{props.user.username}
|
||||
</Typography>
|
||||
</MatchMedia>
|
||||
</Flex>
|
||||
@@ -38,7 +40,7 @@ const UserBoxAuthenticated: StatelessComponent<
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="underlined"
|
||||
onClick={props.onSignOut}
|
||||
onClick={props.onSignOff}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
|
||||
@@ -9,6 +9,7 @@ exports[`renders correctly 1`] = `
|
||||
<withContext(createMutationContainer(withContext(createMutationContainer(withContext(withLocalStateContainer(UserBoxContainer)))))) />
|
||||
<withContext(createMutationContainer(PostCommentFormContainer))
|
||||
assetID="asset-id"
|
||||
signedIn={false}
|
||||
/>
|
||||
<withPropsOnChange(Flex)
|
||||
aria-live="polite"
|
||||
@@ -70,6 +71,7 @@ exports[`when there is more disables load more button 1`] = `
|
||||
<withContext(createMutationContainer(withContext(createMutationContainer(withContext(withLocalStateContainer(UserBoxContainer)))))) />
|
||||
<withContext(createMutationContainer(PostCommentFormContainer))
|
||||
assetID="asset-id"
|
||||
signedIn={false}
|
||||
/>
|
||||
<withPropsOnChange(Flex)
|
||||
aria-live="polite"
|
||||
@@ -145,6 +147,7 @@ exports[`when there is more renders a load more button 1`] = `
|
||||
<withContext(createMutationContainer(withContext(createMutationContainer(withContext(withLocalStateContainer(UserBoxContainer)))))) />
|
||||
<withContext(createMutationContainer(PostCommentFormContainer))
|
||||
assetID="asset-id"
|
||||
signedIn={false}
|
||||
/>
|
||||
<withPropsOnChange(Flex)
|
||||
aria-live="polite"
|
||||
|
||||
+4
-1
@@ -1,7 +1,9 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<withPropsOnChange(Flex)>
|
||||
<withPropsOnChange(Flex)
|
||||
itemGutter={true}
|
||||
>
|
||||
<MatchMediaWithContext
|
||||
gteWidth="sm"
|
||||
>
|
||||
@@ -30,6 +32,7 @@ exports[`renders correctly 1`] = `
|
||||
color="primary"
|
||||
onClick={[Function]}
|
||||
size="small"
|
||||
variant="underlined"
|
||||
>
|
||||
Sign in
|
||||
</withPropsOnChange(Button)>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CreateCommentMutation, withCreateCommentMutation } from "../mutations";
|
||||
interface InnerProps {
|
||||
createComment: CreateCommentMutation;
|
||||
assetID: string;
|
||||
signedIn: boolean;
|
||||
}
|
||||
|
||||
class PostCommentFormContainer extends Component<InnerProps> {
|
||||
@@ -31,7 +32,12 @@ class PostCommentFormContainer extends Component<InnerProps> {
|
||||
return undefined;
|
||||
};
|
||||
public render() {
|
||||
return <PostCommentForm onSubmit={this.onSubmit} signedIn={false} />;
|
||||
return (
|
||||
<PostCommentForm
|
||||
onSubmit={this.onSubmit}
|
||||
signedIn={this.props.signedIn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
} from "talk-stream/__generated__/StreamContainerPaginationQuery.graphql";
|
||||
|
||||
import Stream from "../components/Stream";
|
||||
import { User } from "../containers/UserBoxContainer";
|
||||
|
||||
interface InnerProps {
|
||||
asset: Data;
|
||||
user: User | null | undefined;
|
||||
relay: RelayPaginationProp;
|
||||
}
|
||||
|
||||
@@ -31,6 +33,7 @@ export class StreamContainer extends React.Component<InnerProps> {
|
||||
onLoadMore={this.loadMore}
|
||||
hasMore={this.props.relay.hasMore()}
|
||||
disableLoadMore={this.state.disableLoadMore}
|
||||
user={this.props.user}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React, { Component } from "react";
|
||||
import UserBoxAuthenticated from "talk-stream/components/UserBoxAuthenticated";
|
||||
import { SignOffMutation, withSignOffMutation } from "../../auth/mutations";
|
||||
import { User } from "../containers/UserBoxContainer";
|
||||
|
||||
interface UserBoxAuthenticatedProps {
|
||||
signOff: SignOffMutation;
|
||||
user: User;
|
||||
}
|
||||
|
||||
class UserBoxAuthenticatedContainer extends Component<
|
||||
UserBoxAuthenticatedProps
|
||||
> {
|
||||
private onSignOff = () => {
|
||||
this.props.signOff({});
|
||||
};
|
||||
public render() {
|
||||
return (
|
||||
<UserBoxAuthenticated onSignOff={this.onSignOff} user={this.props.user} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withSignOffMutation(UserBoxAuthenticatedContainer);
|
||||
export default enhanced;
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { Component } from "react";
|
||||
|
||||
import { graphql, withLocalStateContainer } from "talk-framework/lib/relay";
|
||||
import { UserBoxContainerLocal as Local } from "talk-stream/__generated__/UserBoxContainerLocal.graphql";
|
||||
import {
|
||||
@@ -11,11 +10,26 @@ import {
|
||||
} from "talk-stream/mutations";
|
||||
import { Popup } from "talk-ui/components";
|
||||
|
||||
// import UserBoxAuthenticated from "../components/UserBoxAuthenticated";
|
||||
import UserBoxUnauthenticated from "../components/UserBoxUnauthenticated";
|
||||
import UserBoxUnauthenticated from "talk-stream/components/UserBoxUnauthenticated";
|
||||
import UserBoxAuthenticatedContainer from "../containers/UserBoxAuthenticatedContainer";
|
||||
|
||||
export type USER_ROLE =
|
||||
| "ADMIN"
|
||||
| "COMMENTER"
|
||||
| "MODERATOR"
|
||||
| "STAFF"
|
||||
| "%future added value";
|
||||
|
||||
export interface User {
|
||||
id?: string;
|
||||
username?: string | null;
|
||||
displayName?: string | null;
|
||||
role?: USER_ROLE;
|
||||
}
|
||||
|
||||
interface InnerProps {
|
||||
local: Local;
|
||||
user: User | null | undefined;
|
||||
showAuthPopup: ShowAuthPopupMutation;
|
||||
setAuthPopupState: SetAuthPopupStateMutation;
|
||||
}
|
||||
@@ -32,7 +46,13 @@ export class UserBoxContainer extends Component<InnerProps> {
|
||||
local: {
|
||||
authPopup: { open, focus, view },
|
||||
},
|
||||
user,
|
||||
} = this.props;
|
||||
|
||||
if (user) {
|
||||
return <UserBoxAuthenticatedContainer user={user} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup
|
||||
@@ -45,7 +65,6 @@ export class UserBoxContainer extends Component<InnerProps> {
|
||||
onBlur={this.handleBlur}
|
||||
onClose={this.handleClose}
|
||||
/>
|
||||
{/* <UserBoxAuthenticated onSignOut={this.handleSignOut} /> */}
|
||||
<UserBoxUnauthenticated
|
||||
onSignIn={this.handleSignIn}
|
||||
onRegister={this.handleRegister}
|
||||
@@ -71,4 +90,5 @@ const enhanced = withSetAuthPopupStateMutation(
|
||||
)
|
||||
);
|
||||
|
||||
// TODO: (bc) Add fragment here if composing is doable.
|
||||
export default enhanced;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
exports[`renders correctly 1`] = `
|
||||
<React.Fragment>
|
||||
<Popup
|
||||
features="menubar=0,resizable=0,width=500,height=550,top=200,left=500"
|
||||
features="menubar=0,resizable=0,width=350,height=600,top=200,left=500"
|
||||
focus={false}
|
||||
href="/auth.html?view=SIGN_IN"
|
||||
onBlur={[Function]}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "talk-stream/__generated__/PermalinkViewQuery.graphql";
|
||||
import { PermalinkViewQueryLocal as Local } from "talk-stream/__generated__/PermalinkViewQueryLocal.graphql";
|
||||
|
||||
import { Spinner } from "talk-ui/components";
|
||||
import PermalinkViewContainer from "../containers/PermalinkViewContainer";
|
||||
|
||||
interface InnerProps {
|
||||
@@ -29,7 +30,7 @@ export const render = ({
|
||||
if (props) {
|
||||
return <PermalinkViewContainer comment={props.comment} />;
|
||||
}
|
||||
return <div>Loading</div>;
|
||||
return <Spinner />;
|
||||
};
|
||||
|
||||
const PermalinkViewQuery: StatelessComponent<InnerProps> = ({
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { StatelessComponent } from "react";
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { ReadyState } from "react-relay";
|
||||
|
||||
import {
|
||||
graphql,
|
||||
QueryRenderer,
|
||||
@@ -12,7 +10,7 @@ import {
|
||||
StreamQueryVariables,
|
||||
} from "talk-stream/__generated__/StreamQuery.graphql";
|
||||
import { StreamQueryLocal as Local } from "talk-stream/__generated__/StreamQueryLocal.graphql";
|
||||
|
||||
import { Spinner } from "talk-ui/components";
|
||||
import StreamContainer from "../containers/StreamContainer";
|
||||
|
||||
interface InnerProps {
|
||||
@@ -23,25 +21,34 @@ export const render = ({ error, props }: ReadyState<StreamQueryResponse>) => {
|
||||
if (error) {
|
||||
return <div>{error.message}</div>;
|
||||
}
|
||||
|
||||
if (props) {
|
||||
return <StreamContainer asset={props.asset} />;
|
||||
return <StreamContainer asset={props.asset} user={props.me} />;
|
||||
}
|
||||
return <div>Loading</div>;
|
||||
|
||||
return <Spinner />;
|
||||
};
|
||||
|
||||
const StreamQuery: StatelessComponent<InnerProps> = ({
|
||||
local: { assetID },
|
||||
local: { assetID, authToken },
|
||||
}) => (
|
||||
<QueryRenderer<StreamQueryVariables, StreamQueryResponse>
|
||||
query={graphql`
|
||||
query StreamQuery($assetID: ID!) {
|
||||
query StreamQuery($assetID: ID!, $signedIn: Boolean!) {
|
||||
asset(id: $assetID) {
|
||||
...StreamContainer_asset
|
||||
}
|
||||
me @include(if: $signedIn) {
|
||||
id
|
||||
username
|
||||
displayName
|
||||
role
|
||||
}
|
||||
}
|
||||
`}
|
||||
variables={{
|
||||
assetID,
|
||||
signedIn: !!authToken,
|
||||
}}
|
||||
render={render}
|
||||
/>
|
||||
@@ -51,6 +58,7 @@ const enhanced = withLocalStateContainer<Local>(
|
||||
graphql`
|
||||
fragment StreamQueryLocal on Local {
|
||||
assetID
|
||||
authToken
|
||||
}
|
||||
`
|
||||
)(StreamQuery);
|
||||
|
||||
@@ -6,11 +6,7 @@ exports[`renders error 1`] = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`renders loading 1`] = `
|
||||
<div>
|
||||
Loading
|
||||
</div>
|
||||
`;
|
||||
exports[`renders loading 1`] = `<withPropsOnChange(Spinner) />`;
|
||||
|
||||
exports[`renders permalink view container 1`] = `
|
||||
<withContext(withContext(createMutationContainer(Relay(PermalinkViewContainer))))
|
||||
|
||||
@@ -6,11 +6,7 @@ exports[`renders error 1`] = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`renders loading 1`] = `
|
||||
<div>
|
||||
Loading
|
||||
</div>
|
||||
`;
|
||||
exports[`renders loading 1`] = `<withPropsOnChange(Spinner) />`;
|
||||
|
||||
exports[`renders stream container 1`] = `
|
||||
<Relay(StreamContainer)
|
||||
|
||||
@@ -17,10 +17,10 @@ exports[`loads more comments 1`] = `
|
||||
className="Flex-root"
|
||||
>
|
||||
<div
|
||||
className="Flex-flex Flex-wrap Flex-alignCenter"
|
||||
className="Flex-flex Flex-itemGutter Flex-wrap Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantRegular"
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantUnderlined"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -28,6 +28,7 @@ exports[`loads more comments 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
@@ -40,6 +41,7 @@ exports[`loads more comments 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
@@ -55,25 +57,23 @@ exports[`loads more comments 1`] = `
|
||||
className="PostCommentForm-textarea"
|
||||
name="body"
|
||||
onChange={[Function]}
|
||||
placeholder="Post a comment"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="PostCommentForm-postButtonContainer"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth Button-disabled"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
Sign in and join the conversation
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
aria-live="polite"
|
||||
@@ -232,10 +232,10 @@ exports[`renders comment stream 1`] = `
|
||||
className="Flex-root"
|
||||
>
|
||||
<div
|
||||
className="Flex-flex Flex-wrap Flex-alignCenter"
|
||||
className="Flex-flex Flex-itemGutter Flex-wrap Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantRegular"
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantUnderlined"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -243,6 +243,7 @@ exports[`renders comment stream 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
@@ -255,6 +256,7 @@ exports[`renders comment stream 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
@@ -270,25 +272,23 @@ exports[`renders comment stream 1`] = `
|
||||
className="PostCommentForm-textarea"
|
||||
name="body"
|
||||
onChange={[Function]}
|
||||
placeholder="Post a comment"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="PostCommentForm-postButtonContainer"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth Button-disabled"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
Sign in and join the conversation
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
aria-live="polite"
|
||||
@@ -393,6 +393,7 @@ exports[`renders comment stream 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
|
||||
@@ -22,6 +22,7 @@ exports[`renders permalink view 1`] = `
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
target="_parent"
|
||||
type="button"
|
||||
>
|
||||
Show all Comments
|
||||
</a>
|
||||
@@ -88,10 +89,10 @@ exports[`show all comments 1`] = `
|
||||
className="Flex-root"
|
||||
>
|
||||
<div
|
||||
className="Flex-flex Flex-wrap Flex-alignCenter"
|
||||
className="Flex-flex Flex-itemGutter Flex-wrap Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantRegular"
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantUnderlined"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -99,6 +100,7 @@ exports[`show all comments 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
@@ -111,6 +113,7 @@ exports[`show all comments 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
@@ -126,25 +129,23 @@ exports[`show all comments 1`] = `
|
||||
className="PostCommentForm-textarea"
|
||||
name="body"
|
||||
onChange={[Function]}
|
||||
placeholder="Post a comment"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="PostCommentForm-postButtonContainer"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth Button-disabled"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
Sign in and join the conversation
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
aria-live="polite"
|
||||
|
||||
@@ -22,6 +22,7 @@ exports[`renders permalink view with unknown asset 1`] = `
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
target="_parent"
|
||||
type="button"
|
||||
>
|
||||
Show all Comments
|
||||
</a>
|
||||
|
||||
+18
-17
@@ -22,6 +22,7 @@ exports[`renders permalink view with unknown comment 1`] = `
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
target="_parent"
|
||||
type="button"
|
||||
>
|
||||
Show all Comments
|
||||
</a>
|
||||
@@ -52,10 +53,10 @@ exports[`show all comments 1`] = `
|
||||
className="Flex-root"
|
||||
>
|
||||
<div
|
||||
className="Flex-flex Flex-wrap Flex-alignCenter"
|
||||
className="Flex-flex Flex-itemGutter Flex-wrap Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantRegular"
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantUnderlined"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -63,6 +64,7 @@ exports[`show all comments 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
@@ -75,6 +77,7 @@ exports[`show all comments 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
@@ -90,25 +93,23 @@ exports[`show all comments 1`] = `
|
||||
className="PostCommentForm-textarea"
|
||||
name="body"
|
||||
onChange={[Function]}
|
||||
placeholder="Post a comment"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="PostCommentForm-postButtonContainer"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth Button-disabled"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
Sign in and join the conversation
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
aria-live="polite"
|
||||
|
||||
@@ -17,10 +17,10 @@ exports[`renders comment stream 1`] = `
|
||||
className="Flex-root"
|
||||
>
|
||||
<div
|
||||
className="Flex-flex Flex-wrap Flex-alignCenter"
|
||||
className="Flex-flex Flex-itemGutter Flex-wrap Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantRegular"
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantUnderlined"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -28,6 +28,7 @@ exports[`renders comment stream 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
@@ -40,6 +41,7 @@ exports[`renders comment stream 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
@@ -55,25 +57,23 @@ exports[`renders comment stream 1`] = `
|
||||
className="PostCommentForm-textarea"
|
||||
name="body"
|
||||
onChange={[Function]}
|
||||
placeholder="Post a comment"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="PostCommentForm-postButtonContainer"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth Button-disabled"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
Sign in and join the conversation
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
aria-live="polite"
|
||||
|
||||
@@ -17,10 +17,10 @@ exports[`renders comment stream 1`] = `
|
||||
className="Flex-root"
|
||||
>
|
||||
<div
|
||||
className="Flex-flex Flex-wrap Flex-alignCenter"
|
||||
className="Flex-flex Flex-itemGutter Flex-wrap Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantRegular"
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantUnderlined"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -28,6 +28,7 @@ exports[`renders comment stream 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
@@ -40,6 +41,7 @@ exports[`renders comment stream 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
@@ -55,25 +57,23 @@ exports[`renders comment stream 1`] = `
|
||||
className="PostCommentForm-textarea"
|
||||
name="body"
|
||||
onChange={[Function]}
|
||||
placeholder="Post a comment"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="PostCommentForm-postButtonContainer"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth Button-disabled"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
Sign in and join the conversation
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
aria-live="polite"
|
||||
|
||||
@@ -17,10 +17,10 @@ exports[`renders comment stream 1`] = `
|
||||
className="Flex-root"
|
||||
>
|
||||
<div
|
||||
className="Flex-flex Flex-wrap Flex-alignCenter"
|
||||
className="Flex-flex Flex-itemGutter Flex-wrap Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantRegular"
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantUnderlined"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -28,6 +28,7 @@ exports[`renders comment stream 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
@@ -40,6 +41,7 @@ exports[`renders comment stream 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
@@ -55,25 +57,23 @@ exports[`renders comment stream 1`] = `
|
||||
className="PostCommentForm-textarea"
|
||||
name="body"
|
||||
onChange={[Function]}
|
||||
placeholder="Post a comment"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="PostCommentForm-postButtonContainer"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth Button-disabled"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
Sign in and join the conversation
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
aria-live="polite"
|
||||
@@ -179,6 +179,7 @@ exports[`renders comment stream 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Show All Replies
|
||||
</button>
|
||||
@@ -212,10 +213,10 @@ exports[`show all replies 1`] = `
|
||||
className="Flex-root"
|
||||
>
|
||||
<div
|
||||
className="Flex-flex Flex-wrap Flex-alignCenter"
|
||||
className="Flex-flex Flex-itemGutter Flex-wrap Flex-alignCenter"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantRegular"
|
||||
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantUnderlined"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
@@ -223,6 +224,7 @@ exports[`show all replies 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
@@ -235,6 +237,7 @@ exports[`show all replies 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
@@ -250,25 +253,23 @@ exports[`show all replies 1`] = `
|
||||
className="PostCommentForm-textarea"
|
||||
name="body"
|
||||
onChange={[Function]}
|
||||
placeholder="Post a comment"
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="PostCommentForm-postButtonContainer"
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeLarge Button-colorPrimary Button-variantFilled Button-fullWidth Button-disabled"
|
||||
disabled={true}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseDown={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
Sign in and join the conversation
|
||||
</button>
|
||||
</form>
|
||||
<div
|
||||
aria-live="polite"
|
||||
|
||||
@@ -32,6 +32,8 @@ interface InnerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
|
||||
/** Internal: Forwarded Ref */
|
||||
forwardRef?: Ref<HTMLButtonElement>;
|
||||
|
||||
type?: "submit" | "reset" | "button";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,15 +47,15 @@ const BaseButton: StatelessComponent<InnerProps> = ({
|
||||
keyboardFocus,
|
||||
mouseHover,
|
||||
forwardRef,
|
||||
type: typeProp,
|
||||
type,
|
||||
...rest
|
||||
}) => {
|
||||
let Element = "button";
|
||||
|
||||
if (anchor) {
|
||||
Element = "a";
|
||||
}
|
||||
|
||||
let type = typeProp;
|
||||
if (anchor && type) {
|
||||
// tslint:disable:next-line: no-console
|
||||
console.warn(
|
||||
@@ -69,7 +71,9 @@ const BaseButton: StatelessComponent<InnerProps> = ({
|
||||
[classes.mouseHover]: mouseHover,
|
||||
});
|
||||
|
||||
return <Element {...rest} className={rootClassName} ref={forwardRef} />;
|
||||
return (
|
||||
<Element {...rest} className={rootClassName} ref={forwardRef} type={type} />
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withForwardRef(
|
||||
|
||||
@@ -9,6 +9,7 @@ exports[`renders as anchor 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Push Me
|
||||
</a>
|
||||
@@ -23,6 +24,7 @@ exports[`renders correctly 1`] = `
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Push Me
|
||||
</button>
|
||||
|
||||
@@ -36,6 +36,8 @@ interface InnerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** If set renders active state e.g. to implement toggle buttons */
|
||||
active?: boolean;
|
||||
|
||||
type?: "submit" | "reset" | "button";
|
||||
|
||||
/** Internal: Forwarded Ref */
|
||||
forwardRef?: Ref<HTMLButtonElement>;
|
||||
}
|
||||
@@ -57,6 +59,7 @@ export class Button extends React.Component<InnerProps> {
|
||||
disabled,
|
||||
forwardRef,
|
||||
variant,
|
||||
type,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
@@ -84,6 +87,7 @@ export class Button extends React.Component<InnerProps> {
|
||||
classes={pick(classes, "keyboardFocus", "mouseHover")}
|
||||
disabled={disabled}
|
||||
forwardRef={forwardRef}
|
||||
type={type}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -5,32 +5,18 @@ menu: UI Kit
|
||||
|
||||
import { Playground, PropsTable } from 'docz'
|
||||
import FormField from './FormField'
|
||||
import { InputLabel, ValidationMessage, TextField, Typography, Flex, Button} from '../core/client/ui/components'
|
||||
|
||||
# Flex
|
||||
# FormField
|
||||
|
||||
`FormField` is to be used with Form Components `flexbox`.
|
||||
`FormField` is to be used with Form Components: `InputLabel`, `InputDescription`, `TextField`, `ValidationMessage`, etc.
|
||||
FormField manages the gutters between inner elements. It's a form field wrapper of the `Flex` Component.
|
||||
|
||||
## Justify content
|
||||
<Playground>
|
||||
<FormField>
|
||||
</FormField>
|
||||
</Playground>
|
||||
|
||||
## Align items
|
||||
<Playground>
|
||||
<Flex alignItems="center" itemGutter>
|
||||
<Button variant="filled">Push Me</Button>
|
||||
<Button variant="filled" size="small">Push Me</Button>
|
||||
<Button variant="filled" size="large">Push Me</Button>
|
||||
</Flex>
|
||||
</Playground>
|
||||
|
||||
## Direction
|
||||
<Playground>
|
||||
<Flex direction="column" itemGutter>
|
||||
<Button variant="filled">Push Me</Button>
|
||||
<Button variant="filled">Push Me</Button>
|
||||
<Button variant="filled">Push Me</Button>
|
||||
</Flex>
|
||||
<FormField>
|
||||
<InputLabel defaultValue="something.com" color="error" >Username</InputLabel>
|
||||
<InputDescription>A unique identifier displayed on your comments. You may use “_” and “.”</InputDescription>
|
||||
<TextField/>
|
||||
<ValidationMessage>Invalid characters. Try again.</ValidationMessage>
|
||||
</FormField>
|
||||
</Playground>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import TestRenderer from "react-test-renderer";
|
||||
|
||||
import FormField from "../FormField";
|
||||
|
||||
import { InputDescription, InputLabel, TextField } from "../../components";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const renderer = TestRenderer.create(
|
||||
<FormField>Form Components should go here</FormField>
|
||||
);
|
||||
expect(renderer.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("works with multiple form components", () => {
|
||||
const renderer = TestRenderer.create(
|
||||
<FormField>
|
||||
<InputLabel>Username</InputLabel>
|
||||
<InputDescription>
|
||||
A unique identifier displayed on your comments. You may use “_” and “.”
|
||||
</InputDescription>
|
||||
<TextField />
|
||||
</FormField>
|
||||
);
|
||||
expect(renderer.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="FormField-root FormField-itemGutter"
|
||||
>
|
||||
Form Components should go here
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`works with multiple form components 1`] = `
|
||||
<div
|
||||
className="FormField-root FormField-itemGutter"
|
||||
>
|
||||
<label
|
||||
className="Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<p
|
||||
className="Typography-root Typography-inputDescription Typography-colorTextSecondary"
|
||||
>
|
||||
A unique identifier displayed on your comments. You may use “_” and “.”
|
||||
</p>
|
||||
<input
|
||||
className="TextField-root TextField-colorRegular"
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,36 +1,16 @@
|
||||
---
|
||||
name: FormField
|
||||
name: InputDescription
|
||||
menu: UI Kit
|
||||
---
|
||||
|
||||
import { Playground, PropsTable } from 'docz'
|
||||
import FormField from './FormField'
|
||||
import { InputLabel, ValidationMessage, TextField, Typography, Flex, Button} from '../core/client/ui/components'
|
||||
import InputDescription from './InputDescription'
|
||||
|
||||
# Flex
|
||||
|
||||
`FormField` is to be used with Form Components `flexbox`.
|
||||
|
||||
## Justify content
|
||||
<Playground>
|
||||
<FormField>
|
||||
</FormField>
|
||||
</Playground>
|
||||
`InputDescription` is to be used with Form Components.
|
||||
|
||||
## Align items
|
||||
<Playground>
|
||||
<Flex alignItems="center" itemGutter>
|
||||
<Button variant="filled">Push Me</Button>
|
||||
<Button variant="filled" size="small">Push Me</Button>
|
||||
<Button variant="filled" size="large">Push Me</Button>
|
||||
</Flex>
|
||||
</Playground>
|
||||
|
||||
## Direction
|
||||
<Playground>
|
||||
<Flex direction="column" itemGutter>
|
||||
<Button variant="filled">Push Me</Button>
|
||||
<Button variant="filled">Push Me</Button>
|
||||
<Button variant="filled">Push Me</Button>
|
||||
</Flex>
|
||||
<InputDescription>This is a description</InputDescription>
|
||||
</Playground>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import TestRenderer from "react-test-renderer";
|
||||
|
||||
import InputDescription from "../InputDescription";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const renderer = TestRenderer.create(
|
||||
<InputDescription>Form Components should go here</InputDescription>
|
||||
);
|
||||
expect(renderer.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
@@ -1,43 +1,24 @@
|
||||
import cn from "classnames";
|
||||
import React, { ReactNode } from "react";
|
||||
import { StatelessComponent } from "react";
|
||||
import React, { ReactNode, StatelessComponent } from "react";
|
||||
import { Typography } from "talk-ui/components";
|
||||
|
||||
import { withStyles } from "talk-ui/hocs";
|
||||
|
||||
import * as styles from "./FormField.css";
|
||||
|
||||
interface InnerProps {
|
||||
interface InputDescriptionProps {
|
||||
children: ReactNode;
|
||||
classes: typeof styles;
|
||||
id?: string;
|
||||
className?: string;
|
||||
itemGutter?: boolean | "half";
|
||||
}
|
||||
|
||||
const FormField: StatelessComponent<InnerProps> = props => {
|
||||
const { classes, className, children, itemGutter, ...rest } = props;
|
||||
|
||||
// TODO (bc): Use flex component once the extra div issue is solved.
|
||||
const InputDescription: StatelessComponent<InputDescriptionProps> = props => {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
classes.root,
|
||||
{
|
||||
[classes.itemGutter]: itemGutter === true,
|
||||
[classes.halfItemGutter]: itemGutter === "half",
|
||||
},
|
||||
className
|
||||
)}
|
||||
<Typography
|
||||
variant="inputDescription"
|
||||
color="textSecondary"
|
||||
className={className}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
FormField.defaultProps = {
|
||||
itemGutter: true,
|
||||
};
|
||||
|
||||
const enhanced = withStyles(styles)(FormField);
|
||||
export default enhanced;
|
||||
export default InputDescription;
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<p
|
||||
className="Typography-root Typography-inputDescription Typography-colorTextSecondary"
|
||||
>
|
||||
Form Components should go here
|
||||
</p>
|
||||
`;
|
||||
@@ -0,0 +1,68 @@
|
||||
.root {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: rotator 1.4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotator {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
}
|
||||
|
||||
.path {
|
||||
stroke: var(--palette-primary-main);
|
||||
stroke-dasharray: 187;
|
||||
stroke-dashoffset: 0;
|
||||
transform-origin: center;
|
||||
animation: dash 1.4s ease-in-out infinite, colors 5.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes colors {
|
||||
0% {
|
||||
stroke: var(--palette-primary-main);
|
||||
}
|
||||
100% {
|
||||
stroke: var(--palette-primary-light);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dash {
|
||||
0% {
|
||||
stroke-dashoffset: 187;
|
||||
}
|
||||
50% {
|
||||
stroke-dashoffset: 46.75;
|
||||
transform: rotate(135deg);
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 187;
|
||||
transform: rotate(450deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fullRotator {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hack for IE and Edge as they don't support css animations on SVG elements. */
|
||||
_:-ms-lang(x),
|
||||
.path {
|
||||
stroke-dasharray: 160;
|
||||
}
|
||||
|
||||
_:-ms-lang(x),
|
||||
.spinner {
|
||||
animation: fullRotator 1.4s linear infinite;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Spinner
|
||||
menu: UI Kit
|
||||
---
|
||||
|
||||
import { Playground } from "docz"
|
||||
import Spinner from "./Spinner"
|
||||
|
||||
# Spinner
|
||||
|
||||
## Basic usage
|
||||
|
||||
<Playground>
|
||||
<Spinner />
|
||||
</Playground>
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import TestRenderer from "react-test-renderer";
|
||||
|
||||
import Spinner from "../Spinner";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const renderer = TestRenderer.create(<Spinner />);
|
||||
expect(renderer.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import cn from "classnames";
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { withStyles } from "talk-ui/hocs";
|
||||
import * as styles from "./Spinner.css";
|
||||
|
||||
export interface SpinnerProps {
|
||||
/**
|
||||
* Convenient prop to override the root styling.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Override or extend the styles applied to the component.
|
||||
*/
|
||||
classes: typeof styles;
|
||||
}
|
||||
|
||||
const Spinner: StatelessComponent<SpinnerProps> = props => {
|
||||
const { className, classes } = props;
|
||||
|
||||
const rootClassName = cn(classes.root, className);
|
||||
|
||||
return (
|
||||
<div className={rootClassName}>
|
||||
<svg
|
||||
className={styles.spinner}
|
||||
width="40px"
|
||||
height="40px"
|
||||
viewBox="0 0 66 66"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
className={styles.path}
|
||||
fill="none"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
cx="33"
|
||||
cy="33"
|
||||
r="30"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withStyles(styles)(Spinner);
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,25 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="Spinner-root"
|
||||
>
|
||||
<svg
|
||||
className="Spinner-spinner"
|
||||
height="40px"
|
||||
viewBox="0 0 66 66"
|
||||
width="40px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
className="Spinner-path"
|
||||
cx="33"
|
||||
cy="33"
|
||||
fill="none"
|
||||
r="30"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./Spinner";
|
||||
@@ -84,7 +84,6 @@ const TextField: StatelessComponent<TextFieldProps> = props => {
|
||||
|
||||
TextField.defaultProps = {
|
||||
color: "regular",
|
||||
fullWidth: true,
|
||||
placeholder: "",
|
||||
};
|
||||
|
||||
|
||||
@@ -15,3 +15,5 @@ export { default as CallOut } from "./CallOut";
|
||||
export { default as ClickOutside } from "./ClickOutside";
|
||||
export { default as Popup } from "./Popup";
|
||||
export { default as FormField } from "./FormField";
|
||||
export { default as InputDescription } from "./InputDescription";
|
||||
export { default as Spinner } from "./Spinner";
|
||||
|
||||
@@ -137,9 +137,9 @@
|
||||
color: var(--palette-common-black);
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: calc(16rem / var(--rem-base));
|
||||
line-height: calc(16em / 16);
|
||||
letter-spacing: calc(-0.1em / 16);
|
||||
font-size: calc(14rem / var(--rem-base));
|
||||
line-height: calc(20em / 16);
|
||||
letter-spacing: calc(0.2em / 16);
|
||||
}
|
||||
|
||||
.inputText {
|
||||
@@ -163,7 +163,7 @@
|
||||
.inputDescription {
|
||||
font-size: calc(14rem / var(--rem-base));
|
||||
font-weight: var(--font-weight-regular);
|
||||
font-family: var(--font-family);
|
||||
font-family: var(--font-family-sans-serif);
|
||||
line-height: calc(18em / 16);
|
||||
letter-spacing: calc(0.2em / 16);
|
||||
color: var(--palette-text-secondary);
|
||||
|
||||
@@ -86,6 +86,14 @@ const config = convict({
|
||||
env: "LOGGING_LEVEL",
|
||||
arg: "logging",
|
||||
},
|
||||
disable_tenant_caching: {
|
||||
doc:
|
||||
"Disables the tenant caching, all tenants will be loaded from MongoDB each time it's needed",
|
||||
format: Boolean,
|
||||
default: false,
|
||||
env: "DISABLE_TENANT_CACHING",
|
||||
arg: "disableTenantCaching",
|
||||
},
|
||||
});
|
||||
|
||||
export type Config = typeof config;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { RequestHandler } from "express";
|
||||
import { Redis } from "ioredis";
|
||||
import Joi from "joi";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { handleSuccessfulLogin } from "talk-server/app/middleware/passport";
|
||||
import {
|
||||
handleLogout,
|
||||
handleSuccessfulLogin,
|
||||
} from "talk-server/app/middleware/passport";
|
||||
import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
@@ -74,3 +78,39 @@ export const signupHandler = (options: SignupOptions): RequestHandler => async (
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
export interface LogoutOptions {
|
||||
redis: Redis;
|
||||
}
|
||||
|
||||
export const logoutHandler = (options: LogoutOptions): RequestHandler => async (
|
||||
req: Request,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
try {
|
||||
// TODO: rate limit based on the IP address and user agent.
|
||||
|
||||
// Tenant is guaranteed at this point.
|
||||
const tenant = req.tenant!;
|
||||
|
||||
// Check to ensure that the local integration has been enabled.
|
||||
if (!tenant.auth.integrations.local.enabled) {
|
||||
// TODO: replace with better error.
|
||||
return next(new Error("integration is disabled"));
|
||||
}
|
||||
|
||||
// Get the user on the request.
|
||||
const user = req.user;
|
||||
if (!user) {
|
||||
return next(
|
||||
new Error("cannot logout when there is no user on the request")
|
||||
);
|
||||
}
|
||||
|
||||
// Delegate to the logout handler.
|
||||
return handleLogout(options.redis, req, res);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import { handleSubscriptions } from "talk-server/graph/common/subscriptions/midd
|
||||
import { Schemas } from "talk-server/graph/schemas";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
|
||||
import { errorHandler } from "talk-server/app/middleware/error";
|
||||
import { accessLogger, errorLogger } from "./middleware/logging";
|
||||
import serveStatic from "./middleware/serveStatic";
|
||||
import { createRouter } from "./router";
|
||||
@@ -57,6 +58,7 @@ export async function createApp(options: AppOptions): Promise<Express> {
|
||||
// Error Handling
|
||||
parent.use(notFoundMiddleware);
|
||||
parent.use(errorLogger);
|
||||
parent.use(errorHandler);
|
||||
|
||||
return parent;
|
||||
}
|
||||
|
||||
@@ -4,3 +4,20 @@ export const apiErrorHandler: ErrorRequestHandler = (err, req, res, next) => {
|
||||
// TODO: handle better when we improve errors.
|
||||
res.status(500).json({ error: err.message });
|
||||
};
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
|
||||
// TODO: handle better when we improve errors.
|
||||
if (err.message === "not found") {
|
||||
// TODO: handle better when we improve errors.
|
||||
res
|
||||
.status(404)
|
||||
.send(err.message)
|
||||
.end();
|
||||
} else {
|
||||
// TODO: handle better when we improve errors.
|
||||
res
|
||||
.status(500)
|
||||
.send(err.message)
|
||||
.end();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -39,6 +39,10 @@ export const accessLogger: RequestHandler = (req, res, next) => {
|
||||
};
|
||||
|
||||
export const errorLogger: ErrorRequestHandler = (err, req, res, next) => {
|
||||
logger.error(err, "http error");
|
||||
// TODO: handle better when we improve errors.
|
||||
if (err.message !== "not found") {
|
||||
logger.error({ err }, "http error");
|
||||
}
|
||||
|
||||
next(err);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { NextFunction, RequestHandler, Response } from "express";
|
||||
import { Redis } from "ioredis";
|
||||
import Joi from "joi";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { Db } from "mongodb";
|
||||
import passport, { Authenticator } from "passport";
|
||||
|
||||
import { Config } from "talk-common/config";
|
||||
import {
|
||||
blacklistJWT,
|
||||
createJWTStrategy,
|
||||
extractJWTFromRequest,
|
||||
JWTSigningConfig,
|
||||
SigningTokenOptions,
|
||||
signTokenString,
|
||||
@@ -11,6 +17,7 @@ import {
|
||||
import { createLocalStrategy } from "talk-server/app/middleware/passport/local";
|
||||
import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc";
|
||||
import { createSSOStrategy } from "talk-server/app/middleware/passport/sso";
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { User } from "talk-server/models/user";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
import { Request } from "talk-server/types/express";
|
||||
@@ -22,7 +29,9 @@ export type VerifyCallback = (
|
||||
) => void;
|
||||
|
||||
export interface PassportOptions {
|
||||
config: Config;
|
||||
mongo: Db;
|
||||
redis: Redis;
|
||||
signingConfig: JWTSigningConfig;
|
||||
tenantCache: TenantCache;
|
||||
}
|
||||
@@ -48,6 +57,47 @@ export function createPassport(
|
||||
return auth;
|
||||
}
|
||||
|
||||
interface LogoutToken {
|
||||
jti: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
const LogoutTokenSchema = Joi.object().keys({
|
||||
jti: Joi.string(),
|
||||
exp: Joi.number(),
|
||||
});
|
||||
|
||||
export async function handleLogout(redis: Redis, req: Request, res: Response) {
|
||||
// Extract the token from the request.
|
||||
const token = extractJWTFromRequest(req);
|
||||
if (!token) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error("logout requires a token on the request, none was found");
|
||||
}
|
||||
|
||||
// Decode the token.
|
||||
const decoded = jwt.decode(token, {});
|
||||
if (!decoded) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error(
|
||||
"logout requires a token on the request, token was invalid"
|
||||
);
|
||||
}
|
||||
|
||||
// Grab the JTI from the decoded token.
|
||||
const { jti, exp }: LogoutToken = validate(LogoutTokenSchema, decoded);
|
||||
|
||||
// Compute the number of seconds that the token will be valid for.
|
||||
const validFor = exp - Date.now() / 1000;
|
||||
if (validFor > 0) {
|
||||
// Invalidate the token, the expiry is in the future and it needs to be
|
||||
// blacklisted.
|
||||
await blacklistJWT(redis, jti, validFor);
|
||||
}
|
||||
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
|
||||
export async function handleSuccessfulLogin(
|
||||
user: User,
|
||||
signingConfig: JWTSigningConfig,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Redis } from "ioredis";
|
||||
import jwt, { SignOptions } from "jsonwebtoken";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { Db } from "mongodb";
|
||||
import { Strategy } from "passport-strategy";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { Config } from "talk-common/config";
|
||||
import { retrieveUser, User } from "talk-server/models/user";
|
||||
import { Request } from "talk-server/types/express";
|
||||
@@ -38,6 +39,31 @@ export function extractJWTFromRequest(req: Request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function generateJTIBlacklistKey(jti: string) {
|
||||
// jtib: JTI Blacklist namespace.
|
||||
return `jtib:${jti}`;
|
||||
}
|
||||
|
||||
export async function blacklistJWT(
|
||||
redis: Redis,
|
||||
jti: string,
|
||||
validFor: number
|
||||
) {
|
||||
await redis.setex(
|
||||
generateJTIBlacklistKey(jti),
|
||||
Math.ceil(validFor),
|
||||
Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkBlacklistJWT(redis: Redis, jti: string) {
|
||||
const expiredAtString = await redis.get(generateJTIBlacklistKey(jti));
|
||||
if (expiredAtString) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error("JWT exists in blacklist");
|
||||
}
|
||||
}
|
||||
|
||||
export enum AsymmetricSigningAlgorithm {
|
||||
RS256 = "RS256",
|
||||
RS384 = "RS384",
|
||||
@@ -139,6 +165,7 @@ export interface JWTToken {
|
||||
export interface JWTStrategyOptions {
|
||||
signingConfig: JWTSigningConfig;
|
||||
mongo: Db;
|
||||
redis: Redis;
|
||||
}
|
||||
|
||||
export class JWTStrategy extends Strategy {
|
||||
@@ -146,12 +173,14 @@ export class JWTStrategy extends Strategy {
|
||||
|
||||
private signingConfig: JWTSigningConfig;
|
||||
private mongo: Db;
|
||||
private redis: Redis;
|
||||
|
||||
constructor({ signingConfig, mongo }: JWTStrategyOptions) {
|
||||
constructor({ signingConfig, mongo, redis }: JWTStrategyOptions) {
|
||||
super();
|
||||
|
||||
this.signingConfig = signingConfig;
|
||||
this.mongo = mongo;
|
||||
this.redis = redis;
|
||||
}
|
||||
|
||||
public authenticate(req: Request) {
|
||||
@@ -179,13 +208,16 @@ export class JWTStrategy extends Strategy {
|
||||
// Use the algorithm specified in the configuration.
|
||||
algorithms: [this.signingConfig.algorithm],
|
||||
},
|
||||
async (err: Error | undefined, { sub }: JWTToken) => {
|
||||
async (err: Error | undefined, { jti, sub }: JWTToken) => {
|
||||
if (err) {
|
||||
return this.fail(err, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the user.
|
||||
// Check to see if the token has been blacklisted.
|
||||
await checkBlacklistJWT(this.redis, jti);
|
||||
|
||||
// Find the user referenced by the token.
|
||||
const user = await retrieveUser(this.mongo, tenant.id, sub);
|
||||
|
||||
// Return them! The user may be null, but that's ok here.
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Db } from "mongodb";
|
||||
import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2";
|
||||
import { Strategy } from "passport-strategy";
|
||||
|
||||
import { Config } from "talk-common/config";
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { reconstructURL } from "talk-server/app/url";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
@@ -12,6 +13,7 @@ import { OIDCAuthIntegration } from "talk-server/models/settings";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
|
||||
import { upsert } from "talk-server/services/users";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
@@ -177,25 +179,23 @@ const OIDC_SCOPE = "openid email profile";
|
||||
export interface OIDCStrategyOptions {
|
||||
mongo: Db;
|
||||
tenantCache: TenantCache;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export default class OIDCStrategy extends Strategy {
|
||||
public name = "oidc";
|
||||
|
||||
private mongo: Db;
|
||||
private cache = new Map<string, StrategyItem>();
|
||||
private cache: TenantCacheAdapter<StrategyItem>;
|
||||
|
||||
constructor({ mongo, tenantCache }: OIDCStrategyOptions) {
|
||||
constructor({ mongo, tenantCache, config }: OIDCStrategyOptions) {
|
||||
super();
|
||||
|
||||
this.mongo = mongo;
|
||||
this.cache = new TenantCacheAdapter(tenantCache, config);
|
||||
|
||||
// Subscribe to updates with Tenants.
|
||||
tenantCache.subscribe(tenant => {
|
||||
// Delete the tenant cache item when the tenant changes. The refreshed
|
||||
// Tenant will come in with the request.
|
||||
this.cache.delete(tenant.id);
|
||||
});
|
||||
// Connect the cache adapter.
|
||||
this.cache.subscribe();
|
||||
}
|
||||
|
||||
private lookupJWKSClient(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import express from "express";
|
||||
import passport from "passport";
|
||||
|
||||
import { signupHandler } from "talk-server/app/handlers/auth/local";
|
||||
import {
|
||||
logoutHandler,
|
||||
signupHandler,
|
||||
} from "talk-server/app/handlers/auth/local";
|
||||
import { streamHandler } from "talk-server/app/handlers/embed/stream";
|
||||
import { apiErrorHandler } from "talk-server/app/middleware/error";
|
||||
import { errorLogger } from "talk-server/app/middleware/logging";
|
||||
@@ -64,6 +67,12 @@ function createNewAuthRouter(app: AppOptions, options: RouterOptions) {
|
||||
const router = express.Router();
|
||||
|
||||
// Mount the passport routes.
|
||||
router.delete(
|
||||
"/",
|
||||
options.passport.authenticate("jwt", { session: false }),
|
||||
logoutHandler({ redis: app.redis })
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/local",
|
||||
express.json(),
|
||||
@@ -74,6 +83,7 @@ function createNewAuthRouter(app: AppOptions, options: RouterOptions) {
|
||||
express.json(),
|
||||
signupHandler({ db: app.mongo, signingConfig: app.signingConfig })
|
||||
);
|
||||
|
||||
router.post("/sso", wrapAuthn(options.passport, app.signingConfig, "sso"));
|
||||
router.get("/oidc", wrapAuthn(options.passport, app.signingConfig, "oidc"));
|
||||
router.get(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { GQLAuthIntegrationsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { AuthIntegration, AuthIntegrations } from "talk-server/models/settings";
|
||||
import {
|
||||
AuthIntegrations,
|
||||
EnableableIntegration,
|
||||
} from "talk-server/models/settings";
|
||||
|
||||
const disabled: AuthIntegration = { enabled: false };
|
||||
const disabled: EnableableIntegration = { enabled: false };
|
||||
|
||||
const AuthIntegrations: GQLAuthIntegrationsTypeResolver<AuthIntegrations> = {
|
||||
local: auth => auth.local || disabled,
|
||||
|
||||
@@ -38,6 +38,16 @@ enum ACTION_ITEM_TYPE {
|
||||
COMMENTS
|
||||
}
|
||||
|
||||
enum ACTION_GROUP {
|
||||
SPAM_COMMENT
|
||||
TOXIC_COMMENT
|
||||
BODY_COUNT
|
||||
TRUST
|
||||
LINKS
|
||||
BANNED_WORD
|
||||
SUSPECT_WORD
|
||||
}
|
||||
|
||||
################################################################################
|
||||
## Settings
|
||||
################################################################################
|
||||
@@ -57,9 +67,9 @@ enum MODERATION_MODE {
|
||||
}
|
||||
|
||||
"""
|
||||
WordlistSettings describes all the available wordlists.
|
||||
Wordlist describes all the available wordlists.
|
||||
"""
|
||||
type WordlistSettings {
|
||||
type Wordlist {
|
||||
"""
|
||||
banned words will by default reject the comment if it is found.
|
||||
"""
|
||||
@@ -177,6 +187,111 @@ type AuthSettings {
|
||||
integrations: AuthIntegrations!
|
||||
}
|
||||
|
||||
################################################################################
|
||||
## ExternalIntegrations
|
||||
################################################################################
|
||||
|
||||
type AkismetExternalIntegration {
|
||||
"""
|
||||
enabled when True will enable the integration.
|
||||
"""
|
||||
enabled: Boolean!
|
||||
|
||||
"""
|
||||
The key for the Akismet integration.
|
||||
"""
|
||||
key: String
|
||||
|
||||
"""
|
||||
The site (blog) for the Akismet integration.
|
||||
"""
|
||||
site: String
|
||||
}
|
||||
|
||||
type PerspectiveExternalIntegration {
|
||||
"""
|
||||
enabled when True will enable the integration.
|
||||
"""
|
||||
enabled: Boolean!
|
||||
|
||||
"""
|
||||
The endpoint that Talk should use to communicate with the perspective API.
|
||||
"""
|
||||
endpoint: String
|
||||
|
||||
"""
|
||||
The key for the Perspective API integration.
|
||||
"""
|
||||
key: String
|
||||
|
||||
"""
|
||||
The threshold that given a specific toxic comment score, the comment will
|
||||
be marked by Talk as toxic.
|
||||
"""
|
||||
threshold: Float
|
||||
|
||||
"""
|
||||
When True, comments sent will not be stored by the Google Perspective API.
|
||||
"""
|
||||
doNotStore: Boolean
|
||||
}
|
||||
|
||||
type ExternalIntegrations {
|
||||
"""
|
||||
akismet provides integration with the Akismet Spam detection service.
|
||||
"""
|
||||
akismet: AkismetExternalIntegration!
|
||||
|
||||
"""
|
||||
perspective provides integration with the Perspective API comment analysis
|
||||
platform.
|
||||
"""
|
||||
perspective: PerspectiveExternalIntegration!
|
||||
}
|
||||
|
||||
################################################################################
|
||||
## Karma
|
||||
################################################################################
|
||||
|
||||
"""
|
||||
KarmaThreshold defines the bounds for which a User will become unreliable or
|
||||
reliable based on their karma score. If the score is equal or less than the
|
||||
unreliable value, they are unreliable. If the score is equal or more than the
|
||||
reliable value, they are reliable. If they are neither reliable or unreliable
|
||||
then they are neutral.
|
||||
"""
|
||||
type KarmaThreshold {
|
||||
reliable: Int!
|
||||
unreliable: Int!
|
||||
}
|
||||
|
||||
type KarmaThresholds {
|
||||
"""
|
||||
flag represents karma settings in relation to how well a User's flagging
|
||||
ability aligns with the moderation decicions made by moderators.
|
||||
"""
|
||||
flag: KarmaThreshold!
|
||||
|
||||
"""
|
||||
comment represents the karma setting in relation to how well a User's comments are moderated.
|
||||
"""
|
||||
comment: KarmaThreshold!
|
||||
}
|
||||
|
||||
type Karma {
|
||||
"""
|
||||
When true, checks will be completed to ensure that the Karma checks are
|
||||
completed.
|
||||
"""
|
||||
enabled: Boolean!
|
||||
|
||||
"""
|
||||
karmaThresholds contains the currently set thresholds for triggering Trust
|
||||
beheviour.
|
||||
"""
|
||||
thresholds: KarmaThresholds!
|
||||
}
|
||||
|
||||
################################################################################
|
||||
## Settings
|
||||
################################################################################
|
||||
@@ -295,7 +410,7 @@ type Settings {
|
||||
"""
|
||||
wordlist will return a given list of words.
|
||||
"""
|
||||
wordlist: WordlistSettings @auth(roles: [ADMIN, MODERATOR])
|
||||
wordlist: Wordlist @auth(roles: [ADMIN, MODERATOR])
|
||||
|
||||
"""
|
||||
domains will return a given list of whitelisted domains.
|
||||
@@ -306,6 +421,17 @@ type Settings {
|
||||
auth contains all the settings related to authentication and authorization.
|
||||
"""
|
||||
auth: AuthSettings!
|
||||
|
||||
"""
|
||||
integrations contains all the external integrations that can be enabled.
|
||||
"""
|
||||
integrations: ExternalIntegrations @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
karma is the set of settings related to how user Trust and Karma are
|
||||
handled.
|
||||
"""
|
||||
karma: Karma @auth(roles: [ADMIN, MODERATOR])
|
||||
}
|
||||
|
||||
################################################################################
|
||||
@@ -728,7 +854,7 @@ input SettingsInput {
|
||||
charCount: Int
|
||||
organizationName: String
|
||||
organizationContactEmail: String
|
||||
# wordlist: WordlistSettings @auth(roles: [ADMIN, MODERATOR])
|
||||
# wordlist: Wordlist @auth(roles: [ADMIN, MODERATOR])
|
||||
domains: [String!]
|
||||
# auth: AuthSettings!
|
||||
}
|
||||
|
||||
@@ -70,7 +70,11 @@ class Server {
|
||||
const signingConfig = createJWTSigningConfig(this.config);
|
||||
|
||||
// Create the TenantCache.
|
||||
const tenantCache = new TenantCache(mongo, await createRedisClient(config));
|
||||
const tenantCache = new TenantCache(
|
||||
mongo,
|
||||
await createRedisClient(config),
|
||||
config
|
||||
);
|
||||
|
||||
// Prime the tenant cache so it'll be ready to serve now.
|
||||
await tenantCache.primeAll();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
GQLACTION_GROUP,
|
||||
GQLACTION_ITEM_TYPE,
|
||||
GQLACTION_TYPE,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
@@ -10,7 +11,7 @@ export interface Action {
|
||||
action_type: GQLACTION_TYPE;
|
||||
item_type: GQLACTION_ITEM_TYPE;
|
||||
item_id: string;
|
||||
group_id?: string;
|
||||
group_id?: GQLACTION_GROUP;
|
||||
user_id?: string;
|
||||
created_at: Date;
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface StatusHistoryItem {
|
||||
|
||||
export interface Comment extends TenantResource {
|
||||
readonly id: string;
|
||||
parent_id: string | null;
|
||||
parent_id?: string;
|
||||
author_id: string;
|
||||
asset_id: string;
|
||||
body: string;
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import {
|
||||
GQLExternalIntegrations,
|
||||
GQLKarma,
|
||||
GQLMODERATION_MODE,
|
||||
GQLUSER_ROLE,
|
||||
GQLWordlist,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
export interface Wordlist {
|
||||
banned: string[];
|
||||
suspect: string[];
|
||||
}
|
||||
|
||||
export interface EmailDomainRuleCondition {
|
||||
/**
|
||||
* emailDomain is the domain name component of the email addresses that should
|
||||
@@ -49,7 +47,7 @@ export interface AuthRules {
|
||||
restrictTo?: EmailDomainRuleCondition[];
|
||||
}
|
||||
|
||||
export interface AuthIntegration {
|
||||
export interface EnableableIntegration {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -63,7 +61,7 @@ export interface DisplayNameAuthIntegration {
|
||||
* embed to allow single sign on.
|
||||
*/
|
||||
export interface SSOAuthIntegration
|
||||
extends AuthIntegration,
|
||||
extends EnableableIntegration,
|
||||
DisplayNameAuthIntegration {
|
||||
key: string;
|
||||
}
|
||||
@@ -73,7 +71,7 @@ export interface SSOAuthIntegration
|
||||
* will be used in the admin to provide staff logins for users.
|
||||
*/
|
||||
export interface OIDCAuthIntegration
|
||||
extends AuthIntegration,
|
||||
extends EnableableIntegration,
|
||||
DisplayNameAuthIntegration {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
@@ -83,17 +81,17 @@ export interface OIDCAuthIntegration
|
||||
tokenURL: string;
|
||||
}
|
||||
|
||||
export interface FacebookAuthIntegration extends AuthIntegration {
|
||||
export interface FacebookAuthIntegration extends EnableableIntegration {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export interface GoogleAuthIntegration extends AuthIntegration {
|
||||
export interface GoogleAuthIntegration extends EnableableIntegration {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export type LocalAuthIntegration = AuthIntegration;
|
||||
export type LocalAuthIntegration = EnableableIntegration;
|
||||
|
||||
/**
|
||||
* AuthIntegrations describes all of the possible auth integration
|
||||
@@ -130,33 +128,6 @@ export interface Auth {
|
||||
integrations: AuthIntegrations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Akismet provides integration with the Akismet Spam detection service.
|
||||
*/
|
||||
export interface AkismetIntegration {
|
||||
/**
|
||||
* When true, it will enable comments to be checked by Akismet.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* The key for the Akismet integration.
|
||||
*/
|
||||
key?: string;
|
||||
|
||||
/**
|
||||
* The site (blog) for the Akismet integration.
|
||||
*/
|
||||
site?: string;
|
||||
}
|
||||
|
||||
export interface ExternalIntegrations {
|
||||
/**
|
||||
* akismet provides integration with the Akismet Spam detection service.
|
||||
*/
|
||||
akismet: AkismetIntegration;
|
||||
}
|
||||
|
||||
export interface ModerationSettings {
|
||||
moderation: GQLMODERATION_MODE;
|
||||
requireEmailConfirmation: boolean;
|
||||
@@ -175,45 +146,6 @@ export interface ModerationSettings {
|
||||
charCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* KarmaThreshold defines the bounds for which a User will become unreliable or
|
||||
* reliable based on their karma score. If the score is equal or less than the
|
||||
* unreliable value, they are unreliable. If the score is equal or more than the
|
||||
* reliable value, they are reliable. If they are neither reliable or unreliable
|
||||
* then they are neutral.
|
||||
*/
|
||||
export interface KarmaThreshold {
|
||||
reliable: number;
|
||||
unreliable: number;
|
||||
}
|
||||
|
||||
export interface KarmaThresholds {
|
||||
/**
|
||||
* flag represents karma settings in relation to how well a User's flagging
|
||||
* ability aligns with the moderation decicions made by moderators.
|
||||
*/
|
||||
flag: KarmaThreshold;
|
||||
|
||||
/**
|
||||
* comment represents the karma setting in relation to how well a User's comments are moderated.
|
||||
*/
|
||||
comment: KarmaThreshold;
|
||||
}
|
||||
|
||||
export interface Karma {
|
||||
/**
|
||||
* When true, checks will be completed to ensure that the Karma checks are
|
||||
* completed.
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
/**
|
||||
* karmaThresholds contains the currently set thresholds for triggering Trust
|
||||
* beheviour.
|
||||
*/
|
||||
thresholds: KarmaThresholds;
|
||||
}
|
||||
|
||||
export interface Settings extends ModerationSettings {
|
||||
customCssUrl?: string;
|
||||
|
||||
@@ -227,12 +159,12 @@ export interface Settings extends ModerationSettings {
|
||||
* karma is the set of settings related to how user Trust and Karma are
|
||||
* handled.
|
||||
*/
|
||||
karma: Karma;
|
||||
karma: GQLKarma;
|
||||
|
||||
/**
|
||||
* wordlist stores all the banned/suspect words.
|
||||
*/
|
||||
wordlist: Wordlist;
|
||||
wordlist: GQLWordlist;
|
||||
|
||||
/**
|
||||
* Set of configured authentication integrations.
|
||||
@@ -242,5 +174,5 @@ export interface Settings extends ModerationSettings {
|
||||
/**
|
||||
* Various integrations with external services.
|
||||
*/
|
||||
integrations: ExternalIntegrations;
|
||||
integrations: GQLExternalIntegrations;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,9 @@ export async function createTenant(db: Db, input: CreateTenantInput) {
|
||||
akismet: {
|
||||
enabled: false,
|
||||
},
|
||||
perspective: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Request } from "talk-server/types/express";
|
||||
|
||||
export type CreateComment = Omit<
|
||||
CreateCommentInput,
|
||||
"status" | "action_counts"
|
||||
"status" | "action_counts" | "metadata"
|
||||
>;
|
||||
|
||||
export async function create(
|
||||
@@ -44,7 +44,7 @@ export async function create(
|
||||
}
|
||||
|
||||
// Run the comment through the moderation phases.
|
||||
const { status } = await processForModeration({
|
||||
const { status, metadata } = await processForModeration({
|
||||
asset,
|
||||
tenant,
|
||||
comment: input,
|
||||
@@ -55,9 +55,10 @@ export async function create(
|
||||
// TODO: (wyattjoh) use the actions somehow.
|
||||
|
||||
const comment = await createComment(mongo, tenant.id, {
|
||||
...input,
|
||||
status,
|
||||
action_counts: {},
|
||||
...input,
|
||||
metadata,
|
||||
});
|
||||
|
||||
if (input.parent_id) {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
GQLACTION_GROUP,
|
||||
GQLACTION_TYPE,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import {
|
||||
compose,
|
||||
ModerationPhaseContext,
|
||||
} from "talk-server/services/comments/moderation";
|
||||
|
||||
describe("compose", () => {
|
||||
it("handles when a phase throws an error", async () => {
|
||||
const err = new Error("this is an error");
|
||||
const enhanced = compose([
|
||||
() => {
|
||||
throw err;
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(enhanced({} as ModerationPhaseContext)).rejects.toEqual(err);
|
||||
});
|
||||
|
||||
it("handles when it returns a status", async () => {
|
||||
const status = GQLCOMMENT_STATUS.ACCEPTED;
|
||||
const enhanced = compose([() => ({ status })]);
|
||||
|
||||
await expect(enhanced({} as ModerationPhaseContext)).resolves.toEqual({
|
||||
status,
|
||||
metadata: {},
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("merges the metadata", async () => {
|
||||
const status = GQLCOMMENT_STATUS.ACCEPTED;
|
||||
const enhanced = compose([
|
||||
() => ({ metadata: { first: true } }),
|
||||
() => ({ status, metadata: { second: true } }),
|
||||
() => ({ metadata: { third: true } }),
|
||||
]);
|
||||
|
||||
await expect(enhanced({} as ModerationPhaseContext)).resolves.toEqual({
|
||||
status,
|
||||
metadata: { first: true, second: true },
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("merges actions", async () => {
|
||||
const status = GQLCOMMENT_STATUS.ACCEPTED;
|
||||
|
||||
const flags = [
|
||||
{
|
||||
action_type: GQLACTION_TYPE.FLAG,
|
||||
group_id: GQLACTION_GROUP.TOXIC_COMMENT,
|
||||
},
|
||||
{
|
||||
action_type: GQLACTION_TYPE.FLAG,
|
||||
group_id: GQLACTION_GROUP.SPAM_COMMENT,
|
||||
},
|
||||
];
|
||||
|
||||
const enhanced = compose([
|
||||
() => ({
|
||||
actions: [flags[0]],
|
||||
}),
|
||||
() => ({
|
||||
status,
|
||||
actions: [flags[1]],
|
||||
}),
|
||||
() => ({
|
||||
actions: [
|
||||
{
|
||||
action_type: GQLACTION_TYPE.FLAG,
|
||||
group_id: GQLACTION_GROUP.LINKS,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const final = await enhanced({} as ModerationPhaseContext);
|
||||
|
||||
for (const flag of flags) {
|
||||
expect(final.actions).toContainEqual(flag);
|
||||
}
|
||||
|
||||
expect(final.actions).not.toContainEqual({
|
||||
action_type: GQLACTION_TYPE.FLAG,
|
||||
group_id: GQLACTION_GROUP.LINKS,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles when it does not return a status", async () => {
|
||||
const enhanced = compose([
|
||||
() => ({ metadata: { first: true } }),
|
||||
() => ({ metadata: { second: true } }),
|
||||
]);
|
||||
|
||||
await expect(enhanced({} as ModerationPhaseContext)).resolves.toEqual({
|
||||
status: GQLCOMMENT_STATUS.NONE,
|
||||
metadata: { first: true, second: true },
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,13 @@ import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__
|
||||
import { Action } from "talk-server/models/actions";
|
||||
import { Asset } from "talk-server/models/asset";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { CreateComment } from "talk-server/services/comments";
|
||||
|
||||
import { User } from "talk-server/models/user";
|
||||
import { CreateComment } from "talk-server/services/comments";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
import { moderationPhases } from "./phases";
|
||||
|
||||
// TODO: (wyattjoh) move into actions module.
|
||||
// TODO: (wyattjoh) move into actions module once we have action methods.
|
||||
export type CreateAction = Omit<
|
||||
Action,
|
||||
"id" | "item_type" | "item_id" | "created_at"
|
||||
@@ -18,6 +18,7 @@ export type CreateAction = Omit<
|
||||
export interface PhaseResult {
|
||||
actions: CreateAction[];
|
||||
status: GQLCOMMENT_STATUS;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ModerationPhaseContext {
|
||||
@@ -42,31 +43,47 @@ export type IntermediateModerationPhase = (
|
||||
* compose will create a moderation pipeline for which is executable with the
|
||||
* passed actions.
|
||||
*/
|
||||
const compose = (
|
||||
export const compose = (
|
||||
phases: IntermediateModerationPhase[]
|
||||
): ModerationPhase => async context => {
|
||||
const actions: CreateAction[] = [];
|
||||
const final: PhaseResult = {
|
||||
status: GQLCOMMENT_STATUS.NONE,
|
||||
actions: [],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
// Loop over all the moderation phases and see if we've resolved the status.
|
||||
for (const phase of phases) {
|
||||
const result = await phase(context);
|
||||
if (result) {
|
||||
if (result.actions) {
|
||||
actions.push(...result.actions);
|
||||
// If this result contained actions, then we should push it into the
|
||||
// other actions.
|
||||
const { actions } = result;
|
||||
if (actions) {
|
||||
final.actions.push(...actions);
|
||||
}
|
||||
|
||||
// If this result contained metadata, then we should merge it into the
|
||||
// other metadata.
|
||||
const { metadata } = result;
|
||||
if (metadata) {
|
||||
final.metadata = {
|
||||
...final.metadata,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
// If this result contained a status, then we've finished resolving
|
||||
// phases!
|
||||
const { status } = result;
|
||||
if (status) {
|
||||
return { status, actions };
|
||||
final.status = status;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't determine a different comment from a previous itteration, set
|
||||
// it to 'NONE'.
|
||||
return { status: GQLCOMMENT_STATUS.NONE, actions };
|
||||
return final;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
} from "talk-server/services/comments/moderation";
|
||||
|
||||
// This phase checks to see if the asset being processed is closed or not.
|
||||
export const assetClosed: IntermediateModerationPhase = ({ asset }) => {
|
||||
export const assetClosed: IntermediateModerationPhase = ({
|
||||
asset,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
// Check to see if the asset has closed commenting...
|
||||
if (asset.closedAt && asset.closedAt.valueOf() <= Date.now()) {
|
||||
// TODO: (wyattjoh) return better error.
|
||||
throw new Error("asset is currently closed for commenting");
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import {
|
||||
GQLACTION_GROUP,
|
||||
GQLACTION_TYPE,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { ModerationSettings } from "talk-server/models/settings";
|
||||
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
} from "talk-server/services/comments/moderation";
|
||||
|
||||
const testCharCount = (settings: Partial<ModerationSettings>, length: number) =>
|
||||
settings.charCountEnable && settings.charCount && length > settings.charCount;
|
||||
|
||||
export const commentLength: IntermediateModerationPhase = async ({
|
||||
export const commentLength: IntermediateModerationPhase = ({
|
||||
asset,
|
||||
tenant,
|
||||
comment,
|
||||
}) => {
|
||||
}): IntermediatePhaseResult | void => {
|
||||
const length = comment.body.length;
|
||||
|
||||
// Check to see if the body is too short, if it is, then complain about it!
|
||||
@@ -32,7 +36,7 @@ export const commentLength: IntermediateModerationPhase = async ({
|
||||
actions: [
|
||||
{
|
||||
action_type: GQLACTION_TYPE.FLAG,
|
||||
group_id: "BODY_COUNT",
|
||||
group_id: GQLACTION_GROUP.BODY_COUNT,
|
||||
metadata: {
|
||||
count: length,
|
||||
},
|
||||
@@ -40,6 +44,4 @@ export const commentLength: IntermediateModerationPhase = async ({
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ModerationSettings } from "talk-server/models/settings";
|
||||
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
} from "talk-server/services/comments/moderation";
|
||||
|
||||
const testDisabledCommenting = (settings: Partial<ModerationSettings>) =>
|
||||
settings.disableCommenting;
|
||||
@@ -7,7 +10,7 @@ const testDisabledCommenting = (settings: Partial<ModerationSettings>) =>
|
||||
export const commentingDisabled: IntermediateModerationPhase = ({
|
||||
asset,
|
||||
tenant,
|
||||
}) => {
|
||||
}): IntermediatePhaseResult | void => {
|
||||
// Check to see if the asset has closed commenting.
|
||||
if (
|
||||
testDisabledCommenting(tenant) ||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
|
||||
|
||||
import { premod } from "talk-server/services/comments/moderation/phases/premod";
|
||||
import { toxic } from "talk-server/services/comments/moderation/phases/toxic";
|
||||
import { assetClosed } from "./assetClosed";
|
||||
import { commentingDisabled } from "./commentingDisabled";
|
||||
import { commentLength } from "./commentLength";
|
||||
@@ -22,5 +23,6 @@ export const moderationPhases: IntermediateModerationPhase[] = [
|
||||
links,
|
||||
karma,
|
||||
spam,
|
||||
toxic,
|
||||
premod,
|
||||
];
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import {
|
||||
GQLACTION_GROUP,
|
||||
GQLACTION_TYPE,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
} from "talk-server/services/comments/moderation";
|
||||
import {
|
||||
getCommentTrustScore,
|
||||
isReliableCommenter,
|
||||
@@ -10,7 +14,10 @@ import {
|
||||
|
||||
// This phase checks to see if the user making the comment is allowed to do so
|
||||
// considering their reliability (Trust) status.
|
||||
export const karma: IntermediateModerationPhase = ({ tenant, author }) => {
|
||||
export const karma: IntermediateModerationPhase = ({
|
||||
tenant,
|
||||
author,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
// If the user is not a reliable commenter (passed the unreliability
|
||||
// threshold by having too many rejected comments) then we can change the
|
||||
// status of the comment to `SYSTEM_WITHHELD`, therefore pushing the user's
|
||||
@@ -27,7 +34,7 @@ export const karma: IntermediateModerationPhase = ({ tenant, author }) => {
|
||||
actions: [
|
||||
{
|
||||
action_type: GQLACTION_TYPE.FLAG,
|
||||
group_id: "TRUST",
|
||||
group_id: GQLACTION_GROUP.TRUST,
|
||||
metadata: {
|
||||
trust: getCommentTrustScore(author),
|
||||
},
|
||||
@@ -35,6 +42,4 @@ export const karma: IntermediateModerationPhase = ({ tenant, author }) => {
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -2,11 +2,15 @@ import linkify from "linkify-it";
|
||||
import tlds from "tlds";
|
||||
|
||||
import {
|
||||
GQLACTION_GROUP,
|
||||
GQLACTION_TYPE,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { ModerationSettings } from "talk-server/models/settings";
|
||||
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
} from "talk-server/services/comments/moderation";
|
||||
|
||||
/**
|
||||
* The preloaded linkify instance with common tlds.
|
||||
@@ -24,8 +28,7 @@ export const links: IntermediateModerationPhase = ({
|
||||
asset,
|
||||
tenant,
|
||||
comment,
|
||||
author,
|
||||
}) => {
|
||||
}): IntermediatePhaseResult | void => {
|
||||
if (
|
||||
testPremodLinksEnable(tenant, comment.body) ||
|
||||
(asset.settings && testPremodLinksEnable(asset.settings, comment.body))
|
||||
@@ -36,7 +39,7 @@ export const links: IntermediateModerationPhase = ({
|
||||
actions: [
|
||||
{
|
||||
action_type: GQLACTION_TYPE.FLAG,
|
||||
group_id: "LINKS",
|
||||
group_id: GQLACTION_GROUP.LINKS,
|
||||
metadata: {
|
||||
links: comment.body,
|
||||
},
|
||||
@@ -44,6 +47,4 @@ export const links: IntermediateModerationPhase = ({
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -3,14 +3,20 @@ import {
|
||||
GQLMODERATION_MODE,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { ModerationSettings } from "talk-server/models/settings";
|
||||
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
} from "talk-server/services/comments/moderation";
|
||||
|
||||
const testModerationMode = (settings: Partial<ModerationSettings>) =>
|
||||
settings.moderation === GQLMODERATION_MODE.PRE;
|
||||
|
||||
// This phase checks to see if the settings have premod enabled, if they do,
|
||||
// the comment is premod, otherwise, it's just none.
|
||||
export const premod: IntermediateModerationPhase = ({ asset, tenant }) => {
|
||||
export const premod: IntermediateModerationPhase = ({
|
||||
asset,
|
||||
tenant,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
// If the settings say that we're in premod mode, then the comment is in
|
||||
// premod status.
|
||||
|
||||
@@ -23,6 +29,4 @@ export const premod: IntermediateModerationPhase = ({ asset, tenant }) => {
|
||||
status: GQLCOMMENT_STATUS.PREMOD,
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Client } from "akismet-api";
|
||||
|
||||
import {
|
||||
GQLACTION_GROUP,
|
||||
GQLACTION_TYPE,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
|
||||
import logger from "talk-server/logger";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
} from "talk-server/services/comments/moderation";
|
||||
|
||||
export const spam: IntermediateModerationPhase = async ({
|
||||
asset,
|
||||
@@ -12,16 +17,26 @@ export const spam: IntermediateModerationPhase = async ({
|
||||
comment,
|
||||
author,
|
||||
req,
|
||||
}) => {
|
||||
}): Promise<IntermediatePhaseResult | void> => {
|
||||
const integration = tenant.integrations.akismet;
|
||||
|
||||
// We can only check for spam if this comment originated from a graphql
|
||||
// request via an HTTP call.
|
||||
if (!req || !integration.enabled) {
|
||||
if (!req) {
|
||||
logger.debug({ tenant_id: tenant.id }, "request was not available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!integration.enabled) {
|
||||
logger.debug({ tenant_id: tenant.id }, "akismet integration was disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!integration.key || !integration.site) {
|
||||
logger.error(
|
||||
{ tenant_id: tenant.id },
|
||||
"akismet integration was enabled but configuration was missing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,41 +49,73 @@ export const spam: IntermediateModerationPhase = async ({
|
||||
// Grab the properties we need.
|
||||
const userIP = req.ip;
|
||||
if (!userIP) {
|
||||
logger.debug(
|
||||
{ tenant_id: tenant.id },
|
||||
"request did not contain ip address, aborting spam check"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const userAgent = req.get("User-Agent");
|
||||
if (!userAgent || userAgent.length === 0) {
|
||||
logger.debug(
|
||||
{ tenant_id: tenant.id },
|
||||
"request did not contain User-Agent header, aborting spam check"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const referrer = req.get("Referrer");
|
||||
if (!referrer || referrer.length === 0) {
|
||||
logger.debug(
|
||||
{ tenant_id: tenant.id },
|
||||
"request did not contain Referrer header, aborting spam check"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the comment for spam.
|
||||
const isSpam = await client.checkSpam({
|
||||
user_ip: userIP, // REQUIRED
|
||||
referrer, // REQUIRED
|
||||
user_agent: userAgent, // REQUIRED
|
||||
comment_content: comment.body,
|
||||
permalink: asset.url,
|
||||
comment_author: author.displayName || author.username || "",
|
||||
comment_type: "comment",
|
||||
is_test: false,
|
||||
});
|
||||
if (isSpam) {
|
||||
return {
|
||||
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
|
||||
actions: [
|
||||
{
|
||||
action_type: GQLACTION_TYPE.FLAG,
|
||||
group_id: "SPAM_COMMENT",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
try {
|
||||
logger.trace({ tenant_id: tenant.id }, "checking comment for spam");
|
||||
|
||||
return;
|
||||
// Check the comment for spam.
|
||||
const isSpam = await client.checkSpam({
|
||||
user_ip: userIP, // REQUIRED
|
||||
referrer, // REQUIRED
|
||||
user_agent: userAgent, // REQUIRED
|
||||
comment_content: comment.body,
|
||||
permalink: asset.url,
|
||||
comment_author: author.displayName || author.username || "",
|
||||
comment_type: "comment",
|
||||
is_test: false,
|
||||
});
|
||||
if (isSpam) {
|
||||
logger.trace(
|
||||
{ tenant_id: tenant.id, is_spam: isSpam },
|
||||
"comment contained spam"
|
||||
);
|
||||
return {
|
||||
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
|
||||
actions: [
|
||||
{
|
||||
action_type: GQLACTION_TYPE.FLAG,
|
||||
group_id: GQLACTION_GROUP.SPAM_COMMENT,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
// Store the spam result from Akismet in the Comment metadata.
|
||||
akismet: spam,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
logger.trace(
|
||||
{ tenant_id: tenant.id, is_spam: isSpam },
|
||||
"comment did not contain spam"
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ tenant_id: tenant.id, err },
|
||||
"could not determine if comment contained spam"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,10 @@ import {
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLUSER_ROLE,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
} from "talk-server/services/comments/moderation";
|
||||
|
||||
// If a given user is a staff member, always approve their comment.
|
||||
export const staff: IntermediateModerationPhase = ({
|
||||
@@ -10,12 +13,10 @@ export const staff: IntermediateModerationPhase = ({
|
||||
tenant,
|
||||
comment,
|
||||
author,
|
||||
}) => {
|
||||
}): IntermediatePhaseResult | void => {
|
||||
if (author.role !== GQLUSER_ROLE.COMMENTER) {
|
||||
return {
|
||||
status: GQLCOMMENT_STATUS.ACCEPTED,
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { isNil } from "lodash";
|
||||
import ms from "ms";
|
||||
import fetch from "node-fetch";
|
||||
|
||||
import { Omit } from "talk-common/types";
|
||||
import {
|
||||
GQLACTION_GROUP,
|
||||
GQLACTION_TYPE,
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLPerspectiveExternalIntegration,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "talk-server/logger";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
} from "talk-server/services/comments/moderation";
|
||||
|
||||
export const toxic: IntermediateModerationPhase = async ({
|
||||
tenant,
|
||||
comment,
|
||||
}): Promise<IntermediatePhaseResult | void> => {
|
||||
const integration = tenant.integrations.perspective;
|
||||
|
||||
if (!integration.enabled) {
|
||||
// The Toxic comment plugin is not enabled.
|
||||
logger.debug(
|
||||
{ tenant_id: tenant.id },
|
||||
"perspective integration was disabled"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!integration.key) {
|
||||
// The Toxic comment requires a key in order to communicate with the API.
|
||||
logger.error(
|
||||
{ tenant_id: tenant.id },
|
||||
"perspective integration was enabled but configuration was missing"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let endpoint = integration.endpoint;
|
||||
if (isNil(endpoint)) {
|
||||
// TODO: (wyattjoh) replace hardcoded default with config.
|
||||
endpoint = "https://commentanalyzer.googleapis.com/v1alpha1";
|
||||
|
||||
logger.trace(
|
||||
{ tenant_id: tenant.id, endpoint },
|
||||
"endpoint missing in integration settings, using defaults"
|
||||
);
|
||||
}
|
||||
|
||||
let threshold = integration.threshold;
|
||||
if (isNil(threshold)) {
|
||||
// TODO: (wyattjoh) replace hardcoded default with config.
|
||||
threshold = 0.8;
|
||||
|
||||
logger.trace(
|
||||
{ tenant_id: tenant.id, threshold },
|
||||
"threshold missing in integration settings, using defaults"
|
||||
);
|
||||
}
|
||||
|
||||
let doNotStore = integration.doNotStore;
|
||||
if (isNil(doNotStore)) {
|
||||
doNotStore = true;
|
||||
|
||||
logger.trace(
|
||||
{ tenant_id: tenant.id, do_not_store: doNotStore },
|
||||
"doNotStore missing in integration settings, using defaults"
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) replace hardcoded default with config.
|
||||
const timeout = ms("300ms");
|
||||
|
||||
try {
|
||||
logger.trace({ tenant_id: tenant.id }, "checking comment toxicity");
|
||||
|
||||
// Call into the Toxic comment API.
|
||||
const scores = await getScores(
|
||||
comment.body,
|
||||
{
|
||||
endpoint,
|
||||
key: integration.key,
|
||||
doNotStore,
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
const score = scores.SEVERE_TOXICITY.summaryScore;
|
||||
const isToxic = score > threshold;
|
||||
if (isToxic) {
|
||||
logger.trace(
|
||||
{ tenant_id: tenant.id, score, is_toxic: isToxic, threshold },
|
||||
"comment was toxic"
|
||||
);
|
||||
return {
|
||||
status: GQLCOMMENT_STATUS.SYSTEM_WITHHELD,
|
||||
actions: [
|
||||
{
|
||||
action_type: GQLACTION_TYPE.FLAG,
|
||||
group_id: GQLACTION_GROUP.TOXIC_COMMENT,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
// Store the scores from perspective in the Comment metadata.
|
||||
perspective: scores,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
logger.trace(
|
||||
{ tenant_id: tenant.id, score, is_toxic: isToxic, threshold },
|
||||
"comment was not toxic"
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ tenant_id: tenant.id, err },
|
||||
"could not determine comment toxicity"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* getScores will return the toxicity scores for the comment text.
|
||||
*
|
||||
* @param text comment text to check for toxicity
|
||||
* @param settings integration settings used to communicate with the perspective api
|
||||
* @param timeout timeout for communicating with the perspective api
|
||||
*/
|
||||
async function getScores(
|
||||
text: string,
|
||||
{
|
||||
key,
|
||||
endpoint,
|
||||
doNotStore,
|
||||
}: Required<Omit<GQLPerspectiveExternalIntegration, "enabled" | "threshold">>,
|
||||
timeout: number
|
||||
) {
|
||||
try {
|
||||
const response = await fetch(`${endpoint}/comments:analyze?key=${key}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout,
|
||||
body: JSON.stringify({
|
||||
comment: {
|
||||
text,
|
||||
},
|
||||
// TODO: (wyattjoh) support other languages.
|
||||
languages: ["en"],
|
||||
doNotStore,
|
||||
requestedAttributes: {
|
||||
TOXICITY: {},
|
||||
SEVERE_TOXICITY: {},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Grab the data out of the Perspective API.
|
||||
const data = await response.json();
|
||||
|
||||
// Reformat the scores.
|
||||
return {
|
||||
TOXICITY: {
|
||||
summaryScore: data.attributeScores.TOXICITY.summaryScore.value,
|
||||
},
|
||||
SEVERE_TOXICITY: {
|
||||
summaryScore: data.attributeScores.SEVERE_TOXICITY.summaryScore.value,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
// Ensure that the API key doesn't get leaked to the logs by accident.
|
||||
if (err.message) {
|
||||
err.message = err.message.replace(key, "***");
|
||||
}
|
||||
|
||||
// Rethrow the error.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import {
|
||||
GQLACTION_GROUP,
|
||||
GQLACTION_TYPE,
|
||||
GQLCOMMENT_STATUS,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { IntermediateModerationPhase } from "talk-server/services/comments/moderation";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
} from "talk-server/services/comments/moderation";
|
||||
import { containsMatchingPhrase } from "talk-server/services/comments/moderation/wordlist";
|
||||
|
||||
// This phase checks the comment against the wordlist.
|
||||
export const wordlist: IntermediateModerationPhase = ({
|
||||
asset,
|
||||
tenant,
|
||||
comment,
|
||||
author,
|
||||
}) => {
|
||||
}): IntermediatePhaseResult | void => {
|
||||
// Decide the status based on whether or not the current asset/settings
|
||||
// has pre-mod enabled or not. If the comment was rejected based on the
|
||||
// wordlist, then reject it, otherwise if the moderation setting is
|
||||
@@ -23,7 +25,7 @@ export const wordlist: IntermediateModerationPhase = ({
|
||||
actions: [
|
||||
{
|
||||
action_type: GQLACTION_TYPE.FLAG,
|
||||
group_id: "BANNED_WORD",
|
||||
group_id: GQLACTION_GROUP.BANNED_WORD,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -40,11 +42,9 @@ export const wordlist: IntermediateModerationPhase = ({
|
||||
actions: [
|
||||
{
|
||||
action_type: GQLACTION_TYPE.FLAG,
|
||||
group_id: "SUSPECT_WORD",
|
||||
group_id: GQLACTION_GROUP.SUSPECT_WORD,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Config } from "talk-common/config";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
|
||||
export type DeconstructionFn<T> = (tenantID: string, value: T) => Promise<void>;
|
||||
|
||||
/**
|
||||
* TenantCacheAdapter is designed to allow additional services to cache entries
|
||||
* that are related to tenants that could possibly be cached. When caching of
|
||||
* tenants are enabled, this acts as a map to store entries, and will
|
||||
* automatically invalidate tenants that have been updated.
|
||||
*/
|
||||
export class TenantCacheAdapter<T> {
|
||||
private cache = new Map<string, T>();
|
||||
private tenantCache: TenantCache;
|
||||
private isCachingEnabled: boolean;
|
||||
|
||||
private unsubscribeFn?: () => void;
|
||||
private deconstructionFn?: DeconstructionFn<T>;
|
||||
|
||||
constructor(
|
||||
tenantCache: TenantCache,
|
||||
config: Config,
|
||||
deconstructionFn?: DeconstructionFn<T>
|
||||
) {
|
||||
this.tenantCache = tenantCache;
|
||||
this.isCachingEnabled = !config.get("disable_tenant_caching");
|
||||
this.deconstructionFn = deconstructionFn;
|
||||
}
|
||||
|
||||
public subscribe() {
|
||||
if (this.isCachingEnabled) {
|
||||
// Unsubscribe from updates if we
|
||||
this.unsubscribe();
|
||||
|
||||
this.unsubscribeFn = this.tenantCache.subscribe(async tenant => {
|
||||
// Get the current set value for the item in the cache.
|
||||
const value = this.get(tenant.id);
|
||||
|
||||
// Delete the tenant cache item when the tenant changes.
|
||||
this.cache.delete(tenant.id);
|
||||
|
||||
if (this.deconstructionFn) {
|
||||
// The deconstruction function is set. We will check that the value
|
||||
// exists, and if it does, we will call the function with the given
|
||||
// identifier, this allows the caller to attach deconstruction
|
||||
// components to the tenant being removed. The only side affect to
|
||||
// note is that by the time that the deconstruction function is
|
||||
// called, the tenant has already been purged from the cache.
|
||||
if (typeof value !== "undefined") {
|
||||
await this.deconstructionFn(tenant.id, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This will disconnect the map/cache from getting updates.
|
||||
*/
|
||||
public unsubscribe() {
|
||||
if (this.isCachingEnabled && this.unsubscribeFn) {
|
||||
this.unsubscribeFn();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* get will return the cached entry keyed on the tenantID.
|
||||
*
|
||||
* @param tenantID the tenantID for the cached item
|
||||
*/
|
||||
public get(tenantID: string): T | undefined {
|
||||
if (this.isCachingEnabled) {
|
||||
return this.cache.get(tenantID);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* set will set the cached entry into the map (if caching is enabled).
|
||||
*
|
||||
* @param tenantID the tenantID for the cached item
|
||||
* @param value the value to set in the map (if caching is enabled)
|
||||
*/
|
||||
public set(tenantID: string, value: T): TenantCacheAdapter<T> {
|
||||
if (this.isCachingEnabled) {
|
||||
this.cache.set(tenantID, value);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
+56
-16
@@ -4,6 +4,7 @@ import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { EventEmitter } from "events";
|
||||
import { Config } from "talk-common/config";
|
||||
import logger from "talk-server/logger";
|
||||
import {
|
||||
retrieveAllTenants,
|
||||
@@ -29,21 +30,13 @@ export default class TenantCache {
|
||||
/**
|
||||
* tenantsByID reference the tenants that have been cached/retrieved by ID.
|
||||
*/
|
||||
private tenantsByID = new DataLoader<string, Readonly<Tenant> | null>(ids => {
|
||||
logger.debug({ ids: ids.length }, "now loading tenants");
|
||||
return retrieveManyTenants(this.mongo, ids);
|
||||
});
|
||||
private tenantsByID: DataLoader<string, Readonly<Tenant> | null>;
|
||||
|
||||
/**
|
||||
* tenantsByDomain reference the tenants that have been cached/retrieved by
|
||||
* Domain.
|
||||
*/
|
||||
private tenantsByDomain = new DataLoader<string, Readonly<Tenant> | null>(
|
||||
domains => {
|
||||
logger.debug({ domains: domains.length }, "now loading tenants");
|
||||
return retrieveManyTenantsByDomain(this.mongo, domains);
|
||||
}
|
||||
);
|
||||
private tenantsByDomain: DataLoader<string, Readonly<Tenant> | null>;
|
||||
|
||||
/**
|
||||
* Create a new client application ID. This prevents duplicated messages
|
||||
@@ -54,23 +47,70 @@ export default class TenantCache {
|
||||
|
||||
private mongo: Db;
|
||||
private emitter = new EventEmitter();
|
||||
private cachingEnabled: boolean;
|
||||
|
||||
constructor(mongo: Db, subscriber: Redis, config: Config) {
|
||||
this.cachingEnabled = !config.get("disable_tenant_caching");
|
||||
if (!this.cachingEnabled) {
|
||||
logger.warn("tenant caching is disabled");
|
||||
} else {
|
||||
logger.debug("tenant caching is enabled");
|
||||
}
|
||||
|
||||
constructor(mongo: Db, subscriber: Redis) {
|
||||
// Save the Db reference.
|
||||
this.mongo = mongo;
|
||||
|
||||
// Attach to messages on this connection so we can receive updates when
|
||||
// the tenant are changed.
|
||||
subscriber.on("message", this.onMessage);
|
||||
// Configure the data loaders.
|
||||
this.tenantsByID = new DataLoader(
|
||||
async ids => {
|
||||
logger.debug({ ids: ids.length }, "now loading tenants");
|
||||
const tenants = await retrieveManyTenants(this.mongo, ids);
|
||||
logger.debug(
|
||||
{ tenants: tenants.filter(t => t !== null).length },
|
||||
"loaded tenants"
|
||||
);
|
||||
return tenants;
|
||||
},
|
||||
{
|
||||
cache: this.cachingEnabled,
|
||||
}
|
||||
);
|
||||
|
||||
// Subscribe to tenant notifications.
|
||||
subscriber.subscribe(TENANT_UPDATE_CHANNEL);
|
||||
this.tenantsByDomain = new DataLoader(
|
||||
async domains => {
|
||||
logger.debug({ domains: domains.length }, "now loading tenants");
|
||||
const tenants = await retrieveManyTenantsByDomain(this.mongo, domains);
|
||||
logger.debug(
|
||||
{ tenants: tenants.filter(t => t !== null).length },
|
||||
"loaded tenants"
|
||||
);
|
||||
return tenants;
|
||||
},
|
||||
{
|
||||
cache: this.cachingEnabled,
|
||||
}
|
||||
);
|
||||
|
||||
// We don't need updates if we aren't synced to tenant updates.
|
||||
if (this.cachingEnabled) {
|
||||
// Attach to messages on this connection so we can receive updates when
|
||||
// the tenant are changed.
|
||||
subscriber.on("message", this.onMessage);
|
||||
|
||||
// Subscribe to tenant notifications.
|
||||
subscriber.subscribe(TENANT_UPDATE_CHANNEL);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* primeAll will load all the tenants into the cache on startup.
|
||||
*/
|
||||
public async primeAll() {
|
||||
if (!this.cachingEnabled) {
|
||||
logger.debug("tenants not primed, caching disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Grab all the tenants for this node.
|
||||
const tenants = await retrieveAllTenants(this.mongo);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { get } from "lodash";
|
||||
|
||||
import { KarmaThresholds } from "talk-server/models/settings";
|
||||
import { GQLKarmaThresholds } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { User } from "talk-server/models/user";
|
||||
|
||||
export const getCommentTrustScore = (user: User): number =>
|
||||
get(user, "metadata.trust.comment.karma", 0);
|
||||
|
||||
export const isReliableCommenter = (
|
||||
thresholds: KarmaThresholds,
|
||||
thresholds: GQLKarmaThresholds,
|
||||
user: User
|
||||
): boolean | null => {
|
||||
const score = getCommentTrustScore(user);
|
||||
|
||||
+43
-13
@@ -10,13 +10,11 @@ Let's build some forms! We will use the following compoenents `InputLabel`, `Typ
|
||||
### Examples
|
||||
|
||||
import { Playground, PropsTable } from 'docz'
|
||||
import { InputLabel, ValidationMessage, TextField, Typography, Flex, Button} from '../core/client/ui/components'
|
||||
|
||||
# InputLabel
|
||||
import { InputLabel, CallOut, ValidationMessage, TextField, InputDescription, Flex, Button, FormField, Typography } from '../core/client/ui/components'
|
||||
|
||||
## Simple Form
|
||||
<Playground>
|
||||
<Flex>
|
||||
<Flex itemGutter direction="column">
|
||||
<Typography variant="heading1">Sign up to join the conversation</Typography>
|
||||
|
||||
<FormField>
|
||||
@@ -26,14 +24,13 @@ import { InputLabel, ValidationMessage, TextField, Typography, Flex, Button} fro
|
||||
|
||||
<FormField>
|
||||
<InputLabel>Username</InputLabel>
|
||||
<Typography>A unique identifier displayed on your comments. You may use “_” and “.”</Typography>
|
||||
<InputDescription>A unique identifier displayed on your comments. You may use “_” and “.”</InputDescription>
|
||||
<TextField/>
|
||||
</FormField>
|
||||
|
||||
|
||||
<FormField>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<Typography>Must be at least 8 characters</Typography>
|
||||
<InputDescription>Must be at least 8 characters</InputDescription>
|
||||
<TextField/>
|
||||
</FormField>
|
||||
|
||||
@@ -42,14 +39,14 @@ import { InputLabel, ValidationMessage, TextField, Typography, Flex, Button} fro
|
||||
<TextField/>
|
||||
</FormField>
|
||||
|
||||
<Button variant="filled" color="primary" size="large" >Sign up and join the conversation</Button>
|
||||
<Button variant="filled" color="primary" size="large" fullWidth>Sign up and join the conversation</Button>
|
||||
</Flex>
|
||||
</Playground>
|
||||
|
||||
## Simple Form with error
|
||||
|
||||
<Playground>
|
||||
<Flex>
|
||||
<Flex itemGutter direction="column">
|
||||
<Typography variant="heading1">Sign up to join the conversation</Typography>
|
||||
|
||||
<FormField>
|
||||
@@ -58,8 +55,8 @@ import { InputLabel, ValidationMessage, TextField, Typography, Flex, Button} fro
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<InputLabel defaultValue="something.com" color="error" >Username</InputLabel>
|
||||
<Typography>A unique identifier displayed on your comments. You may use “_” and “.”</Typography>
|
||||
<InputLabel defaultValue="something.com">Username</InputLabel>
|
||||
<InputDescription>A unique identifier displayed on your comments. You may use “_” and “.”</InputDescription>
|
||||
<TextField/>
|
||||
<ValidationMessage>Invalid characters. Try again.</ValidationMessage>
|
||||
</FormField>
|
||||
@@ -67,7 +64,7 @@ import { InputLabel, ValidationMessage, TextField, Typography, Flex, Button} fro
|
||||
|
||||
<FormField>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<Typography>Must be at least 8 characters</Typography>
|
||||
<InputDescription>Must be at least 8 characters</InputDescription>
|
||||
<TextField/>
|
||||
</FormField>
|
||||
|
||||
@@ -76,6 +73,39 @@ import { InputLabel, ValidationMessage, TextField, Typography, Flex, Button} fro
|
||||
<TextField/>
|
||||
</FormField>
|
||||
|
||||
<Button variant="filled" color="primary" size="large" >Sign up and join the conversation</Button>
|
||||
<Button variant="filled" color="primary" size="large" fullWidth>Sign up and join the conversation</Button>
|
||||
</Flex>
|
||||
</Playground>
|
||||
|
||||
## Simple Form with CallOut
|
||||
|
||||
<Playground>
|
||||
<Flex itemGutter direction="column">
|
||||
<Typography variant="heading1">Sign up to join the conversation</Typography>
|
||||
<CallOut color="error" fullWidth>The email address or password you entered is incorrect. Try again</CallOut>
|
||||
<FormField>
|
||||
<InputLabel>Email Address</InputLabel>
|
||||
<TextField/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<InputLabel defaultValue="something.com">Username</InputLabel>
|
||||
<InputDescription>A unique identifier displayed on your comments. You may use “_” and “.”</InputDescription>
|
||||
<TextField/>
|
||||
</FormField>
|
||||
|
||||
|
||||
<FormField>
|
||||
<InputLabel>Password</InputLabel>
|
||||
<InputDescription>Must be at least 8 characters</InputDescription>
|
||||
<TextField/>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<InputLabel>Confirm Password</InputLabel>
|
||||
<TextField/>
|
||||
</FormField>
|
||||
|
||||
<Button variant="filled" color="primary" size="large" fullWidth>Sign up and join the conversation</Button>
|
||||
</Flex>
|
||||
</Playground>
|
||||
|
||||
Reference in New Issue
Block a user