Merge branch 'auth-views' of github.com:coralproject/talk into auth-views

This commit is contained in:
Belen Curcio
2018-08-13 11:58:22 -03:00
74 changed files with 3232 additions and 497 deletions
+36
View File
@@ -30,3 +30,39 @@
2. Tenant RedisPubSub Publisher
3. Management RedisPubSub Subscriber
4. Management RedisPubSub Publisher
## Scripts
### Embed
Embed Script - Renders the iFrame <-- does not have a html page in production (should be on server?)
/dist/static/assets/embed.js /static/embed.js
### Stream
Stream - Renders the comment stream <-- data
/dist/static/assets/stream.<HASH>.css /static/assets/stream.<HASH>.css
/dist/static/assets/stream.<HASH>.js /static/assets/stream.<HASH>.js
/dist/static/stream.html /embed/stream
### Admin
Admin - Renders the Admin page <-- data
/dist/static/assets/admin.<HASH>.css /static/assets/admin.<HASH>.css
/dist/static/assets/admin.<HASH>.js /static/assets/admin.<HASH>.js
/dist/static/admin.html /admin
## Development Routes
localhost:3000
/ -> /admin
/dev <-- server side html for dev/iframe integration
localhost:8080
/ -> localhost:3000/dev
/embed/stream <-- stream html (now is at /)
/admin <-- stream html (now is not there)
+1 -1
View File
@@ -10,7 +10,7 @@ const config: Config = {
watchers: {
compileSchema: {
paths: ["core/server/**/*.graphql"],
executor: new CommandExecutor("npm run compile:schema", {
executor: new CommandExecutor("npx gulp server:schema", {
runOnInit: true,
}),
},
+72
View File
@@ -0,0 +1,72 @@
const path = require("path");
const json = require("comment-json");
const fs = require("fs");
const del = require("del");
const gulp = require("gulp");
const ts = require("gulp-typescript");
const sourcemaps = require("gulp-sourcemaps");
const babel = require("gulp-babel");
const generateTypescriptTypes = require("./scripts/generateSchemaTypes");
const _ = require("lodash");
// Parse the tsconfig.json file (which contains comments).
const tsconfig = json.parse(fs.readFileSync("./src/tsconfig.json").toString());
// Create the typescript project.
const tsProject = ts.createProject("./src/tsconfig.json");
const resolveDistFolder = (...paths) => "./" + path.join("dist", ...paths);
gulp.task("clean", () => del([resolveDistFolder()]));
gulp.task("server:schema", () => generateTypescriptTypes());
gulp.task("server:scripts", () =>
gulp
.src([
"./src/**/*.ts",
"./src/**/.*.ts",
// Exclude client files from this, that's for webpack.
"!./src/core/client/**/*.ts",
"!./src/core/client/**/.*.ts",
])
.pipe(sourcemaps.init())
.pipe(tsProject())
.pipe(
babel({
plugins: [
[
"module-resolver",
{
root: [resolveDistFolder()],
alias: _.reduce(
tsconfig.compilerOptions.paths,
(alias, [pathGlob], prefixBlob) => ({
...alias,
[prefixBlob.replace(/\/\*/g, "")]: pathGlob
.replace(/\/\*/g, "")
.replace(/^\./g, resolveDistFolder()),
}),
{}
),
},
],
],
})
)
.pipe(sourcemaps.write("."))
.pipe(gulp.dest(resolveDistFolder()))
);
gulp.task("server:static", () =>
gulp
.src(["./src/core/server/**/*", "!./src/core/server/**/*.ts"])
.pipe(gulp.dest(resolveDistFolder() + "/core/server"))
);
gulp.task(
"server",
gulp.series("server:schema", gulp.parallel("server:scripts", "server:static"))
);
gulp.task("default", gulp.series("clean", "server"));
+1752 -288
View File
File diff suppressed because it is too large Load Diff
+20 -8
View File
@@ -5,7 +5,7 @@
"scripts": {
"build": "npm-run-all compile --parallel build:*",
"build:client": "ts-node ./scripts/build.ts",
"build:server": "tsc -p ./src/tsconfig.json",
"build:server": "gulp server",
"compile": "npm-run-all --parallel compile:*",
"compile:css-types": "tcm src/core/client/",
"compile:relay-stream": "ts-node ./scripts/compileRelay --src ./src/core/client/stream --schema tenant",
@@ -13,7 +13,7 @@
"compile:schema": "node ./scripts/generateSchemaTypes.js",
"docz": "docz",
"start": "node dist/index.js",
"start:development": "cross-env NODE_ENV=development ts-node --project ./src/tsconfig.json -r tsconfig-paths/register ./src/index.ts",
"start:development": "ts-node --project ./src/tsconfig.json -r tsconfig-paths/register ./src/index.ts",
"start:webpackDevServer": "ts-node ./scripts/start.ts",
"lint": "npm-run-all --parallel lint:*",
"lint:server": "tslint --project ./src/tsconfig.json",
@@ -27,7 +27,7 @@
"tscheck:client": "tsc --project ./src/core/client/tsconfig.json --noEmit",
"tscheck:client-embed": "tsc --project ./src/core/client/embed/tsconfig.json --noEmit",
"tscheck:scripts": "tsc --project ./tsconfig.json --noEmit",
"watch": "cross-env NODE_ENV=development ts-node ./scripts/watcher/bin/watcher.ts --config ./config/watcher.ts"
"watch": "NODE_ENV=development ts-node ./scripts/watcher/bin/watcher.ts --config ./config/watcher.ts"
},
"author": "",
"license": "Apache-2.0",
@@ -36,6 +36,7 @@
"apollo-server-express": "^1.3.6",
"bcryptjs": "^2.4.3",
"bunyan": "^1.8.12",
"consolidate": "0.14.0",
"convict": "^4.3.1",
"dataloader": "^1.4.0",
"dotenv": "^6.0.0",
@@ -56,6 +57,7 @@
"lodash": "^4.17.10",
"luxon": "^1.3.1",
"mongodb": "^3.1.1",
"nunjucks": "^3.1.3",
"passport": "^0.4.0",
"passport-local": "^1.0.0",
"passport-oauth2": "^1.4.0",
@@ -78,6 +80,7 @@
"@types/chokidar": "^1.7.5",
"@types/classnames": "^2.2.4",
"@types/commander": "^2.12.2",
"@types/consolidate": "0.0.34",
"@types/convict": "^4.2.0",
"@types/cross-spawn": "^6.0.0",
"@types/dotenv": "^4.0.3",
@@ -85,7 +88,6 @@
"@types/enzyme-adapter-react-16": "^1.0.2",
"@types/eventemitter2": "^4.1.0",
"@types/express": "^4.16.0",
"@types/extract-text-webpack-plugin": "^3.0.3",
"@types/fs-extra": "^5.0.4",
"@types/graphql": "^0.13.3",
"@types/html-webpack-plugin": "^2.30.4",
@@ -97,8 +99,10 @@
"@types/linkify-it": "^2.0.3",
"@types/lodash": "^4.14.111",
"@types/luxon": "^0.5.3",
"@types/mini-css-extract-plugin": "^0.2.0",
"@types/mongodb": "^3.1.1",
"@types/node": "^10.5.2",
"@types/nunjucks": "^3.0.0",
"@types/passport": "^0.4.6",
"@types/passport-local": "^1.0.33",
"@types/passport-oauth2": "^1.4.5",
@@ -123,6 +127,7 @@
"autoprefixer": "^8.6.5",
"babel-core": "^7.0.0-bridge.0",
"babel-loader": "^8.0.0-beta",
"babel-plugin-module-resolver": "^3.1.1",
"babel-plugin-relay": "github:coralproject/patched#babel-plugin-relay",
"babel-preset-react-optimize": "^1.0.1",
"case-sensitive-paths-webpack-plugin": "^2.1.2",
@@ -130,16 +135,16 @@
"chokidar": "^2.0.4",
"classnames": "^2.2.5",
"commander": "^2.16.0",
"comment-json": "^1.1.3",
"copy-webpack-plugin": "^4.5.1",
"cross-env": "^5.2.0",
"cross-spawn": "^6.0.5",
"css-loader": "^0.28.11",
"del": "^3.0.0",
"docz": "^0.5.8",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"enzyme-to-json": "^3.3.4",
"eventemitter2": "^5.0.1",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"final-form": "^4.8.1",
"flat": "^4.1.0",
"fluent": "^0.6.4",
@@ -148,12 +153,19 @@
"fluent-react": "^0.7.0",
"graphql-playground-middleware-express": "^1.7.2",
"graphql-schema-typescript": "^1.2.1",
"gulp": "^4.0.0",
"gulp-babel": "^8.0.0-beta.2",
"gulp-cli": "^2.0.1",
"gulp-sourcemaps": "^2.6.4",
"gulp-typescript": "^5.0.0-alpha.3",
"html-webpack-plugin": "^3.2.0",
"jest": "^23.4.1",
"jest-junit": "^5.1.0",
"jest-localstorage-mock": "^2.2.0",
"jsdom": "^11.11.0",
"loader-utils": "^1.1.0",
"material-design-icons": "^3.0.1",
"mini-css-extract-plugin": "^0.4.1",
"npm-run-all": "^4.1.3",
"postcss-advanced-variables": "^2.3.3",
"postcss-css-variables": "^0.9.0",
@@ -195,8 +207,8 @@
"ts-node": "^6.2.0",
"tsconfig-paths": "^3.4.2",
"tsconfig-paths-webpack-plugin": "^3.1.4",
"tslint": "^5.10.0",
"tslint-config-prettier": "^1.13.0",
"tslint": "^5.11.0",
"tslint-config-prettier": "^1.14.0",
"tslint-loader": "^3.6.0",
"tslint-plugin-prettier": "^1.3.0",
"tslint-react": "^3.6.0",
+16 -10
View File
@@ -77,14 +77,20 @@ async function main() {
return files;
}
main()
.then(files => {
for (const { fileName } of files) {
module.exports = main;
if (require.main === module) {
// Only run the main module on file load if this is the main module (we're
// executing this file directly).
main()
.then(files => {
for (const { fileName } of files) {
// tslint:disable-next-line:no-console
console.log(`Generated ${fileName}`);
}
})
.catch(err => {
// tslint:disable-next-line:no-console
console.log(`Generated ${fileName}`);
}
})
.catch(err => {
// tslint:disable-next-line:no-console
console.error(err);
});
console.error(err);
});
}
+26 -42
View File
@@ -1,6 +1,6 @@
import CaseSensitivePathsPlugin from "case-sensitive-paths-webpack-plugin";
import ExtractTextPlugin from "extract-text-webpack-plugin";
import HtmlWebpackPlugin, { Options } from "html-webpack-plugin";
import MiniCssExtractPlugin from "mini-css-extract-plugin";
import path from "path";
import InterpolateHtmlPlugin from "react-dev-utils/InterpolateHtmlPlugin";
import WatchMissingNodeModulesPlugin from "react-dev-utils/WatchMissingNodeModulesPlugin";
@@ -8,6 +8,7 @@ import TsconfigPathsPlugin from "tsconfig-paths-webpack-plugin";
import UglifyJsPlugin from "uglifyjs-webpack-plugin";
import webpack, { Configuration } from "webpack";
import ManifestPlugin from "webpack-manifest-plugin";
import paths from "./paths";
interface CreateWebpackConfig {
@@ -59,27 +60,6 @@ export default function createWebpackConfig({
},
};
const cssLoaders = [
{
loader: require.resolve("css-loader"),
options: {
modules: true,
importLoaders: 1,
localIdentName: "[name]-[local]-[hash:base64:5]",
minimize: isProduction,
sourceMap: isProduction && !disableSourcemaps,
},
},
{
loader: require.resolve("postcss-loader"),
options: {
config: {
path: paths.appPostCssConfig,
},
},
},
];
const localesOptions = {
pathToLocales: paths.appLocales,
@@ -127,13 +107,9 @@ export default function createWebpackConfig({
},
sourceMap: !disableSourcemaps,
}),
// Note: this won't work without ExtractTextPlugin.extract(..) in `loaders`.
new ExtractTextPlugin({
// We use [md5:contenthash:hex:20] instead of [contenthash:8]
// because of this bug https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/763.
// TODO: Repalce with mini-css-extract-plugin once it supports HMR.
// https://github.com/webpack-contrib/mini-css-extract-plugin
filename: "assets/css/[name].[md5:contenthash:hex:20].css",
new MiniCssExtractPlugin({
filename: "assets/css/[name].[hash].css",
chunkFilename: "assets/css/[id].[hash].css",
}),
]
: [
@@ -316,19 +292,27 @@ export default function createWebpackConfig({
// in development "style" loader enables hot editing of CSS.
{
test: /\.css$/,
loader:
(isProduction &&
ExtractTextPlugin.extract({
fallback: styleLoader,
use: cssLoaders,
})) ||
undefined,
use:
(!isProduction && [
require.resolve("style-loader"),
...cssLoaders,
]) ||
undefined,
use: [
isProduction ? MiniCssExtractPlugin.loader : styleLoader,
{
loader: require.resolve("css-loader"),
options: {
modules: true,
importLoaders: 1,
localIdentName: "[name]-[local]-[hash:base64:5]",
minimize: isProduction,
sourceMap: isProduction && !disableSourcemaps,
},
},
{
loader: require.resolve("postcss-loader"),
options: {
config: {
path: paths.appPostCssConfig,
},
},
},
],
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
+159 -44
View File
@@ -1,5 +1,15 @@
import * as React from "react";
import { StatelessComponent } from "react";
import { Field, Form } from "react-final-form";
import { OnSubmit } from "talk-framework/lib/form";
import {
composeValidators,
required,
validateEmail,
validateEqualPasswords,
validatePassword,
validateUsername,
} from "talk-framework/lib/validation";
import * as styles from "./SignUp.css";
import {
@@ -9,53 +19,158 @@ import {
InputLabel,
TextField,
Typography,
ValidationMessage,
} from "talk-ui/components";
const SignUp: StatelessComponent = props => {
interface FormProps {
email: string;
username: string;
password: string;
confirmPassword: string;
}
export interface SignUpForm {
onSubmit: OnSubmit<FormProps>;
}
const SignUp: StatelessComponent<SignUpForm> = props => {
return (
<Flex itemGutter direction="column" className={styles.root}>
<Flex direction="column">
<Typography variant="heading1" align="center">
Sign up to join the conversation
</Typography>
<FormField>
<InputLabel>Email Address</InputLabel>
<TextField />
</FormField>
<FormField>
<InputLabel>Username</InputLabel>
<Typography variant="inputDescription">
A unique identifier displayed on your comments. You may use _ and
.
</Typography>
<TextField />
</FormField>
<FormField>
<InputLabel>Password</InputLabel>
<Typography>Must be at least 8 characters</Typography>
<TextField />
</FormField>
<FormField>
<InputLabel>Confirm Password</InputLabel>
<TextField />
</FormField>
</Flex>
<div className={styles.footer}>
<Button variant="filled" color="primary" size="large" fullWidth>
Sign up and join the conversation
</Button>
<Flex
itemGutter="half"
justifyContent="center"
className={styles.subFooter}
>
<Typography>Already have an account?</Typography>
<Button variant="underlined" size="small" color="primary">
Sign In
</Button>
</Flex>
</div>
</Flex>
<Form onSubmit={props.onSubmit}>
{({ handleSubmit, submitting }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<Flex itemGutter direction="column" className={styles.root}>
<Flex direction="column">
<Typography variant="heading1" align="center">
Sign up to join the conversation
</Typography>
<Field
name="email"
validate={composeValidators(required, validateEmail)}
>
{({ input, meta }) => (
<FormField>
<InputLabel>Email Address</InputLabel>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
placeholder="Email Address"
/>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</FormField>
)}
</Field>
<Field
name="username"
validate={composeValidators(required, validateUsername)}
>
{({ input, meta }) => (
<FormField>
<InputLabel>Username</InputLabel>
<Typography variant="inputDescription">
A unique identifier displayed on your comments. You may
use _ and .
</Typography>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
placeholder="Username"
/>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</FormField>
)}
</Field>
<Field
name="password"
validate={composeValidators(required, validatePassword)}
>
{({ input, meta }) => (
<FormField>
<InputLabel>Password</InputLabel>
<Typography variant="inputDescription">
Must be at least 8 characters
</Typography>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
placeholder="Password"
type="password"
/>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</FormField>
)}
</Field>
<Field
name="confirmPassword"
validate={composeValidators(
required,
validatePassword,
validateEqualPasswords
)}
>
{({ input, meta }) => (
<FormField>
<InputLabel>Confirm Password</InputLabel>
<Typography variant="inputDescription">
Must be at least 8 characters
</Typography>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
placeholder="Confirm Password"
type="password"
/>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</FormField>
)}
</Field>
</Flex>
<div className={styles.footer}>
<Button variant="filled" color="primary" size="large" fullWidth>
Sign up and join the conversation
</Button>
<Flex
itemGutter="half"
justifyContent="center"
className={styles.subFooter}
>
<Typography>Already have an account?</Typography>
<Button variant="underlined" size="small" color="primary">
Sign In
</Button>
</Flex>
</div>
</Flex>
</form>
)}
</Form>
);
};
@@ -1,10 +1,31 @@
import * as React from "react";
import { StatelessComponent } from "react";
import { BadUserInputError } from "talk-framework/lib/errors";
import SignUp, { SignUpForm } from "../components/SignUp";
import SignUp from "../components/SignUp";
import React, { Component } from "react";
import { SignUpMutation, withSignUpMutation } from "../mutations";
const SignUpContainer: StatelessComponent = () => {
return <SignUp />;
};
interface SignUpContainerProps {
signUp: SignUpMutation;
}
export default SignUpContainer;
class SignUpContainer extends Component<SignUpContainerProps> {
private onSubmit: SignUpForm["onSubmit"] = async (input, form) => {
try {
await this.props.signUp(input);
form.reset();
} catch (error) {
if (error instanceof BadUserInputError) {
return error.invalidArgsLocalized;
}
// tslint:disable-next-line:no-console
console.error(error);
}
return undefined;
};
public render() {
return <SignUp onSubmit={this.onSubmit} />;
}
}
const enhanced = withSignUpMutation(SignUpContainer);
export default enhanced;
@@ -0,0 +1,23 @@
import { Environment } from "relay-runtime";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { createMutationContainer } from "talk-framework/lib/relay";
import { signIn, SignInInput } from "talk-framework/rest";
export type SignInMutation = (input: SignInInput) => Promise<void>;
export async function commit(
environment: Environment,
input: SignInInput,
{ rest, postMessage }: TalkContext
) {
try {
const result = await signIn(rest, input);
postMessage.send("setAuthToken", result.token, window.opener);
window.close();
} catch (err) {
postMessage.send("authError", err.toString(), window.opener);
}
}
export const withSignInMutation = createMutationContainer("signIn", commit);
@@ -0,0 +1,23 @@
import { Environment } from "relay-runtime";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { createMutationContainer } from "talk-framework/lib/relay";
import { signUp, SignUpInput } from "talk-framework/rest";
export type SignUpMutation = (input: SignUpInput) => Promise<void>;
export async function commit(
environment: Environment,
input: SignUpInput,
{ rest, postMessage }: TalkContext
) {
try {
const result = await signUp(rest, input);
postMessage.send("setAuthToken", result.token, window.opener);
window.close();
} catch (err) {
postMessage.send("authError", err.toString(), window.opener);
}
}
export const withSignUpMutation = createMutationContainer("signUp", commit);
+2
View File
@@ -1 +1,3 @@
export { withSetViewMutation, SetViewMutation } from "./SetViewMutation";
export { withSignInMutation, SignInMutation } from "./SignInMutation";
export { withSignUpMutation, SignUpMutation } from "./SignUpMutation";
@@ -6,6 +6,8 @@ import { MediaQueryMatchers } from "react-responsive";
import { Formatter } from "react-timeago";
import { Environment } from "relay-runtime";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
import { UIContext } from "talk-ui/components";
import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside";
@@ -19,9 +21,21 @@ export interface TalkContext {
/** formatter for timeago. */
timeagoFormatter?: Formatter;
/** Session Storage */
localStorage: Storage;
/** Session storage */
sessionStorage: Storage;
/** media query values for testing purposes */
mediaQueryValues?: MediaQueryMatchers;
/** Rest client */
rest: RestClient;
/** postMessage service */
postMessage: PostMessageService;
/**
* A way to listen for clicks that are e.g. outside of the
* current frame for `ClickOutside`
@@ -5,11 +5,18 @@ import { Child as PymChild } from "pym.js";
import React from "react";
import { Formatter } from "react-timeago";
import { Environment, Network, RecordSource, Store } from "relay-runtime";
import { LOCAL_ID } from "talk-framework/lib/relay";
import {
createLocalStorage,
createSessionStorage,
} from "talk-framework/lib/storage";
import { RestClient } from "talk-framework/lib/rest";
import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside";
import { generateMessages, LocalesData, negotiateLanguages } from "../i18n";
import { fetchQuery } from "../network";
import { createFetch, TokenGetter } from "../network";
import { PostMessageService } from "../postMessage";
import { TalkContext } from "./TalkContext";
interface CreateContextArguments {
@@ -61,9 +68,17 @@ export default async function createContext({
eventEmitter = new EventEmitter2({ wildcard: true }),
}: CreateContextArguments): Promise<TalkContext> {
// Initialize Relay.
const source = new RecordSource();
const tokenGetter: TokenGetter = () => {
const localState = source.get(LOCAL_ID);
if (localState) {
return localState.authToken || "";
}
return "";
};
const relayEnvironment = new Environment({
network: Network.create(fetchQuery),
store: new Store(new RecordSource()),
network: Network.create(createFetch(tokenGetter)),
store: new Store(source),
});
// Listen for outside clicks.
@@ -99,6 +114,10 @@ export default async function createContext({
pym,
eventEmitter,
registerClickFarAway,
rest: new RestClient("/api"),
postMessage: new PostMessageService(),
localStorage: createLocalStorage(),
sessionStorage: createSessionStorage(),
};
// Run custom initializations.
@@ -17,3 +17,27 @@ export const VALIDATION_TOO_SHORT = () => (
<span>This field is too short.</span>
</Localized>
);
export const INVALID_EMAIL = () => (
<Localized id="framework-validation-invalidEmail">
<span>Please enter a valid email address.</span>
</Localized>
);
export const INVALID_USERNAME = () => (
<Localized id="framework-validation-invalidUsername">
<span>Please enter a valid username.</span>
</Localized>
);
export const INVALID_PASSWORD = () => (
<Localized id="framework-validation-invalidUsername">
<span>Please enter a valid password.</span>
</Localized>
);
export const PASSWORDS_DO_NOT_MATCH = () => (
<Localized id="framework-validation-passwordsDoNotMatch">
<span>Passwords do not match. Try again.</span>
</Localized>
);
@@ -26,17 +26,28 @@ function getError(errors: Error[]): Error {
return new GraphQLError(errors as any);
}
export type TokenGetter = () => string;
type CreateFetch = (token?: TokenGetter) => FetchFunction;
/**
* fetchQuery is a simple implementation of the `FetchFunction`
* createFetch returns a simple implementation of the `FetchFunction`
* required by Relay. It'll return a `NetworkError` on failure.
*/
const fetchQuery: FetchFunction = async (operation, variables) => {
const createFetch: CreateFetch = tokenGetter => async (
operation,
variables
) => {
const token = tokenGetter && tokenGetter();
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
try {
const response = await fetch("/api/tenant/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers,
body: JSON.stringify({
query: operation.text,
variables,
@@ -58,4 +69,4 @@ const fetchQuery: FetchFunction = async (operation, variables) => {
}
};
export default fetchQuery;
export default createFetch;
@@ -1 +1 @@
export { default as fetchQuery } from "./fetchQuery";
export { default as createFetch, TokenGetter } from "./fetchQuery";
@@ -0,0 +1,80 @@
import { PostMessageService } from "./postMessage";
it("post and subscribe to a message", done => {
const postMessage = new PostMessageService();
const cancel = postMessage.on("test", value => {
expect(value).toBe("value");
done();
cancel();
});
postMessage.send("test", "value", window);
});
it("should support complex value", done => {
const complex = { foo: "bar" };
const postMessage = new PostMessageService();
const cancel = postMessage.on("test", value => {
expect(value).toBe(complex);
done();
cancel();
});
postMessage.send("test", complex, window);
});
it("send to a different origin", done => {
const postMessage = new PostMessageService();
const cancelA = postMessage.on("testA", value => {
throw new Error("Should not reach this");
});
const cancelB = postMessage.on("testB", value => {
done();
cancelA();
cancelB();
});
postMessage.send("testA", "value", window, "http://i-do-not-exist.de");
postMessage.send("testB", "value", window);
});
it("should cancel", done => {
const postMessage = new PostMessageService();
const cancelA = postMessage.on("testA", value => {
throw new Error("Should not reach this");
});
const cancelB = postMessage.on("testB", value => {
done();
cancelB();
});
cancelA();
postMessage.send("testA", "value", window);
postMessage.send("testB", "value", window);
});
it("different scopes are isolated", done => {
const postMessageA = new PostMessageService("scopeA");
const postMessageB = new PostMessageService("scopeB");
const cancelA = postMessageA.on("testA", value => {
throw new Error("Should not reach this");
});
const cancelB = postMessageA.on("testB", value => {
done();
cancelA();
cancelB();
});
postMessageB.send("testA", "value", window);
postMessageA.send("testB", "value", window);
});
it("different message names are isolated", done => {
const postMessage = new PostMessageService();
const cancelA = postMessage.on("testA", value => {
expect(value).toBe("valueA");
});
const cancelB = postMessage.on("testB", value => {
expect(value).toBe("valueB");
done();
cancelA();
cancelB();
});
postMessage.send("testA", "valueA", window);
postMessage.send("testB", "valueB", window);
});
@@ -0,0 +1,66 @@
type PostMessageHandler = (value: any, name: string) => void;
/**
* Wrapper around the HTML postMessage API.
*/
export class PostMessageService {
private origin: string;
private scope: string;
constructor(
scope = "talk",
origin: string = `${location.protocol}//${location.host}`
) {
this.origin = origin;
this.scope = scope;
}
/**
* Send a message over the postMessage API
* @param name string name of the message
* @param value string value of the message
* @param target Window target window, e.g. window.opener
* @param targetOrigin string origin of target
*/
public send(name: string, value: any, target: Window, targetOrigin?: string) {
if (!target) {
return;
}
// Serialize the message to be sent via postMessage.
const msg = { name, value, scope: this.scope };
// Send the message.
target.postMessage(msg, targetOrigin || this.origin);
}
/**
* Subscribe to messages
* @param name string Name of the message
* @param handler PostMessageHandler
*/
public on(name: string, handler: PostMessageHandler) {
const listener = (event: MessageEvent) => {
if (!event.origin) {
if (process.env.NODE_ENV !== "test") {
// tslint:disable-next-line:no-console
console.warn("empty origin received in postMessage", name);
}
} else if (event.origin !== this.origin) {
return;
}
if (event.data.scope !== this.scope) {
return;
}
if (event.data.name !== name) {
return;
}
handler(event.data.value, event.data.name);
};
// Attach the listener to the target.
window.addEventListener("message", listener);
return () => {
window.removeEventListener("message", listener);
};
}
}
+57
View File
@@ -0,0 +1,57 @@
import merge from "lodash/merge";
import { Overwrite } from "talk-framework/types";
const buildOptions = (inputOptions: RequestInit = {}) => {
const defaultOptions: RequestInit = {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
credentials: "same-origin",
};
const options = merge({}, defaultOptions, inputOptions);
if (options.method!.toLowerCase() !== "get") {
options.body = JSON.stringify(options.body);
}
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();
}
};
type PartialRequestInit = Overwrite<Partial<RequestInit>, { body: any }>;
export class RestClient {
public readonly uri: string;
private tokenGetter?: () => string;
constructor(uri: string, tokenGetter?: () => string) {
this.uri = uri;
this.tokenGetter = tokenGetter;
}
public fetch<T>(path: string, options: PartialRequestInit): Promise<T> {
let opts = options;
if (this.tokenGetter) {
opts = merge({}, options, {
headers: {
Authorization: `Bearer ${this.tokenGetter()}`,
},
});
}
return fetch(`${this.uri}${path}`, buildOptions(opts)).then(handleResp);
}
}
@@ -0,0 +1,25 @@
import createInMemoryStorage from "./InMemoryStorage";
it("should set and unset values", () => {
const storage = createInMemoryStorage();
storage.setItem("test", "value");
expect(storage.getItem("test")).toBe("value");
storage.removeItem("test");
expect(storage.getItem("test")).toBeUndefined();
});
it("should return length", () => {
const storage = createInMemoryStorage();
storage.setItem("a", "value");
storage.setItem("b", "value");
storage.setItem("c", "value");
expect(storage.length).toBe(3);
});
it("should nth value", () => {
const storage = createInMemoryStorage();
storage.setItem("a", "a");
storage.setItem("b", "b");
storage.setItem("c", "c");
expect(storage.key(2)).toBe("c");
});
@@ -0,0 +1,45 @@
/**
* InMemoryStorage is a dumb implementation of the Storage interface that will
* not persist the data at all. It implements the Storage interface found:
*
* https://developer.mozilla.org/en-US/docs/Web/API/Storage
*/
class InMemoryStorage implements Storage {
private storage: Record<string, string>;
constructor() {
this.storage = {};
}
get length() {
return Object.keys(this.storage).length;
}
public clear() {
this.storage = {};
}
public key(n: number) {
if (this.length <= n) {
return null;
}
return this.storage[Object.keys(this.storage)[n]];
}
public getItem(key: string) {
return this.storage[key];
}
public setItem(key: string, value: string) {
this.storage[key] = value;
}
public removeItem(key: string) {
delete this.storage[key];
}
}
export default function createInMemoryStorage() {
return new InMemoryStorage();
}
@@ -0,0 +1,5 @@
import prefixStorage from "./prefixStorage";
export default function createLocalStorage(): Storage {
return prefixStorage(window.localStorage, "talk");
}
@@ -0,0 +1,5 @@
import prefixStorage from "./prefixStorage";
export default function createSessionStorage(): Storage {
return prefixStorage(window.sessionStorage, "talk");
}
@@ -0,0 +1,3 @@
export { default as createInMemoryStorage } from "./InMemoryStorage";
export { default as createLocalStorage } from "./LocalStorage";
export { default as createSessionStorage } from "./SessionStorage";
@@ -0,0 +1,86 @@
import sinon from "sinon";
import prefixStorage from "./prefixStorage";
it("should call clear", () => {
const storage = {
clear: sinon.mock().once(),
};
const prefixed = prefixStorage(storage as any, "talk");
prefixed.clear();
storage.clear.verify();
});
it("should call length", () => {
const ret = 10;
const storage = {
get length() {
return ret;
},
};
const prefixed = prefixStorage(storage as any, "talk");
expect(prefixed.length).toBe(ret);
});
it("should call key", () => {
const ret = "value";
const storage = {
key: sinon
.mock()
.withArgs(3)
.returns(ret),
};
const prefixed = prefixStorage(storage as any, "talk");
expect(prefixed.key(3)).toBe(ret);
(storage.key as any).verify();
});
it("should call key", () => {
const ret = "value";
const storage = {
key: sinon
.mock()
.withArgs(3)
.returns(ret),
};
const prefixed = prefixStorage(storage as any, "talk");
expect(prefixed.key(3)).toBe(ret);
(storage.key as any).verify();
});
it("should prefix setItem", () => {
const storage = {
setItem: sinon.mock().withArgs("talk:key", "value"),
};
const prefixed = prefixStorage(storage as any, "talk");
prefixed.setItem("key", "value");
storage.setItem.verify();
});
it("should prefix removeItem", () => {
const storage = {
removeItem: sinon.mock().withArgs("talk:key"),
};
const prefixed = prefixStorage(storage as any, "talk");
prefixed.removeItem("key");
storage.removeItem.verify();
});
it("should prefix getItem", () => {
const ret = "value";
const storage = {
getItem: sinon
.mock()
.withArgs("talk:key")
.returns(ret),
};
const prefixed = prefixStorage(storage as any, "talk");
expect(prefixed.getItem("key")).toBe(ret);
(storage.getItem as any).verify();
});
@@ -0,0 +1,41 @@
/**
* PrefixedStorage decorates a Storage and prefixes keys in
* getItem, setItem and removeItem with given prefix.
*/
class PrefixedStorage implements Storage {
private storage: Storage;
private prefix: string;
constructor(storage: Storage, prefix: string) {
this.storage = storage;
this.prefix = prefix;
}
get length() {
return this.storage.length;
}
public clear() {
this.storage.clear();
}
public key(n: number) {
return this.storage.key(n);
}
public getItem(key: string) {
return this.storage.getItem(`${this.prefix}:${key}`);
}
public setItem(key: string, value: string) {
return this.storage.setItem(`${this.prefix}:${key}`, value);
}
public removeItem(key: string) {
return this.storage.removeItem(`${this.prefix}:${key}`);
}
}
export default function prefixStorage(storage: Storage, prefix: string) {
return new PrefixedStorage(storage, prefix);
}
+39 -1
View File
@@ -1,5 +1,11 @@
import { ReactNode } from "react";
import { VALIDATION_REQUIRED } from "./messages";
import {
INVALID_EMAIL,
INVALID_PASSWORD,
INVALID_USERNAME,
PASSWORDS_DO_NOT_MATCH,
VALIDATION_REQUIRED,
} from "./messages";
type Validator<T, V> = (v: T, values: V) => ReactNode;
@@ -31,3 +37,35 @@ export function composeValidators<T = any, V = any>(
* required is a Validator that checks that the value is truthy.
*/
export const required = createValidator(v => !!v, VALIDATION_REQUIRED());
/**
* validateEmail is a Validator that checks that the value is an email.
*/
export const validateEmail = createValidator(
v => /^.+@.+\..+$/.test(v),
INVALID_EMAIL()
);
/**
* validateUsername is a Validator that checks that the value is a valid username.
*/
export const validateUsername = createValidator(
v => /^[a-zA-Z0-9_.]+$/.test(v),
INVALID_USERNAME()
);
/**
* validateUsername is a Validator that checks that the value is a valid username.
*/
export const validatePassword = createValidator(
v => /^(?=.{8,}).*$/.test(v),
INVALID_PASSWORD()
);
/**s
* validateUsername is a Validator that checks that the value is a valid username.
*/
export const validateEqualPasswords = createValidator(
(v, values) => v === values.password,
PASSWORDS_DO_NOT_MATCH()
);
@@ -0,0 +1,51 @@
import { commitLocalUpdate, Environment, RecordSource } from "relay-runtime";
import { timeout } from "talk-common/utils";
import { LOCAL_ID } from "talk-framework/lib/relay";
import { createInMemoryStorage } from "talk-framework/lib/storage";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import { commit } from "./SetAuthTokenMutation";
let environment: Environment;
const source: RecordSource = new RecordSource();
beforeAll(() => {
environment = createRelayEnvironment({
source,
});
});
it("Sets auth token", async () => {
const context = {
localStorage: createInMemoryStorage(),
};
const authToken = "auth token";
commit(environment, { authToken }, context as any);
expect(source.get(LOCAL_ID)!.authToken).toEqual(authToken);
expect(context.localStorage.getItem("authToken")).toEqual(authToken);
});
it("Removes auth token from localStorage", async () => {
const context = {
localStorage: createInMemoryStorage(),
};
localStorage.setItem("authToken", "tmp");
commit(environment, { authToken: null }, context as any);
expect(context.localStorage.getItem("authToken")).toBeUndefined();
});
it("Should call gc", async () => {
const context = {
localStorage: createInMemoryStorage(),
};
commitLocalUpdate(environment, store => {
store.create("should-disappear", "tmp");
});
const authToken = null;
expect(source.get("should-disappear")).not.toBeUndefined();
commit(environment, { authToken }, context as any);
await timeout();
expect(source.get(LOCAL_ID)!.authToken).toEqual(authToken);
expect(source.get("should-disappear")).toBeUndefined();
});
@@ -0,0 +1,41 @@
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";
export interface SetAuthTokenInput {
authToken: string | null;
}
export type SetAuthTokenMutation = (input: SetAuthTokenInput) => Promise<void>;
export async function commit(
environment: Environment,
input: SetAuthTokenInput,
{ localStorage }: TalkContext
) {
return commitLocalUpdate(environment, store => {
const record = store.get(LOCAL_ID)!;
record.setValue(input.authToken, "authToken");
if (input.authToken) {
localStorage.setItem("authToken", input.authToken);
} else {
localStorage.removeItem("authToken");
}
// Force gc to trigger.
environment
.retain({
dataID: "tmp",
node: { selections: [] },
variables: {},
})
.dispose();
});
}
export const withSetAuthTokenMutation = createMutationContainer(
"setCommentID",
commit
);
@@ -0,0 +1,5 @@
export {
withSetAuthTokenMutation,
SetAuthTokenMutation,
SetAuthTokenInput,
} from "./SetAuthTokenMutation";
+2
View File
@@ -0,0 +1,2 @@
export { default as signIn, SignInInput } from "./signIn";
export { default as signUp, SignUpInput } from "./signUp";
+17
View File
@@ -0,0 +1,17 @@
import { RestClient } from "../lib/rest";
export interface SignInInput {
email: string;
password: string;
}
export interface SignInResponse {
token: string;
}
export default function signIn(rest: RestClient, input: SignInInput) {
return rest.fetch<SignInResponse>("/tenant/auth/local", {
method: "POST",
body: input,
});
}
+18
View File
@@ -0,0 +1,18 @@
import { RestClient } from "../lib/rest";
export interface SignUpInput {
username: string;
password: string;
email: string;
}
export interface SignUpResponse {
token: string;
}
export default function signUp(rest: RestClient, input: SignUpInput) {
return rest.fetch<SignUpResponse>("/tenant/auth/local/signup", {
method: "POST",
body: input,
});
}
@@ -22,7 +22,6 @@ const App: StatelessComponent<AppProps> = props => {
{view}
</Flex>
);
// return <div className={styles.root}>{view}</div>;
};
export default App;
@@ -1,18 +1,28 @@
.root {
width: 100%;
padding-bottom: var(--spacing-unit);
}
.textarea {
composes: bodyCopy from "talk-ui/shared/typography.css";
display: block;
height: 100px;
height: 150px;
width: 100%;
box-sizing: border-box;
margin-bottom: var(--spacing-unit);
padding: var(--spacing-unit);
border-radius: var(--round-corners);
resize: vertical;
}
.postButtonContainer {
display: flex;
justify-content: flex-end;
&:first-child {
flex-grow: 1;
}
&:last-child {
flex-grow: 0;
}
}
@@ -5,8 +5,8 @@ import { Field, Form } from "react-final-form";
import PoweredBy from "./PoweredBy";
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";
interface FormProps {
@@ -15,6 +15,7 @@ interface FormProps {
export interface PostCommentFormProps {
onSubmit: OnSubmit<FormProps>;
signedIn: boolean;
}
const PostCommentForm: StatelessComponent<PostCommentFormProps> = props => (
@@ -29,6 +30,7 @@ const PostCommentForm: StatelessComponent<PostCommentFormProps> = props => (
name={input.name}
onChange={input.onChange}
value={input.value}
placeholder="Post a comment"
/>
{meta.touched &&
(meta.error || meta.submitError) && (
@@ -39,14 +41,26 @@ const PostCommentForm: StatelessComponent<PostCommentFormProps> = props => (
</div>
)}
</Field>
<div className={styles.postButtonContainer}>
<PoweredBy />
<Localized id="comments-postCommentForm-post">
<Button color="primary" variant="filled" disabled={submitting}>
Post
</Button>
</Localized>
</div>
{props.signedIn ? (
<div className={styles.postButtonContainer}>
<PoweredBy />
<Localized id="comments-postCommentForm-submit">
<Button color="primary" variant="filled" disabled={submitting}>
Submit
</Button>
</Localized>
</div>
) : (
<Button
color="primary"
variant="filled"
disabled
fullWidth
size="large"
>
Sign In and join the conversation
</Button>
)}
</form>
)}
</Form>
@@ -1,16 +1,11 @@
import React, { StatelessComponent } from "react";
import { Flex, Typography } from "talk-ui/components";
import * as styles from "./PoweredBy.css";
const PoweredBy: StatelessComponent = props => {
const PoweredBy: StatelessComponent = () => {
return (
<Flex itemGutter="half">
<Typography className={styles.text} variant="bodyCopy">
Powered by
</Typography>
<Typography className={styles.text} variant="heading4">
The Coral Project
</Typography>
<Typography variant="bodyCopy">Powered by</Typography>
<Typography variant="heading4">The Coral Project</Typography>
</Flex>
);
};
@@ -0,0 +1,3 @@
.child {
width: initial;
}
@@ -0,0 +1,51 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Button, Flex, Typography } from "talk-ui/components";
import MatchMedia from "talk-ui/components/MatchMedia";
import * as styles from "./UserBoxAuthenticated.css";
export interface UserBoxUnauthenticatedProps {
onSignOut: () => void;
}
const UserBoxAuthenticated: StatelessComponent<
UserBoxUnauthenticatedProps
> = props => {
return (
<Flex itemGutter>
<Flex itemGutter="half" className={styles.child}>
<MatchMedia gteWidth="sm">
<Localized id="comments-userBoxAuthenticated-signedInAs">
<Typography variant="bodyCopy" component="span">
Signed in as
</Typography>
</Localized>
<Typography variant="bodyCopyBold" component="span">
okbel
</Typography>
</MatchMedia>
</Flex>
<Flex itemGutter="half" className={styles.child}>
<Localized id="comments-userBoxAuthenticated-notYou">
<Typography variant="bodyCopy" component="span">
Not you?
</Typography>
</Localized>
<Localized id="comments-userBoxAuthenticated-signOut">
<Button
color="primary"
size="small"
variant="underlined"
onClick={props.onSignOut}
>
Sign Out
</Button>
</Localized>
</Flex>
</Flex>
);
};
export default UserBoxAuthenticated;
@@ -0,0 +1,16 @@
import { shallow } from "enzyme";
import { noop } from "lodash";
import React from "react";
import { PropTypesOf } from "talk-framework/types";
import UserBoxUnauthenticated from "./UserBoxUnauthenticated";
it("renders correctly", () => {
const props: PropTypesOf<typeof UserBoxUnauthenticated> = {
onSignIn: noop,
onRegister: noop,
};
const wrapper = shallow(<UserBoxUnauthenticated {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -31,7 +31,7 @@ class PostCommentFormContainer extends Component<InnerProps> {
return undefined;
};
public render() {
return <PostCommentForm onSubmit={this.onSubmit} />;
return <PostCommentForm onSubmit={this.onSubmit} signedIn={false} />;
}
}
@@ -11,6 +11,7 @@ import {
} from "talk-stream/mutations";
import { Popup } from "talk-ui/components";
// import UserBoxAuthenticated from "../components/UserBoxAuthenticated";
import UserBoxUnauthenticated from "../components/UserBoxUnauthenticated";
interface InnerProps {
@@ -44,6 +45,7 @@ export class UserBoxContainer extends Component<InnerProps> {
onBlur={this.handleBlur}
onClose={this.handleClose}
/>
{/* <UserBoxAuthenticated onSignOut={this.handleRegister} /> */}
<UserBoxUnauthenticated
onSignIn={this.handleSignIn}
onRegister={this.handleRegister}
+12 -4
View File
@@ -10,16 +10,24 @@ import {
} from "talk-framework/lib/bootstrap";
import AppContainer from "./containers/AppContainer";
import {
onPostMessageAuthError,
onPostMessageSetAuthToken,
onPymSetCommentID,
} from "./listeners";
import { initLocalState } from "./local";
import localesData from "./locales";
import { withSetCommentID } from "./pym";
const pymFeatures = [withSetCommentID];
const listeners = [
onPymSetCommentID,
onPostMessageSetAuthToken,
onPostMessageAuthError,
];
// This is called when the context is first initialized.
async function init(context: TalkContext) {
await initLocalState(context.relayEnvironment);
pymFeatures.forEach(f => f(context));
await initLocalState(context.relayEnvironment, context);
listeners.forEach(f => f(context));
}
async function main() {
@@ -0,0 +1,5 @@
export { default as onPymSetCommentID } from "./onPymSetCommentID";
export {
default as onPostMessageSetAuthToken,
} from "./onPostMessageSetAuthToken";
export { default as onPostMessageAuthError } from "./onPostMessageAuthError";
@@ -0,0 +1,11 @@
import { TalkContext } from "talk-framework/lib/bootstrap";
export default function onPostMessageSetAuthToken({
postMessage,
}: TalkContext) {
// Auth popup will use this to send back errors during login.
postMessage!.on("authError", error => {
// tslint:disable-next-line:no-console
console.error(error);
});
}
@@ -0,0 +1,32 @@
import { Environment, RecordSource } from "relay-runtime";
import { LOCAL_ID } from "talk-framework/lib/relay";
import { createInMemoryStorage } from "talk-framework/lib/storage";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import onPostMessageSetAuthToken from "./onPostMessageSetAuthToken";
let relayEnvironment: Environment;
const source: RecordSource = new RecordSource();
beforeAll(() => {
relayEnvironment = createRelayEnvironment({
source,
});
});
it("Sets auth token", () => {
const token = "auth-token";
const context = {
postMessage: {
on: (name: string, cb: (token: string) => void) => {
expect(name).toBe("setAuthToken");
cb(token);
},
},
relayEnvironment,
localStorage: createInMemoryStorage(),
};
onPostMessageSetAuthToken(context as any);
expect(source.get(LOCAL_ID)!.authToken).toEqual(token);
});
@@ -0,0 +1,11 @@
import { TalkContext } from "talk-framework/lib/bootstrap";
import { commit as setAuthToken } from "talk-framework/mutations/SetAuthTokenMutation";
export default function onPostMessageSetAuthToken(ctx: TalkContext) {
const { relayEnvironment, postMessage } = ctx;
// Auth popup will use this to handle a successful login.
postMessage!.on("setAuthToken", (authToken: string) => {
setAuthToken(relayEnvironment, { authToken }, ctx);
});
}
@@ -3,7 +3,7 @@ import { Environment, RecordSource } from "relay-runtime";
import { LOCAL_ID } from "talk-framework/lib/relay";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import withSetCommentID from "./withSetCommentID";
import onPymSetCommentID from "./onPymSetCommentID";
let relayEnvironment: Environment;
const source: RecordSource = new RecordSource();
@@ -25,7 +25,7 @@ it("Sets comment id", () => {
},
relayEnvironment,
};
withSetCommentID(context as any);
onPymSetCommentID(context as any);
expect(source.get(LOCAL_ID)!.commentID).toEqual(id);
});
@@ -40,6 +40,6 @@ it("Sets comment id to null when empty", () => {
},
relayEnvironment,
};
withSetCommentID(context as any);
onPymSetCommentID(context as any);
expect(source.get(LOCAL_ID)!.commentID).toEqual(null);
});
@@ -3,7 +3,7 @@ import { commitLocalUpdate } from "react-relay";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { LOCAL_ID } from "talk-framework/lib/relay";
export default function withSetCommentID({
export default function onPymSetCommentID({
relayEnvironment,
pym,
}: TalkContext) {
@@ -12,6 +12,7 @@ exports[`init local state 1`] = `
\\"client:root.local\\": {
\\"__id\\": \\"client:root.local\\",
\\"__typename\\": \\"Local\\",
\\"authToken\\": \\"\\",
\\"network\\": {
\\"__ref\\": \\"client:root.local.network\\"
},
@@ -2,6 +2,7 @@ import { Environment, RecordSource } from "relay-runtime";
import { timeout } from "talk-common/utils";
import { LOCAL_ID } from "talk-framework/lib/relay";
import { createInMemoryStorage } from "talk-framework/lib/storage";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import initLocalState from "./initLocalState";
@@ -18,7 +19,7 @@ beforeEach(() => {
});
it("init local state", async () => {
initLocalState(environment);
initLocalState(environment, { localStorage: createInMemoryStorage() } as any);
await timeout();
expect(JSON.stringify(source.toJSON(), null, 2)).toMatchSnapshot();
});
@@ -32,7 +33,7 @@ it("set assetID from query", () => {
document.title,
`http://localhost/?assetID=${assetID}`
);
initLocalState(environment);
initLocalState(environment, { localStorage: createInMemoryStorage() } as any);
expect(source.get(LOCAL_ID)!.assetID).toBe(assetID);
window.history.replaceState(previousState, document.title, previousLocation);
});
@@ -46,7 +47,16 @@ it("set commentID from query", () => {
document.title,
`http://localhost/?commentID=${commentID}`
);
initLocalState(environment);
initLocalState(environment, { localStorage: createInMemoryStorage() } as any);
expect(source.get(LOCAL_ID)!.commentID).toBe(commentID);
window.history.replaceState(previousState, document.title, previousLocation);
});
it("set authToken from localStorage", () => {
const authToken = "auth-token";
const localStorage = createInMemoryStorage();
localStorage.setItem("authToken", authToken);
initLocalState(environment, { localStorage } as any);
expect(source.get(LOCAL_ID)!.authToken).toBe(authToken);
localStorage.removeItem("authToken");
});
@@ -1,6 +1,7 @@
import qs from "query-string";
import { commitLocalUpdate, Environment } from "relay-runtime";
import { TalkContext } from "talk-framework/lib/bootstrap";
import {
createAndRetain,
LOCAL_ID,
@@ -17,14 +18,21 @@ import {
/**
* Initializes the local state, before we start the App.
*/
export default async function initLocalState(environment: Environment) {
export default async function initLocalState(
environment: Environment,
{ localStorage }: TalkContext
) {
commitLocalUpdate(environment, s => {
// TODO: (cvle) move local, auth token and network initialization to framework.
const root = s.getRoot();
// Create the Local Record which is the Root for the client states.
const localRecord = createAndRetain(environment, s, LOCAL_ID, LOCAL_TYPE);
root.setLinkedRecord(localRecord, "local");
// Set auth token
localRecord.setValue(localStorage.getItem("authToken") || "", "authToken");
// Parse query params
const query = qs.parse(location.search);
@@ -21,6 +21,7 @@ type Local {
assetURL: String
commentID: String
authPopup: AuthPopup!
authToken: String
}
extend type Query {
-1
View File
@@ -1 +0,0 @@
export { default as withSetCommentID } from "./withSetCommentID";
+1
View File
@@ -1,3 +1,4 @@
import "jest-localstorage-mock";
import "./enzyme";
import "./jsdom";
@@ -0,0 +1,2 @@
.root {
}
@@ -0,0 +1,36 @@
---
name: FormField
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` is to be used with Form Components `flexbox`.
## 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>
</Playground>
@@ -0,0 +1,43 @@
import cn from "classnames";
import React, { ReactNode } from "react";
import { StatelessComponent } from "react";
import { withStyles } from "talk-ui/hocs";
import * as styles from "./FormField.css";
interface InnerProps {
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.
return (
<div
className={cn(
classes.root,
{
[classes.itemGutter]: itemGutter === true,
[classes.halfItemGutter]: itemGutter === "half",
},
className
)}
{...rest}
>
{children}
</div>
);
};
FormField.defaultProps = {
itemGutter: true,
};
const enhanced = withStyles(styles)(FormField);
export default enhanced;
@@ -0,0 +1 @@
export { default } from "./InputDescription";
@@ -1,5 +1,5 @@
.root {
composes: textField from "talk-ui/shared/typography.css";
composes: inputText placeholderPseudo from "talk-ui/shared/typography.css";
position: relative;
display: block;
padding: calc(0.5 * var(--spacing-unit));
@@ -24,12 +24,3 @@
.fullWidth {
width: 100%;
}
::placeholder {
font-family: var(--font-family);
font-style: normal;
font-weight: normal;
line-height: calc(16rem / var(--rem-base));
font-size: calc(16rem / var(--rem-base));
color: var(--palette-grey-lighter);
}
@@ -18,5 +18,3 @@ import Flex from '../Flex'
<TextField color="error" defaultValue="A TextField with an error" fullWidth/>
</Flex>
</Playground>
@@ -34,9 +34,21 @@ export interface TextFieldProps {
*/
placeholder?: string;
/**
* readOnly
* Mark as readonly
*/
readOnly?: boolean;
/**
* Name
*/
name?: string;
/**
* type
*/
type?: string;
/**
* onChange
*/
onChange?: <T>(event: any) => void;
}
const TextField: StatelessComponent<TextFieldProps> = props => {
+41 -31
View File
@@ -59,33 +59,34 @@
.heading1 {
font-size: calc(24rem / var(--rem-base));
font-weight: var(--font-weight-medium);
font-family: "Manuale";
font-family: var(--font-family-serif);
line-height: calc(32em / 24);
letter-spacing: calc(0.2em / 16);
letter-spacing: calc(0.2em / 24);
color: var(--palette-text-primary);
}
.heading2 {
font-size: calc(20rem / var(--rem-base));
font-weight: var(--font-weight-medium);
font-family: "Manuale";
font-family: var(--font-family-serif);
line-height: calc(24em / 20);
letter-spacing: calc(0.2em / 20);
color: var(--palette-text-primary);
}
.heading3 {
font-size: calc(18rem / var(--rem-base));
font-weight: var(--font-weight-medium);
font-family: "Manuale";
font-family: var(--font-family-serif);
line-height: calc(20em / 18);
letter-spacing: calc(0.2em / 16);
letter-spacing: calc(0.2em / 18);
color: var(--palette-text-primary);
}
.heading4 {
font-size: calc(16rem / var(--rem-base));
font-weight: var(--font-weight-medium);
font-family: "Manuale";
font-family: var(--font-family-serif);
line-height: calc(18em / 16);
letter-spacing: calc(0.2em / 16);
color: var(--palette-text-primary);
@@ -94,7 +95,7 @@
.bodyCopy {
font-size: calc(16rem / var(--rem-base));
font-weight: var(--font-weight-regular);
font-family: "Source Sans Pro";
font-family: var(--font-family-sans-serif);
line-height: calc(18em / 16);
letter-spacing: calc(0.2em / 16);
color: var(--palette-text-primary);
@@ -107,44 +108,44 @@
.button {
color: var(--palette-text-secondary);
font-family: "Source Sans Pro";
font-family: var(--font-family-sans-serif);
font-weight: var(--font-weight-medium);
font-size: 14px;
line-height: calc(18em / 16);
letter-spacing: calc(0.57em / 16);
font-size: calc(14rem / var(--rem-base));
line-height: calc(18em / 14);
letter-spacing: calc(0.57em / 14);
}
.buttonLarge {
color: var(--palette-text-secondary);
font-family: "Source Sans Pro";
font-family: var(--font-family-sans-serif);
font-weight: var(--font-weight-medium);
font-size: 16px;
font-size: calc(16rem / var(--rem-base));
line-height: calc(20em / 16);
letter-spacing: calc(0.57em / 16);
}
.timestamp {
color: var(--palette-text-secondary);
font-family: "Source Sans Pro";
font-family: var(--font-family-sans-serif);
font-weight: var(--font-weight-medium);
font-size: 14px;
line-height: calc(18em / 16);
letter-spacing: calc(0.2em / 16);
font-size: calc(14rem / var(--rem-base));
line-height: calc(18em / 14);
letter-spacing: 0;
}
.alertMessage {
color: var(--palette-common-black);
font-family: var(--font-family);
font-family: var(--font-family-sans-serif);
font-weight: var(--font-weight-medium);
font-size: calc(16rem / var(--rem-base));
line-height: normal;
letter-spacing: calc(0.2em / 16);
line-height: calc(16em / 16);
letter-spacing: calc(-0.1em / 16);
}
.textField {
font-size: calc(16rem / var(--rem-base));
.inputText {
font-weight: var(--font-weight-regular);
font-family: var(--font-family);
font-family: var(--font-family-sans-serif);
font-size: calc(16rem / var(--rem-base));
line-height: calc(18em / 16);
letter-spacing: calc(0.2em / 16);
color: var(--palette-text-primary);
@@ -153,15 +154,15 @@
.inputLabel {
font-size: calc(18rem / var(--rem-base));
font-weight: var(--font-weight-medium);
font-family: "Manuale";
line-height: calc(18em / 16);
letter-spacing: calc(0.2em / 16);
font-family: var(--font-family-serif);
line-height: calc(18em / 18);
letter-spacing: calc(0.2em / 18);
color: var(--palette-text-primary);
}
.inputDescription {
font-size: calc(14rem / var(--rem-base));
font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-regular);
font-family: var(--font-family);
line-height: calc(18em / 16);
letter-spacing: calc(0.2em / 16);
@@ -169,10 +170,19 @@
}
.placeholder {
font-family: var(--font-family);
font-style: normal;
font-weight: normal;
line-height: calc(16rem / var(--rem-base));
font-family: var(--font-family-sans-serif);
font-weight: var(--font-weight-regular);
font-size: calc(16rem / var(--rem-base));
line-height: calc(20em / 16);
letter-spacing: 0;
color: var(--palette-grey-lighter);
}
.placeholderPseudo::placeholder {
font-family: var(--font-family-sans-serif);
font-weight: var(--font-weight-regular);
font-size: calc(16rem / var(--rem-base));
line-height: calc(20em / 16);
letter-spacing: 0;
color: var(--palette-grey-lighter);
}
+2 -2
View File
@@ -64,8 +64,8 @@ const variables = {
roundCorners: "2px",
/* Typography */
remBase: 16,
fontFamily: '"Source Sans Pro"',
fontSize: 16,
fontFamilySansSerif: '"Source Sans Pro"',
fontFamilySerif: '"Manuale"',
fontWeightLight: 300,
fontWeightRegular: 400,
fontWeightMedium: 600,
@@ -14,7 +14,6 @@ export interface SignupBody {
username: string;
password: string;
email: string;
displayName?: string;
}
const SignupBodySchema = Joi.object().keys({
@@ -0,0 +1,5 @@
import { RequestHandler } from "express";
export const streamHandler: RequestHandler = (req, res) => {
res.render("stream");
};
+39 -1
View File
@@ -1,7 +1,10 @@
import cons from "consolidate";
import { Express } from "express";
import http from "http";
import { Redis } from "ioredis";
import { Db } from "mongodb";
import nunjucks from "nunjucks";
import path from "path";
import { Config } from "talk-common/config";
import { notFoundMiddleware } from "talk-server/app/middleware/notFound";
@@ -29,6 +32,9 @@ export interface AppOptions {
* createApp will create a Talk Express app that can be used to handle requests.
*/
export async function createApp(options: AppOptions): Promise<Express> {
// Configure the application.
configureApplication(options);
// Pull the parent out of the options.
const { parent } = options;
@@ -46,7 +52,7 @@ export async function createApp(options: AppOptions): Promise<Express> {
);
// Static Files
parent.use(serveStatic);
parent.use("/assets", serveStatic);
// Error Handling
parent.use(notFoundMiddleware);
@@ -70,6 +76,38 @@ export const listenAndServe = (
const httpServer = app.listen(port, () => resolve(httpServer));
});
function configureApplication(options: AppOptions) {
const { parent } = options;
// Trust the first proxy in front of us, this will enable us to trust the fact
// that SSL was terminated correctly.
parent.set("trust proxy", 1);
// Setup the view config.
setupViews(options);
}
function setupViews(options: AppOptions) {
const { parent } = options;
// configure the default views directory.
const views = path.join(__dirname, "..", "..", "..", "static");
parent.set("views", views);
// Reconfigure nunjucks.
(cons.requires as any).nunjucks = nunjucks.configure(views, {
// In development, we should enable file watch mode.
watch: options.config.get("env") === "development",
});
// assign the nunjucks engine to .njk and .html files.
parent.engine("njk", cons.nunjucks);
parent.engine("html", cons.nunjucks);
// set .html as the default extension.
parent.set("view engine", "html");
}
/**
* attachSubscriptionHandlers attaches all the handlers to the http.Server to
* handle websocket traffic by upgrading their http connections to websocket.
@@ -1,5 +1,6 @@
import { RequestHandler } from "express";
export const notFoundMiddleware: RequestHandler = (req, res, next) => {
// FIXME: (wyattjoh) send an error that won't log as crazily as this one does.
next(new Error("not found"));
};
@@ -1,4 +1,8 @@
import serveStatic from "express-static-gzip";
import path from "path";
export default serveStatic(path.join(__dirname, "..", "..", "dist"), {});
const staticPath = path.resolve(
path.join(__dirname, "..", "..", "..", "..", "static", "assets")
);
export default serveStatic(staticPath, { index: false });
+3
View File
@@ -28,6 +28,9 @@ export default (options: MiddlewareOptions) => async (
// Attach the tenant to the request.
req.tenant = tenant;
// Attach the tenant to the view locals.
res.locals.tenant = tenant;
next();
} catch (err) {
next(err);
+4
View File
@@ -2,6 +2,7 @@ import express from "express";
import passport from "passport";
import { 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";
import { wrapAuthn } from "talk-server/app/middleware/passport";
@@ -134,5 +135,8 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
);
}
// Handle the stream handler.
router.get("/embed/stream", streamHandler);
return router;
}
+1 -1
View File
@@ -2,6 +2,6 @@
## Comments Tab
comments-postCommentForm-post = Posten
comments-postCommentForm-submit = Posten
comments-stream-loadMore = Mehr laden
comments-replyList-showAll = Alle anzeigen
+1 -1
View File
@@ -2,7 +2,7 @@
## Comments Tab
comments-postCommentForm-post = Post
comments-postCommentForm-submit = Submit
comments-stream-loadMore = Load more
comments-replyList-showAll = Show all