mirror of
https://github.com/wassname/talk.git
synced 2026-07-24 13:20:47 +08:00
[next] Install (#1957)
* Wip * Adding Wizard and initial step <3 * Wip * Adding more steps to the Install Wizard * Mantaining state across steps * Adding Steps * Step Component * wip * feat: improved logging * feat: added tenant install route * Progress * Refactor * Adding rest install mutation * Wip * Done * Addding snapshots * URL validators * Adding validation for url, and final step * Header component and main variants * fix: resolved router * fix: corrected spec for prop * fix: updated test snapshot * Translations * Translations * Adding extra validation * prefixing events with On - onSaveData * prefixing events with On - onSaveData * Adding translations, and colors from the palette * Centering steps * Ready * feat: added production redirects * fix: fixes during production * fix: removed dead code * Placeholder translations * removing submit from cancel * Adding a description for the URL field * typo * Refactor and renaming * Using white as var * Refactor and renaming * feat: use new palette colors * fix: extracted styles * Adding await to avoid race condition with setState :) * lint * feat: added urls * fix: wizard leaking styles * fix: simplify icon style * fix: adjust global body css to remove padding to allow seamless integration * fix: types and small refactors * fix: correctly filter wizard children * refactor: simplify step containers * fix: better typecheck * test: remove obsolete snapshots * fix: don't export FormProps
This commit is contained in:
@@ -65,6 +65,23 @@ const config: Config = {
|
||||
runOnInit: true,
|
||||
}),
|
||||
},
|
||||
compileRelayInstall: {
|
||||
paths: [
|
||||
"core/client/install/**/*.ts",
|
||||
"core/client/install/**/*.tsx",
|
||||
"core/client/install/**/*.graphql",
|
||||
"core/server/**/*.graphql",
|
||||
],
|
||||
ignore: [
|
||||
"core/**/*.d.ts",
|
||||
"core/**/*.graphql.ts",
|
||||
"**/test/**/*",
|
||||
"core/**/*.spec.*",
|
||||
],
|
||||
executor: new CommandExecutor("npm run compile:relay-install", {
|
||||
runOnInit: true,
|
||||
}),
|
||||
},
|
||||
compileCSSTypes: {
|
||||
paths: ["**/*.css"],
|
||||
executor: new CommandExecutor("npm run compile:css-types", {
|
||||
@@ -94,6 +111,7 @@ const config: Config = {
|
||||
"compileCSSTypes",
|
||||
"compileRelayStream",
|
||||
"compileRelayAuth",
|
||||
"compileRelayInstall",
|
||||
"compileRelayAdmin",
|
||||
"compileSchema",
|
||||
],
|
||||
@@ -103,6 +121,7 @@ const config: Config = {
|
||||
"compileCSSTypes",
|
||||
"compileRelayStream",
|
||||
"compileRelayAuth",
|
||||
"compileRelayInstall",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Generated
+21
-5
@@ -1675,6 +1675,15 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/bunyan-prettystream": {
|
||||
"version": "0.1.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/bunyan-prettystream/-/bunyan-prettystream-0.1.31.tgz",
|
||||
"integrity": "sha512-NE7fq2ZcX7OSMK+VhTNJkVEHlo+hm0uVXpuLeH1ifGm52Qwuo/kLD2GHo7UcEXMFu3duKver/AFo8C4TME93zw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/case-sensitive-paths-webpack-plugin": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.1.2.tgz",
|
||||
@@ -5714,6 +5723,11 @@
|
||||
"safe-json-stringify": "~1"
|
||||
}
|
||||
},
|
||||
"bunyan-prettystream": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/bunyan-prettystream/-/bunyan-prettystream-0.1.3.tgz",
|
||||
"integrity": "sha1-bDtxMmb2rTIAfHtqsemYokU0nZg="
|
||||
},
|
||||
"bytes": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
|
||||
@@ -6671,6 +6685,13 @@
|
||||
"moment": "2.22.2",
|
||||
"validator": "7.2.0",
|
||||
"yargs-parser": "10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"validator": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-7.2.0.tgz",
|
||||
"integrity": "sha512-c8NGTUYeBEcUIGeMppmNVKHE7wwfm3mYbNZxV+c5mlv9fDHI7Ad3p07qfNrn/CvpdkK2k61fOLRO2sTEhgQXmg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"cookie": {
|
||||
@@ -25297,11 +25318,6 @@
|
||||
"spdx-expression-parse": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"validator": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-7.2.0.tgz",
|
||||
"integrity": "sha512-c8NGTUYeBEcUIGeMppmNVKHE7wwfm3mYbNZxV+c5mlv9fDHI7Ad3p07qfNrn/CvpdkK2k61fOLRO2sTEhgQXmg=="
|
||||
},
|
||||
"value-equal": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/value-equal/-/value-equal-0.4.0.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"compile:css-types": "tcm src/core/client/",
|
||||
"compile:relay-stream": "ts-node ./scripts/compileRelay --src ./src/core/client/stream --schema tenant",
|
||||
"compile:relay-auth": "ts-node ./scripts/compileRelay --src ./src/core/client/auth --schema tenant",
|
||||
"compile:relay-install": "ts-node ./scripts/compileRelay --src ./src/core/client/install --schema tenant",
|
||||
"compile:relay-admin": "ts-node ./scripts/compileRelay --src ./src/core/client/admin --schema tenant",
|
||||
"compile:schema": "node ./scripts/generateSchemaTypes.js",
|
||||
"docz": "docz",
|
||||
@@ -38,6 +39,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bull": "^3.4.4",
|
||||
"bunyan": "^1.8.12",
|
||||
"bunyan-prettystream": "^0.1.3",
|
||||
"cheerio": "^1.0.0-rc.2",
|
||||
"consolidate": "0.14.0",
|
||||
"convict": "^4.3.1",
|
||||
@@ -96,6 +98,7 @@
|
||||
"@types/bcryptjs": "^2.4.1",
|
||||
"@types/bull": "^3.3.16",
|
||||
"@types/bunyan": "^1.8.4",
|
||||
"@types/bunyan-prettystream": "^0.1.31",
|
||||
"@types/case-sensitive-paths-webpack-plugin": "^2.1.2",
|
||||
"@types/cheerio": "^0.22.8",
|
||||
"@types/chokidar": "^1.7.5",
|
||||
|
||||
@@ -255,6 +255,22 @@ export default function createWebpackConfig({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: paths.appInstallLocalesTemplate,
|
||||
use: [
|
||||
// This is the locales loader that loads available locales
|
||||
// from a particular target.
|
||||
{
|
||||
loader: "locales-loader",
|
||||
options: {
|
||||
...localesOptions,
|
||||
// Target specifies the prefix for fluent files to be loaded.
|
||||
// ${target}-xyz.ftl and ${†arget}.ftl are loaded into the locales.
|
||||
target: "install",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Loader for our fluent files.
|
||||
{
|
||||
test: /\.ftl$/,
|
||||
@@ -432,6 +448,12 @@ export default function createWebpackConfig({
|
||||
paths.appAuthIndex,
|
||||
// Remove deactivated entries.
|
||||
],
|
||||
install: [
|
||||
// We ship polyfills by default
|
||||
paths.appPolyfill,
|
||||
...devServerEntries,
|
||||
paths.appInstallIndex,
|
||||
],
|
||||
admin: [
|
||||
// We ship polyfills by default
|
||||
paths.appPolyfill,
|
||||
@@ -457,6 +479,14 @@ export default function createWebpackConfig({
|
||||
inject: "body",
|
||||
...htmlWebpackConfig,
|
||||
}),
|
||||
// Generates an `install.html` file with the <script> injected.
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "install.html",
|
||||
template: paths.appInstallHTML,
|
||||
chunks: ["install"],
|
||||
inject: "body",
|
||||
...htmlWebpackConfig,
|
||||
}),
|
||||
// Generates an `admin.html` file with the <script> injected.
|
||||
new HtmlWebpackPlugin({
|
||||
filename: "admin.html",
|
||||
|
||||
@@ -29,6 +29,10 @@ export default {
|
||||
appAuthLocalesTemplate: resolveSrc("core/client/auth/locales.ts"),
|
||||
appAuthIndex: resolveSrc("core/client/auth/index.tsx"),
|
||||
|
||||
appInstallHTML: resolveSrc("core/client/install/index.html"),
|
||||
appInstallLocalesTemplate: resolveSrc("core/client/install/locales.ts"),
|
||||
appInstallIndex: resolveSrc("core/client/install/index.tsx"),
|
||||
|
||||
appAdminHTML: resolveSrc("core/client/admin/index.html"),
|
||||
appAdminLocalesTemplate: resolveSrc("core/client/admin/locales.ts"),
|
||||
appAdminIndex: resolveSrc("core/client/admin/index.tsx"),
|
||||
|
||||
@@ -15,7 +15,13 @@ import PymControl, {
|
||||
defaultPymControlFactory,
|
||||
PymControlFactory,
|
||||
} from "./PymControl";
|
||||
import { ensureEndSlash } from "./utils";
|
||||
import { ensureNoEndSlash } from "./utils";
|
||||
|
||||
// This is importing the url helper from the framework using a relative path
|
||||
// import because the ts paths are not configured to use the framework for this
|
||||
// target.
|
||||
// TODO: (wyattjoh) replace with import from framework when we include it in the config.
|
||||
import urls from "../framework/helpers/urls";
|
||||
|
||||
export interface StreamEmbedConfig {
|
||||
assetID?: string;
|
||||
@@ -110,7 +116,8 @@ export class StreamEmbed {
|
||||
assetURL: this.config.assetURL,
|
||||
commentID: this.config.commentID,
|
||||
});
|
||||
const url = `${ensureEndSlash(this.config.rootURL)}stream.html${
|
||||
|
||||
const url = `${ensureNoEndSlash(this.config.rootURL)}${urls.embed.stream}${
|
||||
query ? `?${query}` : ""
|
||||
}`;
|
||||
this.pymControl = this.pymControlFactory({
|
||||
|
||||
@@ -4,7 +4,7 @@ exports[`should pass correct values to pymControl 1`] = `
|
||||
Object {
|
||||
"id": "container-id",
|
||||
"title": "StreamEmbed",
|
||||
"url": "http://localhost/stream.html?assetID=asset-id&assetURL=asset-url&commentID=comment-id",
|
||||
"url": "http://localhost/embed/stream?assetID=asset-id&assetURL=asset-url&commentID=comment-id",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -12,6 +12,6 @@ exports[`should pass default values to pymControl 1`] = `
|
||||
Object {
|
||||
"id": "container-id",
|
||||
"title": "StreamEmbed",
|
||||
"url": "http://localhost/stream.html",
|
||||
"url": "http://localhost/embed/stream",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Basic integration test should render iframe 1`] = `"<iframe src=\\"http://localhost/stream.html?assetURL=http%3A%2F%2Flocalhost%2F&initialWidth=0&childId=basic-integration-test-id&parentTitle=&parentUrl=http%3A%2F%2Flocalhost%2F\\" width=\\"100%\\" scrolling=\\"no\\" marginheight=\\"0\\" frameborder=\\"0\\" title=\\"Talk Embed Stream\\" id=\\"basic-integration-test-id_iframe\\" name=\\"basic-integration-test-id_iframe\\" style=\\"width: 1px; min-width: 100%;\\"></iframe>"`;
|
||||
exports[`Basic integration test should render iframe 1`] = `"<iframe src=\\"http://localhost/embed/stream?assetURL=http%3A%2F%2Flocalhost%2F&initialWidth=0&childId=basic-integration-test-id&parentTitle=&parentUrl=http%3A%2F%2Flocalhost%2F\\" width=\\"100%\\" scrolling=\\"no\\" marginheight=\\"0\\" frameborder=\\"0\\" title=\\"Talk Embed Stream\\" id=\\"basic-integration-test-id_iframe\\" name=\\"basic-integration-test-id_iframe\\" style=\\"width: 1px; min-width: 100%;\\"></iframe>"`;
|
||||
|
||||
exports[`Basic integration test should use canonical link 1`] = `"<iframe src=\\"http://localhost/stream.html?assetURL=http%3A%2F%2Flocalhost%2Fcanonical&initialWidth=0&childId=basic-integration-test-id&parentTitle=&parentUrl=http%3A%2F%2Flocalhost%2F\\" width=\\"100%\\" scrolling=\\"no\\" marginheight=\\"0\\" frameborder=\\"0\\" title=\\"Talk Embed Stream\\" name=\\"basic-integration-test-id_iframe\\" style=\\"width: 1px; min-width: 100%;\\"></iframe>"`;
|
||||
exports[`Basic integration test should use canonical link 1`] = `"<iframe src=\\"http://localhost/embed/stream?assetURL=http%3A%2F%2Flocalhost%2Fcanonical&initialWidth=0&childId=basic-integration-test-id&parentTitle=&parentUrl=http%3A%2F%2Flocalhost%2F\\" width=\\"100%\\" scrolling=\\"no\\" marginheight=\\"0\\" frameborder=\\"0\\" title=\\"Talk Embed Stream\\" name=\\"basic-integration-test-id_iframe\\" style=\\"width: 1px; min-width: 100%;\\"></iframe>"`;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "es5"],
|
||||
"types": ["jest"],
|
||||
"types": ["jest", "node"],
|
||||
"paths": {}
|
||||
},
|
||||
"include": [
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import ensureNoEndSlash from "./ensureNoEndSlash";
|
||||
|
||||
it("should remove the slash from the end", () => {
|
||||
const path = ensureNoEndSlash("/test/");
|
||||
expect(path).toBe("/test");
|
||||
});
|
||||
|
||||
it("should not change a string if there is no ending slash", () => {
|
||||
const path = ensureNoEndSlash("/test");
|
||||
expect(path).toBe("/test");
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function ensureEndSlash(p: string) {
|
||||
return p.replace(/\/$/, "");
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as buildURL } from "./buildURL";
|
||||
export { default as ensureEndSlash } from "./ensureEndSlash";
|
||||
export { default as ensureNoEndSlash } from "./ensureNoEndSlash";
|
||||
export { default as startsWith } from "./startsWith";
|
||||
export { default as prefixStorage } from "./prefixStorage";
|
||||
export { default as parseHashQuery } from "./parseHashQuery";
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as getMe } from "./getMe";
|
||||
export { default as getURLWithCommentID } from "./getURLWithCommentID";
|
||||
export { default as urls } from "./urls";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default (process.env.NODE_ENV !== "development"
|
||||
? {
|
||||
admin: "/admin",
|
||||
embed: {
|
||||
stream: "/embed/stream",
|
||||
auth: "/embed/auth",
|
||||
},
|
||||
}
|
||||
: {
|
||||
admin: "/admin.html",
|
||||
embed: {
|
||||
stream: "/stream.html",
|
||||
auth: "/auth.html",
|
||||
},
|
||||
});
|
||||
@@ -53,3 +53,9 @@ export const PASSWORDS_DO_NOT_MATCH = () => (
|
||||
<span>Passwords do not match. Try again.</span>
|
||||
</Localized>
|
||||
);
|
||||
|
||||
export const INVALID_URL = () => (
|
||||
<Localized id="framework-validation-invalidURL">
|
||||
<span>Invalid URL</span>
|
||||
</Localized>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactNode } from "react";
|
||||
import {
|
||||
INVALID_CHARACTERS,
|
||||
INVALID_EMAIL,
|
||||
INVALID_URL,
|
||||
PASSWORD_TOO_SHORT,
|
||||
PASSWORDS_DO_NOT_MATCH,
|
||||
USERNAME_TOO_LONG,
|
||||
@@ -56,6 +57,17 @@ export const validateUsernameCharacters = createValidator(
|
||||
INVALID_CHARACTERS()
|
||||
);
|
||||
|
||||
/**
|
||||
* validateURL is a Validator that checks that the URL only contains valid characters.
|
||||
*/
|
||||
export const validateURL = createValidator(
|
||||
v =>
|
||||
/^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/.test(
|
||||
v
|
||||
),
|
||||
INVALID_URL()
|
||||
);
|
||||
|
||||
/**
|
||||
* validateUsernameMinLength is a Validator that checks that the username has a min length of characters
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as signIn, SignInInput } from "./signIn";
|
||||
export { default as signUp, SignUpInput } from "./signUp";
|
||||
export { default as signOut } from "./signOut";
|
||||
export { default as install, InstallInput } from "./install";
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { RestClient } from "../lib/rest";
|
||||
|
||||
export interface InstallInput {
|
||||
tenant: {
|
||||
organizationName: string;
|
||||
organizationContactEmail: string;
|
||||
organizationURL: string;
|
||||
domains: string[];
|
||||
};
|
||||
user: {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function install(rest: RestClient, input: InstallInput) {
|
||||
return rest.fetch("/tenant/install", {
|
||||
method: "POST",
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
const path = require("path");
|
||||
module.exports = {
|
||||
extends: "../.babelrc.js",
|
||||
plugins: [
|
||||
[
|
||||
"babel-plugin-relay",
|
||||
{ artifactDirectory: path.resolve(__dirname, "./__generated__") },
|
||||
],
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/* Here we add global stylings for body and document */
|
||||
:global {
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
/* Support for all WebKit browsers. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Support for Firefox. */
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--palette-background-light);
|
||||
color: var(--palette-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--spacing-unit) var(--spacing-unit) calc(2 * var(--spacing-unit))
|
||||
var(--spacing-unit);
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const wrapper = shallow(<App />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import React, { Component } from "react";
|
||||
import * as styles from "./App.css";
|
||||
|
||||
import WizardContainer from "../containers/WizardContainer";
|
||||
import MainBar from "./MainBar";
|
||||
|
||||
class App extends Component {
|
||||
public render() {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<MainBar />
|
||||
<div className={styles.container}>
|
||||
<WizardContainer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,20 @@
|
||||
.root {
|
||||
padding: calc(4 * var(--spacing-unit)) 0;
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.headlineMain {
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.subHeadline {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.subHeadlineMain {
|
||||
font-size: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { Flex, Typography } from "talk-ui/components";
|
||||
|
||||
import * as styles from "./Header.css";
|
||||
|
||||
interface HeaderProps {
|
||||
main?: boolean;
|
||||
}
|
||||
|
||||
const Header: StatelessComponent<HeaderProps> = ({ main }) => {
|
||||
return (
|
||||
<Flex
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
direction="column"
|
||||
className={styles.root}
|
||||
>
|
||||
<Typography
|
||||
className={cn(styles.headline, {
|
||||
[styles.headlineMain]: main,
|
||||
})}
|
||||
>
|
||||
The Coral Project
|
||||
</Typography>
|
||||
<Localized id="install-title">
|
||||
<Typography
|
||||
className={cn(styles.subHeadline, {
|
||||
[styles.subHeadlineMain]: main,
|
||||
})}
|
||||
variant="heading1"
|
||||
>
|
||||
Talk Installation Wizard
|
||||
</Typography>
|
||||
</Localized>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,26 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
border-bottom: 2px solid var(--palette-grey-main);
|
||||
padding: var(--spacing-unit);
|
||||
box-sizing: border-box;
|
||||
background: var(--palette-text-primary);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--palette-text-primary) 0%,
|
||||
var(--palette-grey-main) 100%
|
||||
);
|
||||
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.2), 0 4px 5px 0 rgba(0, 0, 0, 0.14),
|
||||
0 1px 10px 0 rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--palette-text-light);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
|
||||
import { Typography } from "talk-ui/components";
|
||||
|
||||
import * as styles from "./MainBar.css";
|
||||
|
||||
const MainBar = () => {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.container}>
|
||||
<Typography variant="heading1" className={styles.title}>
|
||||
Talk
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainBar;
|
||||
@@ -0,0 +1,13 @@
|
||||
.root {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stepBar {
|
||||
padding: var(--spacing-unit) 0 calc(6 * var(--spacing-unit));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { Component, ReactNode } from "react";
|
||||
|
||||
import { Step, StepBar } from "talk-ui/components";
|
||||
|
||||
import { WizardProps } from "../components/Wizard";
|
||||
import Header from "./Header";
|
||||
import * as styles from "./Wizard.css";
|
||||
|
||||
export interface WizardProps {
|
||||
currentStep: number;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
class Wizard extends Component<WizardProps> {
|
||||
public render() {
|
||||
const { children, currentStep, className } = this.props;
|
||||
|
||||
const wizardChildren = React.Children.toArray(children);
|
||||
const wizardChildrenToRender = wizardChildren.filter(
|
||||
(_, i) => i === currentStep
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn(className, styles.root)}>
|
||||
<Header main={currentStep === 0} />
|
||||
{currentStep !== 0 &&
|
||||
currentStep !== wizardChildren.length - 1 && (
|
||||
<StepBar currentStep={currentStep - 1} className={styles.stepBar}>
|
||||
<Step hidden>Start</Step>
|
||||
<Step>
|
||||
<Localized id="install-createYourAccount-stepTitle">
|
||||
<span>Create Admin Account</span>
|
||||
</Localized>
|
||||
</Step>
|
||||
<Step>
|
||||
<Localized id="install-addOrganization-stepTitle">
|
||||
<span>Add Organization Details</span>
|
||||
</Localized>
|
||||
</Step>
|
||||
<Step>
|
||||
<Localized id="install-permittedDomains-stepTitle">
|
||||
<span>Add Permitted Domains</span>
|
||||
</Localized>
|
||||
</Step>
|
||||
<Step hidden>Finish</Step>
|
||||
</StepBar>
|
||||
)}
|
||||
<section className={styles.section}>{wizardChildrenToRender}</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Wizard;
|
||||
@@ -0,0 +1,14 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="App-root"
|
||||
>
|
||||
<MainBar />
|
||||
<div
|
||||
className="App-container"
|
||||
>
|
||||
<withContext(createMutationContainer(WizardContainer)) />
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import Wizard from "../components/Wizard";
|
||||
import { InstallMutation, withInstallMutation } from "../mutations";
|
||||
import FinalStep from "../steps/components/FinalStep";
|
||||
import InitialStep from "../steps/components/InitialStep";
|
||||
import AddOrganizationContainer from "../steps/containers/AddOrganizationContainer";
|
||||
import CreateYourAccountContainer from "../steps/containers/CreateYourAccountContainer";
|
||||
import PermittedDomainsContainer from "../steps/containers/PermittedDomainsContainer";
|
||||
|
||||
import { InstallInput } from "talk-framework/rest";
|
||||
|
||||
interface FormData {
|
||||
organizationName: string;
|
||||
organizationContactEmail: string;
|
||||
organizationURL: string;
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
domains: string[];
|
||||
}
|
||||
|
||||
interface WizardContainerState {
|
||||
step: number;
|
||||
data: FormData;
|
||||
}
|
||||
|
||||
function shapeFinalData(data: FormData): InstallInput {
|
||||
const {
|
||||
organizationName,
|
||||
organizationContactEmail,
|
||||
organizationURL,
|
||||
domains,
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
} = data;
|
||||
|
||||
return {
|
||||
tenant: {
|
||||
organizationName,
|
||||
organizationContactEmail,
|
||||
organizationURL,
|
||||
domains,
|
||||
},
|
||||
user: {
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
install: InstallMutation;
|
||||
}
|
||||
|
||||
class WizardContainer extends Component<Props, WizardContainerState> {
|
||||
public state = {
|
||||
step: 0,
|
||||
data: {
|
||||
organizationContactEmail: "",
|
||||
organizationName: "",
|
||||
organizationURL: "",
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
domains: [],
|
||||
},
|
||||
};
|
||||
|
||||
private handleSaveData = (newData: FormData) => {
|
||||
this.setState(({ data }) => ({
|
||||
data: { ...data, ...newData },
|
||||
}));
|
||||
};
|
||||
|
||||
private handleGoToNextStep = () =>
|
||||
this.setState(({ step }) => ({
|
||||
step: step + 1,
|
||||
}));
|
||||
|
||||
private handleGoToPreviousStep = () =>
|
||||
this.setState(({ step }) => ({
|
||||
step: step - 1,
|
||||
}));
|
||||
|
||||
private handleInstall = async (newData: Partial<FormData>) => {
|
||||
return await this.props.install(
|
||||
shapeFinalData({ ...this.state.data, ...newData })
|
||||
);
|
||||
};
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<Wizard currentStep={this.state.step}>
|
||||
<InitialStep onGoToNextStep={this.handleGoToNextStep} />
|
||||
<CreateYourAccountContainer
|
||||
data={this.state.data}
|
||||
onSaveData={this.handleSaveData}
|
||||
onGoToNextStep={this.handleGoToNextStep}
|
||||
onGoToPreviousStep={this.handleGoToPreviousStep}
|
||||
/>
|
||||
<AddOrganizationContainer
|
||||
data={this.state.data}
|
||||
onSaveData={this.handleSaveData}
|
||||
onGoToNextStep={this.handleGoToNextStep}
|
||||
onGoToPreviousStep={this.handleGoToPreviousStep}
|
||||
/>
|
||||
<PermittedDomainsContainer
|
||||
data={this.state.data}
|
||||
onGoToNextStep={this.handleGoToNextStep}
|
||||
onGoToPreviousStep={this.handleGoToPreviousStep}
|
||||
onInstall={this.handleInstall}
|
||||
/>
|
||||
<FinalStep />
|
||||
</Wizard>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withInstallMutation(WizardContainer);
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Talk - Install</title>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import { StatelessComponent } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import { createManaged } from "talk-framework/lib/bootstrap";
|
||||
|
||||
import App from "./components/App";
|
||||
import localesData from "./locales";
|
||||
|
||||
async function main() {
|
||||
const ManagedTalkContextProvider = await createManaged({
|
||||
localesData,
|
||||
userLocales: navigator.languages,
|
||||
});
|
||||
|
||||
const Index: StatelessComponent = () => (
|
||||
<ManagedTalkContextProvider>
|
||||
<App />
|
||||
</ManagedTalkContextProvider>
|
||||
);
|
||||
|
||||
ReactDOM.render(<Index />, document.getElementById("app"));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* The actual content of this file is being generated by our `locales-loader`.
|
||||
* Please check `./src/loaders` and the webpack config for more information.
|
||||
*
|
||||
* This file only represents the types that gets exported.
|
||||
*/
|
||||
|
||||
import { LocalesData } from "talk-framework/lib/i18n";
|
||||
export default {} as LocalesData;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { TalkContext } from "talk-framework/lib/bootstrap";
|
||||
import { createMutationContainer } from "talk-framework/lib/relay";
|
||||
import { install, InstallInput } from "talk-framework/rest";
|
||||
|
||||
export type InstallMutation = (input: InstallInput) => Promise<void>;
|
||||
|
||||
export async function commit(
|
||||
environment: Environment,
|
||||
input: InstallInput,
|
||||
{ rest }: TalkContext
|
||||
) {
|
||||
try {
|
||||
await install(rest, input);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export const withInstallMutation = createMutationContainer("install", commit);
|
||||
@@ -0,0 +1 @@
|
||||
export { withInstallMutation, InstallMutation } from "./InstallMutation";
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { Field, Form } from "react-final-form";
|
||||
|
||||
import { OnSubmit } from "talk-framework/lib/form";
|
||||
import {
|
||||
composeValidators,
|
||||
required,
|
||||
validateEmail,
|
||||
validateURL,
|
||||
} from "talk-framework/lib/validation";
|
||||
import {
|
||||
CallOut,
|
||||
Flex,
|
||||
FormField,
|
||||
HorizontalGutter,
|
||||
InputDescription,
|
||||
InputLabel,
|
||||
TextField,
|
||||
Typography,
|
||||
ValidationMessage,
|
||||
} from "talk-ui/components";
|
||||
|
||||
import BackButton from "./BackButton";
|
||||
import NextButton from "./NextButton";
|
||||
|
||||
interface FormProps {
|
||||
organizationName: string;
|
||||
organizationContactEmail: string;
|
||||
organizationURL: string;
|
||||
}
|
||||
|
||||
export interface AddOrganizationForm {
|
||||
onSubmit: OnSubmit<FormProps>;
|
||||
onGoToPreviousStep: () => void;
|
||||
data: FormProps;
|
||||
}
|
||||
|
||||
const AddOrganization: StatelessComponent<AddOrganizationForm> = props => {
|
||||
return (
|
||||
<Form
|
||||
onSubmit={props.onSubmit}
|
||||
initialValues={{
|
||||
organizationName: props.data.organizationName,
|
||||
organizationContactEmail: props.data.organizationContactEmail,
|
||||
organizationURL: props.data.organizationURL,
|
||||
}}
|
||||
>
|
||||
{({ handleSubmit, submitting, submitError }) => (
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<HorizontalGutter size="double">
|
||||
<Localized id="install-addOrganization-title">
|
||||
<Typography variant="heading1" align="center">
|
||||
Add Organization
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized id="install-addOrganization-description">
|
||||
<Typography variant="bodyCopy" align="center">
|
||||
Please tell us the name of your organization. This will appear
|
||||
in emails when inviting new team members.
|
||||
</Typography>
|
||||
</Localized>
|
||||
|
||||
{submitError && (
|
||||
<CallOut color="error" fullWidth>
|
||||
{submitError}
|
||||
</CallOut>
|
||||
)}
|
||||
|
||||
<Field
|
||||
name="organizationName"
|
||||
validate={composeValidators(required)}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="install-addOrganization-orgName">
|
||||
<InputLabel>Organization Name</InputLabel>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="install-addOrganization-orgNameTextField"
|
||||
attrs={{ placeholder: true }}
|
||||
>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
placeholder="Organization Name"
|
||||
color={
|
||||
meta.touched && (meta.error || meta.submitError)
|
||||
? "error"
|
||||
: "regular"
|
||||
}
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
/>
|
||||
</Localized>
|
||||
{meta.touched &&
|
||||
(meta.error || meta.submitError) && (
|
||||
<ValidationMessage fullWidth>
|
||||
{meta.error || meta.submitError}
|
||||
</ValidationMessage>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
name="organizationContactEmail"
|
||||
validate={composeValidators(required, validateEmail)}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="install-addOrganization-orgEmail">
|
||||
<InputLabel>Organization Contact Email</InputLabel>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="install-addOrganization-orgEmailTextField"
|
||||
attrs={{ placeholder: true }}
|
||||
>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
placeholder="Organization Contact Email"
|
||||
color={
|
||||
meta.touched && (meta.error || meta.submitError)
|
||||
? "error"
|
||||
: "regular"
|
||||
}
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
/>
|
||||
</Localized>
|
||||
{meta.touched &&
|
||||
(meta.error || meta.submitError) && (
|
||||
<ValidationMessage fullWidth>
|
||||
{meta.error || meta.submitError}
|
||||
</ValidationMessage>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
name="organizationURL"
|
||||
validate={composeValidators(required, validateURL)}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="install-addOrganization-orgURL">
|
||||
<InputLabel>Organization URL</InputLabel>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="install-addOrganization-orgURLDescription"
|
||||
strong={<strong />}
|
||||
>
|
||||
<InputDescription>
|
||||
{/* Related: https://github.com/prettier/prettier/issues/2347 */}
|
||||
Be sure to include <strong>{"http://"}</strong> or{" "}
|
||||
<strong>{"https://"}</strong> in your URL
|
||||
</InputDescription>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="install-addOrganization-orgURLTextField"
|
||||
attrs={{ placeholder: true }}
|
||||
>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
placeholder="Organization URL"
|
||||
color={
|
||||
meta.touched && (meta.error || meta.submitError)
|
||||
? "error"
|
||||
: "regular"
|
||||
}
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
/>
|
||||
</Localized>
|
||||
{meta.touched &&
|
||||
(meta.error || meta.submitError) && (
|
||||
<ValidationMessage fullWidth>
|
||||
{meta.error || meta.submitError}
|
||||
</ValidationMessage>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
<Flex direction="row-reverse" itemGutter>
|
||||
<NextButton submitting={submitting} />
|
||||
<BackButton
|
||||
submitting={submitting}
|
||||
onGoToPreviousStep={props.onGoToPreviousStep}
|
||||
/>
|
||||
</Flex>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddOrganization;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { Button } from "talk-ui/components";
|
||||
|
||||
export interface BackButtonProps {
|
||||
submitting: boolean;
|
||||
onGoToPreviousStep: () => void;
|
||||
}
|
||||
|
||||
const BackButton: StatelessComponent<BackButtonProps> = ({
|
||||
submitting,
|
||||
onGoToPreviousStep,
|
||||
}) => {
|
||||
return (
|
||||
<Localized id="install-back">
|
||||
<Button
|
||||
onClick={onGoToPreviousStep}
|
||||
variant="filled"
|
||||
color="regular"
|
||||
size="large"
|
||||
disabled={submitting}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</Localized>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackButton;
|
||||
@@ -0,0 +1,235 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { 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 {
|
||||
CallOut,
|
||||
Flex,
|
||||
FormField,
|
||||
HorizontalGutter,
|
||||
InputDescription,
|
||||
InputLabel,
|
||||
TextField,
|
||||
Typography,
|
||||
ValidationMessage,
|
||||
} from "talk-ui/components";
|
||||
|
||||
import NextButton from "./NextButton";
|
||||
|
||||
interface FormProps {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export interface CreateYourAccountForm {
|
||||
onSubmit: OnSubmit<FormProps>;
|
||||
onGoToPreviousStep: () => void;
|
||||
data: FormProps;
|
||||
}
|
||||
|
||||
const CreateYourAccount: StatelessComponent<CreateYourAccountForm> = props => {
|
||||
return (
|
||||
<Form
|
||||
onSubmit={props.onSubmit}
|
||||
initialValues={{
|
||||
email: props.data.email,
|
||||
username: props.data.username,
|
||||
}}
|
||||
>
|
||||
{({ handleSubmit, submitting, submitError }) => (
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<HorizontalGutter size="double">
|
||||
<Localized id="install-createYourAccount-title">
|
||||
<Typography variant="heading1" align="center">
|
||||
Create an Administrator Account
|
||||
</Typography>
|
||||
</Localized>
|
||||
|
||||
{submitError && (
|
||||
<CallOut color="error" fullWidth>
|
||||
{submitError}
|
||||
</CallOut>
|
||||
)}
|
||||
|
||||
<Field
|
||||
name="email"
|
||||
validate={composeValidators(required, validateEmail)}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="install-createYourAccount-email">
|
||||
<InputLabel>Email</InputLabel>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="install-createYourAccount-emailTextField"
|
||||
attrs={{ placeholder: true }}
|
||||
>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
placeholder="Email"
|
||||
color={
|
||||
meta.touched && (meta.error || meta.submitError)
|
||||
? "error"
|
||||
: "regular"
|
||||
}
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
/>
|
||||
</Localized>
|
||||
{meta.touched &&
|
||||
(meta.error || meta.submitError) && (
|
||||
<ValidationMessage fullWidth>
|
||||
{meta.error || meta.submitError}
|
||||
</ValidationMessage>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
name="username"
|
||||
validate={composeValidators(required, validateUsername)}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="install-createYourAccount-username">
|
||||
<InputLabel>Username</InputLabel>
|
||||
</Localized>
|
||||
<Localized id="install-createYourAccount-usernameDescription">
|
||||
<InputDescription>
|
||||
A unique identifier displayed on your comments. You may
|
||||
use “_” and “.”
|
||||
</InputDescription>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="install-createYourAccount-usernameTextField"
|
||||
attrs={{ placeholder: true }}
|
||||
>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
placeholder="Username"
|
||||
color={
|
||||
meta.touched && (meta.error || meta.submitError)
|
||||
? "error"
|
||||
: "regular"
|
||||
}
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
/>
|
||||
</Localized>
|
||||
{meta.touched &&
|
||||
(meta.error || meta.submitError) && (
|
||||
<ValidationMessage fullWidth>
|
||||
{meta.error || meta.submitError}
|
||||
</ValidationMessage>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
name="password"
|
||||
validate={composeValidators(required, validatePassword)}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="install-createYourAccount-password">
|
||||
<InputLabel>Password</InputLabel>
|
||||
</Localized>
|
||||
<Localized id="install-createYourAccount-passwordDescription">
|
||||
<InputDescription>
|
||||
Must be at least 8 characters
|
||||
</InputDescription>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="install-createYourAccount-passwordTextField"
|
||||
attrs={{ placeholder: true }}
|
||||
>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
color={
|
||||
meta.touched && (meta.error || meta.submitError)
|
||||
? "error"
|
||||
: "regular"
|
||||
}
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
/>
|
||||
</Localized>
|
||||
{meta.touched &&
|
||||
(meta.error || meta.submitError) && (
|
||||
<ValidationMessage fullWidth>
|
||||
{meta.error || meta.submitError}
|
||||
</ValidationMessage>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
name="confirmPassword"
|
||||
validate={composeValidators(required, validateEqualPasswords)}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="install-createYourAccount-confirmPassword">
|
||||
<InputLabel>Confirm Password</InputLabel>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="install-createYourAccount-confirmPasswordTextField"
|
||||
attrs={{ placeholder: true }}
|
||||
>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
placeholder="Confirm Password"
|
||||
type="password"
|
||||
color={
|
||||
meta.touched && (meta.error || meta.submitError)
|
||||
? "error"
|
||||
: "regular"
|
||||
}
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
/>
|
||||
</Localized>
|
||||
{meta.touched &&
|
||||
(meta.error || meta.submitError) && (
|
||||
<ValidationMessage fullWidth>
|
||||
{meta.error || meta.submitError}
|
||||
</ValidationMessage>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Flex direction="row-reverse">
|
||||
<NextButton submitting={submitting} />
|
||||
</Flex>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateYourAccount;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { urls } from "talk-framework/helpers";
|
||||
import { Button, Flex, Typography } from "talk-ui/components";
|
||||
|
||||
class FinalStep extends Component {
|
||||
public render() {
|
||||
return (
|
||||
<Flex direction="column" justifyContent="center" itemGutter="double">
|
||||
<Localized id="install-finalStep-description">
|
||||
<Typography variant="bodyCopy">
|
||||
Thanks for installing Talk! We sent an email to verify your email
|
||||
address. While you finish setting up the account, you can start
|
||||
engaging with your readers now.
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Flex direction="row" itemGutter justifyContent="center">
|
||||
<Localized id="install-finalStep-goToTheDocs">
|
||||
<Button
|
||||
anchor
|
||||
color="regular"
|
||||
variant="filled"
|
||||
href="https://docs.coralproject.net"
|
||||
target="_blank"
|
||||
>
|
||||
Go to the Docs
|
||||
</Button>
|
||||
</Localized>
|
||||
<Localized id="install-finalStep-goToAdmin">
|
||||
<Button anchor color="primary" variant="filled" href={urls.admin}>
|
||||
Go to Admin
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default FinalStep;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { Button, Flex, Typography } from "talk-ui/components";
|
||||
|
||||
interface InitialStepProps {
|
||||
onGoToNextStep: () => void;
|
||||
}
|
||||
|
||||
class InitialStep extends Component<InitialStepProps> {
|
||||
public render() {
|
||||
return (
|
||||
<Flex direction="column" justifyContent="center" itemGutter="double">
|
||||
<Localized id="install-initialStep-copy">
|
||||
<Typography variant="bodyCopy">
|
||||
The remainder of the Talk installation will take about ten minutes.
|
||||
Once you complete the following three steps, you will have a free
|
||||
installation and provision Mongo and Redis.
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Flex justifyContent="center">
|
||||
<Localized id="install-initialStep-getStarted">
|
||||
<Button
|
||||
onClick={this.props.onGoToNextStep}
|
||||
color="primary"
|
||||
variant="filled"
|
||||
fullWidth={false}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default InitialStep;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { Button, ButtonIcon } from "talk-ui/components";
|
||||
|
||||
export interface NextButtonProps {
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
const NextButton: StatelessComponent<NextButtonProps> = props => {
|
||||
return (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="primary"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={props.submitting}
|
||||
>
|
||||
<Localized id="install-next">
|
||||
<span>Next</span>
|
||||
</Localized>
|
||||
<ButtonIcon>arrow_forward</ButtonIcon>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextButton;
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { Field, Form } from "react-final-form";
|
||||
import { OnSubmit } from "talk-framework/lib/form";
|
||||
|
||||
import {
|
||||
Button,
|
||||
CallOut,
|
||||
Flex,
|
||||
FormField,
|
||||
HorizontalGutter,
|
||||
InputDescription,
|
||||
InputLabel,
|
||||
TextField,
|
||||
Typography,
|
||||
ValidationMessage,
|
||||
} from "talk-ui/components";
|
||||
|
||||
import BackButton from "./BackButton";
|
||||
|
||||
interface FormProps {
|
||||
domains: string;
|
||||
}
|
||||
|
||||
export interface PermittedDomainsForm {
|
||||
onSubmit: OnSubmit<FormProps>;
|
||||
onGoToPreviousStep: () => void;
|
||||
data: {
|
||||
domains: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const PermittedDomains: StatelessComponent<PermittedDomainsForm> = props => {
|
||||
return (
|
||||
<Form
|
||||
onSubmit={props.onSubmit}
|
||||
initialValues={{
|
||||
domains: props.data.domains.join(","),
|
||||
}}
|
||||
>
|
||||
{({ handleSubmit, submitting, submitError }) => (
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<HorizontalGutter size="double">
|
||||
<Localized id="install-permittedDomains-title">
|
||||
<Typography variant="heading1" align="center">
|
||||
Permitted Domains
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized id="install-permittedDomains-description">
|
||||
<Typography variant="bodyCopy" align="center">
|
||||
Enter the domains you would like to permit for Talk, e.g. your
|
||||
local, staging and production environments (ex. localhost:3000,
|
||||
staging.domain.com, domain.com).
|
||||
</Typography>
|
||||
</Localized>
|
||||
|
||||
{submitError && (
|
||||
<CallOut color="error" fullWidth>
|
||||
{submitError}
|
||||
</CallOut>
|
||||
)}
|
||||
|
||||
<Field name="domains">
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="install-permittedDomains-permittedDomains">
|
||||
<InputLabel>Permitted Domains</InputLabel>
|
||||
</Localized>
|
||||
<Localized id="install-permittedDomains-permittedDomainsDescription">
|
||||
<InputDescription>
|
||||
Insert domains separated by comma
|
||||
</InputDescription>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="install-permittedDomains-permittedDomainsTextField"
|
||||
attrs={{ placeholder: true }}
|
||||
>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
placeholder="Domains"
|
||||
color={
|
||||
meta.touched && (meta.error || meta.submitError)
|
||||
? "error"
|
||||
: "regular"
|
||||
}
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
/>
|
||||
</Localized>
|
||||
{meta.touched &&
|
||||
(meta.error || meta.submitError) && (
|
||||
<ValidationMessage fullWidth>
|
||||
{meta.error || meta.submitError}
|
||||
</ValidationMessage>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Flex direction="row-reverse" itemGutter>
|
||||
<Localized id="install-finishInstall">
|
||||
<Button
|
||||
variant="filled"
|
||||
color="primary"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
Finish Install
|
||||
</Button>
|
||||
</Localized>
|
||||
<BackButton
|
||||
submitting={submitting}
|
||||
onGoToPreviousStep={props.onGoToPreviousStep}
|
||||
/>
|
||||
</Flex>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermittedDomains;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { FORM_ERROR } from "final-form";
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-ui/types";
|
||||
|
||||
import AddOrganization, {
|
||||
AddOrganizationForm,
|
||||
} from "../components/AddOrganization";
|
||||
|
||||
interface AddOrganizationContainerProps {
|
||||
onGoToNextStep: () => void;
|
||||
onGoToPreviousStep: () => void;
|
||||
data: PropTypesOf<typeof AddOrganization>["data"];
|
||||
onSaveData: (newData: PropTypesOf<typeof AddOrganization>["data"]) => void;
|
||||
}
|
||||
|
||||
class AddOrganizationContainer extends Component<
|
||||
AddOrganizationContainerProps
|
||||
> {
|
||||
private onSubmit: AddOrganizationForm["onSubmit"] = async (input, form) => {
|
||||
try {
|
||||
this.props.onSaveData(input);
|
||||
return this.props.onGoToNextStep();
|
||||
} catch (error) {
|
||||
return { [FORM_ERROR]: error.message };
|
||||
}
|
||||
};
|
||||
public render() {
|
||||
return (
|
||||
<AddOrganization
|
||||
data={this.props.data}
|
||||
onSubmit={this.onSubmit}
|
||||
onGoToPreviousStep={this.props.onGoToPreviousStep}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AddOrganizationContainer;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { FORM_ERROR } from "final-form";
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-ui/types";
|
||||
|
||||
import CreateYourAccount, {
|
||||
CreateYourAccountForm,
|
||||
} from "../components/CreateYourAccount";
|
||||
|
||||
interface CreateYourAccountContainerProps {
|
||||
onGoToNextStep: () => void;
|
||||
onGoToPreviousStep: () => void;
|
||||
data: PropTypesOf<typeof CreateYourAccount>["data"];
|
||||
onSaveData: (newData: PropTypesOf<typeof CreateYourAccount>["data"]) => void;
|
||||
}
|
||||
|
||||
class CreateYourAccountContainer extends Component<
|
||||
CreateYourAccountContainerProps
|
||||
> {
|
||||
private onSubmit: CreateYourAccountForm["onSubmit"] = async (input, form) => {
|
||||
try {
|
||||
this.props.onSaveData(input);
|
||||
return this.props.onGoToNextStep();
|
||||
} catch (error) {
|
||||
return { [FORM_ERROR]: error.message };
|
||||
}
|
||||
};
|
||||
public render() {
|
||||
return (
|
||||
<CreateYourAccount
|
||||
data={this.props.data}
|
||||
onSubmit={this.onSubmit}
|
||||
onGoToPreviousStep={this.props.onGoToPreviousStep}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CreateYourAccountContainer;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { FORM_ERROR } from "final-form";
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-ui/types";
|
||||
|
||||
import PermittedDomains, {
|
||||
PermittedDomainsForm,
|
||||
} from "../components/PermittedDomains";
|
||||
|
||||
interface PermittedDomainsContainerProps {
|
||||
onGoToNextStep: () => void;
|
||||
onGoToPreviousStep: () => void;
|
||||
data: PropTypesOf<typeof PermittedDomains>["data"];
|
||||
onInstall: (
|
||||
newData: PropTypesOf<typeof PermittedDomains>["data"]
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
class PermittedDomainsContainer extends Component<
|
||||
PermittedDomainsContainerProps
|
||||
> {
|
||||
private onSubmit: PermittedDomainsForm["onSubmit"] = async (input, form) => {
|
||||
try {
|
||||
const domains = input.domains.split(",");
|
||||
await this.props.onInstall({ domains });
|
||||
return this.props.onGoToNextStep();
|
||||
} catch (error) {
|
||||
return { [FORM_ERROR]: error.message };
|
||||
}
|
||||
};
|
||||
public render() {
|
||||
return (
|
||||
<PermittedDomains
|
||||
data={this.props.data}
|
||||
onSubmit={this.onSubmit}
|
||||
onGoToPreviousStep={this.props.onGoToPreviousStep}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PermittedDomainsContainer;
|
||||
@@ -2,7 +2,7 @@
|
||||
:global {
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 2px 8px;
|
||||
padding: 0;
|
||||
|
||||
/* Support for all WebKit browsers. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "talk-stream/mutations";
|
||||
import { Popup } from "talk-ui/components";
|
||||
|
||||
import { urls } from "talk-framework/helpers";
|
||||
import UserBoxAuthenticated from "../components/UserBoxAuthenticated";
|
||||
|
||||
interface InnerProps {
|
||||
@@ -57,7 +58,7 @@ export class UserBoxContainer extends Component<InnerProps> {
|
||||
return (
|
||||
<>
|
||||
<Popup
|
||||
href={`/auth.html?view=${view}`}
|
||||
href={`${urls.embed.auth}?view=${view}`}
|
||||
title="Talk Auth"
|
||||
features="menubar=0,resizable=0,width=350,height=395,top=200,left=500"
|
||||
open={open}
|
||||
|
||||
@@ -5,7 +5,7 @@ exports[`renders correctly 1`] = `
|
||||
<Popup
|
||||
features="menubar=0,resizable=0,width=350,height=395,top=200,left=500"
|
||||
focus={false}
|
||||
href="/auth.html?view=SIGN_IN"
|
||||
href="/embed/auth?view=SIGN_IN"
|
||||
onBlur={[Function]}
|
||||
onClose={[Function]}
|
||||
onFocus={[Function]}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.root {
|
||||
composes: subTabCounter from "talk-ui/shared/typography.css";
|
||||
|
||||
background-color: var(--palette-grey-dark);
|
||||
color: var(--palette-text-light);
|
||||
border-radius: 1px;
|
||||
padding: 2px 3px 3px 3px;
|
||||
}
|
||||
@@ -99,7 +99,7 @@
|
||||
}
|
||||
|
||||
.variantFilled {
|
||||
color: var(--palette-common-white);
|
||||
color: var(--palette-text-light);
|
||||
&.colorRegular {
|
||||
background-color: var(--palette-grey-main);
|
||||
}
|
||||
@@ -243,7 +243,7 @@
|
||||
&:active,
|
||||
&.active {
|
||||
border: 1px solid currentColor;
|
||||
color: var(--palette-common-white);
|
||||
color: var(--palette-text-light);
|
||||
background-color: var(--palette-grey-main);
|
||||
}
|
||||
}
|
||||
@@ -254,7 +254,7 @@
|
||||
&:active,
|
||||
&.active {
|
||||
border: 1px solid currentColor;
|
||||
color: var(--palette-common-white);
|
||||
color: var(--palette-text-light);
|
||||
background-color: var(--palette-primary-main);
|
||||
}
|
||||
}
|
||||
@@ -265,7 +265,7 @@
|
||||
&:active,
|
||||
&.active {
|
||||
border: 1px solid currentColor;
|
||||
color: var(--palette-common-white);
|
||||
color: var(--palette-text-light);
|
||||
background-color: var(--palette-error-main);
|
||||
}
|
||||
}
|
||||
@@ -276,7 +276,7 @@
|
||||
&:active,
|
||||
&.active {
|
||||
border: 1px solid currentColor;
|
||||
color: var(--palette-common-white);
|
||||
color: var(--palette-text-light);
|
||||
background-color: var(--palette-success-main);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
.colorError {
|
||||
background-color: var(--palette-error-light);
|
||||
border-color: var(--palette-error-darkest);
|
||||
color: var(--palette-common-white);
|
||||
color: var(--palette-text-light);
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
.root {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: var(--palette-common-white);
|
||||
border: 2px solid var(--palette-grey-light);
|
||||
box-sizing: border-box;
|
||||
border-radius: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.active,
|
||||
.completed {
|
||||
background-color: var(--palette-success-main);
|
||||
border-color: var(--palette-success-main);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--palette-text-light);
|
||||
font-weight: bold;
|
||||
margin-top: -4px;
|
||||
margin-left: 1px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import cn from "classnames";
|
||||
import React, { StatelessComponent } from "react";
|
||||
import Icon from "../Icon";
|
||||
import * as styles from "./Circle.css";
|
||||
|
||||
interface CircleProps {
|
||||
active?: boolean;
|
||||
completed?: boolean;
|
||||
}
|
||||
|
||||
const Circle: StatelessComponent<CircleProps> = ({ active, completed }) => {
|
||||
const rootClassName = cn(styles.root, {
|
||||
[styles.active]: active,
|
||||
[styles.completed]: completed,
|
||||
});
|
||||
return (
|
||||
<span className={rootClassName}>
|
||||
{completed && <Icon className={styles.icon}>done</Icon>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default Circle;
|
||||
@@ -0,0 +1,12 @@
|
||||
.root {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
height: 0px;
|
||||
border: 1px solid var(--palette-grey-light);
|
||||
box-sizing: border-box;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.completed {
|
||||
border-color: var(--palette-success-main);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import cn from "classnames";
|
||||
import React, { StatelessComponent } from "react";
|
||||
import * as styles from "./Line.css";
|
||||
|
||||
interface CircleProps {
|
||||
active?: boolean;
|
||||
completed?: boolean;
|
||||
}
|
||||
|
||||
const Line: StatelessComponent<CircleProps> = ({ active, completed }) => {
|
||||
const rootClassName = cn(styles.root, {
|
||||
[styles.completed]: completed,
|
||||
});
|
||||
return <span className={rootClassName} />;
|
||||
};
|
||||
|
||||
export default Line;
|
||||
@@ -0,0 +1,11 @@
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.text {
|
||||
position: absolute;
|
||||
top: 26px;
|
||||
transform: translateX(-50%);
|
||||
left: 8%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { Component, ReactNode, ReactText } from "react";
|
||||
import Flex from "../Flex";
|
||||
import Typography from "../Typography";
|
||||
import Circle from "./Circle";
|
||||
import Line from "./Line";
|
||||
import * as styles from "./Step.css";
|
||||
|
||||
interface StepProps {
|
||||
children: ReactText | ReactNode;
|
||||
active?: boolean;
|
||||
completed?: boolean;
|
||||
last?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
class Step extends Component<StepProps> {
|
||||
public render() {
|
||||
const { children, completed, active, last, hidden } = this.props;
|
||||
if (hidden) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Flex
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
itemGutter
|
||||
>
|
||||
<Circle completed={completed} active={active} />
|
||||
{!last && <Line completed={completed} />}
|
||||
</Flex>
|
||||
<Typography className={styles.text}>{children}</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Step;
|
||||
@@ -0,0 +1,6 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: calc(2 * var(--spacing-unit)) 0;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import cn from "classnames";
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import Flex from "../Flex";
|
||||
import * as styles from "./StepBar.css";
|
||||
|
||||
interface StepBarProps {
|
||||
children: ReactNode;
|
||||
currentStep: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
class StepBar extends Component<StepBarProps> {
|
||||
public render() {
|
||||
const { children, currentStep } = this.props;
|
||||
const steps = React.Children.toArray(children);
|
||||
const stepsToRender = steps
|
||||
.filter((child: React.ReactElement<any>) => !child.props.hidden)
|
||||
.map((child: React.ReactElement<any>, index: number, arr) =>
|
||||
React.cloneElement(child, {
|
||||
last: arr.length - 1 === index,
|
||||
active: currentStep === index,
|
||||
completed: currentStep > index,
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn(styles.root, this.props.className)}>
|
||||
<Flex
|
||||
direction="row"
|
||||
itemGutter
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{stepsToRender}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default StepBar;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as StepBar } from "./StepBar";
|
||||
export { default as Step } from "./Step";
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Steps
|
||||
menu: UI Kit
|
||||
---
|
||||
|
||||
# Steps
|
||||
|
||||
### Examples
|
||||
|
||||
import { Flex, Step, StepBar } from './index'
|
||||
|
||||
<Playground>
|
||||
<StepBar currentStep={1}>
|
||||
<Step>Create Admin Account</Step>
|
||||
<Step>Add Organization Details</Step>
|
||||
<Step>Add Permitted Domains</Step>
|
||||
</StepBar>
|
||||
</Playground>
|
||||
@@ -22,3 +22,4 @@ export { default as Icon } from "./Icon";
|
||||
export { default as AriaInfo } from "./AriaInfo";
|
||||
export { default as Message, MessageIcon } from "./Message";
|
||||
export { Tab, TabBar, TabContent, TabPane } from "./Tabs";
|
||||
export { StepBar, Step } from "./Steps";
|
||||
|
||||
@@ -48,6 +48,11 @@ const variables = {
|
||||
text: {
|
||||
primary: "#3B4A53",
|
||||
secondary: "#787D80",
|
||||
light: "#fff",
|
||||
},
|
||||
/* Background colors */
|
||||
background: {
|
||||
light: "#f5f6fa",
|
||||
},
|
||||
/* Common colors */
|
||||
common: {
|
||||
|
||||
@@ -20,10 +20,13 @@ export interface SignupBody {
|
||||
email: string;
|
||||
}
|
||||
|
||||
const SignupBodySchema = Joi.object().keys({
|
||||
export const SignupBodySchema = Joi.object().keys({
|
||||
username: Joi.string().trim(),
|
||||
password: Joi.string().trim(),
|
||||
email: Joi.string().trim(),
|
||||
password: Joi.string(),
|
||||
email: Joi.string()
|
||||
.trim()
|
||||
.lowercase()
|
||||
.email(),
|
||||
});
|
||||
|
||||
export interface SignupOptions {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { RequestHandler } from "express";
|
||||
import { Redis } from "ioredis";
|
||||
import Joi from "joi";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { Omit } from "talk-common/types";
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { LocalProfile } from "talk-server/models/user";
|
||||
import { install, InstallTenant } from "talk-server/services/tenant";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
import { upsert, UpsertUser } from "talk-server/services/users";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
export interface TenantInstallBody {
|
||||
tenant: Omit<InstallTenant, "domain">;
|
||||
user: Required<Pick<UpsertUser, "username" | "email" | "password">>;
|
||||
}
|
||||
|
||||
const TenantInstallBodySchema = Joi.object().keys({
|
||||
tenant: Joi.object().keys({
|
||||
organizationName: Joi.string().trim(),
|
||||
organizationURL: Joi.string()
|
||||
.trim()
|
||||
.uri(),
|
||||
organizationContactEmail: Joi.string()
|
||||
.trim()
|
||||
.lowercase()
|
||||
.email(),
|
||||
domains: Joi.array().items(
|
||||
Joi.string()
|
||||
.trim()
|
||||
.uri()
|
||||
),
|
||||
}),
|
||||
user: Joi.object().keys({
|
||||
username: Joi.string().trim(),
|
||||
password: Joi.string(),
|
||||
email: Joi.string()
|
||||
.trim()
|
||||
.lowercase()
|
||||
.email(),
|
||||
}),
|
||||
});
|
||||
|
||||
export interface TenantInstallHandlerOptions {
|
||||
cache: TenantCache;
|
||||
redis: Redis;
|
||||
mongo: Db;
|
||||
}
|
||||
|
||||
export const tenantInstallHandler = ({
|
||||
mongo,
|
||||
redis,
|
||||
cache,
|
||||
}: TenantInstallHandlerOptions): RequestHandler => async (
|
||||
req: Request,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
try {
|
||||
// Validate that the payload passed in was correct, it will throw if the
|
||||
// payload is invalid.
|
||||
const {
|
||||
tenant: tenantInput,
|
||||
user: userInput,
|
||||
}: TenantInstallBody = validate(TenantInstallBodySchema, req.body);
|
||||
|
||||
// Install will throw if it can not create a Tenant, or it has already been
|
||||
// installed.
|
||||
const tenant = await install(mongo, redis, cache, {
|
||||
...tenantInput,
|
||||
// Infer the Tenant domain via the hostname parameter.
|
||||
domain: req.hostname,
|
||||
});
|
||||
|
||||
// Pull the user details out of the input for the user.
|
||||
const { email, username, password } = userInput;
|
||||
|
||||
// Configure with profile.
|
||||
const profile: LocalProfile = {
|
||||
id: email,
|
||||
type: "local",
|
||||
};
|
||||
|
||||
// Create the first admin user.
|
||||
await upsert(mongo, tenant, {
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
profiles: [profile],
|
||||
role: GQLUSER_ROLE.ADMIN,
|
||||
});
|
||||
|
||||
// Send back the Tenant.
|
||||
return res.sendStatus(204);
|
||||
} catch (err) {
|
||||
// TODO: (wyattjoh) maybe wrap the error?
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { RequestHandler } from "express";
|
||||
|
||||
import { isInstalled } from "talk-server/services/tenant";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
|
||||
export interface InstalledMiddlewareOptions {
|
||||
tenantCache: TenantCache;
|
||||
redirectURL?: string;
|
||||
redirectIfInstalled?: boolean;
|
||||
}
|
||||
|
||||
export const installedMiddleware = ({
|
||||
tenantCache,
|
||||
redirectIfInstalled = false,
|
||||
redirectURL = "/install",
|
||||
}: InstalledMiddlewareOptions): RequestHandler => async (req, res, next) => {
|
||||
const installed = await isInstalled(tenantCache);
|
||||
|
||||
// If Talk is installed, and redirectIfInstall is true, then it will redirect.
|
||||
// If Talk is not installed, and redirectIfInstall is false, then it will also
|
||||
// redirect.
|
||||
if (installed === redirectIfInstalled) {
|
||||
return res.redirect(redirectURL);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NextFunction, Response } from "express";
|
||||
import { RequestHandler } from "express";
|
||||
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
import { Request } from "talk-server/types/express";
|
||||
@@ -7,10 +7,10 @@ export interface MiddlewareOptions {
|
||||
cache: TenantCache;
|
||||
}
|
||||
|
||||
export default (options: MiddlewareOptions) => async (
|
||||
export default (options: MiddlewareOptions): RequestHandler => async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
try {
|
||||
const { cache } = options;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from "express";
|
||||
|
||||
import { AppOptions } from "talk-server/app";
|
||||
import { tenantInstallHandler } from "talk-server/app/handlers/install/tenant";
|
||||
import tenantMiddleware from "talk-server/app/middleware/tenant";
|
||||
import { RouterOptions } from "talk-server/app/router/types";
|
||||
import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
|
||||
@@ -13,6 +14,17 @@ export async function createTenantRouter(
|
||||
) {
|
||||
const router = express.Router();
|
||||
|
||||
// Tenant setup handler.
|
||||
router.use(
|
||||
"/install",
|
||||
express.json(),
|
||||
tenantInstallHandler({
|
||||
cache: app.tenantCache,
|
||||
redis: app.redis,
|
||||
mongo: app.mongo,
|
||||
})
|
||||
);
|
||||
|
||||
// Tenant identification middleware.
|
||||
router.use(tenantMiddleware({ cache: app.tenantCache }));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import express, { Router } from "express";
|
||||
|
||||
import { AppOptions } from "talk-server/app";
|
||||
import { nocacheMiddleware } from "talk-server/app/middleware/cacheHeaders";
|
||||
import { installedMiddleware } from "talk-server/app/middleware/installed";
|
||||
import playground from "talk-server/app/middleware/playground";
|
||||
import { RouterOptions } from "talk-server/app/router/types";
|
||||
import logger from "talk-server/logger";
|
||||
@@ -20,9 +21,36 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
|
||||
attachGraphiQL(router, app);
|
||||
}
|
||||
|
||||
// Add the client targets.
|
||||
router.get("/embed/stream", createClientTargetRouter({ view: "stream" }));
|
||||
router.get("/admin", createClientTargetRouter({ view: "admin" }));
|
||||
// Add the embed targets.
|
||||
router.use("/embed/stream", createClientTargetRouter({ view: "stream" }));
|
||||
router.use("/embed/auth", createClientTargetRouter({ view: "auth" }));
|
||||
|
||||
// Add the standalone targets.
|
||||
router.use(
|
||||
"/admin",
|
||||
installedMiddleware({
|
||||
tenantCache: app.tenantCache,
|
||||
}),
|
||||
createClientTargetRouter({ view: "admin" })
|
||||
);
|
||||
router.use(
|
||||
"/install",
|
||||
installedMiddleware({
|
||||
tenantCache: app.tenantCache,
|
||||
redirectIfInstalled: true,
|
||||
redirectURL: "/admin",
|
||||
}),
|
||||
createClientTargetRouter({ view: "install", cacheDuration: "" })
|
||||
);
|
||||
|
||||
// Handle the root path.
|
||||
router.get(
|
||||
"/",
|
||||
installedMiddleware({ tenantCache: app.tenantCache }),
|
||||
(req, res, next) => {
|
||||
res.redirect("/admin");
|
||||
}
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import bunyan, { LogLevelString } from "bunyan";
|
||||
import bunyan, { LogLevelString, stdSerializers as serializers } from "bunyan";
|
||||
import PrettyStream from "bunyan-prettystream";
|
||||
|
||||
import config from "talk-common/config";
|
||||
|
||||
function getStreams() {
|
||||
// If we aren't in production mode, use the pretty stream printer.
|
||||
if (config.get("env") !== "production") {
|
||||
const pretty = new PrettyStream();
|
||||
pretty.pipe(process.stdout);
|
||||
|
||||
return [{ type: "raw", stream: pretty }];
|
||||
}
|
||||
|
||||
// In production, emit JSON.
|
||||
return [{ stream: process.stdout }];
|
||||
}
|
||||
|
||||
const logger = bunyan.createLogger({
|
||||
name: "talk",
|
||||
serializers: bunyan.stdSerializers,
|
||||
// TODO: (wyattjoh) move this into some managed instance?
|
||||
serializers,
|
||||
streams: getStreams(),
|
||||
level: config.get("logging_level") as LogLevelString,
|
||||
});
|
||||
|
||||
|
||||
@@ -178,6 +178,12 @@ export async function retrieveAllTenants(db: Db) {
|
||||
.toArray();
|
||||
}
|
||||
|
||||
export async function countTenants(mongo: Db) {
|
||||
return collection(mongo)
|
||||
.find({})
|
||||
.count();
|
||||
}
|
||||
|
||||
export type UpdateTenantInput = Omit<DeepPartial<Tenant>, "id" | "domain">;
|
||||
|
||||
export async function updateTenant(
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Db, MongoClient } from "mongodb";
|
||||
import { Config } from "talk-common/config";
|
||||
|
||||
export async function createMongoClient(config: Config): Promise<MongoClient> {
|
||||
return MongoClient.connect(
|
||||
config.get("mongodb"),
|
||||
{ useNewUrlParser: true }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* create will connect to the MongoDB instance identified in the configuration.
|
||||
*
|
||||
@@ -8,10 +15,7 @@ import { Config } from "talk-common/config";
|
||||
*/
|
||||
export async function createMongoDB(config: Config): Promise<Db> {
|
||||
// Connect and create a client for MongoDB.
|
||||
const client = await MongoClient.connect(
|
||||
config.get("mongodb"),
|
||||
{ useNewUrlParser: true }
|
||||
);
|
||||
const client = await createMongoClient(config);
|
||||
|
||||
// Return the database handle, which defaults to the database name provided
|
||||
// in the config connection string.
|
||||
|
||||
+42
@@ -7,6 +7,7 @@ import { EventEmitter } from "events";
|
||||
import { Config } from "talk-common/config";
|
||||
import logger from "talk-server/logger";
|
||||
import {
|
||||
countTenants,
|
||||
retrieveAllTenants,
|
||||
retrieveManyTenants,
|
||||
retrieveManyTenantsByDomain,
|
||||
@@ -38,6 +39,17 @@ export default class TenantCache {
|
||||
*/
|
||||
private tenantsByDomain: DataLoader<string, Readonly<Tenant> | null>;
|
||||
|
||||
/**
|
||||
* tenantCountCache stores all the id's of all the Tenant's that have crossed
|
||||
* it.
|
||||
*/
|
||||
private tenantCountCache = new Set<string>();
|
||||
|
||||
/**
|
||||
* primed is true when the cache has already been fully primed.
|
||||
*/
|
||||
private primed: boolean = false;
|
||||
|
||||
/**
|
||||
* Create a new client application ID. This prevents duplicated messages
|
||||
* generated by this application from being handled as external messages
|
||||
@@ -73,6 +85,11 @@ export default class TenantCache {
|
||||
{ tenants: tenants.filter(t => t !== null).length },
|
||||
"loaded tenants"
|
||||
);
|
||||
|
||||
tenants
|
||||
.filter(t => t !== null)
|
||||
.forEach((t: Readonly<Tenant>) => this.tenantCountCache.add(t.id));
|
||||
|
||||
return tenants;
|
||||
},
|
||||
{
|
||||
@@ -88,6 +105,11 @@ export default class TenantCache {
|
||||
{ tenants: tenants.filter(t => t !== null).length },
|
||||
"loaded tenants"
|
||||
);
|
||||
|
||||
tenants
|
||||
.filter(t => t !== null)
|
||||
.forEach((t: Readonly<Tenant>) => this.tenantCountCache.add(t.id));
|
||||
|
||||
return tenants;
|
||||
},
|
||||
{
|
||||
@@ -106,6 +128,21 @@ export default class TenantCache {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* count will return the number of Tenant's.
|
||||
*/
|
||||
public async count(): Promise<number> {
|
||||
if (!this.cachingEnabled) {
|
||||
return countTenants(this.mongo);
|
||||
}
|
||||
|
||||
if (!this.primed) {
|
||||
await this.primeAll();
|
||||
}
|
||||
|
||||
return this.tenantCountCache.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* primeAll will load all the tenants into the cache on startup.
|
||||
*/
|
||||
@@ -121,14 +158,17 @@ export default class TenantCache {
|
||||
// Clear out all the items in the cache.
|
||||
this.tenantsByID.clearAll();
|
||||
this.tenantsByDomain.clearAll();
|
||||
this.tenantCountCache.clear();
|
||||
|
||||
// Prime the cache with each of these tenants.
|
||||
tenants.forEach(tenant => {
|
||||
this.tenantsByID.prime(tenant.id, tenant);
|
||||
this.tenantsByDomain.prime(tenant.domain, tenant);
|
||||
this.tenantCountCache.add(tenant.id);
|
||||
});
|
||||
|
||||
logger.debug({ tenants: tenants.length }, "primed all tenants");
|
||||
this.primed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,6 +200,7 @@ export default class TenantCache {
|
||||
// Update the tenant cache.
|
||||
this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant);
|
||||
this.tenantsByDomain.clear(tenant.domain).prime(tenant.domain, tenant);
|
||||
this.tenantCountCache.add(tenant.id);
|
||||
|
||||
// Publish the event for the connected listeners.
|
||||
this.emitter.emit(EMITTER_EVENT_NAME, tenant);
|
||||
@@ -207,6 +248,7 @@ export default class TenantCache {
|
||||
// Update the tenant in the local cache.
|
||||
this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant);
|
||||
this.tenantsByDomain.clear(tenant.domain).prime(tenant.domain, tenant);
|
||||
this.tenantCountCache.add(tenant.id);
|
||||
|
||||
// Notify the other nodes about the tenant change.
|
||||
const message: TenantUpdateMessage = {
|
||||
|
||||
@@ -3,29 +3,76 @@ import { Db } from "mongodb";
|
||||
|
||||
import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import {
|
||||
createTenant,
|
||||
CreateTenantInput,
|
||||
Tenant,
|
||||
updateTenant,
|
||||
// UpdateTenantInput,
|
||||
} from "talk-server/models/tenant";
|
||||
|
||||
import logger from "talk-server/logger";
|
||||
import TenantCache from "./cache";
|
||||
|
||||
export type UpdateTenant = GQLSettingsInput;
|
||||
|
||||
export async function update(
|
||||
db: Db,
|
||||
conn: Redis,
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
input: UpdateTenant
|
||||
): Promise<Tenant | null> {
|
||||
const updatedTenant = await updateTenant(db, tenant.id, input);
|
||||
const updatedTenant = await updateTenant(mongo, tenant.id, input);
|
||||
if (!updatedTenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(conn, updatedTenant);
|
||||
await cache.update(redis, updatedTenant);
|
||||
|
||||
return updatedTenant;
|
||||
}
|
||||
|
||||
export type InstallTenant = CreateTenantInput;
|
||||
|
||||
export async function install(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
input: InstallTenant
|
||||
) {
|
||||
if (await isInstalled(cache)) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error(
|
||||
"tenant already setup, setup multi-tenant mode if you want to install more than one tenant"
|
||||
);
|
||||
}
|
||||
|
||||
// Create the Tenant.
|
||||
const tenant = await createTenant(mongo, input);
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, tenant);
|
||||
|
||||
logger.info(
|
||||
{ tenant_id: tenant.id, tenant_domain: tenant.domain },
|
||||
"a tenant has been installed"
|
||||
);
|
||||
|
||||
return tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* canInstall will return a promise that determines if a given install can
|
||||
* proceed.
|
||||
*/
|
||||
export async function canInstall(cache: TenantCache) {
|
||||
return (await cache.count()) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* isInstalled will return a promise that if true, indicates that a Tenant has
|
||||
* been installed.
|
||||
*/
|
||||
export async function isInstalled(cache: TenantCache) {
|
||||
return (await cache.count()) > 0;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ framework-validation-invalidCharacters = Invalid characters. Try again.
|
||||
framework-validation-invalidEmail = Please enter a valid email address.
|
||||
framework-validation-passwordTooShort = Password must contain at least {$minLength} characters.
|
||||
framework-validation-passwordsDoNotMatch = Passwords do not match. Try again.
|
||||
framework-validation-invalidURL = Invalid URL
|
||||
|
||||
framework-timeago-just-now = Just now
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
## Install
|
||||
|
||||
install-back = Back
|
||||
install-next = Next
|
||||
install-finishInstall = Finish Install
|
||||
install-title = Talk Installation Wizard
|
||||
|
||||
install-initialStep-copy = The remainder of the Talk installation will take about ten minutes. Once you complete the following three steps, you will have a free installation and provision Mongo and Redis.
|
||||
install-initialStep-getStarted = Get Started
|
||||
|
||||
install-addOrganization-stepTitle = Add Organization Details
|
||||
install-addOrganization-title = Add Organization
|
||||
install-addOrganization-description = Please tell us the name of your organization. This will appear in emails when inviting new team members.
|
||||
install-addOrganization-orgName = Organization Name
|
||||
install-addOrganization-orgNameTextField =
|
||||
.placeholder = Organization Name
|
||||
install-addOrganization-orgEmail = Organization Contact Email
|
||||
install-addOrganization-orgEmailTextField =
|
||||
.placeholder = Organization Contact Email
|
||||
install-addOrganization-orgURL = Organization URL
|
||||
install-addOrganization-orgURLTextField =
|
||||
.placeholder = Organization URL
|
||||
install-addOrganization-orgURLDescription = Be sure to include <strong>http://</strong> or <strong>https://</strong> in your URL
|
||||
|
||||
install-createYourAccount-stepTitle = Create Admin Account
|
||||
install-createYourAccount-title = Create an Administrator Account
|
||||
install-createYourAccount-email = Email
|
||||
install-createYourAccount-emailTextField =
|
||||
.placeholder = Email
|
||||
install-createYourAccount-username = Username
|
||||
install-createYourAccount-usernameTextField =
|
||||
.placeholder = Username
|
||||
install-createYourAccount-usernameDescription = A unique identifier displayed on your comments. You may use “_” and “.”
|
||||
install-createYourAccount-password = Password
|
||||
install-createYourAccount-passwordTextField =
|
||||
.placeholder = Password
|
||||
install-createYourAccount-passwordDescription = Must be at least 8 characters
|
||||
install-createYourAccount-confirmPassword = Confirm Password
|
||||
install-createYourAccount-confirmPasswordTextField =
|
||||
.placeholder = Confirm Password
|
||||
|
||||
install-permittedDomains-stepTitle = Add Permitted Domains
|
||||
install-permittedDomains-title = Permitted Domains
|
||||
install-permittedDomains-description = Enter the domains you would like to permit for Talk, e.g. your local, staging and production environments (ex. localhost:3000, staging.domain.com, domain.com).
|
||||
install-permittedDomains-permittedDomains = Permitted Domains
|
||||
install-permittedDomains-permittedDomainsTextField =
|
||||
.placeholder = Domains
|
||||
install-permittedDomains-permittedDomainsDescription = Insert domains separated by comma
|
||||
|
||||
install-finalStep-description = Thanks for installing Talk! We sent an email to verify your email address. While you finish setting up the account, you can start engaging with your readers now.
|
||||
install-finalStep-goToTheDocs = Go to the Docs
|
||||
install-finalStep-goToAdmin = Go to Admin
|
||||
Reference in New Issue
Block a user