diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 0000000000..712b62a821 --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,5 @@ +[bumpversion] +current_version = 1.1.0 +commit = True +tag = True + diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 0f74bd1966..0000000000 --- a/.eslintrc +++ /dev/null @@ -1,110 +0,0 @@ -root: true - -plugins: - - react - -ecmaFeatures: - modules: true - jsx: true - arrowFunctions: true - blockBindings: true - -env: - node: true - es6: true - browser: true - jest: true - -extends: - "eslint:recommended" - -rules: - indent: [2, 2, {SwitchCase: 1}] - brace-style: [2, "1tbs"] - camelcase: [2, { properties: "never" }] - callback-return: [2, ["cb", "callback", "next"]] - comma-spacing: 2 - comma-style: [2, "last"] - consistent-return: 2 - curly: [2, "all"] - default-case: 2 - dot-notation: [2, { allowKeywords: true }] - eol-last: 2 - eqeqeq: 2 - func-style: [2, "declaration"] - guard-for-in: 2 - key-spacing: [2, { beforeColon: false, afterColon: true }] - new-cap: 2 - new-parens: 2 - no-alert: 2 - no-array-constructor: 2 - no-caller: 2 - no-console: 0 - no-delete-var: 2 - no-empty-label: 2 - no-eval: 2 - no-extend-native: 2 - no-extra-bind: 2 - no-fallthrough: 2 - no-floating-decimal: 2 - no-implied-eval: 2 - no-invalid-this: 2 - no-iterator: 2 - no-label-var: 2 - no-labels: 2 - no-lone-blocks: 2 - no-loop-func: 2 - no-mixed-spaces-and-tabs: [2, false] - no-multi-spaces: 2 - no-multi-str: 2 - no-native-reassign: 2 - no-nested-ternary: 2 - no-new: 2 - no-new-func: 2 - no-new-object: 2 - no-new-wrappers: 2 - no-octal: 2 - no-octal-escape: 2 - no-process-exit: 2 - no-proto: 2 - no-redeclare: 2 - no-return-assign: 2 - no-script-url: 2 - no-sequences: 2 - no-shadow: 2 - no-shadow-restricted-names: 2 - no-spaced-func: 2 - no-trailing-spaces: 2 - no-undef: 2 - no-undef-init: 2 - no-undefined: 2 - no-underscore-dangle: 2 - no-unused-expressions: 2 - no-unused-vars: [2, {vars: "all", args: "after-used"}] - no-use-before-define: 2 - no-with: 2 - quotes: [2, "single"] - radix: 2 - semi: 2 - semi-spacing: [2, {before: false, after: true}] - space-after-keywords: [2, "always"] - space-before-blocks: 2 - space-before-function-paren: [2, "always"] - space-infix-ops: 2 - space-return-throw-case: 2 - space-unary-ops: [2, {words: true, nonwords: false}] - spaced-comment: [2, "always", { exceptions: ["-"]}] - strict: [2, "global"] - valid-jsdoc: [2, { prefer: { "return": "returns"}}] - wrap-iife: 2 - yoda: [2, "never"] - - # Previously on by default in node environment - no-catch-shadow: 0 - no-console: 0 - no-mixed-requires: 2 - no-new-require: 2 - no-path-concat: 2 - no-process-exit: 2 - global-strict: [0, "always"] - handle-callback-err: [2, "err"] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..2125666142 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..98712d4383 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +node_modules +dist +dist-app-server +.tmp +.sass-cache +app/bower_components +test/bower_components +.idea +.nodemon-find-ref +.DS_Store + +\#*# +.#* + +auto-docs + +netrc +private-deps/* +app/scripts/build +restclient.rc +.build +.build-prod \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..0b1d2535be --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM bagel/universe:337f873f4f23f4b2603972229ae3519c5f61f6d7 + +# Source +COPY ./app /opt/hub/app +# Webpack +COPY ./webpack.config.js /opt/hub/webpack.config.js +COPY ./_webpack /opt/hub/_webpack +# Make +COPY ./Makefile /opt/hub/Makefile +# Gulp +COPY ./gulpfile.js /opt/hub/gulpfile.js +COPY ./gulp-tasks /opt/hub/gulp-tasks +# ESLint +COPY ./.eslintrc /opt/hub/.eslintrc +# Flow +ENV LOGNAME bagels +COPY ./flow-libs /opt/hub/flow-libs +COPY .flowconfig /opt/hub/.flowconfig +ENV PATH /opt/flow/:$PATH + +RUN DEBUG=* webpack -d +RUN make server-target +RUN make styles-base +RUN gulp images::dev +RUN make images +RUN make docker-font-dev +# favicon +COPY ./app/favicon.ico /opt/hub/app/.build/ diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000000..fa29520b5f --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,8 @@ +// Only run on Linux atm +wrappedNode(label: 'docker') { + deleteDir() + stage "checkout" + checkout scm + + documentationChecker("docs") +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..abe9264a6e --- /dev/null +++ b/Makefile @@ -0,0 +1,94 @@ +.PHONY: dns server-prod-target server-target server-extras base base-tag prod prod-tag push-builders js-prod images images-prod + +# Set up make's +dns: + ./containers/dnsmasq/configure_system_dns.sh +hub-deps: + git clone git@github.com:docker/docker-ux.git ./private-deps/docker-ux + git clone git@github.com:docker/hub-js-sdk.git ./private-deps/hub-js-sdk +# -> bootstrap-dev +server-target: + mkdir -p app/.build/public/styles + cp -R app/img app/.build/public +styles-base: + cp ./private-deps/docker-ux/dist/styles/main.css ./app/.build/public/styles/main.css +images: + cp -R ./private-deps/docker-ux/dist/images ./app/.build/public/ +docker-font-dev: + cp -R ./private-deps/docker-ux/dist/fonts ./app/.build/public/ + cp ./app/fonts/* ./app/.build/public/fonts/ + mkdir -p app/.build/public/styles + cp ./app/styles/font-awesome.min.css ./app/.build/public/styles/font-awesome.min.css + +# Circle make's +local: + docker build -f local.Dockerfile -t bagel/hub-builders-local . +copy-local: + docker run --name bagel-local -d bagel/hub-builders-local sleep 50s + docker cp bagel-local:/opt/hub/.build-prod ./.local/ +stage: + docker build -f dockerfiles/Dockerfile-stage-build -t bagel/hub-builders-stage . +copy-stage: + docker run --name bagel-stage -d bagel/hub-builders-stage sleep 50s + docker cp bagel-stage:/opt/hub/.build-prod ./.stage/ +prod: + docker build -f dockerfiles/Dockerfile-prod-build -t bagel/hub-builders-prod . +base-prod-tag: + $(shell docker tag bagel/hub-builders-prod:latest bagel/hub-builders-prod:$(shell git rev-parse --verify HEAD)) +copy-prod: + docker run --name bagel-prod -d bagel/hub-builders-prod sleep 50s + docker cp bagel-prod:/opt/hub/.build-prod . + +# Dockerfile make's +server-prod-target: + rm -rf .build-prod + mkdir -p .build-prod +server-extras: + cp app-server/package.json .build-prod/package.json + cp app-server/favicons/favicon-dev.ico .build-prod/favicon.ico + cp app-server/Dockerfile .build-prod/Dockerfile +js-prod: + ENV=production webpack --production --config _webpack/webpack.prod.config.js + ENV=production webpack --production --config _webpack/webpack.server.config.js +js-stage: + ENV=staging webpack --production --config _webpack/webpack.prod.config.js + ENV=staging webpack --production --config _webpack/webpack.server.config.js +js-local: + ENV=local webpack --production --config _webpack/webpack.prod.config.js + ENV=local webpack --production --config _webpack/webpack.server.config.js +images-prod: + cp -R ./private-deps/docker-ux/dist/images .build-prod/public/ +docker-font-prod: + cp -R ./private-deps/docker-ux/dist/fonts .build-prod/public + cp -R ./app/fonts/* .build-prod/public/fonts/ + mkdir -p app/.build-prod/public/styles + cp ./app/styles/font-awesome.min.css .build-prod/public/styles/font-awesome.min.css +styles-base-prod: + cp ./private-deps/docker-ux/dist/styles/main.css .build-prod/public/styles/main.css +stats-dir: + mkdir -p /stats/css +css-stats: + /opt/hub/node_modules/.bin/cssstats file /opt/hub/.build-prod/public/styles/$(shell cat /tmp/.client-js-hash) > /stats/css-stats.json + +# Unused make commands +# Universe commands are no longer used as we now have the universe branch +dev-test-jest: + docker build -f dockerfiles/Dockerfile-builders-dev-jest -t bagel/hub-builders-dev-jest . +prod-tag: + $(shell docker tag bagel/hub-prod:latest bagel/hub-prod:$(shell git rev-parse --verify HEAD)) +universe: +# [ ! "${$(npm -v):0:1}" == "2" ] && echo "please \"npm install -g npm\" to get npm3'" && exit 1 + rm -rf node_modules + npm install --production + docker build -f dockerfiles/milky-way -t bagel/milky-way . + docker build -f dockerfiles/universe -t bagel/universe . +push-universe: + $(shell docker tag bagel/milky-way:latest bagel/milky-way:$(shell git rev-parse --verify HEAD)) + $(shell docker tag bagel/universe:latest bagel/universe:$(shell git rev-parse --verify HEAD)) + docker push bagel/milky-way + docker push bagel/universe +new-universe: + sed -i '.bak' "s/universe:[a-z0-9]*$$/universe:${UNIVERSE_TAG}/" Dockerfile + sed -i '.bak' "s/universe:[a-z0-9]*$$/universe:${UNIVERSE_TAG}/" local.Dockerfile + sed -i '.bak' "s/universe:[a-z0-9]*$$/universe:${UNIVERSE_TAG}/" dockerfiles/* + sed -i '.bak' "s/milky-way:[a-z0-9]*$$/milky-way:${UNIVERSE_TAG}/" dockerfiles/* diff --git a/README.md b/README.md new file mode 100644 index 0000000000..50a2ce98bb --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Quickstart + +*Make sure to clone this repo into your `/Users/` directory for it to run correctly* + +```bash +make dns +make hub-deps +# you must log in as the 'dux' user. ask one of the frontend +# team members for credentials +npm login +npm install +docker-compose build +npm run build:dev +./startup-scripts/bootstrap-dev.sh +docker-compose up -d +``` + +At this point you will need `tmux` to run `boot-dev-tmux.sh`, it can +be installed on OSX by `brew install tmux` + +```bash +./startup-scripts/boot-dev-tmux.sh +``` + +## tmux env + +Here are some basic commands to help you get around tmux. `C` is +Control, `-` means hit both keys, everything else it a literal +character you need to produce. + +| Command | Keys | +|--------------|---------| +| Next Window | C-b n | +| Next Panel | C-b o | +| Close Window | C-b & y | + +# Docs + +* [React](docs/concepts/React.md) +* [Flux](docs/concepts/Flux.md) +* [React Native](docs/concepts/React-Native.md) +* [React Router](docs/concepts/React-Router.md) +* [Immutability](docs/concepts/Immutability.md) diff --git a/_webpack/_common.webpack.config b/_webpack/_common.webpack.config new file mode 100644 index 0000000000..bf58cb7d55 --- /dev/null +++ b/_webpack/_common.webpack.config @@ -0,0 +1,105 @@ +var ExtractTextPlugin = require("extract-text-webpack-plugin"); +var debug = require('debug')('webpack--common'); +var fs = require('fs'); +var merge = require('lodash/object/merge'); +var cssstats = require('postcss-cssstats'); +var loaders = require('./_commonLoaders'); + +/* Dux Button Config */ +var elementButton = require('@dux/element-button/defaults'); +var buttons = elementButton.mkButtons([{ + name: 'primary', + color: '#FFF', + bg: '#22B8EB' +},{ + name: 'secondary', + color: '#FFF', + bg: '#232C37' +},{ + name: 'coral', + color: '#FFF', + bg: '#FF85AF' +},{ + name: 'success', + color: '#FFF', + bg: '#0FD85A' +},{ + name: 'warning', + color: '#FFF', + bg: '#FF8546' +},{ + name: 'yellow', + color: '#FFF', + bg: '#FFDE50' +},{ + name: 'alert', + color: '#FFF', + bg: '#EB3E46' +}]); +/** + * cssnaneOpts can be true or an options object + * + * http://cssnano.co/options/ + */ +var cssnanoOpts = { + mergeIdents: false +}; +const defaults = merge(require('@dux/element-card/defaults')({ + capBackground: '#f1f6fb', + borderColor: '#c4cdda' + }), + { + duxElementButton: { + radius: '.25rem', + buttons: buttons + } + }); +module.exports = { + resolve: { + extensions: ['', '.js', '.jsx', '.json'], + root: [ + '/opt/hub/app/scripts/', + '/opt/hub/app/scripts/components' + ] + }, + module: { + preLoaders: loaders.preLoaders, + loaders: loaders.commonLoaders + }, + postcss: [ + require('postcss-import')(), + require('postcss-constants')({ + defaults: defaults + }), + require('postcss-each'), + require('postcss-cssnext')({ + browsers: 'last 2 versions', + features: { + // https://github.com/robwierzbowski/node-pixrem/issues/40 + rem: false + } + }), + require('postcss-nested'), + require('lost')({ + gutter: '1.25rem', + flexbox: 'flex' + }), + require('postcss-cssstats')(function(stats) { + /** + * this cssstats callback runs for every postcss file + * perhaps we want to write out this object and + * record the values over time? + * + * problem: there is no filename here + */ + debug(stats); + }), + require('postcss-url')(), + require('cssnano')(cssnanoOpts) + ], + eslint: { + failOnError: true + }, + bail: true, + profile: true +} diff --git a/_webpack/_commonLoaders.js b/_webpack/_commonLoaders.js new file mode 100644 index 0000000000..cd4a46ed51 --- /dev/null +++ b/_webpack/_commonLoaders.js @@ -0,0 +1,22 @@ +var ExtractTextPlugin = require("extract-text-webpack-plugin"); + +var babelcfg = 'babel?optional[]=runtime&stage=0'; + +var preLoaders = [ + { test: /\.jsx?$/, exclude: /node_modules/, loader: 'eslint'} +] + +var commonLoaders = [ + // This loader matches .js and .jsx files + { test: /\.json$/, loader: 'json' }, + { test: /\.jsx?$/, exclude: /node_modules/, loader: babelcfg}, + { test: /dux.*\.jsx?$/, loader: babelcfg}, + { test: /hub-js-sdk.*\.jsx?$/, exclude: /hub-js-sdk.*node_modules.*\.jsx?$/, loader: babelcfg }, + { test: /\.css$/, loader: ExtractTextPlugin.extract('style-loader', 'css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader') }, + { test: /\.svg$/, loader: 'svg-inline' } +] + +module.exports = { + preLoaders: preLoaders, + commonLoaders: commonLoaders +} diff --git a/_webpack/_envConfig.js b/_webpack/_envConfig.js new file mode 100644 index 0000000000..69b6ded1a3 --- /dev/null +++ b/_webpack/_envConfig.js @@ -0,0 +1,104 @@ +var webpack = require('webpack'); +var debug = require('debug')('_envConfig'); + +debug(process.env.ENV); + +function isProd() { + return process.env.ENV === 'production'; +} + +/** + * `development` is staging. + * `staging` is also staging. + * + * The keys in these objects are possible ENV configurations + * + * Pointing production to staging during Alpha + */ + +var HUB_URLS = { + local: 'https://hub.dev.docker.com', + development: 'https://hub-stage.docker.com', + staging: 'https://hub-stage.docker.com', + production: 'https://hub.docker.com' +} + +var RECURLY_PUBLIC_KEY = { + local: 'sjc-9XwqFDBZALFs9BP9dn3J8e', + development: 'sjc-9XwqFDBZALFs9BP9dn3J8e', + production: 'sjc-JIfmXVz2OVkg3xg10NhWm1' +} + +// NO LONGER USED +// var MUNCHKIN_CODE = { +// staging: '453-IHP-147', +// production: '929-FJL-178' +// } + +var BUGSNAG_API_KEY = { + staging: 'ec43d0373895ee5eb76ec75301157a85', + production: 'd639ea00dd6e493b739de27a7ee0f90c' +} + +var TUTUM_SIGNIN_URLS = { + development: 'https://dashboard-staging.tutum.co/login/docker/', + production: 'https://dashboard.tutum.co/login/docker/' +} + +var COOKIE_DOMAIN = { + local: 'hub.dev.docker.com', + development: 'bagels.docker.com', + staging: 'hub-stage.docker.com', + production: 'hub.docker.com' +}; + +// NODE_ENV is an express thing but is NOT being used by us +if (isProd() || process.env.ENV === 'staging') { + process.env.NODE_ENV = 'production' +} + +if ( !~['development', 'local', 'staging', 'production'].indexOf(process.env.ENV) ) { + process.env.ENV = 'development'; +} + +// Override some ENV vars +process.env.HUB_API_BASE_URL = process.env.HUB_API_BASE_URL || HUB_URLS[process.env.ENV] || 'https://hub-stage.docker.com'; +process.env.REGISTRY_API_BASE_URL = HUB_URLS[process.env.ENV] || 'https://hub-stage.docker.com'; +process.env.RECURLY_PUBLIC_KEY = RECURLY_PUBLIC_KEY[process.env.ENV] || 'sjc-9XwqFDBZALFs9BP9dn3J8e'; + + +process.env.CLIENT_JS_FILENAME = process.env.CLIENT_JS_FILENAME || 'client.js'; +process.env.CSS_FILENAME = process.env.CSS_FILENAME || 'style.css'; +process.env.COOKIE_DOMAIN = COOKIE_DOMAIN[process.env.ENV] || 'bagels.docker.com'; + +process.env.BOT_TRACKING_ID = 'PXbPb4C2uT'; + +if(isProd()) { + process.env.BUGSNAG_API_KEY = BUGSNAG_API_KEY.production; + process.env.GOOGLE_TAG_MANAGER = 'gtmActive'; + process.env.BOT_TRACKING_ID = 'PXPmP8ILuI'; +} else if(process.env.ENV === 'staging') { + process.env.BUGSNAG_API_KEY = BUGSNAG_API_KEY.staging; + process.env.GOOGLE_TAG_MANAGER = 'gtmDisabled'; +} + +process.env.TUTUM_SIGNIN_URL = TUTUM_SIGNIN_URLS[process.env.ENV] || 'https://dashboard-staging.tutum.co/login/docker/'; + +process.env.NAUTILUS_API_BASE_URL = HUB_URLS[process.env.ENV] + '/api/nautilus/v1'; + +debug(process.env); + +module.exports = new webpack.EnvironmentPlugin([ + 'BUGSNAG_API_KEY', + 'CLIENT_JS_FILENAME', + 'CSS_FILENAME', + 'ENV', + 'NODE_ENV', + 'GOOGLE_TAG_MANAGER', + 'BOT_TRACKING_ID', + 'HUB_API_BASE_URL', + 'NAUTILUS_API_BASE_URL', + 'RECURLY_PUBLIC_KEY', + 'REGISTRY_API_BASE_URL', + 'TUTUM_SIGNIN_URL' +]); diff --git a/_webpack/hashAndReplaceClient.js b/_webpack/hashAndReplaceClient.js new file mode 100644 index 0000000000..837e0b3a88 --- /dev/null +++ b/_webpack/hashAndReplaceClient.js @@ -0,0 +1,37 @@ +var fs = require('fs'); +var assert = require('assert'); +/** + * Writes the stats object out to /stats.json so we can fetch it from + * the appropriate container. + * + * This fails hard (throws an AssertionError) if the filename does + * not match the regex. This is good because we don't want it shipping + * to production if we can't reliably find the hash for client.js. + * + * The `CLIENT_JS_REGEX` should match `output.filename` in the webpack + * config below. + */ +module.exports = function() { + var CLIENT_JS_REGEX = /client..*.js$/; + this.plugin('done', function(stats) { + var filename = stats.toJson().assets[0].name; + + console.log(filename); + console.log(stats.toJson().assets[0]); + // write stats object + fs.writeFileSync( + '/tmp/dux-stats-client-js.json', + JSON.stringify(stats.toJson())); + + // test `filename` + assert.strictEqual(true, !!filename.match(CLIENT_JS_REGEX), filename + ' does not match expected client.js regex') + + /** + * Store the client.js hash in /tmp/.clientjs-hash for use + * in the server build process + */ + fs.writeFile('/tmp/.client-js-hash', filename, 'utf8', function(err) { + if (err) throw err; + }); + }); +} \ No newline at end of file diff --git a/_webpack/webpack.prod.config.js b/_webpack/webpack.prod.config.js new file mode 100644 index 0000000000..c377284c22 --- /dev/null +++ b/_webpack/webpack.prod.config.js @@ -0,0 +1,32 @@ +var ExtractTextPlugin = require("extract-text-webpack-plugin"); +var ENV_CONFIG = require('./_envConfig.js'); +var HASH_CLIENT = require('./hashAndReplaceClient'); +var _ = require('lodash'); +var commonConfig = require('./_common.webpack.config'); +var webpack = require('webpack'); +var debug = require('debug')('webpack--client'); + +var clientConfig = { + entry: '/opt/hub/app/scripts/client.js', + devtool: 'source-map', + output: { + path: '.build-prod/public/', + filename: 'js/client.[hash].js' + }, + plugins: [ + ENV_CONFIG, + new webpack.optimize.DedupePlugin(), + new webpack.optimize.UglifyJsPlugin({ + compress: { + warnings: false + } + }), + new ExtractTextPlugin('styles/[name]-[id]-[hash].css', { allChunks: true }), + HASH_CLIENT + ] +}; +var clientBundle = _.assign({}, commonConfig, clientConfig); + +module.exports = [ + clientBundle +]; diff --git a/_webpack/webpack.server.config.js b/_webpack/webpack.server.config.js new file mode 100644 index 0000000000..0214170503 --- /dev/null +++ b/_webpack/webpack.server.config.js @@ -0,0 +1,54 @@ +var assert = require('assert'); +var ExtractTextPlugin = require("extract-text-webpack-plugin"); +var ENV_CONFIG = require('./_envConfig.js'); +var _ = require('lodash'); +var commonConfig = require('./_common.webpack.config'); +var debug = require('debug')('webpack--server'); +var fs = require('fs'); + +process.env.CLIENT_JS_FILENAME = fs.readFileSync('/tmp/.client-js-hash', 'utf-8').split('js/')[1]; +var CSS_FILENAME = _.filter(fs.readdirSync('/opt/hub/.build-prod/public/styles/'), + function(str) { + return !/.*\.map$/.test(str); + }); + +assert.strictEqual(1, CSS_FILENAME.length, CSS_FILENAME); +process.env.CSS_FILENAME = CSS_FILENAME[0]; +fs.writeFileSync('/tmp/.client-js-hash', CSS_FILENAME); + +/** + * blacklist this array from being included in `externals`. + * + * This has the effect of making any modules in this list be + * resolved at build time instead of runtime. This affects the + * server bundle + */ +var blacklist = ['.bin', 'hub-js-sdk', 'dux']; +var node_modules = fs.readdirSync('node_modules').filter(function(x) { + return !_.includes(blacklist, x); +}); + +debug('modules that will be runtime require dependencies of the server: ', node_modules); + + +var serverConfig = { + entry: '/opt/hub/app/scripts/server.js', + output: { + path: '.build-prod/', + filename: 'server.js', + libraryTarget: 'commonjs2' + }, + plugins: [ + ENV_CONFIG, + new ExtractTextPlugin('/.ignore/whatever.css', { + allChunks: true + }) + ], + target: 'node', + externals: node_modules, + node: { + __dirname: '/opt/hub/' + } +}; + +module.exports = _.assign({}, commonConfig, serverConfig); diff --git a/app-server/Dockerfile b/app-server/Dockerfile new file mode 100644 index 0000000000..74d2fdc0a0 --- /dev/null +++ b/app-server/Dockerfile @@ -0,0 +1,11 @@ +FROM bagel/node-alpine:4.1.2 + +WORKDIR /opt/hub + +COPY ./package.json package.json +ADD ./modules.tar /opt/hub/ +COPY ./server.js /opt/hub/ +COPY ./public /opt/hub/public/ +COPY favicon.ico /opt/hub/favicon.ico + +CMD ["node", "--max-old-space-size=2048", "server.js"] diff --git a/app-server/favicons/fav copy.png b/app-server/favicons/fav copy.png new file mode 100644 index 0000000000..7b36ba7a6e Binary files /dev/null and b/app-server/favicons/fav copy.png differ diff --git a/app-server/favicons/favicon-dev.ico b/app-server/favicons/favicon-dev.ico new file mode 100644 index 0000000000..f4766ac404 Binary files /dev/null and b/app-server/favicons/favicon-dev.ico differ diff --git a/app-server/favicons/favicon.ico b/app-server/favicons/favicon.ico new file mode 100644 index 0000000000..7b36ba7a6e Binary files /dev/null and b/app-server/favicons/favicon.ico differ diff --git a/app-server/package.json b/app-server/package.json new file mode 100644 index 0000000000..2049a85b85 --- /dev/null +++ b/app-server/package.json @@ -0,0 +1,52 @@ +{ + "name": "docker-hub", + "version": "0.0.1", + "private": true, + "scripts": { + "test": "gulp test", + "start": "node --harmony server/server.js" + }, + "dependencies": { + "@dux/element-card": "0.0.4", + "@dux/element-markdown": "0.0.3", + "@dux/element-button": "0.0.3", + "MD5": "^1.2.1", + "async": "^1.3.0", + "babel": "^5.0.12", + "babel-runtime": "^5.6.18", + "body-parser": "^1.12.2", + "classnames": "^2.1.2", + "cookie": "^0.1.2", + "cookie-parser": "^1.3.4", + "csurf": "^1.8.0", + "debug": "^2.1.3", + "dux": "file:../docker-ux", + "express": "^4.12.3", + "express-state": "^1.2.0", + "flux-router-component": "^0.6.1", + "fluxible": "^0.4.3", + "fluxible-plugin-fetchr": "^0.2.3", + "fluxible-plugin-routr": "^0.3.0", + "highlight.js": "^8.5.0", + "hub-js-sdk": "file:../hub-js-sdk", + "keymirror": "^0.1.1", + "lodash": "^3.6.0", + "marked": "^0.3.3", + "moment": "^2.10.3", + "newrelic": "christopherbiscardi/node-newrelic#c4ccca3764acafaf9c5899e4a1abece828e1f7b8", + "node-jsx": "^0.12.4", + "numeral": "^1.5.3", + "react": "^0.13.1", + "react-router": "^0.13.2", + "react-select": "^0.6.12", + "react-tagsinput": "^1.0.0", + "recurly-js": "git://github.com/recurly/recurly-js#d9740eb3ee416fb999635daecfb524a492dbb058", + "remarkable": "^1.6.0", + "serialize-javascript": "^1.0.0", + "serve-favicon": "^2.2.0", + "superagent": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } +} diff --git a/app/.htaccess b/app/.htaccess new file mode 100644 index 0000000000..ca346584ca --- /dev/null +++ b/app/.htaccess @@ -0,0 +1,663 @@ +# Apache Server Configs v2.2.0 | MIT License +# https://github.com/h5bp/server-configs-apache + +# (!) Using `.htaccess` files slows down Apache, therefore, if you have access +# to the main server config file (usually called `httpd.conf`), you should add +# this logic there: http://httpd.apache.org/docs/current/howto/htaccess.html. + +# ############################################################################## +# # CROSS-ORIGIN RESOURCE SHARING (CORS) # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Cross-domain AJAX requests | +# ------------------------------------------------------------------------------ + +# Allow cross-origin AJAX requests. +# http://code.google.com/p/html5security/wiki/CrossOriginRequestSecurity +# http://enable-cors.org/ + +# +# Header set Access-Control-Allow-Origin "*" +# + +# ------------------------------------------------------------------------------ +# | CORS-enabled images | +# ------------------------------------------------------------------------------ + +# Send the CORS header for images when browsers request it. +# https://developer.mozilla.org/en-US/docs/HTML/CORS_Enabled_Image +# http://blog.chromium.org/2011/07/using-cross-domain-images-in-webgl-and.html +# http://hacks.mozilla.org/2011/11/using-cors-to-load-webgl-textures-from-cross-domain-images/ + + + + + SetEnvIf Origin ":" IS_CORS + Header set Access-Control-Allow-Origin "*" env=IS_CORS + + + + +# ------------------------------------------------------------------------------ +# | Web fonts access | +# ------------------------------------------------------------------------------ + +# Allow access to web fonts from all domains. + + + + Header set Access-Control-Allow-Origin "*" + + + + +# ############################################################################## +# # ERRORS # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | 404 error prevention for non-existing redirected folders | +# ------------------------------------------------------------------------------ + +# Prevent Apache from returning a 404 error as the result of a rewrite +# when the directory with the same name does not exist. +# http://httpd.apache.org/docs/current/content-negotiation.html#multiviews +# http://www.webmasterworld.com/apache/3808792.htm + +Options -MultiViews + +# ------------------------------------------------------------------------------ +# | Custom error messages / pages | +# ------------------------------------------------------------------------------ + +# Customize what Apache returns to the client in case of an error. +# http://httpd.apache.org/docs/current/mod/core.html#errordocument + +ErrorDocument 404 /404.html + + +# ############################################################################## +# # INTERNET EXPLORER # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Better website experience | +# ------------------------------------------------------------------------------ + +# Force Internet Explorer to render pages in the highest available mode +# in the various cases when it may not. +# http://hsivonen.iki.fi/doctype/ie-mode.pdf + + + Header set X-UA-Compatible "IE=edge" + # `mod_headers` cannot match based on the content-type, however, this + # header should be send only for HTML pages and not for the other resources + + Header unset X-UA-Compatible + + + +# ------------------------------------------------------------------------------ +# | Cookie setting from iframes | +# ------------------------------------------------------------------------------ + +# Allow cookies to be set from iframes in Internet Explorer. +# http://msdn.microsoft.com/en-us/library/ms537343.aspx +# http://www.w3.org/TR/2000/CR-P3P-20001215/ + +# +# Header set P3P "policyref=\"/w3c/p3p.xml\", CP=\"IDC DSP COR ADM DEVi TAIi PSA PSD IVAi IVDi CONi HIS OUR IND CNT\"" +# + + +# ############################################################################## +# # MIME TYPES AND ENCODING # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Proper MIME types for all files | +# ------------------------------------------------------------------------------ + + + + # Audio + AddType audio/mp4 m4a f4a f4b + AddType audio/ogg oga ogg opus + + # Data interchange + AddType application/json json map + AddType application/ld+json jsonld + + # JavaScript + # Normalize to standard type. + # http://tools.ietf.org/html/rfc4329#section-7.2 + AddType application/javascript js + + # Video + AddType video/mp4 f4v f4p m4v mp4 + AddType video/ogg ogv + AddType video/webm webm + AddType video/x-flv flv + + # Web fonts + AddType application/font-woff woff + AddType application/vnd.ms-fontobject eot + + # Browsers usually ignore the font MIME types and simply sniff the bytes + # to figure out the font type. + # http://mimesniff.spec.whatwg.org/#matching-a-font-type-pattern + + # Chrome however, shows a warning if any other MIME types are used for + # the following fonts. + + AddType application/x-font-ttf ttc ttf + AddType font/opentype otf + + # Make SVGZ fonts work on the iPad. + # https://twitter.com/FontSquirrel/status/14855840545 + AddType image/svg+xml svgz + AddEncoding gzip svgz + + # Other + AddType application/octet-stream safariextz + AddType application/x-chrome-extension crx + AddType application/x-opera-extension oex + AddType application/x-web-app-manifest+json webapp + AddType application/x-xpinstall xpi + AddType application/xml atom rdf rss xml + AddType image/webp webp + AddType image/x-icon cur + AddType text/cache-manifest appcache manifest + AddType text/vtt vtt + AddType text/x-component htc + AddType text/x-vcard vcf + + + +# ------------------------------------------------------------------------------ +# | UTF-8 encoding | +# ------------------------------------------------------------------------------ + +# Use UTF-8 encoding for anything served as `text/html` or `text/plain`. +AddDefaultCharset utf-8 + +# Force UTF-8 for certain file formats. + + AddCharset utf-8 .atom .css .js .json .jsonld .rss .vtt .webapp .xml + + + +# ############################################################################## +# # URL REWRITES # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Rewrite engine | +# ------------------------------------------------------------------------------ + +# Turn on the rewrite engine and enable the `FollowSymLinks` option (this is +# necessary in order for the following directives to work). + +# If your web host doesn't allow the `FollowSymlinks` option, you may need to +# comment it out and use `Options +SymLinksIfOwnerMatch`, but be aware of the +# performance impact. +# http://httpd.apache.org/docs/current/misc/perf-tuning.html#symlinks + +# Also, some cloud hosting services require `RewriteBase` to be set. +# http://www.rackspace.com/knowledge_center/frequently-asked-question/why-is-mod-rewrite-not-working-on-my-site + + + Options +FollowSymlinks + # Options +SymLinksIfOwnerMatch + RewriteEngine On + # RewriteBase / + + +# ------------------------------------------------------------------------------ +# | Suppressing / Forcing the `www.` at the beginning of URLs | +# ------------------------------------------------------------------------------ + +# The same content should never be available under two different URLs, +# especially not with and without `www.` at the beginning. This can cause +# SEO problems (duplicate content), and therefore, you should choose one +# of the alternatives and redirect the other one. + +# By default `Option 1` (no `www.`) is activated. +# http://no-www.org/faq.php?q=class_b + +# If you would prefer to use `Option 2`, just comment out all the lines +# from `Option 1` and uncomment the ones from `Option 2`. + +# IMPORTANT: NEVER USE BOTH RULES AT THE SAME TIME! + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +# Option 1: rewrite www.example.com → example.com + + + RewriteCond %{HTTPS} !=on + RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC] + RewriteRule ^ http://%1%{REQUEST_URI} [R=301,L] + + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +# Option 2: rewrite example.com → www.example.com + +# Be aware that the following might not be a good idea if you use "real" +# subdomains for certain parts of your website. + +# +# RewriteCond %{HTTPS} !=on +# RewriteCond %{HTTP_HOST} !^www\. [NC] +# RewriteCond %{SERVER_ADDR} !=127.0.0.1 +# RewriteCond %{SERVER_ADDR} !=::1 +# RewriteRule ^ http://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L] +# + + +# ############################################################################## +# # SECURITY # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Clickjacking | +# ------------------------------------------------------------------------------ + +# Protect website against clickjacking. + +# The example below sends the `X-Frame-Options` response header with the value +# `DENY`, informing browsers not to display the web page content in any frame. + +# This might not be the best setting for everyone. You should read about the +# other two possible values for `X-Frame-Options`: `SAMEORIGIN` & `ALLOW-FROM`. +# http://tools.ietf.org/html/rfc7034#section-2.1 + +# Keep in mind that while you could send the `X-Frame-Options` header for all +# of your site’s pages, this has the potential downside that it forbids even +# non-malicious framing of your content (e.g.: when users visit your site using +# a Google Image Search results page). + +# Nonetheless, you should ensure that you send the `X-Frame-Options` header for +# all pages that allow a user to make a state changing operation (e.g: pages +# that contain one-click purchase links, checkout or bank-transfer confirmation +# pages, pages that make permanent configuration changes, etc.). + +# Sending the `X-Frame-Options` header can also protect your website against +# more than just clickjacking attacks: https://cure53.de/xfo-clickjacking.pdf. + +# http://tools.ietf.org/html/rfc7034 +# http://blogs.msdn.com/b/ieinternals/archive/2010/03/30/combating-clickjacking-with-x-frame-options.aspx +# https://www.owasp.org/index.php/Clickjacking + +# +# Header set X-Frame-Options "DENY" +# +# Header unset X-Frame-Options +# +# + +# ------------------------------------------------------------------------------ +# | Content Security Policy (CSP) | +# ------------------------------------------------------------------------------ + +# Mitigate the risk of cross-site scripting and other content-injection attacks. + +# This can be done by setting a `Content Security Policy` which whitelists +# trusted sources of content for your website. + +# The example header below allows ONLY scripts that are loaded from the current +# site's origin (no inline scripts, no CDN, etc). This almost certainly won't +# work as-is for your site! + +# For more details on how to craft a reasonable policy for your site, read: +# http://html5rocks.com/en/tutorials/security/content-security-policy (or the +# specification: http://w3.org/TR/CSP). Also, to make things easier, you can +# use an online CSP header generator such as: http://cspisawesome.com/. + +# +# Header set Content-Security-Policy "script-src 'self'; object-src 'self'" +# +# Header unset Content-Security-Policy +# +# + +# ------------------------------------------------------------------------------ +# | File access | +# ------------------------------------------------------------------------------ + +# Block access to directories without a default document. +# You should leave the following uncommented, as you shouldn't allow anyone to +# surf through every directory on your server (which may includes rather private +# places such as the CMS's directories). + + + Options -Indexes + + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +# Block access to hidden files and directories. +# This includes directories used by version control systems such as Git and SVN. + + + RewriteCond %{SCRIPT_FILENAME} -d [OR] + RewriteCond %{SCRIPT_FILENAME} -f + RewriteRule "(^|/)\." - [F] + + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +# Block access to files that can expose sensitive information. + +# By default, block access to backup and source files that may be left by some +# text editors and can pose a security risk when anyone has access to them. +# http://feross.org/cmsploit/ + +# IMPORTANT: Update the `` regular expression from below to include +# any files that might end up on your production server and can expose sensitive +# information about your website. These files may include: configuration files, +# files that contain metadata about the project (e.g.: project dependencies), +# build scripts, etc.. + + + + # Apache < 2.3 + + Order allow,deny + Deny from all + Satisfy All + + + # Apache ≥ 2.3 + + Require all denied + + + + +# ------------------------------------------------------------------------------ +# | Reducing MIME-type security risks | +# ------------------------------------------------------------------------------ + +# Prevent some browsers from MIME-sniffing the response. + +# This reduces exposure to drive-by download attacks and should be enable +# especially if the web server is serving user uploaded content, content +# that could potentially be treated by the browser as executable. + +# http://blogs.msdn.com/b/ie/archive/2008/07/02/ie8-security-part-v-comprehensive-protection.aspx +# http://msdn.microsoft.com/en-us/library/ie/gg622941.aspx +# http://mimesniff.spec.whatwg.org/ + +# +# Header set X-Content-Type-Options "nosniff" +# + +# ------------------------------------------------------------------------------ +# | Reflected Cross-Site Scripting (XSS) attacks | +# ------------------------------------------------------------------------------ + +# (1) Try to re-enable the Cross-Site Scripting (XSS) filter built into the +# most recent web browsers. +# +# The filter is usually enabled by default, but in some cases it may be +# disabled by the user. However, in Internet Explorer for example, it can +# be re-enabled just by sending the `X-XSS-Protection` header with the +# value of `1`. +# +# (2) Prevent web browsers from rendering the web page if a potential reflected +# (a.k.a non-persistent) XSS attack is detected by the filter. +# +# By default, if the filter is enabled and browsers detect a reflected +# XSS attack, they will attempt to block the attack by making the smallest +# possible modifications to the returned web page. +# +# Unfortunately, in some browsers (e.g.: Internet Explorer), this default +# behavior may allow the XSS filter to be exploited, thereby, it's better +# to tell browsers to prevent the rendering of the page altogether, instead +# of attempting to modify it. +# +# http://hackademix.net/2009/11/21/ies-xss-filter-creates-xss-vulnerabilities +# +# IMPORTANT: Do not rely on the XSS filter to prevent XSS attacks! Ensure that +# you are taking all possible measures to prevent XSS attacks, the most obvious +# being: validating and sanitizing your site's inputs. +# +# http://blogs.msdn.com/b/ie/archive/2008/07/02/ie8-security-part-iv-the-xss-filter.aspx +# http://blogs.msdn.com/b/ieinternals/archive/2011/01/31/controlling-the-internet-explorer-xss-filter-with-the-x-xss-protection-http-header.aspx +# https://www.owasp.org/index.php/Cross-site_Scripting_%28XSS%29 + +# +# # (1) (2) +# Header set X-XSS-Protection "1; mode=block" +# +# Header unset X-XSS-Protection +# +# + +# ------------------------------------------------------------------------------ +# | Secure Sockets Layer (SSL) | +# ------------------------------------------------------------------------------ + +# Rewrite secure requests properly in order to prevent SSL certificate warnings. +# E.g.: prevent `https://www.example.com` when your certificate only allows +# `https://secure.example.com`. + +# +# RewriteCond %{SERVER_PORT} !^443 +# RewriteRule ^ https://example-domain-please-change-me.com%{REQUEST_URI} [R=301,L] +# + +# ------------------------------------------------------------------------------ +# | HTTP Strict Transport Security (HSTS) | +# ------------------------------------------------------------------------------ + +# Force client-side SSL redirection. + +# If a user types `example.com` in his browser, the above rule will redirect +# him to the secure version of the site. That still leaves a window of +# opportunity (the initial HTTP connection) for an attacker to downgrade or +# redirect the request. + +# The following header ensures that browser will ONLY connect to your server +# via HTTPS, regardless of what the users type in the address bar. + +# http://tools.ietf.org/html/draft-ietf-websec-strict-transport-sec-14#section-6.1 +# http://www.html5rocks.com/en/tutorials/security/transport-layer-security/ + +# IMPORTANT: Remove the `includeSubDomains` optional directive if the subdomains +# are not using HTTPS. + +# +# Header set Strict-Transport-Security "max-age=16070400; includeSubDomains" +# + +# ------------------------------------------------------------------------------ +# | Server software information | +# ------------------------------------------------------------------------------ + +# Avoid displaying the exact Apache version number, the description of the +# generic OS-type and the information about Apache's compiled-in modules. + +# ADD THIS DIRECTIVE IN THE `httpd.conf` AS IT WILL NOT WORK IN THE `.htaccess`! + +# ServerTokens Prod + + +# ############################################################################## +# # WEB PERFORMANCE # +# ############################################################################## + +# ------------------------------------------------------------------------------ +# | Compression | +# ------------------------------------------------------------------------------ + + + + # Force compression for mangled headers. + # http://developer.yahoo.com/blogs/ydn/posts/2010/12/pushing-beyond-gzipping + + + SetEnvIfNoCase ^(Accept-EncodXng|X-cept-Encoding|X{15}|~{15}|-{15})$ ^((gzip|deflate)\s*,?\s*)+|[X~-]{4,13}$ HAVE_Accept-Encoding + RequestHeader append Accept-Encoding "gzip,deflate" env=HAVE_Accept-Encoding + + + + # Compress all output labeled with one of the following MIME-types + # (for Apache versions below 2.3.7, you don't need to enable `mod_filter` + # and can remove the `` and `` lines + # as `AddOutputFilterByType` is still in the core directives). + + AddOutputFilterByType DEFLATE application/atom+xml \ + application/javascript \ + application/json \ + application/ld+json \ + application/rss+xml \ + application/vnd.ms-fontobject \ + application/x-font-ttf \ + application/x-web-app-manifest+json \ + application/xhtml+xml \ + application/xml \ + font/opentype \ + image/svg+xml \ + image/x-icon \ + text/css \ + text/html \ + text/plain \ + text/x-component \ + text/xml + + + + +# ------------------------------------------------------------------------------ +# | Content transformations | +# ------------------------------------------------------------------------------ + +# Prevent mobile network providers from modifying the website's content. +# http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.5. + +# +# Header set Cache-Control "no-transform" +# + +# ------------------------------------------------------------------------------ +# | ETags | +# ------------------------------------------------------------------------------ + +# Remove `ETags` as resources are sent with far-future expires headers. +# http://developer.yahoo.com/performance/rules.html#etags. + +# `FileETag None` doesn't work in all cases. + + Header unset ETag + + +FileETag None + +# ------------------------------------------------------------------------------ +# | Expires headers | +# ------------------------------------------------------------------------------ + +# The following expires headers are set pretty far in the future. If you +# don't control versioning with filename-based cache busting, consider +# lowering the cache time for resources such as style sheets and JavaScript +# files to something like one week. + + + + ExpiresActive on + ExpiresDefault "access plus 1 month" + + # CSS + ExpiresByType text/css "access plus 1 year" + + # Data interchange + ExpiresByType application/json "access plus 0 seconds" + ExpiresByType application/ld+json "access plus 0 seconds" + ExpiresByType application/xml "access plus 0 seconds" + ExpiresByType text/xml "access plus 0 seconds" + + # Favicon (cannot be renamed!) and cursor images + ExpiresByType image/x-icon "access plus 1 week" + + # HTML components (HTCs) + ExpiresByType text/x-component "access plus 1 month" + + # HTML + ExpiresByType text/html "access plus 0 seconds" + + # JavaScript + ExpiresByType application/javascript "access plus 1 year" + + # Manifest files + ExpiresByType application/x-web-app-manifest+json "access plus 0 seconds" + ExpiresByType text/cache-manifest "access plus 0 seconds" + + # Media + ExpiresByType audio/ogg "access plus 1 month" + ExpiresByType image/gif "access plus 1 month" + ExpiresByType image/jpeg "access plus 1 month" + ExpiresByType image/png "access plus 1 month" + ExpiresByType video/mp4 "access plus 1 month" + ExpiresByType video/ogg "access plus 1 month" + ExpiresByType video/webm "access plus 1 month" + + # Web feeds + ExpiresByType application/atom+xml "access plus 1 hour" + ExpiresByType application/rss+xml "access plus 1 hour" + + # Web fonts + ExpiresByType application/font-woff "access plus 1 month" + ExpiresByType application/vnd.ms-fontobject "access plus 1 month" + ExpiresByType application/x-font-ttf "access plus 1 month" + ExpiresByType font/opentype "access plus 1 month" + ExpiresByType image/svg+xml "access plus 1 month" + + + +# ------------------------------------------------------------------------------ +# | Filename-based cache busting | +# ------------------------------------------------------------------------------ + +# If you're not using a build process to manage your filename version revving, +# you might want to consider enabling the following directives to route all +# requests such as `/css/style.12345.css` to `/css/style.css`. + +# To understand why this is important and a better idea than `*.css?v231`, read: +# http://stevesouders.com/blog/2008/08/23/revving-filenames-dont-use-querystring + +# +# RewriteCond %{REQUEST_FILENAME} !-f +# RewriteRule ^(.+)\.(\d+)\.(js|css|png|jpe?g|gif)$ $1.$3 [L] +# + +# ------------------------------------------------------------------------------ +# | File concatenation | +# ------------------------------------------------------------------------------ + +# Allow concatenation from within specific style sheets and JavaScript files. + +# e.g.: +# +# If you have the following content in a file +# +# +# +# +# Apache will replace it with the content from the specified files. + +# +# +# Options +Includes +# AddOutputFilterByType INCLUDES application/javascript application/json +# SetOutputFilter INCLUDES +# +# +# Options +Includes +# AddOutputFilterByType INCLUDES text/css +# SetOutputFilter INCLUDES +# +# diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000..ee01a5ee8a Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/fonts/FontAwesome.otf b/app/fonts/FontAwesome.otf new file mode 100644 index 0000000000..681bdd4d4c Binary files /dev/null and b/app/fonts/FontAwesome.otf differ diff --git a/app/fonts/fontawesome-webfont.eot b/app/fonts/fontawesome-webfont.eot new file mode 100644 index 0000000000..a30335d748 Binary files /dev/null and b/app/fonts/fontawesome-webfont.eot differ diff --git a/app/fonts/fontawesome-webfont.svg b/app/fonts/fontawesome-webfont.svg new file mode 100644 index 0000000000..6fd19abcb9 --- /dev/null +++ b/app/fonts/fontawesome-webfont.svg @@ -0,0 +1,640 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/fonts/fontawesome-webfont.ttf b/app/fonts/fontawesome-webfont.ttf new file mode 100644 index 0000000000..d7994e1308 Binary files /dev/null and b/app/fonts/fontawesome-webfont.ttf differ diff --git a/app/fonts/fontawesome-webfont.woff b/app/fonts/fontawesome-webfont.woff new file mode 100644 index 0000000000..6fd4ede0f3 Binary files /dev/null and b/app/fonts/fontawesome-webfont.woff differ diff --git a/app/fonts/fontawesome-webfont.woff2 b/app/fonts/fontawesome-webfont.woff2 new file mode 100644 index 0000000000..5560193ccc Binary files /dev/null and b/app/fonts/fontawesome-webfont.woff2 differ diff --git a/app/img/docker-logos/docker-logo.svg b/app/img/docker-logos/docker-logo.svg new file mode 100644 index 0000000000..91c8e453fd --- /dev/null +++ b/app/img/docker-logos/docker-logo.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/img/docker-logos/docker_logo-01.svg b/app/img/docker-logos/docker_logo-01.svg new file mode 100644 index 0000000000..c5f6b41245 --- /dev/null +++ b/app/img/docker-logos/docker_logo-01.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/img/docker-logos/docker_logo-02.svg b/app/img/docker-logos/docker_logo-02.svg new file mode 100644 index 0000000000..381aa6456c --- /dev/null +++ b/app/img/docker-logos/docker_logo-02.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/img/docker-logos/docker_logo-03.svg b/app/img/docker-logos/docker_logo-03.svg new file mode 100644 index 0000000000..0e3c0e22ed --- /dev/null +++ b/app/img/docker-logos/docker_logo-03.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/img/docker-logos/docker_logo-04.svg b/app/img/docker-logos/docker_logo-04.svg new file mode 100644 index 0000000000..e06ac3f06b --- /dev/null +++ b/app/img/docker-logos/docker_logo-04.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/img/docker-logos/docker_logo-05.svg b/app/img/docker-logos/docker_logo-05.svg new file mode 100644 index 0000000000..4fbbf3671d --- /dev/null +++ b/app/img/docker-logos/docker_logo-05.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/img/docker-logos/docker_logo-06.svg b/app/img/docker-logos/docker_logo-06.svg new file mode 100644 index 0000000000..cc0bccbdff --- /dev/null +++ b/app/img/docker-logos/docker_logo-06.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/img/docker-logos/docker_logo-07.svg b/app/img/docker-logos/docker_logo-07.svg new file mode 100644 index 0000000000..6cb705a9bd --- /dev/null +++ b/app/img/docker-logos/docker_logo-07.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/img/docker-logos/docker_logo-08.svg b/app/img/docker-logos/docker_logo-08.svg new file mode 100644 index 0000000000..28734a80b3 --- /dev/null +++ b/app/img/docker-logos/docker_logo-08.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/img/services/bitbucket-logo.png b/app/img/services/bitbucket-logo.png new file mode 100644 index 0000000000..f3c9a9aa95 Binary files /dev/null and b/app/img/services/bitbucket-logo.png differ diff --git a/app/img/services/github-logo.png b/app/img/services/github-logo.png new file mode 100644 index 0000000000..582c451dac Binary files /dev/null and b/app/img/services/github-logo.png differ diff --git a/app/img/services/gitlab-logo.png b/app/img/services/gitlab-logo.png new file mode 100644 index 0000000000..1b5d498261 Binary files /dev/null and b/app/img/services/gitlab-logo.png differ diff --git a/app/robots.txt b/app/robots.txt new file mode 100644 index 0000000000..ee2cc216a6 --- /dev/null +++ b/app/robots.txt @@ -0,0 +1,3 @@ +# robotstxt.org/ + +User-agent: * diff --git a/app/scripts/actions/addAutoBuildPushTriggerItem.js b/app/scripts/actions/addAutoBuildPushTriggerItem.js new file mode 100644 index 0000000000..a21b202150 --- /dev/null +++ b/app/scripts/actions/addAutoBuildPushTriggerItem.js @@ -0,0 +1,5 @@ +'use strict'; + +export default function(actionContext) { + actionContext.dispatch('ADD_AUTOBUILD_PUSH_TRIGGER_ITEM'); +} diff --git a/app/scripts/actions/addCollaborator.js b/app/scripts/actions/addCollaborator.js new file mode 100644 index 0000000000..37525ee890 --- /dev/null +++ b/app/scripts/actions/addCollaborator.js @@ -0,0 +1,29 @@ +'use strict'; +import { Repositories } from 'hub-js-sdk'; +const { addCollaborator, getCollaboratorsForRepo } = Repositories; +import has from 'lodash/object/has'; +const debug = require('debug')('hub:actions:addCollaborator'); + +export default function addCollaboratorAction(actionContext, {JWT, namespace, name, user}, done) { + actionContext.dispatch('ADD_COLLAB_START'); + addCollaborator(JWT, { namespace, name, user }, (err, res) => { + if(err) { + if(has(err.response.body, 'detail')) { + debug('failed'); + actionContext.dispatch('ADD_COLLAB_ERROR', err.response.body.detail); + } + } else { + debug('succeeded'); + actionContext.dispatch('ADD_COLLAB_SUCCESS'); + getCollaboratorsForRepo(JWT, `${namespace}/${name}`, (getErr, getRes) => { + if(getErr) { + // 'Org repositories do not have collaborators.' + actionContext.dispatch('COLLAB_RECEIVE_COLLABORATORS', {}); + } else { + actionContext.dispatch('COLLAB_RECEIVE_COLLABORATORS', getRes.body); + } + }); + } + done(); + }); +} diff --git a/app/scripts/actions/addPipeline.js b/app/scripts/actions/addPipeline.js new file mode 100644 index 0000000000..b25996c516 --- /dev/null +++ b/app/scripts/actions/addPipeline.js @@ -0,0 +1,58 @@ +'use strict'; + +import includes from 'lodash/collection/includes'; + +import createPipeline from '@dux/hub-sdk/webhooks/createPipeline'; +import MissingArgError from '@dux/hub-sdk/utils/MissingArgError'; +import ValidationError from '@dux/hub-sdk/utils/ValidationError'; + +function handleKnownErrors(err, dispatch) { + if(err instanceof MissingArgError) { + if(includes(err.missingArgs, 'namespace') || includes(err.missingArgs, 'name')) { + /** + * TODO: send something to bugsnag; The user doesn't control these values + * This can happen if an engineer forgets to pass in a required value that + * the user can't control + */ + dispatch('ADD_WEBHOOK_ERROR'); + } else { + dispatch('ADD_WEBHOOK_MISSING_ARGS', err.missingArgs); + } + } else if (err instanceof ValidationError) { + dispatch('ADD_WEBHOOK_VALIDATION_ERRORS', err.validationErrors); + } else { + // unknown error + dispatch('ADD_WEBHOOK_ERROR', err); + } +} + +export default function addPipeline({ dispatch, history }, + { + jwt, + namespace, + name, + pipelineName, + expectFinalCallback, + webhooks + }, + done) { + dispatch('ADD_WEBHOOK_START'); + createPipeline(jwt, + { + namespace, + name, + pipelineName, + expectFinalCallback, + webhooks + }, + (err, res) => { + if(err || !res.ok) { + handleKnownErrors(err, dispatch); + done(); + } else { + dispatch('ADD_WEBHOOK_SUCCESS'); + history.push(`/r/${namespace}/${name}/~/settings/webhooks/`); + done(); + } + }); +} diff --git a/app/scripts/actions/addRepoComment.js b/app/scripts/actions/addRepoComment.js new file mode 100644 index 0000000000..3c0a30cb6a --- /dev/null +++ b/app/scripts/actions/addRepoComment.js @@ -0,0 +1,24 @@ +'use strict'; + +var debug = require('debug')('hub:actions:addRepoComment'); + +import { Repositories } from 'hub-js-sdk'; + +var addRepoComment = function(actionContext, {jwt, repoShortName, comment}) { + Repositories.addCommentToRepo(jwt, repoShortName, comment, function(addErr, addRes) { + if (addErr) { + debug(addErr); + } else if (addRes.body && addRes.ok) { + Repositories.getCommentsForRepo(jwt, repoShortName, function(getErr, getRes) { + if (getErr) { + debug(getErr); + } else { + actionContext.dispatch('RECEIVE_REPO_COMMENTS', getRes.body); + } + }, 1); + } + }); + +}; + +module.exports = addRepoComment; diff --git a/app/scripts/actions/addTeamCollaborator.js b/app/scripts/actions/addTeamCollaborator.js new file mode 100644 index 0000000000..e9e178b067 --- /dev/null +++ b/app/scripts/actions/addTeamCollaborator.js @@ -0,0 +1,26 @@ +'use strict'; +import { + Repositories as R + } from 'hub-js-sdk'; +const debug = require('debug')('hub:actions:addCollaborator'); + +export default function addTeamCollaborator(actionContext, {JWT, namespace, name, id, permission}, done) { + actionContext.dispatch('ADD_COLLAB_START'); + R.addTeamCollaborator(JWT, { namespace, name, group_id: id, permission }, (err, res) => { + if(err) { + debug('failed'); + } else { + debug('succeeded'); + R.getTeamCollaboratorsForRepo(JWT, `${namespace}/${name}`, (getErr, getRes) => { + if (getErr) { + // 'User repository does not have any teams yet' + actionContext.dispatch('COLLAB_RECEIVE_TEAMS', {}); + } else { + actionContext.dispatch('COLLAB_RECEIVE_TEAMS', getRes.body); + } + }); + } + actionContext.dispatch('ADD_COLLAB_FINISH'); + done(); + }); +} diff --git a/app/scripts/actions/addTriggerLink.js b/app/scripts/actions/addTriggerLink.js new file mode 100644 index 0000000000..1df118c658 --- /dev/null +++ b/app/scripts/actions/addTriggerLink.js @@ -0,0 +1,23 @@ +'use strict'; +import _ from 'lodash'; +import { Autobuilds as AutoBuild } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:addTriggerLink'); + +export default function(actionContext, { JWT, namespace, name, to_repo }) { + // to_repo needs to be a repo id + AutoBuild.addAutomatedBuildLink(JWT, namespace, name, to_repo, function(err, res) { + if (err) { + debug(err); + actionContext.dispatch('LINK_AUTOBUILD_ERROR'); + } else { + actionContext.dispatch('LINK_AUTOBUILD_SUCCESS'); + AutoBuild.getAutomatedBuildLinks(JWT, namespace, name, function(getErr, getRes){ + if (getErr) { + debug(err); + } else { + actionContext.dispatch('RECEIVE_AUTOBUILD_LINKS', getRes.body.results); + } + }); + } + }); +} diff --git a/app/scripts/actions/addUserEmail.js b/app/scripts/actions/addUserEmail.js new file mode 100644 index 0000000000..3f67808bde --- /dev/null +++ b/app/scripts/actions/addUserEmail.js @@ -0,0 +1,34 @@ +'use strict'; + +import { sortByOrder } from 'lodash'; +import { + Emails + } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:addEmail'); + +module.exports = function(actionContext, + { + JWT, newEmail, user + }, + done) { + Emails.addEmailsForUser(JWT, user.username, newEmail, function(err, res) { + if (err) { + debug('error', res.body); + actionContext.dispatch('ADD_EMAIL_INVALID', res.body.email); + } else if (res.ok) { + actionContext.dispatch('ADD_EMAIL_SUCCESS', res.body.email); + Emails.getEmailsForUser(JWT, user.username, function(emailErr, emailRes){ + if (emailErr) { + return done(); + } + var emails = emailRes.body.results; + var sortedEmails = sortByOrder(emails, + ['primary', 'verified'], + [false, false]); + actionContext.dispatch('RECEIVE_EMAILS', { + emails: sortedEmails + }); + }); + } + }); +}; diff --git a/app/scripts/actions/addUserEmailChange.js b/app/scripts/actions/addUserEmailChange.js new file mode 100644 index 0000000000..3be02615c6 --- /dev/null +++ b/app/scripts/actions/addUserEmailChange.js @@ -0,0 +1,5 @@ +'use strict'; + +export default function(actionContext, { email }) { + actionContext.dispatch('UPDATE_ADD_EMAIL', email); +} diff --git a/app/scripts/actions/addWebhookToPipeline.jsx b/app/scripts/actions/addWebhookToPipeline.jsx new file mode 100644 index 0000000000..a2605beda8 --- /dev/null +++ b/app/scripts/actions/addWebhookToPipeline.jsx @@ -0,0 +1,8 @@ +'use strict'; + +export default function addWebhookToPipeline({ dispatch }, + params, + done) { + dispatch('ADD_WEBHOOK_NEW_HOOK'); + done(); +} diff --git a/app/scripts/actions/associateGithubAccount.js b/app/scripts/actions/associateGithubAccount.js new file mode 100644 index 0000000000..caf33396a8 --- /dev/null +++ b/app/scripts/actions/associateGithubAccount.js @@ -0,0 +1,23 @@ +'use strict'; + +var debug = require('debug')('hub:actions:associateGithubAccount'); +import has from 'lodash/object/has'; +import { Builds } from 'hub-js-sdk'; + +export default function(actionContext, {jwt, code}) { + //TODO: immediately call the linked accounts status and update the authorized services page + Builds.associateGithubAccount(jwt, code, function(err, res) { + if (err) { + debug('error', err); + const { detail } = err.response.body; + if(detail) { + actionContext.dispatch('GITHUB_ASSOCIATE_ERROR', detail); + } + } else { + if (res.body) { + actionContext.dispatch('GITHUB_ASSOCIATE_SUCCESS', res.body); + } + } + }); + actionContext.dispatch(); +} diff --git a/app/scripts/actions/attemptChangeLongDescription.js b/app/scripts/actions/attemptChangeLongDescription.js new file mode 100644 index 0000000000..f976b9ad49 --- /dev/null +++ b/app/scripts/actions/attemptChangeLongDescription.js @@ -0,0 +1,56 @@ +/* @flow */ +'use strict'; + +import { Repositories as Repos } from 'hub-js-sdk'; +import async from 'async'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +var debug = require('debug')('hub:actions:attemptChangeLongDescription'); + +export default function(actionContext, + {jwt, repoShortName, longDescription}, + done) { + actionContext.dispatch('LONG_DESCRIPTION_ATTEMPT_START'); + + var _updateLongDescription = function(cb) { + Repos.patchRepo(jwt, repoShortName, { + full_description: longDescription + }, function(err, res) { + if (err) { + if(res && res.badRequest) { + debug('error', err); + actionContext.dispatch('LONG_BAD_REQUEST', res.body); + cb(err); + } else { + actionContext.dispatch('DETAILS_ERROR'); + cb(err); + } + } else { + actionContext.dispatch('LONG_DESCRIPTION_SUCCESS'); + cb(null, res.body); + } + }); + }; + + var _getRepoDetails = function(cb) { + Repos.getRepo(jwt, repoShortName, function(err, res) { + const { status, detail } = res.body; + + if (err || status === PENDING_DELETE) { + actionContext.dispatch('REPO_NOT_FOUND', err); + cb(null, detail); + } else { + actionContext.dispatch('RECEIVE_REPOSITORY', res.body); + cb(null, res.body); + } + }); + }; + + async.series([ + _updateLongDescription, + _getRepoDetails + ], function (err, results) { + if(err) { + debug('error', err); + } + }); +} diff --git a/app/scripts/actions/attemptChangeShortDescription.js b/app/scripts/actions/attemptChangeShortDescription.js new file mode 100644 index 0000000000..725b7f5972 --- /dev/null +++ b/app/scripts/actions/attemptChangeShortDescription.js @@ -0,0 +1,58 @@ +/* @flow */ +'use strict'; + +import async from 'async'; +import { Repositories as Repos } from 'hub-js-sdk'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +var debug = require('debug')('hub:actions:attemptChangeShortDescription'); + +export default function(actionContext, + {jwt, repoShortName, shortDescription}, + done) { + actionContext.dispatch('SHORT_DESCRIPTION_ATTEMPT_START'); + + var name = repoShortName.split('/')[1]; + var namespace = repoShortName.split('/')[0]; + var _updateShortDescription = function(cb) { + Repos.patchRepo(jwt, repoShortName, {description: shortDescription}, + function(err, res) { + if (err) { + if(res && res.badRequest) { + debug('error', err); + actionContext.dispatch('SHORT_BAD_REQUEST', res.body); + cb(err); + } else { + actionContext.dispatch('DETAILS_ERROR'); + cb(err); + } + } else { + actionContext.dispatch('SHORT_DESCRIPTION_SUCCESS'); + cb(null, res.body); + } + } + ); + }; + + var _getRepoDetails = function(cb) { + Repos.getRepo(jwt, repoShortName, function(err, res) { + const { status, detail } = res.body; + + if (err || status === PENDING_DELETE) { + actionContext.dispatch('REPO_NOT_FOUND', err); + cb(null, detail); + } else { + actionContext.dispatch('RECEIVE_REPOSITORY', res.body); + cb(null, res.body); + } + }); + }; + + async.series([ + _updateShortDescription, + _getRepoDetails + ], function (err, results) { + if(err) { + debug('error', err); + } + }); +} diff --git a/app/scripts/actions/attemptLogin.js b/app/scripts/actions/attemptLogin.js new file mode 100644 index 0000000000..f0c0fe707f --- /dev/null +++ b/app/scripts/actions/attemptLogin.js @@ -0,0 +1,147 @@ +'use strict'; + +import { parallel, waterfall } from 'async'; +import { Auth, + Repositories as Repos + } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:attemptLogin'); +import { getActivityFeed } from 'hub-js-sdk/src/Hub/SDK/Notifications'; +import { getUser } from 'hub-js-sdk/src/Hub/SDK/JWT'; +import { + getOrgsForUser, + getUserSettings +} from 'hub-js-sdk/src/Hub/SDK/Users'; +import { getToken } from 'hub-js-sdk/src/Hub/SDK/Auth'; +import request from 'superagent'; + +function handleGetRepos({jwt, username, dispatch}) { + return function(callback) { + Repos.getReposForUser(jwt, username, function(err, res) { + if (err) { + callback(null, null); + } else { + dispatch('RECEIVE_REPOS', res.body); + } + }, 1); //get page 1 + }; +} + +function handleGetPrivateRepoStats({jwt, username, dispatch}) { + return function(callback) { + getUserSettings(jwt, username, function(err, res) { + if (res.ok) { + dispatch('RECEIVE_PRIVATE_REPOSTATS', res.body); + } + callback(null, null); + }); + }; +} + +//Get orgs for user +function _getOrgsForCurrentUser({jwt, username, dispatch}) { + return function(user, cb) { + getOrgsForUser(jwt, function(err, res) { + if (err) { + debug('error', err); + cb(null); + } else { + dispatch('RECEIVE_DASHBOARD_NAMESPACES', { + orgs: res.body, + user: user.username + }); + cb(null, user); + } + }); + }; +} + +function handleGetUserInfo({jwt, username, dispatch}, callback) { + waterfall([ + function(cb){ + getUser(jwt, function(err, res) { + if (err) { + cb(err, {}); + } else { + dispatch('RECEIVE_USER', res.body); + cb(null, res.body); + } + }); + }, + _getOrgsForCurrentUser({jwt, username, dispatch}) + ], function(err, user) { + callback(err, { user }); + }); +} + +function fetchDataForDashboard({ + jwt, username, dispatch, done +}) { + dispatch('RECEIVE_JWT', jwt); + dispatch('DASHBOARD_REPOS_STORE_ATTEMPTING_GET_REPOS'); + parallel([ + handleGetRepos({jwt, username, dispatch}), + handleGetPrivateRepoStats({jwt, username, dispatch}) + ], function(err, results) { + if (err) { + debug('error', err); + } + return done(); + }); +} + +module.exports = function({ dispatch, history }, + { username, password }, + done) { + dispatch('LOGIN_ATTEMPT_START'); + getToken(username, + password, + function(err, res) { + if (err) { + debug('error', err); + if (res.unauthorized) { + if(res.body && res.body.detail) { + /** + * This can happen if the user has not verified their email + */ + dispatch('LOGIN_UNAUTHORIZED_DETAIL', res.body); + } else { + dispatch('LOGIN_UNAUTHORIZED'); + } + } else if (res.badRequest){ + try { + dispatch('LOGIN_BAD_REQUEST', JSON.parse(res.text)); + } catch (error) { + dispatch('LOGIN_ERROR'); + } + } else { + // unhandled login error + dispatch('LOGIN_ERROR'); + } + } else { + debug('got token'); + if (res.body.token) { + request.post('/attempt-login/') + .send({jwt: res.body.token}) + .end((cookieErr, cookieRes) => { + handleGetUserInfo({ + jwt: res.body.token, + username, + dispatch + }, function(userErr, userRes) { + dispatch('LOGIN_CLEAR'); + dispatch('CURRENT_USER_CONTEXT', { + username: userRes.user.username + }); + history.pushState(null, '/'); + fetchDataForDashboard({ + jwt: res.body.token, + username: userRes.user.username, + dispatch, + done + }); + }); + }); + } + } + }); +}; diff --git a/app/scripts/actions/attemptSignup.js b/app/scripts/actions/attemptSignup.js new file mode 100644 index 0000000000..197e35bc5d --- /dev/null +++ b/app/scripts/actions/attemptSignup.js @@ -0,0 +1,30 @@ +'use strict'; + +import { Auth } from 'hub-js-sdk'; +import type FluxibleActionContext from '../../../flow-libs/fluxible'; +var debug = require('debug')('hub:actions:attemptSignup'); + +type SignupPayload = { + username: String; + password: String; + email: String; +}; + +module.exports = function(actionContext: FluxibleActionContext, + payload: SignupPayload, + done: Function) { + actionContext.dispatch('SIGNUP_ATTEMPT_START'); + Auth.signup(payload, function(err, res) { + if (err) { + debug('error', err); + if (res.badRequest){ + actionContext.dispatch('SIGNUP_BAD_REQUEST', res.body); + } else { + done(); + } + actionContext.dispatch('SIGNUP_CLEAR_PASSWORD'); + } else { + actionContext.dispatch('SIGNUP_SUCCESS'); + } + }); +}; diff --git a/app/scripts/actions/cancelBuild.js b/app/scripts/actions/cancelBuild.js new file mode 100644 index 0000000000..9ba44d0703 --- /dev/null +++ b/app/scripts/actions/cancelBuild.js @@ -0,0 +1,20 @@ +'use strict'; +import { Builds } from 'hub-js-sdk'; +import has from 'lodash/object/has'; +const debug = require('debug')('hub:actions:cancelBuild'); + +export default function cancelBuildAction(actionContext, {JWT, id, namespace, name, build_code}, done) { + actionContext.dispatch('CANCEL_BUILD_START', id); + Builds.cancelBuild(JWT, { namespace, name, build_code }, (err, res) => { + if(err) { + debug('failed'); + const detail = has(err.response.body, 'detail') ? err.response.body : ''; + actionContext.dispatch('CANCEL_BUILD_ERROR', { id, detail: err.response.body.detail }); + + } else { + debug('succeeded'); + actionContext.dispatch('CANCEL_BUILD_SUCCESS', id); + } + done(); + }); +} diff --git a/app/scripts/actions/changePassword.js b/app/scripts/actions/changePassword.js new file mode 100644 index 0000000000..d38d26dede --- /dev/null +++ b/app/scripts/actions/changePassword.js @@ -0,0 +1,48 @@ +'use strict'; + +import { getToken, logout } from 'hub-js-sdk/src/Hub/SDK/Auth'; +import { changePassword } from 'hub-js-sdk/src/Hub/SDK/Users'; +import async from 'async'; +import request from 'superagent'; +var debug = require('debug')('hub:actions:changePassword'); + +var changePassAction = function({ dispatch, history }, + { + JWT, username, oldpassword, newpassword + }) { + changePassword( + JWT, + {username, oldpassword, newpassword}, + (err, res) => { + + if (err) { + debug('error', err); + dispatch('RESET_PASSWORD_ERROR', res.body); + } else if (res.ok) { + dispatch('CHANGE_PASS_SUCCESS'); + async.parallel([ + function(callback) { + logout(JWT, function(outErr, outRes) { + if (outErr) { + debug('outErr', outErr); + dispatch('LOGOUT_ERROR', outErr); + } else if (outRes.ok) { + dispatch('CHANGE_PASS_CLEAR', {}); + history.pushState(null, '/account/password-reset-confirm/success/'); + dispatch('LOGOUT'); + } + }); + }, + function(callback) { + request.post('/attempt-logout/') + .end(callback); + }], + function(asyncErr, asyncRes) { + + }); + } + } + ); +}; + +module.exports = changePassAction; diff --git a/app/scripts/actions/clearChangePasswordStore.js b/app/scripts/actions/clearChangePasswordStore.js new file mode 100644 index 0000000000..2ec0e9b123 --- /dev/null +++ b/app/scripts/actions/clearChangePasswordStore.js @@ -0,0 +1,8 @@ +/* @flow */ +'use strict'; + +import type FluxibleActionContext from '../../../flow-libs/fluxible'; + +module.exports = function(actionContext: FluxibleActionContext) { + actionContext.dispatch('CHANGE_PASS_CLEAR', {}); +}; diff --git a/app/scripts/actions/clearCloudCoupon.js b/app/scripts/actions/clearCloudCoupon.js new file mode 100644 index 0000000000..88147ed0f0 --- /dev/null +++ b/app/scripts/actions/clearCloudCoupon.js @@ -0,0 +1,5 @@ +'use strict'; + +export default (actionContext) => { + actionContext.dispatch('CLEAR_CLOUD_COUPON'); +}; diff --git a/app/scripts/actions/clearLoginForm.js b/app/scripts/actions/clearLoginForm.js new file mode 100644 index 0000000000..c677a394a0 --- /dev/null +++ b/app/scripts/actions/clearLoginForm.js @@ -0,0 +1,8 @@ +/* @flow */ +'use strict'; + +import type FluxibleActionContext from '../../../flow-libs/fluxible'; + +module.exports = function(actionContext: FluxibleActionContext) { + actionContext.dispatch('LOGIN_CLEAR', {}); +}; diff --git a/app/scripts/actions/clearMemberError.js b/app/scripts/actions/clearMemberError.js new file mode 100644 index 0000000000..6fab18e28e --- /dev/null +++ b/app/scripts/actions/clearMemberError.js @@ -0,0 +1,5 @@ +'use strict'; + +export default function(actionContext) { + actionContext.dispatch('CLEAR_MEMBER_ERROR'); +} diff --git a/app/scripts/actions/cloudNamespaceChange.js b/app/scripts/actions/cloudNamespaceChange.js new file mode 100644 index 0000000000..2947f1bb80 --- /dev/null +++ b/app/scripts/actions/cloudNamespaceChange.js @@ -0,0 +1,78 @@ +'use strict'; + +const debug = require('debug')('hub:actions:cloudNamespaceChange'); +import async from 'async'; +import _ from 'lodash'; + +import { + Billing, + Users + } from 'hub-js-sdk'; + +//GETs current HUB subscription +function _getBillingSubscriptions({JWT, namespace}) { + return (callback) => { + Billing.getBillingSubscriptions(JWT, namespace, (err, res) => { + if(err) { + callback(null, {}); + } else { + let subscriptions = res.body; + + // the assumption is that there is at most one subscription right now + let subscription = _.head(subscriptions); + callback(null, subscription); + } + }); + }; +} + +function _getBillingAccount({JWT, namespace}) { + return (callback) => { + Billing.getBillingAccount(JWT, namespace, (err, res) => { + if(err) { + debug('getBillingAccount', 'no billing account connected'); + callback(null, {}); + } else { + callback(null, res.body); + } + }); + }; +} + +function _getBillingInfo({JWT, namespace}) { + return (callback) => { + Billing.getBillingInfo(JWT, namespace, (err, res) => { + if(err) { + debug('getBillingInfo', 'no billing account connected'); + callback(null, {}); + } else { + callback(null, res.body); + } + }); + }; +} + +export default function billingPlans(actionContext, {JWT, namespace}, done){ + actionContext.dispatch('RESET_CLOUD_BILLING_PLANS'); + async.parallel({ + userPlan: _getBillingSubscriptions({JWT, namespace}), + accountInfo: _getBillingAccount({JWT, namespace}), + billingInfo: _getBillingInfo({JWT, namespace}) + }, function(err, results){ + let { userPlan, accountInfo, billingInfo } = results; + actionContext.dispatch('RECEIVE_CLOUD_BILLING_INFO', { + billingInfo: billingInfo, + accountInfo: accountInfo, + currentPlan: userPlan + }); + let values = _.merge({}, billingInfo, { + account_first: accountInfo.first_name, + account_last: accountInfo.last_name, + company_name: accountInfo.company_name, + email: accountInfo.email + }); + // TODO: Need to differentiate btw billingInfo/accountInfo first/last names + actionContext.dispatch('ENTERPRISE_PAID_POPULATE_FORM', values); + return done(); + }); +} diff --git a/app/scripts/actions/common/onChangeUtil.js b/app/scripts/actions/common/onChangeUtil.js new file mode 100644 index 0000000000..5672dd0bac --- /dev/null +++ b/app/scripts/actions/common/onChangeUtil.js @@ -0,0 +1,48 @@ +'use strict'; + +import updateFormField from './updateFormField'; + +/** + * This function is a generic `onChange` Handler. Typical use is as follows: + * + * ``` + * import onChange from 'this/file/here'; + * + * createClass({ + * _onChange: onChange({ storePrefix: 'ENTERPRISE_TRIAL' }), + * render() { + * return ( + * + * ) + * } + * }); + * ``` + * + * `function setComponentOnChangeHandler` is the one that is `bind`ed. + * + * storePrefix is `ENTERPRISE_TRIAL` in the stores handlers object: + * + * ``` + * handlers: { + * ENTERPRISE_TRIAL_CLEAR_FORM: '_clearForm', + * ENTERPRISE_TRIAL_UPDATE_FIELD_WITH_VALUE: '_updateFieldWithValue', + * ENTERPRISE_TRIAL_ATTEMPT_START: '_enterpriseTrialAttemptStart', + * ENTERPRISE_TRIAL_BAD_REQUEST: '_badRequest', + * ENTERPRISE_TRIAL_SUCCESS: '_enterpriseTrialSuccess' + * } + * ``` + * + * e is the onChange event from the form field. + */ + +export default function({ storePrefix }) { + return function setComponentOnChangeHandler(fieldKey) { + return (e) => { + this.context.executeAction(updateFormField({ storePrefix }), { + fieldKey, + fieldValue: e.target.value + }); + }; + }; +} + diff --git a/app/scripts/actions/common/updateFormField.js b/app/scripts/actions/common/updateFormField.js new file mode 100644 index 0000000000..69eb40e138 --- /dev/null +++ b/app/scripts/actions/common/updateFormField.js @@ -0,0 +1,50 @@ +'use strict'; + +/** + * This function dispatches an event that updates a form backed by a form store. + * @param {string} storePrefix - the prefix for the form store events. This + * scopes the event to a particular store. + * + * The storePrefix usually takes the form of a shortened version of the store's + * name. In the case of the `EnterpriseTrialFormStore`, given handlers as such: + * + * ``` + * handlers: { + * ENTERPRISE_TRIAL_UPDATE_FIELD_WITH_VALUE: 'updateFormField' + * } + * ``` + * + * The store prefix for the preceeding example is `ENTERPRISE_TRIAL`. + * + * --- + * + * Usage of this module in a component might look like: + * + * ``` + * import updateFormField from 'wherever'; + * + * + * var Whatever = createClass({ + * _onChange(fieldKey) { + * return (e) => { + * this.context.executeAction(updateFormField({ + * storePrefix: 'ENTERPRISE_TRIAL' + * }), { + * fieldKey, + * fieldValue: e.target.value + * }); + * } + * }, + * render() { + * return ( + * + * ) + * } + * }); + */ +export default function({storePrefix}){ + return (actionContext, { fieldKey, fieldValue}) => { + actionContext.dispatch(`${storePrefix}_UPDATE_FIELD_WITH_VALUE`, { fieldKey, fieldValue }); + }; +} + diff --git a/app/scripts/actions/common/validateBillingInfo.js b/app/scripts/actions/common/validateBillingInfo.js new file mode 100644 index 0000000000..070f6eed95 --- /dev/null +++ b/app/scripts/actions/common/validateBillingInfo.js @@ -0,0 +1,7 @@ +'use strict'; + +export default function({storePrefix}){ + return (actionContext, fieldErrors) => { + actionContext.dispatch(`${storePrefix}_ERRORS`, fieldErrors); + }; +} diff --git a/app/scripts/actions/convertToOrganization.js b/app/scripts/actions/convertToOrganization.js new file mode 100644 index 0000000000..85a4ed4af5 --- /dev/null +++ b/app/scripts/actions/convertToOrganization.js @@ -0,0 +1,33 @@ +'use strict'; + +import { Users } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:convertToOrganization'); + +module.exports = function(actionContext, {jwt, username, newOwner}, done) { + Users.convertToOrganization(jwt, username, newOwner, function(err, res) { + if (err) { + debug('error', err); + if (res.badRequest){ + let message; + if (res.body) { + message = res.body; + } else { + try { + message = JSON.parse(res.text); + } catch(e) { + message = { + error: 'Your account could not be converted. Make sure you are not a member of another group and that the new owner username exists' + }; + } + } + actionContext.dispatch('CONVERT_TO_ORG_BAD_REQUEST', message); + done(); + } else { + done(); + } + } else { + actionContext.history.push('/login/'); + actionContext.dispatch('LOGOUT', null); + } + }); +}; diff --git a/app/scripts/actions/createAutobuild.js b/app/scripts/actions/createAutobuild.js new file mode 100644 index 0000000000..4daa9ee69d --- /dev/null +++ b/app/scripts/actions/createAutobuild.js @@ -0,0 +1,97 @@ +'use strict'; +import { Autobuilds } from 'hub-js-sdk'; +const getRepository = require('hub-js-sdk').Repositories.getRepo; +import async from 'async'; +import has from 'lodash/object/has'; +import omit from 'lodash/object/omit'; +const debug = require('debug')('hub:actions:createAutobuild'); + +/** + * + * @param actionContext + * @param jwt + * @param autobuildConfig + * {name, namespace, description, active, is_automated, + * provider, is_private, dockerfileLocation, sourceName, sourceType} + */ +export default function(actionContext, {JWT, autobuildConfig}) { + /** + * CreateAutoBuildSerializer { + * vcs_repo_name (string), + * provider (choice) = ['github' or 'bitbucket'], + * dockerhub_repo_name (string), + * is_private (boolean), + * build_tags (array[string]) + * description? + * } + * @param cb + * @private + */ + var _createAutobuild = function(cb) { + var bTags = autobuildConfig.tags; + for (var i = 0; i < bTags.length; ++i) { + bTags[i] = omit(bTags[i], 'id'); + } + var ab = { + name: autobuildConfig.name, + namespace: autobuildConfig.namespace, + description: autobuildConfig.description, + vcs_repo_name: autobuildConfig.build_name, + provider: autobuildConfig.provider, + dockerhub_repo_name: autobuildConfig.namespace + '/' + autobuildConfig.name, + is_private: autobuildConfig.is_private, + active: autobuildConfig.active, + build_tags: bTags + }; + Autobuilds.createAutomatedBuild(JWT, ab, function(err, res) { + if (err) { + debug('createAutomatedBuild error', err); + if (err.response.badRequest) { + //Check fields and set a better response for fields + const { detail } = err.response.body; + if(detail) { + actionContext.dispatch('AUTOBUILD_BAD_REQUEST', detail); + } + } else if (err.response.unauthorized) { + actionContext.dispatch('AUTOBUILD_UNAUTHORIZED', err); + } else if (err.response.serverError) { + actionContext.dispatch('AUTOBUILD_ERROR', err); + } + cb(err); + } else if (res.body) { + var repoUrl = res.body.docker_url; + actionContext.dispatch('AUTOBUILD_SUCCESS'); + cb(null, repoUrl); + } + }); + }; + + /** + * + * @param repoUrl something like arunan/d3 + * @param cb + * @private + */ + var _getRepository = function(repoUrl, cb) { + getRepository(JWT, repoUrl, function(err, res) { + if (err) { + debug('getRepository error', err); + actionContext.dispatch('GET_REPOSITORY_ERROR'); + cb(err); + } else if (res.body) { + cb(null, res.body); + } + }); + }; + + actionContext.dispatch('ATTEMPTING_AUTOBUILD_CREATION'); + async.waterfall([ + _createAutobuild, + _getRepository + ], function(err, result) { + if (!err) { + actionContext.dispatch('RECEIVE_REPOSITORY', result); + } + }); + +} diff --git a/app/scripts/actions/createNewLicenseProduction.js b/app/scripts/actions/createNewLicenseProduction.js new file mode 100644 index 0000000000..4638e58142 --- /dev/null +++ b/app/scripts/actions/createNewLicenseProduction.js @@ -0,0 +1,82 @@ +'use strict'; + +import { Billing } from 'hub-js-sdk'; +const debug = require('debug')('hub:actions:createNewLicenseProduction'); + +export default function(actionContext, { + address1, + address2, + city, + country, + cvv, + first_name, + JWT, + last_name, + month, + number, + package_name, + partnervalue, + postal_code, + state, + username, + year +}, done) { + actionContext.dispatch('ENTERPRISE_PAID_ATTEMPT_START'); + try { + /** + * This will only run where window is defined. ie: the browser + * It throws an exception in node + */ + window.recurly.configure(process.env.RECURLY_PUBLIC_KEY); + } catch(e) { + debug('error', e); + } + + var recurlyData = { + first_name, + last_name, + month, + year, + cvv, + number, + address1, + address2, + city, + country, + postal_code, + state + }; + + window.recurly.token(recurlyData, function(recurlyErr, token) { + if (recurlyErr) { + debug('recurly error', recurlyErr.fields); + actionContext.dispatch('ENTERPRISE_PAID_GET_RECURLY_ERROR', recurlyErr.fields); + done(); + } else { + debug('creating License'); + Billing.createLicense(JWT, { + username, + package: package_name, + payment_token: token.id, + first_name, + last_name, + postal_code, + partner_value: partnervalue + }, (err, results) => { + if(err) { + if(err.response.badRequest) { + const { detail } = err.response.body; + if(detail) { + actionContext.dispatch('ENTERPRISE_PAID_BAD_REQUEST', detail); + } + } else { + actionContext.dispatch('ENTERPRISE_PAID_API_ERROR'); + } + } else { + actionContext.dispatch('ENTERPRISE_PAID_CLEAR_FORM'); + actionContext.history.push('/account/licenses/'); + } + }); + } + }); +} diff --git a/app/scripts/actions/createNewLicenseTrial.js b/app/scripts/actions/createNewLicenseTrial.js new file mode 100644 index 0000000000..09abbb3360 --- /dev/null +++ b/app/scripts/actions/createNewLicenseTrial.js @@ -0,0 +1,53 @@ +'use strict'; + +import { Billing } from 'hub-js-sdk'; +const debug = require('debug')('hub:actions:createNewLicenseTrial'); + +export default function(actionContext, { + JWT, + companyName, + country, + email, + firstName, + jobFunction, + lastName, + namespace, + packageName, + phoneNumber, + state +}) { + actionContext.dispatch('ENTERPRISE_TRIAL_ATTEMPT_START'); + const licenseData = { + company_name: companyName, + country, + email, + first_name: firstName, + job_function: jobFunction, + last_name: lastName, + package: packageName, + phone_number: phoneNumber, + username: namespace + }; + + // state is an optional field + if(state) { + licenseData.state = state; + } + + Billing.createLicense(JWT, licenseData, (err, results) => { + if(err) { + debug('error', err); + if(err.response.badRequest) { + const { detail } = err.response.body; + if(detail) { + actionContext.dispatch('ENTERPRISE_TRIAL_BAD_REQUEST', detail); + } + } else { + actionContext.dispatch('ENTERPRISE_TRIAL_FACEPALM'); + } + } else { + actionContext.dispatch('ENTERPRISE_TRIAL_SUCCESS'); + actionContext.history.push(`/enterprise/trial/success/?namespace=${namespace}&step=1`); + } + }); +} diff --git a/app/scripts/actions/createOrgTeam.js b/app/scripts/actions/createOrgTeam.js new file mode 100644 index 0000000000..a5371050ff --- /dev/null +++ b/app/scripts/actions/createOrgTeam.js @@ -0,0 +1,76 @@ +'use strict'; +import { Orgs } from 'hub-js-sdk'; +import async from 'async'; +const debug = require('debug')('hub:actions:createOrgTeam'); + +module.exports = function(actionContext, {jwt, orgName, team}) { + + var _createTeam = function(cb) { + Orgs.createTeam(jwt, orgName, team, function(err, res) { + if (err) { + debug('createTeam error', err); + cb(err); + var errRes = err.response; + if (errRes.badRequest) { + actionContext.dispatch('TEAM_BAD_REQUEST', err); + } else if (errRes.unauthorized) { + actionContext.dispatch('TEAM_UNAUTHORIZED', err); + } else { + actionContext.dispatch('TEAM_ERROR', err); + } + } else { + if (res.body) { + //send team name to get members for the team + actionContext.dispatch('CREATE_ORG_TEAM', res.body); + actionContext.dispatch('RECEIVE_DASHBOARD_ORG_TEAM', res.body); + cb(null, res.body.name); + } + } + }); + }; + + var _getMembers = function(teamName, cb) { + Orgs.getMembers(jwt, orgName, teamName, function(err, res) { + if (err) { + debug('getMembers error', err); + cb(err); + } else { + actionContext.dispatch('RECEIVE_DASHBOARD_TEAM_MEMBERS', res.body); + cb(null, 'done'); + } + }); + }; + + var _createTeamWrapper = function(cb) { + async.waterfall([ + _createTeam, + _getMembers + ], function(err, res) { + cb(null, res); + } + ); + }; + + var _getTeamsForOrg = function(cb) { + Orgs.getTeams(jwt, orgName, function(err, res) { + if (err) { + debug('getTeams error', err); + cb(err); + } else { + if (res.body) { + cb(null, res.body); + } + } + }); + }; + + async.series([ + _createTeamWrapper, + _getTeamsForOrg + ], function(err, results) { + if (results[0] && results[1]) { + actionContext.dispatch('RECEIVE_DASHBOARD_ORG_TEAMS', results[1]); + actionContext.history.push(`/u/${orgName}/dashboard/teams/?team=${team.name}`); + } + }); +}; diff --git a/app/scripts/actions/createOrganization.js b/app/scripts/actions/createOrganization.js new file mode 100644 index 0000000000..4e2e9d49bd --- /dev/null +++ b/app/scripts/actions/createOrganization.js @@ -0,0 +1,65 @@ +'use strict'; + +var debug = require('debug')('hub:actions:createOrganization'); +import async from 'async'; +import { Orgs, Users } from 'hub-js-sdk'; +//Organization Object +/* + { + id (string), + orgname (regex), + full_name (string), + location (string): Your Location on the world, + company (string): Your organization's name, + profile_url (url): Your place on the web, + gravatar_email (email): This address will define which picture of you is shown, + is_active (boolean): Designates whether user is active. Unselect this instead of deleting accounts., + date_joined (datetime), + gravatar_url (string) + } + */ +export default function(actionContext, {jwt, organization}) { + + var _createOrg = function(cb) { + Orgs.createOrg(jwt, organization, function(err, res) { + if (err) { + if(res && res.badRequest) { + debug('createOrg error', err); + actionContext.dispatch('ADD_ORG_BAD_REQUEST', res.body); + cb(err, res); + } else { + actionContext.dispatch('ADD_ORG_FACEPALM'); + cb(err, res); + } + } else { + cb(null, res.body); + } + }); + }; + + //Get orgs for user + var _getOrgsForUser = function(cb) { + Users.getOrgsForUser(jwt, function(err, res) { + if (err) { + debug('getOrgsForUser error', err); + cb(err, {}); + } else { + actionContext.dispatch('CURRENT_USER_ORGS', res.body.results); + cb(null, res.body.results); + } + }); + return {}; + }; + + async.series([ + _createOrg, + _getOrgsForUser + ], function (err, results) { + if(err) { + debug('final callback error', err); + } else { + actionContext.dispatch('CREATED_ORGANIZATION', {newOrg: results[0], userOrgs: results[1]}); + actionContext.history.push(`/u/${organization.orgname}/dashboard/teams/`); + } + }); +} diff --git a/app/scripts/actions/createRepository.js b/app/scripts/actions/createRepository.js new file mode 100644 index 0000000000..f2463a93dc --- /dev/null +++ b/app/scripts/actions/createRepository.js @@ -0,0 +1,30 @@ +/* @flow */ +'use strict'; + +var createRepo = require('hub-js-sdk').Repositories.createRepository; +var debug: Function = require('debug')('hub:actions:createRepository'); + +var createRepository = function(actionContext: {dispatch: Function}, + {jwt, repository}: {jwt: string; repository: any}) { + createRepo(jwt, repository, (err, res) => { + if (err) { + + if(res && res.badRequest) { + actionContext.dispatch('CREATE_REPO_BAD_REQUEST', res.body); + } else { + actionContext.dispatch('CREATE_REPO_ERROR', err); + } + + } else { + + if (res && res.ok) { + actionContext.dispatch('RECEIVE_REPOSITORY', res.body); + actionContext.dispatch('CREATE_REPO_CLEAR_FORM'); + actionContext.history.push(`/r/${repository.namespace}/${repository.name}/`); + } + + } + }); +}; + +module.exports = createRepository; diff --git a/app/scripts/actions/createSubscription.js b/app/scripts/actions/createSubscription.js new file mode 100644 index 0000000000..1c817d3ab3 --- /dev/null +++ b/app/scripts/actions/createSubscription.js @@ -0,0 +1,374 @@ +'use strict'; +const request = require('superagent'); +import { Billing } from 'hub-js-sdk'; +/*global UpdateBillingInfoPayload */ +import map from 'lodash/collection/map'; +import has from 'lodash/object/has'; +import merge from 'lodash/object/merge'; +import isString from 'lodash/lang/isString'; +import async from 'async'; +var debug = require('debug')('hub:actions:createBillingSubscription'); + +import _encodeForm from '../components/utils/encodeForm.js'; +import { + ACCOUNT, + BILLING, + BILLFORWARD_ACCOUNT_ID, + STRIPE_URL, + STRIPE_STAGE_TOKEN, + STRIPE_PROD_TOKEN, + BF_STAGE_URL, + BF_PROD_URL, + BF_STAGE_TOKEN, + BF_PROD_TOKEN, + v4BillingProfile +} from 'stores/common/Constants.js'; + +const handleResponse = ({ callback, dispatch, type }) => (err, res) => { + if (err) { + let message = 'There was an error creating your subscription. Please check your information and try again.'; + if (res && res.body) { + message = isString(res.body.detail) ? res.body.detail : res.body.message; + } + dispatch('BILLING_SUBMIT_ERROR', message); + callback(err, res); + } else if (res.ok) { + if (type === ACCOUNT) { + dispatch('BILLING_ACCOUNT_EXISTS'); + } else if (type === BILLING) { + dispatch('BILLING_INFO_EXISTS'); + } + const billforward_id = res.header[BILLFORWARD_ACCOUNT_ID]; + callback(null, { billforward_id }); + } +}; + +//-------------------------------------------------------------------------- +// CREATE A BILLING PAYMENT METHOD ON BILLFORWAD WITH STRIPE +//-------------------------------------------------------------------------- +/* + NOTE: Stripe's api only accepts x-www-form-urlencoded data + NOTE: 'billforwardCreatePayment' is chained to this function and requires + `billforward-id`. The `billforward-id` is being passed through to the + create payment function via the META here. +*/ +function _createCardToken({ + cvc, + exp_month, + exp_year, + name_first, + name_last, + number +}, cb) { + const stripeToken = process.env.ENV === 'production' ? + STRIPE_PROD_TOKEN : STRIPE_STAGE_TOKEN; + const card = { + name: `${name_first} ${name_last}`, + cvc, + number, + exp_month, + exp_year + }; + const encoded = _encodeForm({ card }); + request.post(STRIPE_URL) + .accept('application/json') + .type('application/x-www-form-urlencoded') + .set('Authorization', 'Bearer ' + stripeToken) + .send(encoded) + .end(cb); +} + +/* + NOTE: This is the call to billforward that actually adds a payment method + to a user's billing profile. + This call requires a billforward-id (accountID) which is DIFFERENT than the + docker_id - Hence why 'billingCreatePaymentMethod' is NOT being wrapped by + the getAccountFromNamespace decorator. +*/ +function _billforwardAddPaymentMethod({ + '@type': type, + accountID, + cardID, + defaultPaymentMethod, + gateway, + stripeToken +}, cb) { + const billforwardUrl = process.env.ENV === 'production' ? + BF_PROD_URL : BF_STAGE_URL; + const billforwardToken = process.env.ENV === 'production' ? + BF_PROD_TOKEN : BF_STAGE_TOKEN; + + request.post(billforwardUrl) + .accept('application/json') + .type('application/json') + .set('Authorization', 'Bearer ' + billforwardToken) + .send({ + '@type': type, + accountID, + cardID, + defaultPaymentMethod, + gateway, + stripeToken + }) + .end(cb); +} + +function billingStripeCreatePaymentMethod(dispatch, { + billforward_id, + cvc, + exp_month, + exp_year, + name_first, + name_last, + number +}, cb) { + const cardData = { + cvc, + exp_month, + exp_year, + name_first, + name_last, + number + }; + /* + NOTE: + Creating a payment method requires 2 parts + 1 - Generating a token from Stripe's api + 2 - Sending generated token to Billforward's api to attach payment method to + a relevant billing profile. + */ + _createCardToken(cardData, (stripeErr, stripeRes) => { + if (!stripeRes.ok) { + let message = 'There was an error submitting your card information. Please check your information and try again.'; + if (stripeRes.error && stripeRes.error.message) { + message = stripeRes.error.message; + } + dispatch('BILLING_SUBMIT_ERROR', message); + cb(message); + } else { + const tokenObject = stripeRes && stripeRes.body; + const stripeToken = tokenObject.id; + const cardID = tokenObject.card.id; + const accountID = billforward_id; + const bfData = { + '@type': 'StripeAuthCaptureRequest', + accountID, + cardID, + defaultPaymentMethod: true, + gateway: 'Stripe', + stripeToken + }; + _billforwardAddPaymentMethod(bfData, handleResponse({ callback: cb, dispatch, type: BILLING })); + } + }); +} + +//-------------------------------------------------------------------------- +// SAVE ADDRESS INFORMATION IN BILLFORWARD +//-------------------------------------------------------------------------- +const updateV4BillingProfile = ({ JWT, profileData, docker_id }, cb) => { + const v4BillingAPI = v4BillingProfile(docker_id); + request + .patch(v4BillingAPI) + .accept('application/json') + .type('application/json') + .set('Authorization', 'Bearer ' + JWT) + .send(profileData) + .end(cb); +}; + +//-------------------------------------------------------------------------- +// CREATE NEW BILLING ACCOUNT/PROFILE & SUBSCRIPTION +//-------------------------------------------------------------------------- +function createSubscription(actionContext, { + JWT, + user, + accountInfo, + billingInfo, + card, + isNewBillingAccount, + billforwardId: existingBfId, + plan_code, + package_code +}, done) { + actionContext.dispatch('BILLING_SUBMIT_START'); + const { + first_name, + last_name, + address1, + address2, + country, + state, + zip, + city + } = billingInfo; + const { + number, + cvv, + month, + year, + last_four, + type, + coupon_code, + coupon + } = card; + + let isOrg = false; + let username = ''; + if (has(user, 'username')) { + username = user.username; + } else if (has(user, 'orgname')) { + username = user.orgname; + isOrg = true; + } + + var subscriptionData = { + first_name, + last_name, + email: accountInfo.email, + username, + plan: plan_code, + package: package_code + }; + if (coupon_code) { + // coupon code is an optional field + subscriptionData.coupon_code = coupon_code; + } + + async.waterfall([ + function(callback) { // create billing profile + const account = merge({}, accountInfo, {username}); + /* + NOTE: + Can only get to this action IF + 1) You don't have a billing profile account (Create the billing account) + 2) You don't have any billing payment information - but you have a billing profile account (Update the account) + */ + if (isNewBillingAccount) { + Billing.createBillingAccount(JWT, account, handleResponse({callback, dispatch: actionContext.dispatch, type: ACCOUNT})); + // on success - callback(null, { billforward_id }) + } else { + Billing.updateBillingAccount(JWT, username, account, handleResponse({callback, dispatch: actionContext.dispatch, type: ACCOUNT})); + } + }, + function({ billforward_id }, callback) { + const bfId = billforward_id || existingBfId; + if (bfId) { + // NOTE: Save billing address information + const profileData = { + first_name, + last_name, + addresses: [ + { + address_line_1: address1, + address_line_2: address2, + city, + province: state, + country, + post_code: zip, + primary_address: true + } + ] + }; + updateV4BillingProfile({ + JWT, + profileData, + docker_id: user.id + }, (err, res) => { + if (err) { + let message = 'There was an error saving your address information. Please check your information and try again.'; + if (res && res.body) { + message = isString(res.body.detail) ? res.body.detail : res.body.message; + } + actionContext.dispatch('BILLING_SUBMIT_ERROR', message); + callback(null); + return; + } + callback(null, {...res.body, billforward_id }); + }); + return; + } + // NOTE: Unecessary for non-migrated accounts - Creation of billingprofile will update the account data + callback(null, { billforward_id }); + }, + function({ billforward_id }, callback) { + const bfId = billforward_id || existingBfId; + if (bfId) { + // NOTE: If we have a billforward id - create payment Method via stripe + billingStripeCreatePaymentMethod(actionContext.dispatch, { + billforward_id: bfId, + cvc: cvv, + exp_month: month, + exp_year: year, + name_first: first_name, + name_last: last_name, + number + }, callback); + return; + } + // NOTE: If we do NOT have a billforward id - tokenize via recurly + const recurlyData = { + first_name, + last_name, + address1, + address2, + country, + state, + zip, + city, + number, + cvv, + month, + year, + coupon_code + }; + try { + /** + * This will only run where window is defined. ie: the browser + * It throws an exception in node + */ + window.recurly.configure(process.env.RECURLY_PUBLIC_KEY); + } catch(e) { + debug('error', e); + } + window.recurly.token(recurlyData, function(recurlyErr, token) { + if (recurlyErr) { + debug('recurly token error', recurlyErr.message); + actionContext.dispatch('GET_RECURLY_ERROR', recurlyErr); + callback(recurlyErr); + return; + } + callback(null, { recurlyToken: token.id }); + }); + }, + function({ recurlyToken }, callback) { + // NOTE: append the recurly token to the subscriptionData object; + subscriptionData.payment_token = recurlyToken; + /* + NOTE: Creating billing subscription will either + A) Use default card if billing account is migrated and we get a billforward id + B) Subscription Data will have a recurly token if no billforward id + IF Billing Info already exists: + - Stripe: Adds a new default card + - Recurly: we're posting with the recurly token which will update it + */ + Billing.createBillingSubscription(JWT, subscriptionData, handleResponse({callback, dispatch: actionContext.dispatch, type: BILLING})); + } + ], + function(err, res) { + if (err) { + debug(err); + done(); + } else { + if (isOrg) { + actionContext.history.push(`/u/${username}/dashboard/billing/`); + } else { + actionContext.history.push('/account/billing-plans/'); + } + actionContext.dispatch('BILLING_SUBMIT_SUCCESS'); + done(); + } + }); +} + +module.exports = createSubscription; diff --git a/app/scripts/actions/createTeamMembers.js b/app/scripts/actions/createTeamMembers.js new file mode 100644 index 0000000000..eee9225bad --- /dev/null +++ b/app/scripts/actions/createTeamMembers.js @@ -0,0 +1,66 @@ +'use strict'; + +var debug = require('debug')('hub:actions:createTeamMembers'); +import async from 'async'; +import { Orgs } from 'hub-js-sdk'; + +export default function(actionContext, {jwt, orgname, teamname, members}) { + + var _createMember = function(cb) { + var _makeMemberFuncArray = function(member) { + return function() { + // member.username can be either a valid email or a username + Orgs.createMember(jwt, orgname, teamname, member.username, function(err, res) { + if (err) { + debug('createMember error', err); + if (res.badRequest) { + actionContext.dispatch('TEAM_MEMBER_BAD_REQUEST', err); + } else if (res.unauthorized) { + actionContext.dispatch('TEAM_MEMBER_UNAUTHORIZED', err); + } else { + actionContext.dispatch('TEAM_MEMBER_ERROR', err); + } + } else { + if (res.ok) { + actionContext.dispatch('ORG_TEAM_CLEAR_ERROR_STATES'); + cb(null, res.body); + } + } + }); + }; + }; + var funcArray = []; + for(var i = 0; i < members.length; ++i) { + funcArray.push(_makeMemberFuncArray(members[i])); + } + async.parallel(funcArray, function(err, results) { + if (err) { + cb(err); + } else { + cb(null, results); + } + }); + }; + + //Get members for team + var _getMembersForTeam = function(cb) { + Orgs.getMembers(jwt, orgname, teamname, function(err, res) { + if (err) { + debug('getMembers error', err); + cb(err, {}); + } else { + actionContext.dispatch('RECEIVE_DASHBOARD_TEAM_MEMBERS', res.body); + cb(null, res.body.results); + } + }); + }; + + async.series([ + _createMember, + _getMembersForTeam + ], function (err, results) { + if(err) { + debug('final callback error', err); + } + }); +} diff --git a/app/scripts/actions/createWebhook.js b/app/scripts/actions/createWebhook.js new file mode 100644 index 0000000000..0915fe4f11 --- /dev/null +++ b/app/scripts/actions/createWebhook.js @@ -0,0 +1,27 @@ +'use strict'; +import async from 'async'; +import request from 'superagent'; +const debug = require('debug')('createWebhook'); + +// TODO: REMOVE. This is deprecated in favor of AddPipeline +module.exports = function(actionContext, { JWT, namespace, name, webhookName }) { + + request.post(`${process.env.REGISTRY_API_BASE_URL}/v2/repositories/${namespace}/${name}/webhooks/`) + .accept('application/json') + .set('Authorization', `JWT ${JWT}`) + .send({ + name: webhookName + }) + .end((err, results) => { + if(err) { + debug(err); + if(err.response.badRequest) { + debug('badrequest'); + } else { + debug('facepalm'); + } + } else { + actionContext.history.push(`/r/${namespace}/${name}/~/settings/webhooks/`); + } + }); +}; diff --git a/app/scripts/actions/delCollaborator.js b/app/scripts/actions/delCollaborator.js new file mode 100644 index 0000000000..3ffce1abb2 --- /dev/null +++ b/app/scripts/actions/delCollaborator.js @@ -0,0 +1,24 @@ +'use strict'; +import { + Repositories as R + } from 'hub-js-sdk'; + +export default function delCollaborator(actionContext, { JWT, namespace, name, username }, done) { + actionContext.dispatch('DEL_COLLABORATORS_SET_LOADING', username); + R.delCollaborator(JWT, { namespace, name, username }, (err, res) => { + if(err) { + actionContext.dispatch('DEL_COLLABORATORS_SET_ERROR', username); + } else { + actionContext.dispatch('DEL_COLLABORATORS_SET_SUCCESS', username); + R.getCollaboratorsForRepo(JWT, `${namespace}/${name}`, (getErr, getRes) => { + if(getErr) { + // 'Org repositories do not have collaborators.' + actionContext.dispatch('COLLAB_RECEIVE_COLLABORATORS', {}); + } else { + actionContext.dispatch('COLLAB_RECEIVE_COLLABORATORS', getRes.body); + } + }); + } + done(); + }); +} diff --git a/app/scripts/actions/delTeamCollaborator.js b/app/scripts/actions/delTeamCollaborator.js new file mode 100644 index 0000000000..cda330a123 --- /dev/null +++ b/app/scripts/actions/delTeamCollaborator.js @@ -0,0 +1,24 @@ +'use strict'; +import { + Repositories as R + } from 'hub-js-sdk'; + +export default function delCollaborator(actionContext, { JWT, namespace, name, group_id }, done) { + actionContext.dispatch('DEL_COLLABORATORS_SET_LOADING', group_id); + R.delTeamCollaborator(JWT, { namespace, name, group_id }, (err, res) => { + if(err) { + actionContext.dispatch('DEL_COLLABORATORS_SET_ERROR', group_id); + } else { + actionContext.dispatch('DEL_COLLABORATORS_SET_SUCCESS', group_id); + R.getTeamCollaboratorsForRepo(JWT, `${namespace}/${name}`, (getErr, getRes) => { + if (getErr) { + // 'User repository does not have any teams yet' + actionContext.dispatch('COLLAB_RECEIVE_TEAMS', {}); + } else { + actionContext.dispatch('COLLAB_RECEIVE_TEAMS', getRes.body); + } + }); + } + done(); + }); +} diff --git a/app/scripts/actions/deleteAutoBuildPushTriggerItem.js b/app/scripts/actions/deleteAutoBuildPushTriggerItem.js new file mode 100644 index 0000000000..e3ff3c61bb --- /dev/null +++ b/app/scripts/actions/deleteAutoBuildPushTriggerItem.js @@ -0,0 +1,11 @@ +'use strict'; + +var debug = require('debug')('hub:actions:deleteAutoBuildPushTriggerItem'); + +export default function(actionContext, {isNew, index}) { + if (isNew) { + actionContext.dispatch('DELETE_AUTOBUILD_NEW_TAG_ITEM', index); + } else { + actionContext.dispatch('DELETE_AUTOBUILD_PUSH_TRIGGER_ITEM', index); + } +} diff --git a/app/scripts/actions/deleteEmail.js b/app/scripts/actions/deleteEmail.js new file mode 100644 index 0000000000..aa8db4b2f0 --- /dev/null +++ b/app/scripts/actions/deleteEmail.js @@ -0,0 +1,38 @@ +'use strict'; + +import sortByOrder from 'lodash/collection/sortByOrder'; +import { series, each } from 'async'; +import { + Emails + } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:deleteEmail'); + +function updateEmailSettings({ dispatch }, + {JWT, delEmailID, username}, + done) { + dispatch('START_SAVE_ACTION'); + Emails.deleteEmailByID(JWT, + delEmailID, + (err, res) => { + if (err) { + return debug('deleteEmailByID error', err); + } + Emails.getEmailsForUser(JWT, username, function(emailErr, emailRes){ + if (emailErr) { + debug('getEmailsForUser error', emailErr); + dispatch('FINISH_SAVE_ACTION'); + return done(); + } + var emails = emailRes.body.results; + var sortedEmails = sortByOrder(emails, + ['primary', 'verified'], + [false, false]); + dispatch('RECEIVE_EMAILS', { + emails: sortedEmails + }); + dispatch('FINISH_SAVE_ACTION'); + }); + }); +} + +module.exports = updateEmailSettings; diff --git a/app/scripts/actions/deleteEmailNotifs.js b/app/scripts/actions/deleteEmailNotifs.js new file mode 100644 index 0000000000..3cae021514 --- /dev/null +++ b/app/scripts/actions/deleteEmailNotifs.js @@ -0,0 +1,46 @@ +'use strict'; + +var debug = require('debug')('hub:actions:deleteEmailNotifs'); +import async from 'async'; + +import { Notifications } from 'hub-js-sdk'; + +var deleteEmailNotifs = function(actionContext, {jwt, notificationID}) { + var _delNotificationSettings = function(cb) { + Notifications.deleteNotificationSubscription(jwt, notificationID, function(err, res) { + if (err) { + debug('deleteNotificationSubscription error', err); + cb(err); + } else if (res.ok) {//TODO:Weird that 204 no content is sent on success + return cb(null, res); + } + }); + }; + + var _getNotificationSettings = function(cb) { + //all good, do the next call to get the notifications + Notifications.getNotificationSubscriptions(jwt, function(err, res) { + if (err) { + debug('getNotificationSubscriptions error', err); + cb(err); + } else { + cb(null, res.body.results); + } + }); + }; + + async.series([ + _delNotificationSettings, + _getNotificationSettings + ], function (err, results) { + if (err) { + actionContext.dispatch('SAVE_NOTIFICATIONS_ERROR'); + debug('final callback error', err); + } else if (results[1]) { + actionContext.dispatch('SAVE_NOTIFICATIONS_SUCCESS'); + actionContext.dispatch('RECEIVE_NOTIFICATIONS', results[1]); + } + }); +}; + +module.exports = deleteEmailNotifs; diff --git a/app/scripts/actions/deletePipeline.js b/app/scripts/actions/deletePipeline.js new file mode 100644 index 0000000000..e04e8be1cb --- /dev/null +++ b/app/scripts/actions/deletePipeline.js @@ -0,0 +1,25 @@ +'use strict'; + +import request from 'superagent'; + +export default function deletePipeline({ + dispatch, history +}, { + jwt, namespace, name, slug +}, done) { + dispatch('DELETE_PIPELINE_ATTEMPTING'); + request.del(`${process.env.HUB_API_BASE_URL}/v2/repositories/${namespace}/${name}/webhook_pipeline/${slug}/`) + .set('Authorization', `JWT ${jwt}`) + .type('json') + .accept('json') + .end((err, res) => { + if(err) { + dispatch('DELETE_PIPELINE_FACEPALM'); + done(); + } else { + dispatch('DELETE_PIPELINE_SUCCESS'); + history.push(`/r/${namespace}/${name}/~/settings/webhooks/`); + done(); + } + }); +} diff --git a/app/scripts/actions/deleteRepo.js b/app/scripts/actions/deleteRepo.js new file mode 100644 index 0000000000..7f44b65c9e --- /dev/null +++ b/app/scripts/actions/deleteRepo.js @@ -0,0 +1,22 @@ +'use strict'; + +import { Repositories as Repos } from 'hub-js-sdk'; +const debug = require('debug')('hub:actions:deleteRepo'); + +export default function(actionContext, {jwt, repoShortName}) { + actionContext.dispatch('DELETE_REPO_ATTEMPT_START'); + Repos.deleteRepository(jwt, repoShortName, (err, res) => { + if (err) { + const { badRequest, body } = err.response; + if(badRequest) { + actionContext.dispatch('DELETE_REPO_BAD_REQUEST', body); + } else { + actionContext.dispatch('DELETE_REPO_ERROR', err); + } + } else { + if (res && res.ok) { + actionContext.history.push('/'); + } + } + }); +} diff --git a/app/scripts/actions/deleteRepoComment.js b/app/scripts/actions/deleteRepoComment.js new file mode 100644 index 0000000000..14b6c5e39d --- /dev/null +++ b/app/scripts/actions/deleteRepoComment.js @@ -0,0 +1,24 @@ +'use strict'; + +var debug = require('debug')('hub:actions:deleteRepoComment'); + +import { Repositories } from 'hub-js-sdk'; + +var deleteRepoComment = function(actionContext, {jwt, repoShortName, commentid}) { + Repositories.deleteRepoComment(jwt, repoShortName, commentid, function(delErr, delRes) { + if (delErr) { + debug('deleteRepoComment error', delErr); + } else if (delRes.ok) { + Repositories.getCommentsForRepo(jwt, repoShortName, function(getErr, getRes) { + if (getErr) { + debug('getCommentsForRepo error', getErr); + } else { + actionContext.dispatch('RECEIVE_REPO_COMMENTS', getRes.body); + } + }); + } + }); + +}; + +module.exports = deleteRepoComment; diff --git a/app/scripts/actions/downloadInvoice.js b/app/scripts/actions/downloadInvoice.js new file mode 100644 index 0000000000..edbd33b746 --- /dev/null +++ b/app/scripts/actions/downloadInvoice.js @@ -0,0 +1,24 @@ +'use strict'; +// Blob is a polyfill +require('vendor/Blob'); +import { saveAs } from 'vendor/FileSaver'; + +const debug = require('debug')('hub:actions:downloadInvoice'); + +module.exports = function(actionContext, { JWT, username, invoiceId }) { + const xhr = new XMLHttpRequest(); + xhr.open('GET', process.env.REGISTRY_API_BASE_URL + '/api/billing/v3/account/' + username + '/invoices/' + invoiceId + '/'); + xhr.setRequestHeader('Authorization', 'JWT ' + JWT); + xhr.responseType = 'blob'; + xhr.onload = function() { + if (xhr.status === 200) { + const blob = new Blob([xhr.response], { + type: 'application/pdf' + }); + saveAs(blob, 'docker_invoice_' + invoiceId + '.pdf'); + } else { + debug('error'); + } + }; + xhr.send(); +}; diff --git a/app/scripts/actions/downloadLicenseContent.js b/app/scripts/actions/downloadLicenseContent.js new file mode 100644 index 0000000000..fb823166d0 --- /dev/null +++ b/app/scripts/actions/downloadLicenseContent.js @@ -0,0 +1,31 @@ +'use strict'; + +const debug = require('debug')('hub:actions:downloadLicenseContent'); +import { Billing } from 'hub-js-sdk'; +// Blob is a polyfill +require('vendor/Blob'); +import { saveAs } from 'vendor/FileSaver'; + +export default function downloadLicenseContent(actionContext, { jwt, namespace, keyId }) { + actionContext.dispatch('ATTEMPTING_LICENSE_DOWNLOAD_START'); + Billing.getLicenseDownloadContent(jwt, { keyId, namespace }, (err, res) => { + if (err) { + debug('error', err); + if(err.response.badRequest) { + const { detail } = err.response.body; + if(detail) { + actionContext.dispatch('DOWNLOAD_LICENSE_CONTENT_BAD_REQUEST', detail); + } + } else { + actionContext.dispatch('DOWNLOAD_LICENSE_CONTENT_FACEPALM'); + } + } else { + actionContext.dispatch('RECEIVE_LICENSE_DOWNLOAD_CONTENT'); + //perform download as result of clicking download button + const blob = new Blob([res.text], { + type: 'text/plain;charset=utf-8' + }); + saveAs(blob, `docker_subscription.lic`); + } + }); +} diff --git a/app/scripts/actions/enterpriseAttemptLogin.js b/app/scripts/actions/enterpriseAttemptLogin.js new file mode 100644 index 0000000000..f8bbaa055d --- /dev/null +++ b/app/scripts/actions/enterpriseAttemptLogin.js @@ -0,0 +1,155 @@ +'use strict'; + +import { parallel, waterfall } from 'async'; +import sortBy from 'lodash/collection/sortBy'; +import { Auth, + Repositories as Repos, + Billing + } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:enterpriseAttemptLogin'); +import { getActivityFeed } from 'hub-js-sdk/src/Hub/SDK/Notifications'; +import { getUser } from 'hub-js-sdk/src/Hub/SDK/JWT'; +import { + getNamespacesForUser +} from 'hub-js-sdk/src/Hub/SDK/Users'; +import { getToken } from 'hub-js-sdk/src/Hub/SDK/Auth'; +import request from 'superagent'; + +//Get orgs for user +function _getOrgsForCurrentUser({jwt, username, dispatch}) { + return function(user, cb) { + getNamespacesForUser(jwt, function(err, res) { + if (err) { + debug('getNamespacesForUser', err); + cb(null); + } else { + // brute force the namespace reception so we can use a single action for now + dispatch('ENTERPRISE_TRIAL_RECEIVE_ORGS', res.body.namespaces); + dispatch('ENTERPRISE_PAID_RECEIVE_ORGS', res.body.namespaces); + cb(null, user); + } + }); + }; +} + +function _getBillingPlans({jwt, dispatch}) { + return function(user, cb) { + Billing.getPlans(jwt, 'personal', (err, res) => { + if(err) { + debug('getPlans error', err); + cb(null); + } else { + let plansList = res.body; + let sortedPlans = sortBy(plansList, 'display_order'); + dispatch('RECEIVE_BILLING_PLANS', { + plansList: sortedPlans + }); + cb(null, user); + } + }); + }; +} +function _getBillingAccount({jwt, dispatch}) { + return function(user, cb) { + Billing.getBillingAccount(jwt, user.username, (err, res) => { + if(err) { + debug('no billing account connected'); + cb(err, {}); + } else { + dispatch('RECEIVE_BILLING_INFO', { + accountInfo: res.body + }); + cb(null, user); + } + }); + }; +} + +function _getBillingInfo({jwt, dispatch}) { + return (user, cb) => { + Billing.getBillingInfo(jwt, user.username, (err, res) => { + if(err) { + debug('no billing account connected'); + cb(err, {}); + } else { + dispatch('RECEIVE_BILLING_INFO', { + billingInfo: res.body + }); + cb(null, user); + } + }); + }; +} + + +function handleGetUserInfo({jwt, username, dispatch}, callback) { + waterfall([ + function(cb){ + getUser(jwt, function(err, res) { + if (err) { + cb(err, {}); + } else { + dispatch('RECEIVE_USER', res.body); //LOGIN USING JWT.getUser + cb(null, res.body); + } + }); + }, + _getOrgsForCurrentUser({jwt, username, dispatch}), + _getBillingPlans({jwt, dispatch}), + _getBillingAccount({jwt, dispatch}), + _getBillingInfo({jwt, dispatch}) //Waterfalling the user through each call. + ], function(err, user) { + callback(err, { user }); + }); +} + +module.exports = function({ dispatch }, + { username, password }, + done) { + dispatch('LOGIN_ATTEMPT_START'); + getToken(username, + password, + function(err, res) { + if (err) { + debug('getToken error', err); + if (res.unauthorized) { + if(res.body && res.body.detail) { + /** + * This can happen if the user has not verified their email + */ + dispatch('LOGIN_UNAUTHORIZED_DETAIL', res.body); + } else { + dispatch('LOGIN_UNAUTHORIZED'); + } + } else if (res.badRequest){ + try { + dispatch('LOGIN_BAD_REQUEST', JSON.parse(res.text)); + } catch (error) { + dispatch('LOGIN_ERROR'); + } + } else { + // unhandled login error + dispatch('LOGIN_ERROR'); + } + } else { + if (res.body.token) { + request.post('/attempt-login/') + .send({jwt: res.body.token}) + .end((cookieErr, cookieRes) => { + handleGetUserInfo({ + jwt: res.body.token, + username, + dispatch + }, function(userErr, userRes) { + /** + * CreateBillingSubscription requires having allPlans + * If loggedOut and JWT is populated first, it will attempt to populate form, BEFORE plans have been dispatched + */ + dispatch('RECEIVE_JWT', res.body.token); + dispatch('LOGIN_CLEAR'); + }); + }); + } + } + }); +}; diff --git a/app/scripts/actions/forgotPasswordSubmit.js b/app/scripts/actions/forgotPasswordSubmit.js new file mode 100644 index 0000000000..ae953cbe3a --- /dev/null +++ b/app/scripts/actions/forgotPasswordSubmit.js @@ -0,0 +1,18 @@ +/* @flow */ +'use strict'; +import { + Users + } from 'hub-js-sdk'; +const debug = require('debug')('hub:actions:forgotPasswordSubmit'); + +var forgotPasswordSubmit = function(actionContext:{ dispatch : Function }, { email }) { + Users.forgotPassword(email, function(err, res) { + if (err) { + debug('forgotPassword error', err); + } else { + actionContext.dispatch('FORGOT_PASSWORD_SENT', res.body); + } + }); +}; + +module.exports = forgotPasswordSubmit; diff --git a/app/scripts/actions/getAllReposForFiltering.js b/app/scripts/actions/getAllReposForFiltering.js new file mode 100644 index 0000000000..84b6662d0c --- /dev/null +++ b/app/scripts/actions/getAllReposForFiltering.js @@ -0,0 +1,40 @@ +'use strict'; +var debug = require('debug')('hub:actions:getAllReposForFiltering'); +import async from 'async'; +import isArray from 'lodash/lang/isArray'; +import { + Repositories as Repos +} from 'hub-js-sdk'; + +export default function getAllReposForFiltering(actionContext, {jwt, user}) { + + actionContext.dispatch('DASHBOARD_REPOS_STORE_ATTEMPTING_GET_ALL_REPOS'); + var _getAllRepos = function (cb) { + Repos.getAllReposForUser(jwt, user, function (err, res) { + if (err) { + cb(err); + } else { + cb(null, isArray(res.body) && res.body.length); + } + }); + }; + + //Get repos for user or org + var _getReposForUserOrOrg = function (pageSize, cb) { + Repos.getReposForUser(jwt, user, function (err, res) { + if (err) { + actionContext.dispatch('ERROR_RECEIVING_REPOS', err); + cb(); + } else { + actionContext.dispatch('DASHBOARD_REPOS_STORE_RECEIVE_ALL_REPOS_SUCCESS', res.body); + cb(); + } + }, 1, pageSize); + }; + + async.waterfall([ + _getAllRepos, + _getReposForUserOrOrg + ], function(error, response) { + }); +} diff --git a/app/scripts/actions/getPipelineHistory.js b/app/scripts/actions/getPipelineHistory.js new file mode 100644 index 0000000000..a1beeb9292 --- /dev/null +++ b/app/scripts/actions/getPipelineHistory.js @@ -0,0 +1,22 @@ +'use strict'; + +import request from 'superagent'; + +export default function getPipelineHistory({ + dispatch, history +}, { + jwt, namespace, name, slug +}, done) { + request.get(`${process.env.HUB_API_BASE_URL}/v2/repositories/${namespace}/${name}/webhook_pipeline/${slug}/history/`) + .set('Authorization', `JWT ${jwt}`) + .type('json') + .accept('json') + .end((err, res) => { + if (err) { + return done(); + } else { + dispatch('RECEIVE_PIPELINE_HISTORY', {slug, payload: res.body}); + return done(); + } + }); +} diff --git a/app/scripts/actions/getRepoComments.js b/app/scripts/actions/getRepoComments.js new file mode 100644 index 0000000000..d87ee52987 --- /dev/null +++ b/app/scripts/actions/getRepoComments.js @@ -0,0 +1,23 @@ +'use strict'; + +import _ from 'lodash'; +import { + Repositories as Repos + } from 'hub-js-sdk'; + +export default function repoComments(actionContext, payload) { + var token = payload.JWT; + + var namespace = payload.namespace; + if(payload.namespace === '_') { + namespace = 'library'; + } + var repoShortName = namespace + '/' + payload.repoName; + Repos.getCommentsForRepo(token, repoShortName, function(err, res) { + if (err) { + actionContext.dispatch('ERROR_RECEIVING_REPO_COMMENTS'); + } else { + actionContext.dispatch('RECEIVE_REPO_COMMENTS', res.body); + } + }, payload.pageNumber); +} diff --git a/app/scripts/actions/getSettingsData.js b/app/scripts/actions/getSettingsData.js new file mode 100644 index 0000000000..d8ae3fcf88 --- /dev/null +++ b/app/scripts/actions/getSettingsData.js @@ -0,0 +1,28 @@ +'use strict'; +import _ from 'lodash'; +import { + Users + } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:getSettingsData'); + +var getSettingsData = function(actionContext, {JWT, username, repoType}) { + Users.getUserSettings(JWT, username, function(err, res) { + if (err){ + debug('getUserSettings', err); + } else { + if (repoType === 'regular') { + actionContext.dispatch('CREATE_REPO_UPDATE_FIELD_WITH_VALUE', { + fieldKey: 'is_private', + fieldValue: res.body.default_repo_visibility + }); + } else if (repoType === 'autobuild') { + actionContext.dispatch('AUTOBUILD_FORM_UPDATE_FIELD_WITH_VALUE', { + fieldKey: 'isPrivate', + fieldValue: res.body.default_repo_visibility + }); + } + } + }); +}; + +module.exports = getSettingsData; diff --git a/app/scripts/actions/getTeamMembers.js b/app/scripts/actions/getTeamMembers.js new file mode 100644 index 0000000000..9a88acb6a7 --- /dev/null +++ b/app/scripts/actions/getTeamMembers.js @@ -0,0 +1,17 @@ +'use strict'; + +import _ from 'lodash'; +import { + Orgs + } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:getTeamMembers'); + +export default function(actionContext, {jwt, orgname, teamname}) { + Orgs.getMembers(jwt, orgname, teamname, function (err, res) { + if (err) { + debug('error', err); + } else { + actionContext.dispatch('RECEIVE_TEAM_MEMBERS', res.body); + } + }); +} diff --git a/app/scripts/actions/githubOauth.js b/app/scripts/actions/githubOauth.js new file mode 100644 index 0000000000..fada58ae49 --- /dev/null +++ b/app/scripts/actions/githubOauth.js @@ -0,0 +1,12 @@ +'use strict'; + +const debug = require('debug')('hub:actions:githubOauth'); +const _ = require('lodash'); +const request = require('superagent'); + +module.exports = function(actionContext, {stateString}) { + request.post('/oauth/github-attempt/') + .send( {ghk: stateString} ) + .end(function(err, res) { + }); +}; diff --git a/app/scripts/actions/linkBitbucket.js b/app/scripts/actions/linkBitbucket.js new file mode 100644 index 0000000000..ab634d805a --- /dev/null +++ b/app/scripts/actions/linkBitbucket.js @@ -0,0 +1,15 @@ +'use strict'; + +import { Builds } from 'hub-js-sdk'; +const debug = require('debug')('hub:actions:linkBitbucket'); + +module.exports = function(actionContext, jwt) { + Builds.getBitbucketAuthUrl(jwt, function(err, res) { + if (err) { + debug('error', err); + actionContext.dispatch('BITBUCKET_AUTH_URL_ERROR', err); + } else if (res.ok) { + actionContext.dispatch('RECEIVE_BITBUCKET_AUTH_URL', res.body); + } + }); +}; diff --git a/app/scripts/actions/linkGithub.js b/app/scripts/actions/linkGithub.js new file mode 100644 index 0000000000..2e410330f9 --- /dev/null +++ b/app/scripts/actions/linkGithub.js @@ -0,0 +1,15 @@ +'use strict'; + +import { Builds } from 'hub-js-sdk'; +const debug = require('debug')('hub:actions:linkGithub'); + +module.exports = function(actionContext, jwt) { + Builds.getGithubClientID(jwt, function(err, res) { + if (err) { + debug('error', err); + actionContext.dispatch('GITHUB_ID_ERROR', err); + } else if (res.ok) { + actionContext.dispatch('RECEIVE_GITHUB_ID', res.body); + } + }); +}; diff --git a/app/scripts/actions/loginUpdateFormField.js b/app/scripts/actions/loginUpdateFormField.js new file mode 100644 index 0000000000..d2a993ac58 --- /dev/null +++ b/app/scripts/actions/loginUpdateFormField.js @@ -0,0 +1,7 @@ +'use strict'; + +export default function({ dispatch }, + { fieldKey, fieldValue }, + done) { + dispatch('LOGIN_UPDATE_FIELD_WITH_VALUE', { fieldKey, fieldValue }); +} diff --git a/app/scripts/actions/logout.js b/app/scripts/actions/logout.js new file mode 100644 index 0000000000..abf325ae5a --- /dev/null +++ b/app/scripts/actions/logout.js @@ -0,0 +1,29 @@ +/* @flow */ +'use strict'; + +import type FluxibleActionContext from '../../../flow-libs/fluxible'; + +import { Auth } from 'hub-js-sdk'; +import request from 'superagent'; +import async from 'async'; +const debug = require('debug')('hub:actions:logout'); + +module.exports = function(actionContext: FluxibleActionContext, jwt) { + async.parallel([ + function(callback) { + Auth.logout(jwt, function(err, res) { + if (err) { + debug('error', err); + actionContext.dispatch('LOGOUT_ERROR', err); + } else if (res.ok) { + actionContext.dispatch('LOGOUT'); + actionContext.history.push('/'); + } + }); + }, + function(callback) { + request.post('/attempt-logout/') + .end(callback); + }], + function(err, results) {}); +}; diff --git a/app/scripts/actions/longDescriptionUpdateFormField.js b/app/scripts/actions/longDescriptionUpdateFormField.js new file mode 100644 index 0000000000..f3cad471e7 --- /dev/null +++ b/app/scripts/actions/longDescriptionUpdateFormField.js @@ -0,0 +1,12 @@ +'use strict'; + +export default function({ dispatch }, + { + fieldKey, + fieldValue + }, + done) { + dispatch('LONG_DESCRIPTION_UPDATE_FIELD_WITH_VALUE', { + fieldKey, fieldValue + }); +} diff --git a/app/scripts/actions/navigate.js b/app/scripts/actions/navigate.js new file mode 100644 index 0000000000..6be4f002be --- /dev/null +++ b/app/scripts/actions/navigate.js @@ -0,0 +1,186 @@ +'use strict'; + +var debug = require('debug')('hub:actions:navigate'); +import _ from 'lodash'; +import { + JWT, + Notifications, + Repositories as Repos, + Search, + Users, + Billing +} from 'hub-js-sdk'; +import accountSettings from './navigate/accountSettings'; +import addRepo from './navigate/addRepo'; +import billingPlans from './navigate/billingPlans'; +import bitbucketRedirect from './navigate/bitbucketRedirect'; +import bitbucketUsersAndRepos from './navigate/bitbucketUsersAndRepos'; +import buildsMain from './navigate/buildsMain'; +import buildLogs from './navigate/buildLogs'; +import autoBuildSettings from './navigate/autoBuildSettings'; +import collaborators from './navigate/repoSettingsCollaborators'; +import dashStars from './navigate/dashStars'; +import dashContribs from './navigate/dashContribs'; +import dockerfile from './navigate/dockerfile'; +import explore from './navigate/explore'; +import serverTrial from './navigate/serverTrial'; +import serverTrialSuccess from './navigate/serverTrialSuccess'; +import serverBilling from './navigate/serverBilling'; +import cloudBilling from './navigate/cloudBilling'; +import getNamespaces from './navigate/getNamespaces'; +import githubUsersAndRepos from './navigate/githubUsersAndRepos'; +import githubRedirect from './navigate/githubRedirect'; +import home from './navigate/home'; +import licenses from './navigate/licenses'; +import linkedAccountSettings from './navigate/linkedAccountsSettings'; +import notificationSettings from './navigate/notificationSettings'; +import orgDashBilling from './navigate/orgBilling'; +import orgDashTeams from './navigate/orgDashTeams'; +import orgHome from './navigate/orgHome'; +import orgSummary from './navigate/orgSummary'; +import orgSettings from './navigate/orgSettings'; +import repo from './navigate/repo'; +import repoDetailsTags from './navigate/repoDetailsTags'; +import repoDetailsScannedTag from './navigate/repoDetailsScannedTag'; +import repoOfficial from './navigate/repoOfficial'; +import repoSettings from './navigate/repoSettings'; +import resetPass from './navigate/resetPass'; +import search from './navigate/search'; +import toOrg from './navigate/toOrg.js'; +import UserStore from '../stores/UserStore'; +import user from './navigate/user'; +import userStars from './navigate/userStars'; +import webhooks from './navigate/webhooks'; + +function noop({actionContext, payload, done, maybeData}) { + done(); +} + +function routesHaveHandlerFor(route, routes){ + return _.has(routes, route); +} + +function withUser(actionContext, payload, cb) { + /** + * If we are on the server and payload.cookies.jwt is a + * jwt, use it + */ + if (payload.cookies && payload.cookies.jwt) { + let token = payload.cookies.jwt; + actionContext.dispatch('RECEIVE_JWT', token); + /** + * Use the JWT to get the JWT's user's data. + */ + JWT.getUser(token, function(err, res) { + if (err) { + debug('NOT REALLY EXPIRED. JUST AN ERROR'); + actionContext.dispatch('EXPIRED_SIGNATURE', null); + return cb(null, err); + } else { + var cbData = res.body; + cbData.isAdmin = res.body.is_admin; + actionContext.dispatch('RECEIVE_USER', res.body); + cb(null, {token: token, user: cbData}); + } + }); + } else if (payload.jwt) { + debug('payload has jwt'); + /** + * if we have access to a jwt already, use it instead and assume + * we already have the user data since we're likely on the client + * (and we fill in the user data when a user logs in on the client) + */ + cb(null, { + token: payload.jwt, + user: actionContext.getStore(UserStore).getState() + }); + } else { + debug('no jwt and payload has no cookies; This should not happen'); + /** + * We have no jwt and no error? This shouldn't happen. + */ + cb(null, {}); + } +} + +module.exports = function(actionContext, payload, done) { + var _done = done; + done = function() { + _done.apply(this, arguments); + }; + + if (!payload.location.pathname) { + /** + * if we don't have a pathname, react-router doesn't have a route. + * ignore it. There's nothing we can do. + */ + return done(); + } + + withUser(actionContext, payload, function(err, maybeData) { + if (err) { + debug(err); + return done(); + } + + let routeName = payload.routes[payload.routes.length - 1].name; + debug('routeName', routeName); + let routes = { + 'accountSettings': accountSettings, + 'addRepo': addRepo, + 'addWebhook': noop, + 'addAutoBuild': linkedAccountSettings, // Questionable navigate Route + 'authServicesRoot': linkedAccountSettings, + 'autobuildBitbucket': getNamespaces, + 'autobuildBitbucketOrgs': bitbucketUsersAndRepos, + 'autobuildGithub': getNamespaces, + 'autobuildGithubOrgs': githubUsersAndRepos, + 'autobuildSettings': autoBuildSettings, + 'billingPlans': billingPlans, + 'bitbucketRedirect': bitbucketRedirect, + 'buildLogs': buildLogs, + 'buildsMain': buildsMain, + 'cloudBilling': cloudBilling, + 'collaborators': collaborators, + 'createOrgSubscription': orgDashBilling, + 'createSubscription': billingPlans, + 'dashboardHome': home, + 'dashContribs': dashContribs, + 'dashStars': dashStars, + 'dockerfile': dockerfile, + 'explore': explore, + 'githubRedirect': githubRedirect, + 'licenses': licenses, + 'notifications': notificationSettings, + 'orgDashBilling': orgDashBilling, + 'orgDashHome': orgHome, + 'orgDashSettings': orgSettings, + 'orgDashTeams': orgDashTeams, + 'orgSummary': orgSummary, + 'repoDetailsInfo': repo, + 'repoDetailsTags': repoDetailsTags, + 'repoDetailsScannedTag': repoDetailsScannedTag, + 'repoOfficial': repoOfficial, + 'repoSettingsMain': repoSettings, + 'resetPass': resetPass, + 'search': search, + 'serverBilling': serverBilling, + 'serverTrial': serverTrial, + 'serverTrialSuccess': serverTrialSuccess, + 'toOrg': toOrg, + 'updateBillingInfo': billingPlans, + 'updateOrgBillingInfo': orgDashBilling, + 'user': user, + 'userRepos': user, //This route is a clone of /u/:user/ WHY DO WE HAVE THIS? + 'userStars': userStars, + 'webhooks': webhooks + }; + actionContext.dispatch('CHANGE_ROUTE', payload); + if(routesHaveHandlerFor(routeName, routes)){ + routes[routeName]({ actionContext, payload, done, maybeData }); + } else { + debug(`no handler for ${routeName}`, payload.routes); + done(); + } + }); +}; diff --git a/app/scripts/actions/navigate/accountSettings.js b/app/scripts/actions/navigate/accountSettings.js new file mode 100644 index 0000000000..e10642d3b9 --- /dev/null +++ b/app/scripts/actions/navigate/accountSettings.js @@ -0,0 +1,79 @@ +'use strict'; +const debug = require('debug')('navigate::accountSettings'); +import { + Emails, + Users +} from 'hub-js-sdk'; +import async from 'async'; +import _ from 'lodash'; + +function sortEmails(emails) { + return _.sortByOrder(emails, + ['primary', 'verified'], + [false, false]); +} + +function parseRes({res, actionContext}) { + let emails = res.body.results; + let sortedEmails = _.sortByOrder(emails, + ['primary', 'verified'], + [false, false]); + return sortedEmails; +} + +export default function accountSettings({ + actionContext, payload, done, maybeData +}){ + actionContext.dispatch('CHANGE_PASS_CLEAR'); + if (_.has(maybeData, 'token')) { + var { token, user } = maybeData; + async.parallel({ + emails: function(callback) { + debug('ACCOUNT SETTINGS EMAILS'); + if (user && user.isAdmin) { + Emails.getEmailsForUser(token, user.username, function(err, res){ + if (err) { + callback(); + } else { + let emails = parseRes({res, actionContext}); + actionContext.dispatch('RECEIVE_EMAILS', {emails: emails}); + callback(null, emails); + } + }); + } else { + Emails.getEmailsJWT(token, function(err, res){ + if (err) { + callback(); + } else { + let emails = parseRes({res, actionContext}); + actionContext.dispatch('RECEIVE_EMAILS', {emails: emails}); + callback(null, emails); + } + }); + } + }, + repoStats: function(callback) { + debug('GET REPO STATS'); + Users.getUserSettings(token, user.username, function(err, res) { + if (err) { + callback(); + } else { + actionContext.dispatch('RECEIVE_PRIVATE_REPOSTATS', res.body); + callback(null, res.body); + } + }); + } + }, function(err, res) { + if (err) { + debug('ERROR', err); + done(); + } else { + let { emails, repoStats } = res; + debug('SUCCESS'); + done(); + } + }); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/addRepo.js b/app/scripts/actions/navigate/addRepo.js new file mode 100644 index 0000000000..b70b6bb913 --- /dev/null +++ b/app/scripts/actions/navigate/addRepo.js @@ -0,0 +1,66 @@ +'use strict'; + +import has from 'lodash/object/has'; +import { + Users +} from 'hub-js-sdk'; +import async from 'async'; +var debug = require('debug')('navigate::ADD REPO'); + +export default function repo({actionContext, payload, done, maybeData}){ + + // Defining functions here so we have access to payload + const getUserSettings = (callback) => { + // Users.getUserSettings === Orgs.getOrgSettings - equivalent calls + let username = maybeData.user.username; + if (payload.location.query.namespace) { + username = payload.location.query.namespace; + } + Users.getUserSettings(maybeData.token, username, function(getErr, getRes) { + // This is to get default visibility. If this fails, we shouldn't block + if (getErr){ + callback(null, getErr); + } else { + callback(null, getRes.body); + } + }); + }; + + const getNamespace = (callback) => { + Users.getNamespacesForUser(maybeData.token, function(namespaceErr, namespaceRes) { + if (namespaceErr) { + // If we don't get back namespaces, we can't do anything, so no point in continuing with the other calls + callback(namespaceErr); + } else { + callback(null, namespaceRes.body); + } + }); + }; + + if (has(maybeData, 'token')) { + async.parallel({ + getNamespace, + getUserSettings + }, + function(err, res) { + actionContext.dispatch('CREATE_REPO_CLEAR_FORM'); + if (err) { + done(); + } else { + const is_private = has(res.getUserSettings, 'default_repo_visibility') ? res.getUserSettings.default_repo_visibility : true; + actionContext.dispatch('CREATE_REPO_UPDATE_FIELD_WITH_VALUE', {fieldKey: 'is_private', fieldValue: is_private}); + actionContext.dispatch('RECEIVE_PRIVATE_REPOSTATS', res.getUserSettings); + if (has(res.getNamespace, 'namespaces')) { + actionContext.dispatch('CREATE_REPO_RECEIVE_NAMESPACES', { + namespaces: res.getNamespace, + selectedNamespace: maybeData.user.username + }); + } + // No namespaces is already handled in AddRepo.jsx - Dispatch is unecessary + done(); + } + }); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/autoBuildSettings.js b/app/scripts/actions/navigate/autoBuildSettings.js new file mode 100644 index 0000000000..3d13720ad6 --- /dev/null +++ b/app/scripts/actions/navigate/autoBuildSettings.js @@ -0,0 +1,136 @@ +'use strict'; +const debug = require('debug')('navigate::repoBuildSettings'); +import { parallel, waterfall } from 'async'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +import _ from 'lodash'; +import { + Repositories as Repos, + Autobuilds as AutoBuild + } from 'hub-js-sdk'; + +function getRepo({maybeToken, actionContext, user, splat}) { + return function(callback){ + Repos.getRepo(maybeToken, `${user}/${splat}`, function(err, res) { + let status; + if (res && res.body) { + status = res.body.status; + } + + if (err || status === PENDING_DELETE) { + debug('GET REPO ERR::', err); + actionContext.dispatch('REPO_NOT_FOUND', err); + return callback(err); + } else { + debug('GETTING REPOSITORY::', res.body); + actionContext.dispatch('RECEIVE_REPOSITORY', res.body); + return callback(null, res.body); + } + }); + }; +} + +function getAutobuildSettings({maybeToken, actionContext, user, splat}) { + return function(callback){ + AutoBuild.getAutomatedBuildSettings(maybeToken, user, splat, function(err, res) { + if (err) { + debug('GET AUTOBUILD SETTINGS ERR::', err); + return callback(null, null); + } + debug('AUTOBUILDSETTINGS', res.body); + actionContext.dispatch('RECEIVE_AUTOBUILD_SETTINGS', res.body); + //also dispatch to the triggerByTag store to initialize the status + const {build_tags} = res.body; + if (build_tags) { + actionContext.dispatch('INITIALIZE_AB_TRIGGERS', build_tags); + } + return callback(null, res.body); + }); + }; +} + +function getAutobuildLinks({maybeToken, actionContext, user, splat}) { + return function(callback) { + AutoBuild.getAutomatedBuildLinks(maybeToken, user, splat, function(err, res) { + if (err) { + debug('GET AUTOBUILD LINKS ERR::', err); + return callback(null, null); + } + debug('AUTOBUILDLINKS', res.body); + actionContext.dispatch('RECEIVE_AUTOBUILD_LINKS', res.body.results); + return callback(null, res.body); + }); + }; +} + +function getTriggerStatus({maybeToken, actionContext, user, splat}) { + return function(callback) { + AutoBuild.getTriggerStatus(maybeToken, user, splat, function(err, res) { + if (err) { + debug('GET TRIGGER STATUS ERR::', err); + const defaultStatus = { + token: '', + trigger_url: '', + active: false + }; + actionContext.dispatch('RECEIVE_TRIGGER_STATUS', defaultStatus); + return callback(null, null); + } + debug('AUTOBUILDTRIGGERS', res.body); + actionContext.dispatch('RECEIVE_TRIGGER_STATUS', res.body); + return callback(null, res.body); + }); + }; +} + +function getTriggerLogs({maybeToken, actionContext, user, splat}) { + return function(callback) { + AutoBuild.getTriggerLogs(maybeToken, user, splat, function(err, res) { + if (err) { + debug('GET TRIGGER LOGS ERROR', err.res); + return callback(null, null); + } + debug('AUTOBUILDTRIGGERLOGS', res.body); + actionContext.dispatch('RECEIVE_TRIGGER_LOGS', res.body.results); + return callback(null, res.body.results); + }); + }; +} + +function getRest(args) { + return function(repoDetails, cbk) { + if (repoDetails) { + parallel({ + getAutoBuild: getAutobuildSettings(args), + getAutoLinks: getAutobuildLinks(args), + getTriggerStatus: getTriggerStatus(args), + getTriggerLogs: getTriggerLogs(args) + }, function (err, res) { + cbk(null, res); + }); + } else { + cbk(null); + } + }; +} + +export default function repoSettingsBuilds({actionContext, payload, done, maybeData}) { + debug('maybeData:', maybeData); + if (_.has(maybeData, 'token')) { + let args = { + actionContext, + maybeToken: maybeData.token, + user: payload.params.user, + splat: payload.params.splat + }; + + waterfall([ + getRepo(args), + getRest(args) + ], function (e, r) { + done(); + }); + } else { + actionContext.dispatch('REPO_NOT_FOUND', null); + done(); + } +} diff --git a/app/scripts/actions/navigate/billingPlans.js b/app/scripts/actions/navigate/billingPlans.js new file mode 100644 index 0000000000..857edd4778 --- /dev/null +++ b/app/scripts/actions/navigate/billingPlans.js @@ -0,0 +1,145 @@ +'use strict'; + +const debug = require('debug')('navigate::billingPlans'); +import async from 'async'; +import _ from 'lodash'; +import { + BILLFORWARD_ACCOUNT_ID +} from 'stores/common/Constants.js'; + +import { + Billing +} from 'hub-js-sdk'; + +function _getPersonalPlans({token}) { + return (callback) => { + Billing.getPlans(token, 'personal', (err, res) => { + if(err) { + debug(err); + callback(null, []); + } else { + let plansList = res.body; + let sortedPlans = _.sortBy(plansList, 'display_order'); + callback(null, sortedPlans); + } + }); + }; +} + +function _getBillingSubscriptions({token, user}) { + return (callback) => { + Billing.getBillingSubscriptions(token, user.username, (err, res) => { + if(!res || res.status === 500) { + debug('SERVER ISSUE: getting subscriptions'); + callback('SERVER ISSUE'); + } else if (err) { + callback(null, res.body); + } else { + debug('GET USER PLANS', res.body); + let subscriptions = res.body; + + // the assumption is that there is at most one subscription right now + let subscription = _.head(subscriptions); + callback(err, subscription); + } + }); + }; +} + +function _getBillingAccount({token, user}) { + return (callback) => { + Billing.getBillingAccount(token, user.username, (err, res) => { + if(!res || res.status === 500) { + debug('SERVER ISSUE: getting billing account info'); + // NOTE: If this is a brand new billing account - set newBilling to true + callback(null, {account: { newBilling: true }}); + } else if (err) { + debug('NO BILLING ACCOUNT CONNECTED'); + // NOTE: If this is a brand new billing account - set newBilling to true + callback(null, {account: { newBilling: true }}); + } else { + debug('GET BILLING ACCOUNT', res.body); + const account = { ...res.body, newBilling: false }; + callback(err, { account, billforwardId: res.header[BILLFORWARD_ACCOUNT_ID] }); + } + }); + }; +} + +function _getBillingInfo({token, user}) { + return (callback) => { + Billing.getBillingInfo(token, user.username, (err, res) => { + if(!res || res.status === 500) { + debug('SERVER ISSUE: getting billing info'); + // NOTE: If this is a brand new billing profile - set newBilling to true + callback(null, { newBilling: true }); + } else if (err) { + debug('NO BILLING INFO CONNECTED'); + // NOTE: If this is a brand new billing profile - set newBilling to true + callback(null, { newBilling: true }); + } else { + debug('GET BILLING INFO', res.body); + callback(err, { ...res.body, newBilling: false }); + } + }); + }; +} + +function _getBillingInvoices({token, user}) { + return (callback) => { + Billing.getBillingInvoices(token, user.username, (err, res) => { + if(err) { + debug('NO BILLING ACCOUNT CONNECTED'); + callback(null, []); + } else { + debug('GET BILLING INVOICES', res.body); + callback(err, res.body); + } + }); + }; +} + +export default function billingPlans({actionContext, payload, done, maybeData}){ + if (maybeData.token && maybeData.user) { + let {token, user} = maybeData; + actionContext.dispatch('RESET_BILLING_PLANS'); + /* + NOTE: + None of the functions should pass an error into the callback or else it will + kill the rest of the calls and return before we're able to fetch all the data + */ + async.parallel({ + allPlans: _getPersonalPlans({token}), + userPlan: _getBillingSubscriptions({token, user}), + accountInfo: _getBillingAccount({token, user}), + billingInfo: _getBillingInfo({token, user}), + invoiceList: _getBillingInvoices({token, user}) + }, function(err, results){ + const { + allPlans, + userPlan, + accountInfo, + billingInfo, + invoiceList + } = results; + debug('BILLING PLANS', results); + // IF AN ACCOUNT HAS BEEN MIGRATED - ITS ACCOUNT INFO WILL HAVE PARAMETER 'payment_gateway' === 'stripe' + const gateway = accountInfo && accountInfo.account && accountInfo.account.payment_gateway; + const billforwardId = accountInfo && accountInfo.billforwardId; + actionContext.dispatch('RECEIVE_BILLING_PLANS', { + plansList: allPlans + }); + actionContext.dispatch('RECEIVE_BILLING_INFO', { + billingInfo: billingInfo, + accountInfo: accountInfo && accountInfo.account, + currentPlan: userPlan, + gateway, + billforwardId + }); + actionContext.dispatch('RECEIVE_INVOICES', {invoices: invoiceList}); + return done(); + }); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/bitbucketRedirect.js b/app/scripts/actions/navigate/bitbucketRedirect.js new file mode 100644 index 0000000000..fc63beeea9 --- /dev/null +++ b/app/scripts/actions/navigate/bitbucketRedirect.js @@ -0,0 +1,72 @@ +'use strict'; + +import async from 'async'; +import has from 'lodash/object/has'; +import { Builds } from 'hub-js-sdk'; +import linkedAccountSettingsAction from './linkedAccountsSettings'; +var debug = require('debug')('navigate::bitbucketRedirect'); + +/** + * This action is hit when the site gets redirected from the github oauth workflow + * @param actionContext + * @param payload + * @param done + * @param maybeData + */ +export default function bitbucketRedirect({actionContext, payload, done, maybeData}){ + + const SOURCES = { + GITHUB: 'github', + BITBUCKET: 'bitbucket' + }; + + debug('In Bitbucket Redirect Route Handler !'); + //If token is available the user is already logged in + var token; + if (has(maybeData, 'token')) { + token = maybeData.token; + var oauthVerifier = payload.location.query.oauth_verifier; + var oauthToken = payload.location.query.oauth_token; + + var _associateBitbucketAccount = function(cb) { + Builds.associateBitbucketAccount(token, {oauth_verifier: oauthVerifier, oauth_token: oauthToken}, function(err, res) { + if (err) { + debug(JSON.stringify(err)); + debug('ERROR ASSOCIATING BITBUCKET ACCOUNT: ' + err); + actionContext.dispatch('BITBUCKET_ASSOCIATE_ERROR', res.body); + cb(err); + } else { + if (res.body) { + debug('Bitbucket account association Success: ' + res.body); + actionContext.dispatch('BITBUCKET_ASSOCIATE_SUCCESS', res.body); + cb(null, res.body); + } + } + }); + }; + + var _getLinkedAccounts = function(callback) { + debug('Getting linked account settings state.'); + linkedAccountSettingsAction( + { + actionContext: actionContext, + payload: payload, + done: callback, + maybeData: maybeData + } + ); + }; + + async.series([ + _associateBitbucketAccount, + _getLinkedAccounts + ], function(err, results) { + done(); + } + ); + } else { + token = ''; + //TODO: redirect to login page or test redirect happens automatically + done(); + } +} diff --git a/app/scripts/actions/navigate/bitbucketUsersAndRepos.js b/app/scripts/actions/navigate/bitbucketUsersAndRepos.js new file mode 100644 index 0000000000..365a8f28d6 --- /dev/null +++ b/app/scripts/actions/navigate/bitbucketUsersAndRepos.js @@ -0,0 +1,49 @@ +'use strict'; + +var debug = require('debug')('navigate::bitbucketRepos'); +import async from 'async'; +import { + Builds + } from 'hub-js-sdk'; +import has from 'lodash/object/has'; +import linkedAccountAction from './linkedAccountsSettings'; +var debug = require('debug')('navigate::bitbucketUsersAndRepos'); + +export default function getBitbucketRepos({actionContext, payload, done, maybeData}) { + debug('GET BITBUCKET REPOS'); + var _getLinkedAccountStatus = function(cb) { + linkedAccountAction({ + actionContext: actionContext, + payload: payload, + done: cb, + maybeData: maybeData}); + }; + + var _getSourceRepos = function(cb) { + Builds.getSourceRepos('bitbucket', maybeData.token, function (err, res) { + if (err) { + const { detail } = err.response.body; + if (detail) { + actionContext.dispatch('LINKED_REPO_SOURCES_ERROR', detail); + } + cb(null); + } else{ + cb(null, res.body); + } + }); + }; + + + if (has(maybeData, 'token')) { + async.parallel([ + _getLinkedAccountStatus, + _getSourceRepos + ], function(err, results) { + actionContext.dispatch('SET_LINKED_REPO_TYPE', 'bitbucket'); + actionContext.dispatch('RECEIVE_LINKED_REPO_SOURCES', results[1]); + done(); + }); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/buildLogs.js b/app/scripts/actions/navigate/buildLogs.js new file mode 100644 index 0000000000..21f0240a8e --- /dev/null +++ b/app/scripts/actions/navigate/buildLogs.js @@ -0,0 +1,105 @@ +'use strict'; + +import has from 'lodash/object/has'; +import { parallel, waterfall } from 'async'; +import request from 'superagent'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +import { + Repositories as Repos, + Autobuilds +} from 'hub-js-sdk'; +const debug = require('debug')('buildLogs'); + +export default function buildLogs({actionContext, payload, done, maybeData}) { + var token; + if (has(maybeData, 'token')) { + token = maybeData.token; + } else { + token = ''; + } + + var namespace = payload.params.user; + if (payload.params.user === '_') { + namespace = 'library'; + } + var repoShortName = namespace + '/' + payload.params.splat; + const build_code = payload.params.build_code; + + var _getRepo = function (callback) { + Repos.getRepo(token, repoShortName, function (err, res) { + let status; + if (res && res.body) { + status = res.body.status; + } + + if (err || status === PENDING_DELETE) { + actionContext.dispatch('REPO_NOT_FOUND', err); + callback(err); + } else { + actionContext.dispatch('RECEIVE_REPOSITORY', res.body); + callback(null, res.body); + } + }); + }; + + var _getRest = function (repoDetail, cb) { + if (repoDetail) { + parallel([ + function (callback) { + Repos.getCommentsForRepo(token, repoShortName, function (err, res) { + if (err) { + callback(err); + } else { + callback(null, res.body); + } + }); + }, function (callback) { + request.get(process.env.REGISTRY_API_BASE_URL + '/v2/repositories/' + repoShortName + '/buildhistory/' + build_code + '/') + .accept('application/json') + .set('Authorization', 'JWT ' + token) + .end((err, res) => { + if(err) { + callback(); + } else { + debug('BUILD LOGS RECEIVE', res.body); + actionContext.dispatch('BUILD_LOGS_RECEIVE', res.body); + callback(); + } + }); + }, function (callback) { + if (repoDetail.is_automated) { + Autobuilds.getAutomatedBuildSettings(token, namespace, payload.params.splat, function (err, res) { + if (err) { + actionContext.dispatch('AUTOBUILD_REPO_NOT_FOUND'); + callback(err); + } else { + actionContext.dispatch('RECEIVE_AUTOBUILD_SETTINGS', res.body); + callback(); + } + }); + } else { + callback(); + } + } + ], + function (error, results) { + if (error) { + cb(); + } else { + actionContext.dispatch('RECEIVE_REPO_COMMENTS', results[0]); + cb(); + } + }); + } else { + cb(); + } + }; + + waterfall([ + _getRepo, + _getRest + ], function(err, res) { + done(); + }); + +} diff --git a/app/scripts/actions/navigate/buildsMain.js b/app/scripts/actions/navigate/buildsMain.js new file mode 100644 index 0000000000..5e2bc2b681 --- /dev/null +++ b/app/scripts/actions/navigate/buildsMain.js @@ -0,0 +1,99 @@ +'use strict'; + +import has from 'lodash/object/has'; +import { parallel, waterfall } from 'async'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +import { + Repositories as Repos, + Autobuilds +} from 'hub-js-sdk'; + +export default function buildsMain({actionContext, payload, done, maybeData}) { + var token; + if (has(maybeData, 'token')) { + token = maybeData.token; + } else { + token = ''; + } + + var namespace = payload.params.user; + if (payload.params.user === '_') { + namespace = 'library'; + } + var repoShortName = namespace + '/' + payload.params.splat; + + var _getRepo = function (callback) { + Repos.getRepo(token, repoShortName, function (err, res) { + let status; + if (res && res.body) { + status = res.body.status; + } + + if (err || status === PENDING_DELETE) { + actionContext.dispatch('REPO_NOT_FOUND', err); + callback(err); + } else { + actionContext.dispatch('RECEIVE_REPOSITORY', res.body); + callback(null, res.body); + } + }); + }; + + var _getRest = function (repoDetail, cb) { + if (repoDetail) { + parallel([ + function (callback) { + Repos.getBuildHistory(token, { + namespace, + name: payload.params.splat + }, function (err, res) { + if (err) { + callback(); + } else { + actionContext.dispatch('RECEIVE_BUILD_HISTORY_FOR_REPOSITORY', res.body); + callback(); + } + }); + }, function(callback) { + if (repoDetail.is_automated) { + Autobuilds.getAutomatedBuildSettings(token, namespace, payload.params.splat, function (err, res) { + if (err) { + actionContext.dispatch('AUTOBUILD_REPO_NOT_FOUND'); + callback(null); + } else { + callback(null, res.body); + } + }); + } else { + callback(null); + } + } + ], + function (error, results) { + if (error) { + cb(); + } else { + if (results[1]) { + actionContext.dispatch('RECEIVE_AUTOBUILD_SETTINGS', results[1]); + //also dispatch to the triggerByTag store to initialize the status + const {build_tags} = results[1]; + if (build_tags) { + actionContext.dispatch('INITIALIZE_AB_TRIGGERS', build_tags); + } + } + cb(); + } + }); + } else { + cb(); + } + }; + + waterfall([ + _getRepo, + _getRest + ], function(err, res) { + done(); + }); + +} diff --git a/app/scripts/actions/navigate/cloudBilling.js b/app/scripts/actions/navigate/cloudBilling.js new file mode 100644 index 0000000000..4fbb8e1368 --- /dev/null +++ b/app/scripts/actions/navigate/cloudBilling.js @@ -0,0 +1,109 @@ +'use strict'; + +const debug = require('debug')('navigate::billingPlans'); +import async from 'async'; +import _ from 'lodash'; + +import { + Billing, + Users + } from 'hub-js-sdk'; + +//GETs current HUB subscription +function _getBillingSubscriptions({token, user}) { + return (callback) => { + Billing.getBillingSubscriptions(token, user.username, (err, res) => { + if(err) { + callback(null, {}); + } else { + debug('GET USER PLANS', res.body); + let subscriptions = res.body; + + // the assumption is that there is at most one subscription right now + let subscription = _.head(subscriptions); + callback(err, subscription); + } + }); + }; +} + +function _getBillingAccount({token, user}) { + return (callback) => { + Billing.getBillingAccount(token, user.username, (err, res) => { + if(err) { + debug('NO BILLING ACCOUNT CONNECTED'); + callback(null, {}); + } else { + debug('GET BILLING ACCOUNT', res.body); + callback(null, res.body); + } + }); + }; +} + +function _getBillingInfo({token, user}) { + return (callback) => { + Billing.getBillingInfo(token, user.username, (err, res) => { + if(err) { + debug('NO BILLING ACCOUNT CONNECTED'); + callback(null, {}); + } else { + debug('GET BILLING INFO', res.body); + callback(null, res.body); + } + }); + }; +} +/** + * ============================================= + * ^ GET SUBSCRIPTIONS/ACCOUNTINFO/BILLINGINFO + * Must re-get on change of namespace + * ============================================= + */ + +//GET namespaces for user +function _getNamespaces({token}) { + return (callback) => { + Users.getNamespacesForUser(token, function(err, res) { + if (err) { + callback(null, {}); + } else { + callback(null, res.body.namespaces); + } + }); + }; +} + +export default function billingPlans({actionContext, payload, done, maybeData}){ + if (maybeData.token && maybeData.user) { + const {token, user} = maybeData; + actionContext.dispatch('RESET_CLOUD_BILLING_PLANS'); + async.parallel({ + userPlan: _getBillingSubscriptions({token, user}), + accountInfo: _getBillingAccount({token, user}), + billingInfo: _getBillingInfo({token, user}), + namespaces: _getNamespaces({token}) + }, function(err, results){ + const { userPlan, accountInfo, billingInfo, namespaces } = results; + debug('CLOUD BILLING PLANS', results); + actionContext.dispatch('RECEIVE_CLOUD_BILLING_INFO', { + billingInfo: billingInfo, + accountInfo: accountInfo, + currentPlan: userPlan + }); + actionContext.dispatch('ENTERPRISE_PAID_RECEIVE_ORGS', namespaces); + const values = _.merge({}, billingInfo, { + account_first: accountInfo.first_name, + account_last: accountInfo.last_name, + company_name: accountInfo.company_name, + email: accountInfo.email + }); + debug('INITIALIZE ENTERPRISE BILLING FORM: ', values); + // Need to differentiate btw billingInfo/accountInfo first/last names + actionContext.dispatch('ENTERPRISE_PAID_POPULATE_FORM', values); + return done(); + }); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/dashContribs.js b/app/scripts/actions/navigate/dashContribs.js new file mode 100644 index 0000000000..45f607930e --- /dev/null +++ b/app/scripts/actions/navigate/dashContribs.js @@ -0,0 +1,78 @@ +'use strict'; +var debug = require('debug')('navigate::dashStars'); +import async from 'async'; +import _ from 'lodash'; +import { + Repositories as Repos, + Notifications, + Orgs, + Users + } from 'hub-js-sdk'; + +export default function home({actionContext, payload, done, maybeData}){ + if (_.has(maybeData, 'token')) { + + //This is always for current user + //Get contributed repos + var _getReposByFilterTypeContrib = function(cb) { + var username = maybeData.user.username; + Repos.getContributedReposForUser(maybeData.token, username, function (err, res) { + if (err) { + cb(); + } else { + actionContext.dispatch('RECEIVE_CONTRIB', res.body); + cb(); + } + }, payload.location.query.page); + }; + + + //Get orgs for user + var _getOrgsForCurrentUser = function(cb) { + Users.getOrgsForUser(maybeData.token, function(err, res) { + if (err) { + debug(err); + cb(); + } else { + actionContext.dispatch('RECEIVE_DASHBOARD_NAMESPACES', { + orgs: res.body, + user: maybeData.user.username + }); + cb(); + } + }); + }; + + //Get user settings for private repo stats + var _getUserSettings = function(cb) { + Users.getUserSettings(maybeData.token, maybeData.user.username, function(err, res) { + if (err) { + debug(err); + cb(); + } else { + actionContext.dispatch('RECEIVE_PRIVATE_REPOSTATS', res.body); + cb(); + } + }); + }; + + //This is executed only for the currently logged in user, so put in starred, contributed only here + var _doParallelCalls = function() { + async.parallel([ + _getOrgsForCurrentUser, + _getReposByFilterTypeContrib, + _getUserSettings + ], function(err, results) { + if (err) { + debug(err); + } + return done(); + }); + }; + + actionContext.dispatch('CURRENT_USER_CONTEXT', { username: maybeData.user.username }); + _doParallelCalls(); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/dashRepos.js b/app/scripts/actions/navigate/dashRepos.js new file mode 100644 index 0000000000..3aeffbdf48 --- /dev/null +++ b/app/scripts/actions/navigate/dashRepos.js @@ -0,0 +1,138 @@ +'use strict'; +var debug = require('debug')('navigate::dashRepos'); +import async from 'async'; +import _ from 'lodash'; +import { + Repositories as Repos, + Notifications, + Orgs, + Users +} from 'hub-js-sdk'; + +export default function home({actionContext, payload, done, maybeData}){ + if (_.has(maybeData, 'token')) { + + //Get repos for user or org + var _getReposForUserOrOrg = function(cb, userType) { + var userOrOrgName = payload.params.user || maybeData.user.username; + actionContext.dispatch('DASHBOARD_REPOS_STORE_ATTEMPTING_GET_REPOS'); + Repos.getReposForUser(maybeData.token, userOrOrgName, function(err, res) { + if (err) { + actionContext.dispatch('ERROR_RECEIVING_REPOS'); + cb(); + } else { + actionContext.dispatch('RECEIVE_REPOS', res.body); + cb(); + } + }, payload.location.query.page); + }; + + //Get orgs for user + var _getOrgsForCurrentUser = function(cb) { + Users.getOrgsForUser(maybeData.token, function(err, res) { + if (err) { + debug(err); + cb(); + } else { + actionContext.dispatch('CURRENT_USER_ORGS', res.body); + cb(); + } + }); + }; + + //Get user settings for private repo stats + var _getUserSettings = function(cb) { + Users.getUserSettings(maybeData.token, maybeData.user.username, function(err, res) { + if (err) { + debug(err); + cb(); + } else { + actionContext.dispatch('RECEIVE_PRIVATE_REPOSTATS', res.body); + cb(); + } + }); + }; + + //This is executed only for the currently logged in user, so put in starred, contributed only here + var _doParallelCalls = function() { + async.parallel([ + _getReposForUserOrOrg, + _getOrgsForCurrentUser, + _getUserSettings, + function(callback) { + Notifications.getActivityFeed(maybeData.token, function(err, res) { + if (res) { + actionContext.dispatch('RECEIVE_ACTIVITY_FEED', res.body); + } + callback(); + }); + } + ], function(err, results) { + if (err) { + debug(err); + } + return done(); + }); + }; + + //Get Organization by name + var _getOrgByName = function() { + Orgs.getOrg(maybeData.token, payload.params.user, function(err, res) { + if (err) { + debug(err); + } else { + var org = res.body; + if (!_.isEmpty(org)) { + return org; + } + } + }); + return {}; + }; + + //Get user by name + var _getUserByName = function() { + Users.getUser(maybeData.token, payload.params.user, function(err, res) { + if (err) { + debug(err); + } else { + var user = res.body; + if (!_.isEmpty(user)) { + return user; + } + } + }); + return {}; + }; + + if (payload.params.user) { + if (payload.params.user === maybeData.user.username) { + actionContext.dispatch('CURRENT_USER_CONTEXT', { username: payload.params.user }); + _doParallelCalls(); + } else { + //get Org by name, if success set userOrOrg = `org` else set it to `user` + async.series([ + function (callback) { + callback(null, _getOrgByName(payload.params.user)); + }, + function (callback) { + callback(null, _getUserByName(payload.params.user)); + } + ], function (err, results) { + if (err) { + debug(err); + } else { + debug('Results of Series Call: ' + JSON.stringify(results)); + actionContext.dispatch('CURRENT_USER_CONTEXT', { username: payload.params.user }); + _getReposForUserOrOrg(done); + } + }); + } + } else { + actionContext.dispatch('CURRENT_USER_CONTEXT', { username: maybeData.user.username }); + _doParallelCalls(); + } + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/dashStars.js b/app/scripts/actions/navigate/dashStars.js new file mode 100644 index 0000000000..5744f62161 --- /dev/null +++ b/app/scripts/actions/navigate/dashStars.js @@ -0,0 +1,77 @@ +'use strict'; +var debug = require('debug')('navigate::dashStars'); +import async from 'async'; +import _ from 'lodash'; +import { + Repositories as Repos, + Notifications, + Orgs, + Users +} from 'hub-js-sdk'; + +export default function home({actionContext, payload, done, maybeData}){ + if (_.has(maybeData, 'token')) { + + //This is always for current user + //Get starred repos + var _getReposByFilterTypeStarred = function(cb) { + var username = maybeData.user.username; + Repos.getStarredReposForUser(maybeData.token, username, function (err, res) { + if (err) { + cb(); + } else { + actionContext.dispatch('RECEIVE_STARRED', res.body); + cb(); + } + }, payload.location.query.page); + }; + + //Get orgs for user + var _getOrgsForCurrentUser = function(cb) { + Users.getOrgsForUser(maybeData.token, function(err, res) { + if (err) { + debug(err); + cb(); + } else { + actionContext.dispatch('RECEIVE_DASHBOARD_NAMESPACES', { + orgs: res.body, + user: maybeData.user.username + }); + cb(); + } + }); + }; + + //Get user settings for private repo stats + var _getUserSettings = function(cb) { + Users.getUserSettings(maybeData.token, maybeData.user.username, function(err, res) { + if (err) { + debug(err); + cb(); + } else { + actionContext.dispatch('RECEIVE_PRIVATE_REPOSTATS', res.body); + cb(); + } + }); + }; + + //This is executed only for the currently logged in user, so put in starred, contributed only here + var _doParallelCalls = function() { + async.parallel([ + _getOrgsForCurrentUser, + _getReposByFilterTypeStarred, + _getUserSettings + ], function(err, results) { + if (err) { + debug(err); + } + return done(); + }); + }; + + actionContext.dispatch('CURRENT_USER_CONTEXT', { username: maybeData.user.username }); + _doParallelCalls(); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/dockerfile.js b/app/scripts/actions/navigate/dockerfile.js new file mode 100644 index 0000000000..d43d6afda5 --- /dev/null +++ b/app/scripts/actions/navigate/dockerfile.js @@ -0,0 +1,90 @@ +'use strict'; + +import has from 'lodash/object/has'; +import { parallel, waterfall } from 'async'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +import { + Repositories as Repos, + Autobuilds +} from 'hub-js-sdk'; + +export default function dockerfile({actionContext, payload, done, maybeData}){ + var token; + if (has(maybeData, 'token')) { + token = maybeData.token; + } else { + token = ''; + } + + var namespace = payload.params.user; + if(payload.params.user === '_') { + namespace = 'library'; + } + var repoShortName = namespace + '/' + payload.params.splat; + + var _getRepo = function (callback) { + Repos.getRepo(token, repoShortName, function (err, res) { + let status; + if (res && res.body) { + status = res.body.status; + } + + if (err || status === PENDING_DELETE) { + actionContext.dispatch('REPO_NOT_FOUND', err); + callback(err); + } else { + actionContext.dispatch('RECEIVE_REPOSITORY', res.body); + callback(null, res.body); + } + }); + }; + + var _getRest = function (repoDetail, cb) { + if (repoDetail) { + parallel([ + function (callback) { + Repos.getDockerfile(token, repoShortName, function (err, res) { + if (err) { + callback(); + } else { + actionContext.dispatch('RECEIVE_DOCKERFILE_FOR_REPOSITORY', res.body); + callback(); + } + }); + }, function(callback) { + if (repoDetail.is_automated) { + Autobuilds.getAutomatedBuildSettings(token, namespace, payload.params.splat, function (err, res) { + if (err) { + actionContext.dispatch('AUTOBUILD_REPO_NOT_FOUND'); + callback(null); + } else { + callback(null, res.body); + } + }); + } else { + callback(null); + } + } + ], + function (error, results) { + if (error) { + cb(); + } else { + if (results[1]) { + actionContext.dispatch('RECEIVE_AUTOBUILD_SETTINGS', results[1]); + } + cb(); + } + }); + } else { + cb(); + } + }; + + waterfall([ + _getRepo, + _getRest + ], function(err, res) { + done(); + }); +} diff --git a/app/scripts/actions/navigate/explore.js b/app/scripts/actions/navigate/explore.js new file mode 100644 index 0000000000..1167f1ef23 --- /dev/null +++ b/app/scripts/actions/navigate/explore.js @@ -0,0 +1,26 @@ +'use strict'; +var debug = require('debug')('navigate::home'); +import async from 'async'; +import _ from 'lodash'; +import { + Repositories as Repos, + Notifications, + Orgs, + Users +} from 'hub-js-sdk'; + +export default function explore({actionContext, payload, done, maybeData}){ + + //Get repos for library + // We have, hijacked the repos store. This might not be good + // in the long run + Repos.getReposForUser(maybeData.token, 'library', function(err, res) { + if (err) { + actionContext.dispatch('ERROR_RECEIVING_REPOS'); + done(); + } else { + actionContext.dispatch('RECEIVE_REPOS', res.body); + done(); + } + }, payload.location.query.page); +} diff --git a/app/scripts/actions/navigate/getNamespaces.js b/app/scripts/actions/navigate/getNamespaces.js new file mode 100644 index 0000000000..99376c1547 --- /dev/null +++ b/app/scripts/actions/navigate/getNamespaces.js @@ -0,0 +1,38 @@ +'use strict'; + +import _ from 'lodash'; +import { + Users + } from 'hub-js-sdk'; +var debug = require('debug')('navigate::getNamespaces'); + +export default function getNamespaces({actionContext, payload, done, maybeData}){ + var initialRepoName = payload.params.sourceRepoName; + var initialNamespace = payload.location.query.namespace || maybeData.user.username; + if (_.has(maybeData, 'token')) { + Users.getNamespacesForUser(maybeData.token, function(err, res) { + if (err) { + return done(); + } + Users.getUserSettings(maybeData.token, maybeData.user.username, function(settingsErr, settingsRes) { + if (settingsRes.body) { + actionContext.dispatch('RECEIVE_PRIVATE_REPOSTATS', settingsRes.body); + actionContext.dispatch('RECEIVE_NAMESPACES', res.body); + actionContext.dispatch('INITIALIZE_AUTOBUILD_FORM', {name: initialRepoName, namespace: initialNamespace}); + actionContext.dispatch('CLEAR_AUTOBUILD_FORM_ERRORS'); + if (payload.routes[payload.routes.length - 1].name === 'autobuildGithub') { + actionContext.dispatch('SET_LINKED_REPO_TYPE', 'github'); + } else if (payload.routes[payload.routes.length - 1].name === 'autobuildBitbucket') { + actionContext.dispatch('SET_LINKED_REPO_TYPE', 'bitbucket'); + } + return done(); + } else if (settingsErr) { + debug(settingsErr); + return done(); + } + }); + }); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/githubRedirect.js b/app/scripts/actions/navigate/githubRedirect.js new file mode 100644 index 0000000000..d3878ee164 --- /dev/null +++ b/app/scripts/actions/navigate/githubRedirect.js @@ -0,0 +1,84 @@ +'use strict'; + +import _ from 'lodash'; +import async from 'async'; +import { Builds } from 'hub-js-sdk'; +import request from 'superagent'; +var debug = require('debug')('navigate::githubRedirect'); +import GithubLinkStore from '../../stores/GithubLinkStore'; +import linkedAccountSettingsAction from './linkedAccountsSettings'; + +/** + * This action is hit when the site gets redirected from the github oauth workflow + * @param actionContext + * @param payload + * @param done + * @param maybeData + */ +export default function githubRedirect({actionContext, payload, done, maybeData}){ + + const SOURCES = { + GITHUB: 'github', + BITBUCKET: 'bitbucket' + }; + + debug('In Gihub Redirect Route Handler !'); + //If token is available the user is already logged in + var token; + if (_.has(maybeData, 'token')) { + token = maybeData.token; + var code = payload.location.query.code; + var state = payload.location.query.state; + + var _associateGithubAccount = function(cb) { + Builds.associateGithubAccount(token, code, function(err, res) { + if (err) { + debug(JSON.stringify(err)); + debug('ERROR ASSOCIATING GITHUB ACCOUNT: ' + err); + actionContext.dispatch('GITHUB_ASSOCIATE_ERROR', res.body); + cb(err); + } else { + if (res.body) { + debug('GitHub account association Success: ' + res.body); + actionContext.dispatch('GITHUB_ASSOCIATE_SUCCESS', res.body); + cb(null, res.body); + } + } + }); + }; + + var _getLinkedAccounts = function(callback) { + debug('Getting linked account settings state.'); + linkedAccountSettingsAction( + { + actionContext: actionContext, + payload: payload, + done: callback, + maybeData: maybeData + } + ); + }; + + debug('Payload Cookies: ' + payload.cookies); + //TODO: validate state before linking + if (payload.cookies && payload.cookies.ghOauthKey === state) { + async.series([ + _associateGithubAccount, + _getLinkedAccounts + ], function(err, results) { + request.post('/oauth/github-done/') + .end(function(e, r) { debug('github-oauth done or exited.'); }); + done(); + } + ); + } else { + //The validation of the state failed + actionContext.dispatch('GITHUB_SECURITY_ERROR', 'There was a security issue with your request. Please try again later.'); + done(); + } + } else { + token = ''; + actionContext.dispatch('GITHUB_ASSOCIATE_ERROR'); + done(); + } +} diff --git a/app/scripts/actions/navigate/githubUsersAndRepos.js b/app/scripts/actions/navigate/githubUsersAndRepos.js new file mode 100644 index 0000000000..da5bb49ded --- /dev/null +++ b/app/scripts/actions/navigate/githubUsersAndRepos.js @@ -0,0 +1,48 @@ +'use strict'; + +var debug = require('debug')('navigate::githubRepos'); +import async from 'async'; +import { + Builds + } from 'hub-js-sdk'; +import has from 'lodash/object/has'; +import linkedAccountAction from './linkedAccountsSettings'; + +export default function getGithubRepos({actionContext, payload, done, maybeData}) { + + var _getLinkedAccountStatus = function(cb) { + linkedAccountAction({ + actionContext: actionContext, + payload: payload, + done: cb, + maybeData: maybeData}); + }; + + var _getSourceRepos = function(cb) { + Builds.getSourceRepos('github', maybeData.token, function (err, res) { + if (err) { + const { detail } = err.response.body; + if (detail) { + actionContext.dispatch('LINKED_REPO_SOURCES_ERROR', detail); + } + cb(null); + } else{ + cb(null, res.body); + } + }); + }; + + + if (has(maybeData, 'token')) { + async.parallel([ + _getLinkedAccountStatus, + _getSourceRepos + ], function(err, results) { + actionContext.dispatch('SET_LINKED_REPO_TYPE', 'github'); + actionContext.dispatch('RECEIVE_LINKED_REPO_SOURCES', results[1]); + done(); + }); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/home.js b/app/scripts/actions/navigate/home.js new file mode 100644 index 0000000000..144c26efe9 --- /dev/null +++ b/app/scripts/actions/navigate/home.js @@ -0,0 +1,132 @@ +'use strict'; +var debug = require('debug')('navigate::home'); +import async from 'async'; +import _ from 'lodash'; +import { + Repositories as Repos, + Notifications, + Orgs, + Users +} from 'hub-js-sdk'; + +export default function home({actionContext, payload, done, maybeData}){ + if (_.has(maybeData, 'token')) { + + //Get repos for user or org + var _getReposForUserOrOrg = function(cb, userType) { + var userOrOrgName = payload.params.user || maybeData.user.username; + actionContext.dispatch('DASHBOARD_REPOS_STORE_ATTEMPTING_GET_REPOS'); + Repos.getReposForUser(maybeData.token, userOrOrgName, function(err, res) { + if (err) { + actionContext.dispatch('ERROR_RECEIVING_REPOS'); + cb(); + } else { + actionContext.dispatch('RECEIVE_REPOS', res.body); + cb(); + } + }, payload.location.query.page); + }; + + //Get orgs for user + var _getOrgsForCurrentUser = function(cb) { + Users.getOrgsForUser(maybeData.token, function(err, res) { + if (err) { + debug(err); + cb(); + } else { + actionContext.dispatch('RECEIVE_DASHBOARD_NAMESPACES', { + orgs: res.body, + user: maybeData.user.username + }); + cb(); + } + }); + }; + + //Get user settings for private repo stats + var _getUserSettings = function(cb) { + Users.getUserSettings(maybeData.token, maybeData.user.username, function(err, res) { + if (err) { + debug(err); + cb(); + } else { + actionContext.dispatch('RECEIVE_PRIVATE_REPOSTATS', res.body); + cb(); + } + }); + }; + + var _doParallelCalls = function() { + async.parallel([ + _getReposForUserOrOrg, + _getOrgsForCurrentUser, + _getUserSettings + ], function(err, results) { + if (err) { + debug(err); + } + return done(); + }); + }; + + //Get Organization by name + var _getOrgByName = function() { + Orgs.getOrg(maybeData.token, payload.params.user, function(err, res) { + if (err) { + debug(err); + } else { + var org = res.body; + if (!_.isEmpty(org)) { + return org; + } + } + }); + return {}; + }; + + //Get user by name + var _getUserByName = function() { + Users.getUser(maybeData.token, payload.params.user, function(err, res) { + if (err) { + debug(err); + } else { + var user = res.body; + if (!_.isEmpty(user)) { + return user; + } + } + }); + return {}; + }; + + if (payload.params.user) { + if (payload.params.user === maybeData.user.username) { + actionContext.dispatch('CURRENT_USER_CONTEXT', { username: payload.params.user }); + _doParallelCalls(); + } else { + async.series([ + function (callback) { + callback(null, _getOrgByName(payload.params.user)); + }, + function (callback) { + callback(null, _getUserByName(payload.params.user)); + } + ], function (err, results) { + if (err) { + debug(err); + } else { + debug('Results of Series Call: ' + JSON.stringify(results)); + //User context is defined as `type`, `name` and `status flag` + actionContext.dispatch('CURRENT_USER_CONTEXT', {username: payload.params.user }); + _getReposForUserOrOrg(done); + } + }); + } + } else { + actionContext.dispatch('CURRENT_USER_CONTEXT', { username: maybeData.user.username }); + _doParallelCalls(); + } + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/licenses.js b/app/scripts/actions/navigate/licenses.js new file mode 100644 index 0000000000..2735150419 --- /dev/null +++ b/app/scripts/actions/navigate/licenses.js @@ -0,0 +1,57 @@ +'use strict'; +var debug = require('debug')('navigate::licenses'); +import async from 'async'; +import request from 'superagent'; +import _ from 'lodash'; +import { + Repositories as Repos, + Notifications, + Orgs, + Users +} from 'hub-js-sdk'; + +function getLicenses({ userID, token, actionContext}, done) { + request(process.env.REGISTRY_API_BASE_URL + '/api/licensing/v3/license/' + userID + '/') + .accept('application/json') + .set('Authorization', 'JWT ' + token) + .end((err, res) => { + if (err) { + done(null, null); + } else { + + let licenses = _.map(res.body.licenses, function(obj) { + return _.merge({}, + obj, + { + orgname: userID + }); + }); + done(null, licenses); + } + }); +} + +export default function licensesFn({ + actionContext, payload, done, maybeData +}){ + + if(maybeData.token) { + Users.getNamespacesForUser(maybeData.token, function(err, res){ + async.map(res.body.namespaces, + function(item, cb) { + getLicenses({ + userID: item, + token: maybeData.token, + actionContext + }, cb); + }, + function(error, results) { + actionContext.dispatch('RECEIVE_LICENSES', _.compact(results)); + done(); + }); + }); + } else { + // user must be logged in; they aren't + done(); + } +} diff --git a/app/scripts/actions/navigate/linkedAccountsSettings.js b/app/scripts/actions/navigate/linkedAccountsSettings.js new file mode 100644 index 0000000000..5f93a8be21 --- /dev/null +++ b/app/scripts/actions/navigate/linkedAccountsSettings.js @@ -0,0 +1,118 @@ +'use strict'; + +var debug = require('debug')('navigate::linkedAccounts'); +import async from 'async'; +import { + Builds + } from 'hub-js-sdk'; +import _ from 'lodash'; + +const SOURCES = { + GITHUB: 'github', + BITBUCKET: 'bitbucket' +}; + +export default function linked({actionContext, payload, done, maybeData}){ + if (_.has(maybeData, 'token')) { + + var _checkGithub = function(cb) { + Builds.checkGithub(maybeData.token, function(err, res) { + if(err) { + debug(err); + cb(null, false); + } else { + debug('Github account exists'); + cb(null, true); + } + }); + }; + + var _checkBitbucket = function(cb) { + Builds.checkBitbucket(maybeData.token, function(err, res) { + if(err) { + debug(err); + cb(null, false); + } else { + cb(null, true); + } + }); + }; + + var _getGithubAccount = function(accountExists, cb) { + if(accountExists) { + Builds.getSourceAccount(SOURCES.GITHUB, maybeData.token, function (err, res) { + if (err) { + cb(null, err); + } else { + cb(null, res.body); + } + }); + } else { + cb(null, {detail: 'No associated Github user'}); + } + }; + + var _getBitbucketAccount = function(accountExists, cb) { + if (accountExists) { + Builds.getSourceAccount(SOURCES.BITBUCKET, maybeData.token, function (err, res) { + if(err) { + cb(null, err); + } else { + cb(null, res.body); + } + }); + } else { + Builds.getBitbucketAuthUrl(maybeData.token, function(err, res) { + if (err) { + debug(err); + actionContext.dispatch('BITBUCKET_AUTH_URL_ERROR', err); + } else if (res.ok) { + actionContext.dispatch('RECEIVE_BITBUCKET_AUTH_URL', res.body); + } + }); + cb(null, {detail: 'No associated Bitbucket user'}); + } + }; + + var _wfGetGithubAccount = function(cb) { + async.waterfall([ + _checkGithub, + _getGithubAccount + ], function(err, result) { + debug('Github WF - result', result); + if (!err) { + cb(null, result); + } + }); + }; + + var _wfGetBitbucketAccount = function(cb) { + async.waterfall([ + _checkBitbucket, + _getBitbucketAccount + ], function(err, result) { + debug('Bitbucket WF - result', result); + if (!err) { + cb(null, result); + } + }); + }; + + //Check github and bitbucket and then get the account + async.parallel({ + _wfGetGithubAccount, + _wfGetBitbucketAccount + }, function(err, results) { + debug('results: ', results); + let accounts = { + github: results._wfGetGithubAccount, + bitbucket: results._wfGetBitbucketAccount, + gitlab: {detail: 'No associated GitLab user'} + }; + actionContext.dispatch('RECEIVE_SOURCE_ACCOUNTS', accounts); + return done(); + }); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/notificationSettings.js b/app/scripts/actions/navigate/notificationSettings.js new file mode 100644 index 0000000000..787ee68396 --- /dev/null +++ b/app/scripts/actions/navigate/notificationSettings.js @@ -0,0 +1,62 @@ +'use strict'; +const debug = require('debug')('navigate::notificationSettings'); +import _ from 'lodash'; +import async from 'async'; +import { + Emails, Notifications + } from 'hub-js-sdk'; + +export default function notificationSettings({actionContext, payload, done, maybeData}){ + if (_.has(maybeData, 'token')) { + var {token, user} = maybeData; + async.parallel({ + subscriptions: function(callback) { + Emails.getEmailSubscriptions(token, user.username, function(err, res){ + if (err) { + debug(err); + callback(); + } else { + callback(null, res.body); + } + }); + }, + notifications: function(callback) { + Notifications.getNotificationSubscriptions(token, function(err, res) { + if (err) { + debug(err); + callback(); + } else { + callback(null, res.body.results); + } + }); + }, + emails: function(callback) { + Emails.getEmailsForUser(token, user.username, function(err, res){ + if (err) { + debug(err); + callback(); + } else { + let emails = res.body.results; + let sortedEmails = _.sortByOrder(emails, ['primary', 'verified'], [false, false]); + callback(null, sortedEmails); + } + }); + } + }, function(err, res) { + let {subscriptions, notifications, emails} = res; + var weeklyDigest, betaGroup; + if (subscriptions) { + weeklyDigest = subscriptions.DockerNewsMailingList; + betaGroup = subscriptions.DockerBetaGroupMailingList; + actionContext.dispatch('RECEIVE_EMAIL_SUBSCRIPTIONS', {weeklyDigest: weeklyDigest, betaGroup: betaGroup}); + } + if (notifications) { + actionContext.dispatch('RECEIVE_NOTIFICATIONS', notifications); + } + actionContext.dispatch('RECEIVE_EMAILS', {emails: emails}); + done(); + }); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/orgBilling.js b/app/scripts/actions/navigate/orgBilling.js new file mode 100644 index 0000000000..d85e68da1d --- /dev/null +++ b/app/scripts/actions/navigate/orgBilling.js @@ -0,0 +1,222 @@ +'use strict'; + +const debug = require('debug')('navigate::OrgBilling'); +import async from 'async'; +import _ from 'lodash'; +import { + BILLFORWARD_ACCOUNT_ID +} from 'stores/common/Constants.js'; + +import { + Billing, + Users, + Orgs + } from 'hub-js-sdk'; + +function _getOrgPlans(token) { + return (callback) => { + Billing.getPlans(token, 'personal', (err, res) => { + if(err) { + debug(err); + callback(null, []); + } else { + let plansList = res.body; + let sortedPlans = _.sortBy(plansList, 'display_order'); + debug(sortedPlans); + callback(null, sortedPlans); + } + }); + }; +} + +function _getBillingSubscriptions(token, username) { + return (callback) => { + Billing.getBillingSubscriptions(token, username, (err, res) => { + if(!res || res.status === 500) { + debug('SERVER ISSUE: getting subscriptions'); + callback('SERVER ISSUE'); + } else if (err) { + callback(null, res.body); + } else { + debug('GET USER PLANS', res.body); + let subscriptions = res.body; + + // the assumption is that there is at most one subscription right now + let subscription = _.head(subscriptions); + callback(err, subscription); + } + }); + }; +} + +function _getBillingAccount(token, username) { + return (callback) => { + Billing.getBillingAccount(token, username, (err, res) => { + if(!res || res.status === 500) { + debug('SERVER ISSUE: getting billing account info'); + // NOTE: If this is a brand new billing account - set newBilling to true + callback(null, {account: { newBilling: true }}); + } else if (err) { + debug('NO BILLING ACCOUNT CONNECTED'); + // NOTE: If this is a brand new billing account - set newBilling to true + callback(null, {account: { newBilling: true }}); + } else { + debug('GET BILLING ACCOUNT', res.body); + const account = { ...res.body, newBilling: false }; + callback(err, { account, billforwardId: res.header[BILLFORWARD_ACCOUNT_ID] }); + } + }); + }; +} + +function _getBillingInfo(token, username) { + return (callback) => { + Billing.getBillingInfo(token, username, (err, res) => { + if(!res || res.status === 500) { + debug('SERVER ISSUE: getting billing info'); + // NOTE: If this is a brand new billing profile - set newBilling to true + callback(null, { newBilling: true }); + } else if (err) { + debug('NO BILLING INFO CONNECTED'); + // NOTE: If this is a brand new billing profile - set newBilling to true + callback(null, { newBilling: true }); + } else { + debug('GET BILLING INFO', res.body); + callback(err, { ...res.body, newBilling: false }); + } + }); + }; +} + +function _getBillingInvoices(token, username) { + return (callback) => { + Billing.getBillingInvoices(token, username, (err, res) => { + if(err) { + debug('NO BILLING ACCOUNT INVOICES'); + callback(null, []); + } else { + debug('GET BILLING INVOICES', res.body); + callback(err, res.body); + } + }); + }; +} + +//Get orgs for user +function _getOrgsForCurrentUser(token) { + return (callback) => { + Users.getOrgsForUser(token, function(err, res) { + if (err) { + debug(err); + callback(); + } else { + debug('GET Orgs for current User', res.body); + callback(null, res.body); + } + }); + }; +} + +//Get org's settings since we are in the organization's home +function _getOrgSettings(token, username) { + return (callback) => { + Orgs.getOrg(token, username, function (err, res) { + if (err) { + callback(); + } else { + debug('GET Orgs Settings', res.body); + callback(null, res.body); + } + }); + }; +} + +//Get user settings for private repo stats +var _getOrgPrivateRepoSettings = function(ac, token, username) { + return (cb) => { + Orgs.getOrgSettings(token, username, function (err, res) { + if (err) { + debug(err); + ac.dispatch('PRIVATE_REPOSTATS_NO_PERMISSIONS', err); + cb(null, err); + } else { + ac.dispatch('RECEIVE_PRIVATE_REPOSTATS', res.body); + cb(null, res.body); + } + }); + }; +}; + +var _getNamespaces = function(actionContext, token, profileuser) { + return (callback) => { + Users.getNamespacesForUser(token, function(err, res) { + if (err) { + callback(); + } else { + callback(null, res.body); + actionContext.dispatch('CREATE_REPO_RECEIVE_NAMESPACES', { + namespaces: res.body, + selectedNamespace: profileuser + }); + } + }); + }; +}; + +export default function billingOrgPlans({actionContext, payload, done, maybeData}){ + if (maybeData.token && maybeData.user) { + let {token, user} = maybeData; + let username = payload.params.user; + actionContext.dispatch('RESET_BILLING_PLANS'); + /* + NOTE: + None of the functions should pass an error into the callback or else it will + kill the rest of the calls and return before we're able to fetch all the data + */ + async.parallel({ + allPlans: _getOrgPlans(token), + userPlan: _getBillingSubscriptions(token, username), + accountInfo: _getBillingAccount(token, username), + billingInfo: _getBillingInfo(token, username), + invoiceList: _getBillingInvoices(token, username), + getOrgs: _getOrgsForCurrentUser(token), + getOrgSettings: _getOrgSettings(token, username), + getPrivateRepoStats: _getOrgPrivateRepoSettings(actionContext, token, username), + getNamespaces: _getNamespaces(actionContext, token, user.username) + }, function(err, results){ + const { + allPlans, + userPlan, + accountInfo, + billingInfo, + invoiceList, + getOrgs, + getOrgSettings + } = results; + debug('BILLING PLANS', results); + const gateway = accountInfo && accountInfo.account && accountInfo.account.payment_gateway; + const billforwardId = accountInfo && accountInfo.billforwardId; + + actionContext.dispatch('CURRENT_USER_CONTEXT', { username: username }); + actionContext.dispatch('RECEIVE_BILLING_PLANS', { + plansList: allPlans + }); + actionContext.dispatch('RECEIVE_BILLING_INFO', { + billingInfo: billingInfo, + accountInfo: accountInfo && accountInfo.account, + currentPlan: userPlan, + gateway, + billforwardId + }); + actionContext.dispatch('RECEIVE_INVOICES', {invoices: invoiceList}); + actionContext.dispatch('RECEIVE_DASHBOARD_NAMESPACES', { + orgs: getOrgs, + user: user.username + }); + actionContext.dispatch('RECEIVE_ORGANIZATION', getOrgSettings); + return done(); + }); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/orgDashTeams.js b/app/scripts/actions/navigate/orgDashTeams.js new file mode 100644 index 0000000000..41987a8e12 --- /dev/null +++ b/app/scripts/actions/navigate/orgDashTeams.js @@ -0,0 +1,146 @@ +'use strict'; +var debug = require('debug')('navigate orgHome'); +import async from 'async'; +import _ from 'lodash'; +import { + Repositories as Repos, + Notifications, + Orgs, + Users +} from 'hub-js-sdk'; + +export default function home({actionContext, payload, done, maybeData}){ + if (_.has(maybeData, 'token')) { + const currentOrg = payload.params.user; + + //Get orgs for user + var _getOrgsForCurrentUser = function(cb) { + Users.getOrgsForUser(maybeData.token, function(err, res) { + if (err) { + debug(err); + cb(); + } else { + actionContext.dispatch('RECEIVE_DASHBOARD_NAMESPACES', { + orgs: res.body, + user: maybeData.user.username + }); + cb(); + } + }); + }; + + //Get user settings for private repo stats + var _getOrgPrivateRepoSettings = function(cb) { + Orgs.getOrgSettings(maybeData.token, currentOrg, function(err, res) { + if (err) { + debug(err); + actionContext.dispatch('PRIVATE_REPOSTATS_NO_PERMISSIONS', err); + actionContext.dispatch('TEAM_READ_ONLY', true); + cb(); + } else { + actionContext.dispatch('RECEIVE_PRIVATE_REPOSTATS', res.body); + actionContext.dispatch('TEAM_READ_ONLY', false); + cb(); + } + }); + }; + + //Get Teams for an org + var _getTeamsForOrg = function(cb) { + Orgs.getTeams(maybeData.token, currentOrg, function(err, res) { + if (err) { + debug(err); + cb(); + } else { + actionContext.dispatch('RECEIVE_DASHBOARD_ORG_TEAMS', res.body); + cb(); + } + }); + }; + + var _getNamespaces = function(cb) { + Users.getNamespacesForUser(maybeData.token, function(err, res) { + if (err) { + cb(); + } else { + cb(null, res.body); + actionContext.dispatch('CREATE_REPO_RECEIVE_NAMESPACES', { + namespaces: res.body, + selectedNamespace: maybeData.user.username + }); + } + }); + }; + + const currentTeam = payload.location.query.team; + var _getTeam = function(cb) { + if (currentTeam) { + Orgs.getTeam(maybeData.token, currentOrg, currentTeam, function(err, res) { + if (err) { + actionContext.dispatch('ORG_DASHBOARD_MEMBERS_ERROR', err); + cb(null, err); + } else { + actionContext.dispatch('RECEIVE_DASHBOARD_ORG_TEAM', res.body); + cb(null, res.body); + } + }); + } else { + cb(null, null); + } + }; + + var _getMembers = function(cb) { + if (currentTeam) { + Orgs.getMembers(maybeData.token, currentOrg, currentTeam, function(err, res) { + if (err) { + actionContext.dispatch('ORG_DASHBOARD_MEMBERS_ERROR', err); + cb(null, err); + } else { + actionContext.dispatch('RECEIVE_DASHBOARD_TEAM_MEMBERS', res.body); + cb(null, res.body); + } + }); + } else { + cb(null, null); + } + }; + + //This is executed only for the currently logged in user, so put in starred, contributed only here + var _doParallelCalls = function() { + async.parallel([ + _getOrgsForCurrentUser, + _getOrgPrivateRepoSettings, + _getTeamsForOrg, + _getNamespaces, + _getTeam, + _getMembers + ], function(err, results) { + if (err) { + debug(err); + } + return done(); + }); + }; + + //Get Organization by name + var _getOrgByName = function(name, cb) { + Orgs.getOrg(maybeData.token, name, function(err, res) { + if (err) { + debug(err); + cb({}); + } else { + var org = res.body; + cb(org); + } + }); + }; + + actionContext.dispatch('CURRENT_USER_CONTEXT', { + username: payload.params.user + }); + _doParallelCalls(); + + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/orgHome.js b/app/scripts/actions/navigate/orgHome.js new file mode 100644 index 0000000000..dc2a9bd106 --- /dev/null +++ b/app/scripts/actions/navigate/orgHome.js @@ -0,0 +1,143 @@ +'use strict'; +var debug = require('debug')('navigate orgHome'); +import async from 'async'; +import _ from 'lodash'; +import { + Repositories as Repos, + Notifications, + Orgs, + Users +} from 'hub-js-sdk'; + +export default function home({actionContext, payload, done, maybeData}){ + if (_.has(maybeData, 'token')) { + + //Get repos for user or org + var _getReposForUserOrOrg = function(cb, userType) { + actionContext.dispatch('DASHBOARD_REPOS_STORE_ATTEMPTING_GET_REPOS'); + Repos.getReposForUser(maybeData.token, payload.params.user, function(err, res) { + if (err) { + actionContext.dispatch('ERROR_RECEIVING_REPOS'); + cb(); + } else { + actionContext.dispatch('RECEIVE_REPOS', res.body); + cb(); + } + }, payload.location.query.page); + }; + + //Get contributed repos + var _getReposByFilterTypeContrib = function(cb) { + var username = maybeData.user.username; + Repos.getContributedReposForUser(maybeData.token, username, function (err, res) { + if (err) { + cb(); + } else { + var resultPayload = {type: 'contrib', results: res.body.results}; + actionContext.dispatch('RECEIVE_CONTRIB', resultPayload); + cb(); + } + }); + }; + + //Get orgs for user + var _getOrgsForCurrentUser = function(cb) { + Users.getOrgsForUser(maybeData.token, function(err, res) { + if (err) { + debug(err); + cb(); + } else { + actionContext.dispatch('RECEIVE_DASHBOARD_NAMESPACES', { + orgs: res.body, + user: maybeData.user.username + }); + cb(); + } + }); + }; + + //Get user settings for private repo stats + var _getOrgPrivateRepoSettings = function(cb) { + Orgs.getOrgSettings(maybeData.token, payload.params.user, function(err, res) { + if (err) { + debug(err); + actionContext.dispatch('PRIVATE_REPOSTATS_NO_PERMISSIONS', err); + actionContext.dispatch('TEAM_READ_ONLY', true); + cb(); + } else { + actionContext.dispatch('RECEIVE_PRIVATE_REPOSTATS', res.body); + actionContext.dispatch('TEAM_READ_ONLY', false); + cb(); + } + }); + }; + + //Get org's settings since we are in the organization's home + var _getOrgSettings = function(cb) { + Orgs.getOrg(maybeData.token, payload.params.user, function (err, res) { + if (err) { + cb(); + } else { + actionContext.dispatch('RECEIVE_ORGANIZATION', res.body); + cb(); + } + }); + }; + + var _getNamespaces = function(cb) { + Users.getNamespacesForUser(maybeData.token, function(err, res) { + if (err) { + cb(); + } else { + cb(null, res.body); + actionContext.dispatch('CREATE_REPO_RECEIVE_NAMESPACES', { + namespaces: res.body, + selectedNamespace: maybeData.user.username + }); + } + }); + }; + + //This is executed only for the currently logged in user, so put in starred, contributed only here + var _doParallelCalls = function() { + async.parallel([ + _getReposForUserOrOrg, + _getOrgsForCurrentUser, + _getReposByFilterTypeContrib, + _getOrgSettings, + _getOrgPrivateRepoSettings, + _getNamespaces + ], function(err, results) { + if (err) { + debug(err); + } + return done(); + }); + }; + + //Get Organization by name + var _getOrgByName = function(name, cb) { + Orgs.getOrg(maybeData.token, name, function(err, res) { + if (err) { + debug(err); + cb({}); + } else { + var org = res.body; + cb(org); + } + }); + }; + + if (payload.params.user) { + actionContext.dispatch('CURRENT_USER_CONTEXT', { username: payload.params.user }); + _doParallelCalls(); + } else { + debug('mark 5'); + actionContext.dispatch('CURRENT_USER_CONTEXT', { username: payload.params.user }); + _doParallelCalls(); + } + } else { + debug('mark 6'); + done(); + } +} diff --git a/app/scripts/actions/navigate/orgSettings.js b/app/scripts/actions/navigate/orgSettings.js new file mode 100644 index 0000000000..e0ff148a60 --- /dev/null +++ b/app/scripts/actions/navigate/orgSettings.js @@ -0,0 +1,66 @@ +'use strict'; + +import _ from 'lodash'; +import { + Users + } from 'hub-js-sdk'; +import async from 'async'; +var debug = require('debug')('navigate::orgSettings'); + +export default function orgs({actionContext, payload, done, maybeData}){ + debug('Organization Settings Navigate Token -> ' + maybeData.token); + debug('ORG SETTINGS PAYLOAD', payload); + let orgName = payload.params.user; + if (_.has(maybeData, 'token')) { + async.parallel({ + getOrgs: function(callback) { + Users.getOrgsForUser(maybeData.token, function(getErr, getRes) { + if (getErr) { + debug(getErr); + callback(); + } else { + actionContext.dispatch('RECEIVE_DASHBOARD_NAMESPACES', { + orgs: getRes.body, + user: maybeData.user.username + }); + actionContext.dispatch('CURRENT_USER_ORGS', getRes.body); + actionContext.dispatch('SELECT_ORGANIZATION', orgName); + actionContext.dispatch('CURRENT_USER_CONTEXT', { username: orgName }); + callback(null, getRes.body); + } + }); + }, + getUserSettings: function(callback) { + let username = payload.params.user; + Users.getUserSettings(maybeData.token, username, function(getErr, getRes) { + if (getErr){ + debug(getErr); + callback(); + } else { + let is_private = (getRes.default_repo_visibility); + actionContext.dispatch('CREATE_REPO_UPDATE_FIELD_WITH_VALUE', {fieldKey: 'is_private', fieldValue: is_private}); + actionContext.dispatch('RECEIVE_PRIVATE_REPOSTATS', getRes.body); + callback(null, getRes.body); + } + }); + }, + getNamespaces: function(callback) { + Users.getNamespacesForUser(maybeData.token, function(err, res) { + if (err) { + callback(); + } else { + callback(null, res.body); + actionContext.dispatch('CREATE_REPO_RECEIVE_NAMESPACES', { + namespaces: res.body, + selectedNamespace: maybeData.user.username + }); + } + }); + } + }, function(err, res){ + done(); + }); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/orgSummary.js b/app/scripts/actions/navigate/orgSummary.js new file mode 100644 index 0000000000..e385c69e4a --- /dev/null +++ b/app/scripts/actions/navigate/orgSummary.js @@ -0,0 +1,24 @@ +'use strict'; + +import _ from 'lodash'; +import { + Users + } from 'hub-js-sdk'; +var debug = require('debug')('navigate::orgSummary'); + +export default function orgs({actionContext, payload, done, maybeData}){ + debug('Organization Settings Navigate Token -> ' + maybeData.token); + if (_.has(maybeData, 'token')) { + Users.getOrgsForUser(maybeData.token, function(err, res) { + if (err) { + debug(err); + done(); + } else { + actionContext.dispatch('CURRENT_USER_ORGS', res.body); + done(); + } + }); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/repo.js b/app/scripts/actions/navigate/repo.js new file mode 100644 index 0000000000..6680ef4b72 --- /dev/null +++ b/app/scripts/actions/navigate/repo.js @@ -0,0 +1,100 @@ +'use strict'; + +import _ from 'lodash'; +import async from 'async'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +import { + Repositories as Repos, + Autobuilds +} from 'hub-js-sdk'; + +export default function repo({actionContext, payload, done, maybeData}){ + var token; + if (_.has(maybeData, 'token')) { + token = maybeData.token; + } else { + token = ''; + } + + var namespace = payload.params.user; + if(payload.params.user === '_') { + namespace = 'library'; + } + var repoShortName = namespace + '/' + payload.params.splat; + + //1. Get repository details + //2. If successful, set valid repo and pass it along for the next set of calls + //3. If not valid, set repo to be not valid and dispatch repo not found + var _getRepo = function(cb) { + Repos.getRepo(token, repoShortName, function(err, res) { + var repoInfo = {}; + let status; + if (res && res.body) { + status = res.body.status; + } + + if (err || status === PENDING_DELETE) { + repoInfo.error = err; + repoInfo.isValid = false; + cb(null, repoInfo); + actionContext.dispatch('REPO_NOT_FOUND', err); + } else { + repoInfo.info = res.body; + actionContext.dispatch('RECEIVE_REPOSITORY', res.body); + repoInfo.isValid = true; + cb(null, repoInfo); + } + }); + }; + + var _getRepoDetails = function(repoInfo, cb) { + if (repoInfo.isValid) { + async.parallel([ + function(callback) { + Repos.getCommentsForRepo(token, repoShortName, function(err, res) { + if (err) { + callback(err); + } else { + callback(null, res.body); + } + }, 1); + }, function(callback) { + if (repoInfo.info.is_automated) { + Autobuilds.getAutomatedBuildSettings(token, namespace, payload.params.splat, function (err, res) { + if (err) { + actionContext.dispatch('AUTOBUILD_REPO_NOT_FOUND'); + callback(null); + } else { + callback(null, res.body); + } + }); + } else { + callback(null); + } + } + ], + function(error, results) { + if (error) { + cb(error); + } else { + cb(null, results); + } + }); + } else { + cb('repo not found'); + } + }; + + async.waterfall([ + _getRepo, + _getRepoDetails + ], function(e, finalResults) { + if (finalResults) { + actionContext.dispatch('RECEIVE_REPO_COMMENTS', finalResults[0]); + if (finalResults[1]) { + actionContext.dispatch('RECEIVE_AUTOBUILD_SETTINGS', finalResults[1]); + } + } + done(); + }); +} diff --git a/app/scripts/actions/navigate/repoDetailsScannedTag.js b/app/scripts/actions/navigate/repoDetailsScannedTag.js new file mode 100644 index 0000000000..dd7e9803d4 --- /dev/null +++ b/app/scripts/actions/navigate/repoDetailsScannedTag.js @@ -0,0 +1,114 @@ +'use strict'; + +import { parallel } from 'async'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +import has from 'lodash/object/has'; +import merge from 'lodash/object/merge'; +import { Repositories as Repos } from 'hub-js-sdk'; +import { + RECEIVE_REPO, + RECEIVE_SCANNED_TAG_DATA, + ERROR +} from 'reduxConsts'; +const debug = require('debug')('navigate::repoDetailsScannedTagData'); +import request from 'superagent'; +import { normalize, arrayOf } from 'normalizr'; +import { scan } from 'normalizers'; + +const getRepo = ({maybeToken, actionContext, user, splat}) => (callback) => { + Repos.getRepo(maybeToken, `${user}/${splat}`, function(err, res) { + let status; + if (res && res.body) { + status = res.body.status; + } + + if (err || status === PENDING_DELETE) { + actionContext.dispatch('REPO_NOT_FOUND', err); + return callback(err); + } else { + return callback(null, res.body); + } + }); +}; + +/* + * Get nautilus scan info for the tag. + * TODO: Once this API is in it's final form for the jan release, move it to the SDK + */ +const getScanForTag = ({actionContext, maybeToken, payload, user, splat, tagname, done}) => (callback) => { + const namespace = user; + const name = splat; + const req = request.get(`${process.env.NAUTILUS_API_BASE_URL}/repositories/result?namespace=${namespace}&reponame=${name}&tag=${tagname}&detailed=1`); + if (maybeToken) { + req.set('Authorization', 'JWT ' + maybeToken); + } + req.timeout(7000); + req.end((err, res) => { + if (err) { + // NOTE: Suffixing the dispatch type with _STATUS means the status + // reducer will record this in the same way as our SDK calls + // via the middleware. + // When we move this to the SDK, the error will be dispatched automatically + actionContext.reduxStore.dispatch({ + type: `${RECEIVE_SCANNED_TAG_DATA}_STATUS`, + payload: { + status: ERROR, + statusKey: ['getScanForTag', namespace, name, tagname], + error: err + }, + error: true + }); + return callback(err); + } else { + // The API response contains a 'scan' resource within an object + // inside 'scan_details' + const { scan_details, image: { reponame, tag } } = res.body; + const { latest_scan_status } = res.body; + const result = { latest_scan_status, reponame, tag, ...scan_details }; + const normalized = normalize(result, scan); + return callback(null, normalized); + } + }); +}; + +export default function repoDetailsScannedTag({actionContext, payload, done, maybeData}){ + let token = ''; + if (has(maybeData, 'token')) { + token = maybeData.token; + } + const args = { + actionContext, + maybeToken: token, + user: payload.params.user, + splat: payload.params.splat, + tagname: payload.params.tagname + }; + + parallel({ + repo: getRepo(args), + tagScan: getScanForTag(args) + }, function(err, res){ + if (err) { + actionContext.dispatch('REPO_NOT_FOUND', err); + } else { + const { repo, tagScan } = res; + /* REPOS */ + //required for repository page header to display + actionContext.dispatch('RECEIVE_REPOSITORY', repo); + // We also need to dispatch to Redux; this will store the current repo + // within the repos reducer allowing us to find the namespace and repo + // name for the current route. + actionContext.reduxStore.dispatch({ + type: RECEIVE_REPO, + payload: repo + }); + + /* SCANS */ + actionContext.reduxStore.dispatch({ + type: RECEIVE_SCANNED_TAG_DATA, + payload: tagScan + }); + } + done(); + }); +} diff --git a/app/scripts/actions/navigate/repoDetailsTags.js b/app/scripts/actions/navigate/repoDetailsTags.js new file mode 100644 index 0000000000..576505e142 --- /dev/null +++ b/app/scripts/actions/navigate/repoDetailsTags.js @@ -0,0 +1,147 @@ +'use strict'; +const debug = require('debug')('navigate::repo'); +import { parallel } from 'async'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +import has from 'lodash/object/has'; +import merge from 'lodash/object/merge'; +import request from 'superagent'; +import { + RECEIVE_NAUTILUS_TAGS_FOR_REPOSITORY, + RECEIVE_TAGS_FOR_REPOSITORY, + RECEIVE_REPO +} from 'reduxConsts.js'; +import { normalize, arrayOf } from 'normalizr'; +import { tag } from 'normalizers'; +import { + Repositories as Repos +} from 'hub-js-sdk'; + +const getRepo = ({maybeToken, actionContext, user, splat}) => (callback) => { + Repos.getRepo(maybeToken, `${user}/${splat}`, function(err, res) { + let status; + if (res && res.body) { + status = res.body.status; + } + + if (err || status === PENDING_DELETE) { + return callback(err); + } else { + return callback(null, res.body); + } + }); +}; + +// getTagsForRepo uses the hub API for loading tag information. This is used to +// show information about tags that are not scanned. +// +// We use this and the nautilus API because each API has incomplete information: +// - The nautilus API has vulnerability information +// - This API has the image size +// +// NOTE: This dispatches to Redux reducers and fluxible stores in the final callback +const getTagsForRepo = ({actionContext, maybeToken, user, splat}) => (callback) => { + const namespace = user; + const reponame = splat; + Repos.getTagsForRepo(maybeToken, `${user}/${splat}`, function(err, res) { + if (err) { + return callback(err); + } + const { results } = res.body; + // TODO: Assert normalize works as expected via jest/mocha + const tags = normalize(results, arrayOf(tag)); + return callback(null, { namespace, reponame, tags }); + }); +}; + +// getNautilusTagsForRepo uses the nautilus API to load tags and their +// vulnerability information via the nautilus API. +// +// NOTE: This dispatches to Redux reducers, not fluxible stores +const getNautilusTagsForRepo = ({actionContext, maybeToken, user, splat}) => (callback) => { + const namespace = user; + const reponame = splat; + const req = request.get(`${process.env.NAUTILUS_API_BASE_URL}/repositories/summaries/${namespace}/${reponame}`); + if (maybeToken) { + req.set('Authorization', 'JWT ' + maybeToken); + } + req.timeout(7000); + req.end((err, res) => { + if (err) { + // Nautilus call does NOT trigger an error page + return callback(null, null); + } + // TODO: Assert normalize works as expected via jest/mocha + const tags = normalize( + res.body, + arrayOf(tag), + { + // TODO: Add records + // NOTE: The nautius API uses a field called 'tag' to represent the + // tag name, whereas the HUB api uses 'name'. + // + // Our record, frontend code and normalizr key expects us to use + // the 'name' field. This normalizes the record to tag. + assignEntity: (obj, key, val) => { + obj[key] = val; + if (key === 'tag') { + obj.name = val; + delete obj.tag; + } + } + } + ); + return callback(null, { namespace, reponame, tags }); + }); +}; + +export default function repoDetailsTags({actionContext, payload, done, maybeData}) { + debug('maybeData:', maybeData); + let token = ''; + if (has(maybeData, 'token')) { + token = maybeData.token; + } + const { user, splat } = payload.params; + const args = { + actionContext, + maybeToken: token, + user, + splat + }; + + parallel({ + repo: getRepo(args), + tags: getTagsForRepo(args), + scans: getNautilusTagsForRepo(args) + }, (err, res) => { + if (err) { + // Tags or repo error + actionContext.dispatch('REPO_NOT_FOUND', err); + } else { + const { repo, tags, scans } = res; + /* REPO */ + actionContext.dispatch('RECEIVE_REPOSITORY', repo); + // We also need to dispatch to Redux; this will store the current repo + // within the repos reducer allowing us to find the namespace and repo + // name for the current route. + actionContext.reduxStore.dispatch({ + type: RECEIVE_REPO, + payload: repo + }); + + /* TAGS */ + actionContext.reduxStore.dispatch({ + type: RECEIVE_TAGS_FOR_REPOSITORY, + payload: tags + }); + + /* SCANS */ + if (scans) { + actionContext.reduxStore.dispatch({ + type: RECEIVE_NAUTILUS_TAGS_FOR_REPOSITORY, + payload: scans + }); + } + } + done(); + }); +} diff --git a/app/scripts/actions/navigate/repoOfficial.js b/app/scripts/actions/navigate/repoOfficial.js new file mode 100644 index 0000000000..9fcf0e3dba --- /dev/null +++ b/app/scripts/actions/navigate/repoOfficial.js @@ -0,0 +1,48 @@ +'use strict'; + +import _ from 'lodash'; +import async from 'async'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +import { + Repositories as Repos +} from 'hub-js-sdk'; + +export default function repo({actionContext, payload, done, maybeData}){ + var token; + if (_.has(maybeData, 'token')) { + token = maybeData.token; + } else { + token = ''; + } + var repoShortName = 'library/' + payload.params.splat; + async.series([ + function(callback) { + Repos.getRepo(token, repoShortName, function(err, res) { + let status; + if (res && res.body) { + status = res.body.status; + } + + if (err || status === PENDING_DELETE) { + actionContext.dispatch('REPO_NOT_FOUND', err); + callback(err); + } else { + actionContext.dispatch('RECEIVE_REPOSITORY', res.body); + callback(null, res.body); + } + }); + }, function(callback) { + Repos.getCommentsForRepo(token, repoShortName, function(err, res) { + if (err) { + callback(err); + } else { + actionContext.dispatch('RECEIVE_REPO_COMMENTS', res.body); + callback(null, res.body); + } + }); + } + ], + function(error, results) { + done(); + }); +} diff --git a/app/scripts/actions/navigate/repoSettings.js b/app/scripts/actions/navigate/repoSettings.js new file mode 100644 index 0000000000..0af30fd929 --- /dev/null +++ b/app/scripts/actions/navigate/repoSettings.js @@ -0,0 +1,67 @@ +'use strict'; +const debug = require('debug')('navigate::repo'); +import { parallel, waterfall } from 'async'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +import _ from 'lodash'; +import { + Repositories as Repos, + Users +} from 'hub-js-sdk'; + +function getRepo({maybeToken, actionContext, user, splat}) { + return function(callback){ + Repos.getRepo(maybeToken, `${user}/${splat}`, function(err, res) { + let status; + if (res && res.body) { + status = res.body.status; + } + + if (err || status === PENDING_DELETE) { + actionContext.dispatch('REPO_NOT_FOUND', res.body); + return callback(null, null); + } else { + actionContext.dispatch('RECEIVE_REPOSITORY', res.body); + return callback(null, res.body); + } + }); + }; +} + +function handleGetPrivateRepoStats({maybeToken, user, actionContext}) { + return function(repoDetails, callback) { + if (repoDetails) { + Users.getUserSettings(maybeToken, user, function (err, res) { + if (err) { + callback(); + } else { + actionContext.dispatch('RECEIVE_PRIVATE_REPOSTATS', res.body); + callback(); + } + }); + } else { + callback(null, null); + } + }; +} + +export default function repoSettingsMain({actionContext, payload, done, maybeData}){ + debug('maybeData:', maybeData); + if (_.has(maybeData, 'token')) { + let args = { + actionContext, + maybeToken: maybeData.token, + user: payload.params.user, + splat: payload.params.splat + }; + + waterfall([ + getRepo(args), + handleGetPrivateRepoStats(args) + ], function(err, res){ + done(); + }); + } else { + actionContext.dispatch('REPO_NOT_FOUND', null); + done(); + } +} diff --git a/app/scripts/actions/navigate/repoSettingsCollaborators.js b/app/scripts/actions/navigate/repoSettingsCollaborators.js new file mode 100644 index 0000000000..d6b5878d91 --- /dev/null +++ b/app/scripts/actions/navigate/repoSettingsCollaborators.js @@ -0,0 +1,97 @@ +'use strict'; +const debug = require('debug')('navigate::repo'); +import { parallel } from 'async'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +import _ from 'lodash'; +import { + Repositories as Repos, + Orgs +} from 'hub-js-sdk'; + +function getRepo({maybeToken, actionContext, user, splat}) { + return function(callback){ + Repos.getRepo(maybeToken, `${user}/${splat}`, function(err, res) { + let status; + if (res && res.body) { + status = res.body.status; + } + + if (err || status === PENDING_DELETE) { + actionContext.dispatch('REPO_NOT_FOUND', null); + return callback(err); + } + actionContext.dispatch('RECEIVE_REPOSITORY', res.body); + return callback(); + }); +}; +} + +// GET's the collaborators for a user repo (which should be individuals) +function getCollaborators({maybeToken, actionContext, user, splat}) { + return function(cb) { + Repos.getCollaboratorsForRepo(maybeToken, `${user}/${splat}`, (err, res) => { + if(err) { + // 'Org repositories do not have collaborators.' + actionContext.dispatch('COLLAB_RECEIVE_COLLABORATORS', {}); + } else { + actionContext.dispatch('COLLAB_RECEIVE_COLLABORATORS', res.body); + } + cb(); + }); + }; +} + + //GET's the collaborators for an organization repo (which should be teams) +function getTeamCollaborators({maybeToken, actionContext, user, splat}) { + return function(cb) { + Repos.getTeamCollaboratorsForRepo(maybeToken, `${user}/${splat}`, (err, res) => { + if (err) { + // 'User repository does not have any teams yet' + actionContext.dispatch('COLLAB_RECEIVE_TEAMS', {}); + } else { + actionContext.dispatch('COLLAB_RECEIVE_TEAMS', res.body); + } + cb(); + }); + }; +} + +// GET's all teams for the organization +function getOrgTeams({maybeToken, actionContext, user, splat}) { + return function(cb) { + Orgs.getTeams(maybeToken, user, (err, res) => { + if (err) { + // 'No such organization' + actionContext.dispatch('COLLAB_RECEIVE_TEAMS', {}); + actionContext.dispatch('COLLAB_RECEIVE_ALL_TEAMS', {results: []}); + } else { + actionContext.dispatch('COLLAB_RECEIVE_ALL_TEAMS', res.body); + } + cb(); + }); + }; +} + +export default function repoSettingsMain({actionContext, payload, done, maybeData}){ + debug('maybeData:', maybeData); + if (_.has(maybeData, 'token')) { + let args = { + actionContext, + maybeToken: maybeData.token, + user: payload.params.user, + splat: payload.params.splat + }; + + parallel([ + getRepo(args), + getCollaborators(args), + getTeamCollaborators(args), + getOrgTeams(args) + ], function(err, res){ + done(); + }); + } else { + actionContext.dispatch('REPO_NOT_FOUND', null); + done(); + } +} diff --git a/app/scripts/actions/navigate/resetPass.js b/app/scripts/actions/navigate/resetPass.js new file mode 100644 index 0000000000..fa68db137a --- /dev/null +++ b/app/scripts/actions/navigate/resetPass.js @@ -0,0 +1,6 @@ +'use strict'; + +export default function resetPass({actionContext, payload, done, maybeData}){ + actionContext.dispatch('CHANGE_PASS_CLEAR'); + done(); +} diff --git a/app/scripts/actions/navigate/search.js b/app/scripts/actions/navigate/search.js new file mode 100644 index 0000000000..d0652a2ce4 --- /dev/null +++ b/app/scripts/actions/navigate/search.js @@ -0,0 +1,44 @@ +'use strict'; +var debug = require('debug')('navigate::search'); + +import { + Search +} from 'hub-js-sdk'; + +export default function search({actionContext, payload, done, maybeData}){ + debug('Hit /search:: Query = ' + JSON.stringify(payload.location.query)); + debug('Searching for: ' + payload.location.query.q); + //the filter param values are `0` because the python api accepts either `0` or `False`, we decided to use `0` + var searchQueryParams = { + query: payload.location.query.q || '', + page: payload.location.query.page || '', + isAutomated: payload.location.query.isAutomated || 0, + isOfficial: payload.location.query.isOfficial || 0, + starCount: payload.location.query.starCount || 0, + pullCount: payload.location.query.pullCount || 0 + }; + + //TODO: Maybe we should have a single dispatch here? + actionContext.dispatch('SUBMIT_SEARCH_QUERY', searchQueryParams.query); + actionContext.dispatch('UPDATE_SEARCH_PAGE', searchQueryParams.page); + actionContext.dispatch('UPDATE_SEARCH_OTHERFILTERS', searchQueryParams); + + //This is to search repositories + Search.searchRepos(maybeData.token, searchQueryParams, function(err, res) { + if (err) { + debug(err); + actionContext.dispatch('SEARCH_ERROR', err); + done(); + } else { + debug(res); + var queryResult = res.body; + //Query Result details (for paging) + if (queryResult) { + actionContext.dispatch('PROCESS_SEARCH_RESULTS', queryResult); + done(); + } else { + done(); + } + } + }); +} diff --git a/app/scripts/actions/navigate/serverBilling.js b/app/scripts/actions/navigate/serverBilling.js new file mode 100644 index 0000000000..00f6fcfb78 --- /dev/null +++ b/app/scripts/actions/navigate/serverBilling.js @@ -0,0 +1,31 @@ +'use strict'; +var debug = require('debug')('navigate::home'); +import async from 'async'; +import _ from 'lodash'; +import { + Users +} from 'hub-js-sdk'; + +export default function enterpriseTrial({actionContext, payload, done, maybeData}){ + + if(payload.location.query.partnervalue) { + actionContext.dispatch('ENTERPRISE_PARTNER_RECEIVE_CODE', { + code: payload.location.query.partnervalue + }); + } + + if(_.has(maybeData, 'token')){ + Users.getNamespacesForUser(maybeData.token, function(err, res) { + if (err) { + done(); + } else { + if(res.body && res.body.namespaces) { + actionContext.dispatch('ENTERPRISE_PAID_RECEIVE_ORGS', res.body.namespaces); + } + done(); + } + }); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/serverTrial.js b/app/scripts/actions/navigate/serverTrial.js new file mode 100644 index 0000000000..7aae21f284 --- /dev/null +++ b/app/scripts/actions/navigate/serverTrial.js @@ -0,0 +1,31 @@ +'use strict'; +var debug = require('debug')('navigate::home'); +import async from 'async'; +import _ from 'lodash'; +import { + Users +} from 'hub-js-sdk'; + +export default function enterpriseTrial({actionContext, payload, done, maybeData}){ + + if(payload.location.query.partnervalue) { + actionContext.dispatch('ENTERPRISE_PARTNER_RECEIVE_CODE', { + code: payload.location.query.partnervalue + }); + } + + if(_.has(maybeData, 'token')){ + Users.getNamespacesForUser(maybeData.token, function(err, res) { + if (err) { + done(); + } else { + if(res.body && res.body.namespaces) { + actionContext.dispatch('ENTERPRISE_TRIAL_RECEIVE_ORGS', res.body.namespaces); + } + done(); + } + }); + } else { + done(); + } +} diff --git a/app/scripts/actions/navigate/serverTrialSuccess.js b/app/scripts/actions/navigate/serverTrialSuccess.js new file mode 100644 index 0000000000..6c4bc1f200 --- /dev/null +++ b/app/scripts/actions/navigate/serverTrialSuccess.js @@ -0,0 +1,39 @@ +'use strict'; +const debug = require('debug')('navigate::serverTrialSuccess'); +import merge from 'lodash/object/merge'; +import find from 'lodash/collection/find'; +import { Billing } from 'hub-js-sdk'; + +export default function serverTrialSuccess({ + actionContext, payload, done, maybeData +}){ + const { namespace } = payload.location.query; + if(maybeData.token) { + Billing.getLicensesForNamespace(maybeData.token, { namespace }, (err, res) => { + if (err) { + debug(err); + if(err.response.badRequest) { + const { detail } = err.response.body; + if(detail) { + actionContext.dispatch('RECEIVE_TRIAL_LICENSE_BAD_REQUEST', detail); + } + } else { + actionContext.dispatch('RECEIVE_TRIAL_LICENSE_FACEPALM', err); + } + done(); + } else { + let license = find(res.body.licenses, (obj) => { + return obj.tier === 'Trial' || obj.alias === 'Trial'; + }); + license = merge({}, license, { namespace }); + actionContext.dispatch('RECEIVE_TRIAL_LICENSE', license); + done(); + } + }); + } else { + // user must be logged in; they aren't + const err = `You must be logged in to access your trial license`; + actionContext.dispatch('RECEIVE_TRIAL_LICENSE_BAD_REQUEST', err); + done(); + } +} diff --git a/app/scripts/actions/navigate/toOrg.js b/app/scripts/actions/navigate/toOrg.js new file mode 100644 index 0000000000..f1497b3af1 --- /dev/null +++ b/app/scripts/actions/navigate/toOrg.js @@ -0,0 +1,7 @@ +'use strict'; +var debug = require('debug')('navigate::toOrg'); + +export default function updateOrgOwner({actionContext, payload, done, maybeData}){ + actionContext.dispatch('UPDATE_TO_ORG_OWNER', {owner: ''}); + done(); +} diff --git a/app/scripts/actions/navigate/user.js b/app/scripts/actions/navigate/user.js new file mode 100644 index 0000000000..8dfe8eea64 --- /dev/null +++ b/app/scripts/actions/navigate/user.js @@ -0,0 +1,58 @@ +'use strict'; +var debug = require('debug')('navigate::home'); +import async from 'async'; +import _ from 'lodash'; +import { + Repositories as Repos, + Users +} from 'hub-js-sdk'; + +export default function home({actionContext, payload, done, maybeData}){ + // This works without a jwt + var token = null; + + if (_.has(maybeData, 'token')) { + token = maybeData.token; + } + + //Get repos for user or org + var _getReposForUser = function(cb) { + actionContext.dispatch('DASHBOARD_REPOS_STORE_ATTEMPTING_GET_REPOS'); + Repos.getReposForUser(token, payload.params.user, function(err, res) { + if (err) { + cb(); + } else { + actionContext.dispatch('RECEIVE_PROFILE_REPOS', res.body); + cb(); + } + }, payload.location.query.page); + }; + + var _getUserByName = function(cb) { + Users.getUser(token, payload.params.user, function(err, res) { + if (err) { + if(res && res.notFound) { + actionContext.dispatch('USER_PROFILE_404'); + cb(err, null); + } else { + debug(err); + cb(); + } + } else { + actionContext.dispatch('RECEIVE_PROFILE_USER', res.body); + cb(); + } + }); + }; + + //Get user by name + async.parallel([ + _getReposForUser, + _getUserByName + ], function(err, results) { + if (err) { + debug(err); + } + return done(); + }); +} diff --git a/app/scripts/actions/navigate/userStars.js b/app/scripts/actions/navigate/userStars.js new file mode 100644 index 0000000000..4e5e1d8f42 --- /dev/null +++ b/app/scripts/actions/navigate/userStars.js @@ -0,0 +1,57 @@ +'use strict'; +var debug = require('debug')('navigate::userStars'); +import async from 'async'; +import _ from 'lodash'; +import { + Repositories as Repos, + Users +} from 'hub-js-sdk'; + +export default function userStars({actionContext, payload, done, maybeData}){ + // This works without a jwt + var token = null; + + if (_.has(maybeData, 'token')) { + token = maybeData.token; + } + + //Get repos for user or org + var _getStarredReposForUser = function(cb) { + Repos.getStarredReposForUser(token, payload.params.user, function(err, res) { + if (err) { + cb(); + } else { + actionContext.dispatch('RECEIVE_PROFILE_STARRED_REPOS', res.body); + cb(); + } + }, payload.location.query.page); + }; + + var _getUserByName = function(cb) { + Users.getUser(token, payload.params.user, function(err, res) { + if (err) { + if(res && res.notFound) { + actionContext.dispatch('USER_PROFILE_404'); + cb(err, null); + } else { + debug(err); + cb(); + } + } else { + actionContext.dispatch('RECEIVE_PROFILE_USER', res.body); + cb(); + } + }); + }; + + //Get user by name + async.parallel([ + _getStarredReposForUser, + _getUserByName + ], function(err, results) { + if (err) { + debug(err); + } + return done(); + }); +} diff --git a/app/scripts/actions/navigate/webhooks.js b/app/scripts/actions/navigate/webhooks.js new file mode 100644 index 0000000000..930d0ce151 --- /dev/null +++ b/app/scripts/actions/navigate/webhooks.js @@ -0,0 +1,76 @@ +'use strict'; + +import _ from 'lodash'; +import { parallel } from 'async'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +import { + Repositories as Repos +} from 'hub-js-sdk'; +import getPipelines from '@dux/hub-sdk/webhooks/getPipelines'; + +var debug = require('debug')('action::webhooks'); + +const fetchRepo = ({ token, namespace, name }) => callback => { + Repos.getRepo(token, `${namespace}/${name}`, (err, { body }) => { + const { status } = body; + if (err || status === PENDING_DELETE) { + // We should handle other possible errors here (500, etc) + return callback('REPO_NOT_FOUND'); + } else { + return callback(null, body); + } + }); +}; + +const fetchPipelines = ({ token, namespace, name }) => callback => { + getPipelines(token, { + namespace, + name + }, (err, res) => { + if (err) { + return callback(); + } else { + return callback(null, res.body); + } + }); +}; + +export default function fetchWebhooksPageData({ + actionContext: { dispatch }, + payload: { params }, + done, + maybeData: { token, user } +}) { + + if (!token) { + dispatch('REPO_NOT_FOUND', null); + return done(); + } + + debug(params); + const { + user: namespace, + splat: name + } = params; + + parallel({ + repository: fetchRepo({ token, namespace, name }), + pipelines: fetchPipelines({ token, namespace, name }) + }, + (err, { repository, pipelines }) => { + if(err) { + debug('err', err); + /** + * If there's an error, 404 by default, but handle other + * errors in the future + */ + dispatch('REPO_NOT_FOUND', null); + done(); + } else { + debug('receive', repository, pipelines); + dispatch('RECEIVE_REPOSITORY', repository); + dispatch('RECEIVE_WEBHOOKS', pipelines); + done(); + } + }); +} diff --git a/app/scripts/actions/onAddCollaboratorChange.js b/app/scripts/actions/onAddCollaboratorChange.js new file mode 100644 index 0000000000..8fb1d9f315 --- /dev/null +++ b/app/scripts/actions/onAddCollaboratorChange.js @@ -0,0 +1,5 @@ +'use strict'; + +export default function onAddCollaboratorChange(actionContext, { collaborator }) { + actionContext.dispatch('ON_ADD_COLLAB_CHANGE', collaborator); +} diff --git a/app/scripts/actions/redux/tags.js b/app/scripts/actions/redux/tags.js new file mode 100644 index 0000000000..3ae1df4988 --- /dev/null +++ b/app/scripts/actions/redux/tags.js @@ -0,0 +1,22 @@ +'use strict'; + +import { Repositories as Repos } from 'hub-js-sdk'; +import { DELETE_REPO_TAG } from 'reduxConsts'; +const debug = require('debug')('hub:actions:redux:deleteRepoTag'); + +export const deleteRepoTag = ({ JWT, namespace, name, tagName }) => ({ + type: DELETE_REPO_TAG, + payload: { + namespace, + reponame: name, + tagName + }, + meta: { + sdk: { + call: Repos.deleteRepoTag, + args: [JWT, { namespace, name, tagName }], + callback: (err, res) => ({}), + statusKey: ['deleteRepoTag', tagName] + } + } +}); diff --git a/app/scripts/actions/regenTriggerToken.js b/app/scripts/actions/regenTriggerToken.js new file mode 100644 index 0000000000..d978f2fb2f --- /dev/null +++ b/app/scripts/actions/regenTriggerToken.js @@ -0,0 +1,27 @@ +'use strict'; +const debug = require('debug')('hub:actions:regenTriggerToken'); +import { + Autobuilds as AutoBuild + } from 'hub-js-sdk'; + +function getTriggerStatus(JWT, actionContext, namespace, name) { + AutoBuild.getTriggerStatus(JWT, namespace, name, function(err, res) { + if (err) { + debug('error', err); + } + if (res.ok) { + actionContext.dispatch('RECEIVE_TRIGGER_STATUS', res.body); + } + }); +} + +export default function regenTriggerToken(actionContext, {JWT, namespace, name}) { + AutoBuild.regenBuildTriggerToken(JWT, namespace, name, function(err, res) { + if (err) { + debug('error', err); + } + if (res.ok) { + getTriggerStatus(JWT, actionContext, namespace, name); + } + }); +} diff --git a/app/scripts/actions/removeTeam.js b/app/scripts/actions/removeTeam.js new file mode 100644 index 0000000000..3c506007f7 --- /dev/null +++ b/app/scripts/actions/removeTeam.js @@ -0,0 +1,26 @@ +'use strict'; + +var debug = require('debug')('hub:actions:removeTeam'); + +import { Orgs } from 'hub-js-sdk'; + +var removeTeam = function(actionContext, {jwt, orgname, teamname}) { + Orgs.deleteTeam(jwt, orgname, teamname, function(delErr, delRes) { + if (delErr) { + debug('error', delErr); + actionContext.dispatch('TEAM_ERROR', delErr); + } else if (delRes.ok) { + actionContext.dispatch('DELETE_DASHBOARD_TEAM_SUCCESS'); + actionContext.history.push(`/u/${orgname}/dashboard/teams/`); + Orgs.getTeams(jwt, orgname, function(err, res) { + if (err) { + debug('getTeams error', err); + } else { + actionContext.dispatch('RECEIVE_DASHBOARD_ORG_TEAMS', res.body); + } + }); + } + }); +}; + +module.exports = removeTeam; diff --git a/app/scripts/actions/removeTeamMember.js b/app/scripts/actions/removeTeamMember.js new file mode 100644 index 0000000000..5d6b6b298a --- /dev/null +++ b/app/scripts/actions/removeTeamMember.js @@ -0,0 +1,24 @@ +'use strict'; + +var debug = require('debug')('hub:actions:removeTeamMember'); + +import { Orgs } from 'hub-js-sdk'; + +var removeTeamMember = function(actionContext, {jwt, orgname, teamname, membername}) { + Orgs.deleteMember(jwt, orgname, teamname, membername, function(delErr, delRes) { + if (delErr) { + debug('error', delErr); + actionContext.dispatch('TEAM_MEMBER_ERROR', delErr); + } else if (delRes.ok) { + Orgs.getMembers(jwt, orgname, teamname, function(err, res) { + if (err) { + debug('getMembers error', err); + } else { + actionContext.dispatch('RECEIVE_DASHBOARD_TEAM_MEMBERS', res.body); + } + }); + } + }); +}; + +module.exports = removeTeamMember; diff --git a/app/scripts/actions/removeTriggerLink.js b/app/scripts/actions/removeTriggerLink.js new file mode 100644 index 0000000000..3a7d25f527 --- /dev/null +++ b/app/scripts/actions/removeTriggerLink.js @@ -0,0 +1,20 @@ +'use strict'; +import _ from 'lodash'; +import { Autobuilds as AutoBuild } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:removeTriggerLink'); + +export default function(actionContext, { JWT, namespace, name, repo_id }) { + AutoBuild.deleteAutomatedBuildLink(JWT, namespace, name, repo_id, function(err, res) { + if (err) { + debug('error', err); + } else { + AutoBuild.getAutomatedBuildLinks(JWT, namespace, name, function(getErr, getRes){ + if (getErr) { + debug('getAutomatedBuildLinks error', err); + } else { + actionContext.dispatch('RECEIVE_AUTOBUILD_LINKS', getRes.body.results); + } + }); + } + }); +} diff --git a/app/scripts/actions/removeWebhookFromPipeline.js b/app/scripts/actions/removeWebhookFromPipeline.js new file mode 100644 index 0000000000..6f77808a66 --- /dev/null +++ b/app/scripts/actions/removeWebhookFromPipeline.js @@ -0,0 +1,8 @@ +'use strict'; + +export default function removeWebhookFromPipeline({ dispatch }, + params, + done) { + dispatch('ADD_WEBHOOK_REMOVE_HOOK'); + done(); +} diff --git a/app/scripts/actions/resendConfirmationEmail.js b/app/scripts/actions/resendConfirmationEmail.js new file mode 100644 index 0000000000..336762c181 --- /dev/null +++ b/app/scripts/actions/resendConfirmationEmail.js @@ -0,0 +1,20 @@ +'use strict'; +import { + Emails + } from 'hub-js-sdk'; +const debug = require('debug')('hub:actions:resendConfirmationEmail'); + +var resendConfirmationEmail = function(actionContext, {JWT, emailID}) { + actionContext.dispatch('RESEND_EMAIL_CONFIRMATION_ATTEMPT_START', emailID); + Emails.resendConfirmationEmail(JWT, emailID, function(err, res) { + if (err) { + debug('error', err); + actionContext.dispatch('RESEND_EMAIL_CONFIRMATION_FAILED', emailID); + } else { + actionContext.dispatch('RESEND_EMAIL_CONFIRMATION_SENT', emailID); + } + setTimeout(() => {actionContext.dispatch('RESEND_EMAIL_CONFIRMATION_CLEAR', emailID);}, 4000); + }); +}; + +module.exports = resendConfirmationEmail; diff --git a/app/scripts/actions/resetNotifications.js b/app/scripts/actions/resetNotifications.js new file mode 100644 index 0000000000..0eef639999 --- /dev/null +++ b/app/scripts/actions/resetNotifications.js @@ -0,0 +1,11 @@ +'use strict'; +import _ from 'lodash'; + +export default function(actionContext, slateArray) { + if (_.includes(slateArray, 'notifications')) { + actionContext.dispatch('RESET_EMAIL_NOTIFICATIONS_STORE'); + } + if (_.includes(slateArray, 'outbound')) { + actionContext.dispatch('RESET_OUTBOUND_EMAILS_STORE'); + } +} diff --git a/app/scripts/actions/resetPasswordSubmit.js b/app/scripts/actions/resetPasswordSubmit.js new file mode 100644 index 0000000000..d44247e627 --- /dev/null +++ b/app/scripts/actions/resetPasswordSubmit.js @@ -0,0 +1,21 @@ +/* @flow */ +'use strict'; +import { + Users + } from 'hub-js-sdk'; +const debug = require('debug')('hub:actions:resetPasswordSubmit'); + +var resetPasswordSubmit = function(actionContext:{ dispatch : Function }, { uidb64, reset_token, password_1, password_2 }) { + Users.resetPassword(uidb64, password_1, password_2, reset_token, function(err, res) { + if (err) { + debug('error', err); + actionContext.dispatch('RESET_PASSWORD_ERROR', res.body); + } else if (res.ok) { + actionContext.dispatch('RESET_PASSWORD_SUCCESSFUL'); + actionContext.history.push('/account/password-reset-confirm/success/'); + actionContext.dispatch('CHANGE_PASS_CLEAR', {}); + } + }); +}; + +module.exports = resetPasswordSubmit; diff --git a/app/scripts/actions/resetWebhookForm.js b/app/scripts/actions/resetWebhookForm.js new file mode 100644 index 0000000000..1de31e6100 --- /dev/null +++ b/app/scripts/actions/resetWebhookForm.js @@ -0,0 +1,8 @@ +'use strict'; + +export default function resetWebhookForm({ dispatch }, + params, + done) { + dispatch('ADD_WEBHOOK_RESET'); + done(); +} diff --git a/app/scripts/actions/saveEmailNotifs.js b/app/scripts/actions/saveEmailNotifs.js new file mode 100644 index 0000000000..3737bb7380 --- /dev/null +++ b/app/scripts/actions/saveEmailNotifs.js @@ -0,0 +1,51 @@ +'use strict'; + +var debug = require('debug')('hub:actions:saveEmailNotifs'); +import async from 'async'; + +import { Notifications } from 'hub-js-sdk'; + +var saveEmailNotifs = function(actionContext, {jwt, notification}) { + actionContext.dispatch('START_SAVE_ACTION'); + var _saveNotificationSettings = function(cb) { + Notifications.setNotificationSubscription(jwt, notification, function(err, res) { + if (err) { + debug('error', err); + cb(err); + } else if (res.body && res.ok) { + return cb(null, res.body); + } + }); + }; + + var _getNotificationSettings = function(cb) { + //all good, do the next call to get the notifications + Notifications.getNotificationSubscriptions(jwt, function(err, res) { + if (err) { + debug('getNotificationSubscriptions error', err); + cb(err); + } else { + cb(null, res.body.results); + } + }); + }; + + async.series([ + function (callback) { + _saveNotificationSettings(callback); + }, + function (callback) { + _getNotificationSettings(callback); + } + ], function (err, results) { + if (err) { + debug('async.series callback error', err); + actionContext.dispatch('SAVE_NOTIFICATIONS_ERROR'); + } else if (results[1]) { + actionContext.dispatch('SAVE_NOTIFICATIONS_SUCCESS'); + actionContext.dispatch('RECEIVE_NOTIFICATIONS', results[1]); + } + }); +}; + +module.exports = saveEmailNotifs; diff --git a/app/scripts/actions/saveOrgProfile.js b/app/scripts/actions/saveOrgProfile.js new file mode 100644 index 0000000000..37c6eed102 --- /dev/null +++ b/app/scripts/actions/saveOrgProfile.js @@ -0,0 +1,60 @@ +'use strict'; + +var debug = require('debug')('ACTION::saveOrgProfile'); +import async from 'async'; +import { Orgs } from 'hub-js-sdk'; +//Organization Object +/* + { + id (string), + orgname (regex), + full_name (string), + location (string): Your Location on the world, + company (string): Your organization's name, + profile_url (url): Your place on the web, + gravatar_email (email): This address will define which picture of you is shown, + is_active (boolean): Designates whether user is active. Unselect this instead of deleting accounts., + date_joined (datetime), + gravatar_url (string) + } + */ +export default function(actionContext, {jwt, orgname, organization}) { + + var _updateOrg = function(cb) { + Orgs.updateOrg(jwt, orgname, organization, function(err, res) { + if (err) { + debug(err); + actionContext.dispatch('UPDATE_ORG_ERROR', err); + cb(err, {}); + } else { + if (res.ok) { + cb(null, res.body); + } + } + }); + }; + + //Get orgs for user + var _getUpdatedOrg = function(cb) { + Orgs.getOrg(jwt, orgname, function(err, res) { + if (err) { + debug(err); + cb(err, {}); + } else { + cb(null, res.body); + actionContext.dispatch('UPDATE_ORG_SUCCESS'); + } + }); + }; + + async.series([ + _updateOrg, + _getUpdatedOrg + ], function (err, results) { + if(err) { + debug(err); + } else { + actionContext.dispatch('RECEIVE_ORGANIZATION', results[1]); + } + }); +} diff --git a/app/scripts/actions/saveOutbound.js b/app/scripts/actions/saveOutbound.js new file mode 100644 index 0000000000..fbcb25adba --- /dev/null +++ b/app/scripts/actions/saveOutbound.js @@ -0,0 +1,106 @@ +'use strict'; + +import { parallel } from 'async'; +import isEmpty from 'lodash/lang/isEmpty'; +import { + Emails + } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:saveOutbound'); + +module.exports = function({ dispatch }, + { + JWT, + username, + weeklyDigest, + betaGroup + }) { + /*eslint-disable camelcase */ + var digestSubscribe = weeklyDigest.subscribed_emails; + var digestUnsubscribe = weeklyDigest.unsubscribed_emails; + var betaSubscribe = betaGroup.subscribed_emails; + var betaUnsubscribe = betaGroup.unsubscribed_emails; + /*eslint-enable camelcase */ + parallel({ + digestSubscribed: function (callback) { + Emails.subscribeEmails(JWT, username, { + /*eslint-disable camelcase */ + subscribed_emails: digestSubscribe, + mailing_list: 'DockerNewsMailingList' + /*eslint-enable camelcase*/ + }, function (err, res) { + if (err) { + callback(err); + } else { + callback(null, res); + } + }); + }, + betaSubscribed: function (callback) { + Emails.subscribeEmails(JWT, username, { + /*eslint-disable camelcase */ + subscribed_emails: betaSubscribe, + mailing_list: 'DockerBetaGroupMailingList' + /*eslint-enable camelcase */ + }, function (err, res) { + if (err) { + callback(err); + } else { + callback(null, res); + } + }); + }, + digestUnsubscribed: function (callback) { + if (!isEmpty(digestUnsubscribe)) { + Emails.unsubscribeEmails(JWT, username, { + /*eslint-disable camelcase */ + unsubscribed_emails: digestUnsubscribe, + mailing_list: 'DockerNewsMailingList' + /*eslint-enable camelcase */ + }, function (err, res) { + if (err) { + callback(err); + } else { + callback(null, res.body.unsubscribed); + } + }); + } else { + callback(null); + } + }, + betaUnsubscribed: function (callback) { + if (!isEmpty(betaUnsubscribe)) { + Emails.unsubscribeEmails(JWT, username, { + /*eslint-disable camelcase */ + unsubscribed_emails: betaUnsubscribe, + mailing_list: 'DockerBetaGroupMailingList' + /*eslint-enable camelcase */ + }, function (err, res) { + if (err) { + callback(err); + } else { + callback(null, res.body.unsubscribed); + } + }); + } else { + callback(null); + } + } + }, + function(err, res) { + if(err) { + debug(err); + dispatch('SAVE_OUTBOUND_ERROR'); + } else { + Emails.getEmailSubscriptions(JWT, username, function(error, response){ + if (error) { + return debug(error); + } + dispatch('RECEIVE_EMAIL_SUBSCRIPTIONS', { + weeklyDigest: response.body.DockerNewsMailingList, + betaGroup: response.body.DockerBetaGroupMailingList + }); + dispatch('SAVE_OUTBOUND_SUCCESS'); + }); + } + }); +}; diff --git a/app/scripts/actions/savePushAutoBuildSettings.js b/app/scripts/actions/savePushAutoBuildSettings.js new file mode 100644 index 0000000000..c20dee1a21 --- /dev/null +++ b/app/scripts/actions/savePushAutoBuildSettings.js @@ -0,0 +1,74 @@ +'use strict'; +import { + Autobuilds + } from 'hub-js-sdk'; +import has from 'lodash/object/has'; +import merge from 'lodash/object/merge'; +import sortBy from 'lodash/collection/sortBy'; +import async from 'async'; +var debug = require('debug')('hub:actions:savePushAutoBuildSettings'); + +function saveTag({JWT, namespace, name}) { + return (tag, callback) => { + if (tag.isNew && !tag.toDelete) { // toDelete unecessary now as tags with isNew && toDelete SHOULD be removed + var ab = merge({}, tag, {repoName: name, namespace: namespace}); + Autobuilds.createAutomatedBuildTags(JWT, ab, function(err, res) { + if (err) { + debug(err); + tag.error = true; + callback(null, { tag, err }); + } else { + callback(null, res.body); + } + }); + } else if (tag.toDelete && !!tag.id) { + Autobuilds.deleteAutomatedBuildTags(JWT, namespace, name, tag.id, function(err, res){ + if (err) { + debug(err); + tag.error = true; + callback(null, { tag, err }); + } else { + callback(null, null); + } + }); + } else { + Autobuilds.updateAutomatedBuildTags(JWT, namespace, name, tag.id, tag, function(err, res){ + if (err) { + debug(err); + tag.error = true; + callback(null, { tag, err }); + } else { + callback(null, res.body); + } + }); + } + }; +} + +export default function(actionContext, {JWT, namespace, name, tags}, done) { + let encounteredError = false; + async.map(tags, saveTag({ JWT, namespace, name }), function(error, results) { + results.forEach( (resultTag) => { + if (has(resultTag, 'tag')) { + const { tag, err } = resultTag; + //We are keeping track of only `update` docker tag errors and at the moment we get them in `non_field_errors` + const nonFieldErrors = err.response.body.non_field_errors; + if (has(tag, 'error')) { + actionContext.dispatch('SAVE_BUILD_TAGS_ERROR', {name: tag.name, error: nonFieldErrors}); + encounteredError = true; + } + } + }); + if (!encounteredError) { + //Gets here only if there are no errors + actionContext.dispatch('SAVE_BUILD_TAGS_SUCCESS'); + const sorted = sortBy(results, 'id'); + actionContext.dispatch('UPDATE_AUTO_BUILD_SETTINGS', { + field: 'autoBuildStore', + key: 'build_tags', + value: sorted + }); + } + done(); + }); +} diff --git a/app/scripts/actions/saveSettingsData.js b/app/scripts/actions/saveSettingsData.js new file mode 100644 index 0000000000..dee5615660 --- /dev/null +++ b/app/scripts/actions/saveSettingsData.js @@ -0,0 +1,22 @@ +'use strict'; +import { + Users, + JWT +} from 'hub-js-sdk'; +const debug = require('debug')('hub:actions:saveSettingsData'); + +var saveSettingsData = function(actionContext, payload) { + actionContext.dispatch('ACCOUNT_INFO_ATTEMPT_START'); + Users.updateUser(payload.JWT, payload.username, payload.updateData, function(err, res) { + if (err) { + debug(err); + actionContext.dispatch('ACCOUNT_INFO_BAD_REQUEST', err.response.body); + } else { + actionContext.dispatch('ACCOUNT_INFO_SUCCESS'); + setTimeout(() => {actionContext.dispatch('ACCOUNT_INFO_STATUS_CLEAR');}, 4000); + actionContext.dispatch('RECEIVE_USER', res.body); + } + }); +}; + +module.exports = saveSettingsData; diff --git a/app/scripts/actions/saveTeamProfile.js b/app/scripts/actions/saveTeamProfile.js new file mode 100644 index 0000000000..4630603723 --- /dev/null +++ b/app/scripts/actions/saveTeamProfile.js @@ -0,0 +1,53 @@ +'use strict'; + +var debug = require('debug')('hub:actions:saveTeamProfile'); +import async from 'async'; +import { Orgs } from 'hub-js-sdk'; +//Team Object +/* + { + id (string), + teamname (regex), + description (string) + } + */ +export default function(actionContext, {jwt, orgname, teamname, team}) { + + var _updateTeam = function(cb) { + Orgs.updateTeam(jwt, { orgname, teamname, team }, function(err, res) { + if (err) { + debug(err); + actionContext.dispatch('UPDATE_TEAM_ERROR', err); + cb(err, {}); + } else { + if (res.ok) { + actionContext.dispatch('UPDATE_TEAM_SUCCESS'); + cb(null, res.body); + } + } + }); + }; + + //Get Team for org + var _getUpdatedTeam = function(cb) { + Orgs.getTeam(jwt, orgname, team.name, function(err, res) { + if (err) { + debug(err); + cb(err, {}); + } else { + cb(null, res.body); + } + }); + }; + + async.series([ + _updateTeam, + _getUpdatedTeam + ], function (err, results) { + if(err) { + debug(err); + } else { + actionContext.dispatch('RECEIVE_ORG_TEAM', results[1]); + } + }); +} diff --git a/app/scripts/actions/selectOrganization.js b/app/scripts/actions/selectOrganization.js new file mode 100644 index 0000000000..ca2f46c77e --- /dev/null +++ b/app/scripts/actions/selectOrganization.js @@ -0,0 +1,8 @@ +/* @flow */ +'use strict'; + +//TODO: organization object needs to be documented in hub-js-sdk when available +module.exports = function(actionContext: {dispatch: Function}, + orgName: {orgName: string}) { + actionContext.dispatch('SELECT_ORGANIZATION', orgName); +}; diff --git a/app/scripts/actions/selectSourceRepoForAutobuild.js b/app/scripts/actions/selectSourceRepoForAutobuild.js new file mode 100644 index 0000000000..46cd52b208 --- /dev/null +++ b/app/scripts/actions/selectSourceRepoForAutobuild.js @@ -0,0 +1,6 @@ +'use strict'; + +//The source repository could be github, bitbucket or anything else in the future +export default function selectSourceRepoForAutobuild(actionContext, selectedSourceRepo) { + actionContext.dispatch('SELECT_SOURCE_REPO', selectedSourceRepo); +} diff --git a/app/scripts/actions/setNewPrimaryEmail.js b/app/scripts/actions/setNewPrimaryEmail.js new file mode 100644 index 0000000000..4ba49a370f --- /dev/null +++ b/app/scripts/actions/setNewPrimaryEmail.js @@ -0,0 +1,47 @@ +'use strict'; + +import { sortByOrder } from 'lodash'; +import { series, each } from 'async'; +import { + Emails + } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:setNewPrimaryEmail'); + +function updateEmailSettings({ dispatch }, + { + JWT, username, emailId + }, + done) { + dispatch('START_SAVE_ACTION'); + series([ + function update(callback) { + debug('emailObject', emailId); + Emails.updateEmailByID(JWT, + emailId, + {primary: true}, + (err, res) => callback(null, res)); + } + ], + function afterUpdatingEmails(err, results) { + if (err) { + return debug(err); + } + Emails.getEmailsForUser(JWT, username, function(emailErr, res){ + if (emailErr) { + debug('emailErr', emailErr); + dispatch('FINISH_SAVE_ACTION'); + return done(); + } + var emails = res.body.results; + var sortedEmails = sortByOrder(emails, + ['primary', 'verified'], + [false, false]); + dispatch('RECEIVE_EMAILS', { + emails: sortedEmails + }); + dispatch('FINISH_SAVE_ACTION'); + }); + }); +} + +module.exports = updateEmailSettings; diff --git a/app/scripts/actions/shortDescriptionUpdateFormField.js b/app/scripts/actions/shortDescriptionUpdateFormField.js new file mode 100644 index 0000000000..3acb1f78c6 --- /dev/null +++ b/app/scripts/actions/shortDescriptionUpdateFormField.js @@ -0,0 +1,9 @@ +'use strict'; + +export default function({ dispatch }, + { fieldKey, fieldValue }, + done) { + dispatch('SHORT_DESCRIPTION_UPDATE_FIELD_WITH_VALUE', { + fieldKey, fieldValue + }); +} diff --git a/app/scripts/actions/signupUpdateFormField.js b/app/scripts/actions/signupUpdateFormField.js new file mode 100644 index 0000000000..1b63a9a684 --- /dev/null +++ b/app/scripts/actions/signupUpdateFormField.js @@ -0,0 +1,7 @@ +'use strict'; + +export default function(actionContext, { fieldKey, fieldValue }, done) { + actionContext.dispatch('SIGNUP_UPDATE_FIELD_WITH_VALUE', { + fieldKey, fieldValue + }); +} diff --git a/app/scripts/actions/signupValidations.js b/app/scripts/actions/signupValidations.js new file mode 100644 index 0000000000..95f19464b1 --- /dev/null +++ b/app/scripts/actions/signupValidations.js @@ -0,0 +1,59 @@ +'use strict'; + +var usernameValidation = function(actionContext, payload) { + var validationState; + var username = payload.unsafeUsername; + if (username.length > 3 && username.length <= 30 && username.match(/[a-z0-9\.\-_]+$/)) { + validationState = 'SUCCESS'; + } else if (username.length === 0) { + validationState = 'NOTHING'; + } else { + validationState = 'ERROR'; + } + actionContext.dispatch('VALIDATED_SIGNUP_USERNAME', { + username: username, + valState: validationState + }); +}; + +var passValidation = function(actionContext, payload) { + var validationState; + var password = payload.unsafePass; + if (password.length >= 5 && password.match(/[a-zA-Z]/) && password.match(/[0-9]/) && + password.match(/[\.-_!@#$%^&\*\(\)\[\]\{\}~:]/)) { + validationState = 'SUPERSUCCESS'; + } else if (password.length >= 5 && password.match(/[a-zA-Z]/) && password.match(/[0-9]/)) { + validationState = 'SUCCESS'; + } else if (password.length >= 5) { + validationState = 'WEAK'; + } else if (password.length === 0) { + validationState = 'NOTHING'; + } else if (password.length < 5) { + validationState = 'ERROR'; + } + actionContext.dispatch('VALIDATED_SIGNUP_PASSWORD', { + password: password, + valState: validationState + }); +}; +var emailValidation = function(actionContext, payload) { + var validationState; + var email = payload.unsafeEmail; + if (email.length > 3 && email.match(/.@./) && !email.match(/.\ ./)) { + validationState = 'SUCCESS'; + } else if (email.length === 0) { + validationState = 'NOTHING'; + } else { + validationState = 'ERROR'; + } + actionContext.dispatch('VALIDATED_SIGNUP_EMAIL', { + email: email, + valState: validationState + }); +}; + +module.exports = { + usernameValidation: usernameValidation, + passValidation: passValidation, + emailValidation: emailValidation +}; diff --git a/app/scripts/actions/toggleDeleteRepoNameConfirmBox.js b/app/scripts/actions/toggleDeleteRepoNameConfirmBox.js new file mode 100644 index 0000000000..d54621917b --- /dev/null +++ b/app/scripts/actions/toggleDeleteRepoNameConfirmBox.js @@ -0,0 +1,5 @@ +'use strict'; + +export default function(actionContext) { + actionContext.dispatch('TOGGLE_DELETE_REPO_NAME_CONFIRM_BOX'); +} diff --git a/app/scripts/actions/toggleLongDescriptionEdit.js b/app/scripts/actions/toggleLongDescriptionEdit.js new file mode 100644 index 0000000000..0ebd88cdf3 --- /dev/null +++ b/app/scripts/actions/toggleLongDescriptionEdit.js @@ -0,0 +1,5 @@ +'use strict'; + +export default function (actionContext, { isEditing }) { + actionContext.dispatch('TOGGLE_LONG_DESCRIPTION_EDIT', { isEditing }); +} diff --git a/app/scripts/actions/toggleShortDescriptionEdit.js b/app/scripts/actions/toggleShortDescriptionEdit.js new file mode 100644 index 0000000000..438bcb1792 --- /dev/null +++ b/app/scripts/actions/toggleShortDescriptionEdit.js @@ -0,0 +1,6 @@ +'use strict'; + +export default function (actionContext, { isEditing }) { + actionContext.dispatch('TOGGLE_SHORT_DESCRIPTION_EDIT', { isEditing }); +} + diff --git a/app/scripts/actions/toggleStarred.js b/app/scripts/actions/toggleStarred.js new file mode 100644 index 0000000000..11a3c342f6 --- /dev/null +++ b/app/scripts/actions/toggleStarred.js @@ -0,0 +1,30 @@ +'use strict'; + +import { + Repositories as Repos +} from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:toggleStarred'); + +export default function(actionContext, {jwt, repoShortName, status}) { + if(status) { + Repos.starRepo(jwt, repoShortName, + function(err, res) { + if (err) { + debug(err); + } else if (res.ok) { + actionContext.dispatch('TOGGLE_STARRED_STATE', status); + } + } + ); + } else { + Repos.unstarRepo(jwt, repoShortName, + function(err, res) { + if (err) { + debug(err); + } else if (res.ok) { + actionContext.dispatch('TOGGLE_STARRED_STATE', status); + } + } + ); + } +} diff --git a/app/scripts/actions/toggleTriggerStatus.js b/app/scripts/actions/toggleTriggerStatus.js new file mode 100644 index 0000000000..b23abf2a91 --- /dev/null +++ b/app/scripts/actions/toggleTriggerStatus.js @@ -0,0 +1,27 @@ +'use strict'; +const debug = require('debug')('hub:actions:toggleTriggerStatus'); +import { + Autobuilds as AutoBuild + } from 'hub-js-sdk'; + +function getTriggerStatus(JWT, actionContext, namespace, name) { + AutoBuild.getTriggerStatus(JWT, namespace, name, function(err, res) { + if (err) { + debug(err); + } + if (res.ok) { + actionContext.dispatch('RECEIVE_TRIGGER_STATUS', res.body); + } + }); +} + +export default function toggleTriggerStatus(actionContext, {JWT, namespace, name, active}) { + AutoBuild.toggleTriggerStatus(JWT, namespace, name, active, function(err, res) { + if (err) { + debug(err); + } + if (res.ok) { + getTriggerStatus(JWT, actionContext, namespace, name); + } + }); +} diff --git a/app/scripts/actions/toggleVisibility.js b/app/scripts/actions/toggleVisibility.js new file mode 100644 index 0000000000..4b296d9d03 --- /dev/null +++ b/app/scripts/actions/toggleVisibility.js @@ -0,0 +1,23 @@ +'use strict'; + +import { Users } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:toggleVisibility'); + +module.exports = function(actionContext, {JWT, username, visibility}) { + actionContext.dispatch('START TOGGLE REPO VISIBILITY'); + Users.updateDefaultVisibility(JWT, { username, visibility }, function(err, res){ + if (err) { + actionContext.dispatch('UPDATE_ORG_ERROR', err); + } else if (res.ok) { + Users.getUserSettings(JWT, username, function(getErr, getRes) { + if (getErr) { + debug(getErr); + } else if (getRes.ok) { + actionContext.dispatch('RECEIVE_PRIVATE_REPOSTATS', getRes.body); + let is_private = (getRes.body.default_repo_visibility === 'private'); + actionContext.dispatch('CREATE_REPO_UPDATE_FIELD_WITH_VALUE', {fieldKey: 'is_private', fieldValue: is_private}); + } + }); + } + }); +}; diff --git a/app/scripts/actions/toggleVisibilityRepoNameConfirmBox.js b/app/scripts/actions/toggleVisibilityRepoNameConfirmBox.js new file mode 100644 index 0000000000..196a28bac2 --- /dev/null +++ b/app/scripts/actions/toggleVisibilityRepoNameConfirmBox.js @@ -0,0 +1,5 @@ +'use strict'; + +export default function(actionContext) { + actionContext.dispatch('TOGGLE_VISIBILITY_REPO_NAME_CONFIRM_BOX'); +} diff --git a/app/scripts/actions/triggerBuild.js b/app/scripts/actions/triggerBuild.js new file mode 100644 index 0000000000..06bd11ee9d --- /dev/null +++ b/app/scripts/actions/triggerBuild.js @@ -0,0 +1,15 @@ +'use strict'; + +import { Autobuilds } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:triggerBuild'); + +module.exports = function(actionContext, {JWT, name, namespace}) { + Autobuilds.triggerBuild(JWT, namespace, name, (err, res) => { + if (err) { + debug(err); + actionContext.dispatch('AB_TRIGGER_ERROR'); + } else { + actionContext.dispatch('AB_TRIGGER_SUCCESS'); + } + }); +}; diff --git a/app/scripts/actions/triggerBuildByTag.js b/app/scripts/actions/triggerBuildByTag.js new file mode 100644 index 0000000000..ba29f7bc59 --- /dev/null +++ b/app/scripts/actions/triggerBuildByTag.js @@ -0,0 +1,34 @@ +'use strict'; + +import { Autobuilds } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:triggerBuildByTag'); + +module.exports = function(actionContext, {JWT, tag, triggerId}) { + let successStatus, errorStatus; + actionContext.dispatch('ATTEMPT_TRIGGER_BY_TAG', triggerId); + Autobuilds.triggerBuildByTag(JWT, tag, (err, res) => { + if (err) { + debug(err); + actionContext.dispatch('AB_TRIGGER_BY_TAG_ERROR', + { + error: 'Error triggering the build. Please check your source name.', + id: triggerId + }); + } else { + if (res.ok) { + switch(res.status) { + case 202: + successStatus = 'Successfully triggered a build.'; + break; + case 200: + successStatus = 'Attempted to trigger a build. Please check the build details page for more information.'; + //TODO: report this as error or success once it is clear what exactly this means + break; + default: + break; + } + actionContext.dispatch('AB_TRIGGER_BY_TAG_SUCCESS', {success: successStatus, id: triggerId}); + } + } + }); +}; diff --git a/app/scripts/actions/unlinkBitbucket.js b/app/scripts/actions/unlinkBitbucket.js new file mode 100644 index 0000000000..55a3a9e911 --- /dev/null +++ b/app/scripts/actions/unlinkBitbucket.js @@ -0,0 +1,26 @@ +'use strict'; + +import { Builds } from 'hub-js-sdk'; +const debug = require('debug')('hub:actions:unlinkBitbucket'); +import linkedAccountSettingsAction from './navigate/linkedAccountsSettings'; + +module.exports = function(actionContext, jwt) { + Builds.unlinkBitbucket(jwt, function(err, res) { + if (err) { + debug(err); + const { detail } = err.response.body; + if(detail) { + actionContext.dispatch('BITBUCKET_UNLINK_ERROR', detail); + } + } else if (res.ok) { + linkedAccountSettingsAction( + { + actionContext: actionContext, + payload: {}, + done: function() { debug('done unlinking bitbucket.'); }, + maybeData: {token: jwt} + } + ); + } + }); +}; diff --git a/app/scripts/actions/unlinkGithub.js b/app/scripts/actions/unlinkGithub.js new file mode 100644 index 0000000000..e5e7dbc021 --- /dev/null +++ b/app/scripts/actions/unlinkGithub.js @@ -0,0 +1,27 @@ +'use strict'; + +import { Builds } from 'hub-js-sdk'; +const debug = require('debug')('hub:actions:unlinkGithub'); +import has from 'lodash/object/has'; +import linkedAccountSettingsAction from './navigate/linkedAccountsSettings'; + +module.exports = function(actionContext, jwt) { + Builds.unlinkGithub(jwt, function(err, res) { + if (err) { + debug(err); + const { detail } = err.response.body; + if(detail) { + actionContext.dispatch('GITHUB_UNLINK_ERROR', detail); + } + } else if (res.ok) { + linkedAccountSettingsAction( + { + actionContext: actionContext, + payload: {}, + done: function() { debug('done unlinking github account.'); }, + maybeData: {token: jwt} + } + ); + } + }); +}; diff --git a/app/scripts/actions/updateAccountInfoFormField.js b/app/scripts/actions/updateAccountInfoFormField.js new file mode 100644 index 0000000000..a70ccb1a5d --- /dev/null +++ b/app/scripts/actions/updateAccountInfoFormField.js @@ -0,0 +1,5 @@ +'use strict'; + +export default function(actionContext, { fieldKey, fieldValue}) { + actionContext.dispatch('ACCOUNT_INFO_UPDATE_FIELD_WITH_VALUE', { fieldKey, fieldValue }); +} diff --git a/app/scripts/actions/updateAddOrganizationFormField.js b/app/scripts/actions/updateAddOrganizationFormField.js new file mode 100644 index 0000000000..b11166861c --- /dev/null +++ b/app/scripts/actions/updateAddOrganizationFormField.js @@ -0,0 +1,5 @@ +'use strict'; + +export default function(actionContext, { fieldKey, fieldValue}) { + actionContext.dispatch('ADD_ORG_UPDATE_FIELD_WITH_VALUE', { fieldKey, fieldValue }); +} diff --git a/app/scripts/actions/updateAddRepositoryFormField.js b/app/scripts/actions/updateAddRepositoryFormField.js new file mode 100644 index 0000000000..940c91053e --- /dev/null +++ b/app/scripts/actions/updateAddRepositoryFormField.js @@ -0,0 +1,7 @@ +'use strict'; + +export default function(actionContext, { fieldKey, fieldValue}) { + actionContext.dispatch('CREATE_REPO_UPDATE_FIELD_WITH_VALUE', { + fieldKey, fieldValue + }); +} diff --git a/app/scripts/actions/updateAutoBuildPushTriggerItem.js b/app/scripts/actions/updateAutoBuildPushTriggerItem.js new file mode 100644 index 0000000000..bd36a50333 --- /dev/null +++ b/app/scripts/actions/updateAutoBuildPushTriggerItem.js @@ -0,0 +1,12 @@ +/* @flow */ +/*global VisibilityFormFieldPayload */ +'use strict'; + +var debug = require('debug')('hub:actions:updateAutoBuildPushTriggerItem'); +export default function(actionContext, { isNew, index, fieldkey, value}) { + if (isNew) { + actionContext.dispatch('UPDATE_AUTOBUILD_NEW_TAG_ITEM', {index, fieldkey, value}); + } else { + actionContext.dispatch('UPDATE_AUTOBUILD_PUSH_TRIGGER_ITEM', {index, fieldkey, value}); + } +} diff --git a/app/scripts/actions/updateAutoBuildSettings.js b/app/scripts/actions/updateAutoBuildSettings.js new file mode 100644 index 0000000000..08fc59e2e9 --- /dev/null +++ b/app/scripts/actions/updateAutoBuildSettings.js @@ -0,0 +1,20 @@ +'use strict'; + +import { Autobuilds } from 'hub-js-sdk'; +var debug = require('debug')('hub:actions:updateAutoBuildSettings'); + +module.exports = function(actionContext, {JWT, namespace, name, data}) { + Autobuilds.updateAutomatedBuildSettings(JWT, namespace, name, data, function(err, res){ + if (err) { + debug(res.body); + } else if (res.ok) { + Autobuilds.getAutomatedBuildSettings(JWT, namespace, name, function(getErr, getRes) { + if (getErr) { + debug(getErr); + } else if (getRes.ok) { + actionContext.dispatch('RECEIVE_AUTOBUILD_SETTINGS', getRes.body); + } + }); + } + }); +}; diff --git a/app/scripts/actions/updateAutoBuildSettingsStore.js b/app/scripts/actions/updateAutoBuildSettingsStore.js new file mode 100644 index 0000000000..82fc8de4c3 --- /dev/null +++ b/app/scripts/actions/updateAutoBuildSettingsStore.js @@ -0,0 +1,5 @@ +'use strict'; + +export default function(actionContext, { field, key, value}) { + actionContext.dispatch('UPDATE_AUTO_BUILD_SETTINGS', {field, key, value}); +} diff --git a/app/scripts/actions/updateAutobuildFormField.js b/app/scripts/actions/updateAutobuildFormField.js new file mode 100644 index 0000000000..6e06bc9486 --- /dev/null +++ b/app/scripts/actions/updateAutobuildFormField.js @@ -0,0 +1,7 @@ +'use strict'; + +export default function(actionContext, { fieldKey, fieldValue}) { + actionContext.dispatch('AUTOBUILD_FORM_UPDATE_FIELD_WITH_VALUE', { + fieldKey, fieldValue + }); +} diff --git a/app/scripts/actions/updateBillingInfoFormField.js b/app/scripts/actions/updateBillingInfoFormField.js new file mode 100644 index 0000000000..b386a3de83 --- /dev/null +++ b/app/scripts/actions/updateBillingInfoFormField.js @@ -0,0 +1,15 @@ +'use strict'; + +export default function(actionContext, { field, fieldKey, fieldValue}) { + if (field === 'card' && fieldKey === 'number') { + var type = window.recurly.validate.cardType(fieldValue.toString()); + actionContext.dispatch('BILLING_INFO_UPDATE_FIELD_WITH_VALUE', { + field: 'card', + fieldKey: 'type', + fieldValue: type + }); + } + actionContext.dispatch('BILLING_INFO_UPDATE_FIELD_WITH_VALUE', { + field, fieldKey, fieldValue + }); +} diff --git a/app/scripts/actions/updateBillingInformation.js b/app/scripts/actions/updateBillingInformation.js new file mode 100644 index 0000000000..669ae950bb --- /dev/null +++ b/app/scripts/actions/updateBillingInformation.js @@ -0,0 +1,87 @@ +'use strict'; +/*global UpdateBillingInfoPayload */ +import { Billing } from 'hub-js-sdk'; +import _ from 'lodash'; +import async from 'async'; +var debug = require('debug')('hub:actions:updateBillingInformation'); + +function updateBillingInformation(actionContext, {JWT, user, billingInfo, accountInfo, card}, done) { + actionContext.dispatch('BILLING_SUBMIT_START'); + var recurlyData = _.merge({}, billingInfo, card); + let isOrg = false; + let username = ''; + if (_.has(user, 'username')) { + username = user.username; + } else if (_.has(user, 'orgname')) { + username = user.orgname; + isOrg = true; + } + const account = _.merge({}, accountInfo, {user: username}); + + try { + /** + * This will only run where window is defined. ie: the browser + * It throws an exception in node + */ + window.recurly.configure(process.env.RECURLY_PUBLIC_KEY); + } catch(e) { + debug('error', e); + } + window.recurly.token(recurlyData, function(recurlyErr, token) { + if (recurlyErr) { + // SHOULD ONLY GET HERE IF RECURLY DECLINES THEIR INFO + debug('recurly error', recurlyErr); + actionContext.dispatch('GET_RECURLY_ERROR', recurlyErr); + done(); + } else { + async.series([ + function(callback){ + Billing.updateBillingAccount(JWT, username, account, function(accountErr, accountRes){ + if (accountErr) { + let message = 'There was an error updating your contact information. Please check your information and try again.'; + if (_.has(accountRes.body, 'detail') && _.isString(accountRes.body.detail)) { + message = accountRes.body.detail; + } + actionContext.dispatch('BILLING_SUBMIT_ERROR', message); + callback(accountErr, accountRes); + } else if (accountRes.ok) { + callback(null, accountRes.body); + } + }); + }, + function(callback) { + let updatedInfo = {username: username, payment_token: token.id}; + Billing.updateBillingInfo(JWT, username, updatedInfo, function(billErr, billRes) { + if (billErr) { + debug('updateBillingInfo error', billErr); + let message = 'There was an error submitting your billing information. Please check your information and try again.'; + if (_.has(billRes.body, 'detail') && _.isString(billRes.body.detail)) { + message = billRes.body.detail; + } + actionContext.dispatch('BILLING_SUBMIT_ERROR', message); + callback(billErr, billRes); + } else if (billRes.ok) { + callback(null, billRes.body); + } + }); + } + ], + function(err, res) { + if (err) { + done(); + } else { + if (isOrg) { + actionContext.history.push(`/u/${username}/dashboard/billing/`); + } else { + actionContext.history.push('/account/billing-plans/'); + } + actionContext.dispatch('RECEIVE_BILLING_INFO', {billingInfo: res[1], accountInfo: res[0]}); + actionContext.dispatch('BILLING_SUBMIT_SUCCESS'); + done(); + } + }); + } + }); +} + +module.exports = updateBillingInformation; diff --git a/app/scripts/actions/updateChangePassStore.js b/app/scripts/actions/updateChangePassStore.js new file mode 100644 index 0000000000..cdea3e35a4 --- /dev/null +++ b/app/scripts/actions/updateChangePassStore.js @@ -0,0 +1,7 @@ +'use strict'; + +var updateStore = function(actionContext, payload) { + actionContext.dispatch('CHANGE_PASS_UPDATE', payload); +}; + +module.exports = updateStore; diff --git a/app/scripts/actions/updateCloudBillingSubscription.js b/app/scripts/actions/updateCloudBillingSubscription.js new file mode 100644 index 0000000000..3341a87f51 --- /dev/null +++ b/app/scripts/actions/updateCloudBillingSubscription.js @@ -0,0 +1,35 @@ +'use strict'; +/** + * CLONE OF updateSubscriptionPlanOrPackage.js + * with Different dispatches + */ +import { Billing } from 'hub-js-sdk'; +import has from 'lodash/object/has'; +var debug = require('debug')('hub:actions:updateCloudBillingSubscription'); + +function updateBillingSubscriptions(actionContext, {JWT, username, subscription_uuid, package_code, coupon_code, isOrg}, done) { + actionContext.dispatch('ENTERPRISE_PAID_ATTEMPT_START'); + var data = { + package: package_code, + username, + coupon_code + }; + Billing.updateBillingSubscriptions(JWT, subscription_uuid, username, data, function(err, res) { + if (err) { + debug(err); + // If fails - There's nothing else we can do. Facepalm + actionContext.dispatch('ENTERPRISE_PAID_BAD_REQUEST', res.body.detail); + done(); + } else if (res.ok) { + actionContext.dispatch('ENTERPRISE_PAID_SUCCESS'); + if (isOrg) { + actionContext.history.push(`/u/${username}/dashboard/billing/`); + } else { + actionContext.history.push('/account/billing-plans/'); + } + done(); + } + }); +} + +module.exports = updateBillingSubscriptions; diff --git a/app/scripts/actions/updateEnterpriseTrialFormField.js b/app/scripts/actions/updateEnterpriseTrialFormField.js new file mode 100644 index 0000000000..481545ac79 --- /dev/null +++ b/app/scripts/actions/updateEnterpriseTrialFormField.js @@ -0,0 +1,5 @@ +'use strict'; + +export default function(actionContext, {fieldKey, fieldValue}) { + actionContext.dispatch('ENTERPRISE_TRIAL_UPDATE_FIELD_WITH_VALUE', { fieldKey, fieldValue }); +} diff --git a/app/scripts/actions/updateNotifCheckbox.js b/app/scripts/actions/updateNotifCheckbox.js new file mode 100644 index 0000000000..26697d0aa2 --- /dev/null +++ b/app/scripts/actions/updateNotifCheckbox.js @@ -0,0 +1,5 @@ +'use strict'; + +export default function(actionContext, cboxType) { + actionContext.dispatch('NOTIF_CHECKBOX_CLICK', cboxType); +} diff --git a/app/scripts/actions/updateOutbound.js b/app/scripts/actions/updateOutbound.js new file mode 100644 index 0000000000..cba8bae610 --- /dev/null +++ b/app/scripts/actions/updateOutbound.js @@ -0,0 +1,5 @@ +'use strict'; + +export default function(actionContext, newList) { + actionContext.dispatch('UPDATE_OUTBOUND', newList); +} diff --git a/app/scripts/actions/updateRepoSettingsVisibilityField.js b/app/scripts/actions/updateRepoSettingsVisibilityField.js new file mode 100644 index 0000000000..38259ca559 --- /dev/null +++ b/app/scripts/actions/updateRepoSettingsVisibilityField.js @@ -0,0 +1,56 @@ +'use strict'; + +import async from 'async'; +var debug = require('debug')('hub:actions:updateRepoSettingsVisibilityField'); +import { Repositories as Repos, Users } from 'hub-js-sdk'; + +function updateRepoVisibility(actionContext, { jwt, isPrivate, repoShortName }) { + return (cb) => { + Repos.updateRepoVisibility(jwt, { isPrivate, repoShortName }, (err, res) => { + if (err) { + const { badRequest, body } = err.response; + if(badRequest) { + debug(err); + actionContext.dispatch('VISIBILITY_BAD_REQUEST', body); + cb(err); + } else if (res && res.body.error) { + actionContext.dispatch('VISIBILITY_ERROR', res.body); + cb(err); + } else { + actionContext.dispatch('VISIBILITY_ERROR'); + cb(err); + } + } else { + actionContext.dispatch('TOGGLE_VISIBILITY_SUCCESS', isPrivate); + cb(null, res.body); + } + }); + }; +} + +function getPrivateRepoStats(actionContext, { jwt, repoShortName }) { + return (cb) => { + const namespace = repoShortName.split('/')[0]; + Users.getUserSettings(jwt, namespace, function (err, res) { + if (err) { + cb(); + } else { + actionContext.dispatch('RECEIVE_PRIVATE_REPOSTATS', res.body); + cb(); + } + }); + }; +} + + +export default function(actionContext, { jwt, isPrivate, repoShortName }, done) { + actionContext.dispatch('TOGGLE_VISIBILITY_ATTEMPT_START'); + async.series([ + updateRepoVisibility(actionContext, { jwt, isPrivate, repoShortName }), + getPrivateRepoStats(actionContext, {jwt, repoShortName }) + ], function(err, results) { + if (err) { + debug(err); + } + }); +} diff --git a/app/scripts/actions/updateStripeBilling.js b/app/scripts/actions/updateStripeBilling.js new file mode 100644 index 0000000000..826f4eb6e5 --- /dev/null +++ b/app/scripts/actions/updateStripeBilling.js @@ -0,0 +1,292 @@ +'use strict'; +/*global UpdateBillingInfoPayload */ +const request = require('superagent'); +import { Billing } from 'hub-js-sdk'; +import has from 'lodash/object/has'; +import merge from 'lodash/object/merge'; +import map from 'lodash/collection/map'; +import find from 'lodash/collection/find'; +import isString from 'lodash/lang/isString'; +import async from 'async'; +var debug = require('debug')('hub:actions:updateBillingInformation'); + +import _encodeForm from '../components/utils/encodeForm.js'; +import { + ACCOUNT, + BILLING, + BILLFORWARD_ACCOUNT_ID, + STRIPE_URL, + STRIPE_STAGE_TOKEN, + STRIPE_PROD_TOKEN, + BF_STAGE_URL, + BF_PROD_URL, + BF_STAGE_TOKEN, + BF_PROD_TOKEN, + v4BillingProfile +} from 'stores/common/Constants.js'; + +const handleResponse = ({ callback, dispatch, type }) => (err, res) => { + if (err) { + let message; + if (type === ACCOUNT) { + message = 'There was an error updating your account profile information. Please check your information and try again.'; + } else if (type === BILLING) { + message = 'There was an error updating your billing information. Please check your information and try again.'; + } + if (res && res.body) { + message = isString(res.body.detail) ? res.body.detail : res.body.message; + } + dispatch('BILLING_SUBMIT_ERROR', message); + callback(err, res); + } else if (res.ok) { + if (type === ACCOUNT) { + dispatch('BILLING_ACCOUNT_EXISTS'); + } else if (type === BILLING) { + dispatch('BILLING_INFO_EXISTS'); + } + const billforward_id = res.header[BILLFORWARD_ACCOUNT_ID]; + callback(null, { ...res.body, billforward_id }); + } +}; + +/* + NOTE: Stripe's api only accepts x-www-form-urlencoded data + NOTE: 'billforwardCreatePayment' is chained to this function and requires + `billforward-id`. The `billforward-id` is being passed through to the + create payment function via the META here. +*/ +function _createCardToken({ + cvc, + exp_month, + exp_year, + name_first, + name_last, + number +}, cb) { + const stripeToken = process.env.ENV === 'production' ? + STRIPE_PROD_TOKEN : STRIPE_STAGE_TOKEN; + const card = { + name: `${name_first} ${name_last}`, + cvc, + number, + exp_month, + exp_year + }; + const encoded = _encodeForm({ card }); + request.post(STRIPE_URL) + .accept('application/json') + .type('application/x-www-form-urlencoded') + .set('Authorization', 'Bearer ' + stripeToken) + .send(encoded) + .end(cb); +} + +/* + NOTE: This is the call to billforward that actually adds a payment method + to a user's billing profile. + This call requires a billforward-id (accountID) which is DIFFERENT than the + docker_id - Hence why 'billingCreatePaymentMethod' is NOT being wrapped by + the getAccountFromNamespace decorator. +*/ +function _billforwardAddPaymentMethod({ + '@type': type, + accountID, + cardID, + defaultPaymentMethod, + gateway, + stripeToken +}, cb) { + const billforwardUrl = process.env.ENV === 'production' ? + BF_PROD_URL : BF_STAGE_URL; + const billforwardToken = process.env.ENV === 'production' ? + BF_PROD_TOKEN : BF_STAGE_TOKEN; + + request.post(billforwardUrl) + .accept('application/json') + .type('application/json') + .set('Authorization', 'Bearer ' + billforwardToken) + .send({ + '@type': type, + accountID, + cardID, + defaultPaymentMethod, + gateway, + stripeToken + }) + .end(cb); +} + +//-------------------------------------------------------------------------- +// CREATE A BILLING PAYMENT METHOD ON BILLFORWAD WITH STRIPE +// DUPLICATED FROM ./createStripeSubscription.js +//-------------------------------------------------------------------------- +function billingCreatePaymentMethod(dispatch, { + billforwardId, + cvc, + exp_month, + exp_year, + name_first, + name_last, + number +}, cb) { + const cardData = { + cvc, + exp_month, + exp_year, + name_first, + name_last, + number + }; + /* + NOTE: + Creating a payment method requires 2 parts + 1 - Generating a token from Stripe's api + 2 - Sending generated token to Billforward's api to attach payment method to + a relevant billing profile. + */ + _createCardToken(cardData, (stripeErr, stripeRes) => { + if (!stripeRes.ok) { + let message = 'There was an error submitting your card information. Please check your information and try again.'; + if (stripeRes.error && stripeRes.error.message) { + message = stripeRes.error.message; + } + dispatch('BILLING_SUBMIT_ERROR', message); + cb(message); + } else { + const tokenObject = stripeRes && stripeRes.body; + const stripeToken = tokenObject.id; + const cardID = tokenObject.card.id; + const accountID = billforwardId; + const bfData = { + '@type': 'StripeAuthCaptureRequest', + accountID, + cardID, + defaultPaymentMethod: true, + gateway: 'Stripe', + stripeToken + }; + _billforwardAddPaymentMethod(bfData, handleResponse({callback: cb, dispatch, type: BILLING})); + } + }); +} +//-------------------------------------------------------------------------- +// SAVE ADDRESS INFORMATION IN BILLFORWARD +//-------------------------------------------------------------------------- +const updateV4BillingProfile = ({ JWT, profileData, docker_id }, cb) => { + const v4BillingAPI = v4BillingProfile(docker_id); + request + .patch(v4BillingAPI) + .accept('application/json') + .type('application/json') + .set('Authorization', 'Bearer ' + JWT) + .send(profileData) + .end(cb); +}; + +//-------------------------------------------------------------------------- +// UPDATE STRIPE BILLING INFORMATION +// NOTE: This will just add a payment method - creating multiple cards +//-------------------------------------------------------------------------- +function updateBillingInformation(actionContext, { + JWT, + user, + billingInfo, + accountInfo, + card, + billforwardId +}, done) { + actionContext.dispatch('BILLING_SUBMIT_START'); + let isOrg = false; + let username = ''; + if (has(user, 'username')) { + username = user.username; + } else if (has(user, 'orgname')) { + username = user.orgname; + isOrg = true; + } + const { + email, + first_name, + last_name, + company_name + } = accountInfo; + const { + address1, + address2, + country, + state, + zip, + city, + first_name: billing_first, + last_name: billing_last + } = billingInfo; + const { + number, + cvv, + month, + year + } = card; + + async.series([ + function(callback){ + const account = merge({}, accountInfo, {user: username}); + Billing.updateBillingAccount(JWT, username, account, handleResponse({callback, dispatch: actionContext.dispatch, type: ACCOUNT})); + }, + function(callback) { + /* + NOTE: + THIS WILL CREATE A NEW PAYMENT METHOD ON EVERY CALL + - In billforward this could potentially save multiple versions of the same card + - Solution would be to check whether the card is the same and decide to create or not + */ + billingCreatePaymentMethod(actionContext.dispatch, { + billforwardId, + cvc: cvv, + exp_month: month, + exp_year: year, + name_first: billing_first, + name_last: billing_last, + number + }, callback); + }, + function(callback) { + const profileData = { + first_name, + last_name, + addresses: [ + { + address_line_1: address1, + address_line_2: address2, + city, + province: state, + country, + post_code: zip, + primary_address: true + } + ] + }; + updateV4BillingProfile({ + JWT, + profileData, + docker_id: user.id + }, handleResponse({callback, dispatch: actionContext.dispatch, type: ACCOUNT})); + } + ], + function(err, res) { + if (err) { + done(); + } else { + if (isOrg) { + actionContext.history.push(`/u/${username}/dashboard/billing/`); + } else { + actionContext.history.push('/account/billing-plans/'); + } + actionContext.dispatch('BILLING_SUBMIT_SUCCESS'); + done(); + } + }); + + +} + +module.exports = updateBillingInformation; diff --git a/app/scripts/actions/updateSubscriptionPlanOrPackage.js b/app/scripts/actions/updateSubscriptionPlanOrPackage.js new file mode 100644 index 0000000000..b4ea288715 --- /dev/null +++ b/app/scripts/actions/updateSubscriptionPlanOrPackage.js @@ -0,0 +1,66 @@ +'use strict'; +/*global UpdateBillingInfoPayload */ +import { Billing } from 'hub-js-sdk'; +import _ from 'lodash'; +const CLOUD_METERED = 'cloud_metered'; +var debug = require('debug')('hub:actions:updateSubscriptionPlanOrPackage'); + +function updateBillingSubscriptions(actionContext, { + coupon_code, + JWT, + package_code, + plan_code, + username, + subscription_uuid, + add_ons + }, done) { + actionContext.dispatch('UPDATE_PLAN_START', plan_code); + var data = { + username, + coupon_code, + add_ons + }; + + // we are either updating a plan or a package + if (plan_code) { + data.plan = plan_code; + } + if (package_code) { + data.package = package_code; + } + + // Explicitly remove all add ons (such as nautilus) when downgrading to + // a free account + if (plan_code === CLOUD_METERED) { + data.add_ons = []; + } + + if (!plan_code || plan_code === CLOUD_METERED) { + actionContext.dispatch('UNSUBSCRIBE_PLAN'); + } else if (!package_code) { + actionContext.dispatch('UNSUBSCRIBE_PACKAGE'); + } + Billing.updateBillingSubscriptions(JWT, subscription_uuid, username, data, function(err, res) { + if (err) { + debug(err); + const detail = err.response && err.response.body && err.response.body.detail; + const error = detail || 'Could not reach server.'; + actionContext.dispatch('UPDATE_PLAN_ERROR', error); + done(); + } else if (res.ok) { + Billing.getBillingSubscriptions(JWT, username, function(getErr, getRes) { + if (getErr) { + debug(getErr); + } else if (getRes.ok) { + let subscriptions = getRes.body; + let subscription = _.head(subscriptions); + // This will update your subscription in the store - while leaving the billing information the same + actionContext.dispatch('RECEIVE_BILLING_SUBSCRIPTION', {currentPlan: subscription}); + } + done(); + }); + } + }); +} + +module.exports = updateBillingSubscriptions; diff --git a/app/scripts/actions/updateToOrgOwner.js b/app/scripts/actions/updateToOrgOwner.js new file mode 100644 index 0000000000..9ea02666c0 --- /dev/null +++ b/app/scripts/actions/updateToOrgOwner.js @@ -0,0 +1,5 @@ +'use strict'; + +export default function(actionContext, payload) { + actionContext.dispatch('UPDATE_TO_ORG_OWNER', payload); +} diff --git a/app/scripts/actions/validateCouponCode.js b/app/scripts/actions/validateCouponCode.js new file mode 100644 index 0000000000..0c3a8e57bc --- /dev/null +++ b/app/scripts/actions/validateCouponCode.js @@ -0,0 +1,45 @@ +'use strict'; +var debug = require('debug')('hub:actions:validateCouponCode'); + +module.exports = (actionContext, {coupon_code, plan}) => { + if (coupon_code) { + try { + /** + * This will only run where window is defined. ie: the browser + * It throws an exception in node + */ + window.recurly.configure(process.env.RECURLY_PUBLIC_KEY); + /* eslint-disable new-cap */ + var pricing = window.recurly.Pricing(); + /* eslint-enable new-cap*/ + pricing + .plan(plan, {quantity: 1}) + .coupon(coupon_code) + .catch(function(err) { + debug('ERROR', err); + actionContext.dispatch('BILLING_ERRORS', {fieldErrors: {coupon_code: true}}); + }) + .done(function(price) { + var discount = price.now.discount; + if (discount % 1 === 0) { + // remove the trailing decimals if its a whole number + discount = Math.round(discount); + } + actionContext.dispatch('UPDATE_COUPON_VALUE', discount); + if (discount <= 0) { + actionContext.dispatch('BILLING_ERRORS', {fieldErrors: {coupon_code: true}}); + } else { + actionContext.dispatch('BILLING_ERRORS', {fieldErrors: {coupon_code: false}}); + } + }); + } catch(e) { + debug('error', e); + actionContext.dispatch('BILLING_ERRORS', {fieldErrors: {coupon_code: true}}); + } + actionContext.dispatch('BILLING_INFO_UPDATE_FIELD_WITH_VALUE', {field: 'card', fieldKey: 'coupon_code', fieldValue: coupon_code}); + } else { + actionContext.dispatch('BILLING_ERRORS', {fieldErrors: {coupon_code: false}}); + actionContext.dispatch('BILLING_INFO_UPDATE_FIELD_WITH_VALUE', {field: 'card', fieldKey: 'coupon_code', fieldValue: ''}); + actionContext.dispatch('UPDATE_COUPON_VALUE', 0); + } +}; diff --git a/app/scripts/app.js b/app/scripts/app.js new file mode 100644 index 0000000000..5aaec97293 --- /dev/null +++ b/app/scripts/app.js @@ -0,0 +1,103 @@ +'use strict'; +const debug = require('debug')('hub:app'); +const Fluxible = require('fluxible'); + +let app = new Fluxible({ + component: require('./components/Routes.jsx'), + stores: [ + require('./stores/AccountInfoFormStore'), + require('./stores/AccountSettingsLicensesStore'), + require('./stores/AddOrganizationStore'), + require('./stores/AddTrialLicenseStore'), + require('./stores/AddWebhookFormStore'), + require('./stores/ApplicationStore'), + require('./stores/AutobuildConfigStore'), + require('./stores/AutoBuildSettingsStore'), + require('./stores/AutobuildSourceRepositoriesStore'), + require('./stores/AutobuildStore'), + require('./stores/AutobuildTagsStore'), + require('./stores/AutobuildTriggerByTagStore'), + require('./stores/BillingInfoFormStore'), + require('./stores/BillingPlansStore'), + require('./stores/BitbucketLinkStore'), + require('./stores/ChangePasswordStore'), + require('./stores/CloudCouponStore'), + require('./stores/CloudBillingStore'), + require('./stores/ConvertToOrgStore'), + require('./stores/CreateRepositoryFormStore'), + require('./stores/DashboardContribsStore'), + require('./stores/DashboardMembersStore'), + require('./stores/DashboardNamespacesStore'), + require('./stores/DashboardReposStore'), + require('./stores/DashboardStarsStore'), + require('./stores/DashboardStore'), + require('./stores/DashboardTeamsStore'), + require('./stores/DeletePipelineStore'), + require('./stores/DeleteRepoFormStore'), + require('./stores/EmailNotifStore'), + require('./stores/EmailsStore'), + require('./stores/EnterprisePaidFormStore'), + require('./stores/EnterprisePartnerTrackingStore'), + require('./stores/EnterpriseTrialFormStore'), + require('./stores/EnterpriseTrialSuccessStore'), + require('./stores/GithubLinkStore'), + require('./stores/JWTStore'), + require('./stores/LoginStore'), + require('./stores/NotifyStore'), + require('./stores/OrgTeamStore'), + require('./stores/OrganizationStore'), + require('./stores/OutboundCommunicationStore'), + require('./stores/PipelineHistoryStore'), + require('./stores/PlansStore'), + require('./stores/PrivateRepoUsageStore'), + require('./stores/RepoDetailsBuildLogs'), + require('./stores/RepoDetailsBuildsStore'), + require('./stores/RepoDetailsDockerfileStore'), + require('./stores/RepoDetailsLongDescriptionFormStore'), + require('./stores/RepoDetailsShortDescriptionFormStore'), + require('./stores/RepoDetailsVisibilityFormStore'), + require('./stores/RepoSettingsCollaborators'), + require('./stores/RepositoryCommentsStore'), + require('./stores/RepositoryPageStore'), + require('./stores/SearchStore'), + require('./stores/SignupStore'), + require('./stores/TriggerBuildStore'), + require('./stores/UserProfileReposStore'), + require('./stores/UserProfileStore'), + require('./stores/UserProfileStarsStore'), + require('./stores/UserStore'), + require('./stores/WebhooksSettingsStore') + ] +}); + +/** + * Add a plugin which adds the global redux store as .reduxStore to all flux + * actions. + * + * This allows us to call actionContext.reduxStore.dispatch() to dispatch + * actions from fluxible actions + */ +app.plug({ + name: 'ReduxActionIntegration', + plugContext(options, context) { + // Options should be passed reduxStore from the options passed into + // createContext + + return { + // Each action's context should also have the redux store that's defined + // within the app context. + plugActionContext(actionContext) { + // NOTE: Server-side rendering passes this in as options. + // Client-side rendering passes this in as context. + // Fluxible has no way to specify options within rehydration. + // We can only affect context. + + // This is defined in server.js within `server.use` for server-side + // rendering and within `app.rehydrate` in client.js + actionContext.reduxStore = options.reduxStore || context.reduxStore; + } + }; + } +}); + +export default app; diff --git a/app/scripts/bootstrapCreateElement.js b/app/scripts/bootstrapCreateElement.js new file mode 100644 index 0000000000..1f9ccce538 --- /dev/null +++ b/app/scripts/bootstrapCreateElement.js @@ -0,0 +1,21 @@ +'use strict'; + +import React from 'react'; +import provideContext from 'fluxible-addons-react/provideContext'; +const debug = require('debug')('hub:bootstrapCreateElement'); +/** + * We create a react element for the Router using this function, passing it to + * the `createElement` (func) property in . This will enable us to + * provide context for the Fluxible app and adds some context functions like + * `executeAction` and `getStore` to all components in our Routes. + * + * context is a Fluxible Context. + */ +export default (context) => { + return (component, props) => { + debug('setting context'); + props.context = context.getComponentContext(); + props.JWT = props.JWT || (props.cookies && props.cookies.JWT) || false; + return React.createElement(provideContext(component), props); + }; +}; diff --git a/app/scripts/client.js b/app/scripts/client.js new file mode 100644 index 0000000000..54cdf61f0a --- /dev/null +++ b/app/scripts/client.js @@ -0,0 +1,102 @@ +/*global window, process, require */ +'use strict'; + +// Needed to shim Object.assign (used by 3rd party libs) +import 'babel-core/polyfill'; + +import React from 'react'; +import { render } from 'react-dom'; +import { Router } from 'react-router'; +import FluxRouter from './fluxibleRouter'; +import createHistory from 'history/lib/createBrowserHistory'; +import app from './app'; +import navigateAction from './actions/navigate'; +import JWTStore from './stores/JWTStore'; +import bootstrapCreateElement from './bootstrapCreateElement'; + +// This renders a Provider for basic flux integration +import { Provider } from 'react-redux'; +import reducers from './reducers'; +import enhancedCreateStore from './reduxStore'; + +require('velocity-animate'); +require('velocity-animate/velocity.ui'); + +let oDebug = require('debug'); +if (process.env.ENV !== 'production') { + oDebug.enable('hub:*'); +} +const debug = oDebug('hub:client'); +const dehydratedState = window.App; // Sent from the server + +if (process.env.ENV !== 'production') { + window.React = React; // For chrome dev tool support +} + +let history = createHistory(); +let unlisten = history.listen(function (location) { + debug('unlisten', location.pathname); +}); + +// Plug a History into the actionContext +let pluginRouter = Router; +app.plug({ + name: 'HistoryPlugin', + plugContext() { + return { + plugActionContext(actionContext) { + actionContext.history = history; + } + }; + } +}); + +function onUpdate(context) { + return (state, callback) => { + debug('at least the second render'); + if (state) { + if (!state.jwt) { + let jwtStore = context.getComponentContext().getStore(JWTStore); + state.jwt = jwtStore.getJWT(); + } + context.executeAction(navigateAction, state, callback); + } + }; +} + +function renderApp(context) { + const mountNode = document.getElementById('app'); + const Routes = app.getComponent(); + + // context.reduxStore works client-side as we're setting this directly + // below in rehydrate. + const jsx = ( + + + + ); + + return render(jsx, mountNode, () => { debug('React Rendered'); }); +} + +// The callback is called after the app has rehydrated any plugins; +// our redux plugin neets to create the store itself. +app.rehydrate(dehydratedState, function(err, context) { + debug('rehydrating app'); + if (err) { + throw err; + } + + // Create a new store and save it to Fluxible's app context. + context.reduxStore = enhancedCreateStore(reducers); + + window.context = context; + debug('supposedly the first render'); + // Don't call the action on the first render on top of the server rehydration + // Otherwise there is a race condition where the action gets executed before + // render has been called, which can cause the checksum to fail. + renderApp(context); +}); diff --git a/app/scripts/components/AddRepo.css b/app/scripts/components/AddRepo.css new file mode 100644 index 0000000000..f70fa38675 --- /dev/null +++ b/app/scripts/components/AddRepo.css @@ -0,0 +1,5 @@ +@import 'dux/css/box.css'; + +.contentWrapper { + margin-top: var(--default-margin); +} diff --git a/app/scripts/components/AddRepo.jsx b/app/scripts/components/AddRepo.jsx new file mode 100644 index 0000000000..21a6ef975d --- /dev/null +++ b/app/scripts/components/AddRepo.jsx @@ -0,0 +1,259 @@ +'use strict'; + +import React, { createClass, PropTypes } from 'react'; +import _ from 'lodash'; +import connectToStores from 'fluxible-addons-react/connectToStores'; + +import CreateRepositoryFormStore from 'stores/CreateRepositoryFormStore'; +import DUXInput from './common/DUXInput.jsx'; +import SimpleTextArea from './common/SimpleTextArea.jsx'; +import Route404 from './common/RouteNotFound404Page.jsx'; +import PrivateRepoUsageStore from 'stores/PrivateRepoUsageStore.js'; +import RepositoryNameInput from 'common/RepositoryNameInput.jsx'; +import createRepository from 'actions/createRepository'; +import updateAddRepositoryFormField from 'actions/updateAddRepositoryFormField'; +import getSettingsData from 'actions/getSettingsData'; +import { STATUS } from 'stores/common/Constants'; +import { SplitSection } from 'common/Sections'; + +import Markdown from '@dux/element-markdown'; +import Card, { Block } from '@dux/element-card'; +import { PageHeader, Button } from 'dux'; +import styles from './AddRepo.css'; +import DocumentTitle from 'react-document-title'; + +var debug = require('debug')('AddRepositoryForm'); + +var AddRepositoryForm = createClass({ + displayName: 'AddRepositoryForm', + contextTypes: { + executeAction: PropTypes.func.isRequired + }, + propTypes: { + JWT: PropTypes.string.isRequired, + createRepoFormStore: PropTypes.shape({ + fields: PropTypes.object.isRequired, + values: PropTypes.object.isRequired, + namespaces: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, + globalFormError: PropTypes.string + }), + privateRepoUsage: PropTypes.shape({ + privateRepoAvailable: PropTypes.number.isRequired + }) + }, + getInitialState: function() { + return { + currentNamespace: this.props.createRepoFormStore.values.namespace + }; + }, + _handleCreate: function(e) { + e.preventDefault(); + + var newRepo = { + namespace: this.state.currentNamespace, + name: this.props.createRepoFormStore.values.name.toLowerCase(), + description: this.props.createRepoFormStore.values.description, + full_description: this.props.createRepoFormStore.values.full_description, + is_private: this.props.createRepoFormStore.values.is_private === 'private' + }; + + this.context.executeAction(createRepository, + { + jwt: this.props.JWT, + repository: newRepo + }); + }, + _onChange(fieldKey) { + let _this = this; + return (e) => { + if (fieldKey === 'namespace') { + this.setState({ + currentNamespace: e.target.value + }); + this.context.executeAction(getSettingsData, { + JWT: _this.props.JWT, + username: e.target.value, + repoType: 'regular' + }); + } + this.context.executeAction(updateAddRepositoryFormField, { + fieldKey, + fieldValue: e.target.value + }); + }; + }, + _getCurrentQueryNamespace() { + //Check if user has passed in namespace as query | verify if they have access to it + var currentNamespace = this.props.location.query.namespace; + if (!_.includes(this.props.createRepoFormStore.namespaces, currentNamespace)) { + currentNamespace = this.props.createRepoFormStore.values.namespace; + } + return currentNamespace; + }, + componentDidMount() { + this.setState({ + currentNamespace: this._getCurrentQueryNamespace() + }); + }, + _renderCreateForm() { + const { createRepoFormStore } = this.props; + + var globalFormError = (
); + if(createRepoFormStore.globalFormError) { + globalFormError = ( +
{createRepoFormStore.globalFormError}
+ ); + } + + var nameError = (

); + var nameClass = ''; + if(createRepoFormStore.fields.name.hasError) { + nameError = ( +

+ {createRepoFormStore.fields.name.error + + ' No spaces and special characters other than - and . are allowed. Repo names should not begin/end with a . or - .'} +

+ ); + nameClass = 'text-error'; + } + + var fullDescriptionError = (

); + var fullDescriptionClass = 'large-12 columns'; + if(createRepoFormStore.fields.full_description.hasError) { + fullDescriptionError = ( +

{createRepoFormStore.fields.full_description.error}

+ ); + fullDescriptionClass = 'large-12 columns form-error'; + } + + var descriptionError = (

); + var descriptionClass = 'large-12 columns'; + if(createRepoFormStore.fields.description.hasError) { + descriptionError = ( +

{createRepoFormStore.fields.description.error}

+ ); + descriptionClass = 'large-12 columns form-error'; + } + var defaultValue = createRepoFormStore.values.is_private ? 'private' : 'public'; + var errText = ''; + if (createRepoFormStore.fields.is_private.hasError) { + errText = + + {createRepoFormStore.fields.is_private.error} + ; + } + const subtitleContent = `1. Choose a namespace *(Required)* +2. Add a repository name *(Required)* +3. Add a short description +4. Add markdown to the full description field +5. Set it to be a private or public repository`; + + return ( + {subtitleContent}}> +
+ {globalFormError} +
+
+ + {nameError} +
+ +
+
+ + {descriptionError} +
+
+ +
+
+ + {fullDescriptionError} +
+
+ +
+
+ Visibility + + {errText} +
+
+ + + +
+
+
+ ); + }, + render() { + if (!this.props.JWT) { + return (); + } else { + let content = ( +
+
+ + +

Something went wrong!

+

+ We were unable to populate your user namespaces. + If this issue persists, please contact support@docker.com +  or file an issue at hub-feedback on github +

+
+
+
+
+ ); + if ( this.props.createRepoFormStore.namespaces && this.props.createRepoFormStore.namespaces.length > 0 ) { + content = ( +
+ {this._renderCreateForm()} +
+ ); + } + + return ( + +
+ + { content } +
+
+ ); + } + } +}); + +export default connectToStores(AddRepositoryForm, + [ + CreateRepositoryFormStore + ], + function({ getStore }, props) { + return { + createRepoFormStore: getStore(CreateRepositoryFormStore).getState() + }; + }); diff --git a/app/scripts/components/Application.css b/app/scripts/components/Application.css new file mode 100644 index 0000000000..b8cb605434 --- /dev/null +++ b/app/scripts/components/Application.css @@ -0,0 +1,31 @@ +.main{ + display:flex; + flex-direction: column; + justify-content: flex-start; + min-height: 100vh; +} + +.storePromo { + text-align: center; + padding-left: 2rem; + padding-top: 0.3rem; + padding-bottom: 0.3rem; + color: white; + + /* set fallback in case multiple background images are not supported */ + background-color: #008BBF; + background-image: + linear-gradient(-74deg, transparent 90%, rgba(255, 255, 255, 0.23) 20%), + linear-gradient(-74deg, transparent 83%, rgba(255, 255, 255, 0.18) 15%), + linear-gradient(-74deg, transparent 76%, rgba(255, 255, 255, 0.1) 15%); + + a { + padding-bottom: 0.3rem; + color: inherit; + text-decoration: none; + } + + .storePromoUnderline { + text-decoration: underline; + } +} diff --git a/app/scripts/components/Application.jsx b/app/scripts/components/Application.jsx new file mode 100644 index 0000000000..f7655f9352 --- /dev/null +++ b/app/scripts/components/Application.jsx @@ -0,0 +1,135 @@ +'use strict'; + +const debug = require('debug')('Application'); +import ApplicationStore from '../stores/ApplicationStore'; +import JWTStore from '../stores/JWTStore'; +import UserStore from '../stores/UserStore.js'; +import React, { PropTypes, Component, cloneElement } from 'react'; +import Welcome from './Welcome.jsx'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import Spinner from './Spinner.jsx'; +import MainNav from './Nav.jsx'; +import styles from './Application.css'; +import DocumentTitle from 'react-document-title'; +import merge from 'lodash/object/merge'; +import { STORE_OFFICIAL_REPONAME_ID_MAP } from './store-promotion'; + +class Application extends Component { + + renderGenericStorePromo() { + return ( + + ); + } + + renderRepoStorePromo(name, id) { + return ( + + ); + } + + + render() { + //Destructure router and props + const { JWT, user, location, history, params } = this.props; + //We use !JWT to check if a user is in logged out state + + if (!JWT && location.pathname === '/') { + debug('Out Home'); + return ( + +
+ + +
+
+ ); + } else if (history.isActive('/login/') || history.isActive('/reset-password/')) { + debug('Application'); + return ( + +
+ {this.props.children} +
+
+ ); + } else { + debug('Root'); + + // Render store promotion based on the current location and only if the + // store-promo flag is set. + let promo = null; + const storePromoFlag = this.props.location.query['store-promo'] === '1'; + if (storePromoFlag) { + if (history.isActive('/_/') || + location.pathname.indexOf('/r/library/') === 0) { + const name = params.splat; + const id = STORE_OFFICIAL_REPONAME_ID_MAP[name]; + if (name && id) { + promo = this.renderRepoStorePromo(name, id); + } + } else if (history.isActive('/search/')) { + promo = this.renderGenericStorePromo(); + } else if (history.isActive('/explore/')) { + promo = this.renderGenericStorePromo(); + } + } + + const ifJWT = JWT ? JWT : ''; + return ( + +
+ {promo} + + {this.props.children && cloneElement(this.props.children, { + JWT: ifJWT, + user, + location + })} +
+
+ ); + } + } +} + +export default connectToStores(Application, + [ + ApplicationStore, + JWTStore, + UserStore + ], + ({ getStore }, props) => { + return merge({}, + getStore(ApplicationStore).getState(), + { + JWT: getStore(JWTStore).getJWT(), + user: getStore(UserStore).getState() + }); + }); diff --git a/app/scripts/components/Badge.jsx b/app/scripts/components/Badge.jsx new file mode 100644 index 0000000000..254a2cf6eb --- /dev/null +++ b/app/scripts/components/Badge.jsx @@ -0,0 +1,17 @@ +'use strict'; + +import React from 'react'; + +var Badge = React.createClass({ + render: function() { + var name = this.props.name; + var category = this.props.category; + return ( +
+
{name}
+
+ ); + } +}); + +module.exports = Badge; diff --git a/app/scripts/components/CSEngineDownloadPage.css b/app/scripts/components/CSEngineDownloadPage.css new file mode 100644 index 0000000000..ef0891456a --- /dev/null +++ b/app/scripts/components/CSEngineDownloadPage.css @@ -0,0 +1,3 @@ +.pageWrapper { + padding-top: 1.25rem; +} \ No newline at end of file diff --git a/app/scripts/components/CSEngineDownloadPage.jsx b/app/scripts/components/CSEngineDownloadPage.jsx new file mode 100644 index 0000000000..a9741e14b3 --- /dev/null +++ b/app/scripts/components/CSEngineDownloadPage.jsx @@ -0,0 +1,20 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +import CSEngineBox from 'common/CSEngineBox'; +import styles from './CSEngineDownloadPage.css'; + +//publicly accessible page for only CS engine download +export default class CSEngineDownloadPage extends Component { + render() { + return ( +
+
+
+ +
+
+
+ ); + } +} diff --git a/app/scripts/components/CreateButton.jsx b/app/scripts/components/CreateButton.jsx new file mode 100644 index 0000000000..458fe26762 --- /dev/null +++ b/app/scripts/components/CreateButton.jsx @@ -0,0 +1,24 @@ +'use strict'; +import React from 'react'; +import _ from 'lodash'; +import { FluxibleMixin } from 'fluxible'; + +var CreateButton = React.createClass({ + propTypes: { + what: React.PropTypes.string.isRequired + }, + getDefaultProps: function() { + return { + what: '' + }; + }, + render: function() { + return ( + + ); + } +}); + +module.exports = CreateButton; diff --git a/app/scripts/components/DashboardWrapper.css b/app/scripts/components/DashboardWrapper.css new file mode 100644 index 0000000000..d22ed0dc67 --- /dev/null +++ b/app/scripts/components/DashboardWrapper.css @@ -0,0 +1,4 @@ +.select { + margin-top: 6px; + min-width: 200px; +} diff --git a/app/scripts/components/DashboardWrapper.jsx b/app/scripts/components/DashboardWrapper.jsx new file mode 100644 index 0000000000..31627c12b6 --- /dev/null +++ b/app/scripts/components/DashboardWrapper.jsx @@ -0,0 +1,98 @@ +'use strict'; + +import React, { Component, PropTypes, cloneElement } from 'react'; +import { Link } from 'react-router'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import trunc from 'lodash/string/trunc'; +import PrivateReposBlock from './dashboards/PrivateRepoStatusBlock.jsx'; +import DashboardNamespacesStore from '../stores/DashboardNamespacesStore'; +import { SecondaryNav } from 'dux'; +import GravatarOption from './gravatar/GravatarOption'; +import GravatarValue from './gravatar/GravatarValue'; +import FA from './common/FontAwesome'; +import LiLink from './common/LiLink'; +import Select from 'react-select'; +import styles from './DashboardWrapper.css'; + +const { array, func, shape, string } = PropTypes; +const debug = require('debug')('DashboardWrapper'); + +class DashboardWrapper extends Component { + + static propTypes = { + //currentUserContext (dashboard user context, could be logged in user or one of the user's orgs) + //namespaces (all the namespaces that the user can look at) + dashboardNamespaces: shape({ + currentUserContext: string.isRequired, + namespaces: array.isRequired + }), + JWT: string, + user: shape({ + username: string + }) + } + + mkOptions = (arr) => { + return arr.map((namespace) => { + return {value: namespace, label: trunc(namespace, 16)}; + }); + }; + + changeContext = ({value: namespace, label}) => { + if (namespace) { + const { dashboardNamespaces, user, history, location } = this.props; + let currentPage = parseInt(location.query.page, 10); + if (dashboardNamespaces.currentUserContext === namespace) { + //do nothing + debug('doing nothing. Namespace is the same'); + } else if (user.username === namespace) { + //navigate to home + debug('navigate to home; its a user namespace'); + history.pushState(null, '/', {page: currentPage || 1}); + } else { + //navigate to org dashboard + debug('navigate to org dashboard'); + history.pushState(null, `/u/${namespace}/dashboard/`); + } + } + }; + + render() { + const { children, dashboardNamespaces, JWT, user } = this.props; + return ( +
+ +
    +
  • + +
  • + + Repositories + + + Teams + + { Billing } + { Settings } +
+ +
+ {cloneElement(children, { + JWT, + user: org, + isOwner, + currentUserContext + })} +
+ ); + } else { + return ( + + ); + } + } +} + +export default connectToStores(OrgDashboardWrapper, + [ + DashboardNamespacesStore, + OrganizationStore + ], + function({ getStore }, props) { + return merge( + {}, + getStore(DashboardNamespacesStore).getState(), + {org: getStore(OrganizationStore).getCurrentOrg()} + ); + }); diff --git a/app/scripts/components/Register.css b/app/scripts/components/Register.css new file mode 100644 index 0000000000..9a60f5fef6 --- /dev/null +++ b/app/scripts/components/Register.css @@ -0,0 +1,11 @@ +@import "dux/css/colors.css"; +@import "dux/css/box.css"; + +/* Upper Section */ +.header { + background: var(--docker-dark); + color: var(--smoke); + min-height: 560px; + height: 100%; + padding-top: 75px; +} diff --git a/app/scripts/components/Register.jsx b/app/scripts/components/Register.jsx new file mode 100644 index 0000000000..13b71b6b9f --- /dev/null +++ b/app/scripts/components/Register.jsx @@ -0,0 +1,23 @@ +'use strict'; + +import styles from './Register.css'; + +import React, { Component, PropTypes } from 'react'; +import SignupForm from './welcome/SignupForm.jsx'; +var debug = require('debug')('Register'); + +export default class Register extends Component { + render() { + return ( +
+
+ +
+ +
+ +
+
+ ); + } +} diff --git a/app/scripts/components/RepositoryPageWrapper.css b/app/scripts/components/RepositoryPageWrapper.css new file mode 100644 index 0000000000..87f9cfb4d5 --- /dev/null +++ b/app/scripts/components/RepositoryPageWrapper.css @@ -0,0 +1,31 @@ +@import "dux/css/colors.css"; + +.repoLabel { + text-transform: uppercase; + font-weight: 500; +} + +.repoHeader { + border-bottom: 1px solid #c1c9d1; + background-color: white; + padding-top: 1.6rem; + padding-bottom: 1.2rem; +} +.repoTitle { + color: #555; + margin-bottom: 0; + font-weight: 300; +} +.repoSubtitle { + color: var(--secondary-5); + display: block; +} + +.repoStar { + cursor: pointer; + color: var(--secondary-3); + margin-left: 1rem; + font-size: xx-large; +} + +.private {} \ No newline at end of file diff --git a/app/scripts/components/RepositoryPageWrapper.jsx b/app/scripts/components/RepositoryPageWrapper.jsx new file mode 100644 index 0000000000..ef3ceb7888 --- /dev/null +++ b/app/scripts/components/RepositoryPageWrapper.jsx @@ -0,0 +1,187 @@ +'use strict'; + +import styles from './RepositoryPageWrapper.css'; +import React, { PropTypes, Component, cloneElement } from 'react'; +const { string, number, bool, shape, array, object, func } = PropTypes; +import { Link } from 'react-router'; +import _ from 'lodash'; +import includes from 'lodash/collection/includes'; +import merge from 'lodash/object/merge'; +import omit from 'lodash/object/omit'; +import words from 'lodash/string/words'; +import moment from 'moment'; +import FA from 'common/FontAwesome'; +const REPOSTATUS = require('../stores/repostore/Constants').STATUS; + +import RouteNotFound404Page from 'common/RouteNotFound404Page'; +import toggleStarred from '../actions/toggleStarred'; +import RepositoryPageStore from '../stores/RepositoryPageStore'; +import DashboardNamespacesStore from '../stores/DashboardNamespacesStore'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import DocumentTitle from 'react-document-title'; + +var debug = require('debug')('RepositoryPageWrapper'); + +class Privacy extends Component { + static propTypes = { + isOfficial: bool.isRequired, + isPrivate: bool.isRequired, + isAutomated: bool.isRequired + } + render() { + const publicOrPrivate = this.props.isPrivate ? 'Private' : 'Public'; + if (this.props.isOfficial) { + return ( +
+
Official Repository
+
+ ); + } else if (this.props.isAutomated) { + return ( +
+
{publicOrPrivate} | Automated Build
+
+ ); + } else { + return ( +
+
{publicOrPrivate} Repository
+
+ ); + } + } +} + +class StarRepo extends Component { + static propTypes = { + hasStarred: bool.isRequired, + toggleStar: func.isRequired + } + render() { + const { toggleStar, hasStarred } = this.props; + + let toggle = toggleStar(true); + let icon = 'fa-star-o'; + + if (hasStarred) { + toggle = toggleStar(false); + icon = 'fa-star'; + } + + return ( + + + + ); + } +} + +class RepositoryPageWrapper extends Component { + static propTypes = { + description: string.isRequired, + fullDescription: string.isRequired, + hasStarred: bool.isRequired, + isPrivate: bool.isRequired, + isAutomated: bool.isRequired, + lastUpdated: string, + name: string.isRequired, + namespace: string.isRequired, + status: number.isRequired, + canEdit: bool.isRequired, + comments: shape({ + count: number.isRequired, + results: array.isRequired + }), + user: object, + JWT: string, + history: object.isRequired, + ownedNamespaces: array.isRequired, + namespaces: array, + currentUserContext: string + } + + static contextTypes = { + executeAction: func.isRequired + } + + _toggleStar = status => e => { + e.preventDefault(); + var repoShortName = this.props.namespace + '/' + this.props.name; + this.context.executeAction(toggleStarred, { + jwt: this.props.JWT, + repoShortName: repoShortName, + status: status + }); + }; + + render() { + if (this.props.STATUS === REPOSTATUS.REPO_NOT_FOUND) { + return ; + } + + let privacy; + + //Redirect to user profile page if public user, if org (and owner) redirect to dashboard, if public org, org profile page + let namespaceLink; + var {namespace, ownedNamespaces, user} = this.props; + if (includes(ownedNamespaces, this.props.namespace) && namespace !== user.username) { + namespaceLink = {this.props.namespace}; + } else { + namespaceLink = {this.props.namespace}; + } + const starRepo = ; + let repoShortName = ( +
+ {namespaceLink} + / + {this.props.name} + {starRepo} +
+ ); + if (this.props.isOfficial || this.props.namespace === 'library') { + repoShortName = ( +
+ {this.props.name} + {starRepo} +
+ ); + } + + let computedLastUpdated = 'never'; + if (this.props.lastUpdated) { + computedLastUpdated = moment(this.props.lastUpdated).fromNow(); + } + + return ( + +
+
+
+
+ +

{repoShortName}

+ Last pushed: {computedLastUpdated} +
+
+
+ {this.props.children && cloneElement(this.props.children, omit(this.props, 'children'))} +
+
+ ); + } +} + +export default connectToStores(RepositoryPageWrapper, + [ + RepositoryPageStore, + DashboardNamespacesStore + ], + ({ getStore }, props) => { + return merge({}, + getStore(DashboardNamespacesStore).getState(), + getStore(RepositoryPageStore).getState()); + }); diff --git a/app/scripts/components/Routes.css b/app/scripts/components/Routes.css new file mode 100644 index 0000000000..2f483ab999 --- /dev/null +++ b/app/scripts/components/Routes.css @@ -0,0 +1,15 @@ +/** + * Adding global css in Routes.css to load class names as-is so we don't have local processed classes + * + * vendor overrides + * ---------------- + * 1. react-select [styles/vendor-overrides/react-select.css] + * 2. rc-tooltip [styles/vendor-overrides/rc-tooltip.css] + * + */ +:global { + @import '../../styles/vendor-overrides/react-select.css'; + /** Default styles from rc-tooltip which will be overwritten **/ + @import "rc-tooltip/assets/bootstrap_white.css"; + @import '../../styles/vendor-overrides/rc-tooltip.css'; +} diff --git a/app/scripts/components/Routes.jsx b/app/scripts/components/Routes.jsx new file mode 100644 index 0000000000..713b0d8100 --- /dev/null +++ b/app/scripts/components/Routes.jsx @@ -0,0 +1,212 @@ +'use strict'; + +import { + Route, Redirect, IndexRoute +} from 'react-router'; +import React from 'react'; +import styles from './Routes.css'; + +const Account = require('./account/Account'); +const AccountSettings = require('./account/AccountSettings'); +const AddOrganizationForm = require('./account/AddOrganizationForm'); +import AddRepo from './AddRepo'; +const Application = require('./Application'); +const Autobuild = require('./repositories/Autobuild'); +const AutobuildIndex = require('./repositories/AutobuildIndex.jsx'); +const AutoBuildSetupForm = require('./repositories/AutoBuildSetupForm'); +const BillingPlans = require('./account/BillingPlans'); +import UpdateBillingInfo from './account/UpdateBillingInfo'; +import ChangePassSuccess from './welcome/ChangePassSuccess'; +import CreateBillingSubscription from './account/CreateBillingSubscription'; +import ConvertToOrg from './account/ConvertToOrg'; +import CSEngineDownloadPage from './CSEngineDownloadPage'; +import DashboardContribs from './dashboards/Contribs'; +import DashboardWrapper from './DashboardWrapper'; +import DashboardRepos from './dashboards/Repos'; +import DashboardStars from './dashboards/Stars'; +import orgDashTeams from './orgdashboards/Teams'; +import Dockerfile from './repo/repo_details/Dockerfile'; +import Explore from './Explore'; +import EnterpriseSubscriptions from './enterprise/Enterprise'; +import ServerTrial from './enterprise/EnterpriseTrial'; +import EnterpriseTrialSuccess from './enterprise/EnterpriseTrialSuccess'; +import EULA from './EULA'; +import EnterpriseTrialTerms from './enterprise/EnterpriseTrialTerms.jsx'; +import ForgotPassword from './welcome/ForgotPass'; +import GithubLinkScopes from './account/services/GithubLinkScopes'; +import Help from './help/Help'; +import Licenses from './account/Licenses'; +const LinkedAccountSourcesForm = require('./repositories/LinkedAccountSourcesForm'); +const LinkedServices = require('./account/LinkedServices'); +import Login from './Login'; +import Members from './orgdashboards/Members'; +const NotificationsSettings = require('./account/NotificationsSettings'); +const OrganizationProfile = require('./account/orgs/OrganizationProfile'); +const OrganizationSettings = require('./account/OrganizationSettings'); +const OrganizationSummary = require('./account/OrganizationSummary'); +import OrgDashboardWrapper from './OrgDashboardWrapper'; +import Register from './Register'; +import RepositorySettingsBuilds from './repo/repoSettings/Builds'; +import RepositorySettingsCollaborators from './repo/repoSettings/CollaboratorsWrapper.jsx'; +import RepositorySettingsMain from './repo/repoSettings/SettingsMain'; +import RepositorySettingsWebhooks from './repo/repoSettings/webhooks'; +import RepositorySettingsWrapper from './repo/RepositorySettingsWrapper'; +import RepositoryDetailsBuildDetails from './repo/repo_details/BuildDetails'; +import RepositoryDetailsBuildLogs from './repo/repo_details/BuildLogs'; +import RepositoryDetailsInfo from './repo/repo_details/Info'; +import RepositoryDetailsTags from './repo/repo_details/Tags'; +import RepositoryDetailsScannedTag from './repo/repo_details/ScannedTag'; +import RepositoryDetailsWrapper from './repo/RepositoryDetailsWrapper'; +import RepositoryPageWrapper from './RepositoryPageWrapper'; +import ResetPassword from './welcome/ResetPass'; +import RouteNotFound404Page from './common/RouteNotFound404Page'; +const Search = require('./search/Search'); +const UserStars = require('./userWrapper/UserStars'); +const User = require('./Users'); //Unused +import UserProfileWrapper from './UserProfileWrapper'; +import UserProfileRepos from './userprofile/Repos'; + +// NOTE: Provider right now doesn't work easily with fluxible's render pipeline. +// Even though we add the as a root element to Routes.jsx: +// +// module.exports = ( +// +// { routes } +// +// ); +// +// Fluxible doesn't render the root Provider component; it renders the first +// component which connects to fluxibles stores. +// +// FIX: We've instead added as the base class that fluxibleRouter +// renders +// +// TODO: When we rip out Fluxible add as a top level component here. + +var routes = ( + + {/* Login and Password */} + + + + + + {/* Currently logged in user Dashboard */} + + + + + + + {/* Public user profile */} + + + + + + {/* Organization Dashboard */} + + + + + + + + + + {/* Organizations Summary and Add Route */} + + + + + + {/* Add a repository route */} + + + {/* Autobuild creation related routes */} + + + + + + + + + {/* Github linking related route | the scope selection screen */} + + + {/* Official repositories route | TODO: add library/:name */} + + + + + + + {/* THIS ROUTE IS A DUPLICATE OF /u/:user/. WHY IS THIS HERE??? */} + + + + + + + + + + + + + + + + + + + + + + {/* User Account Settings */} + + + + + + + + + + + + + + + + + {/* Billing/Enterprise/Subscription related routes */} + + + {/* TODO: @camacho 2/9/16 - remove routes after 1 week to give time for loaded clients to be updated*/} + + + + + + + + + + {/* Some publicly available routes to explore, search and ask for help */} + + + + + + {/* Handle 404 for bad routes | If no route matches, render a 404 page */} + + +); + +module.exports = routes; diff --git a/app/scripts/components/Spinner.jsx b/app/scripts/components/Spinner.jsx new file mode 100644 index 0000000000..9a90885587 --- /dev/null +++ b/app/scripts/components/Spinner.jsx @@ -0,0 +1,20 @@ +'use strict'; +const React = require('react'); + +var Spinner = React.createClass({ + render: function() { + return (
+
+
+
+
+
+
+
+
+
+
); + } +}); + +module.exports = Spinner; diff --git a/app/scripts/components/StatsComponent.jsx b/app/scripts/components/StatsComponent.jsx new file mode 100644 index 0000000000..f285424eba --- /dev/null +++ b/app/scripts/components/StatsComponent.jsx @@ -0,0 +1,26 @@ +'use strict'; +import React from'react'; + +//TODO: add to component, currently just use a placeholder docker icon +//
    +//
  • {this.props.value}
  • +//
  • {this.props.statsItemName}
  • +//
+ +var StatsComponent = React.createClass({ + displayName: 'StatsComponent', + propTypes: { + value: React.PropTypes.string.isRequired, + statsItemName: React.PropTypes.string.isRequired + }, + render: function() { + return ( +
+

{this.props.value}

+

{this.props.statsItemName}

+
+ ); + } +}); + +module.exports = StatsComponent; diff --git a/app/scripts/components/UserProfileWrapper.css b/app/scripts/components/UserProfileWrapper.css new file mode 100644 index 0000000000..4059138564 --- /dev/null +++ b/app/scripts/components/UserProfileWrapper.css @@ -0,0 +1,27 @@ +@import "dux/css/colors.css"; +@import "dux/css/box.css"; + +.heading { + padding-left: 1rem; +} + +.userinfo { + border-top: 1px solid var(--secondary-5); + border-bottom: 1px solid var(--secondary-5); + padding: 1rem; + margin-right: 1rem; + list-style-type: none; +} + + .item { + width: 260px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gravatar { + border: 1px solid var(--secondary-5); + margin: 1rem 0; + border-radius: 3px; +} diff --git a/app/scripts/components/UserProfileWrapper.jsx b/app/scripts/components/UserProfileWrapper.jsx new file mode 100644 index 0000000000..d859f0de65 --- /dev/null +++ b/app/scripts/components/UserProfileWrapper.jsx @@ -0,0 +1,127 @@ +'use strict'; + +import React, { PropTypes, createClass, cloneElement } from 'react'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import UserProfileStore from '../stores/UserProfileStore'; +import RepositoriesList from './common/RepositoriesList'; +import RouteNotFound404Page from './common/RouteNotFound404Page'; +import moment from 'moment'; +import { SecondaryNav } from 'dux'; +import FA from 'common/FontAwesome'; +import { Link } from 'react-router'; +import _ from 'lodash'; +import styles from './UserProfileWrapper.css'; +import DocumentTitle from 'react-document-title'; + +var debug = require('debug')('UserProfileWrapper'); + +var UserShape = { + id: PropTypes.string.isRequired, + username: PropTypes.string, + orgname: PropTypes.string, + full_name: PropTypes.string.isRequired, + location: PropTypes.string.isRequired, + company: PropTypes.string.isRequired, + profile_url: PropTypes.string.isRequired, + date_joined: PropTypes.string.isRequired, + gravatar_url: PropTypes.string.isRequired +}; + +var ProfileCard = createClass({ + displayName: 'ProfileCard', + propTypes: UserShape, + render() { + var gravatar = this.props.gravatar_url; + /** + * If the gravatar has a size equal to 80 in the url, + * change it to 512. This is a HACK. + */ + if(this.props.gravatar_url && this.props.gravatar_url.match(/s=80/)) { + gravatar = gravatar.replace('s=80', 's=512'); + } + + var maybeLocation = null; + var maybeCompany = null; + var maybeProfileUrl = null; + + if (this.props.location) { + maybeLocation =
  • {this.props.location}
  • ; + } + if (this.props.maybeCompany) { + maybeCompany =
  • {this.props.company}
  • ; + } + if (this.props.profile_url) { + maybeProfileUrl =
  • {this.props.profile_url}
  • ; + } + + return ( +
    +
    +
    + +
    +
    +
    +

    {this.props.username || this.props.orgname}

    +

    {this.props.full_name}

    +
      + {maybeLocation} + {maybeCompany} + {maybeProfileUrl} +
    • Joined {moment(this.props.date_joined).format('MMMM YYYY')}
    • +
    +
    +
    + ); + } +}); + +var UserProfile = createClass({ + displayName: 'UserProfile', + propTypes: { + user: PropTypes.shape(UserShape) + }, + render() { + if(this.props.STATUS === '404' || _.isEmpty(this.props.user)) { + return (); + } else { + let namespace; + var maybeStars = null; + if(this.props.user.orgname) { + namespace = this.props.user.orgname; + } else { + namespace = this.props.user.username; + maybeStars = (
  • Stars
  • ); + } + return ( + +
    + +
      +
    • Repos
    • + {maybeStars} +
    +
    +
    +
    + +
    +
    + {this.props.children && cloneElement(this.props.children, {user: this.props.user})} +
    +
    +
    +
    + ); + } + } +}); + +export default connectToStores(UserProfile, + [ + UserProfileStore + ], + function({ getStore }, props) { + return getStore(UserProfileStore) + .getState(); + }); diff --git a/app/scripts/components/Users.jsx b/app/scripts/components/Users.jsx new file mode 100644 index 0000000000..1e173210da --- /dev/null +++ b/app/scripts/components/Users.jsx @@ -0,0 +1,60 @@ +'use strict'; +/** +TODO: UNUSED COMPONENTS. SHOULD REMOVE +*/ +import React from 'react'; +import { Link } from 'react-router'; + +var UserPage = React.createClass({ + getDefaultProps: function() { + return { + user: {}, + JWT: '' + }; + }, + render: function() { + return ( +
    + This will be the base wrapper of the 'Users' page where either your or another users profile will appear
    + This will let you see your public facing page at /u/username/ too
    + 'Your' homepage/dashboard will live at /home/
    + +
    + ); + } +}); + +var RootUser = React.createClass({ + render: function() { + return ( +
    +

    + This is root user page.
    + When not looking at a specific user or an owned image
    + This will show a list of repos/images owned by the root user
    + This could be a image box of some sort +

    + ); + } +}); + +var User = React.createClass({ + // This page should either be the users home page or the view page for other users + render: function() { + return ( +
    +

    + This is the UID: {this.props.params.uid}
    + This is main user page.
    + This will show a list of repos/images owned by the user
    + +

    + ); + } +}); + +module.exports = { + userpage: UserPage, + rootuser: RootUser, + user: User +}; diff --git a/app/scripts/components/Welcome.css b/app/scripts/components/Welcome.css new file mode 100644 index 0000000000..7c4be7573f --- /dev/null +++ b/app/scripts/components/Welcome.css @@ -0,0 +1,86 @@ +@import "dux/css/colors.css"; +@import "dux/css/box.css"; + +.flex{ + display:flex; + flex-direction: column; + justify-content: flex-start; + flex-grow: 1; +} + +.white { + background-color: var(--white); + flex-grow: 1; + padding-top: calc(var(--default-margin) * 1.5); +} + +.browse { + padding: .5rem 0; + text-align: center; +} + +/* Upper Section */ +.header { + background: var(--docker-dark); + color: var(--smoke); + min-height: 560px; + padding-top: 75px; +} + +.top { + padding: 0 var(--default-margin); +} + +.buildShipRun { + margin-top: 5rem; +} + +.subtext { + color: var(--white); +} + +.heading { + font-weight: 300; + color: var(--smoke); +} + +.headingHero { + composes: heading; + font-size: 3rem; + line-height: 0.8; +} + +.headingHeroAlt { + composes: headingHero; + color: var(--primary-color); +} + +/* Lower Section */ +.lowerHeading { + text-align: center; + margin-bottom: var(--default-margin); +} + +/* Company List */ +.companyRow { + padding: 1.5rem 0 .5rem 0; + margin-left: 0; + margin-right: 0; + margin-bottom: var(--default-margin); +} + +.companyLinkLi { + text-align: center; +} + +/* Footer */ +.footer { + text-align: center; + margin-top: var(--default-margin); +} + +.footerCopy { + color: var(--gray-3); + font-weight: 100; + font-size: .8rem; +} diff --git a/app/scripts/components/Welcome.jsx b/app/scripts/components/Welcome.jsx new file mode 100644 index 0000000000..5a9a95bb0e --- /dev/null +++ b/app/scripts/components/Welcome.jsx @@ -0,0 +1,113 @@ +'use strict'; + +import styles from './Welcome.css'; + +import React, { createClass, PropTypes } from 'react'; +import LoginForm from './welcome/LoginForm.jsx'; +import SignupForm from './welcome/SignupForm.jsx'; +import { Link } from 'react-router'; +import classnames from 'classnames'; +import Button from '@dux/element-button'; +var debug = require('debug')('Welcome'); + +let LowerSection = createClass({ + displayName: 'LowerSection', + propTypes: { + companyList: PropTypes.arrayOf(PropTypes.object) + }, + getDefaultProps() { + return { + companyList: [ + {logo: 'harvard'}, + {logo: 'sony'}, + {logo: 'nordstrom'}, + {logo: 'oracle'}, + {logo: 'zendesk'}, + {logo: 'gopro'}, + {logo: 'autodesk'}, + {logo: 'cisco'}, + {logo: 'zenefits'}, + {logo: 'dollar-shave-club'}, + {logo: 'zipcar'}, + {logo: 'gilt'}, + {logo: 'adp'}, + {logo: 'makerbot'}, + {logo: 'oculus'}, + {logo: 'adobe'} + ] + }; + }, + mkCompanyLi({name, url, logo}) { + let img = `/public/images/customers/${logo}.png`; + return ( +
  • + +
  • + ); + }, + render() { + + let companyClasses = classnames({ + 'small-block-grid-4': true, + [styles.companyRow]: true + }); + + return ( +
    +

    The most innovative companies use Docker

    +
    +
    +
      + {this.props.companyList.map(str => { return this.mkCompanyLi(str); })} +
    +
    +
    +
    + ); + } +}); + +var Welcome = React.createClass({ + displayName: 'WelcomePage', + transitionExplore: function(e){ + e.preventDefault(); + this.props.history.pushState(null, '/explore/'); + }, + render() { + + let buildShipRun = classnames({ + 'large-8 columns': true, + [styles.buildShipRun]: true + }); + + return ( +
    +
    +
    + +
    +

    Build, Ship, & Run

    +

    Any App, Anywhere

    +

    Dev-test pipeline automation, 100,000+ free apps, public and private registries

    +
    + +
    + +
    + +
    +
    +
    + Browse Thousands of the most popular software tools in the Docker Image Library +
    +
    +
    +

    © 2016 Docker Inc.

    +
    +
    +
    + ); + } +}); + +module.exports = Welcome; diff --git a/app/scripts/components/account/Account.jsx b/app/scripts/components/account/Account.jsx new file mode 100644 index 0000000000..0f30fa138b --- /dev/null +++ b/app/scripts/components/account/Account.jsx @@ -0,0 +1,127 @@ +'use strict'; + +import React, { PropTypes, cloneElement } from 'react'; +import { Link } from 'react-router'; +import FluxibleMixin from 'fluxible-addons-react/FluxibleMixin'; +import { SecondaryNav } from 'dux'; +import FA from 'common/FontAwesome'; +import LiLink from '../common/LiLink'; +import AccountSettingsLicensesStore from '../../stores/AccountSettingsLicensesStore'; +import EmailNotifStore from '../../stores/EmailNotifStore'; +import Route404 from '../common/RouteNotFound404Page.jsx'; + +let SettingNav = React.createClass({ + displayName: 'SettingsSecondaryNav', + mixins: [FluxibleMixin], + contextTypes: { + getStore: PropTypes.func.isRequired + }, + statics: { + storeListeners: { + onLicensesStoreChange: [AccountSettingsLicensesStore], + onNotifStoreChange: [EmailNotifStore] + } + }, + onLicensesStoreChange: function() { + let store = this.context.getStore(AccountSettingsLicensesStore); + this.setState({ + licenseAttempt: store.getAttempt() + }); + }, + onNotifStoreChange: function() { + let store = this.context.getStore(EmailNotifStore); + this.setState({ + notificationAttempt: store.getAttempt() + }); + }, + getInitialState() { + return { + licenseAttempt: false, + notificationAttempt: false + }; + }, + _showLicensesLoader() { + this.context.getStore(AccountSettingsLicensesStore).setAttempt(true); + this.setState({ + licenseAttempt: true + }); + }, + _showNotificationsLoader() { + this.context.getStore(EmailNotifStore).setAttempt(true); + this.setState({ + notificationAttempt: true + }); + }, + renderLicenses() { + var licensesElement; + if (!this.state.licenseAttempt) { + licensesElement = 'Licenses'; + } else { + licensesElement = (Licenses ); + } + return {licensesElement}; + }, + renderNotifications() { + var notificationsElement; + if (!this.state.notificationAttempt) { + notificationsElement = 'Notifications'; + } else { + notificationsElement = (Notifications ); + } + return {notificationsElement}; + }, + render() { + return ( + +
      + Account Settings + Billing & Plans + Linked Accounts & Services + {this.renderNotifications()} + {this.renderLicenses()} +
    +
    + ); + } +}); + +var Account = React.createClass({ + mixins: [FluxibleMixin], + propTypes: { + loggedOutElement: PropTypes.element + }, + contextTypes: { + getStore: PropTypes.func.isRequired + }, + render: function() { + + const { JWT, user, location } = this.props; + const path = location.pathname; + if (!JWT && path === '/account/billing-plans/create-subscription/') { + /* Handles the www.docker.com/pricing links to buy hub plans at URLs + * /account/billing-plans/create-subscription/?plan=index_personal_PLANSIZE + * when the user is logged out (has special redirects) + */ + return cloneElement(this.props.children, { + user: this.props.user, + JWT: this.props.JWT + }); + } else if (!JWT) { + return ( + + ); + } else { + return ( +
    + + {this.props.children && cloneElement(this.props.children, { + user: this.props.user, + JWT: this.props.JWT + })} +
    + ); + } + } +}); + +module.exports = Account; diff --git a/app/scripts/components/account/AccountSettings.css b/app/scripts/components/account/AccountSettings.css new file mode 100644 index 0000000000..f0a93fca61 --- /dev/null +++ b/app/scripts/components/account/AccountSettings.css @@ -0,0 +1,14 @@ +@import "dux/css/box"; +@import "dux/css/colors"; + +.body { + margin-top: var(--default-margin); +} + +.margin { + margin: var(--default-margin); +} + +.visibility { + color: var(--black); +} \ No newline at end of file diff --git a/app/scripts/components/account/AccountSettings.jsx b/app/scripts/components/account/AccountSettings.jsx new file mode 100644 index 0000000000..9a102998b5 --- /dev/null +++ b/app/scripts/components/account/AccountSettings.jsx @@ -0,0 +1,141 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import { Link } from 'react-router'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import _ from 'lodash'; + +import styles from './AccountSettings.css'; +import EmailForm from './forms/EmailForm'; +import AccountInfoForm from './forms/AccountInfoForm'; +import ChangePassForm from './forms/ChangePassForm'; +import convertToOrgAction from '../../actions/convertToOrganization'; +import toggleVisibility from '../../actions/toggleVisibility.js'; +import PrivateRepoUsageStore from '../../stores/PrivateRepoUsageStore.js'; +import { PageHeader, Button } from 'dux'; +import classnames from 'classnames'; +import { SplitSection } from '../common/Sections.jsx'; +import DocumentTitle from 'react-document-title'; + +var debug = require('debug')('AccountSettings'); + +var Settings = React.createClass({ + contextTypes: { + executeAction: React.PropTypes.func.isRequired + }, + getDefaultProps: function() { + return { + user: {}, + JWT: '' + }; + }, + render: function() { + + return ( + +
    + +
    +
    + + + + + +
    +
    +
    +
    + ); + } +}); + +var DefaultVisibility = React.createClass({ + displayName: 'DefaultVisibility', + PropTypes: { + defaultRepoVisibility: PropTypes.string.isRequired + }, + contextTypes: { + executeAction: React.PropTypes.func.isRequired + }, + toggleClick(visibility) { + const _this = this; + return (e) => { + e.preventDefault(); + this.context.executeAction(toggleVisibility, _.merge(visibility, {JWT: _this.props.JWT, username: _this.props.username})); + }; + }, + render: function() { + + return ( +
    + Update the default visibility for your repositories.

    }> +
    +
    +
      +
    • + +
    • +
    • + +
    • +
    +
    +
    +
    +
    + ); + } +}); + +var ToOrgForm = React.createClass({ + _toOrgClick: function(e) { + e.preventDefault(); + this.props.history.pushState(null, '/account/convert-to-org/'); + }, + render: function() { + + if (this.props.userType === 'Organization') { + return ( +
    +
    +
    This is an Organization account for:
    +
    {this.props.username}
    +
    +
    +
    + ); + } else { + return ( + To use organization features you must convert your account from a "User" to an "Organization"

    }> +
    + +
    +
    + ); + } + } +}); + +export default connectToStores(Settings, + [ + PrivateRepoUsageStore + ], + function({ getStore }, props) { + return getStore(PrivateRepoUsageStore).getState(); + }); diff --git a/app/scripts/components/account/AddOrganizationForm.css b/app/scripts/components/account/AddOrganizationForm.css new file mode 100644 index 0000000000..f70fa38675 --- /dev/null +++ b/app/scripts/components/account/AddOrganizationForm.css @@ -0,0 +1,5 @@ +@import 'dux/css/box.css'; + +.contentWrapper { + margin-top: var(--default-margin); +} diff --git a/app/scripts/components/account/AddOrganizationForm.jsx b/app/scripts/components/account/AddOrganizationForm.jsx new file mode 100644 index 0000000000..63e658d507 --- /dev/null +++ b/app/scripts/components/account/AddOrganizationForm.jsx @@ -0,0 +1,96 @@ +'use strict'; + +import React from 'react'; +import OrganizationStore from '../../stores/OrganizationStore'; +import AddOrganizationStore from '../../stores/AddOrganizationStore'; +import createOrganizationAction from '../../actions/createOrganization'; +import updateAddOrganizationFormField from '../../actions/updateAddOrganizationFormField'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +const debug = require('debug')('AddOrganizationForm'); +import SimpleInput from 'common/SimpleInput.jsx'; +import Button from '@dux/element-button'; +import styles from './AddOrganizationForm.css'; +import { SplitSection } from '../common/Sections.jsx'; + +var AddOrganizationForm = React.createClass({ + contextTypes: { + executeAction: React.PropTypes.func.isRequired + }, + propTypes: { + JWT: React.PropTypes.string.isRequired + }, + _onChange(fieldKey) { + return (e) => { + this.context.executeAction(updateAddOrganizationFormField, { + fieldKey, + fieldValue: e.target.value + }); + }; + }, + _handleCreate: function(e) { + e.preventDefault(); + /*eslint-disable camelcase */ + var newOrg = { + orgname: this.props.values.orgname.toLowerCase(), + full_name: this.props.values.full_name, + gravatar_email: this.props.values.gravatar_email, + company: this.props.values.company, + location: this.props.values.location, + profile_url: this.props.values.profile_url + /*eslint-enable camelcase */ + }; + this.context.executeAction(createOrganizationAction, { + jwt: this.props.JWT, + organization: newOrg + }); + }, + render: function() { + return ( +
    + + Organizations can have multiple Teams. Teams can have differing permissions. Namespace is + unique and this is where repositories for this organization will be created. +

    }> +
    +
    + + + + + + + + + +
    +
    + ); + } +}); + +export default connectToStores(AddOrganizationForm, + [ + AddOrganizationStore + ], + function({ getStore }, props) { + return getStore(AddOrganizationStore).getState(); + }); diff --git a/app/scripts/components/account/BillingPlans.css b/app/scripts/components/account/BillingPlans.css new file mode 100644 index 0000000000..d0d01aa909 --- /dev/null +++ b/app/scripts/components/account/BillingPlans.css @@ -0,0 +1,21 @@ +@import 'dux/css/colors.css'; + +.body { + margin-top: 2rem; +} + +.error { + background: var(--primary-5); + color:white; +} + +.plansQuestion { + margin-bottom: 2rem; +} + +.questionTitle { + font-weight: 500; + color: var(--secondary-1); +} + +/*.questionAnswer {}*/ diff --git a/app/scripts/components/account/BillingPlans.jsx b/app/scripts/components/account/BillingPlans.jsx new file mode 100644 index 0000000000..dc241415cc --- /dev/null +++ b/app/scripts/components/account/BillingPlans.jsx @@ -0,0 +1,249 @@ +/*global MktoForms2*/ + +'use strict'; + +import React, { PropTypes } from 'react'; +const { string, array, object, func, shape } = PropTypes; +import { Link } from 'react-router'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import isEmpty from 'lodash/lang/isEmpty'; + +import BillingPlansStore from '../../stores/BillingPlansStore'; +import updateSubscriptionPlanOrPackage from '../../actions/updateSubscriptionPlanOrPackage.js'; + +import PlansTable from './billingplans/Plans'; +import EnterpriseSubscriptions from './billingplans/EnterpriseSubscriptions.jsx'; +import BillingInfo from './billingplans/BillingInfo.jsx'; +import InvoiceTables from './billingplans/InvoiceTables.jsx'; +import styles from './BillingPlans.css'; +import { FullSection } from '../common/Sections.jsx'; +import Route404 from '../common/RouteNotFound404Page.jsx'; +import { PageHeader } from 'dux'; +import DocumentTitle from 'react-document-title'; + +/* Marketo constants for the marketing survey form */ +const mktoFormId = 1317; +const mktoFormElemId = 'mktoForm_' + mktoFormId; +const mktoFormBaseUrl = 'https://app-sj05.marketo.com'; +const mktoFormMunchkinId = '929-FJL-178'; + +var BillingInfoPage = React.createClass({ + + contextTypes: { + executeAction: func.isRequired + }, + + PropTypes: { + JWT: string, + user: object, + currentPlan: shape({ + id: string, + plan: string, + package: string + }), + accountInfo: shape({ + account_code: string, + username: string, + email: string, + first_name: string, + last_name: string, + company_name: string + }), + billingInfo: shape({ + city: string, + state: string, + zip: string, + first_name: string, + last_name: string, + address1: string, + address2: string, + country: string + }), + plansError: string, + invoices: array, + unsubscribing: string, + updatePlan: string + }, + + stopSubscription(subscriptionType) { + /** + * UPDATE 4/6/16 "cloud_metered" is the new "free" plan instead of deleting + * per ticket HUB-2219 + */ + return () => { + const { JWT, user, currentPlan } = this.props; + const namespace = user.username || user.orgname; + let subscriptionData = { + JWT, + username: namespace, + subscription_uuid: currentPlan.subscription_uuid + }; + if (subscriptionType === 'plan') { + subscriptionData.plan_code = 'cloud_metered'; + if (currentPlan.package) { + // preserve package (like cloud_starter) if it exists + subscriptionData.package_code = currentPlan.package; + } + } else if (subscriptionType === 'package') { + // If you are removing a package, leave the plan alone + subscriptionData.plan_code = currentPlan.plan; + // Explicitly set null to remove + subscriptionData.package_code = null; + } + this.context.executeAction(updateSubscriptionPlanOrPackage, subscriptionData); + }; + }, + + showSurveyModal(subscriptionType) { + return () => { + if (typeof MktoForms2 === 'object' && + typeof MktoForms2.loadForm === 'function') { + MktoForms2.loadForm( + mktoFormBaseUrl, + mktoFormMunchkinId, + mktoFormId, + (form) => { + form.onSubmit(this.stopSubscription(subscriptionType)); + // Don't refresh the page after a successful submission. + // React component will re-render itself. + form.onSuccess(() => false); + form.vals({'Email': this.props.accountInfo.email}); + MktoForms2.lightbox(form).show(); + }); + } else { + // If for any reason there is a problem with the Marketo script, + // we shouldn't block the user from stopping his/her subscription. + this.stopSubscription(subscriptionType); + } + }; + }, + + getSurveyModalHtml() { + return ( +
    +
    +
    + ); + }, + + render: function() { + let plansIntro; + let plansFooter; + let errorSection; + const { + accountInfo, + billingInfo, + currentPlan, + invoices, + isOwner, + JWT, + plansError, + unsubscribing, + updatePlan, + user, + history + } = this.props; + if (accountInfo.newBilling) { + plansIntro = ( + +
    + The Docker Hub Registry is free to use for public repositories. Plans with private repositories are + available in different sizes. All plans allow collaboration with unlimited people. +
    +
    + ); + plansFooter = ( + +
    +
    What types of payment do you accept?
    +
    Credit card (Visa, MasterCard, Discover, or American Express).
    +
    +
    +
    Do I have to pay to use your service?
    +
    No, you only have to pay if you require one or more private repository.
    +
    +
    +
    Can I change my plan at a later time?
    +
    Yes, you can upgrade or downgrade at any time.
    +
    +
    +
    What if I need a larger plan?
    +
    Please contact our Sales team at sales@docker.com or call us toll free at 888-214-4258.
    +
    +
    + ); + } else { + plansIntro = (
    ); + plansFooter = (
    ); + } + if (plansError) { + errorSection = ( + +
    + There was an error trying to update your subscription. Please contact the Docker support team.
    + {plansError} +
    +
    + ); + } + const username = user.username || user.orgname || ''; + const isOrg = !!user.orgname; + + if (isOrg && !isOwner) { + return ( + + ); + } else { + /* + accountInfo.newBilling / billingInfo.newBilling + Means this is a brand new account without any information saved to the backend + */ + return ( + +
    + +
    + {plansIntro} + {errorSection} + + {plansFooter}
    +
    + + + +
    + {this.getSurveyModalHtml()} +
    +
    + ); + } + } +}); + +export default connectToStores(BillingInfoPage, + [BillingPlansStore], + function({ getStore }, props) { + return getStore(BillingPlansStore).getState(); + }); diff --git a/app/scripts/components/account/ConvertToOrg.css b/app/scripts/components/account/ConvertToOrg.css new file mode 100644 index 0000000000..ace3d024ff --- /dev/null +++ b/app/scripts/components/account/ConvertToOrg.css @@ -0,0 +1,25 @@ +@import 'dux/css/colors.css'; + +.body { + margin-top: 1rem; +} + +.toOrgContent { + margin-bottom: 1rem; +} + +.warning { + color: var(--primary-5); +} + +.center { + text-align: center; + margin-bottom: 1rem; +} + +.form { + padding-top: 1rem; + div { + margin-bottom: 0; + } +} \ No newline at end of file diff --git a/app/scripts/components/account/ConvertToOrg.jsx b/app/scripts/components/account/ConvertToOrg.jsx new file mode 100644 index 0000000000..22545837e9 --- /dev/null +++ b/app/scripts/components/account/ConvertToOrg.jsx @@ -0,0 +1,145 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import { Link } from 'react-router'; +import _ from 'lodash'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import classnames from 'classnames'; + +import DUXInput from '../common/DUXInput.jsx'; +import convertToOrgAction from '../../actions/convertToOrganization'; +import updateToOrgOwner from '../../actions/updateToOrgOwner.js'; +import ConvertToOrgStore from '../../stores/ConvertToOrgStore.js'; +import { PageHeader, Module } from 'dux'; +import Button from '@dux/element-button'; + +import { FullSection } from '../common/Sections.jsx'; + +import styles from './ConvertToOrg.css'; + +var debug = require('debug')('ConvertToOrg'); + +var _mkErrors = function(err, key) { + const errorClass = classnames({ + [ styles.center ]: true, + [ styles.warning ]: true + }); + return ( +
    + { err } +
    + ); +}; + +var ConvertToOrg = React.createClass({ + contextTypes: { + executeAction: PropTypes.func.isRequired + }, + propTypes: { + user: PropTypes.shape({ + username: PropTypes.string + }), + JWT: PropTypes.string, + convertError: PropTypes.bool.isRequired, + error: PropTypes.object + }, + getInitialState: function() { + return { + newOwner: '' + }; + }, + onChangeOwner: function(e) { + e.preventDefault(); + var newOwner = e.target.value; + this.context.executeAction(updateToOrgOwner, { newOwner: newOwner }); + }, + onCancelClick: function(e) { + e.preventDefault(); + this.props.history.pushState(null, '/account/settings/'); + }, + submitChangeOrg: function(e) { + e.preventDefault(); + debug('Change user to org'); + this.context.executeAction(convertToOrgAction, + {jwt: this.props.JWT, username: this.props.user.username, newOwner: this.props.newOwner}); + }, + render: function() { + var disabled = !this.props.newOwner; + var intent = this.props.convertError ? 'alert' : null; + let error = null; + if (this.props.convertError) { + error = ( +
    + { _.map(this.props.error, _mkErrors) } +
    + ); + } + return ( +
    + +
    +
    + +
    Your user account will be transformed into an organization account where all administrative duties are left to another user or group of users. You will no longer be able to login to this account.
    +
    + +
    Email addresses for this account will be removed, freeing them up to be used for any other accounts.
    +
    + +
    Converting your account removes any associations to other services like GitHub or Atlassian Bitbucket. You will be able to link your external accounts to another Docker Hub user.
    +
    + +
    Billing details and Private Repository plans will remain attached to this account after it is converted to an organization.
    +
    + +
    Repository namespaces and names remain unchanged. Any user collaborators that you have configured for these repositories will be removed and must be reconfigured using group collaborators.
    +
    + +
    Automated Builds for this account will be updated to appear as if they were originallly configured by the initial organization owner. Any user in a group with 'admin' level access to a repository will be able to edit Automated Build Configurations.
    +
    +
    +
    +
    WARNING
    +
    This account conversion operation can not be undone.
    +
    +
    +
    +
    +

    In order to complete the conversion of your account to an organization you will need to enter the Docker ID of an **existing** Docker Hub user account. + The user account you specify will become a member of the Owners group and will have full administrative privileges to manage the organization.

    + + { error } +
    +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ); + } +}); + +export default connectToStores(ConvertToOrg, + [ConvertToOrgStore], + function({ getStore }, props) { + return getStore(ConvertToOrgStore).getState(); + }); diff --git a/app/scripts/components/account/CreateBillingSubscription.css b/app/scripts/components/account/CreateBillingSubscription.css new file mode 100644 index 0000000000..d7d63cbe3e --- /dev/null +++ b/app/scripts/components/account/CreateBillingSubscription.css @@ -0,0 +1,36 @@ +@import "dux/css/box"; +@import 'dux/css/colors.css'; + +.select { + width: 5rem; +} +.error { + color: var(--primary-5); +} + +.title { + font-weight: 700; + margin-top: 1.5rem; +} + +.subtitle { + color: var(--primary-2); + margin-bottom: .5rem; +} + +.preview { + composes: module from 'dux/dux/Module/Module.css'; + padding: 2rem 2rem 0; + margin-left: 1rem; +} + +.total { + margin-bottom: 35px; +} + +.couponCode { + margin-top: 2rem; +} +.price { + text-align: right; +} diff --git a/app/scripts/components/account/CreateBillingSubscription.jsx b/app/scripts/components/account/CreateBillingSubscription.jsx new file mode 100644 index 0000000000..972231444b --- /dev/null +++ b/app/scripts/components/account/CreateBillingSubscription.jsx @@ -0,0 +1,319 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +const { string, number, func, shape, object, array, oneOfType } = PropTypes; +import map from 'lodash/collection/map'; +import find from 'lodash/collection/find'; +import includes from 'lodash/collection/includes'; +import has from 'lodash/object/has'; +import merge from 'lodash/object/merge'; +import classnames from 'classnames'; +import { Link } from 'react-router'; +import connectToStores from 'fluxible-addons-react/connectToStores'; + +import BillingInfoForm from './billingplans/BillingInfoForm.jsx'; +import BillingInfoFormStore from '../../stores/BillingInfoFormStore.js'; +import PlansStore from '../../stores/PlansStore.js'; +import BillingPlansStore from '../../stores/BillingPlansStore.js'; +import DUXInput from '../common/DUXInput.jsx'; +import createBillingSubscription from '../../actions/createSubscription.js'; +import updateSubscriptionPlanOrPackage from '../../actions/updateSubscriptionPlanOrPackage.js'; +import updateBillingInfoFormField from '../../actions/updateBillingInfoFormField.js'; +import validateBillingInfo from '../../actions/common/validateBillingInfo.js'; +import validateCouponCode from '../../actions/validateCouponCode.js'; +import EnterpriseLoggedOutPage from '../enterprise/EnterpriseLoggedOutPage.jsx'; +import { Button, PageHeader } from 'dux'; +import styles from './CreateBillingSubscription.css'; + +var debug = require('debug')('createBillingSubscription'); + +function mkShortPlanIntervalUnit(unit) { + if (unit === 'months'){ + return 'mo'; + } else if (unit === 'years') { + return 'yr'; + } else { + return 'mo'; + } +} + +function _mkOptions(list_item) { + return ( + + ); +} + +var CreateBillingSubscription = React.createClass({ + displayName: 'CreateBillingSubscription', + contextTypes: { + getStore: func.isRequired, + executeAction: func.isRequired + }, + getInitialState: function() { + return { + couponCode: '', + selectedPlan: this.props.location.query.plan + }; + }, + propTypes: { + JWT: string.isRequired, + user: object.isRequired, + billingInfoForm: shape({ + billforwardId: string, + accountInfo: shape({ + account_code: string, + username: string, + email: string, + first_name: string, + last_name: string, + company_name: string + }), + billingInfo: shape({ + city: string, + state: string, + zip: string, + first_name: string, + last_name: string, + address1: string, + address2: string, + country: string + }), + card: shape({ + number: string, + cvv: string, + month: oneOfType([number, string]), + year: oneOfType([number, string]), + type: string, + coupon_code: string, + coupon: number + }), + errorMessage: string, + fieldErrors: object, + STATUS: string + }), + plans: shape({ + currentPlan: shape({ + subscription_uuid: string, + package: string + }), + plansList: array + }) + }, + createBillingSubscription(){ + const { JWT, user } = this.props; + const { billforwardId, accountInfo, billingInfo, card } = this.props.billingInfoForm; + const { currentPlan } = this.props.plans; + const { package: currentPackage, subscription_uuid } = currentPlan; + const { selectedPlan } = this.state; + const { executeAction } = this.context; + if (selectedPlan) { + // User has no billing profile account OR no billing payment information + var subscriptionData = { + JWT, + user, + accountInfo, + billingInfo, + card, + billforwardId, + isNewBillingAccount: accountInfo.newBilling, + plan_code: selectedPlan + }; + + return executeAction(createBillingSubscription, subscriptionData); + } else if (currentPackage) { + // User HAS a CLOUD plan - upgrade to hub plan WITH cloud subscription + // SHOULD NEVER REACH HERE ANYMORE - PACKAGES HAVE BEEN REMOVED!! + const namespace = user.username || user.orgname; + let updatePlanInfo = { + JWT, + username: namespace, + subscription_uuid: subscription_uuid, + plan_code: selectedPlan, + package_code: currentPackage, + coupon_code: card.coupon_code + }; + return executeAction(updateSubscriptionPlanOrPackage, updatePlanInfo); + } + return executeAction(validateBillingInfo({storePrefix: 'BILLING'})); + }, + getPlan(selectedPlan) { + const plans = [ + 'index_personal_micro', + 'index_personal_small', + 'index_personal_medium', + 'index_personal_large', + 'index_personal_xlarge', + 'index_personal_xxlarge' + ]; + if (selectedPlan && includes(plans, selectedPlan)) { + const { plansList } = this.props.plans; + const planObject = find(plansList, {plan_code: selectedPlan}); + const interval = has(planObject, 'plan_interval_unit') ? mkShortPlanIntervalUnit(planObject.plan_interval_unit) : 'mo'; + const price = parseInt(planObject.price_in_cents, 10) / 100; + return {planCode: planObject.plan_code, price: price, interval}; + } else { + return {price: 0, interval: 'mo', planCode: null}; + } + }, + _onChange(field, fieldKey) { + return (e) => { + this.context.executeAction(updateBillingInfoFormField, { + field, + fieldKey, + fieldValue: e.target.value + }); + }; + }, + _updateSelectPlan(e) { + e.preventDefault(); + let planCode = e.target.value; + const { card } = this.props.billingInfoForm; + this.setState({selectedPlan: planCode}); + this.props.history.pushState(null, this.props.location.pathname, {plan: planCode}); + if (card.coupon > 0 || this.state.couponCode) { + this.context.executeAction(validateCouponCode, {coupon_code: this.state.couponCode, plan: planCode}); + } + }, + _updateCoupon(e) { + e.preventDefault(); + this.setState({couponCode: e.target.value}); + }, + validateCoupon(plan) { + return (e) => { + e.preventDefault(); + this.context.executeAction(validateCouponCode, {coupon_code: this.state.couponCode, plan: plan}); + this.setState({couponCode: ''}); + }; + }, + render: function() { + const { plansList } = this.props.plans; + const { selectedPlan } = this.state; + const { JWT, user, history } = this.props; + + if(!JWT) { + //get plan type from end of '?plan=index_personal_PLANTYPE' + //default to micro plan if no query selected + const planType = selectedPlan ? selectedPlan.substring(15) : 'micro'; + return ; + } else { + const { accountInfo, billingInfo, card, fieldErrors, STATUS, errorMessage } = this.props.billingInfoForm; + const { price, interval, planCode } = this.getPlan(selectedPlan); + + var discount; + if (card.coupon > 0) { + discount = '- $' + card.coupon; + } else { + discount = '-----'; + } + let titleClass = classnames({ + [styles.title]: true, + [styles.error]: true + }); + let title = ( +
    + * Please select a plan:  + +
    + ); + + if (selectedPlan) { + title = ( +
    + You are subscribing to the   + +   plan at ${price}/{interval} +
    + ); + } + const namespace = user.username || user.orgname; + const isOrg = !!user.orgname; + + return ( +
    + + +
    +
    + { title } +
    Once your billing information has been processed, your account will be immediately upgraded.
    +
    Thank you for subscribing!
    +
    Your billing information can be updated at any time
    +
    +
    +
    +
    + +
    +
    +
    +
    + Plan Cost: +
    +
    + ${price} +
    +
    +
    +
    + Coupon: +
    +
    + {discount} +
    +
    +
    +
    +
    + Total Charge: +
    +
    + ${price - card.coupon} +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    + ); + } + } +}); + +export default connectToStores(CreateBillingSubscription, + [BillingInfoFormStore, PlansStore], + function({ getStore }, props) { + return merge({}, + {plans: getStore(PlansStore).getState(), billingInfoForm: getStore(BillingInfoFormStore).getState()}); + }); diff --git a/app/scripts/components/account/Licenses.css b/app/scripts/components/account/Licenses.css new file mode 100644 index 0000000000..dda5c374ae --- /dev/null +++ b/app/scripts/components/account/Licenses.css @@ -0,0 +1,51 @@ +@import "dux/css/box"; +@import "dux/css/colors"; + +.license { + background: var(--white); + border: 1px solid var(--iron); + border-top-right-radius: var(--global-radius) 0; + border-top-left-radius: var(--global-radius) 0; + padding: 1rem 2rem; + margin: 1rem 0 0 0; +} + +.download { + background: var(--secondary-5); + color: var(--secondary-2); + border-bottom-right-radius: var(--global-radius) 0; + border-bottom-left-radius: var(--global-radius) 0; + text-align: center; + padding: .5rem; + line-height: 16px; + font-size: 14px; +} + +.downloadAlert { + background: var(--alert-color); + color: var(--secondary-5); + border-bottom-right-radius: var(--global-radius) 0; + border-bottom-left-radius: var(--global-radius) 0; + text-align: center; + padding: .5rem; +} + +.pageWrapper { + padding-top: 1.25rem; +} + +.legal { + font-size: 12px; + font-style: italic; + min-height: 48px; +} + +input.checkbox { + margin-bottom: 0; + margin-right: 4px; +} + +.finePrint { + font-size: 10px; + font-style: italic; +} diff --git a/app/scripts/components/account/Licenses.jsx b/app/scripts/components/account/Licenses.jsx new file mode 100644 index 0000000000..2b074cc196 --- /dev/null +++ b/app/scripts/components/account/Licenses.jsx @@ -0,0 +1,221 @@ +'use strict'; + +import React, { PropTypes, createClass, Component } from 'react'; +import _ from 'lodash'; +import moment from 'moment'; +import classnames from 'classnames'; +import { Link } from 'react-router'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import request from 'superagent'; + +// Blob is a polyfill +require('vendor/Blob'); +import { saveAs } from 'vendor/FileSaver'; +import FA from 'common/FontAwesome'; + +import { PageHeader } from 'dux'; +import AccountSettingsLicensesStore from '../../stores/AccountSettingsLicensesStore'; +import Row from '../common/Row'; +import CSEngineBox from 'common/CSEngineBox'; + +import styles from './Licenses.css'; +import DocumentTitle from 'react-document-title'; + +var debug = require('debug')('AccountSettingsLicenses'); + +class LicenseBox extends Component { + + state = { + hasAcceptedTerms: false, + hasError: false, + isDownloading: false, + // Paid licenses require terms + // https://github.com/docker/dhe-license-server/blob/master/tiers/tiers.go + requiresAcceptedTerms: this.props.tier !== 'Trial' && this.props.tier !== 'Evaluation' + } + + onCheckboxChange = () => { + this.setState({ + hasAcceptedTerms: !this.state.hasAcceptedTerms + }); + } + + onClick = (e) => { + this.setState({ + isDownloading: true, + hasError: false + }); + request.get(process.env.REGISTRY_API_BASE_URL + + '/api/licensing/v3/license/' + + this.props.orgname + + '/' + + this.props.keyId + + '/') + .set('Authorization', 'JWT ' + this.props.JWT) + .end((err, res) => { + if(err) { + this.setState({ + isDownloading: false, + hasError: true + }); + } else { + + this.setState({ + isDownloading: false, + hasError: false + }); + + const blob = new Blob([res.text], { + type: 'text/plain;charset=utf-8' + }); + saveAs(blob, `docker_subscription.lic`); + } + + }); + } + + render() { + const { expiration, alias, orgname, end, tier, maxEngines } = this.props; + const { requiresAcceptedTerms, hasAcceptedTerms } = this.state; + const exp = moment(expiration).fromNow(); + // This is a temporary work around until we can enable click to accept + // in Store + let maybeTermsAndConditions =
    ; + if (requiresAcceptedTerms) { + const EUSALink = 'https://www.docker.com/docker-software-end-user-subscription-agreement'; + const checkbox = ( + + ); + maybeTermsAndConditions = ( +
    + { checkbox } + I agree to Docker's subscription terms +
    This will not override any pre-negotiated terms.
    +
    + ); + } + + const preventDownload = requiresAcceptedTerms && !hasAcceptedTerms; + let icon; + let onClick; + if (preventDownload) { + icon = 'Please accept terms to download'; + onClick = () => {}; + } else { + icon = ; + onClick = this.onClick; + if(this.state.isDownloading) { + icon = ; + } + } + + const classes = classnames({ + 'large-3 columns': true, + 'end': end + }); + + const downloadClasses = classnames({ + [styles.download]: !this.state.hasError, + [styles.downloadAlert]: this.state.hasError + }); + return ( +
    +
    +

    {alias}

    +

    Organization: {orgname}

    +

    Engines: {maxEngines}

    +

    Expires: {exp}

    + { maybeTermsAndConditions } +
    + +
    {icon}
    +
    + ); + } +} + +class Licenses extends Component { + contextTypes: { + executeAction: React.PropTypes.func.isRequired + } + + propTypes: { + JWT: PropTypes.string.isRequired, + licenses: PropTypes.array.isRequired + } + + state = { + rpm: { + isDownloading: false, + hasError: false + }, + deb: { + isDownloading: false, + hasError: false + } + } + + + render() { + if(this.props.licenses.length <= 0) { + return ( + +
    + +
    + +
    You don't seem to have any + licenses; Get a + Trial. +
    +
    +
    +
    +
    + ); + } else { + const { rpm, deb } = this.state; + + const icon = ; + const iconDownloading = ; + + const rpmIcon = rpm.isDownloading ? iconDownloading : icon; + const debIcon = deb.isDownloading ? iconDownloading : icon; + + const rpmIntent = rpm.hasAlert ? 'alert' : null; + const debIntent = deb.hasAlert ? 'alert' : null; + + return ( + +
    + +
    +
    +
    + +
    +
    +
    + {this.props.licenses.map((license, i, arr) => { return ; } )} +
    +
    +
    +
    + ); + } + } +} + +export default connectToStores(Licenses, + [ + AccountSettingsLicensesStore + ], + function({ getStore }, props) { + return getStore(AccountSettingsLicensesStore).getState(); + }); diff --git a/app/scripts/components/account/LinkedServices.css b/app/scripts/components/account/LinkedServices.css new file mode 100644 index 0000000000..e8b35530b3 --- /dev/null +++ b/app/scripts/components/account/LinkedServices.css @@ -0,0 +1,44 @@ +@import 'dux/css/colors.css'; +@import 'dux/css/box.css'; + +.body { + margin-top: 2rem; +} + +.icon { + width: 100px; + height: 100px; +} + +.name { + font-weight: 700; + margin: 0 var(--default-margin); +} + +.access { + margin: .5rem 0; + text-align: left; +} + +.service { + composes: base from 'dux/dux/Module/Module.css'; + color: var(--secondary-4); + padding: 1.8rem; + text-align: center; + transition: border .2s; + i { + font-size: 5rem; + margin-bottom: 1rem; + } + &:hover { + background: #f4f9fc; + color: var(--secondary-4); + cursor: pointer; + } + &.link:hover { + border: 1px solid var(--primary-2); + } + &.unlink:hover { + border: 1px solid var(--primary-5); + } +} \ No newline at end of file diff --git a/app/scripts/components/account/LinkedServices.jsx b/app/scripts/components/account/LinkedServices.jsx new file mode 100644 index 0000000000..497139ed3c --- /dev/null +++ b/app/scripts/components/account/LinkedServices.jsx @@ -0,0 +1,177 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import { PageHeader } from 'dux'; + +import AutobuildStore from '../../stores/AutobuildStore'; +import BitbucketLinkStore from '../../stores/BitbucketLinkStore'; +import GithubLinkStore from '../../stores/GithubLinkStore'; +import GithubLinkAction from '../../actions/linkGithub'; +import BitbucketLinkAction from '../../actions/linkBitbucket'; +import GithubUnlinkAction from '../../actions/unlinkGithub'; +import BitbucketUnlinkAction from '../../actions/unlinkBitbucket'; +import { Button, Module } from 'dux'; +import { SplitSection } from '../common/Sections.jsx'; +import FA from '../common/FontAwesome'; +import classnames from 'classnames'; +import styles from './LinkedServices.css'; +import merge from 'lodash/object/merge'; +import DocumentTitle from 'react-document-title'; + +const { string, array, object, func } = PropTypes; + +class LinkedServices extends Component { + + static contextTypes = { + executeAction: func.isRequired + } + + static propTypes = { + JWT: string.isRequired, + githubAccount: object, + githubRepos: array, + bitbucketAccount: object, + bitbucketRepos: array, + gitlabAccount: object, + gitlabRepos: array, + bitbucketError: string, + bbAuthUrl: string, + githubError: string + } + + authServiceClick = (e) => { + e.preventDefault(); + } + + render() { + var github = {service: 'Github', account: this.props.githubAccount}; + var bitbucket = {service: 'Bitbucket', account: this.props.bitbucketAccount}; + var gitlab = {service: 'Gitlab', account: this.props.gitlabAccount}; + let maybeError; + const errorMsg = this.props.githubError || this.props.bitbucketError; + if (errorMsg) { + maybeError = {errorMsg}; + } + return ( + +
    + +
    +
    + + These account links are currently used for Automated Builds, + so that we can access your project lists and help you configure your Automated Builds. +   Please note: A github/bitbucket account can be connected to only one docker hub account at a time.

    }> +
    + + +
    +
    + {maybeError} +
    +
    +
    +
    +
    + ); + } +} + +class LinkedAccount extends Component { + static propTypes = { + data: object, + bbAuthUrl: string, + JWT: string, + history: object.isRequired + } + + static contextTypes = { + executeAction: PropTypes.func.isRequired + } + + linkAction = (provider, e) => { + e.preventDefault(); + if (provider.service.toLowerCase() === 'github') { + this.context.executeAction(GithubLinkAction, this.props.JWT); + this.props.history.pushState(null, '/account/authorized-services/github-permissions/'); + } else if (provider.service.toLowerCase() === 'bitbucket') { + const bbWin = window.open(); + bbWin.location = this.props.bbAuthUrl; + } + } + + unlinkAction = (provider, e) => { + e.preventDefault(); + if (provider.service.toLowerCase() === 'github') { + this.context.executeAction(GithubUnlinkAction, this.props.JWT); + } else if (provider.service.toLowerCase() === 'bitbucket') { + this.context.executeAction(BitbucketUnlinkAction, this.props.JWT); + } + } + + render() { + var service = this.props.data.service; + var icon; + if (service.toLowerCase() === 'github') { + icon = 'fa-github'; + } else if (service.toLowerCase() === 'bitbucket') { + icon = 'fa-bitbucket'; + } + var account = this.props.data.account; + let linkClass = classnames({ + [styles.service]: true, + [styles.unlink]: account, + [styles.link]: !account + }); + if (account) { + return ( +
    +
    +
    +
    + +
    +
    + {account.login}:
    + read/write access +
    +

    + Unlink {service} +
    +
    + ); + } else { + return ( +
    +
    +
    + Link {service} +
    +
    + ); + } + } +} + +export default connectToStores(LinkedServices, + [ + AutobuildStore, + BitbucketLinkStore, + GithubLinkStore + ], + function({ getStore }, props) { + return merge( + {}, + getStore(AutobuildStore).getState(), + { bitbucketError: getStore(BitbucketLinkStore).getState().error, + bbAuthUrl: getStore(BitbucketLinkStore).getState().authURL, + githubError: getStore(GithubLinkStore).getState().error } + ); + }); diff --git a/app/scripts/components/account/NotificationsSettings.css b/app/scripts/components/account/NotificationsSettings.css new file mode 100644 index 0000000000..1a3cebfd7a --- /dev/null +++ b/app/scripts/components/account/NotificationsSettings.css @@ -0,0 +1,36 @@ +@import 'dux/css/colors.css'; + +.body { + margin-top: 2rem; +} + +.button { + margin-bottom: -1rem; +} + +.success { + border: 1px solid var(--primary-2); +} + +.formError { + border: 1px solid var(--primary-5); +} + +.notification { + margin-bottom: 1rem; + word-wrap: break-word; +} +.notification:hover { + cursor: pointer; + cursor: hand; +} +.unverifiedNotif { + margin-bottom: 1rem; + word-wrap: break-word; + color: var(--secondary-4); + cursor: default; +} + +.checkbox { + padding-top: .5rem; +} diff --git a/app/scripts/components/account/NotificationsSettings.jsx b/app/scripts/components/account/NotificationsSettings.jsx new file mode 100644 index 0000000000..63364634c7 --- /dev/null +++ b/app/scripts/components/account/NotificationsSettings.jsx @@ -0,0 +1,240 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import _ from 'lodash'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import classnames from 'classnames'; + +import { Button, PageHeader } from 'dux'; +import OutboundCommunicationStore from '../../stores/OutboundCommunicationStore'; +import EmailsStore from '../../stores/EmailsStore'; +import resetNotifications from '../../actions/resetNotifications.js'; +import saveOutbound from '../../actions/saveOutbound'; +import UpdateOutbound from '../../actions/updateOutbound.js'; +import EmailNotifForm from './notificationSettings/EmailNotifForm.jsx'; + +import { SplitSection } from './../common/Sections.jsx'; +import styles from './NotificationsSettings.css'; +import DocumentTitle from 'react-document-title'; + +var debug = require('debug')('NotificationSettings'); + +var Notifications = React.createClass({ + displayName: 'Notifications', + contextTypes: { + executeAction: PropTypes.func.isRequired + }, + propTypes: { + user: PropTypes.shape({ + username: PropTypes.string + }), + JWT: PropTypes.string + }, + getDefaultProps: function() { + return { + user: {}, + JWT: '' + }; + }, + onOutboundClick: function(e) { + debug('onOutboundClick'); + var email = e.currentTarget.getAttribute('data-email'); + var list = e.currentTarget.getAttribute('data-list'); + var unsubscribedIndex; + var subscribedIndex; + var newSubscribed; + var newUnsubscribed; + var newList; + if (list === 'weekly') { + /*eslint-disable camelcase */ + unsubscribedIndex = this.props.weeklyDigest.unsubscribed_emails.indexOf(email); + subscribedIndex = this.props.weeklyDigest.subscribed_emails.indexOf(email); + newUnsubscribed = _.clone(this.props.weeklyDigest.unsubscribed_emails); + newSubscribed = _.clone(this.props.weeklyDigest.subscribed_emails); + if (unsubscribedIndex > -1 && subscribedIndex === -1) { + newUnsubscribed.splice(unsubscribedIndex, 1); + newSubscribed.push(email); + } else { + newSubscribed.splice(subscribedIndex, 1); + newUnsubscribed.push(email); + } + newList = { + subscribed_emails: newSubscribed, + unsubscribed_emails: newUnsubscribed + }; + this.context.executeAction(UpdateOutbound, {list: 'weekly', data: newList}); + /*eslint-enable camelcase */ + } else if (list === 'beta') { + unsubscribedIndex = this.props.betaGroup.unsubscribed_emails.indexOf(email); + subscribedIndex = this.props.betaGroup.subscribed_emails.indexOf(email); + newUnsubscribed = _.clone(this.props.betaGroup.unsubscribed_emails); + newSubscribed = _.clone(this.props.betaGroup.subscribed_emails); + if (unsubscribedIndex > -1 && subscribedIndex === -1) { + newUnsubscribed.splice(unsubscribedIndex, 1); + newSubscribed.push(email); + } else { + newSubscribed.splice(subscribedIndex, 1); + newUnsubscribed.push(email); + } + newList = { + unsubscribed_emails: newUnsubscribed, + subscribed_emails: newSubscribed + }; + this.context.executeAction(UpdateOutbound, {list: 'beta', data: newList}); + } + }, + onOutboundSubmit: function(e) { + e.preventDefault(); + let _this = this; + let weeklyUns = []; + let betaUns = []; + let weekly = _.clone(this.props.weeklyDigest); + let beta = _.clone(this.props.betaGroup); + /* eslint-disable camelcase */ + weekly.unsubscribed_emails.forEach(function(email) { + if (_this.isVerified(email)) { + weeklyUns.push(email); + } + }); + weekly.unsubscribed_emails = weeklyUns; + beta.unsubscribed_emails.forEach(function(email) { + if (_this.isVerified(email)) { + betaUns.push(email); + } + }); + beta.unsubscribed_emails = betaUns; + /* eslint-enable camelcase */ + var outboundData = { + JWT: this.props.JWT, + username: this.props.user.username, + weeklyDigest: weekly, + betaGroup: beta + }; + this.context.executeAction(saveOutbound, outboundData); + }, + onOutboundCancel: function(e) { + debug('resetting outbound communication'); + this.context.executeAction(resetNotifications, ['outbound']); + }, + sortEmails: function(emailArray) { + var _this = this; + return (_.sortBy(emailArray, + function(email) { + return !(_this.isVerified(email)); + }) + ); + }, + isVerified: function(email) { + return _.pluck(_.filter(this.props.emails, {email: email}), 'verified')[0]; + }, + render: function() { + var digestEmails = this.sortEmails(this.props.digestEmails); + var betaEmails = this.sortEmails(this.props.betaEmails); + return ( + +
    + +
    +
    + + + The following settings will control how Docker communicates news, new products, new features, and more.

    }> +
    + +
    Docker Weekly
    +

    Docker weekly is a newsletter which contains the latest news, updates, and information on releases and other exciting stuff. Sign up!

    +
    + +
    Docker Beta Group
    +

    Become part of our Docker Beta Group and get early access to some Docker products and/or features.

    +

    Please select which email address (or addresses) you'd like to subscribe:

    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    + ); + } +}); + +var OutboundList = React.createClass({ + contextTypes: { + executeAction: PropTypes.func.isRequired + }, + propTypes: { + emailGroups: PropTypes.shape({ + subscribed_emails: PropTypes.array, + unsubscribed_emails: PropTypes.array + }), + emailList: PropTypes.array, + emails: PropTypes.array, + onOutboundClick: PropTypes.func.isRequired, + type: PropTypes.string.isRequired + }, + render: function() { + let _this = this; + return ( +
    +
    +
    + {this.props.children} +
    +
    + {this.props.emailList.map(function(email) { + if (_this.props.isVerified(email)) { + return ( +
    +
    + +
    +
    + {email} +
    +
    + ); + } else { + return ( +
    +
    + +
    +
    + {email} - unverified +
    +
    + ); + } + })} +
    + ); + } +}); + +export default connectToStores(Notifications, + [OutboundCommunicationStore, EmailsStore], function({ getStore }, props) { + return _.merge({}, getStore(OutboundCommunicationStore).getState(), getStore(EmailsStore).getEmails()); + }); diff --git a/app/scripts/components/account/OrganizationSettings.jsx b/app/scripts/components/account/OrganizationSettings.jsx new file mode 100644 index 0000000000..48c000f7b6 --- /dev/null +++ b/app/scripts/components/account/OrganizationSettings.jsx @@ -0,0 +1,62 @@ +'use strict'; + +import React, { cloneElement } from 'react'; +import { Link } from 'react-router'; +import FluxibleMixin from 'fluxible-addons-react/FluxibleMixin'; +import OrganizationStore from '../../stores/OrganizationStore'; +import { PageHeader } from 'dux'; +import FA from '../common/FontAwesome'; +import DocumentTitle from 'react-document-title'; +const debug = require('debug')('OrganizationSettings'); + +var OrganizationSettings = React.createClass({ + displayName: 'OrganizationSettings', + mixins: [FluxibleMixin], + getInitialState: function() { + return { + orgs: this.context.getStore(OrganizationStore).getOrgs() + }; + }, + statics: { + storeListeners: { + onOrgStoreChange: [OrganizationStore] + } + }, + onOrgStoreChange: function() { + this.setState({ + orgs: this.context.getStore(OrganizationStore).getOrgs() + }); + }, + componentDidMount: function() { + this.setState({ + orgs: this.context.getStore(OrganizationStore).getOrgs() + }); + }, + render: function() { + + var maybeCreateOrgBtn; + var pathname = this.props.location.pathname; + if (pathname.indexOf('/add/') === -1) { + maybeCreateOrgBtn = Create Organization ; + } + + return ( + +
    + + {maybeCreateOrgBtn} + +
    + {cloneElement(this.props.children, { + user: this.props.user, + JWT: this.props.JWT, + orgs: this.state.orgs + })} +
    +
    +
    + ); + } +}); + +module.exports = OrganizationSettings; diff --git a/app/scripts/components/account/OrganizationSummary.css b/app/scripts/components/account/OrganizationSummary.css new file mode 100644 index 0000000000..65001dbfb0 --- /dev/null +++ b/app/scripts/components/account/OrganizationSummary.css @@ -0,0 +1,23 @@ +@import "dux/css/box.css"; + +.orgGridItem { + composes: module from "dux/dux/Module/Module.css"; + text-align: center; + &:hover { + cursor: pointer; + } +} +.orgSummary { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.orgAvatar { + width: 3rem; + height: 3rem; + border-radius: 100%; + padding: 5px; +} +.orgListItem { + list-style: none; +} diff --git a/app/scripts/components/account/OrganizationSummary.jsx b/app/scripts/components/account/OrganizationSummary.jsx new file mode 100644 index 0000000000..554dbbca17 --- /dev/null +++ b/app/scripts/components/account/OrganizationSummary.jsx @@ -0,0 +1,62 @@ +'use strict'; + +import styles from './OrganizationSummary.css'; +import classnames from 'classnames'; +import React, { Component, PropTypes } from 'react'; +const { bool, string } = PropTypes; +import { Link } from 'react-router'; +import selectOrganizationAction from '../../actions/selectOrganization'; +import { mkAvatarForNamespace } from 'utils/avatar'; + +const debug = require('debug')('OrganizationSummary'); + + +class OrgItem extends Component { + + static propTypes = { + orgname: string.isRequired, + end: bool + } + + render() { + const { orgname, end } = this.props; + const classes = classnames( + styles.orgListItem, + 'medium-3', + 'columns', + { + 'end': end + } + ); + return ( +
  • + +
    + +
    {orgname}
    +
    + +
  • + ); + } +} + +export default class OrganizationSummary extends Component { + static contextTypes = { + executeAction: React.PropTypes.func.isRequired + } + static propTypes = { + user: React.PropTypes.object.isRequired, + JWT: React.PropTypes.string.isRequired, + orgs: React.PropTypes.array.isRequired + } + render() { + const orgItems = this.props.orgs.map((org, i, array) => { return ; }); + + return ( +
      + {orgItems} +
    + ); + } +} diff --git a/app/scripts/components/account/UpdateBillingInfo.css b/app/scripts/components/account/UpdateBillingInfo.css new file mode 100644 index 0000000000..b2ee158666 --- /dev/null +++ b/app/scripts/components/account/UpdateBillingInfo.css @@ -0,0 +1,11 @@ +@import "dux/css/box"; +@import "dux/css/colors"; + +.body { + margin-top: var(--default-margin); +} + +.subtitle { + color: var(--primary-2); + margin-bottom: .5rem; +} diff --git a/app/scripts/components/account/UpdateBillingInfo.jsx b/app/scripts/components/account/UpdateBillingInfo.jsx new file mode 100644 index 0000000000..1b7baf22e2 --- /dev/null +++ b/app/scripts/components/account/UpdateBillingInfo.jsx @@ -0,0 +1,131 @@ +'use strict'; +import React, { PropTypes } from 'react'; +const { string, number, func, shape, object, array, oneOfType } = PropTypes; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import updateBillingInformation from '../../actions/updateBillingInformation.js'; +import updateStripeBilling from '../../actions/updateStripeBilling.js'; +import BillingInfoFormStore from '../../stores/BillingInfoFormStore.js'; +import BillingInfoForm from './billingplans/BillingInfoForm.jsx'; +import { Link } from 'react-router'; +import classnames from 'classnames'; +import styles from './UpdateBillingInfo.css'; +import { PageHeader, Module } from 'dux'; +var debug = require('debug')('UpdateBillingInfo:'); + +var updateBillingInfoPage = React.createClass({ + displayName: 'UpdateBillingInfo', + contextTypes: { + getStore: func.isRequired, + executeAction: func.isRequired + }, + propTypes: { + JWT: string.isRequired, + user: object.isRequired, + billforwardId: string, + accountInfo: shape({ + account_code: string, + username: string, + email: string, + first_name: string, + last_name: string, + company_name: string + }), + billingInfo: shape({ + city: string, + state: string, + zip: string, + first_name: string, + last_name: string, + address1: string, + address2: string, + country: string + }), + card: shape({ + number: string, + cvv: string, + month: oneOfType([number, string]), + year: oneOfType([number, string]), + type: string + }), + errorMessage: string, + fieldErrors: object, + STATUS: string + }, + updateBillingInfoSubmit(){ + const { + JWT, + user, + accountInfo, + billingInfo, + card, + billforwardId, + location + } = this.props; + if (billforwardId) { + // If we have a billforwardID then we will update via stripe + this.context.executeAction(updateStripeBilling, { + JWT, + user, + accountInfo, + billingInfo, + billforwardId, + card + }); + } else { + // If we do not have a billforward ID then we go through the original recurly update flow + this.context.executeAction(updateBillingInformation, { + JWT, + user, + accountInfo, + billingInfo, + card + }); + } + }, + render: function() { + const { + user, + history, + accountInfo, + billingInfo, + card, + fieldErrors, + STATUS, + errorMessage + } = this.props; + const namespace = user.username || user.orgname; + let isOrg = !!user.orgname; + return ( +
    + +
    +
    +
    Billing information is required for changing or upgrading subscriptions
    +
    You may update your billing information at any time
    +
    +
    +
    +
    + +
    +
    +
    + ); + } +}); + +export default connectToStores(updateBillingInfoPage, + [BillingInfoFormStore], + function({ getStore }, props) { + return getStore(BillingInfoFormStore).getState(); + }); diff --git a/app/scripts/components/account/billingplans/BillingInfo.css b/app/scripts/components/account/billingplans/BillingInfo.css new file mode 100644 index 0000000000..246b626169 --- /dev/null +++ b/app/scripts/components/account/billingplans/BillingInfo.css @@ -0,0 +1,7 @@ +@import 'dux/css/colors.css'; + +.infoContent { + border-left: 1px solid var(--iron); + padding: 0 3rem; + margin: .5rem 0; +} diff --git a/app/scripts/components/account/billingplans/BillingInfo.jsx b/app/scripts/components/account/billingplans/BillingInfo.jsx new file mode 100644 index 0000000000..5b5f210f25 --- /dev/null +++ b/app/scripts/components/account/billingplans/BillingInfo.jsx @@ -0,0 +1,132 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import { Link } from 'react-router'; + +import Button from '@dux/element-button'; +import { SplitSection } from '../../common/Sections.jsx'; +import FA from '../../common/FontAwesome.jsx'; +import styles from './BillingInfo.css'; + +var BillingInfo = React.createClass({ + propTypes: { + currentPlan: PropTypes.shape({ + id: PropTypes.string, + plan: PropTypes.string + }), + accountInfo: PropTypes.shape({ + account_code: PropTypes.string, + username: PropTypes.string, + email: PropTypes.string, + first_name: PropTypes.string, + last_name: PropTypes.string, + company_name: PropTypes.string + }), + billingInfo: PropTypes.shape({ + city: PropTypes.string, + state: PropTypes.string, + zip: PropTypes.string, + first_name: PropTypes.string, + last_name: PropTypes.string, + address1: PropTypes.string, + address2: PropTypes.string, + country: PropTypes.string + }), + invoices: PropTypes.array, + isOrg: PropTypes.bool.isRequired, + username: PropTypes.string.isRequired + }, + _updateClick: function(e) { + e.preventDefault(); + if (this.props.isOrg) { + this.props.history.pushState(null, `/u/${this.props.username}/dashboard/billing/update-info/`); + } else { + this.props.history.pushState(null, '/account/billing-plans/update/'); + } + }, + render: function() { + const { + accountInfo, + billingInfo, + currentPlan + } = this.props; + if (accountInfo.newBilling) { + return ( +
    + ); + } + let subtitle = ( +
    +
    +

    * fields required to complete a billing transaction

    +
    +
    +
    + +
    +
    +
    + ); + let cardInfo; + if (billingInfo.card_type && billingInfo.last_four) { + cardInfo = ( +
    +
    Card Info:
    +
    + {billingInfo.first_name} {billingInfo.last_name}
    + {billingInfo.card_type} card ending with x{billingInfo.last_four}
    + Expiration: {billingInfo.month}/{billingInfo.year} +
    +
    + ); + } + let addressInfo; + if (billingInfo.address1 && billingInfo.country) { + addressInfo = ( +
    +
    Billing Address:
    +
    + {billingInfo.address1}
    + {billingInfo.address2}
    + {billingInfo.city} {billingInfo.state} {billingInfo.zip}
    + {billingInfo.country} +
    +
    + ); + } + return ( + +
    +
    +
    Name:
    +
    +
    +
    {accountInfo.first_name} {accountInfo.last_name}
    +
    +
    +
    +
    +
    Email:
    +
    +
    +
    {accountInfo.email}
    +
    +
    +
    +
    +
    Company:
    +
    +
    +
    {accountInfo.company_name}
    +
    +
    + { cardInfo } + { addressInfo } +
    + ); + } +}); + +module.exports = BillingInfo; diff --git a/app/scripts/components/account/billingplans/BillingInfoForm.css b/app/scripts/components/account/billingplans/BillingInfoForm.css new file mode 100644 index 0000000000..07b96a3836 --- /dev/null +++ b/app/scripts/components/account/billingplans/BillingInfoForm.css @@ -0,0 +1,36 @@ +@import 'dux/css/colors.css'; +@import 'dux/css/box.css'; + +.marginBottom { + margin-bottom: var(--default-margin); +} + +.form { + padding: 0 var(--default-margin); +} + +.billingDropdown { + width: 100%; + margin-bottom: 1.5rem; +} + +.error { + border: 1px solid var(--primary-5); +} + +.billingFormSection { + margin-bottom: 1.5rem; +} + +.dateText { + text-align: center; + height: 2rem; + display: flex; + align-items: center; + margin-bottom: 2rem; +} + +.globalError { + color: var(--primary-5); + padding-bottom: 1rem; +} \ No newline at end of file diff --git a/app/scripts/components/account/billingplans/BillingInfoForm.jsx b/app/scripts/components/account/billingplans/BillingInfoForm.jsx new file mode 100644 index 0000000000..80fb22eb19 --- /dev/null +++ b/app/scripts/components/account/billingplans/BillingInfoForm.jsx @@ -0,0 +1,380 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +const { string, number, object, bool, func, shape, oneOfType } = PropTypes; +import { Link } from 'react-router'; +import includes from 'lodash/collection/includes'; +import map from 'lodash/collection/map'; +import classnames from 'classnames'; + +import DUXInput from '../../common/DUXInput.jsx'; +import FA from '../../common/FontAwesome.jsx'; +import acceptedCards from '../../common/data/acceptedCards.js'; +import updateBillingInfoFormField from '../../../actions/updateBillingInfoFormField.js'; +import validateBillingInfo from '../../../actions/common/validateBillingInfo.js'; +import { STATUS } from 'stores/billingformstore/Constants.js'; +import { Button } from 'dux'; +import Card, { Block } from '@dux/element-card'; + +var countries = require('common/data/countries.js'); +var states = require('common/data/states.js'); +var months = require('common/data/months.js'); +var years = require('common/data/years.js'); + +import styles from './BillingInfoForm.css'; +var debug = require('debug')('BillingInfoForm:'); + +var _mkOptions = function(list_item){ + return ( + + ); +}; + +var _mkCountryOptions = function(country) { + return ( + + ); +}; + +var BillingInfoForm = React.createClass({ + contextTypes: { + getStore: func.isRequired, + executeAction: func.isRequired + }, + propTypes: { + isOrg: bool.isRequired, + username: string.isRequired, + accountInfo: shape({ + account_code: string, + username: string, + email: string, + first_name: string, + last_name: string, + company_name: string + }), + billingInfo: shape({ + city: string, + state: string, + zip: string, + first_name: string, + last_name: string, + address1: string, + address2: string, + country: string + }), + card: shape({ + number: string, + cvv: string, + month: oneOfType([number, string]), + year: oneOfType([number, string]), + type: string + }), + fieldErrors: object.isRequired, + errorMessage: string, + STATUS: string.isRequired, + history: object.isRequired + }, + _onChange(field, fieldKey) { + return (e) => { + this.context.executeAction(updateBillingInfoFormField, { + field, + fieldKey, + fieldValue: e.target.value + }); + }; + }, + _onSelectChange(field, fieldKey) { + return (e) => { + var fieldValue = e.target.options[e.target.selectedIndex].value; + if (fieldKey === 'month' || fieldKey === 'year') { + fieldValue = parseInt(fieldValue, 10); + } + this.context.executeAction(updateBillingInfoFormField, { + field, + fieldKey, + fieldValue: fieldValue + }); + }; + }, + _onBackClick(e) { + e.preventDefault(); + if (this.props.isOrg) { + this.props.history.pushState(null, `/u/${this.props.username}/dashboard/billing/`); + } else { + this.props.history.pushState(null, '/account/billing-plans/'); + } + }, + _mkIconFromCardType(cardType) { + const icon = acceptedCards[cardType]; + if (icon) { + return (); + } + }, + formSubmit(e){ + e.preventDefault(); + const billing = this.props.billingInfo; + let validate = window.recurly.validate; + const fieldErrors = { + number: !validate.cardNumber(this.props.card.number), + expiry: !validate.expiry(this.props.card.month, this.props.card.year), + cvv: !validate.cvv(this.props.card.cvv), + first_name: !billing.first_name, + last_name: !billing.last_name, + city: !billing.city, + address1: !billing.address1, + country: !billing.country + }; + const accountErr = { hasError: !this.props.accountInfo.email }; + if (includes(fieldErrors, true) || accountErr.hasError ) { + const hasError = { fieldErrors, accountErr }; + this.context.executeAction(validateBillingInfo({storePrefix: 'BILLING'}), hasError); + } else { + this.props.submitAction(); + } + }, + render: function() { + const { card } = this.props; + const cardIcon = card.number ? this._mkIconFromCardType(card.type) : null; + var expiryClass = classnames({ + [styles.error]: this.props.fieldErrors.expiry, + [styles.billingDropdown]: true + }); + var countryClass = classnames({ + [styles.billingDropdown]: true, + [styles.error]: this.props.fieldErrors.country + }); + let submit = 'Submit'; + if (this.props.STATUS === STATUS.ATTEMPTING) { + submit = ( +
    + Submitting +
    + ); + } + var intent; + if (this.props.STATUS === STATUS.SUCCESS) { + intent = 'success'; + submit = ( +
    + Redirecting +
    + ); + } else if (this.props.STATUS === STATUS.FORM_ERROR) { + intent = 'alert'; + } + const submitButton = ( + + ); + return ( + + +
    +
    +
    Contact Info:
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    Billing Info:
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    + {cardIcon} +
    +
    +
    +
    + Expires +
    +
    + +
    +
    + / +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    + { this.props.errorMessage } +
    +
    +
    + { submitButton } +
    +
    + +
    +
    +
    +
    +
    + ); + } +}); + +var PostalComponent = React.createClass({ + propTypes: { + billingInfo: shape({ + city: string, + state: string, + zip: string, + first_name: string, + last_name: string, + address1: string, + address2: string, + country: string + }), + onChange: func.isRequired, + onSelectChange: func.isRequired + }, + render: function() { + var countryState; + var country = this.props.billingInfo.country; + var stateClass = this.props.fieldErrors.state ? 'billing-dropdown error' : 'billing-dropdown'; + //IF US TERRITORY - ADD STATES SELECT + if (includes(['US', 'UM'], country)) { + countryState = ( +
    + +
    + ); + } else { + countryState = ( +
    + +
    + ); + } + return ( +
    + {countryState} +
    + +
    +
    + ); + } +}); + +export default BillingInfoForm; diff --git a/app/scripts/components/account/billingplans/EnterpriseSubscriptions.css b/app/scripts/components/account/billingplans/EnterpriseSubscriptions.css new file mode 100644 index 0000000000..0dffb51685 --- /dev/null +++ b/app/scripts/components/account/billingplans/EnterpriseSubscriptions.css @@ -0,0 +1,98 @@ +@import 'dux/css/colors.css'; +@import 'dux/css/box.css'; + +.cancel { + float: right; + color: var(--primary-5); +} +.cancel:hover { + color: color(var(--primary-5) blackness(15%)); +} + +.title { + color: var(--primary-1); + i { + color: var(--secondary-5); + } +} +.title:hover { + cursor: pointer; + color: color(var(--primary-1) blackness(55%)); + i { + color: color(var(--secondary-5) blackness(55%)); + } +} + +.cloudStarter { + margin-left: var(--default-margin); +} + +.centerText { + text-align: center; +} + +.check { + color: var(--primary-2); +} + +.download { + margin-right: var(--default-margin);; +} + +.curlHelp { + font-size: .875rem; + font-weight: 500; + margin-bottom: 1rem; +} + +.curlWrap { + width: 100%; +} + +.curlCommand { + resize: none; + overflow: hidden; + height: inherit; +} + +.clipboard { + height: 3rem; + display:flex; + flex-direction: row; + align-items: center; +} + +.downloadFlexItem { + display: flex; + flex-flow: row nowrap; + flex-grow: 4; + flex-basis: 0; + padding: 0 1rem; + word-break: break-word; + max-width: 100%; +} + +.flexbox { + display: flex; + flex-flow: column; +} + +.flexRow { + min-height: 9rem; + padding: 1rem; + display: flex; + align-items: center; + background-color: var(--white); + border: 1px solid var(--secondary-5); + border-radius: 3px; + margin-bottom: .5rem; +} + +.flexItem { + display: flex; + flex-flow: row nowrap; + flex-grow: 1; + flex-basis: 0; + padding: 0.7em 1rem 0.7em 1rem; + word-break: break-word; +} diff --git a/app/scripts/components/account/billingplans/EnterpriseSubscriptions.jsx b/app/scripts/components/account/billingplans/EnterpriseSubscriptions.jsx new file mode 100644 index 0000000000..8e9710c501 --- /dev/null +++ b/app/scripts/components/account/billingplans/EnterpriseSubscriptions.jsx @@ -0,0 +1,160 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +const { string, shape, object, func } = PropTypes; +import { Link } from 'react-router'; +import { FullSection } from '../../common/Sections.jsx'; +import FA from '../../common/FontAwesome'; +import styles from './EnterpriseSubscriptions.css'; +import CopyCodeBox from 'common/CopyCodeBox'; +import classnames from 'classnames'; +import {DEB, RPM} from 'common/data/csEngineInstructions'; +const debug = require('debug')('EnterpriseSubscriptions'); + +var EnterpriseSubscriptions = React.createClass({ + propTypes: { + currentPlan: shape({ + id: string, + plan: string + }), + stopSubscription: func.isRequired, + unsubscribing: string, + user: object, + history: object.isRequired + }, + getInitialState() { + return { + confirmAction: '' + }; + }, + selectAction(plan) { + return (e) => { + e.preventDefault(); + this.setState({confirmAction: plan}); + }; + }, + cancelSelectPlan: function(e) { + e.preventDefault(); + this.setState({confirmAction: ''}); + }, + moreInfo: function(e) { + e.preventDefault(); + this.props.history.pushState(null, '/enterprise/'); + }, + purchaseCloud: function(e) { + e.preventDefault(); + this.props.history.pushState(null, '/enterprise/cloud-starter/'); + }, + render: function() { + let owned; + let price; + let subInfo; + let cloudActionButton; + let curlBody; + + // Hide Cloud Starter for anyone who has not already purchased it + if (this.props.currentPlan.package !== 'cloud_starter') { + return null; + } + + owned = ( +
    Currently Subscribed 
    + ); + if (this.state.confirmAction === 'cloud') { + cloudActionButton = ( +
    + Confirm or  + Cancel +
    + ); + } else { + cloudActionButton = ( + + ); + } + if (this.props.unsubscribing === 'package' || this.props.unsubscribing === 'subscription') { + cloudActionButton = (
    Removing Subscription
    ); + } + + let cloudStarter = ( +
    +
    +

     Cloud Starter

    + {owned} +
    +
    + ); + + if (this.state.confirmAction === 'download') { + cloudActionButton = null; + cloudStarter = null; + curlBody = ( +
    +
    +
    +
    + * Copy and run either the RPM or the DEB specific instructions in your terminal. + +
    +
    +
    +
    + RPM + +
    +
    +
    +
    + DEB + + + + For more details about this installation,  + + view our documentation + + +
    +
    +
    +
    + ); + } else { + price = ( +
    $150/mo
    + ); + subInfo = ( +
    +
    + 20 Private Repositories
    + 10 Docker Engines
    + Email Support +
    +
    + ); + } + return ( + +
    +
    + {cloudStarter} + {price} + {subInfo} + {cloudActionButton} + {curlBody} +
    +
    +
    + ); + } +}); + +module.exports = EnterpriseSubscriptions; diff --git a/app/scripts/components/account/billingplans/InvoiceTables.jsx b/app/scripts/components/account/billingplans/InvoiceTables.jsx new file mode 100644 index 0000000000..fab6841bda --- /dev/null +++ b/app/scripts/components/account/billingplans/InvoiceTables.jsx @@ -0,0 +1,73 @@ +'use strict'; +import React, { PropTypes } from 'react'; +import { Link } from 'react-router'; +import _ from 'lodash'; +import moment from 'moment'; +import { FullSection } from '../../common/Sections.jsx'; +import { FlexTable, FlexRow, FlexHeader, FlexItem } from '../../common/FlexTable.jsx'; +import downloadInvoice from '../../../actions/downloadInvoice.js'; +const debug = require('debug')('COMPONENT:INVOICE_TABLE'); + +let mkInvoiceTable = function(invoice) { + var subtotal = '$' + (parseInt(invoice.subtotal_in_cents, 10) / 100); + var total = '$' + (parseInt(invoice.total_in_cents, 10) / 100); + var date = moment.utc(invoice.created_at).format('MMM Do YYYY'); + debug('CREATED_AT: ', invoice.created_at); + debug('CREATED_AT_MOMENT: ', date); + return ( + + {date} + {invoice.invoice_number} + {invoice.state} + {total} + + Download Invoice + + + ); +}; + +var InvoiceTables = React.createClass({ + contextTypes: { + executeAction: React.PropTypes.func.isRequired + }, + propTypes: { + invoices: PropTypes.array, + username: PropTypes.string, + JWT: PropTypes.string + }, + downloadInvoice(id) { + return (e) => { + e.preventDefault(); + this.context.executeAction(downloadInvoice, {JWT: this.props.JWT, username: this.props.username, invoiceId: id}); + }; + }, + render: function() { + if (!this.props.JWT || _.isEmpty(this.props.invoices)) { + return ( +
    + ); + } else { + return ( + +
    + + + Date + Invoice # + State + Total + Download + + {this.props.invoices.map(mkInvoiceTable, this)} + +
    +
    + ); + } + } +}); + +module.exports = InvoiceTables; diff --git a/app/scripts/components/account/billingplans/Plans.css b/app/scripts/components/account/billingplans/Plans.css new file mode 100644 index 0000000000..700f88f13b --- /dev/null +++ b/app/scripts/components/account/billingplans/Plans.css @@ -0,0 +1,58 @@ +@import 'dux/css/colors.css'; +@import 'dux/css/box.css'; + +.cancel { + color: var(--primary-5); +} +.cancel:hover { + color: color(var(--primary-5) blackness(15%)); +} + +.shield { + margin-right: 0.5rem; +} + +.nautilusUpsellRow { + display: flex; + width: 100%; + padding: 0.3rem; + background: #f1f6fb; +} + +.nautilusUpsell { + width: 100%; + margin: 0.75rem; + background: var(--white); + border: 1px solid var(--secondary-5); + border-radius: var(--global-radius); + + label { + display: flex; + align-items: center; + padding: 0.5rem; + font-size: 1rem; + font-weight: 400; + line-height: 1rem; + color: var(--secondary-2); + } +} + +.nautilusEnabled { + border-color: #22b8eb; +} + +.enableNautilus { + display: flex; + align-items: center; + margin: 0 0.5rem; + + input { + margin: 0; + } +} + +.monitoredWithNautilus { + display: flex; + align-items: center; + margin: 0.5rem; +} diff --git a/app/scripts/components/account/billingplans/Plans.jsx b/app/scripts/components/account/billingplans/Plans.jsx new file mode 100644 index 0000000000..72d6ef7eb4 --- /dev/null +++ b/app/scripts/components/account/billingplans/Plans.jsx @@ -0,0 +1,355 @@ +'use strict'; +/*eslint-disable camelcase*/ +import React, { PropTypes } from 'react'; +import { Link } from 'react-router'; +import PlansStore from '../../../stores/PlansStore'; +import updateSubscriptionPlanOrPackage from '../../../actions/updateSubscriptionPlanOrPackage.js'; +import defaultPlans from 'common/data/plans.js'; +/** +* Used for the public pricing page (/billing-plans/) +* We should either find a workaround for defaultPlans or remove this route altogether +*/ +import FA from '../../common/FontAwesome.jsx'; +import _ from 'lodash'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import { FullSection } from '../../common/Sections.jsx'; +import { FlexTable, FlexRow, FlexHeader, FlexItem } from '../../common/FlexTable.jsx'; +import styles from './Plans.css'; +const CLOUD_METERED = 'cloud_metered'; +const debug = require('debug')('Plans:'); +const NAUTILUS = 'nautilus'; +import Tooltip from 'rc-tooltip'; +import classnames from 'classnames'; + + +function mkShortPlanIntervalUnit(unit) { + if (unit === 'months'){ + return 'mo'; + } else if (unit === 'years') { + return 'yr'; + } else { + return 'mo'; + } +} + +let mkPricingElement = function(plan) { + var action; + var shortInterval = mkShortPlanIntervalUnit(plan.plan_interval_unit); + var price = parseInt(plan.price_in_cents, 10) / 100; + var pricePerInterval = {price: price, interval: shortInterval}; + + var currentPlanName = this.props.currentPlan.plan || 'free'; + var currentSize = this.getPlanSize(currentPlanName); + debug(currentSize); + if (this.props.JWT) { + if (!!this.props.currentPlan && currentPlanName === plan.plan_code) { + action = 'Current Plan'; + } else if (this.state.confirmPlan === plan.plan_code) { + action = ( +
    + Confirm or  + Cancel +
    + ); + } else if (this.props.updatePlan === plan.plan_code) { + action = ( +
    + Processing +
    + ); + } else if (!!this.props.currentPlan && currentSize > plan.num_private_repos) { + action = (Downgrade Plan); + } else { + action = (Upgrade Plan); + } + } else { + action = ( +
    + Sign up or Log in +
    + ); + } + + return ( + + ); +}; + +function isNautilusEnabled(currentPlan) { + const { add_ons } = currentPlan; + if (!add_ons) { + return false; + } + return _.indexOf(add_ons, NAUTILUS) >= 0; +} + + +var PlanRow = React.createClass({ + displayName: 'PlanRow', + propTypes: { + is_active: PropTypes.bool.isRequired, + num_private_repos: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + + pricePerInterval: PropTypes.shape({ + price: PropTypes.number.isRequired, + interval: PropTypes.string.isRequired + }), + action: PropTypes.oneOfType([ + PropTypes.string.isRequired, + PropTypes.object.isRequired + ]) + }, + render() { + if (!this.props.is_active) { + return null; + } else { + var ppi = this.props.pricePerInterval; + return ( + + {this.props.name} + ${ppi.price}/{ppi.interval} + {this.props.num_private_repos} + {this.props.num_private_repos} + {this.props.action} + + ); + } + } +}); + +var PlansTable = React.createClass({ + displayName: 'PlansTable', + + contextTypes: { + getStore: React.PropTypes.func.isRequired, + executeAction: React.PropTypes.func.isRequired + }, + + propTypes: { + JWT: PropTypes.string, + username: PropTypes.oneOfType([ + PropTypes.string, PropTypes.bool + ]), + history: PropTypes.object.isRequired, + user: PropTypes.object, + plansList: PropTypes.array.isRequired, + currentPlan: PropTypes.shape({ + subscription_uuid: PropTypes.string, + plan: PropTypes.string, + package: PropTypes.string + }), + stopSubscription: PropTypes.func.isRequired, + billingInfo: PropTypes.object.isRequired, + isNewBilling: PropTypes.bool.isRequired, + updatePlan: PropTypes.string + }, + + getInitialState() { + return { + confirmPlan: '', + hasNautilus: isNautilusEnabled(this.props.currentPlan) + }; + }, + + getPlanSize(plan_code) { + return (_.result(_.find(this.props.plansList, {plan_code: plan_code}), 'num_private_repos')); + }, + + selectConfirmPlan(plan) { + return (e) => { + e.preventDefault(); + this.setState({confirmPlan: plan.plan_code}); + }; + }, + + cancelSelectPlan: function(e) { + e.preventDefault(); + this.setState({confirmPlan: ''}); + }, + + _stopSubscription: function(e) { + e.preventDefault(); + this.setState({confirmPlan: ''}); + this.props.stopSubscription(); + }, + + updateSubscriptionsPlan(plan) { + return (e) => { + e.preventDefault(); + this.setState({confirmPlan: ''}); + if (this.props.isNewBilling) { + // IF USER HAS NO BILLING ACCOUNT OR NO BILLING PAYMENT INFORMATION + // GO TO CREATION PAGE + if (_.has(this.props.user, 'username')) { + this.props.history.pushState(null, '/account/billing-plans/create-subscription/', {plan: plan.plan_code}); + } else if (_.has(this.props.user, 'orgname')) { + this.props.history.pushState(null, `/u/${this.props.user.orgname}/dashboard/billing/create-subscription/`, {plan: plan.plan_code}); + } + } else { + // IF USER HAS BILLING INFORMATION - THEN WE CAN JUST UPGRADE + this.context.executeAction(updateSubscriptionPlanOrPackage, + {JWT: this.props.JWT, + username: this.props.username, + subscription_uuid: this.props.currentPlan.subscription_uuid, + plan_code: plan.plan_code, + package_code: this.props.currentPlan.package + }); + } + }; + }, + + toggleNautilus() { + const { currentPlan, username, JWT } = this.props; + const { plan, subscription_uuid } = currentPlan; + const { hasNautilus } = this.state; + const add_ons = hasNautilus ? [] : [NAUTILUS]; + const data = { + plan_code: plan, + username, + add_ons, + subscription_uuid, + JWT + }; + + this.context.executeAction(updateSubscriptionPlanOrPackage, data); + this.setState({ hasNautilus: !hasNautilus }); + }, + + renderNautilusUpsell() { + const { currentPlan } = this.props; + const isFreePlan = !currentPlan || !currentPlan.plan + || currentPlan.plan === CLOUD_METERED; + let text; + const shield = ( + + + + + + + + + ); + if (isFreePlan) { + text = 'Enable security scanning when you upgrade your plan.'; + const url = 'https://docs.docker.com/docker-cloud/builds/image-scan/'; + const link = ( + + Learn more about Docker Security Scanning + + ); + return ( +
    +
    + {shield} + {text} {link} +
    +
    + ); + } + + var nautilusUpsellClasses = classnames({ + [styles.nautilusUpsell]: true, + [styles.nautilusEnabled]: this.state.hasNautilus + }); + + text = [ + 'Monitor with Docker Security Scanning - available for your private ', + 'repositories for free while in preview.' + ].join(''); + return ( +
    + +
    + ); + }, + + render() { + const {JWT, currentPlan, updatePlan, plansList, isOrg} = this.props; + var action; + if (!JWT) { + action = ( +
    + Sign up or Log in +
    + ); + } else if (!currentPlan.plan || currentPlan.plan === CLOUD_METERED) { + action = 'Current Plan'; + } else if (updatePlan === CLOUD_METERED) { + action = ( +
    Removing Plan
    + ); + } else if (this.state.confirmPlan === 'free') { + action = ( +
    + Confirm or  + Cancel +
    + ); + } else { + action = Downgrade Plan; + } + let allPlans = defaultPlans; + if (!_.isEmpty(plansList)) { + allPlans = _.clone(plansList); + } + const privateRepos = isOrg ? '0' : '1'; + return ( + +
    + + + + Plan + + + Price + + + Private Repositories + + + Parallel Builds + + + + + Free + $0/mo + {privateRepos} + {privateRepos} + {action} + + {allPlans.map(mkPricingElement, this)} +
    + {this.renderNautilusUpsell()} +
    +
    +
    +
    + ); + } +}); + +export default connectToStores(PlansTable, + [PlansStore], + function({ getStore }, props) { + return getStore(PlansStore).getState(); + }); diff --git a/app/scripts/components/account/forms/AccountInfoForm.css b/app/scripts/components/account/forms/AccountInfoForm.css new file mode 100644 index 0000000000..72cf57abed --- /dev/null +++ b/app/scripts/components/account/forms/AccountInfoForm.css @@ -0,0 +1,21 @@ +@import 'dux/css/colors.css'; + +.accountForm { + lost-flex-container: row; +} +.lostFormInputs { + lost-column: 2/3; +} +.accountButtonWrapper { + lost-column: 1/3; + align-self: flex-end; + > * { + lost-column: 1/2; + margin-bottom: 1rem; + } +} + +.error { + color: var(--primary-5); + margin-bottom: 0.3rem; +} diff --git a/app/scripts/components/account/forms/AccountInfoForm.jsx b/app/scripts/components/account/forms/AccountInfoForm.jsx new file mode 100644 index 0000000000..72f34c38cc --- /dev/null +++ b/app/scripts/components/account/forms/AccountInfoForm.jsx @@ -0,0 +1,133 @@ +'use strict'; + +import React from 'react'; +import classnames from 'classnames'; +import connectToStores from 'fluxible-addons-react/connectToStores'; + +import AccountInfoFormStore from '../../../stores/AccountInfoFormStore'; +import SimpleInput from 'common/SimpleInput.jsx'; +import SaveSettings from '../../../actions/saveSettingsData'; +import updateAccountInfoFormField from '../../../actions/updateAccountInfoFormField'; +import Button from '@dux/element-button'; +import { SplitSection } from '../../common/Sections.jsx'; + +import styles from './AccountInfoForm.css'; +import FA from 'common/FontAwesome.jsx'; +import {STATUS as BASE_STATUS} from 'stores/common/Constants.js'; + +var AccountInfoForm = React.createClass({ + contextTypes: { + executeAction: React.PropTypes.func.isRequired + }, + _onChange(fieldKey) { + return (e) => { + this.context.executeAction(updateAccountInfoFormField, { + fieldKey, + fieldValue: e.target.value + }); + }; + }, + onSubmit: function(e) { + e.preventDefault(); + this.context.executeAction(SaveSettings, { + JWT: this.props.JWT, + username: this.props.user.username, + updateData: this.props.values + }); + }, + renderButton: function(STATUS) { + switch (STATUS) { + case BASE_STATUS.SUCCESSFUL: + return ( + + ); + case BASE_STATUS.ERROR: + return ( + + ); + case BASE_STATUS.ATTEMPTING: + return ( + + ); + default: + return ( + + ); + } + }, + render: function() { + const { + full_name, + company, + location, + profile_url, + gravatar_email + } = this.props.fields; + return ( + This information will be visible to all users of Docker Hub.

    }> +
    + +
    +
    + {full_name.error} +
    + + +
    + {company.error} +
    + + +
    + {location.error} +
    + + +
    + {profile_url.error} +
    + + +
    + {gravatar_email.error} +
    + +
    + +
    + { this.renderButton(this.props.STATUS) } +
    + +
    +
    + ); + } +}); + +export default connectToStores(AccountInfoForm, + [ + AccountInfoFormStore + ], + function({ getStore }, props) { + return getStore(AccountInfoFormStore).getState(); + }); diff --git a/app/scripts/components/account/forms/ChangePassForm.css b/app/scripts/components/account/forms/ChangePassForm.css new file mode 100644 index 0000000000..5afebc5a6e --- /dev/null +++ b/app/scripts/components/account/forms/ChangePassForm.css @@ -0,0 +1,31 @@ +@import 'dux/css/colors.css'; +@import 'dux/css/box'; + +.changePassSave { + display: flex; + align-items: flex-end; +} + +.label { + color: #7a8491; +} + +.error { + color: var(--primary-5); + margin-bottom: 0.3rem; +} + +.wrapper { + lost-flex-container: row; +} +.buttons { + lost-column: 1/3; + align-self: flex-end; + > * { + margin-bottom: 1rem; + lost-column: 1/2; + } +} +.lostFormInputs { + lost-column: 2/3; +} diff --git a/app/scripts/components/account/forms/ChangePassForm.jsx b/app/scripts/components/account/forms/ChangePassForm.jsx new file mode 100644 index 0000000000..3e10e2fb7a --- /dev/null +++ b/app/scripts/components/account/forms/ChangePassForm.jsx @@ -0,0 +1,163 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import _ from 'lodash'; +import classnames from 'classnames'; + +import ChangePasswordStore from '../../../stores/ChangePasswordStore'; +import SimpleInput from 'common/SimpleInput.jsx'; +import changePass from '../../../actions/changePassword'; +import updateChangePassStore from '../../../actions/updateChangePassStore'; +import Button from '@dux/element-button'; +import { SplitSection } from '../../common/Sections.jsx'; + +import styles from './ChangePassForm.css'; + +var debug = require('debug')('ChangePassForm'); + +var ChangePassForm = React.createClass({ + contextTypes: { + getStore: PropTypes.func.isRequired, + executeAction: PropTypes.func.isRequired + }, + getDefaultProps: function() { + return { + JWT: '', + username: '' + }; + }, + getInitialState: function() { + return { + confpassErr: '' + }; + }, + onStoreChange: function() { + var store = this.context.getStore(ChangePasswordStore); + this.setState(store.getState()); + }, + oldPassChange: function(e) { + e.preventDefault(); + var oldpass = e.target.value; + var payload = { + oldpass: oldpass, + newpass: this.props.changePasswordStore.newpass, + confpass: this.props.changePasswordStore.confpass + }; + this.context.executeAction(updateChangePassStore, payload); + }, + newPassChange: function(e) { + e.preventDefault(); + var newpass = e.target.value; + var payload = { + oldpass: this.props.changePasswordStore.oldpass, + newpass: newpass, + confpass: this.props.changePasswordStore.confpass + }; + this.context.executeAction(updateChangePassStore, payload); + }, + confirmChange: function(e) { + var confpass = e.target.value; + var payload = { + oldpass: this.props.changePasswordStore.oldpass, + newpass: this.props.changePasswordStore.newpass, + confpass: confpass + }; + this.context.executeAction(updateChangePassStore, payload); + this.setState({ + confpassErr: false + }); + }, + onSubmit: function(e) { + e.preventDefault(); + var store = this.props.changePasswordStore; + if (store.confpass === store.newpass) { + var payload = { + JWT: this.props.JWT, + username: this.props.username, + oldpassword: this.props.changePasswordStore.oldpass, + newpassword: this.props.changePasswordStore.newpass + }; + this.context.executeAction(changePass, payload); + } else { + debug('passwords do not match'); + this.setState({ + confpassErr: true + }); + } + }, + render: function() { + var store = this.props.changePasswordStore; + var oldHasErr = _.has(store.err, 'old_password') || _.has(store.err, 'non_field_errors'); + var oldError; + var newHasErr = _.has(store.err, 'new_password'); + var newError; + if (oldHasErr) { + if (_.has(store.err, 'old_password')) { + oldError = store.err.old_password[0]; + } else { + oldError = store.err.non_field_errors[0]; + } + } + if (newHasErr) { + newError = store.err.new_password[0]; + } + + return ( + Please choose a password which is longer than 6 characters.

    }> +
    +
    +
    + + +
    + { oldError } +
    + + + +
    + { newError } +
    + + + +
    + { this.state.confpassErr ? 'Make sure passwords are identical' : '' } +
    + + +
    +
    + +
    +
    +
    +
    + ); + } +}); + +export default connectToStores(ChangePassForm, + [ChangePasswordStore], + function({ getStore }, props) { + return { + changePasswordStore: getStore(ChangePasswordStore).getState() + }; + }); diff --git a/app/scripts/components/account/forms/EmailForm.css b/app/scripts/components/account/forms/EmailForm.css new file mode 100644 index 0000000000..8a176e8033 --- /dev/null +++ b/app/scripts/components/account/forms/EmailForm.css @@ -0,0 +1,17 @@ +@import 'dux/css/colors.css'; +@import 'dux/css/box'; + +.addError { + padding: .5rem .5rem 0 .5rem; + color: var(--primary-5); +} + +.noBottomMargin { + width: 100%; + input { + margin-bottom: 0; + } + > * { + margin-bottom: 0; + } +} diff --git a/app/scripts/components/account/forms/EmailForm.jsx b/app/scripts/components/account/forms/EmailForm.jsx new file mode 100644 index 0000000000..4e98950483 --- /dev/null +++ b/app/scripts/components/account/forms/EmailForm.jsx @@ -0,0 +1,121 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import has from 'lodash/object/has'; +import classnames from 'classnames'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import Button from '@dux/element-button'; +import Card, { Block } from '@dux/element-card'; + +import { SplitSection } from 'common/Sections.jsx'; +import { FlexTable, FlexRow, FlexHeader, FlexItem } from 'common/FlexTable.jsx'; +import SimpleInput from 'common/SimpleInput'; +import EmailElement from './email/EmailElement.jsx'; +import addUserEmail from 'actions/addUserEmail.js'; +import addUserEmailChange from 'actions/addUserEmailChange.js'; +import setNewPrimaryEmail from 'actions/setNewPrimaryEmail'; +import EmailsStore from 'stores/EmailsStore'; + +import styles from './EmailForm.css'; + +var debug = require('debug')('EmailForm'); + +var EmailForm = React.createClass({ + + contextTypes: { + executeAction: React.PropTypes.func.isRequired + }, + propTypes: { + user: PropTypes.object.isRequired, + JWT: PropTypes.string.isRequired, + emails: PropTypes.array.isRequired, + addEmail: PropTypes.string, + addError: PropTypes.string, + emailConfirmations: PropTypes.object + }, + onAddEmailChange: function(e) { + e.preventDefault(); + this.context.executeAction(addUserEmailChange, {email: e.target.value}); + }, + saveNewEmail: function(e) { + e.preventDefault(); + this.context.executeAction(addUserEmail, { + JWT: this.props.JWT, + newEmail: this.props.addEmail, + user: this.props.user + }); + }, + setNewPrimary(id) { + return (e) => { + e.preventDefault(); + var payload = { + JWT: this.props.JWT, + username: this.props.user.username, + emailId: id + }; + this.context.executeAction(setNewPrimaryEmail, payload); + }; + }, + _mkEmailElement({id, user, primary, email, verified}) { + const {emailConfirmations} = this.props; + let emailStatus = ''; + if (has(emailConfirmations, id)) { + emailStatus = emailConfirmations[id]; + } + return (); + }, + render: function() { + const buttonVariant = !this.props.addError ? 'primary' : 'alert'; + let errorMsg; + if (this.props.addError) { + errorMsg = (
    {this.props.addError}
    ); + } + return ( +

    This email address will be used for all notifications and correspondence from Docker.

    +

    If you wish to designate a different email address as primary, first add a new address to your account and then click "make primary".

    )}> + + + +
    + + {errorMsg} + +
    + +
    + +
    +
    +
    + {this.props.emails.map(this._mkEmailElement)} +
    + + ); + } +}); + +export default connectToStores(EmailForm, + [ + EmailsStore + ], function({ getStore }, props) { + return getStore(EmailsStore).getState(); + }); diff --git a/app/scripts/components/account/forms/email/DeleteEmailElement.jsx b/app/scripts/components/account/forms/email/DeleteEmailElement.jsx new file mode 100644 index 0000000000..64c16fdb85 --- /dev/null +++ b/app/scripts/components/account/forms/email/DeleteEmailElement.jsx @@ -0,0 +1,44 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import deleteEmail from 'actions/deleteEmail.js'; +import { FlexItem } from 'common/FlexTable.jsx'; +import styles from './EmailComponents.css'; +import FA from 'common/FontAwesome.jsx'; + +export default React.createClass({ + displayName: 'deleteEmailElement', + propTypes: { + isPrimaryEmail: PropTypes.bool.isRequired, + emailid: PropTypes.number.isRequired, + user: PropTypes.string.isRequired, + JWT: PropTypes.string.isRequired + }, + contextTypes: { + executeAction: PropTypes.func.isRequired + }, + deleteEmail: function(e) { + e.preventDefault(); + var payload = { + JWT: this.props.JWT, + username: this.props.user, + delEmailID: this.props.emailid + }; + this.context.executeAction(deleteEmail, payload); + }, + render() { + if(this.props.isPrimaryEmail) { + return ( + + ); + } else { + return ( + + + + + + ); + } + } +}); diff --git a/app/scripts/components/account/forms/email/EmailComponents.css b/app/scripts/components/account/forms/email/EmailComponents.css new file mode 100644 index 0000000000..dc8c95a9c0 --- /dev/null +++ b/app/scripts/components/account/forms/email/EmailComponents.css @@ -0,0 +1,24 @@ +@import 'dux/css/colors.css'; + +.emailAddress { + word-wrap: break-word; +} + +.emphasis { + color: var(--secondary-3); + font-weight: 800; +} + +.failed { + color: var(--primary-5); +} + +.remove { + color: var(--primary-5); + transition: color .15s ease; + align-self: center; +} + +.remove:hover { + color: var(--secondary-1); + } diff --git a/app/scripts/components/account/forms/email/EmailElement.jsx b/app/scripts/components/account/forms/email/EmailElement.jsx new file mode 100644 index 0000000000..3fee719c9e --- /dev/null +++ b/app/scripts/components/account/forms/email/EmailElement.jsx @@ -0,0 +1,56 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import { FlexTable, FlexRow, FlexHeader, FlexItem } from 'common/FlexTable.jsx'; +import styles from './EmailComponents.css'; +import PrimaryEmail from './PrimaryEmail'; +import VerifiedOrResend from './VerifiedOrResend'; +import DeleteEmailElement from './DeleteEmailElement'; + +export default React.createClass({ + displayName: 'EmailElement', + propTypes: { + user: PropTypes.string.isRequired, + isPrimaryEmail: PropTypes.bool.isRequired, + emailid: PropTypes.number.isRequired, + email: PropTypes.string.isRequired, + isVerified: PropTypes.bool.isRequired, + JWT: PropTypes.string.isRequired, + setNewPrimary: PropTypes.func.isRequired, + STATUS: PropTypes.string + }, + render(){ + const { + JWT, + user, + isVerified, + email, + emailid, + isPrimaryEmail, + setNewPrimary, + STATUS + } = this.props; + return ( + + +
    {email}
    +
    + + + +
    + ); + } +}); diff --git a/app/scripts/components/account/forms/email/PrimaryEmail.jsx b/app/scripts/components/account/forms/email/PrimaryEmail.jsx new file mode 100644 index 0000000000..991d7f130a --- /dev/null +++ b/app/scripts/components/account/forms/email/PrimaryEmail.jsx @@ -0,0 +1,37 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import { FlexItem } from 'common/FlexTable.jsx'; +import styles from './EmailComponents.css'; + +export default React.createClass({ + displayName: 'PrimaryEmail', + propTypes: { + isVerified: PropTypes.bool.isRequired, + isPrimaryEmail: PropTypes.bool.isRequired, + emailID: PropTypes.number.isRequired, + setNewPrimary: PropTypes.func.isRequired + }, + contextTypes: { + executeAction: PropTypes.func.isRequired + }, + render() { + if (!this.props.isVerified) { + return ( + + ); + } else if (this.props.isPrimaryEmail) { + return ( + + primary + + ); + } else { + return ( + + make primary + + ); + } + } +}); diff --git a/app/scripts/components/account/forms/email/VerifiedOrResend.jsx b/app/scripts/components/account/forms/email/VerifiedOrResend.jsx new file mode 100644 index 0000000000..3bfb874368 --- /dev/null +++ b/app/scripts/components/account/forms/email/VerifiedOrResend.jsx @@ -0,0 +1,53 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import resendConfirmationEmail from 'actions/resendConfirmationEmail'; +import { FlexItem } from 'common/FlexTable.jsx'; +import styles from './EmailComponents.css'; +import FA from 'common/FontAwesome.jsx'; +import { EMAILSTATUS } from 'stores/emailsstore/Constants'; + +export default React.createClass({ + displayName: 'VerifiedOrResend', + propTypes: { + isVerified: PropTypes.bool.isRequired, + email: PropTypes.string.isRequired, + emailid: PropTypes.number.isRequired, + STATUS: PropTypes.string + }, + contextTypes: { + executeAction: PropTypes.func.isRequired + }, + _resendConfirmation(e) { + e.preventDefault(); + this.context.executeAction(resendConfirmationEmail, { + JWT: this.props.JWT, + emailID: this.props.emailid + }); + }, + render() { + const { STATUS } = this.props; + if(this.props.isVerified){ + return ( + verified + ); + } else if (STATUS === EMAILSTATUS.ATTEMPTING) { + return (Sending ); + } else if (STATUS === EMAILSTATUS.SUCCESS) { + return (Email Sent!); + } else if (STATUS === EMAILSTATUS.FAILED) { + return ( + +
    Failed
    +
    + ); + } else { + return ( + + Resend Email + + ); + } + } +}); diff --git a/app/scripts/components/account/notificationSettings/EmailNotifForm.css b/app/scripts/components/account/notificationSettings/EmailNotifForm.css new file mode 100644 index 0000000000..4fc8366809 --- /dev/null +++ b/app/scripts/components/account/notificationSettings/EmailNotifForm.css @@ -0,0 +1,25 @@ +@import 'dux/css/colors.css'; + +.button { + margin-bottom: -1rem; +} + +.success { + border: 1px solid var(--primary-2); +} + +.formError { + border: 1px solid var(--primary-5); +} + +.checkbox { + padding-top: .5rem; +} + +.notification { + margin-bottom: 1rem; +} +.notification:hover { + cursor: pointer; + cursor: hand; +} diff --git a/app/scripts/components/account/notificationSettings/EmailNotifForm.jsx b/app/scripts/components/account/notificationSettings/EmailNotifForm.jsx new file mode 100644 index 0000000000..0b229c47dd --- /dev/null +++ b/app/scripts/components/account/notificationSettings/EmailNotifForm.jsx @@ -0,0 +1,158 @@ +'use strict'; +import React, { PropTypes } from 'react'; +import _ from 'lodash'; +import classnames from 'classnames'; +import connectToStores from 'fluxible-addons-react/connectToStores'; + +import styles from './EmailNotifForm.css'; + +import EmailNotifStore from '../../../stores/EmailNotifStore.js'; +import deleteEmailNotifs from '../../../actions/deleteEmailNotifs'; +import resetNotifications from '../../../actions/resetNotifications.js'; +import saveEmailNotifs from '../../../actions/saveEmailNotifs'; +import updateNotifCheckbox from '../../../actions/updateNotifCheckbox.js'; +import { Button } from 'dux'; +import { SplitSection } from '../../common/Sections'; +var debug = require('debug')('EmailNotifForm'); + +var EmailNotifForm = React.createClass({ + displayName: 'EmailNotifForm', + contextTypes: { + executeAction: PropTypes.func.isRequired, + getStore: PropTypes.func.isRequired + }, + propTypes: { + user: PropTypes.object.isRequired, + JWT: PropTypes.string.isRequired + }, + onNotifCheckboxClick: function(cboxType, evt) { + this.context.executeAction(updateNotifCheckbox, cboxType); + }, + //TODO: Fix this when we have time to change the backend API. This sucks atm. + _getNotificationPayload: function(type) { + var nType = ''; + switch (type) { + case 'auto': + nType = 'trusted_build_fail'; + break; + case 'star': + nType = 'new_repo_star'; + break; + case 'comment': + nType = 'new_repo_comment'; + break; + } + return { + 'user': this.props.user.username, + 'notification': nType, + 'last_occurrence': '1970-01-01T00:00:00Z' + }; + }, + _getNotificationDeletePayload: function(type) { + switch (type) { + case 'auto': + return this.props.autoBuildNotificationID; + case 'star': + return this.props.starNotificationID; + case 'comment': + return this.props.imgCommentNotificationID; + default: + break; + } + }, + onNotifSubmit: function(e) { + e.preventDefault(); + var store = this.context.getStore(EmailNotifStore); + var changed = store.hasChanged.bind(store); + //TODO: this NEEDS refactoring once we discuss a better way to handle notification settings! eek. + if (changed('star')) { + if (this.props.starNotification) { + var nStar = this._getNotificationPayload('star'); + this.context.executeAction(saveEmailNotifs, {jwt: this.props.JWT, notification: nStar}); + } else { + var nStarDel = this._getNotificationDeletePayload('star'); + if(nStarDel > 0) { + this.context.executeAction(deleteEmailNotifs, {jwt: this.props.JWT, notificationID: nStarDel}); + } + } + } + if (changed('comment')) { + if (this.props.imgCommentNotification) { + var nComment = this._getNotificationPayload('comment'); + this.context.executeAction(saveEmailNotifs, {jwt: this.props.JWT, notification: nComment}); + } else { + var nCommentDel = this._getNotificationDeletePayload('comment'); + if(nCommentDel > 0) { + this.context.executeAction(deleteEmailNotifs, {jwt: this.props.JWT, notificationID: nCommentDel}); + } + } + } + if (changed('auto')) { + if (this.props.autoBuildNotification) { + var nAuto = this._getNotificationPayload('auto'); + this.context.executeAction(saveEmailNotifs, {jwt: this.props.JWT, notification: nAuto}); + } else { + var nAutoDel = this._getNotificationDeletePayload('auto'); + if(nAutoDel > 0) { + this.context.executeAction(deleteEmailNotifs, {jwt: this.props.JWT, notificationID: nAutoDel}); + } + } + } + }, + onNotifCancel: function(e) { + debug('resetting email notifications'); + this.context.executeAction(resetNotifications, ['notifications']); + }, + render: function() { + return ( + The following settings will control how email is sent based on the occurrence of specific events.

    }> +
    +
    +
    + +
    +
    + Notify me when my Repositories get starred +
    +
    +
    +
    + +
    +
    + Notify me when a comment is posted on my Repositories +
    +
    +
    +
    + +
    +
    + Notify me when an automated build fails +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + ); + } +}); + +export default connectToStores(EmailNotifForm, + [EmailNotifStore], function({ getStore }, props) { + return getStore(EmailNotifStore).getState(); + }); diff --git a/app/scripts/components/account/orgs/OrganizationAddTeam.jsx b/app/scripts/components/account/orgs/OrganizationAddTeam.jsx new file mode 100644 index 0000000000..a703271d72 --- /dev/null +++ b/app/scripts/components/account/orgs/OrganizationAddTeam.jsx @@ -0,0 +1,86 @@ +'use strict'; +import React, {Component, PropTypes} from 'react'; +import DUXInput from 'common/DUXInput.jsx'; +import createOrgTeamAction from '../../../actions/createOrgTeam'; +import OrgTeamStore from '../../../stores/OrgTeamStore'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import Button from '@dux/element-button'; +import styles from './TeamProfile.css'; + +import { STATUS as ORG_STATUS } from '../../../stores/orgteamstore/Constants'; +const { string, array, func, object } = PropTypes; + +class OrganizationAddTeam extends Component { + static contextTypes = { + executeAction: func.isRequired + } + + static propTypes = { + JWT: string.isRequired, + onCancel: func, + name: string, + description: string, + members: array, + errorDetails: object, + success: string, + STATUS: string + } + + state = { + teamname: '', + teamdesc: '', + members: [] + } + + _handleCreateTeam = (e) => { + e.preventDefault(); + this.context.executeAction(createOrgTeamAction, + { + jwt: this.props.JWT, + orgName: this.props.params.user, + team: {name: this.state.teamname, description: this.state.teamdesc} }); + } + + _handleReset = (e) => { + e.preventDefault(); + } + + teamNameChange = (e) => { + this.setState({teamname: e.target.value}); + } + + teamDescChange = (e) => { + this.setState({teamdesc: e.target.value}); + } + + render() { + let maybeError; + if (this.props.errorDetails.detail) { + maybeError = {this.props.errorDetails.detail}; + } + return ( +
    +
    + + +
    + + +
    + {maybeError} + +
    + + ); + } +} + +export default connectToStores(OrganizationAddTeam, + [ + OrgTeamStore + ], + function({ getStore }, props) { + return getStore(OrgTeamStore).getState(); + }); diff --git a/app/scripts/components/account/orgs/OrganizationProfile.css b/app/scripts/components/account/orgs/OrganizationProfile.css new file mode 100644 index 0000000000..f0e10ae231 --- /dev/null +++ b/app/scripts/components/account/orgs/OrganizationProfile.css @@ -0,0 +1,12 @@ +@import "dux/css/colors"; + +.visibility { + color: var(--black); + &, > input { + cursor: pointer; + } +} + +.label { + color: #7a8491; +} diff --git a/app/scripts/components/account/orgs/OrganizationProfile.jsx b/app/scripts/components/account/orgs/OrganizationProfile.jsx new file mode 100644 index 0000000000..6c721405d0 --- /dev/null +++ b/app/scripts/components/account/orgs/OrganizationProfile.jsx @@ -0,0 +1,175 @@ +'use strict'; + +import React from 'react'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import _ from 'lodash'; + +import saveOrgProfileAction from '../../../actions/saveOrgProfile'; +import toggleVisibility from '../../../actions/toggleVisibility.js'; +import OrganizationStore from '../../../stores/OrganizationStore'; +import PrivateRepoUsageStore from '../../../stores/PrivateRepoUsageStore.js'; +import SimpleInput from 'common/SimpleInput.jsx'; +import { SplitSection } from '../../common/Sections.jsx'; +import Route404 from '../../common/RouteNotFound404Page.jsx'; +import Button from '@dux/element-button'; +import styles from './OrganizationProfile.css'; + +var OrganizationProfile = React.createClass({ + contextTypes: { + executeAction: React.PropTypes.func.isRequired + }, + propTypes: { + currentOrg: React.PropTypes.shape({ + orgname: React.PropTypes.string, + full_name: React.PropTypes.string, + location: React.PropTypes.string, + company: React.PropTypes.string, + profile_url: React.PropTypes.string + }), + isOwner: React.PropTypes.bool.isRequired, + JWT: React.PropTypes.string, + error: React.PropTypes.string, + success: React.PropTypes.string, + defaultVisibility: React.PropTypes.oneOf(['public', 'private']) + }, + getInitialState: function() { + return { + orgname: this.props.currentOrg.orgname, + fullName: this.props.currentOrg.full_name, + location: this.props.currentOrg.location, + company: this.props.currentOrg.company, + profileUrl: this.props.currentOrg.profile_url, + gravatarEmail: this.props.currentOrg.gravatar_email, + defaultVisibility: this.props.defaultVisibility + }; + }, + onSubmit: function(e) { + e.preventDefault(); + var updatedOrg = { + full_name: this.state.fullName, + location: this.state.location, + company: this.state.company, + profile_url: this.state.profileUrl, + gravatar_email: this.state.gravatarEmail + }; + this.context.executeAction(saveOrgProfileAction, { + jwt: this.props.JWT, + orgname: this.state.orgname, + organization: updatedOrg + }); + this.context.executeAction(toggleVisibility, { + JWT: this.props.JWT, + username: this.state.orgname, + visibility: this.state.defaultVisibility + }); + }, + orgFullNameChange: function(e) { + this.setState({fullName: e.target.value}); + }, + orgCompanyChange: function(e) { + this.setState({company: e.target.value}); + }, + locationChange: function(e) { + this.setState({location: e.target.value}); + }, + profileUrlChange: function(e) { + this.setState({profileUrl: e.target.value}); + }, + gravatarEmailChange: function(e) { + this.setState({gravatarEmail: e.target.value}); + }, + toggleClick: function({ visibility }) { + return (e) => { + this.setState({defaultVisibility: visibility}); + }; + }, + render: function() { + var maybeError = ; + var maybeSuccess = ; + if (this.props.error) { + maybeError = {this.props.error}; + } else if(this.props.success) { + //TODO: this could be an alert box with a close icon that can be closed when the user wants to dismiss + //Or time out after some time (ideally i would like to see this as a notification and that's it) + maybeSuccess =

    {this.props.success}
    ; + } + if (this.props.isOwner) { + return ( +
    +
    + This information is private to users with access to this organization.

    }> +
    +
    +
    +
    + Default Visibility: +
    +
      +
    • + +
    • +
    • + +
    • +
    +
    + + + + + + + + + + + + + + + + + + {maybeError} + {maybeSuccess} + +
    +
    + ); + } else { + return ( + + ); + } + } +}); + +export default connectToStores(OrganizationProfile, + [ + OrganizationStore, + PrivateRepoUsageStore + ], + function({ getStore }, props) { + return _.merge({}, getStore(OrganizationStore).getState(), {defaultVisibility: getStore(PrivateRepoUsageStore).getState().defaultRepoVisibility}); + }); diff --git a/app/scripts/components/account/orgs/TeamProfile.css b/app/scripts/components/account/orgs/TeamProfile.css new file mode 100644 index 0000000000..b7cdc43037 --- /dev/null +++ b/app/scripts/components/account/orgs/TeamProfile.css @@ -0,0 +1,7 @@ +@import "dux/css/box.css"; + +.addTeamButtonGroup { + button[type="submit"] { + margin-right: var(--default-margin); + } +} diff --git a/app/scripts/components/account/orgs/TeamProfile.jsx b/app/scripts/components/account/orgs/TeamProfile.jsx new file mode 100644 index 0000000000..5fed357741 --- /dev/null +++ b/app/scripts/components/account/orgs/TeamProfile.jsx @@ -0,0 +1,102 @@ +'use strict'; + +import React, {PropTypes, Component} from 'react'; +import saveTeamProfileAction from '../../../actions/saveTeamProfile'; +import deleteTeamProfileAction from '../../../actions/removeTeam'; +import DUXInput from 'common/DUXInput.jsx'; +import styles from './TeamProfile.css'; +import Button from '@dux/element-button'; +import _ from 'lodash'; + +const { string, object, bool, func } = PropTypes; + +export default class TeamProfile extends Component { + + static contextTypes = { + executeAction: func.isRequired + } + + static propTypes = { + JWT: string, + history: object.isRequired, + error: string, + success: string, + orgname: string, + clearError: func, + onDeleteTeam: func, + onUpdateTeam: func, + team: object + } + + state = { + teamName: this.props.team.name, + teamDesc: this.props.team.description + } + + onSubmit = (e) => { + e.preventDefault(); + var updatedTeam = { + name: this.state.teamName, + description: this.state.teamDesc + }; + this.context.executeAction(saveTeamProfileAction, { + jwt: this.props.JWT, + orgname: this.props.orgname, + teamname: this.props.team.name, + team: updatedTeam + }); + } + + onDelete = (e) => { + e.preventDefault(); + this.context.executeAction(deleteTeamProfileAction, { + jwt: this.props.JWT, + orgname: this.props.orgname, + teamname: this.props.team.name + }); + } + + teamNameChange = (e) => { + if (this.state.teamName !== 'owners') { + this.setState({teamName: e.target.value}); + } else { + this.setState({teamName: 'owners'}); + } + } + + teamDescChange = (e) => { + this.setState({teamDesc: e.target.value}); + } + + render() { + var maybeError = ; + var maybeSuccess = ; + if (this.props.error) { + maybeError = {this.props.error}; + } else if(this.props.success) { + //TODO: this could be an alert box with a close icon that can be closed when the user wants to dismiss + //Or time out after some time (ideally i would like to see this as a notification and that's it) + maybeSuccess =

    {this.props.success}
    ; + } + var maybeDeleteBtn; + if (this.state.teamName !== 'owners' && this.props.team.name === this.state.teamName) { + maybeDeleteBtn = (); + } + return ( +
    + + +
    + + {maybeDeleteBtn} +
    + {maybeError} + {maybeSuccess} + + ); + } +} diff --git a/app/scripts/components/account/services/GithubLinkScopes.jsx b/app/scripts/components/account/services/GithubLinkScopes.jsx new file mode 100644 index 0000000000..b4b56f52b0 --- /dev/null +++ b/app/scripts/components/account/services/GithubLinkScopes.jsx @@ -0,0 +1,86 @@ +'use strict'; + +import React from 'react'; +import GithubLinkStore from '../../../stores/GithubLinkStore'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +const debug = require('debug')('COMPONENT:GithubLinkScopes'); +import githubOathAction from '../../../actions/githubOauth'; + +import { SplitSection } from '../../common/Sections'; +import { Button } from 'dux'; + +var GithubLinkScopes = React.createClass({ + displayName: 'GithubLinkScopes', + contextTypes: { + executeAction: React.PropTypes.func.isRequired + }, + _generateGHStateString: function() { + //Generate random string + return Math.random().toString(36).substring(8); + }, + _limitedScope: function(evt) { + evt.preventDefault(); + var ss = this._generateGHStateString(); + window.open( + 'https://github.com/login/oauth/authorize?client_id=' + + this.props.githubClientID + + '&state=' + + ss, '_blank'); + this.context.executeAction(githubOathAction, {stateString: ss}); + }, + _recommendedScope: function(evt) { + evt.preventDefault(); + var ss = this._generateGHStateString(); + window.open( + 'https://github.com/login/oauth/authorize?client_id=' + + this.props.githubClientID + + '&scope=repo' + + '&state=' + + ss, '_blank'); + this.context.executeAction(githubOathAction, {stateString: ss}); + }, + render: function() { + return ( +
    +
    + +
    +
    +
    Public and Private (Recommended)
    +
      +
    • Read and Write access to public and private repositories. + (We only use write access to add service hooks and add deploy keys)
    • +
    • Required if you want to setup an Automated Build from a private GitHub repository.
    • +
    • Required if you want to use a private GitHub organization.
    • +
    • We will automatically configure the service hooks and deploy keys for you.
    • +
    +
    +
    + +
    +
    +
    Limited Access
    +
      +
    • Public read only access.
    • +
    • Only works with public repositories and organizations.
    • +
    • You will need to manually make changes to your repositories in order to use Automated Build.
    • +
    +
    +
    + +
    +
    +
    +
    + ); + } +}); + +export default connectToStores(GithubLinkScopes, + [ + GithubLinkStore + ], + function({ getStore }, props) { + return getStore(GithubLinkStore).getState(); + }); diff --git a/app/scripts/components/common/AlertBox.jsx b/app/scripts/components/common/AlertBox.jsx new file mode 100644 index 0000000000..3a5d5f9f82 --- /dev/null +++ b/app/scripts/components/common/AlertBox.jsx @@ -0,0 +1,34 @@ +'use strict'; + +import React, { Component, PropTypes } from 'react'; +const { oneOf, func } = PropTypes; +import { intents } from 'dux/dux/utils/props'; +import classnames from 'classnames'; +import _ from 'lodash'; + +export default class AlertBox extends Component { + static propTypes = { + intent: oneOf(intents), + onClick: func + } + + static defaultProps = { + onClick() {} + } + + render() { + const { intent } = this.props; + + const classes = classnames({ + 'alert-box': true, + [intent]: _.includes(intents, intent) + }); + + return ( +
    + {this.props.children} +
    + ); + } +} diff --git a/app/scripts/components/common/BlankSlate.css b/app/scripts/components/common/BlankSlate.css new file mode 100644 index 0000000000..67bb58242c --- /dev/null +++ b/app/scripts/components/common/BlankSlate.css @@ -0,0 +1,27 @@ +@import 'dux/css/colors.css'; +.blankSlates { + background: #fff; + padding: 2.5rem; + h1 { + color: var(--secondary-7); + font-size: 2rem; + font-weight: 300; + } +} + +.link { + background: transparent; + border-radius: 3px; + border: 1px solid var(--silver); + color: var(--secondary-4); + margin: 0 .3rem .3rem 0; + padding: 1.8rem; + i { + font-size: 2.5em; + margin-bottom: 1rem; + } + &:hover { + background: #f4f9fc; + color: var(--secondary-4); + } +} diff --git a/app/scripts/components/common/BlankSlate.jsx b/app/scripts/components/common/BlankSlate.jsx new file mode 100644 index 0000000000..28bf52acf7 --- /dev/null +++ b/app/scripts/components/common/BlankSlate.jsx @@ -0,0 +1,73 @@ +'use strict'; + +import styles from './BlankSlate.css'; +import React, { PropTypes, Component } from 'react'; +import { Link } from 'react-router'; +import FA from './FontAwesome'; +import classnames from 'classnames'; + +const { string, object } = PropTypes; + +/* + * Blankslate + * + * Usage: + * + * + * + * + * + */ + +export class BlankSlates extends Component { + + static propTypes = { + title: string + } + + render() { + return ( +
    +
    +
    +

    {this.props.title}

    +

    {this.props.subtext}

    +
    +
    + {this.props.children} +
    +
    +
    + ); + } +} + +export class BlankSlate extends Component { + static defaultProps = { + query: {} + } + + render() { + const linkClasses = classnames({ + 'button': true, + [styles.link]: true + }); + + const { link, query, icon, title } = this.props; + return ( + +
    {title} + + ); + } +} + +BlankSlate.propTypes = { + link: string.isRequired, + icon: string.isRequired, + title: string, + subtext: string, + query: object +}; diff --git a/app/scripts/components/common/CSEngineBox.css b/app/scripts/components/common/CSEngineBox.css new file mode 100644 index 0000000000..2cd6281e00 --- /dev/null +++ b/app/scripts/components/common/CSEngineBox.css @@ -0,0 +1,24 @@ +@import "dux/css/colors"; + +.downloadFlexItem { + background: var(--white); + border: 1px solid var(--iron); + display: flex; + flex-flow: row nowrap; + flex-grow: 4; + flex-basis: 0; + padding: 1rem 1rem; + word-break: break-word; +} + +.curlHelp { + font-size: .875rem; + font-weight: 500; + margin-bottom: 1rem; +} + +.curlWrap { + width: 100%; + padding-left: 1rem; +} + diff --git a/app/scripts/components/common/CSEngineBox.jsx b/app/scripts/components/common/CSEngineBox.jsx new file mode 100644 index 0000000000..8cfb33ea39 --- /dev/null +++ b/app/scripts/components/common/CSEngineBox.jsx @@ -0,0 +1,47 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +import CopyCodeBox from 'common/CopyCodeBox'; +import { DEB, RPM } from 'common/data/csEngineInstructions'; +import styles from './CSEngineBox.css'; + +export default class CSEngineBox extends Component { + render() { + return ( +
    +
    +

    Install CS Engine

    +
    +
    + * Copy and run either the RPM or the DEB specific instructions in your terminal. +
    +
    +
    +
    + RPM + +
    +
    +
    +
    + DEB + + + + For more details about this installation,  + + view our documentation + + +
    +
    +
    +
    + ); + } +} diff --git a/app/scripts/components/common/Code.css b/app/scripts/components/common/Code.css new file mode 100644 index 0000000000..0a5a8f177d --- /dev/null +++ b/app/scripts/components/common/Code.css @@ -0,0 +1,83 @@ +.code { + font-family: Consolas, Liberation Mono, Courier, monospace; + /** + * We scope everything rendered in markdown by the above computed + * class. Using :global ensures that we don't have to modify the parser + * to add classes (which would be a turrible idea) in that it styles + * everything *inside of* .code. + */ + :global { + + /** + * Syntax Highlighting + * + * Five-color theme from a single blue hue. + */ + .hljs, pre code[class*="lang-"] { + display: block; + overflow-x: auto; + padding: 0.5em; + background: #eaeef3; + -webkit-text-size-adjust: none; + white-space: pre; + } + + .hljs, + .hljs-list .hljs-built_in { + color: #00193a; + } + + .hljs-keyword, + .hljs-title, + .hljs-important, + .hljs-request, + .hljs-header, + .hljs-doctag { + font-weight: bold; + } + + .hljs-comment, + .hljs-chunk { + color: #738191; + } + + .hljs-string, + .hljs-title, + .hljs-parent, + .hljs-built_in, + .hljs-literal, + .hljs-filename, + .hljs-value, + .hljs-addition, + .hljs-tag, + .hljs-argument, + .hljs-link_label, + .hljs-blockquote, + .hljs-header, + .hljs-name { + color: #0048ab; + } + + .hljs-decorator, + .hljs-prompt, + .hljs-subst, + .hljs-symbol, + .hljs-doctype, + .hljs-regexp, + .hljs-preprocessor, + .hljs-pragma, + .hljs-pi, + .hljs-attribute, + .hljs-attr_selector, + .hljs-xmlDocTag, + .hljs-deletion, + .hljs-shebang, + .hljs-string .hljs-variable, + .hljs-link_url, + .hljs-bullet, + .hljs-sqbracket, + .hljs-phony { + color: #4c81c9; + } + } +} \ No newline at end of file diff --git a/app/scripts/components/common/Code.jsx b/app/scripts/components/common/Code.jsx new file mode 100644 index 0000000000..99ab0adedc --- /dev/null +++ b/app/scripts/components/common/Code.jsx @@ -0,0 +1,30 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +import hljs from 'highlight.js'; +const debug = require('debug')('Code'); +import styles from './Code.css'; + +function renderCode(str) { + const { value } = hljs.highlightAuto(str); + return value; +} + +export default class Code extends Component { + render() { + if(!this.props.children) { + return null; + } + + const html = { + __html: renderCode(this.props.children) + }; + + return ( +
    +
    +
    + ); + } +} diff --git a/app/scripts/components/common/CopyCodeBox.css b/app/scripts/components/common/CopyCodeBox.css new file mode 100644 index 0000000000..377eb21c63 --- /dev/null +++ b/app/scripts/components/common/CopyCodeBox.css @@ -0,0 +1,37 @@ +@import "dux/css/box"; +@import "dux/css/colors"; + +.copyBox { + display: flex; + flex-flow: row; + flex-basis: 95%; + margin-right: 1rem; + margin-bottom: 1rem; + overflow-x: auto; + border: 1px solid var(--iron); + border-radius: var(--global-radius); + padding: 0.5rem; +} + +.code { + font-family: monospace; + font-size: .8125em; +} +.contentBox { + composes: code; + white-space: pre; +} + +.dollarBox { + composes: code; + font-weight: 600; + white-space: pre; + user-select: none; +} + +.wrapper { + display: flex; + flex-flow: row nowrap; + align-items: flex-start; + width: 100%; +} \ No newline at end of file diff --git a/app/scripts/components/common/CopyCodeBox.jsx b/app/scripts/components/common/CopyCodeBox.jsx new file mode 100644 index 0000000000..f1e7dfa9de --- /dev/null +++ b/app/scripts/components/common/CopyCodeBox.jsx @@ -0,0 +1,75 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import styles from './CopyCodeBox.css'; +import Button from '@dux/element-button'; +const debug = require('debug')('CopyCodeBox'); +import repeat from 'lodash/string/repeat'; +const { bool, number, string } = PropTypes; + +export default class CopyCodeBox extends Component { + static propTypes = { + content: string.isRequired, + dollar: bool, + lines: number + } + + static defaultProps = { + dollar: false, + lines: 1 + } + + selectContents = (e) => { + const content = findDOMNode(this.refs.content); + let range; + if ( document.selection ) { + range = document.body.createTextRange(); + range.moveToElementText(content); + range.select(); + } else if ( window.getSelection ) { + range = document.createRange(); + range.selectNodeContents(content); + window.getSelection().removeAllRanges(); + window.getSelection().addRange(range); + } + } + + copyContentsToClipboard = (e) => { + /* Works in Chrome and Firefox 41.x + * TODO: Does not work in Safari + */ + this.selectContents(e); + try { + const success = document.execCommand('copy'); + debug(`Copy worked: ${success}`); + } catch (err) { + debug('Cannot copy.'); + } + } + render() { + const { content, dollar, lines } = this.props; + let dollarDiv; + const button = ( + + ); + if (dollar) { + const inner = repeat('$ \n', lines); + dollarDiv = ( +
    {inner}
    + ); + } + return ( +
    +
    + {dollarDiv} +
    {content}
    +
    + {button} +
    + ); + } +} diff --git a/app/scripts/components/common/DUXInput-b.css b/app/scripts/components/common/DUXInput-b.css new file mode 100644 index 0000000000..c5e308bfe3 --- /dev/null +++ b/app/scripts/components/common/DUXInput-b.css @@ -0,0 +1,9 @@ +@import "dux/css/colors"; + +:root { + --input-text-color: var(--white); + --label-color: var(--secondary-5); + --highlight-color: var(--primary-color); +} + +@import "./DUXInput-base.css"; \ No newline at end of file diff --git a/app/scripts/components/common/DUXInput-base.css b/app/scripts/components/common/DUXInput-base.css new file mode 100644 index 0000000000..d43814cea0 --- /dev/null +++ b/app/scripts/components/common/DUXInput-base.css @@ -0,0 +1,101 @@ +/* + * API: + * --label-color + * --input-text-color + * --alert-color + * --highlight-color + */ + +input.duxInput { + padding: 10px 10px 10px 5px; + border: none; + border-bottom: 1px solid gray; + margin: 0; + color: var(--input-text-color); +} +.duxInput:focus { + outline: none; +} + +.group { + position: relative; + margin-bottom: 35px; +} + +.hasError { + border-bottom-color: var(--alert-color); +} + +.hasError .label { + color: var(--alert-color); +} + +.hasError .bar:before, .hasError .bar:after { + background: var(--alert-color); +} + +.label { + color: var(--label-color); + font-weight: normal; + position: absolute; + pointer-events: none; + top: -20px; + transition: 0.2s ease all; +} + +/* BOTTOM BARS ================================= */ +.bar { + position:relative; + display:block; + +} +.bar:before, .bar:after { + content: ''; + height: 2px; + width: 0; + bottom: 0; + position: absolute; + background: var(--highlight-color); + transition: 0.2s ease all; + } +.bar:before { + left: 50%; +} +.bar:after { + right: 50%; +} + +/* HIGHLIGHTER ================================== */ +.highlight { + position: absolute; + height: 60%; + width: 100px; + top: 25%; + left: 0; + pointer-events: none; + transition: 0.2s ease all; + opacity: 0.5; +} + +.duxInput:focus ~ .bar:before { + width: 50%; +} +.duxInput:focus ~ .bar:after { + width: 50%; +} + +.duxInput:focus ~ .highlight { + -webkit-animation: inputHighlighter 0.3s ease; +} + +/* ANIMATIONS ================ */ +@-webkit-keyframes inputHighlighter { + from { + background: var(--highlight-color); + } + to { + width:0; + background:transparent; + } +} + diff --git a/app/scripts/components/common/DUXInput.css b/app/scripts/components/common/DUXInput.css new file mode 100644 index 0000000000..fe01e15184 --- /dev/null +++ b/app/scripts/components/common/DUXInput.css @@ -0,0 +1,9 @@ +@import "dux/css/colors"; + +:root { + --input-text-color: var(--black); + --label-color: var(--secondary-3); + --highlight-color: var(--primary-color); +} + +@import "./DUXInput-base.css" diff --git a/app/scripts/components/common/DUXInput.jsx b/app/scripts/components/common/DUXInput.jsx new file mode 100644 index 0000000000..722f984db7 --- /dev/null +++ b/app/scripts/components/common/DUXInput.jsx @@ -0,0 +1,130 @@ +'use strict'; + +import variantA from './DUXInput.css'; +import variantB from './DUXInput-b.css'; + +import React, { + Component, + PropTypes +} from 'react'; +import { findDOMNode } from 'react-dom'; +const { any, func, string, bool, oneOf } = PropTypes; +import classnames from 'classnames'; +import _ from 'lodash'; +import AlertBox from 'common/AlertBox'; +const debug = require('debug')('DUXInput'); + +export default class DUXInput extends Component { + + static propTypes = { + className: string, + error: string, + hasError: bool.isRequired, + name: string, + onChange: func.isRequired, + success: string, + type: oneOf('hidden text password email search'.split(' ')), + value: string, + variant: string + } + + static defaultProps = { + onChange() { + debug('No onChange function set for Input'); + }, + value: '', + hasError: false, + error: '', + type: 'text', + success: '', + name: '' + } + + focusInput = () => { + findDOMNode(this.refs.input).focus(); + } + + state = { + value: '' + } + + _onChange = (e) => { + // Set local state + const { value } = e.target; + this.setState({ value }); + + // Pass control to onChange controller. Usually a form. + this.props.onChange(e); + } + + componentWillReceiveProps(props) { + /** + * State should be tracked globally in a parent component. As + * such, we need to check the passed in prop against the current + * (local) state value to avoid overwriting the input value. + * + * This prevents a bug which resulted in the cursor being "jumped" + * to the end of an input after every character. + */ + if (props.value !== this.state.value) { + this.setState({ + value: props.value + }); + } + } + + render() { + /** + * Theme variant is accepted through the props. + */ + const { variant, name } = this.props; + const styles = variant !== 'b' ? variantA : variantB; + + /** + * If there is an error, display it + */ + let maybeError = ; + if(this.props.hasError && this.props.error) { + maybeError = {this.props.error}; + } + + /** + * If there is a success message, display it + */ + let maybeSuccess = ; + if(this.props.success) { + /** + * TODO: This shouldn't be a property of the input in the way + * that it currently exists. A field-level success state should + * be very minimal. + * + * OLD_TODO: this could be an alert box with a close icon that can be + * closed when the user wants to dismiss or time out after some + * time (ideally i would like to see this as a notification and + * that's it) + */ + maybeSuccess = {this.props.success}; + } + + const groupClasses = classnames({ + [styles.group]: true, + [styles.hasError]: this.props.hasError + }); + + return ( +
    + + + + {maybeError} + {maybeSuccess} +
    + ); + } +} diff --git a/app/scripts/components/common/FancyInput.css b/app/scripts/components/common/FancyInput.css new file mode 100644 index 0000000000..44264aa907 --- /dev/null +++ b/app/scripts/components/common/FancyInput.css @@ -0,0 +1,76 @@ +@import "dux/css/colors.css"; +@import "dux/css/box.css"; + +.inputDiv { + width: 100%; + margin-bottom: 2rem; + > input.default { + padding: 10px 10px 10px 5px; + border: none; + border-bottom: 1px solid #cbcbcb; + margin: 0; + color: #465f77; + &:focus { + outline: none; + } + &:focus::placeholder { + transition: opacity .5s ease; + opacity: .5; + } + } + &.hasError > input { + border-bottom: 1px solid var(--alert-color); + } +} + +.white { + > input.default { + color: var(--white); + border-bottom: 1px solid #465f77; + &::placeholder { + color: #7891b0; + } + } +} + +.hasError { + border-bottom-color: var(--alert-color); + border-radius: var(--global-radius); +} + +.hasError .bar:before, .hasError .bar:after { + background: var(--alert-color); +} + +.bar { + position:relative; + display:block; +} +.bar:before, .bar:after { + content: ''; + height: 2px; + width: 0; + bottom: 0; + position: absolute; + background: var(--primary-color); + transition: 0.2s ease all; + } +.bar:before { + left: 50%; +} +.bar:after { + right: 50%; +} +.default:focus ~ .bar:before { + width: 50%; +} +.default:focus ~ .bar:after { + width: 50%; +} + +.error { + position: absolute; + color: var(--alert-color); + font-weight: 200; + margin: 0 .5rem; +} diff --git a/app/scripts/components/common/FancyInput.jsx b/app/scripts/components/common/FancyInput.jsx new file mode 100644 index 0000000000..dd851a808f --- /dev/null +++ b/app/scripts/components/common/FancyInput.jsx @@ -0,0 +1,99 @@ +'use strict'; + +import React, { + Component, + PropTypes +} from 'react'; +import { findDOMNode } from 'react-dom'; +import classnames from 'classnames'; +import styles from './FancyInput.css'; +const { any, func, string, bool, oneOf } = PropTypes; +const debug = require('debug')('SimpleInput'); + +export default class FancyInput extends Component { + static propTypes = { + autoFocus: bool, + hasError: bool, + error: string, + name: string, + onChange: func.isRequired, + placeholder: string, + readOnly: bool, + type: oneOf('hidden text password email search'.split(' ')), + value: any.isRequired, + variant: oneOf(['white']) + } + + static defaultProps = { + hasError: false, + error: '', + onChange() { + debug('No onChange function set for Input'); + }, + value: '', + placeholder: '', + type: 'text', + name: '' + } + + state = { + value: '' + } + + _onChange = (e) => { + // Set local state + const { value } = e.target; + this.setState({ value }); + + // Pass control to onChange controller. Usually a form. + this.props.onChange(e); + } + + componentWillReceiveProps(props) { + /** + * State should be tracked globally in a parent component. As + * such, we need to check the passed in prop against the current + * (local) state value to avoid overwriting the input value. + * + * This prevents a bug which resulted in the cursor being "jumped" + * to the end of an input after every character. + */ + if (props.value !== this.state.value) { + this.setState({ + value: props.value + }); + } + } + + render() { + const { hasError, + error, + name, + placeholder, + readOnly, + type, + value, + variant, + autofocus } = this.props; + const groupClass = classnames({ + [styles.inputDiv]: true, + [styles.white]: variant === 'white', + [styles.hasError]: hasError + }); + + return ( +
    + + +
    {error}
    +
    + ); + } +} diff --git a/app/scripts/components/common/FlexTable.css b/app/scripts/components/common/FlexTable.css new file mode 100644 index 0000000000..c05d32354c --- /dev/null +++ b/app/scripts/components/common/FlexTable.css @@ -0,0 +1,92 @@ +@import 'dux/css/colors.css'; +@import 'dux/css/box.css'; + +.defaultBorder { + border: 1px solid var(--secondary-5); +} + +.inACardBorder { + border-bottom: 1px solid var(--secondary-5); +} + +.flexTable { + border-radius: 3px; + color: var(--secondary-2); + display: flex; + flex-flow: column; + justify-content: center; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + margin-bottom: 1.25rem; +} + +.flexHeader { + background: #f1f6fb; + border-bottom: 1px solid var(--secondary-5); + border-top-left-radius: var(--global-radius); + border-top-right-radius: var(--global-radius); + display: flex; + font-weight: 400; + font-size: 1rem; + padding: 0.3rem; + width: 100%; +} + +.flexRow { + background: var(--white); + border-bottom: 1px solid #e5e5e5; + display: flex; + padding: 0.3rem; + width: 100%; +} + +.flexRow:nth-child(2n+1) { + background: #f9f9f9; +} + +.flexRow:last-child { + border-bottom: 0; + border-bottom-right-radius: var(--global-radius); + border-bottom-left-radius: var(--global-radius); +} + +.selectableFlexRow { + composes: flexRow; + &:hover { + background: var(--silver); + cursor: pointer; + } +} + +.flexItem { + display: flex; + flex-flow: row nowrap; + flex-basis: 0; + padding-left: 1rem; + padding-right: 1rem; + word-break: break-word; +} + +.flexItemPadding { + padding-top: .75rem; + padding-bottom: .75rem; +} + +.flexEnd { + justify-content: flex-end; +} + +@each $num in 1, 2, 3, 4, 5, 6 { + .flexItemGrow$(num) { + flex-grow: $num; + } +} + +.error { + border: 1px solid var(--primary-5); +} + +.success { + border: 1px solid var(--primary-2); +} diff --git a/app/scripts/components/common/FlexTable.jsx b/app/scripts/components/common/FlexTable.jsx new file mode 100644 index 0000000000..0e9b2bfb5b --- /dev/null +++ b/app/scripts/components/common/FlexTable.jsx @@ -0,0 +1,113 @@ +'use strict'; + +import React, { Component, PropTypes } from 'react'; +const { number, bool, func, string } = PropTypes; +import includes from 'lodash/collection/includes'; +import styles from './FlexTable.css'; +import classnames from 'classnames'; + +export class FlexTable extends Component { + static propTypes = { + success: bool, + error: bool, + isWrappedInACard: bool + }; + + static defaultProps = { + isWrappedInACard: false + }; + + render() { + const { error, isWrappedInACard, success } = this.props; + const tableClass = classnames({ + [styles.flexTable]: true, + [styles.defaultBorder]: !isWrappedInACard, + [styles.inACardBorder]: isWrappedInACard, + [styles.success]: success, + [styles.error]: error + }); + + return ( +
    + {this.props.children} +
    + ); + } +} + +export class FlexRow extends Component { + static propTypes = { + onMouseOver: func, + onMouseLeave: func, + onClick: func, + selectable: bool, + className: string + } + + render() { + const { + onClick, + onMouseOver, + onMouseLeave, + selectable + } = this.props; + + let className = (selectable) ? styles.selectableFlexRow : styles.flexRow; + if (this.props.className) { + className += ` ${this.props.className}`; + } + + return ( +
    + {this.props.children} +
    + ); + } +} + +export class FlexHeader extends Component { + + render() { + return ( +
    + {this.props.children} +
    + ); + } +} + +export class FlexItem extends Component { + + static propTypes = { + grow: number, + end: bool, + noPadding: bool + } + + static defaultProps = { + grow: 1, + noPadding: false + } + + render() { + //Optional itemClass usually used for setting widths + //noPadding removes top and bottom padding + const { grow, end, noPadding } = this.props; + + const itemClass = classnames({ + [styles.flexItem]: true, + [styles.flexEnd]: end, + [styles.flexItemPadding]: !noPadding, + [styles[`flexItemGrow${grow}`]]: includes([1, 2, 3, 4, 5, 6], grow) + }); + + return ( +
    + {this.props.children} +
    + ); + } +} diff --git a/app/scripts/components/common/FontAwesome.jsx b/app/scripts/components/common/FontAwesome.jsx new file mode 100644 index 0000000000..7a09848dd1 --- /dev/null +++ b/app/scripts/components/common/FontAwesome.jsx @@ -0,0 +1,54 @@ +'use strict'; + +import React, { Component, PropTypes } from 'react'; +const { oneOf, string, bool } = PropTypes; +import classnames from 'classnames'; +import _ from 'lodash'; +var debug = require('debug')('FontAwesomeIcon'); + +const sizes = ['lg', '2x', '3x', '4x', '5x']; +const animations = ['spin', 'pulse']; +const flips = ['horizontal', 'vertical']; +const rotations = [90, 180, 270]; + +export default class FontAwesome extends Component { + static propTypes = { + icon: string.isRequired, + + animate: oneOf(animations), + fixedWidth: bool, + flip: oneOf(flips), + invert: bool, + rotate: oneOf(rotations), + size: oneOf(sizes), + stack: bool + } + render() { + + const { + animate, + fixedWidth, + flip, + icon, + invert, + rotate, + size, + stack + } = this.props; + + const classes = classnames({ + 'fa': true, + 'fa-fw': fixedWidth, + 'fa-inverse': invert, + [icon]: true, + [`fa-${animate}`]: _.includes(animations, animate), + [`fa-${size}`]: _.includes(sizes, size) && !stack, + [`fa-flip-${flip}`]: _.includes(flips, flip), + [`fa-rotate-${rotate}`]: _.includes(rotations, rotate), + [`fa-stack-${size}`]: _.includes(sizes, size) && stack + }); + + return (); + } +} + diff --git a/app/scripts/components/common/LiLink.jsx b/app/scripts/components/common/LiLink.jsx new file mode 100644 index 0000000000..e97162573f --- /dev/null +++ b/app/scripts/components/common/LiLink.jsx @@ -0,0 +1,17 @@ +'use strict'; + +import React from 'react'; +const debug = require('debug')('LiLink: '); +import { Link } from 'react-router'; + +export default class LiLink extends Link { + isActive() { + const { to, query, onlyActiveOnIndex } = this.props; + return this.context.history.isActive(to, query, onlyActiveOnIndex); + } +//In order to show active state on nav links, we need the active class on the li element +//Dependant on Foundation .active class + render() { + return
  • {super.render()}
  • ; + } +} diff --git a/app/scripts/components/common/ListSelector.css b/app/scripts/components/common/ListSelector.css new file mode 100644 index 0000000000..fb0cfaa9e4 --- /dev/null +++ b/app/scripts/components/common/ListSelector.css @@ -0,0 +1,32 @@ +@import "dux/css/colors.css"; +@import "dux/css/box.css"; + +.header { + font-weight: 400; + padding: 0.5rem; + margin: 0; + background-color: var(--silver); + border: 1px solid var(--secondary-5); + border-top-left-radius: var(--global-radius); + border-top-right-radius: var(--global-radius); +} + +.listSelectorItems { + background: #fff; + list-style: none; + padding: 0; + margin: 0; + border: 1px solid var(--secondary-5); + border-bottom-left-radius: var(--global-radius); + border-bottom-right-radius: var(--global-radius); +} + +.listSelectorItems li { + padding: .5rem; + border-bottom: 1px solid var(--secondary-5); +} + +.listSelectorItems li:hover { + background-color: var(--silver); + cursor: pointer; +} \ No newline at end of file diff --git a/app/scripts/components/common/ListSelector.jsx b/app/scripts/components/common/ListSelector.jsx new file mode 100644 index 0000000000..b6fc7f132c --- /dev/null +++ b/app/scripts/components/common/ListSelector.jsx @@ -0,0 +1,33 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +import { StrippedModule } from 'dux'; +import styles from './ListSelector.css'; +import _ from 'lodash'; + +const { string, arrayOf, node } = PropTypes; + +export default class ListSelector extends Component { + + static propTypes = { + header: node, + items: arrayOf(node) + } + + render() { + var header = null; + if (_.isString(this.props.header)) { + header = (
    {this.props.header}
    ); + } else if (_.isObject(this.props.header)) { + header =
    {this.props.header}
    ; + } + + return ( + + {header} +
      + {this.props.items} +
    +
    ); + } +} diff --git a/app/scripts/components/common/Pagination.jsx b/app/scripts/components/common/Pagination.jsx new file mode 100644 index 0000000000..213cc40157 --- /dev/null +++ b/app/scripts/components/common/Pagination.jsx @@ -0,0 +1,119 @@ +'use strict'; + +import React, { + PropTypes, + createClass +} from 'react'; +import classnames from 'classnames'; +import _ from 'lodash'; +var debug = require('debug')('Pagination'); + +function noop(e) { + e.preventDefault(); +} + +var Page = createClass({ + displayName: 'Page', + propTypes: { + _onClick: PropTypes.func.isRequired, + pageNumber: PropTypes.number.isRequired, + currentPage: PropTypes.number.isRequired + }, + render() { + var classes = classnames({ + 'current': this.props.currentPage === this.props.pageNumber + }); + + return ( +
  • + {this.props.pageNumber} +
  • + ); + } +}); + +function mkPage(pageNumber) { + return ( + + ); +} + +export default createClass({ + displayName: 'Pagination', + propTypes: { + next: PropTypes.string, + prev: PropTypes.string, + currentPage: PropTypes.number.isRequired, + pageSize: PropTypes.number.isRequired, + onChangePage: PropTypes.func.isRequired + }, + _onClick(pageNumber) { + return (e) => { + //Check if currentPage is to the right of ellipsis and the last from the beginning or the end + //based on whether it is the beginning side or end side, update the page ranges + e.preventDefault(); + this.props.onChangePage(pageNumber); + }; + }, + render() { + var paginationComponent; + // is there a page before this one? + var previousPageExists = !!this.props.prev; + // is there a page after this one? + var nextPageExists = !!this.props.next; + var currentPage = [this.props.currentPage].map(mkPage, this); + var prevClasses = classnames({ + 'arrow': true, + 'unavailable': !previousPageExists + }); + + var nextClasses = classnames({ + 'arrow': true, + 'unavailable': !nextPageExists + }); + + var prevPage = null; + if (previousPageExists) { + prevPage = [( +
  • «
  • + ), ( +
  • {this.props.currentPage - 1}
  • + )]; + } + + var nextPage = null; + if (nextPageExists) { + nextPage = [( +
  • {this.props.currentPage + 1}
  • + ), ( +
  • »
  • + )]; + } + + if (nextPageExists || previousPageExists) { + paginationComponent = ( +
      + {prevPage} + {currentPage} + {nextPage} +
    + ); + } + + return ( +
    + {paginationComponent} +
    + ); + } +}); diff --git a/app/scripts/components/common/PendingDeleteRepositoryItem.css b/app/scripts/components/common/PendingDeleteRepositoryItem.css new file mode 100644 index 0000000000..ba66ee2e53 --- /dev/null +++ b/app/scripts/components/common/PendingDeleteRepositoryItem.css @@ -0,0 +1,84 @@ +@import "dux/css/colors.css"; +@import "dux/css/box.css"; + +.pendingDeleteItem { + background: var(--white); + border: 1px solid var(--secondary-5); + border-radius: var(--global-radius); + color: var(--secondary-2); + display: flex; + flex-flow: row; + margin: 1rem 0; + font-weight: 500; + cursor: not-allowed; + + &:hover { + border-color: var(--primary-5); + .action { + background-color: var(--primary-1); + color: white; + border-color: var(--primary-1); + } + } +} + +.repoName { + color: var(--primary-1); +} +.avatar { + width: 50px; + height: 50px; + border-radius: var(--global-radius); +} +.officialAvatar { + background: var(--primary-color); +} + +/* sections */ +.section { + display: flex; + border-left: 1px solid var(--secondary-5); + padding: 1rem; +} +.head { + composes: section; + flex-grow: 1; + flex-flow: row; + &:first-child { + border-left: 0; + } +} + +.action { + composes: section; + background-color: var(--secondary-5); + color: var(--secondary-4); + flex-flow: column; + i { + position: relative; + top: 0.5rem; + left: 1.2rem; + } +} +.text { + margin-top: 1.2rem; + margin-left: 0.4rem; + font-size: 0.7rem; +} + +/* "Visibility" Classes */ +.title { + padding-left: 1rem; + padding-right: 1rem; +} +.labels { + color: var(--secondary-4); +} +.pendingDelete { + color: var(--primary-5); +} +/* utility */ +.flexible { + display: flex; + flex: 1; +} diff --git a/app/scripts/components/common/PendingDeleteRepositoryItem.jsx b/app/scripts/components/common/PendingDeleteRepositoryItem.jsx new file mode 100644 index 0000000000..228864aa0d --- /dev/null +++ b/app/scripts/components/common/PendingDeleteRepositoryItem.jsx @@ -0,0 +1,58 @@ +'use strict'; + +import styles from './PendingDeleteRepositoryItem.css'; +import React, { PropTypes, Component } from 'react'; +let { func, string } = PropTypes; +import classnames from 'classnames'; +import FA from 'common/FontAwesome'; +import { mkAvatarForNamespace, isOfficialAvatarURL } from 'utils/avatar'; + +/** + * PendingDeleteRepositoryItem will show only the name and namespace + */ + +export default class PendingDeleteRepositoryItem extends Component { + + static propTypes = { + namespace: string.isRequired, + name: string.isRequired + } + + render() { + + const { + name, + namespace + } = this.props; + + const repoDisplayName = namespace + '/' + name; + const avatar = mkAvatarForNamespace(namespace, name); + const avatarClass = classnames({ + [styles.avatar]: true, + [styles.officialAvatar]: isOfficialAvatarURL(avatar) + }); + + return ( +
  • +
    +
    +
    +
    +
    +
    { repoDisplayName }
    + Deleting... +
    +
    +
    +
    + +
    DETAILS
    +
    +
    +
  • + ); + } +} diff --git a/app/scripts/components/common/RepositoriesList.jsx b/app/scripts/components/common/RepositoriesList.jsx new file mode 100644 index 0000000000..a8511c69c4 --- /dev/null +++ b/app/scripts/components/common/RepositoriesList.jsx @@ -0,0 +1,113 @@ +'use strict'; + +import React, { PropTypes, createClass } from 'react'; +const { array, bool, element, func, oneOfType } = PropTypes; +import PendingDeleteRepositoryItem from './PendingDeleteRepositoryItem'; +import RepositoryListItem from './RepositoryListItem'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +import has from 'lodash/object/has'; + +var debug = require('debug')('RepositoriesList'); + +function mkRepoListItem(repo) { + + const { + is_automated, + is_official, + is_private, + name, + namespace, + pull_count, + repo_name, + star_count, + status + } = repo; + /** + * TODO: after snakecase to camelcase in hub-js-sdk is done remove + * explicit props + */ + if(has(repo, 'is_private')) { + repo.isPrivate = is_private; + } + if(has(repo, 'is_official')) { + repo.isOfficial = is_official; + } + if(has(repo, 'is_offical')) { + // This is a legit typo in the ES results + repo.isOfficial = repo.is_offical; + } + if(has(repo, 'repo_name')) { + repo.repoName = repo_name; + } + + let key = repo_name || (namespace + '/' + name); + + if (status === PENDING_DELETE) { + return ( + + ); + } else { + return ( + + ); + } +} + +var BlankSlate = createClass({ + displayName: 'BlankSlate', + render() { + return ( +
    +
    +
    +

    No Repositories Yet.

    + Create Repository +
    +
    +
    + ); + } +}); + +export default createClass({ + displayName: 'RepositoriesList', + propTypes: { + blankSlate: element, + repos: array.isRequired + }, + getDefaultProps() { + return { + blankSlate: (), + repos: [] + }; + }, + render() { + const { + blankSlate, + repos + } = this.props; + + let content = blankSlate; + + if(repos && repos.length > 0) { + content = ( +
    +
      + {repos.map(mkRepoListItem, this)} +
    +
    + ); + } + + return ( +
    + {content} +
    + ); + } +}); diff --git a/app/scripts/components/common/RepositoryListItem.css b/app/scripts/components/common/RepositoryListItem.css new file mode 100644 index 0000000000..c1ee0eeca1 --- /dev/null +++ b/app/scripts/components/common/RepositoryListItem.css @@ -0,0 +1,111 @@ +@import "dux/css/colors.css"; +@import "dux/css/box.css"; + +.repositoryListItem { + background: var(--white); + border: 1px solid var(--secondary-5); + border-radius: var(--global-radius); + color: var(--secondary-2); + display: flex; + flex-flow: row; + margin: 1rem 0; + font-weight: 500; + + &:hover { + border-color: var(--primary-1); + .action { + background-color: var(--primary-1); + color: white; + border-color: var(--primary-1); + } + } +} +.repoName { + color: var(--primary-color); +} +.avatar { + width: 60px; + height: 60px; + border-radius: var(--global-radius); + display: flex; +} +.img { + align-self: center; +} +.officialAvatar { + background: var(--primary-color); +} + +/* sections */ +.section { + display: flex; + border-left: 1px solid var(--secondary-5); + padding: 1rem; +} +.head { + composes: section; + flex-grow: 1; + flex-flow: row; + &:first-child { + border-left: 0; + } +} +.stats { + composes: section; + min-width: 100px; + text-align: center; + flex-flow: row; +} +.action { + composes: section; + background-color: var(--secondary-5); + color: var(--secondary-4); + flex-flow: column; + i { + position: relative; + top: 0.5rem; + left: 1.2rem; + } +} +.text { + margin-top: 1.2rem; + margin-left: 0.4rem; + font-size: 0.7rem; +} +/* Stats Boxes */ +.labelValue { + padding-top: 0.25rem; + margin: 0 auto; +} +.value { + margin-bottom: 0.25rem; + color: var(--secondary-4); +} +.subLabel { + color: var(--secondary-4); +} +/* "Visibility" Classes */ +.title { + padding-left: 1rem; + padding-right: 1rem; +} +.labels { + color: var(--secondary-4); +} +/*.official { + color: var(--primary-1); +}*/ +.public { + color: color(var(--primary-1) blackness(50%)); +} +.private { + color: var(--secondary-4); +} +.automated { + color: var(--secondary-4); +} +/* utility */ +.flexible { + display: flex; + flex: 1; +} diff --git a/app/scripts/components/common/RepositoryListItem.jsx b/app/scripts/components/common/RepositoryListItem.jsx new file mode 100644 index 0000000000..fbedea3372 --- /dev/null +++ b/app/scripts/components/common/RepositoryListItem.jsx @@ -0,0 +1,326 @@ +'use strict'; + +import styles from './RepositoryListItem.css'; +import React, { PropTypes, createClass, Component } from 'react'; +let { string, number } = PropTypes; +import { Link } from 'react-router'; +import classnames from 'classnames'; +import FA from 'common/FontAwesome'; +import numeral from 'numeral'; +import { mkAvatarForNamespace, isOfficialAvatarURL } from 'utils/avatar'; +import isFinite from 'lodash/lang/isFinite'; + +var debug = require('debug')('RepositoryListItem'); + +/* TODO: Should dedupe these render methods */ + +/** + * RepositoryListItem has to handle two versions of a Repository data + * structure. One comes from ElasticSearch, and one comes from Postgres + * + * ElasticSearch: + * + * -- Official + * { + * "repo_name": "ubuntu", + * "is_offical": true, + * "is_automated": false, + * "short_description": "", + * "repo_owner": null, + * "pull_count": 9, + * "star_count": 2 + * } + * + * -- Normal + * { + * "repo_name": "cpuguy83/ubuntu", + * "is_offical": false, + * "is_automated": false, + * "short_description": "ubuntu but more awesome", + * "repo_owner": null, + * "pull_count": 2, + * "star_count": 0 + * } + * + * Postgres: + * + * --Official + * --Normal + * { + * "last_updated": null, + * "pull_count": 9, + * "star_count": 2, + * "can_edit": true, + * "is_automated": false, + * "is_private": false, + * "full_description": null, + * "description": "", + * "status": 1, + * "namespace": "library", + * "name": "ubuntu", + * "user": "jlhawn" + * } + */ + +class Stats extends Component { + static propTypes: { + title: string, + value: number, + inBuckets: bool + } + + formatNumber(n) { + if (n < 1000) { + return numeral(n).format('0a'); + } + return numeral(n).format('0.0a').toUpperCase(); + } + + /** + * Buckets to format the number: + * 10M + + * 5M + + * 1M + + * 500k + + * 100k + + * 50k + + * 10k + + */ + formatBucketedNumber(n) { + if (n < 10000) { + return this.formatNumber(n); + } else if(n < 50000) { + return '10K+'; + } else if(n < 100000) { + return '50K+'; + } else if(n < 500000) { + return '100K+'; + } else if(n < 1000000) { + return '500K+'; + } else if(n < 5000000) { + return '1M+'; + } else if(n < 10000000) { + return '5M+'; + } else { + return '10M+'; + } + } + + render() { + const { value, title, inBuckets } = this.props; + + let statValue; + + if (isFinite(value)) { + if (inBuckets) { + statValue = this.formatBucketedNumber(value); + } else { + statValue = this.formatNumber(value); + } + } else { + return null; + } + + return ( +
    +
    +
    {statValue}
    +
    {title.toUpperCase()}
    +
    +
    + ); + } +} + +var PostgresRepo = createClass({ + displayName: 'PostgresRepo', + propTypes: { + namespace: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + status: PropTypes.number, + description: PropTypes.string, + fullDescription: PropTypes.string, + isPrivate: PropTypes.bool, + isAutomated: PropTypes.bool, + isOfficial: PropTypes.bool, + starCount: PropTypes.number, + pullCount: PropTypes.number + }, + render() { + /** + * Since Official Repos don't show their namespace (library), + * we use this to chop that part off if we need to by redefining + * this variable. + */ + var repoDisplayName = this.props.namespace + '/' + this.props.name; + var linkTo = `/r/${repoDisplayName}/`; + + /** + * visibility can be 'official', 'public' or 'private', with an + * appropriate class to match. + */ + var visibility = null; + var visibilityClasses = {}; + /** + * autobuild is set if this repository is an automated build, otherwise + * we rely on `null` to render nothing. + */ + var autobuild = null; + + if(this.props.isOfficial || this.props.namespace === 'library') { + + visibility = 'official'; + visibilityClasses = classnames({ + [styles.official]: true + }); + repoDisplayName = this.props.name || this.props.repo_name; + linkTo = `/_/${this.props.name}/`; + + } else { + + visibility = 'private'; + if(!this.props.isPrivate) { + visibility = 'public'; + } + visibilityClasses = classnames({ + [styles.public]: !this.props.isPrivate, + [styles.private]: this.props.isPrivate + }); + + if(this.props.isAutomated) { + autobuild = | automated build; + } + } + + const avatar = mkAvatarForNamespace(this.props.namespace, this.props.name); + const avatarClass = classnames({ + [styles.avatar]: true, + [styles.officialAvatar]: isOfficialAvatarURL(avatar) + }); + + return ( +
  • + +
    +
    +
    +
    +
    {repoDisplayName}
    + {visibility} + {autobuild}
    +
    +
    + + +
    + +
    DETAILS
    +
    + +
  • + ); + } +}); + + +var ElasticRepo = createClass({ + displayName: 'ElasticRepo', + propTypes: { + repoName: PropTypes.string.isRequired, + isOfficial: PropTypes.bool.isRequired, + isAutomated: PropTypes.bool.isRequired, + pullCount: PropTypes.number.isRequired, + starCount: PropTypes.number.isRequired + }, + render() { + var repoDisplayName = this.props.repoName; + var linkTo; + + /** + * visibility can be 'official', 'public' or 'private', with an + * appropriate class to match. + */ + var visibility = null; + var visibilityClasses = {}; + /** + * autobuild is set if this repository is an automated build, otherwise + * we rely on `null` to render nothing. + */ + var autobuild = null; + + if(this.props.isOfficial) { + + visibility = 'official'; + visibilityClasses = classnames({ + [styles.official]: true + }); + linkTo = `/_/${this.props.repoName}/`; + + } else { + + let [namespace, splat] = this.props.repoName.split('/'); + linkTo = `/r/${namespace}/${splat}/`; + + if(this.props.isPrivate) { + visibility = 'private'; + } else { + visibility = 'public'; + } + visibilityClasses = classnames({ + [styles.public]: !this.props.isPrivate, + [styles.private]: this.props.isPrivate + }); + + if(this.props.isAutomated) { + autobuild = | automated build; + } + } + + const avatar = mkAvatarForNamespace(this.props.namespace, this.props.repoName); + const avatarClass = classnames({ + [styles.avatar]: true, + [styles.officialAvatar]: isOfficialAvatarURL(avatar) + }); + + return ( +
  • + +
    +
    +
    +
    {repoDisplayName}
    +
    + {visibility} + {autobuild}
    +
    +
    + + +
    + +
    DETAILS
    +
    + +
  • + ); + } +}); + +export default class RepositoryListItem extends Component { + render() { + debug(this.props); + if(this.props.repoName) { + // Render a repo from ElasticSearch + return ( + + ); + } else { + // Render a repo from Postgres + return ( + + ); + } + } +} diff --git a/app/scripts/components/common/RepositoryNameInput.jsx b/app/scripts/components/common/RepositoryNameInput.jsx new file mode 100644 index 0000000000..4a53602878 --- /dev/null +++ b/app/scripts/components/common/RepositoryNameInput.jsx @@ -0,0 +1,42 @@ +'use strict'; + +import React, { Component, PropTypes } from 'react'; +import SimpleInput from './SimpleInput'; +const {array, func, string} = PropTypes; + +export default class RepositoryNameInput extends Component { + static propTypes = { + namespaces: array.isRequired, + selectedNamespace: string.isRequired, + repoName: string.isRequired, + onRepoNameChange: func.isRequired, + onNamespaceChange: func.isRequired, + inputClass: string + } + + render() { + + var namespaceOptions = this.props.namespaces.map(function(item, idx) { + return (); + }); + + return ( +
    +
    + +
    +
    + +
    +
    + ); + } +} diff --git a/app/scripts/components/common/RouteNotFound404Page.css b/app/scripts/components/common/RouteNotFound404Page.css new file mode 100644 index 0000000000..e1f73119e0 --- /dev/null +++ b/app/scripts/components/common/RouteNotFound404Page.css @@ -0,0 +1,40 @@ +@import 'dux/css/colors.css'; +.wrap { + background: var(--docker-dark); + height: 100%; + text-align: center; +} + +.messageModule { + animation: fadein 0.3s; + padding: 1.5rem 0 0 0; + margin: 4rem 0; + width: 800px; +} + +@keyframes fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.heading { + color: #71859d; + font-size: 6rem; + font-weight: 400; +} + +.subheading { + color: #cbd5df; + font-weight: 400; +} + +.message { + color: #71859d; + margin: 0 auto; + padding: .5rem; + font-size: 1.1rem; +} diff --git a/app/scripts/components/common/RouteNotFound404Page.jsx b/app/scripts/components/common/RouteNotFound404Page.jsx new file mode 100644 index 0000000000..1eb6acedff --- /dev/null +++ b/app/scripts/components/common/RouteNotFound404Page.jsx @@ -0,0 +1,26 @@ +'use strict'; + +import React from 'react'; +import { Module } from 'dux'; +import styles from './RouteNotFound404Page.css'; + +var RouteNotFound404Page = React.createClass({ + displayName: 'RouteNotFound404Page', + render: function() { + return ( +
    +
    +
    +
    +

    404

    +

    Page Not Found

    +

    Sorry, but the page you were trying to view does not exist.

    +
    +
    +
    +
    + ); + } +}); + +module.exports = RouteNotFound404Page; diff --git a/app/scripts/components/common/Row.jsx b/app/scripts/components/common/Row.jsx new file mode 100644 index 0000000000..7d73456638 --- /dev/null +++ b/app/scripts/components/common/Row.jsx @@ -0,0 +1,12 @@ +'use strict'; + +import React, { createClass } from 'react'; + +export default createClass({ + displayName: 'Row', + render() { + return ( +
    {this.props.children}
    + ); + } +}); diff --git a/app/scripts/components/common/Sections.css b/app/scripts/components/common/Sections.css new file mode 100644 index 0000000000..7c52eabcbb --- /dev/null +++ b/app/scripts/components/common/Sections.css @@ -0,0 +1,3 @@ +.section { + padding: 1rem 2rem; +} diff --git a/app/scripts/components/common/Sections.jsx b/app/scripts/components/common/Sections.jsx new file mode 100644 index 0000000000..57ed33e7b7 --- /dev/null +++ b/app/scripts/components/common/Sections.jsx @@ -0,0 +1,67 @@ +'use strict'; + +import React, { Component, PropTypes } from 'react'; +const { string, node, object, oneOfType, bool } = PropTypes; +import Card, { Block } from '@dux/element-card'; +import styles from './Sections.css'; + +export class SplitSection extends Component { + static propTypes = { + title: string, + subtitle: node, + /** + * module is a props passthrough for precision control over how + * Module displays. An example is setting the `intent` on a module. + */ + module: oneOfType([object, bool]) + }; + static defaultProps = { + module: true + }; + + render() { + var children; + if (this.props.module) { + children = ( + + + {this.props.children} + + + ); + } else { + children = this.props.children; + } + + return ( +
    +
    +
    {this.props.title}
    +
    + {this.props.subtitle} +
    +
    +
    + { children } +
    +
    + ); + } +} + +export class FullSection extends Component { + static propTypes = { + title: string + }; + + render() { + return ( +
    +
    +
    {this.props.title}
    +
    + {this.props.children} +
    + ); + } +} diff --git a/app/scripts/components/common/SimpleInput.css b/app/scripts/components/common/SimpleInput.css new file mode 100644 index 0000000000..562b6227f7 --- /dev/null +++ b/app/scripts/components/common/SimpleInput.css @@ -0,0 +1,23 @@ +@import "dux/css/colors.css"; +@import "dux/css/box.css"; + +input.default { + background-color: var(--white); + border-radius: var(--global-radius); +&:focus { + border: 1px solid var(--primary-1); + background-color: var(--white); + } +} + +input.error { + border: 1px solid var(--primary-5); + border-radius: var(--global-radius); +&:focus { + border: 1px solid var(--primary-5); + } +} + +.inputDiv { + width: 100%; +} \ No newline at end of file diff --git a/app/scripts/components/common/SimpleInput.jsx b/app/scripts/components/common/SimpleInput.jsx new file mode 100644 index 0000000000..bf8af06b4c --- /dev/null +++ b/app/scripts/components/common/SimpleInput.jsx @@ -0,0 +1,100 @@ +'use strict'; + +import React, { + Component, + PropTypes +} from 'react'; +import { findDOMNode } from 'react-dom'; +import styles from './SimpleInput.css'; +const { any, func, string, bool, oneOf } = PropTypes; +const debug = require('debug')('SimpleInput'); + +/* + * A simpler version of DUXInput without errors / alert boxes + * that is a full field (not a line) + */ +export default class SimpleInput extends Component { + static propTypes = { + autoFocus: bool, + hasError: bool, + name: string, + onChange: func.isRequired, + placeholder: string, + readOnly: bool, + type: oneOf('hidden text password email search'.split(' ')), + value: any.isRequired + } + + static defaultProps = { + hasError: false, + onChange() { + debug('No onChange function set for Input'); + }, + value: '', + placeholder: '', + type: 'text', + name: '' + } + + focusInput = () => { + findDOMNode(this.refs.input).focus(); + } + + state = { + value: '' + } + + _onChange = (e) => { + // Set local state + const { value } = e.target; + this.setState({ value }); + + // Pass control to onChange controller. Usually a form. + this.props.onChange(e); + } + + componentWillReceiveProps(props) { + /** + * State should be tracked globally in a parent component. As + * such, we need to check the passed in prop against the current + * (local) state value to avoid overwriting the input value. + * + * This prevents a bug which resulted in the cursor being "jumped" + * to the end of an input after every character. + */ + if (props.value !== this.state.value) { + this.setState({ + value: props.value + }); + } + } + + componentDidMount() { + const { autoFocus } = this.props; + if (autoFocus) { + this.focusInput(); + } + } + + render() { + const { hasError, + name, + placeholder, + readOnly, + type, + value } = this.props; + const inputClass = hasError ? styles.error : styles.default; + return ( +
    + +
    + ); + } +} diff --git a/app/scripts/components/common/SimpleTextArea.css b/app/scripts/components/common/SimpleTextArea.css new file mode 100644 index 0000000000..280e2ae2d6 --- /dev/null +++ b/app/scripts/components/common/SimpleTextArea.css @@ -0,0 +1,5 @@ +@import "dux/css/box.css"; + +.textarea { + border-radius: var(--global-radius); +} \ No newline at end of file diff --git a/app/scripts/components/common/SimpleTextArea.jsx b/app/scripts/components/common/SimpleTextArea.jsx new file mode 100644 index 0000000000..d6c76e12c9 --- /dev/null +++ b/app/scripts/components/common/SimpleTextArea.jsx @@ -0,0 +1,89 @@ +'use strict'; + +import React, { + Component, + PropTypes +} from 'react'; +import { findDOMNode } from 'react-dom'; +import styles from './SimpleTextArea.css'; +const { any, func, string, number } = PropTypes; +const debug = require('debug')('SimpleTextArea'); + +/* + * A simpler version of DUXInput without errors / alert boxes + * that is a text area + */ +export default class SimpleTextArea extends Component { + static propTypes = { + cols: number, + name: string, + onChange: func.isRequired, + placeholder: string, + rows: number, + value: any.isRequired + } + + static defaultProps = { + onChange() { + debug('No onChange function set for Input'); + }, + cols: 2, + rows: 100, + value: '', + placeholder: '', + name: '' + } + + focusInput = () => { + findDOMNode(this.refs.textarea).focus(); + } + + state = { + value: '' + } + + _onChange = (e) => { + // Set local state + const { value } = e.target; + this.setState({ value }); + + // Pass control to onChange controller. Usually a form. + this.props.onChange(e); + } + + componentWillReceiveProps(props) { + /** + * State should be tracked globally in a parent component. As + * such, we need to check the passed in prop against the current + * (local) state value to avoid overwriting the input value. + * + * This prevents a bug which resulted in the cursor being "jumped" + * to the end of an input after every character. + */ + if (props.value !== this.state.value) { + this.setState({ + value: props.value + }); + } + } + + render() { + const { cols, + rows, + name, + placeholder, + value } = this.props; + return ( +
    + + ); + const submit = this.updateLongDescription; + return ( + + +
    +
    + {input} + {maybeError} +
    +
      +
    • + +
    • +
    • + +
    • +
    +
    +
    +
    +
    +
    + ); + } + + render() { + const { + canEdit, + isEditing, + successfulSave, + fields + } = this.props; + const { longDescription } = this.props.values; + let cardHeading = `Full Description`; + + if (isEditing) { + cardHeading = `Full Description (Optional, Limit 25,000 Characters)`; + return this.renderForm(cardHeading); + } else { + const headingActions = canEdit ? [{ + key: 'edit', + icon: 'fa-edit', + action: this.toggleEditMode + }] : []; + let formattedDescription; + if (!longDescription) { + formattedDescription = ( +
    +

    + Full description is empty for this repo. +

    +
    ); + } else { + formattedDescription = ({longDescription}); + } + const maybeSuccessClass = classnames({ + [styles.successfulSave]: successfulSave + }); + + let maybeSuccess = ; + if (successfulSave) { + maybeSuccess = ( +
    + {fields.longDescription.success} +
    + ); + } + return ( + + +
    + {maybeSuccess} + {formattedDescription} +
    +
    +
    + ); + } + } +} + +export default connectToStores(RepoFullDescription, + [RepoDetailsLongDescriptionFormStore], + function({ getStore }, props) { + return getStore(RepoDetailsLongDescriptionFormStore).getState(); + }); diff --git a/app/scripts/components/repo/repo_details/info/RepoShortDescription.css b/app/scripts/components/repo/repo_details/info/RepoShortDescription.css new file mode 100644 index 0000000000..4f653e3006 --- /dev/null +++ b/app/scripts/components/repo/repo_details/info/RepoShortDescription.css @@ -0,0 +1,32 @@ +@import "dux/css/colors.css"; +@import "dux/css/box.css"; + +input.textArea { + background-color: var(--white); + &:focus { + border: 1px solid var(--primary-1); + background-color: var(--white); + } +} +.text { + margin-bottom: var(--default-margin); +} +input.error { + border: 1px solid var(--primary-5); + &:focus { + border: 1px solid var(--primary-5); + } +} +.errorText { + composes: text; + color: var(--primary-5); +} + +.successText { + composes: text; + color: var(--primary-2); +} + +.successfulSave { + border: 1px solid var(--primary-2); +} \ No newline at end of file diff --git a/app/scripts/components/repo/repo_details/info/RepoShortDescription.jsx b/app/scripts/components/repo/repo_details/info/RepoShortDescription.jsx new file mode 100644 index 0000000000..a300c5d028 --- /dev/null +++ b/app/scripts/components/repo/repo_details/info/RepoShortDescription.jsx @@ -0,0 +1,179 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import attemptChangeShortDescription from 'actions/attemptChangeShortDescription'; +import RepoDetailsShortDescriptionFormStore from 'stores/RepoDetailsShortDescriptionFormStore'; +import shortDescriptionUpdateFormField from 'actions/shortDescriptionUpdateFormField'; +import toggleShortDescriptionEdit from 'actions/toggleShortDescriptionEdit'; +import Card, { Block } from '@dux/element-card'; +import { Button } from 'dux'; +import classnames from 'classnames'; +import styles from './RepoShortDescription.css'; +import SimpleInput from 'common/SimpleInput'; +const { string, object, bool, number, func, shape } = PropTypes; +const debug = require('debug')('RepoShortDescription'); + + +class RepoShortDescription extends Component { + static propTypes = { + canEdit: bool.isRequired, + name: string.isRequired, + namespace: string.isRequired, + JWT: string, + isEditing: bool.isRequired, + successfulSave: bool, + values: shape({ + shortDescription: string + }) + } + + static contextTypes = { + executeAction: func.isRequired + } + + static defaultProps = { + successfulSave: false, + isEditing: false + } + + toggleEditMode = (e) => { + this.context.executeAction(toggleShortDescriptionEdit, { isEditing: !this.props.isEditing }); + } + + onChange = (fieldKey) => { + return (e) => { + this.context.executeAction(shortDescriptionUpdateFormField, { + fieldKey, + fieldValue: e.target.value + }); + }; + } + + updateShortDescription = (event) => { + event.preventDefault(); + const { JWT, + name, + namespace + } = this.props; + const { shortDescription } = this.props.values; + this.context.executeAction(attemptChangeShortDescription, + { + jwt: JWT, + repoShortName: namespace + '/' + name, + shortDescription: shortDescription + }); + } + renderForm = (cardHeading) => { + let maybeError = ; + const shortDesc = this.props.fields.shortDescription; + const currentShortDesc = this.props.values.shortDescription; + const maxChars = 100; + let hasError = false; + if (shortDesc.hasError) { + /* check for error from request (post submit). success is handled by non-edit card */ + maybeError = (
    {shortDesc.error}
    ); + hasError = true; + } else if (currentShortDesc.length > maxChars) { + /* check for typed input that's too long - not caught until submit otherwise */ + const tooMany = currentShortDesc.length - maxChars; + + const msg = `Maximum ${maxChars} characters. You are over the limit by: ${tooMany}`; + maybeError = (
    {msg}
    ); + hasError = true; + } + const intent = hasError ? 'alert' : 'primary'; + const textAreaClass = classnames({ + [styles.textArea]: true, + [styles.error]: hasError + }); + const input = ( + + ); + const submit = this.updateShortDescription; + return ( + + +
    +
    + {input} + {maybeError} +
    +
      +
    • + +
    • +
    • + +
    • +
    +
    +
    +
    +
    +
    + ); + } + + render() { + const { + canEdit, + isEditing, + successfulSave, + fields + } = this.props; + const { shortDescription } = this.props.values; + let cardHeading = `Short Description`; + + if (isEditing) { + cardHeading = `Short Description (Optional, Limit 100 Characters)`; + return this.renderForm(cardHeading); + } else { + let headingActions = canEdit ? [{ + key: 'edit', + icon: 'fa-edit', + action: this.toggleEditMode + }] : []; + let maybeSuccessClass = classnames({ + [styles.successfulSave]: successfulSave + }); + let maybeSuccess = ; + if (successfulSave) { + maybeSuccess = (
    + {fields.shortDescription.success} +
    ); + } + return ( + + +
    + {maybeSuccess} + {shortDescription || 'Short description is empty for this repo.'} +
    +
    +
    + ); + } + } +} + +export default connectToStores(RepoShortDescription, + [RepoDetailsShortDescriptionFormStore], + function({ getStore }, props) { + return getStore(RepoDetailsShortDescriptionFormStore).getState(); + }); diff --git a/app/scripts/components/repo/repo_details/nautilusUtils.js b/app/scripts/components/repo/repo_details/nautilusUtils.js new file mode 100644 index 0000000000..f6a911cda1 --- /dev/null +++ b/app/scripts/components/repo/repo_details/nautilusUtils.js @@ -0,0 +1,63 @@ +'use strict'; + +const keyMirror = require('keymirror'); +import forEach from 'lodash/collection/forEach'; +import trim from 'lodash/string/trim'; + +export const mkComponentId = (component) => { + const { component: name, version } = component; + const id = version ? `${name}:${version}` : `${name}:`; + return trim(id); +}; + +export const mapCvss = (cvss) => { + if (cvss <= 0) { + return 'secure'; + } + if (cvss < 4) { + return 'minor'; + } + if (cvss < 7) { + return 'major'; + } + return 'critical'; +}; + +// Get a map of component key to its highest CVSS score +// { bgaes: 4.5, } +export const getHighestComponentCvss = (components, vulnerabilities) => { + let highestComponentCvss = {}; + forEach(components, (c, compName) => { + let cvssMax = 0; + forEach(c.vulnerabilities, v => { + let { cvss } = vulnerabilities[v]; + if (cvss > cvssMax) { + cvssMax = cvss; + } + }); + highestComponentCvss[compName] = cvssMax; + }); + return highestComponentCvss; +}; + +// Given a list of severities returns the highest severity string using mapCvss +// Usage: +// getHighestSeverity([0.5, 2, 4]) +// getHighestSeverity(...cvssList) +export const getHighestSeverity = (...cvss) => { + if (cvss.length === 0) { + return -1; + } + let maxScore = 0; + for (let i in cvss) { + if (cvss[i] > maxScore) { + maxScore = cvss[i]; + } + } + return mapCvss(maxScore); +}; + +export const consts = keyMirror({ + FAILED: null, + IN_PROGRESS: null +}); diff --git a/app/scripts/components/repo/repo_details/scannedTag/ComponentGrid.css b/app/scripts/components/repo/repo_details/scannedTag/ComponentGrid.css new file mode 100644 index 0000000000..14c5447c8b --- /dev/null +++ b/app/scripts/components/repo/repo_details/scannedTag/ComponentGrid.css @@ -0,0 +1,5 @@ +.componentGrid { + display: flex; + flex-flow: row wrap; + align-content: flex-start; +} diff --git a/app/scripts/components/repo/repo_details/scannedTag/ComponentGrid.jsx b/app/scripts/components/repo/repo_details/scannedTag/ComponentGrid.jsx new file mode 100644 index 0000000000..0112e09fe7 --- /dev/null +++ b/app/scripts/components/repo/repo_details/scannedTag/ComponentGrid.jsx @@ -0,0 +1,70 @@ +'use strict'; + +import React, { Component, PropTypes } from 'react'; +const { object, array, func, string } = PropTypes; +import { mapCvss, getHighestSeverity } from '../nautilusUtils.js'; +import styles from './ComponentGrid.css'; +import Square from './Square'; +import forEach from 'lodash/collection/forEach'; +import { mkComponentId } from '../nautilusUtils'; + +export default class ComponentGrid extends Component { + static propTypes = { + componentsSortedBySeverity: array.isRequired, + onClick: func, + selectedComponent: string, + vulnerabilities: object + } + + static defaultProps = { + selectedComponent: '' + } + + getNumVulnsBySeverity = (component) => { + const { vulnerabilities: vulns } = this.props; + let numVulns = { + critical: 0, + major: 0, + minor: 0, + secure: 0 + }; + forEach(component.vulnerabilities, (v) => { + numVulns[mapCvss(vulns[v].cvss)]++; + }); + return numVulns; + } + + mkComponentSquare = (component) => { + const { selectedComponent, onClick, vulnerabilities: vulns } = this.props; + const componentFullName = mkComponentId(component); + const isSelected = selectedComponent === componentFullName; + const numVulnsBySeverity = this.getNumVulnsBySeverity(component); + let severity = 'secure'; + if (numVulnsBySeverity.critical) { + severity = 'critical'; + } else if (numVulnsBySeverity.major) { + severity = 'major'; + } else if (numVulnsBySeverity.minor) { + severity = 'minor'; + } + + return ( + + ); + } + + render() { + const { componentsSortedBySeverity } = this.props; + return ( +
    + { componentsSortedBySeverity.map(this.mkComponentSquare) } +
    + ); + } +} diff --git a/app/scripts/components/repo/repo_details/scannedTag/Layer.css b/app/scripts/components/repo/repo_details/scannedTag/Layer.css new file mode 100644 index 0000000000..69374b57f3 --- /dev/null +++ b/app/scripts/components/repo/repo_details/scannedTag/Layer.css @@ -0,0 +1,82 @@ +@import 'dux/css/box.css'; +@import 'dux/css/colors.css'; +/* Note: tooltip CSS is globally styled in styles/vender-overrides/rc-tooltip.css */ + +.grey { + color: #CECED9; +} + +.row { + padding: 1rem 0 1rem 1.5rem; + position: relative; +} + +.unselectedRow { + composes: row; + border-top: 1px solid #E4E4E4; +} + +.selectedRow { + composes: row; + border: 1px solid #E4E4E4; + border-radius: var(--global-radius); +} + +/* remove top border for unselected row following a selected row */ +.selectedRow + .unselectedRow { + border-top: none; +} + +.check { + color: #6BCEC0; +} + +.chevron { + color: #CECED9; + cursor: pointer; + display: inline-block; + float: right; + margin: 6px 0; +} + +.layerSize { + composes: grey; + display: inline-block; + font-size: 0.8rem; +} + +.dockerCommandLine { + text-overflow: ellipsis; + overflow: hidden; + width:100%; + display: inline; + margin-right: 0.5rem; + font-size: 1.1rem; + color: #78828F; +} +a .dockerCommandLine { + color: #22b8eb; +} + +.title { + margin-bottom: 0.5rem; +} + +.layerNum { + position: absolute; + left: -5px; + color: #bbb; + top: 2px; + font-weight: 500; +} + +.baseLayerLabel { + background-color: #E7E7E7; + color: var(--jumbo); + border-radius: var(--global-radius); + padding: 0.2rem 0.5rem; +} + +.numVulnerableComps { + margin-right: 0.5rem; +} diff --git a/app/scripts/components/repo/repo_details/scannedTag/Layer.jsx b/app/scripts/components/repo/repo_details/scannedTag/Layer.jsx new file mode 100644 index 0000000000..0ed47f870b --- /dev/null +++ b/app/scripts/components/repo/repo_details/scannedTag/Layer.jsx @@ -0,0 +1,234 @@ +'use strict'; + +import React, { Component, PropTypes } from 'react'; +import ComponentGrid from './ComponentGrid'; +import LayerVulnerabilitiesTable from './LayerVulnerabilitiesTable'; +import size from 'lodash/collection/size'; +import map from 'lodash/collection/map'; +const { object, shape, string, bool, func, number } = PropTypes; +import styles from './Layer.css'; +import FontAwesome from 'common/FontAwesome'; +import ui from 'redux-ui'; +import numeral from 'numeral'; +import forEach from 'lodash/collection/forEach'; +import { getHighestComponentCvss, mapCvss } from '../nautilusUtils.js'; +import Tooltip from 'rc-tooltip'; +import classnames from 'classnames'; +import { VelocityComponent } from 'velocity-react'; +const debug = require('debug')('hub:Layer'); + +// Layer is a row in the table of layers for a ScannedTag +@ui({ + state: { + selectedComponent: '', + isExpanded: false, + // if a component is selected and unselected, should the table still show? + shouldReturnToExpanded: false + } +}) +export default class Layer extends Component { + //vulnerabilities and components are just for this layer + static propTypes = { + ui: shape({ + selectedComponent: string, + isExpanded: bool, + shouldReturnToExpanded: bool + }), + updateUI: func, + + layerNum: number, + components: object.isRequired, + layer: object.isRequired, + vulnerabilities: object.isRequired + } + + truncateStringInMiddle = (str, max = 25, sep = `...`) => { + //don't truncate if it's less than max chars + const sepLen = sep.length; + const len = str.length; + if (len < max || sepLen > max) { return str; } + const n = -0.5 * (max - len - sepLen); + const center = len / 2; + return str.substr(0, center - n) + sep + str.substr(len - center + n); + } + + mkTitleRow = () => { + const { components, layer, vulnerabilities, ui: { isExpanded } } = this.props; + const truncateAtChars = 45; + let titleText = layer.docker_command_line; + if (titleText.length > truncateAtChars) { + titleText = this.truncateStringInMiddle(layer.docker_command_line, truncateAtChars); + } + //add link to clickable rows + let title =
    { titleText }
    ; + if (size(layer.components)) { + title = ( + +
    + { titleText } +
    +
    + ); + } + //add tooltip + const instruction = ( + { layer.docker_command_line }
    } + placement='top' + mouseEnterDelay={ 0.1 } + mouseLeaveDelay={ 0.3 } + align={ { overflow: { adjustY: 0 } } }> + { title } + + ); + const layerSize =
    Compressed size: { numeral(layer.size).format('0.0b') }
    ; + let chevron; + if (size(components)) { + chevron = ( +
    + + + +
    + ); + } + return
    { instruction }{ layerSize } { chevron }
    ; + } + + mkLine = (numVulnerableComps, numComps, layerType) => { + let vulnComps, baseLayer; + if (!numComps) { + vulnComps = ( + + No components in this layer + + ); + } else if (!numVulnerableComps && numComps) { + vulnComps = ( + + +  No vulnerable components + + ); + } else if (numVulnerableComps === 1) { + vulnComps = 1 vulnerable component; + } else if (numComps) { + //nothing for layers with no components + vulnComps = {numVulnerableComps} vulnerable components; + } + + // add base layer tag if relevant + if (layerType === 'BASE') { + baseLayer = Base Layer; + } + return
    {vulnComps} {baseLayer}
    ; + } + + toggleExpanded = () => { + const { updateUI } = this.props; + const { selectedComponent, isExpanded, shouldReturnToExpanded } = this.props.ui; + if (!!selectedComponent && isExpanded) { + //clear selected component when closing + updateUI('selectedComponent', ''); + } + updateUI('shouldReturnToExpanded', !shouldReturnToExpanded); + updateUI('isExpanded', !isExpanded); + } + + selectComponent = (componentId) => { + const { updateUI } = this.props; + const { isExpanded, shouldReturnToExpanded } = this.props.ui; + if (!isExpanded && !!componentId) { + updateUI('isExpanded', true); + } + //close the layer if it was closed before you selected then unselected a component + if (isExpanded && !componentId && !shouldReturnToExpanded) { + updateUI('isExpanded', false); + } + updateUI('selectedComponent', componentId); + } + + viewAll = () => { + const { updateUI } = this.props; + const { selectedComponent } = this.props.ui; + //can only be triggered when isExpanded && selectedComponent !== '' + updateUI('selectedComponent', ''); + } + + sortComponentsBySeverity = (highestComponentCvss) => { + //layer may not have any components + const { components } = this.props; + if (!size(components)) { + return []; + } + return map(components, (c, key) => { + return { ...c, key }; + }).sort( (c1, c2) => { + return highestComponentCvss[c2.key] - highestComponentCvss[c1.key]; + }); + } + + getNumVulnerableComponents = (highestComponentCvss) => { + const { components } = this.props; + let numVulnComps = 0; + forEach(components, (c, key) => { + //get the component's highest cvss and the severity of it + if (mapCvss(highestComponentCvss[key]) !== 'secure') { + numVulnComps++; + } + }); + return numVulnComps; + } + + render() { + const { selectedComponent, isExpanded } = this.props.ui; + const { components, layer, vulnerabilities } = this.props; + // map of { componentName: highestCvss } + const highestComponentCvss = getHighestComponentCvss(components, vulnerabilities); + const componentsSortedBySeverity = this.sortComponentsBySeverity(highestComponentCvss); + //nothing displayed for non-base layers with no components + let maybeLine, maybeTable; + const numComps = size(components); + if (numComps && isExpanded) { + maybeTable = ( + + ); + } + if (!isExpanded) { + const numVulnerableComponents = this.getNumVulnerableComponents(highestComponentCvss); + maybeLine = this.mkLine(numVulnerableComponents, numComps, layer.type); + } + let maybeGrid; + //text is displayed instead of table when there are no components (#NOP) + if (numComps) { + maybeGrid = ( + + ); + } + const rowClasses = classnames({ + 'row': true, + [styles.unselectedRow]: !isExpanded, + [styles.selectedRow]: isExpanded + }); + return ( +
    +
    + { (this.props.layerNum + 1) } + { this.mkTitleRow() } + { maybeLine } + { maybeTable } +
    +
    + { maybeGrid } +
    +
    + ); + } +} diff --git a/app/scripts/components/repo/repo_details/scannedTag/LayerVulnerabilitiesTable.css b/app/scripts/components/repo/repo_details/scannedTag/LayerVulnerabilitiesTable.css new file mode 100644 index 0000000000..fc36086c1e --- /dev/null +++ b/app/scripts/components/repo/repo_details/scannedTag/LayerVulnerabilitiesTable.css @@ -0,0 +1,61 @@ +.grey { + color: #B1B5B9; +} + +hr.border { + color: #E4E4E4; + margin: 0.5rem 0 0.5rem; +} + +.headerText { + composes: grey; + text-transform: uppercase; + margin: 0.5rem 0 0.25rem; + font-weight: 500; + font-size: 0.85rem; +} + +.critical { + color: #EB3E46; +} + +.major { + color: #FF8546; +} + +.minor { + color: #FFC458; +} + +.secure { + color: #D3DFDD; +} + +.licenseType { + composes: grey; + font-size: 0.8rem; +} + +.severity { + text-align: right; +} + +.vulnerabilityLines { + margin-bottom: 0.25rem; +} + +.viewAll { + composes: grey; + border-top: 1px solid #C4CDDA; + border-bottom: 1px solid #C4CDDA; + text-align: center; + padding: 0.5rem 0; + cursor: pointer; + text-transform: uppercase; + font-size: 0.85rem; + margin-top: 0.5rem; +} + +.wrapWords { + word-wrap: break-word; +} diff --git a/app/scripts/components/repo/repo_details/scannedTag/LayerVulnerabilitiesTable.jsx b/app/scripts/components/repo/repo_details/scannedTag/LayerVulnerabilitiesTable.jsx new file mode 100644 index 0000000000..5b860ece99 --- /dev/null +++ b/app/scripts/components/repo/repo_details/scannedTag/LayerVulnerabilitiesTable.jsx @@ -0,0 +1,162 @@ +'use strict'; + +import React, { Component, PropTypes } from 'react'; +const { object, bool, array, func, string } = PropTypes; +import styles from './LayerVulnerabilitiesTable.css'; +import { mapCvss, mkComponentId } from '../nautilusUtils.js'; +import forEach from 'lodash/collection/forEach'; +import capitalize from 'lodash/string/capitalize'; +import FontAwesome from 'common/FontAwesome'; +import classnames from 'classnames'; +import Tooltip from 'rc-tooltip'; + +export default class LayerVulnerabilitiesTable extends Component { + static propTypes = { + componentsSortedBySeverity: array.isRequired, + selectedComponent: string, + isExpanded: bool, + viewAll: func, + vulnerabilities: object.isRequired + } + + static defaultProps = { + selectedComponent: '' + } + + mkHeader = () => { + return ( +
    + +
    Component
    +
    Vulnerability
    +
    Severity
    +
    +
    + ); + } + + mkComponentArea = (fullName, license, license_type) => { + return ( +
    +
    { fullName }
    +
    + { `${license}:${capitalize(license_type)} License` } +
    +
    + ); + } + + mkVulnerabilityArea = (fullName, vulns) => { + if (!vulns.length) { + return
    No known vulnerabilities
    ; + } + return vulns.map( v => { + const tooltipContent = ( +
    +
    { v.cve }
    +
    { v.summary }
    +
    + ); + return ( + + + + ); + }); + } + + mkSeverityArea = (fullName, vulns) => { + if (!vulns.length) { + let classes = classnames({ + [styles.secure]: true, + [styles.vulnerabilityLines]: true, + [styles.severity]: true + }); + return
    N/A
    ; + } + return vulns.map( v => { + let classes = classnames({ + [styles[v.severity]]: true, + [styles.vulnerabilityLines]: true, + [styles.severity]: true + }); + return ( +
    + { capitalize(v.severity) } +
    + ); + }); + } + + mkComponentRow = (component) => { + const { + license, + license_type, + component: name, + version, + vulnerabilities + } = component; + const { selectedComponent, vulnerabilities: layerVulnerabilities } = this.props; + const id = mkComponentId(component); + // only render this row if there is NOT a selected component OR this is the selected component + let content; + if (!selectedComponent || selectedComponent === id) { + const fullName = version ? `${name} ${version}` : name; + const componentArea = this.mkComponentArea(fullName, license, license_type); + let componentVulns = []; + forEach(vulnerabilities, (v) => { + const vuln = layerVulnerabilities[v]; + //add severity key to vuln object: ex. severity: 'minor' + componentVulns.push({ ...vuln, severity: mapCvss(vuln.cvss)}); + }); + //sort component vulnerabilities by cvss so most vulnerable is first + componentVulns.sort((v1, v2) => v2.cvss - v1.cvss); + const vulnerabilityArea = this.mkVulnerabilityArea(fullName, componentVulns); + const severityArea = this.mkSeverityArea(fullName, componentVulns); + content = ( +
    +

    +
    {componentArea}
    +
    {vulnerabilityArea}
    +
    {severityArea}
    +
    + ); + } + return content; + } + + render() { + const { + componentsSortedBySeverity, + selectedComponent, + isExpanded, + viewAll + } = this.props; + let viewAllSection; + if (selectedComponent) { + viewAllSection = ( +
    + { `View All `} +
    + ); + } + return ( +
    + { this.mkHeader() } + { componentsSortedBySeverity.map(this.mkComponentRow) } + { viewAllSection } +
    + ); + } +} diff --git a/app/scripts/components/repo/repo_details/scannedTag/ScanHeader.css b/app/scripts/components/repo/repo_details/scannedTag/ScanHeader.css new file mode 100644 index 0000000000..6ecdfbf0e3 --- /dev/null +++ b/app/scripts/components/repo/repo_details/scannedTag/ScanHeader.css @@ -0,0 +1,50 @@ +@import 'dux/css/colors.css'; + +/* Top section with vulnerability counts */ +.verticalSpacing { + line-height: 1.2rem; +} + +.headerRow { + padding-bottom: 1rem; +} + +.vulnerabilityText { + composes: verticalSpacing; + font-size: 1.2rem; + padding-bottom: 0.5rem; +} + +.feedback { + composes: verticalSpacing; + text-align: right; +} + +.feedbackLink { + color: var(--secondary-5); +} + +/* same color as layer titles */ +.lastScanned { + color: #78828f; +} + +.inlineBlock { + display: inline-block; +} + +.bigCheck { + composes: inlineBlock; + color: #6bcec0; + padding-right: 1rem; +} + +/* Layers and Components section */ +.tableHeader { + border-top: 1px solid #E4E4E4; + padding: 0.5rem 0; +} + +.componentsTitle { + padding-left: 1rem; +} diff --git a/app/scripts/components/repo/repo_details/scannedTag/ScanHeader.jsx b/app/scripts/components/repo/repo_details/scannedTag/ScanHeader.jsx new file mode 100644 index 0000000000..5b33a5ff5f --- /dev/null +++ b/app/scripts/components/repo/repo_details/scannedTag/ScanHeader.jsx @@ -0,0 +1,64 @@ +'use strict'; + +import React from 'react'; +import styles from './ScanHeader.css'; +import capitalize from 'lodash/string/capitalize'; +import size from 'lodash/collection/size'; +import FontAwesome from 'common/FontAwesome'; +import moment from 'moment'; +import { consts } from '../nautilusUtils'; + +const ScanHeader = ({ scan, componentsBySeverity }) => { + const { critical, major, minor, secure } = componentsBySeverity; + const { reponame, tag, sha256sum, completed_at, latest_scan_status } = scan; + const numTotalComponents = critical.length + major.length + minor.length + secure.length; + const numVulnerableComponents = numTotalComponents - secure.length; + const components = numTotalComponents === 1 ? `component` : `components`; + const isOrAre = numVulnerableComponents === 1 ? `is` : `are`; + const lastScanned = latest_scan_status === consts.IN_PROGRESS ? `New scan in progress, showing results from ${moment(completed_at).fromNow()}` : `Scanned ${moment(completed_at).fromNow()}`; + let vulnText, vulnArea; + if (!numVulnerableComponents) { + vulnText =
    Your image is clean! No known vulnerabilities were found.
    ; + vulnArea = ( +
    +
    + +
    +
    +
    {vulnText}
    +
    {lastScanned}
    +
    +
    + ); + } else { + vulnText = `${numVulnerableComponents} of ${numTotalComponents} ${components} ${isOrAre} vulnerable`; + vulnArea = ( +
    +
    {vulnText}
    +
    {lastScanned}
    +
    + ); + } + const feedback = ( + + Provide Feedback + + ); + return ( +
    +
    +
    + {vulnArea} +
    +
    {feedback}
    +
    +
    +
    Layers
    +
    Components
    +
    +
    + ); +}; + +export default ScanHeader; diff --git a/app/scripts/components/repo/repo_details/scannedTag/Square.css b/app/scripts/components/repo/repo_details/scannedTag/Square.css new file mode 100644 index 0000000000..fccd037205 --- /dev/null +++ b/app/scripts/components/repo/repo_details/scannedTag/Square.css @@ -0,0 +1,59 @@ +.square { + margin-right: 1px; + margin-bottom: 1px; + width: 50px; + height: 50px; + cursor: pointer; + user-select: none; +} + +.square:hover { + border: 1px solid black; +} + +.solid { + opacity: 1; +} + +.faded { + opacity: 0.4; +} + +.outline { + border: 1px solid black; +} + +.component { + color: #B1B5B9; + text-transform: uppercase; + font-weight: 500; + font-size: 0.85rem; +} + +.tooltipSquare { + display: inline-block; + width: 11px; + height: 11px; + vertical-align: middle; + margin-right: .25rem; +} + +.vulnLineWrapper { + padding-top: 0.25rem; +} + +.critical { + background-color: #EB3E46; +} + +.major { + background-color: #FF8546; +} + +.minor { + background-color: #FFC458; +} + +.secure { + background-color: #D3DFDD; +} diff --git a/app/scripts/components/repo/repo_details/scannedTag/Square.jsx b/app/scripts/components/repo/repo_details/scannedTag/Square.jsx new file mode 100644 index 0000000000..abc557fd80 --- /dev/null +++ b/app/scripts/components/repo/repo_details/scannedTag/Square.jsx @@ -0,0 +1,110 @@ +'use strict'; + +import React, { Component, PropTypes } from 'react'; +const { object, bool, func, shape, oneOf, number } = PropTypes; +import classnames from 'classnames'; +import styles from './Square.css'; +import capitalize from 'lodash/string/capitalize'; +import Tooltip from 'rc-tooltip'; +import { mkComponentId } from '../nautilusUtils.js'; +const severities = ['critical', 'major', 'minor', 'secure']; + +export default class Square extends Component { + static propTypes = { + component: object.isRequired, + isSelected: bool, + isLayerSelected: bool, + numVulnsBySeverity: shape({ + critical: number, + major: number, + minor: number + }), + onClick: func.isRequired, + severity: oneOf(severities) + } + + static defaultProps = { + isSelected: false, + isLayerSelected: false, + severity: 'secure' + } + + onClick = (e) => { + const { isSelected, component } = this.props; + if (isSelected) { + //unselect component + this.props.onClick(''); + } else { + // Note: this is the ID that normalizr uses to refer to components + this.props.onClick(mkComponentId(component)); + } + } + + mkTooltipContent = () => { + const { component, version } = this.props.component; + const { critical, major, minor } = this.props.numVulnsBySeverity; + let vulnList = ( +
    +
    { this.mkTooltipVulnLine('critical', critical) }
    +
    { this.mkTooltipVulnLine('major', major) }
    +
    { this.mkTooltipVulnLine('minor', minor) }
    +
    + ); + if (!critical && !major && !minor) { + vulnList =
    { this.mkTooltipVulnLine('secure') }
    ; + } + return ( +
    +
    COMPONENT
    +
    { `${component} ${version}` }
    + { vulnList } +
    + ); + } + + // Create line with square for inside tooltip, ex: `[] 4 Critical Vulnerabilities` + mkTooltipVulnLine = (severity, num) => { + if (!num && severity !== 'secure') { + return null; + } + const v = num === 1 ? `Vulnerability` : `Vulnerabilities`; + const squareClasses = classnames({ + [styles[severity]]: true, + [styles.tooltipSquare]: true + }); + const text = severity === 'secure' ? `No Known Vulnerabilities` : `${num} ${capitalize(severity)} ${v}`; + return ( +
    +   + { text } +
    + ); + } + + render() { + const { component, version } = this.props.component; + const tooltipId = `square-${component}-${version}`; + const tooltipContent = this.mkTooltipContent(); + const { isLayerSelected, isSelected, severity } = this.props; + const squareClasses = classnames({ + [styles.square]: true, + //layer is not selected or this component is selected + [styles.solid]: !isLayerSelected || isSelected, + //another component is selected + [styles.faded]: isLayerSelected && !isSelected, + [styles.outline]: isSelected, + [styles[severity]]: true + }); + return ( + +
    +   +
    +
    + ); + } +} diff --git a/app/scripts/components/repo/repo_details/scannedTag/selectors.js b/app/scripts/components/repo/repo_details/scannedTag/selectors.js new file mode 100644 index 0000000000..ddfdd32924 --- /dev/null +++ b/app/scripts/components/repo/repo_details/scannedTag/selectors.js @@ -0,0 +1,85 @@ +'use strict'; + +import { Map } from 'immutable'; +import { createSelector } from 'reselect'; +import { mapCvss, getHighestSeverity } from '../nautilusUtils.js'; +import values from 'lodash/object/values'; +import forEach from 'lodash/collection/forEach'; + + +// Returns the specific tag's scan for the current route +export const getScan = state => { + // When requesting the scan from the API we only ever get one scan back. + // This means that to get our scan we use the first value in our scan object. + // TODO: When we move to redux-simple-router we can use the router's state + // to create the object key as you'd expect. + const vals = values(state.scans.get('scan', new Map()).toJS()); + if (vals.length === 0) { + return null; + } + return vals[0]; +}; + +export const getVulnerabilities = (state) => + state.scans.get('vulnerability', new Map()).toJS(); + +export const getComponents = (state) => + state.scans.get('component', new Map()).toJS(); + +export const getLayers = (state) => + state.scans.get('blob', new Map()).toJS(); + +/** + * Returns a JS object containing component keys sorted into severity adjectives + * @return object {critical: [CompKey, CompKey], major: [], minor: [], secure: []} + */ +export const getComponentsBySeverity = createSelector( + [getComponents, getVulnerabilities], + (comps, vulns) => { + let map = { + critical: [], + major: [], + minor: [], + secure: [] + }; + //for each component in this scan get all the vulnerability cvss scores in an array + //and return the highest + forEach(comps, (componentDetail, componentKey) => { + //componentDetail.vulnerabilities is either null or has vuln keys + const { vulnerabilities } = componentDetail; + if (vulnerabilities === null || !vulnerabilities.length) { + map.secure.push(componentKey); + } else { + let cvssScores = []; + forEach(vulnerabilities, (v) => { + cvssScores.push(vulns[v].cvss); + }); + map[getHighestSeverity(...cvssScores)].push(componentKey); + } + }); + return map; + } +); + +/** + * Returns a JS object containing layer.index as keys (sha is not unique), + * with an object containing only that layer's vulnerabilities as the value. + * The object with vulnerabilies will be a subset of all vulnerabilities + * @return object { 0: [Vuln, Vuln], 1: [Vuln], ... } + */ +export const getVulnerabilitiesByLayer = createSelector( + [getLayers, getComponents, getVulnerabilities], + (layers, components, vulnerabilities) => { + let map = {}; + forEach(layers, l => { + let layerVulnerabilities = {}; + forEach(l.components, c => { + forEach(components[c].vulnerabilities, v => { + layerVulnerabilities[v] = vulnerabilities[v]; + }); + }); + map[l.index] = layerVulnerabilities; + }); + return map; + } +); diff --git a/app/scripts/components/repo/repo_details/tags/DeleteTagArea.css b/app/scripts/components/repo/repo_details/tags/DeleteTagArea.css new file mode 100644 index 0000000000..fda8ff4288 --- /dev/null +++ b/app/scripts/components/repo/repo_details/tags/DeleteTagArea.css @@ -0,0 +1,22 @@ +@import "dux/css/box.css"; +@import "dux/css/colors.css"; + +.confirmDelete { + color: var(--primary-5); +} +.confirmDelete:hover { + color: color(var(--primary-5) blackness(15%)); +} + +.delete { + color: var(--secondary-5); + transition: color .15s ease-in-out; + align-self: center; +} +.delete:hover { + color: var(--primary-5); +} + +.error { + color: var(--primary-5); +} diff --git a/app/scripts/components/repo/repo_details/tags/DeleteTagArea.jsx b/app/scripts/components/repo/repo_details/tags/DeleteTagArea.jsx new file mode 100644 index 0000000000..4eb3ecff56 --- /dev/null +++ b/app/scripts/components/repo/repo_details/tags/DeleteTagArea.jsx @@ -0,0 +1,70 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +import { ATTEMPTING, ERROR } from 'reduxConsts'; +const { func, instanceOf } = PropTypes; +import styles from './DeleteTagArea.css'; +import FA from 'common/FontAwesome'; +import { StatusRecord } from 'records'; +const debug = require('debug')('hub:deleteTagArea'); + +export default class DeleteTagArea extends Component { + // default status comes from StatusRecord + static propTypes = { + status: instanceOf(StatusRecord), + deleteTag: func + } + + state = { + confirmingDelete: false + } + + confirmDelete = (e) => { + this.setState({ + confirmingDelete: true + }); + } + + cancelConfirm = () => { + this.setState({ + confirmingDelete: false + }); + } + + render() { + const { deleteTag, status: { status } } = this.props; + const { confirmingDelete } = this.state; + let content; + if (status === ERROR) { + content = ( +
    + Deletion Failed +
    + ); + } else if (status === ATTEMPTING) { + content = ( +
    + Deleting  +
    + ); + } else if (confirmingDelete) { + content = ( + + ); + } else { + content = ( + + + + ); + } + return content; + } +} diff --git a/app/scripts/components/repo/repo_details/tags/ErrorBar.css b/app/scripts/components/repo/repo_details/tags/ErrorBar.css new file mode 100644 index 0000000000..0faaf05e1d --- /dev/null +++ b/app/scripts/components/repo/repo_details/tags/ErrorBar.css @@ -0,0 +1,9 @@ +@import 'dux/css/colors.css'; + +.bar { + height: 100%; + width: 100%; + display: inline-block; + background-color: var(--iron); + user-select: none; +} diff --git a/app/scripts/components/repo/repo_details/tags/ErrorBar.jsx b/app/scripts/components/repo/repo_details/tags/ErrorBar.jsx new file mode 100644 index 0000000000..f277767452 --- /dev/null +++ b/app/scripts/components/repo/repo_details/tags/ErrorBar.jsx @@ -0,0 +1,10 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +import styles from './ErrorBar.css'; + +export default class ErrorBar extends Component { + render() { + return
     
    ; + } +} diff --git a/app/scripts/components/repo/repo_details/tags/InProgressBar.css b/app/scripts/components/repo/repo_details/tags/InProgressBar.css new file mode 100644 index 0000000000..29bb491b77 --- /dev/null +++ b/app/scripts/components/repo/repo_details/tags/InProgressBar.css @@ -0,0 +1,9 @@ +@import 'dux/css/colors.css'; + +.gradient { + height: 100%; + width: 100%; + display: inline-block; + user-select: none; + background-image: repeating-linear-gradient(-45deg, var(--iron), var(--iron) 12px, var(--base) 0, var(--base) 24px); +} diff --git a/app/scripts/components/repo/repo_details/tags/InProgressBar.jsx b/app/scripts/components/repo/repo_details/tags/InProgressBar.jsx new file mode 100644 index 0000000000..85be1df782 --- /dev/null +++ b/app/scripts/components/repo/repo_details/tags/InProgressBar.jsx @@ -0,0 +1,10 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +import styles from './InProgressBar.css'; + +export default class InProgressBar extends Component { + render() { + return
     
    ; + } +} diff --git a/app/scripts/components/repo/repo_details/tags/ScannedTagRow.css b/app/scripts/components/repo/repo_details/tags/ScannedTagRow.css new file mode 100644 index 0000000000..5ae99cd0c0 --- /dev/null +++ b/app/scripts/components/repo/repo_details/tags/ScannedTagRow.css @@ -0,0 +1,41 @@ +.lineSpacing { + line-height: 1rem; +} + +.linePadding { + padding-bottom: 0.5rem; +} + +.smallText { + font-size: 0.9rem; +} + +.green { + color: #7DCCC0; +} + +.grey { + color: #7A8492; +} + +.icon { + margin-right: 0.5rem; +} + +.tagSize { + composes: lineSpacing; + composes: smallText; + color: #CECED9; +} + +.tagName { + composes: lineSpacing; + margin-right: 0.5rem; + font-size: 1.2rem; +} + + +.vulnerabilityBarWrapper { + height: 1rem; + width: 350px; +} diff --git a/app/scripts/components/repo/repo_details/tags/ScannedTagRow.jsx b/app/scripts/components/repo/repo_details/tags/ScannedTagRow.jsx new file mode 100644 index 0000000000..77f7379be5 --- /dev/null +++ b/app/scripts/components/repo/repo_details/tags/ScannedTagRow.jsx @@ -0,0 +1,174 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +import { Link } from 'react-router'; +const { func, object, shape, string, number, bool, instanceOf } = PropTypes; +import DeleteTagArea from './DeleteTagArea'; +import { FlexRow, FlexItem } from 'common/FlexTable.jsx'; +import VulnerabilityBar from './VulnerabilityBar'; +import InProgressBar from './InProgressBar'; +import ErrorBar from './ErrorBar'; +import bytesToSize from '../../../utils/bytesToSize'; +const debug = require('debug')('ScannedTagRow'); +import FontAwesome from 'common/FontAwesome'; +import classnames from 'classnames'; +import styles from './ScannedTagRow.css'; +import { StatusRecord } from 'records'; +import moment from 'moment'; +import { consts } from '../nautilusUtils'; + +//Renders a tag row for the Tags table with Nautilus Scan information +export default class ScannedTagRow extends Component { + + static propTypes = { + actions: shape({ + deleteRepoTag: func + }), + + tag: shape({ + name: string.isRequired, + full_size: number, + critical: number, + healthy: number, + major: number, + minor: number + }), + JWT: string.isRequired, + name: string.isRequired, + namespace: string.isRequired, + canEdit: bool.isRequired, + status: instanceOf(StatusRecord) + } + + static defaultProps = { + canEdit: false + } + + deleteTag = (e) => { + const { JWT, name, namespace, tag, actions } = this.props; + const tagName = tag.name; + actions.deleteRepoTag({ JWT, namespace, name, tagName }); + } + + render = () => { + const { + tag, + canEdit, + namespace, + name: repoName, + status + } = this.props; + const { + name, + full_size, + last_scanned, + critical, + healthy: secure, + major, + minor, + latest_scan_status + } = tag; + // this is the default value, indicating that no scan results exist + const isFirstScan = moment(last_scanned).isSame('0001-01-01T00:00:00Z'); + // A scan is in progress or failed but we have stale results to show + const scanInProgress = latest_scan_status === consts.IN_PROGRESS && !isFirstScan; + // A scan is in progress or failed and we don't have prior results to show + const newScanInProgress = latest_scan_status === consts.IN_PROGRESS && isFirstScan; + const newScanFailed = latest_scan_status === consts.FAILED && isFirstScan; + + const numVulnerableComponents = critical + major + minor; + const vulnerabilityTextClass = classnames({ + [styles.lineSpacing]: true, + [styles.grey]: true + }); + const vulnerabilityIconClass = classnames({ + [styles.lineSpacing]: true, + [styles.green]: !numVulnerableComponents, + [styles.grey]: numVulnerableComponents || newScanInProgress || newScanFailed, + [styles.icon]: true + }); + let icon, text, bar, lastScanned, titleLink; + if (newScanInProgress) { + icon = 'fa-refresh'; + text = `Scanning image for vulnerabilities...`; + bar = ; + lastScanned = `Scan in progress...`; + titleLink = name; + } else if (newScanFailed) { + icon = 'fa-minus-circle'; + text = `Could not complete image scan`; + bar = ; + lastScanned = `Scan failed`; + titleLink = name; + } else { + const numVulns = numVulnerableComponents ? 'vulnerabilities' : 'no known vulnerabilities'; + text = `This image has ${numVulns}`; + icon = numVulnerableComponents ? 'fa-exclamation-circle' : 'fa-check'; + bar = ( + + ); + //Do not expose refresh scanner failure if scan results exist + lastScanned = scanInProgress ? `New scan in progress...` : `Scanned ${moment(last_scanned).fromNow()}`; + titleLink = {name}; + } + const vulnerabilityArea = ( +
    +
    + + + + + {text} + +
    +
    + {bar} +
    +
    + ); + const lastScannedClasses = classnames({ + [styles.lineSpacing]: true, + [styles.smallText]: true, + [styles.grey]: true + }); + const tagNameClasses = classnames({ + [styles.tagName]: true, + [styles.grey]: newScanInProgress || newScanFailed + }); + const titleArea = ( +
    +
    + + {titleLink} + + + Compressed size: {bytesToSize(full_size)} + +
    +
    + {lastScanned} +
    +
    + ); + let deleteArea; + if (canEdit) { + deleteArea = ( + + ); + } + + return ( + + {titleArea} + {vulnerabilityArea} + {deleteArea} + + ); + } +} diff --git a/app/scripts/components/repo/repo_details/tags/UnscannedTagRow.jsx b/app/scripts/components/repo/repo_details/tags/UnscannedTagRow.jsx new file mode 100644 index 0000000000..5baac35de8 --- /dev/null +++ b/app/scripts/components/repo/repo_details/tags/UnscannedTagRow.jsx @@ -0,0 +1,72 @@ +'use strict'; +import React, { PropTypes, Component } from 'react'; +const { func, object, shape, string, number, bool, instanceOf } = PropTypes; +import DeleteTagArea from './DeleteTagArea'; +import { FlexRow, FlexItem } from 'common/FlexTable.jsx'; +import bytesToSize from '../../../utils/bytesToSize'; +const debug = require('debug')('UnscannedTagRow'); +import { StatusRecord } from 'records'; +import moment from 'moment'; + +//Renders a tag row for the Tags table that does not have Nautilus Scan information +export default class UnscannedTagRow extends Component { + static propTypes = { + actions: shape({ + deleteRepoTag: func + }), + + tag: shape({ + name: string.isRequired, + full_size: number + }), + JWT: string.isRequired, + name: string.isRequired, + namespace: string.isRequired, + canEdit: bool.isRequired, + status: instanceOf(StatusRecord) + } + + static defaultProps = { + canEdit: false + } + + deleteTag = (e) => { + const { JWT, name, namespace, tag } = this.props; + const tagName = tag.name; + this.props.actions.deleteRepoTag({ JWT, namespace, name, tagName }); + } + + render() { + const { + tag, + canEdit, + namespace, + name: repoName, + status + } = this.props; + const { name, full_size, last_updated } = tag; + + let deleteArea; + if (canEdit) { + deleteArea = ( + + ); + } + + const lastUpdated = last_updated ? moment(last_updated).fromNow() : `Unavailable`; + + return ( + + {name} + {bytesToSize(full_size)} + {lastUpdated} + + {deleteArea} + + + ); + } +} diff --git a/app/scripts/components/repo/repo_details/tags/VulnerabilityBar.jsx b/app/scripts/components/repo/repo_details/tags/VulnerabilityBar.jsx new file mode 100644 index 0000000000..7baff85afa --- /dev/null +++ b/app/scripts/components/repo/repo_details/tags/VulnerabilityBar.jsx @@ -0,0 +1,65 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +const { number } = PropTypes; + +export default class VulnerabilityBar extends Component { + static propTypes = { + critical: number, + secure: number, + major: number, + minor: number + } + + getStyle = (severity, num, total) => { + const colors = { + critical: `#E8404B`, + major: `#FD854E`, + minor: `#FED954`, + secure: `#7DCCC0` + }; + + let width; + if (total) { + width = num / total * 100; + } else if (severity === 'secure'){ + width = 100; + } else { + width = 0; + } + + return { + width: `${width}%`, + backgroundColor: colors[severity], + display: `inline-block`, + height: `100%`, + userSelect: 'none' + }; + + } + + render() { + const { + critical, + secure, + major, + minor + } = this.props; + const total = critical + secure + major + minor; + const criticalStyle = this.getStyle('critical', critical, total); + const majorStyle = this.getStyle('major', major, total); + const minorStyle = this.getStyle('minor', minor, total); + const secureStyle = this.getStyle('secure', secure, total); + const wrapperStyle = { width: `100%` }; + + return ( +
    +
     
    +
     
    +
     
    +
     
    +
    + ); + + } +} diff --git a/app/scripts/components/repo/repo_details/tags/selectors.js b/app/scripts/components/repo/repo_details/tags/selectors.js new file mode 100644 index 0000000000..07cc2e5104 --- /dev/null +++ b/app/scripts/components/repo/repo_details/tags/selectors.js @@ -0,0 +1,65 @@ +'use strict'; + +import { createSelector } from 'reselect'; +import { Map, List } from 'immutable'; +import filter from 'lodash/collection/filter'; +import size from 'lodash/collection/size'; +import map from 'lodash/collection/map'; +import values from 'lodash/object/values'; + + +// Returns all repository tags (unordered) +export const getRepoTags = (state) => { + const reponame = state.repos.get('name', ''); + const namespace = state.repos.get('namespace', ''); + // We need to use toJS() to deeply convert tags from immutable to objects. + // We also return an array because getScannedTags and getUnscannedTags return + // arrays - keeping things consistent. + return values(state.tags.getIn([namespace, reponame, 'tags'], new Map()).toJS()); +}; + +// Returns all repository tags in the order of the hub API +// Note: This does _not_ return any tags that are returned by nautilus but not hub +// so we use the getRepoTags for the scannedTag selector +export const getRepoTagsInOrder = (state) => { + const reponame = state.repos.get('name', ''); + const namespace = state.repos.get('namespace', ''); + // We need to use toJS() to deeply convert tags from immutable to objects. + // We also return an array because getScannedTags and getUnscannedTags return + // arrays - keeping things consistent. + let orderedTags = state.tags.getIn([namespace, reponame, 'result'], []); + if (orderedTags.toArray) { + orderedTags = orderedTags.toArray(); + } + const tags = state.tags.getIn([namespace, reponame, 'tags'], new Map()).toJS(); + return map(orderedTags, (tagId) => tags[tagId]); +}; + + +// Returns only tags which have been scanned by nautilus +export const getScannedTags = createSelector( + [getRepoTags], + (tags) => { + // If the tag has a 'healthy' key then this has been scanned by nautilus + return filter(tags, (tag) => tag.healthy !== undefined); + } +); +// Number of tags scanned by nautilus +export const getScannedTagCount = createSelector( + [getScannedTags], + (tags) => size(tags) +); + +// getUnscannedTags returns only tags **not** scanned by nautilus +export const getUnscannedTags = createSelector( + [getRepoTagsInOrder], + (tags) => { + // If healthy is undefined this tag only has a hub response + return filter(tags, (tag) => tag.healthy === undefined); + } +); + +export const getUnscannedTagCount = createSelector( + [getUnscannedTags], + (tags) => size(tags) +); diff --git a/app/scripts/components/repositories/AutoBuildSetupForm.css b/app/scripts/components/repositories/AutoBuildSetupForm.css new file mode 100644 index 0000000000..c078308f01 --- /dev/null +++ b/app/scripts/components/repositories/AutoBuildSetupForm.css @@ -0,0 +1,49 @@ +@import "dux/css/box.css"; +@import "dux/css/colors.css"; + +.errorText { + white-space: pre; +} + +.input { + margin-right: var(--default-margin); +} + +.formContainer { + margin-top: var(--default-margin); +} + +.error { + color: var(--primary-5); + font-size: .875rem; + margin-bottom: 0.3rem; +} + +/* TODO: this is also used in EnterpriseTrialForm.css | a candidate to be in colors.css */ +.label { + color: #7a8491; + font-weight: 500; + sup { + font-size: 1rem; + vertical-align: text-bottom; + } +} + +.customizeLabel { + composes: label; + margin-bottom: 0.5rem; +} + +.floatRight { + float: right; + margin-right: 1rem; +} + +.globalError { + composes: error; + composes: floatRight; +} + +.select { + border-radius: var(--global-radius); +} \ No newline at end of file diff --git a/app/scripts/components/repositories/AutoBuildSetupForm.jsx b/app/scripts/components/repositories/AutoBuildSetupForm.jsx new file mode 100644 index 0000000000..19d03c80af --- /dev/null +++ b/app/scripts/components/repositories/AutoBuildSetupForm.jsx @@ -0,0 +1,410 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import findIndex from 'lodash/array/findIndex'; +import includes from 'lodash/collection/includes'; +import omit from 'lodash/object/omit'; +import map from 'lodash/collection/map'; +import AutoBuildTagsInput from './AutoBuildTagsInput.jsx'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import RepositoryNameInput from 'common/RepositoryNameInput.jsx'; +import SimpleTextArea from 'common/SimpleTextArea.jsx'; +import AutobuildStore from '../../stores/AutobuildStore'; +import AutobuildConfigStore from '../../stores/AutobuildConfigStore'; +import AutobuildSourceRepositoriesStore from '../../stores/AutobuildSourceRepositoriesStore'; +import RepoStore from '../../stores/RepositoryPageStore'; +import UserStore from '../../stores/UserStore'; +import createAutobuild from '../../actions/createAutobuild'; +import updateAutobuildFormField from '../../actions/updateAutobuildFormField.js'; +import getSettingsData from 'actions/getSettingsData'; +import { PageHeader } from 'dux'; +import AlertBox from 'common/AlertBox'; +import Card, { Block } from '@dux/element-card'; +import Button from '@dux/element-button'; +import { validateRepositoryName } from '../utils/validateRepositoryName'; +import { STATUS as COMMONSTATUS } from '../../stores/common/Constants'; +import Markdown from '@dux/element-markdown'; + +const { + ATTEMPTING +} = COMMONSTATUS; + +const buildTagsClientSideError = 'No empty strings allowed for docker tag (or) source tag/branch name specification.'; + +import styles from './AutoBuildSetupForm.css'; + +const { + array, + bool, + func, + number, + object, + oneOf, + shape, + string +} = PropTypes; + +var AutoBuildSetupForm = React.createClass({ + contextTypes: { + executeAction: func.isRequired + }, + propTypes: { + user: object.isRequired, + JWT: string.isRequired, + ownedNamespaces: array.isRequired, + configStore: shape({ + description: string, + isPrivate: oneOf(['private', 'public']).isRequired, + name: string.isRequired, + namespace: string.isRequired, + sourceRepoName: string.isRequired, + STATUS: string.isRequired + }), + sourceRepositories: shape({ + type: string.isRequired + }) + }, + getInitialState: function() { + return { + isActive: true, + buildTags: this.defaultBuildTags, + clientSideError: '', + advancedMode: false + }; + }, + /*eslint-disable camelcase*/ + + /* + * By default, if the input is empty for source tag/branch name, send the string: '{sourceref}' & 'master' + * By default, if the input is empty for docker tag name, send the string with regex for all matches & 'latest' + */ + defaultBuildTags: [ + { + id: 'tag-0', + name: 'latest', + source_type: 'Branch', + source_name: 'master', + dockerfile_location: '/' + }, + { + id: 'tag-1', + name: '{sourceref}', + source_type: 'Branch', + source_name: '/^([^m]|.[^a]|..[^s]|...[^t]|....[^e]|.....[^r]|.{0,5}$|.{7,})/', + dockerfile_location: '/' + } + ], + getBuildTagsToSend: function() { + let bTags = this.state.buildTags; + return map(bTags, (tag) => { + return omit(tag, 'id'); + }); + }, + /*eslint-enable camelcase */ + _handleCreate: function(evt) { + evt.preventDefault(); + const { username } = this.props.user; + const { buildTags, isActive } = this.state; + const { description, isPrivate, name, namespace, sourceRepoName } = this.props.configStore; + const { type } = this.props.sourceRepositories; + + const params = this.props.params; + const sourceRepoFallback = `${params.sourceRepoNamespace}/${params.sourceRepoName}`; + + if (!validateRepositoryName(name.toLowerCase())) { + //check if the repo name is valid | client side check + this.setState({ + clientSideError: `No spaces and special characters other than '.' and '-' are allowed. +Repository names should not begin/end with a '.' or '-'.` + }); + } else { + + var newAutobuild = { + user: username, + namespace: namespace, + name: name.toLowerCase(), + description: description, + is_private: isPrivate === 'private', + build_name: sourceRepoName.toLowerCase() || sourceRepoFallback.toLowerCase(), + provider: type.toLowerCase(), + active: isActive, + tags: this.getBuildTagsToSend() + }; + + this.context.executeAction(createAutobuild, {JWT: this.props.JWT, autobuildConfig: newAutobuild}); + } + }, + _onActiveStateChange: function(e) { + this.setState({isActive: !this.state.isActive}); + }, + _getTagIndex: function(id) { + return findIndex(this.state.buildTags, function(tag) { + return (tag.id === id); + }); + }, + _setTagState: function(id, prop, value) { + let bTags = this.state.buildTags; + bTags[this._getTagIndex(id)][prop] = value; //find tag and update property + this.setState({ + buildTags: bTags + }); + }, + _onTagRemoved: function(id) { + //Remove tag, when user removes it from the form + let bTags = this.state.buildTags; + bTags.splice(this._getTagIndex(id), 1); + this.setState({ + buildTags: bTags + }); + }, + _onTagAdded: function(tag) { + let bTags = this.state.buildTags; + bTags.push(tag); + this.setState({ + buildTags: bTags + }); + }, + _resetBuildTagsError: function() { + //Reset clientSideError on change + if (this.state.clientSideError === buildTagsClientSideError) { + this.setState({ + clientSideError: '' + }); + } + }, + _onSourceNameChange: function(tagId, e) { + let sourceName = e.target.value; + let sourceType = this.state.buildTags[this._getTagIndex(tagId)].sourceType; + if (sourceName === '' && sourceType && sourceType === 'Branch') { + sourceName = '/^([^m]|.[^a]|..[^s]|...[^t]|....[^e]|.....[^r]|.{0,5}$|.{7,})/'; + } else if (sourceName === '' && sourceType && sourceType === 'Tag') { + sourceName = '/.*/'; + } else { + this._resetBuildTagsError(); + //Strip trailing and leading spaces. If we end up with empty string, throw an error. + if (sourceName.trim() === '') { + this.setState({ + clientSideError: buildTagsClientSideError + }); + } + } + this._setTagState(tagId, 'source_name', sourceName.trim()); + }, + _onSourceTypeChange: function(tagId, e) { + this._setTagState(tagId, 'source_type', e.target.value); + }, + _onDockerfileLocationChange: function(tagId, e) { + this._setTagState(tagId, 'dockerfile_location', e.target.value); + }, + _onTagChange: function(tagId, e) { + let tagName = e.target.value; + if (tagName === '') { + tagName = '{sourceref}'; + } + this._setTagState(tagId, 'name', tagName.trim()); + }, + _updateForm(fieldKey) { + return (e) => { + if (fieldKey === 'namespace') { + this.setState({ + currentNamespace: e.target.value + }); + this.context.executeAction(getSettingsData, { + JWT: this.props.JWT, + username: e.target.value, + repoType: 'autobuild' + }); + } else if (fieldKey === 'name' && this.state.clientSideError) { + this.setState({ + clientSideError: '' + }); + } + this.context.executeAction(updateAutobuildFormField, { + fieldKey, + fieldValue: e.target.value + }); + }; + }, + componentWillReceiveProps: function(nextProps) { + const { name, namespace, success, STATUS } = nextProps.configStore; + //If autobuild was created successfully + if (STATUS.SUCCESSFUL || success) { + this.props.history.pushState(null, `/r/${namespace}/${name.toLowerCase()}/`); + } + }, + customTagsConfig: function() { + this.setState({ + advancedMode: true, + buildTags: [] + }); + }, + defaultTagsConfig: function() { + this.setState({ + advancedMode: false, + buildTags: this.defaultBuildTags + }); + }, + render: function() { + + const { + description, + error, + isPrivate, + name, + namespace, + success, + STATUS + } = this.props.configStore; + + /* start error/success handling */ + let maybeSuccess = ; + if (success) { + maybeSuccess = {success}; + } + + let nameError; + let nameErrorContent = error.dockerhub_repo_name; + if(nameErrorContent) { + nameError = nameErrorContent; + } + + let descriptionError; + if(error.description) { + descriptionError = error.description; + } + + let privateRepoError; + if(error.is_private) { + privateRepoError = error.is_private; + } + + let buildTagsError; + if (error.buildTags) { + buildTagsError = error.buildTags; + } + + let maybeError = null; + let errorDetail = error.detail || this.state.clientSideError; + if (errorDetail) { + maybeError = ( +
    +
    + {errorDetail} +
    +
    + ); + } + /* end error handling */ + + //Check if user has passed in namespace as query | verify if they have access to it + let currentUserNamespace = this.props.location.query.namespace; + if (!includes(this.props.ownedNamespaces, currentUserNamespace)) { + //If they don't have access to the namespace set in the query param ? then fallback to default namespace + currentUserNamespace = this.props.user.namespace; + } + let tagsConfigList = null; + + if (this.state.advancedMode) { + tagsConfigList = ( +
    +
    +
    Customize Autobuild Tags
    + +
    + {buildTagsError} +
    + +
    + ); + } + + return ( +
    + +
    +
    + + +
    +
    +
    + +
    + {nameError} +
    + +
    +
    + +
    + {privateRepoError} +
    + +
    +
    + +
    + {descriptionError} +
    + + + {/* advanced mode */} + {tagsConfigList} +
    + {maybeError} +
    +
    + +
    +
    + + {maybeSuccess} +
    +
    +
    +
    +
    + ); + } +}); + +export default connectToStores(AutoBuildSetupForm, + [ + AutobuildSourceRepositoriesStore, + AutobuildConfigStore, + UserStore + ], + function({ getStore }, props){ + return { + sourceRepositories: getStore(AutobuildSourceRepositoriesStore).getState(), + configStore: getStore(AutobuildConfigStore).getState(), + ownedNamespaces: getStore(UserStore).getNamespaces() + }; + }); diff --git a/app/scripts/components/repositories/AutoBuildTagsInput.jsx b/app/scripts/components/repositories/AutoBuildTagsInput.jsx new file mode 100644 index 0000000000..c5b4c52e53 --- /dev/null +++ b/app/scripts/components/repositories/AutoBuildTagsInput.jsx @@ -0,0 +1,159 @@ +'use strict'; + +import React from 'react'; +import { FlexTable, Header, Item } from 'common/TagsFlexTable'; +import AutoBuildTagsInputItem from './AutoBuildTagsInputItem.jsx'; + +const AutoBuildTagsInput = React.createClass({ + contextTypes: { + getStore: React.PropTypes.func.isRequired + }, + propTypes: { + repo: React.PropTypes.string.isRequired, + onTagRemoved: React.PropTypes.func.isRequired, + onTagAdded: React.PropTypes.func.isRequired, + onSourceTypeChange: React.PropTypes.func.isRequired, + onSourceNameChange: React.PropTypes.func.isRequired, + onDockerfileLocationChange: React.PropTypes.func.isRequired + }, + getInitialState: function() { + const masterDefaultRow = { + row: 'row-0', + sourceType: 'Branch', + sourceName: 'master', + sign: 'plus', + tag: 'latest', + fileLocation: '/', + tagId: 'tag-0' + }; + const dynamicTagRow = { + row: 'row-1', + sourceType: 'Branch', + sourceName: '', + sign: 'minus', + tag: '', + fileLocation: '/', + tagId: 'tag-1' + }; + return { + currentRows: [ + this.makeRow(masterDefaultRow), + this.makeRow(dynamicTagRow) + ] + }; + }, + _handleBtnClickAdd: function(e) { + e.preventDefault(); + let rows = this.state.currentRows; + let idx = this.state.currentRows.length; + const row = { + row: 'row-' + idx, + sourceType: 'Branch', + sourceName: '', + sign: 'minus', + tag: '', + fileLocation: '/', + tagId: 'tag-' + idx + }; + const newRow = this.makeRow(row); + this.setState({ + currentRows: rows.concat(newRow) + }); + //Make an empty build tag that will get updated on edit + const bTag = { + id: 'tag-' + idx, + sourceType: 'Branch', + sourceName: '', + fileLocation: '/', + tagName: '' + }; + this.props.onTagAdded(this.makeBuildTag(bTag)); + }, + makeRow: function(rowObj) { + const { + row, + sourceType, + sourceName, + sign, + tag, + fileLocation, + tagId + } = rowObj; + + return ( + + ); + }, + makeBuildTag: function(newBuildTag) { + const { + id, + sourceType, + sourceName, + fileLocation, + tagName + } = newBuildTag; + /*eslint-disable camelcase */ + /* By default, if the input is empty for source tag/branch name, send the string: '{sourceref}'*/ + /* By default, if the input is empty for docker tag name, send the string with the regex for all matches */ + return { + id: id, + name: tagName === '' ? '{sourceref}' : tagName, + source_type: sourceType, + source_name: sourceName === '' ? '/^([^m]|.[^a]|..[^s]|...[^t]|....[^e]|.....[^r]|.{0,5}$|.{7,})/' : sourceName, + dockerfile_location: fileLocation + }; + /*eslint-enable camelcase */ + }, + componentDidMount: function() { + const masterBuildTag = { + id: 'tag-0', + sourceType: 'Branch', + sourceName: 'master', + fileLocation: '/', + tagName: 'latest' + }; + const dynamicBuildTag = { + id: 'tag-1', + sourceType: 'Branch', + sourceName: '', + fileLocation: '/', + tagName: '' + }; + this.props.onTagAdded( + this.makeBuildTag(masterBuildTag) + ); + this.props.onTagAdded( + this.makeBuildTag(dynamicBuildTag) + ); + }, + render: function() { + return ( + +
    + Push Type + Name + Dockerfile Location + Docker Tag + +
    + { this.state.currentRows } +
    + ); + } +}); + +module.exports = AutoBuildTagsInput; diff --git a/app/scripts/components/repositories/AutoBuildTagsInputItem.css b/app/scripts/components/repositories/AutoBuildTagsInputItem.css new file mode 100644 index 0000000000..754af1558e --- /dev/null +++ b/app/scripts/components/repositories/AutoBuildTagsInputItem.css @@ -0,0 +1,28 @@ +@import 'dux/css/colors.css'; +@import 'dux/css/box.css'; + +.faBtn { + cursor: pointer; + user-select: none; + margin-left: auto; + margin-right: auto; + margin-top: 0.8rem; +} + +.addBtn { + composes: faBtn; + color: var(--success-color); +} + +.removeBtn { + composes: faBtn; + color: var(--alert-color); +} + +.select { + border-radius: var(--global-radius); +} + +input.rounded { + border-radius: var(--global-radius); +} \ No newline at end of file diff --git a/app/scripts/components/repositories/AutoBuildTagsInputItem.jsx b/app/scripts/components/repositories/AutoBuildTagsInputItem.jsx new file mode 100644 index 0000000000..17a1cb06f9 --- /dev/null +++ b/app/scripts/components/repositories/AutoBuildTagsInputItem.jsx @@ -0,0 +1,123 @@ +'use strict'; + +import React, {PropTypes} from 'react'; +import { Row, Header, Item } from 'common/TagsFlexTable'; +import FA from 'common/FontAwesome'; +import styles from './AutoBuildTagsInputItem.css'; +import Button from '@dux/element-button'; +const {func, string} = PropTypes; + +const AutoBuildTagsInputItem = React.createClass({ + propTypes: { + row: string.isRequired, + sign: string.isRequired, + tagId: string.isRequired, + tagName: string.isRequired, + removeItem: func.isRequired, + sourceName: string.isRequired, + sourceType: string.isRequired, + fileLocation: string.isRequired, + handleBtnClick: func.isRequired, + onSourceTypeChange: func.isRequired, + onSourceNameChange: func.isRequired, + onTagChange: func.isRequired, + onDockerfileLocationChange: func.isRequired + }, + getInitialState: function() { + return { + hidden: false, + sourceType: 'Branch' + }; + }, + _handleHide: function(e) { + e.preventDefault(); + e.stopPropagation(); + this.setState({ + hidden: true + }); + this.props.removeItem(this.props.tagId); + }, + onSourceTypeChange: function(e) { + this.setState({ + sourceType: e.target.value + }); + this.props.onSourceTypeChange(e); + }, + componentDidMount: function() { + this.setState({ + tag: this.props.tagName + }); + }, + render: function() { + + let sourceNamePlaceholder; + let tagNamePlaceholder; + + //Support to show default rule for each type of rule in the UI + const branchesOrTags = this.state.sourceType === 'Branch' ? 'branches' : 'tags'; + const branchOrTag = this.state.sourceType === 'Branch' ? 'branch' : 'tag'; + if (this.state.sourceType === 'Branch') { + sourceNamePlaceholder = 'All branches except master'; + } else { + sourceNamePlaceholder = '/.*/ This targets all tags'; + } + tagNamePlaceholder = `Same as ${branchOrTag}`; + + const { + fileLocation, + handleBtnClick, + onDockerfileLocationChange, + onTagChange, + onSourceNameChange, + row, + sign, + sourceName, + sourceType, + tagName + } = this.props; + + let item = null; + if (!this.state.hidden) { + item = ( + + + + + + + + + + + + + + + + + + + + ); + } + + return item; + } +}); + +module.exports = AutoBuildTagsInputItem; diff --git a/app/scripts/components/repositories/Autobuild.jsx b/app/scripts/components/repositories/Autobuild.jsx new file mode 100644 index 0000000000..5b8d7cf080 --- /dev/null +++ b/app/scripts/components/repositories/Autobuild.jsx @@ -0,0 +1,102 @@ +'use strict'; + +import AutobuildStore from '../../stores/AutobuildStore'; +import Route404 from '../common/RouteNotFound404Page.jsx'; +import AutobuildBlankSlate from './AutobuildBlankSlate.jsx'; +import React, { PropTypes, cloneElement } from 'react'; +import { Link } from 'react-router'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import { SecondaryNav } from 'dux'; +import LiLink from '../common/LiLink'; +const debug = require('debug')('COMPONENT:Autobuild'); + +let AutobuildSourcesNav = React.createClass({ + displayName: 'AutobuildSourcesNav', + propTypes: { + namespace: PropTypes.string, + githubAccount: PropTypes.object, + bitbucketAccount: PropTypes.object + }, + _checkAccountsAndGetLinks: function() { + var liLinks = []; + if (this.props.githubAccount) { + liLinks.push( + + GitHub ({this.props.githubAccount.login}) + ); + } + + if (this.props.bitbucketAccount) { + liLinks.push( + + Bitbucket ({this.props.bitbucketAccount.login}) + ); + } + + if (!this.props.githubAccount || !this.props.bitbucketAccount) { + liLinks.push( Link Accounts); + } + + return liLinks; + }, + render: function() { + var liLinks = this._checkAccountsAndGetLinks(); + return ( +
    + +
      + {liLinks} +
    +
    +
    + ); + } +}); + +var Autobuild = React.createClass({ + propTypes: { + user: React.PropTypes.object, + JWT: React.PropTypes.string + }, + contextTypes: { + executeAction: React.PropTypes.func.isRequired + }, + render: function() { + if (!this.props.JWT) { + return ( + + ); + } else if (!this.props.githubAccount && !this.props.bitbucketAccount) { + return ( +
    + + +
    + ); + } else { + return ( +
    + + {this.props.children && cloneElement(this.props.children, { + user: this.props.user, + JWT: this.props.JWT, + githubAccount: this.props.githubAccount, + bitbucketAccount: this.props.bitbucketAccount + })} +
    + ); + } + } +}); + +export default connectToStores(Autobuild, + [ + AutobuildStore + ], + function({ getStore }, props) { + return getStore(AutobuildStore).getState(); + }); diff --git a/app/scripts/components/repositories/AutobuildBlankSlate.css b/app/scripts/components/repositories/AutobuildBlankSlate.css new file mode 100644 index 0000000000..7de08e056a --- /dev/null +++ b/app/scripts/components/repositories/AutobuildBlankSlate.css @@ -0,0 +1,6 @@ +@import "dux/css/box.css"; +@import "dux/css/colors.css"; + +.row { + margin-top: var(--default-margin); +} diff --git a/app/scripts/components/repositories/AutobuildBlankSlate.jsx b/app/scripts/components/repositories/AutobuildBlankSlate.jsx new file mode 100644 index 0000000000..df12b31f36 --- /dev/null +++ b/app/scripts/components/repositories/AutobuildBlankSlate.jsx @@ -0,0 +1,41 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import Card, { Block } from '@dux/element-card'; +import FA from 'common/FontAwesome'; +import styles from './AutobuildBlankSlate.css'; +import { Link } from 'react-router'; + +var AutobuildBlankSlate = React.createClass({ + displayName: 'AutobuildBlankSlate', + propTypes: { + slateItems: React.PropTypes.element + }, + render() { + var slateItems = null; + if (!this.props.slateItems) { + slateItems = ( +
    +

    You haven't linked to GitHub or Bitbucket yet.

    + Link Accounts +
    + ); + } else { + slateItems = this.props.slateItems; + } + + return ( +
    +
    + + + {slateItems} + + +
    +
    + ); + } +}); + +module.exports = AutobuildBlankSlate; diff --git a/app/scripts/components/repositories/AutobuildIndex.css b/app/scripts/components/repositories/AutobuildIndex.css new file mode 100644 index 0000000000..4059a66e80 --- /dev/null +++ b/app/scripts/components/repositories/AutobuildIndex.css @@ -0,0 +1,28 @@ +@import "dux/css/box.css"; +@import "dux/css/colors.css"; + +.row { + margin-top: var(--default-margin); +} + +.link { + display: block; + background: white; + border-radius: var(--global-radius); + border: 1px solid var(--silver); + color: var(--secondary-4); + margin: 0 .3rem .3rem 0; + padding: 1.8rem; + i { + font-size: 6rem; + margin-bottom: 1rem; + } + &:hover { + background: #f4f9fc; + color: var(--secondary-4); + } + &:focus { + background: color(#f4f9fc blackness(10%)); + color: var(--secondary-4); + } +} diff --git a/app/scripts/components/repositories/AutobuildIndex.jsx b/app/scripts/components/repositories/AutobuildIndex.jsx new file mode 100644 index 0000000000..23158f62e7 --- /dev/null +++ b/app/scripts/components/repositories/AutobuildIndex.jsx @@ -0,0 +1,67 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +import { Link } from 'react-router'; +import FA from 'common/FontAwesome'; +import classnames from 'classnames'; +import styles from './AutobuildIndex.css'; + +export default class AutobuildIndex extends Component { + static propTypes = { + githubAccount: PropTypes.object, + bitbucketAccount: PropTypes.object + } + + render() { + let githubText; + let githubLink; + let bitbucketText; + let bitbucketLink; + const linkClasses = classnames({ + 'button': true, + [styles.link]: true + }); + + if (this.props.githubAccount) { + githubLink = `/add/automated-build/${this.props.params.userNamespace}/github/orgs/`; + githubText = ( +

    Create Auto-build

    + ); + } else { + githubLink = '/account/authorized-services/'; + githubText = ( +

    Link Account

    + ); + } + if (this.props.bitbucketAccount) { + bitbucketLink = `/add/automated-build/${this.props.params.userNamespace}/bitbucket/orgs/`; + bitbucketText = ( +

    Create Auto-build

    + ); + } else { + bitbucketLink = '/account/authorized-services/'; + bitbucketText = ( +

    Link Account

    + ); + } + + return ( +
    +
    + + {githubText} + +

    Github

    + +
    +
    + + {bitbucketText} + +

    Bitbucket

    + +
    +
    + ); + } +} diff --git a/app/scripts/components/repositories/LinkedAccountSourcesForm.css b/app/scripts/components/repositories/LinkedAccountSourcesForm.css new file mode 100644 index 0000000000..32e2acd73a --- /dev/null +++ b/app/scripts/components/repositories/LinkedAccountSourcesForm.css @@ -0,0 +1,3 @@ +.arrowSelect { + margin-top: 0.3rem; +} \ No newline at end of file diff --git a/app/scripts/components/repositories/LinkedAccountSourcesForm.jsx b/app/scripts/components/repositories/LinkedAccountSourcesForm.jsx new file mode 100644 index 0000000000..4ba18daac6 --- /dev/null +++ b/app/scripts/components/repositories/LinkedAccountSourcesForm.jsx @@ -0,0 +1,163 @@ +'use strict'; +import React, { Component, PropTypes } from 'react'; +import { Link } from 'react-router'; +import _ from 'lodash'; +import AutobuildBlankSlate from './AutobuildBlankSlate.jsx'; +import AutobuildSourceRepositoriesStore from '../../stores/AutobuildSourceRepositoriesStore'; +import selectSourceRepoForAutobuild from '../../actions/selectSourceRepoForAutobuild'; +import ListSelector from '../common/ListSelector.jsx'; +import FilterBar from '../filter/FilterBar.jsx'; +import FA from 'common/FontAwesome'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import styles from './LinkedAccountSourcesForm.css'; + +const debug = require('debug')('COMPONENT:LinkedAccountSourcesForm'); +const { array, func } = PropTypes; + +class LinkedAccountSourcesForm extends Component{ + + static propTypes = { + repos: array + } + + static contextTypes = { + executeAction: func.isRequired + } + + state = { + selectedRepos: [], + selectedUserOrg: {} + } + + _handleUserOrgClick = (userOrOrg) => { + //Clicked on user or org, show the repositories under that user or organization + this.setState({ + selectedUserOrg: userOrOrg, + selectedRepos: userOrOrg.repo_list + }); + } + + _handleRepoClick = (item, currentType) => { + //Replace all "/" with "-" if they exist, so route is always valid + item.name = item.name.replace(/[/]/g, '-'); + this.props.history.pushState(null, + `/add/automated-build/${currentType.toLowerCase()}/form/${this.state.selectedUserOrg.name}/${item.name}/`, + {namespace: this.props.params.userNamespace} + ); + //Set the source repository in the store, so the autobuild configuration form can get to it + this.context.executeAction(selectSourceRepoForAutobuild, item); + } + + _makeUserOrgList = (list) => { + var userAndOrgsList = list.map(function(item, idx) { + var imgAvatar = item.avatar_url; + var selectedArrow; + if (this.state.selectedUserOrg.name === item.name) { + selectedArrow = ; + } + return ( +
  • +
    +   {item.name} + {selectedArrow} +
    +
  • ); + }, this); + return userAndOrgsList; + } + + _makeReposList = (list) => { + var links = []; + var currentType = this.props.type; + var _this = this; + const namespace = this.state.selectedUserOrg.name; + if (currentType) { + links = list.map(function(item, idx) { + return ( +
  • + {item.name} +
  • ); + }); + } + return links; + } + + _filterRepos = (query) => { + //e.preventDefault(); + if (query) { + this.setState({ + selectedRepos: _.filter(this.state.selectedRepos, function (repo) { + return repo.name.indexOf(query) !== -1; + }) + }); + } else { + this.setState({ + selectedRepos: this.state.selectedUserOrg.repo_list + }); + } + } + + componentDidMount = () => { + //Set the first element as selected if there is none selected + if (_.isEmpty(this.state.selectedUserOrg) && this.props.repos) { + var selected = this.props.repos[0]; + this.setState({ + selectedUserOrg: selected, + selectedRepos: selected.repo_list + }); + } + } + + componentWillReceiveProps = (nextProps) => { + //Set the first element as selected if there is none selected + if (nextProps.repos && nextProps.repos.length > 0) { + var selected = nextProps.repos[0]; + this.setState({ + selectedUserOrg: selected, + selectedRepos: selected.repo_list + }); + } + } + + render() { + if (this.props.repos) { + var currentUserAndOrgs = this._makeUserOrgList(this.props.repos); + var currentRepos = []; + if (currentUserAndOrgs && currentUserAndOrgs.length > 0) { + currentRepos = this._makeReposList(this.state.selectedRepos); + } + var filterBar = (); + return ( +
    +
    +
    +
    + +
    +
    + +
    +
    +
    + ); + } else { + const slateItem = ( +
    +

    +  An error occurred while trying to connect to {this.props.type}. +

    +
    Link your account here.
    +
    + ); + return (); + } + } +} + +export default connectToStores(LinkedAccountSourcesForm, + [ + AutobuildSourceRepositoriesStore + ], + function({ getStore }, props) { + return getStore(AutobuildSourceRepositoriesStore).getState(); + }); diff --git a/app/scripts/components/repositories/RepositoryBlock.jsx b/app/scripts/components/repositories/RepositoryBlock.jsx new file mode 100644 index 0000000000..2fabeaf3f7 --- /dev/null +++ b/app/scripts/components/repositories/RepositoryBlock.jsx @@ -0,0 +1,62 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import { Link } from 'react-router'; + +export default React.createClass({ + displayName: 'RepositoryBlock', + propTypes: { + namespace: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + status: PropTypes.number, + description: PropTypes.string, + fullDescription: PropTypes.string, + isPrivate: PropTypes.bool, + // trusted means Automated Build + isTrusted: PropTypes.bool, + isOfficial: PropTypes.bool, + starCount: PropTypes.number, + pullCount: PropTypes.number + }, + getDefaultProps: function() { + return { + status: 0, + description: '', + fullDescription: '', + isPrivate: true, + isTrusted: false, + isOfficial: false, + starCount: 0, + pullCount: 0 + }; + }, + render: function() { + return (
  • +
    +
    +
    + +
    +
    +
    + {this.props.namespace} +
    + {this.props.name} +
    + {this.props.isPrivate ? 'Private' : 'Public'} + {this.props.isTrusted ? ' | Automated Build' : ''} +
    +
    +
    +
    +

    {this.props.description}

    +
    +
    +

    {this.props.starCount} STARS

    +

    {this.props.pullCount} DOWNLOADS

    +
    +
    +
  • + ); + } +}); diff --git a/app/scripts/components/search/ResultsNotFound.css b/app/scripts/components/search/ResultsNotFound.css new file mode 100644 index 0000000000..0853d05e32 --- /dev/null +++ b/app/scripts/components/search/ResultsNotFound.css @@ -0,0 +1,39 @@ +@import 'dux/css/colors.css'; + +.wrap { + height: 100%; + text-align: center; +} + +.messageModule { + animation: fadein 0.3s; + padding: 1.5rem 0 0 0; + margin: 4rem 0; +} + +@keyframes fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.heading { + color: #71859d; + font-size: 3rem; + font-weight: 400; + +} + +.subheading { + color: var(--secondary-7); +} + +.message { + color: #71859d; + margin: 0 auto; + padding: .5rem; + font-size: 1.1rem; +} diff --git a/app/scripts/components/search/ResultsNotFound.jsx b/app/scripts/components/search/ResultsNotFound.jsx new file mode 100644 index 0000000000..7cd4a3e226 --- /dev/null +++ b/app/scripts/components/search/ResultsNotFound.jsx @@ -0,0 +1,35 @@ +'use strict'; + +import React, { Component } from 'react'; +import styles from './ResultsNotFound.css'; + +/* + * A component similar to the RouteNotFound404Page meant for no results + * from a search or filter. Any of the props can be customized beyond text, ex: + * } /> + * to include a search icon. + */ +export default class ResultsNotFound extends Component { + + static defaultProps = { + heading: 'Sorry!', + subheading: 'We couldn\'t find any results for this search.', + message: 'Please double check your input and try again.' + } + + render() { + return ( +
    +
    +
    +
    +

    {this.props.heading}

    +

    {this.props.subheading}

    +

    {this.props.message}

    +
    +
    +
    +
    + ); + } +} diff --git a/app/scripts/components/search/Search.jsx b/app/scripts/components/search/Search.jsx new file mode 100644 index 0000000000..e4ce222e00 --- /dev/null +++ b/app/scripts/components/search/Search.jsx @@ -0,0 +1,153 @@ +'use strict'; +import React, { PropTypes } from 'react'; +import FluxibleMixin from 'fluxible-addons-react/FluxibleMixin'; + +import Pagination from '../common/Pagination.jsx'; +import ResultsNotFound from './ResultsNotFound.jsx'; +import SearchStore from './../../stores/SearchStore'; +import SearchBar from './SearchBar.jsx'; +import Spinner from '../Spinner.jsx'; +import FA from '../common/FontAwesome'; +import RepositoriesList from '../common/RepositoriesList'; +import { PageHeader } from 'dux'; +import _ from 'lodash'; +import findKey from 'lodash/object/findKey'; +import DocumentTitle from 'react-document-title'; + +var debug = require('debug')('COMPONENT:Search'); + +var _getQueryParams = function(state) { + //transition to will always have `q` appended as query param at the very least + //Other query params like: `s` -> sort by | `t=User` -> user | `t=Organization` -> Org | `f=official` + // `f=automated_builds` | `s=date_created`, `s=last_updated`, `s=alphabetical`, `s=stars`, `s=downloads` + // `s=pushes` + var queryParams = { + q: state.query || '', + page: state.page || 1, + isAutomated: state.isAutomated || 0, + isOfficial: state.isOfficial || 0, + pullCount: state.pullCount || 0, + starCount: state.starCount || 0 + }; + return queryParams; +}; + +var Search = React.createClass({ + mixins: [FluxibleMixin], + statics: { + storeListeners: [SearchStore] + }, + contextTypes: { + getStore: React.PropTypes.func.isRequired + }, + getInitialState: function() { + return this.context.getStore(SearchStore).getState(); + }, + //on Search Store Change + onChange: function() { + //When a search query has been submitted + var state = this.context.getStore(SearchStore).getState(); + this.setState(state); + }, + _getCurrentFilter: function() { + const query = this.props.location.query; + return findKey(query, (val, key) => { + return val === '1' && key !== 'q' && key !== 'page'; + }); + }, + _retransitionToSearch: function() { + this.props.history.pushState(null, '/search/', _getQueryParams(this.state)); + }, + _onFilterChange: function(event) { + event.preventDefault(); + if (event.target.value === 'isAutomated') { + this.setState({isAutomated: 1, isOfficial: 0, starCount: 0, pullCount: 0}, this._retransitionToSearch); + } else if (event.target.value === 'isOfficial') { + this.setState({isOfficial: 1, isAutomated: 0, starCount: 0, pullCount: 0}, this._retransitionToSearch); + } if (event.target.value === 'starCount') { + this.setState({starCount: 1, isOfficial: 0, isAutomated: 0, pullCount: 0}, this._retransitionToSearch); + } else if (event.target.value === 'pullCount') { + this.setState({pullCount: 1, isOfficial: 0, starCount: 0, isAutomated: 0}, this._retransitionToSearch); + } else if (event.target.value === 'all') { + this.setState({pullCount: 0, isOfficial: 0, starCount: 0, isAutomated: 0}, this._retransitionToSearch); + } + }, + _onChangePage(pageNumber) { + pageNumber = parseInt(pageNumber, 10); + this.setState({ + page: pageNumber + }, function() { + this.props.history.pushState(null, '/search/', _getQueryParams(this.state)); + }); + }, + _renderMessage() { + var message; + if (this.state.count === 0) { + if (this.state.isAutomated === '0' && this.state.isOfficial === '0') { + message = _.isEmpty(this.state.query) ? `Your search is empty!` : `Your search of '${this.state.query}' did not match any repository names or descriptions.`; + } else { + let filterType = this.state.isAutomated === '1' ? 'automated builds' : 'official repositories'; + message = `There are no ${filterType} matching '${this.state.query}'. Try removing this filter to see more results.`; + } + return ( +
    +
    + } message={ message } /> +
    +
    + ); + } else { + return ; + } + }, + _renderFilterBar() { + if (this.state.count === 0 && this.state.isAutomated === '0' && this.state.isOfficial === '0') { + return ; + } else { + return ( +
    +
    + +
    +
    + ); + } + }, + render: function() { + var maybePagination; + if (this.state.results && this.state.results.length > 0 && this.state.count > 10) { + maybePagination = ( + +
    +
    + +
    +
    +
    + ); + } + return ( + +
    + +
    +
    + { this._renderFilterBar() } + + {maybePagination} +
    +
    +
    + ); + } +}); +module.exports = Search; diff --git a/app/scripts/components/search/SearchBar.css b/app/scripts/components/search/SearchBar.css new file mode 100644 index 0000000000..46c8b37690 --- /dev/null +++ b/app/scripts/components/search/SearchBar.css @@ -0,0 +1,17 @@ +@import "dux/css/box"; + +input.searchInput { + background: #405165; + border: 1px solid #4c5968; + border-radius: var(--global-radius); + color: #fff; + padding-left: 24px; +} + +.fa { + position: relative; + color: white; + max-width: 1rem; + top: -7px; + left: 6px; +} \ No newline at end of file diff --git a/app/scripts/components/search/SearchBar.jsx b/app/scripts/components/search/SearchBar.jsx new file mode 100644 index 0000000000..86994c2df3 --- /dev/null +++ b/app/scripts/components/search/SearchBar.jsx @@ -0,0 +1,84 @@ +'use strict'; +import React from 'react'; +import FluxibleMixin from 'fluxible-addons-react/FluxibleMixin'; +import SearchStore from '../../stores/SearchStore'; +import styles from './SearchBar.css'; +import FA from '../common/FontAwesome'; + +var debug = require('debug')('COMPONENT:SearchBar'); + +var _getQueryParams = function(state) { + //transition to will always have `q` appended as query param at the very least + //Other query params like: `s` -> sort by | `t=User` -> user | `t=Organization` -> Org | `f=official` + // `f=automated_builds` | `s=date_created`, `s=last_updated`, `s=alphabetical`, `s=stars`, `s=downloads` + // `s=pushes` + var queryParams = { + q: state.query || '', + page: state.page || 1, + isAutomated: state.isAutomated || 0, + isOfficial: state.isOfficial || 0, + starCount: state.starCount || 0, + pullCount: state.pullCount || 0 + }; + return queryParams; +}; + +var SearchBar = React.createClass({ + mixins: [FluxibleMixin], + statics: { + storeListeners: [SearchStore] + }, + contextTypes: { + getStore: React.PropTypes.func.isRequired + }, + getDefaultProps() { + return { + placeholder: 'Search' + }; + }, + getInitialState: function() { + return this.context.getStore(SearchStore).getState(); + }, + //on Search Store Change + onChange: function() { + //When a search query has been submitted + var state = this.context.getStore(SearchStore).getState(); + this.setState(state); + }, + _handleQueryChange: function(event) { + event.preventDefault(); + //Change page to number 1 when the query is changed + this.setState({ + page: 1 + }); + this.setState({query: event.target.value}); + }, + _handleQuerySubmit: function(event) { + event.preventDefault(); + //second parameter will be empty object always since we don't have /search/{?}/ + //third param will be the query /search/?q=whatever&s=blah&f=bleh + this.props.history.pushState(null, '/search/', _getQueryParams(this.state)); + }, + render: function() { + var searchQuery = this.state.query; + var inputPlaceholder = this.props.placeholder; + return ( +
    +
    +
    + +
    + +
    +
    +
    +
    + ); + } +}); + +module.exports = SearchBar; diff --git a/app/scripts/components/search/SearchResultItem.jsx b/app/scripts/components/search/SearchResultItem.jsx new file mode 100644 index 0000000000..579c35becf --- /dev/null +++ b/app/scripts/components/search/SearchResultItem.jsx @@ -0,0 +1,67 @@ +'use strict'; + +import React from 'react'; +import Badge from '../Badge.jsx'; +import StatsComponent from '../StatsComponent.jsx'; +var debug = require('debug')('COMPONENT:SearchResultItem'); + +//TODO: will go under the ul in item info, will be a bunch of key value pairs reused across +//TODO: Logged out views will have the `owner/reponame` (think about this) +//TODO: Star icon should be passed to badge as d-`iconname` where `d-` is for the docker font icons + +var SearchResultItem = React.createClass({ + render: function() { + var resultItem = this.props.resultItem; + + //Push badges based on result item + var badges = []; + var officialBadge =
  • ; + var autobuildBadge =
  • ; + + // jscs:disable requireCamelCaseOrUpperCaseIdentifiers + if (resultItem.is_official) { + badges.push(officialBadge); + } else if (resultItem.is_automated) { + badges.push(autobuildBadge); + } + + //TODO: repo_owner is null atm, since API performance degrades if we try to get it + //
  • + + return ( +
  • +
    +
    +
    + +
    +
    +
    +
    {resultItem.repo_name}
    +
    +
    +
      + {badges} +
    +
    +
    +
    +
      +
    • +
    • +
    +
    +
    +
    +
    +

    {resultItem.short_description}

    +
    +
    +
    +
  • + ); + // jscs:enable + } +}); + +module.exports = SearchResultItem; diff --git a/app/scripts/components/search/SearchResults.jsx b/app/scripts/components/search/SearchResults.jsx new file mode 100644 index 0000000000..bd56981d11 --- /dev/null +++ b/app/scripts/components/search/SearchResults.jsx @@ -0,0 +1,42 @@ +'use strict'; +/** +TODO: UNUSED COMPONENT. SHOULD REMOVE +*/ +import React from 'react'; +import ResultItem from './SearchResultItem.jsx'; +var debug = require('debug')('COMPONENT:SearchResults'); + +var SearchResults = React.createClass({ + contextTypes: { + getStore: React.PropTypes.func.isRequired + }, + getResultItem: function(resultItem, idx) { + if (resultItem.short_description) { + if (resultItem.short_description.length > 100) { + resultItem.short_description = resultItem.short_description.substring(0, 100) + '...'; + } else if (resultItem.short_description.length === 0) { + resultItem.short_description = 'No description set'; + } + } + return ; + }, + _handleSearchResultClick: function(repoName, e) { + e.preventDefault(); + //TODO: Handle official images/repos differently + this.props.history.pushState(null, `/r/${repoName}/`); + }, + render: function() { + var results = this.props.results; + var resultItems = []; + if (results) { + resultItems = results.map(this.getResultItem); + } + return ( +
    +
      {resultItems}
    +
    + ); + } +}); + +module.exports = SearchResults; diff --git a/app/scripts/components/store-promotion.js b/app/scripts/components/store-promotion.js new file mode 100644 index 0000000000..13645dc176 --- /dev/null +++ b/app/scripts/components/store-promotion.js @@ -0,0 +1,106 @@ +'use strict'; + +module.exports = { + STORE_OFFICIAL_REPONAME_ID_MAP: { + busybox: '061eb9c1-aeec-4ec8-8c4b-ba30ae1d5b47', + telegraf: 'f031f47c-6f88-4c21-a31a-20be46809d37', + consul: 'fec1f6b9-0a26-49b7-990b-d001a3496cc6', + sourcemage: '983e7005-65f1-4ff0-833e-a2edce972d88', + nuxeo: '139c1693-dc39-42cd-8104-cdf838504954', + mysql: '3083290a-203f-4c04-b2de-cc057959d2c9', + piwik: '40157634-cee7-462f-9927-27d734de3a28', + ros: '4e84c2be-5bb3-40f8-8854-8901e496d576', + 'buildpack-deps': '9e56c286-5b40-4838-89fe-fd513c9c3bd6', + 'hello-world': 'f35b81c7-b96a-45d3-809d-99eef381e71f', + sonarqube: '3f8fc4ce-eb8e-40ad-88ba-69e97299c64f', + celery: '4a239159-e3cf-42b1-b16f-6a9a8555285f', + neurodebian: '63dbd2e1-f29e-498b-8b16-1477770ae733', + glassfish: '4dfbd2c4-46dd-4674-8595-433594514a69', + erlang: '06fabd5a-6420-4209-a756-3d3355b3c672', + cirros: 'e3d3b771-35d3-45fe-92af-3f8bbd56cb40', + postgres: '022689bf-dfd8-408f-9e1c-19acac32e57b', + python: '1ae86987-df14-4741-9433-d9602a4da995', + crux: '8319429c-874d-4cfa-9dde-b861f7245eab', + pypy: '91db8014-64b0-4417-9429-e8128e04191c', + alpine: 'e7e5dbc4-2103-4b7c-9409-b0ca32ce3d83', + memcached: 'deec12eb-5792-49d2-919e-0b861bb910cd', + rabbitmq: 'fa7625b4-fdca-4b48-b078-692f6451965a', + orientdb: '7a741b7c-b96a-44ce-b64d-d0b59e6a7803', + chronograf: 'c3749997-031b-4ced-9f0b-cb4d15b6a6a1', + centos: 'd5052416-4069-4619-8597-ba61df35ba6f', + hipache: '12bafabf-353e-47ad-a416-44d827874489', + kibana: '41105537-6820-4448-908b-4ac7b31be0c2', + 'websphere-liberty': 'cacdc40b-0fed-4221-952a-18b0b8a3500b', + redis: '1f6ef28b-3e48-4da1-b838-5bd8710a2053', + 'mongo-express': 'e89a86ce-9988-4ee1-ada9-228925730018', + lightstreamer: 'b0766515-2ca5-4ea2-9120-713cab78d0f7', + gazebo: 'e42e1e00-1168-4815-b951-6ee5bae86d58', + kapacitor: 'f310b81d-5c23-4936-880d-027ffe296557', + influxdb: '2053499a-fe59-495d-a024-518311223f3f', + maven: '3e44be96-02cf-427e-bdbe-f108ec3be831', + odoo: 'f351cb39-4574-44e7-afa6-b3cc87390041', + notary: '7cb50ac7-d119-4ec7-a276-cad5154fb67e', + solr: 'f4e3929d-d8bc-491e-860c-310d3f40fff2', + logstash: '6464c97c-8e9c-492c-8a35-27620300a6cc', + thrift: '579aa075-9d28-4ae5-9966-357b96f70550', + haproxy: '85c386ff-85a7-4d61-b309-5901f625c36f', + drupal: '680b2b97-bc91-44fc-9437-73748b0a8f0a', + mono: '4234a761-444b-4dea-a6b3-31bda725c427', + joomla: 'e17a7172-1f32-4191-83ba-606f354cce16', + elasticsearch: '1090e442-627e-4bf2-b29a-555f57a64ecd', + clojure: '800b3c7e-3b83-4af5-ab84-b57086887339', + 'rakudo-star': '2a6a7106-f60e-495a-b085-e0496e23b72e', + traefik: '0319de97-4b7f-4a74-a260-5d027ec8cee0', + elixir: '4e418a43-2be9-44fd-8640-9dbb82c369a2', + irssi: 'cb02bc13-349b-4aad-9b2e-6045c16d9c4c', + julia: 'b228ffc7-3d8c-46c5-9e2d-4101e2b9629a', + jruby: 'c90a0b81-88c5-437a-87b2-38c9a398451e', + haskell: '74b1a6b9-c84a-4221-891d-18d28f1798e5', + neo4j: '5f0be9a7-f5d7-4974-9c0e-78c33484e79c', + httpd: '3574ef8c-86e4-40ed-a705-3a52d9786bde', + php: '9c2c5426-0cca-4a30-a450-b2961541c6dc', + java: '199a18b1-511b-47fd-b287-a41555fafb9f', + bonita: 'c37b224a-dec9-4dbe-b18a-5f1789766409', + jetty: 'd88c12a5-0ca7-4358-aef0-c759b0e5290a', + rails: 'a3ed7866-8446-4221-b86d-67507eb6be3e', + perl: '5241d150-6793-4ca8-a392-51098722f672', + swarm: '8ea4fd22-82ac-4ec8-b89d-018cb0944d72', + redmine: 'dfd4330b-0b3a-479c-8bbc-8b4cad6235b8', + wordpress: 'c14a56d6-07e4-464b-b71c-4b24dc7f1836', + tomee: 'afc9cbe9-8325-4609-ad48-cc891f1e0002', + photon: '45053cf9-0044-46cc-9748-d6d01a548dd3', + 'r-base': 'f2e50720-cada-432f-85a5-1ade438d537b', + oraclelinux: '505eac14-101f-44de-b80f-d79743a2288d', + ruby: '0f900dcb-7e32-45e4-b095-6dfa2f5b597b', + iojs: 'c3a93fc8-ef57-47df-ad12-6e443cd93f61', + rethinkdb: '6e5e7f5d-52fa-4598-a8fc-f1911a873ac0', + mongo: '9147d1b7-a686-4e38-8ecd-94a47f5da9cf', + golang: '3e4f3e51-3930-4dd8-975c-517705d9d4e7', + mageia: '179f8859-4966-4750-a2a9-5cd203556556', + owncloud: 'a66e9029-57d7-4aef-89b0-a98987e8dbf2', + cassandra: '53ad54dd-9bd8-48cd-89b4-fc1d5fdfa6cc', + gcc: '06ad851d-f666-47d3-9ef3-e90535c141ec', + fedora: 'a5648d34-aacd-4599-ab72-79662aa09df2', + couchdb: '263ad544-d7c7-4892-8244-fe78b6231265', + arangodb: '806abe10-8cee-451b-856b-57bcf0fdf6d0', + jenkins: 'd55eda09-d7f0-47b0-8780-3407f2f9142c', + hylang: '62cb43d8-bf5b-4115-8408-ff1412ead82e', + node: 'e6658267-cc55-421e-b5be-5e69460fb0d1', + percona: '176b92e3-b122-422c-96f0-d518bffe9bd5', + registry: 'd93c7069-a612-4019-ade6-8c3b0a73acd9', + mariadb: '1712cb54-62e1-405b-a973-1492552c9bb9', + debian: '40efd176-3c0a-4460-9f94-fc98ca92362f', + ghost: '57aa97f1-9868-4936-906c-b83d78cfaf4f', + opensuse: '99f9bd45-4c65-4e45-aa36-234e9150efc6', + tomcat: '3d5f71ad-2cc0-467f-ab6a-351e7adf404e', + django: '65765d71-d893-407d-a707-486c7381dfbf', + backdrop: 'aa66b4ed-fd75-4a87-87a7-c173c740a42f', + docker: '995677a1-1f6a-4e2e-929a-fde926abbd95', + ubuntu: '414e13de-f1ba-40d0-9867-08f2e5884b3f', + nginx: '37b1dde7-a3e7-463a-a0e3-d8be2b136292', + sentry: '788f45b7-2701-4244-90e4-3f85aef116d8', + couchbase: '881296c9-606f-4861-91b6-26a31ef52618', + crate: 'dfac6fed-98d7-4c5d-b338-fb6930c62c69', + nats: '59c6aa2f-dcca-4f2c-8c30-2ea2326feab2' + } +}; diff --git a/app/scripts/components/userWrapper/UserStars.jsx b/app/scripts/components/userWrapper/UserStars.jsx new file mode 100644 index 0000000000..1fa3055750 --- /dev/null +++ b/app/scripts/components/userWrapper/UserStars.jsx @@ -0,0 +1,67 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +const { array, string, func } = PropTypes; +import connectToStores from 'fluxible-addons-react/connectToStores'; + +import UserProfileStarsStore from 'stores/UserProfileStarsStore'; +import RepositoriesList from 'common/RepositoriesList'; +import Pagination from 'common/Pagination'; +import { Module } from 'dux'; + +var debug = require('debug')('UserProfileStars'); + +var UserStars = React.createClass({ + displayName: 'UserStars', + propTypes: { + starred: array, + next: string, + prev: string + }, + _onChangePage(pageNumber) { + this.props.history.pushState(null, `/u/${this.props.user.username}/starred/`, {page: pageNumber}); + }, + render() { + + var currentPageNumber = parseInt(this.props.location.query.page, 10); + var maybePagination; + + if(this.props.starred && this.props.starred.length > 0) { + maybePagination = ( +
    +
    + +
    +
    + ); + + return ( +
    +
    + + {maybePagination} +
    +
    + ); + + } else { + + return ( + This user does not have any starred repos. + ); + + } + } +}); + +export default connectToStores(UserStars, + [ + UserProfileStarsStore + ], + function({ getStore }, props) { + return getStore(UserProfileStarsStore) + .getState(); + }); diff --git a/app/scripts/components/userprofile/Repos.jsx b/app/scripts/components/userprofile/Repos.jsx new file mode 100644 index 0000000000..05ecd4898b --- /dev/null +++ b/app/scripts/components/userprofile/Repos.jsx @@ -0,0 +1,79 @@ +'use strict'; + +import React, { PropTypes, Component } from 'react'; +import { Link } from 'react-router'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import UserProfileReposStore from '../../stores/UserProfileReposStore'; +import RepositoriesList from '../common/RepositoriesList'; +import Pagination from '../common/Pagination'; +import moment from 'moment'; +import { Module } from 'dux'; + +var debug = require('debug')('UserProfileRepos'); + +class Repos extends Component { + + static propTypes = { + user: PropTypes.object.isRequired, + repos: PropTypes.array, + next: PropTypes.string, + prev: PropTypes.string + } + + _onChangePage = (pageNumber) => { + const username = this.props.params.user; + this.props.history.pushState(null, `/u/${username}/`, {page: pageNumber}); + } + + render() { + + debug(this.props); + if(this.props.repos && this.props.repos.length > 0) { + + return ( +
    +
    + + {this.renderPagination()} +
    +
    + ); + + } else { + debug(this.props); + const { username, orgname } = this.props.user; + const namespace = username || orgname; + return ( + + This user has not created any repos yet + + ); + } + } + renderPagination = (e) => { + if(this.props.repos && this.props.repos.length > 0) { + const currentPageNumber = parseInt(this.props.location.query.page, 10); + return ( +
    +
    + +
    +
    + ); + } else { + return null; + } + } +} + +export default connectToStores(Repos, + [ + UserProfileReposStore + ], + function({ getStore }, props) { + return getStore(UserProfileReposStore) + .getState(); + }); diff --git a/app/scripts/components/utils/avatar.js b/app/scripts/components/utils/avatar.js new file mode 100644 index 0000000000..0750062fb6 --- /dev/null +++ b/app/scripts/components/utils/avatar.js @@ -0,0 +1,30 @@ +'use strict'; + +import officialLogos from 'common/data/officialLogos.js'; +const officialImage = '/public/images/logos/mini-logo-white-inset.png'; + +const isOfficialNamespace = (namespace) => namespace === '_' || namespace === 'library'; + +// reponame is an optional param used to descriminate between official repos +// and it will be ignored unless it is an official repo +export function mkAvatarForNamespace(namespace, reponame) { + if (isOfficialNamespace(namespace) && reponame && officialLogos[reponame]) { + return `/public/images/official/${officialLogos[reponame]}`; + } + if(isOfficialNamespace(namespace) || !namespace) { + return officialImage; + } + return `${process.env.HUB_API_BASE_URL}/v2/users/${namespace}/avatar/`; +} + +/** + * This function should be treated as deprecated. We can not reliably + * detect the namespace type (User || Org) from the urls. + */ +export function mkAvatarForOrg(namespace) { + return `${process.env.HUB_API_BASE_URL}/v2/orgs/${namespace}/avatar/`; +} + +export function isOfficialAvatarURL(url) { + return url === officialImage; +} diff --git a/app/scripts/components/utils/bytesToSize.js b/app/scripts/components/utils/bytesToSize.js new file mode 100644 index 0000000000..bf0b6bebc9 --- /dev/null +++ b/app/scripts/components/utils/bytesToSize.js @@ -0,0 +1,23 @@ +'use strict'; + +export default function bytesToSize(bytes, precision) +{ + const kilobyte = 1000; + const megabyte = kilobyte * 1000; + const gigabyte = megabyte * 1000; + const terabyte = gigabyte * 1000; + + if ((bytes >= 0) && (bytes < kilobyte)) { + return bytes + ' B'; + } else if ((bytes >= kilobyte) && (bytes < megabyte)) { + return (bytes / kilobyte).toFixed(precision) + ' KB'; + } else if ((bytes >= megabyte) && (bytes < gigabyte)) { + return (bytes / megabyte).toFixed(precision) + ' MB'; + } else if ((bytes >= gigabyte) && (bytes < terabyte)) { + return (bytes / gigabyte).toFixed(precision) + ' GB'; + } else if (bytes >= terabyte) { + return (bytes / terabyte).toFixed(precision) + ' TB'; + } else { + return 'Unknown size'; + } +} diff --git a/app/scripts/components/utils/encodeForm.js b/app/scripts/components/utils/encodeForm.js new file mode 100644 index 0000000000..5f6b5d64d3 --- /dev/null +++ b/app/scripts/components/utils/encodeForm.js @@ -0,0 +1,17 @@ +'use strict'; +import map from 'lodash/collection/map'; + +const _encodeForm = (obj, prefix) => { + const str = []; + map(obj, (value, key) => { + if (obj.hasOwnProperty(key)) { + const k = prefix ? `${prefix}[${key}]` : key; + str.push(typeof value === 'object' ? + _encodeForm(value, k) : + `${encodeURIComponent(k)}=${encodeURIComponent(value)}`); + } + }); + return str.join('&'); +}; + +export default _encodeForm; diff --git a/app/scripts/components/utils/validateRepositoryName.js b/app/scripts/components/utils/validateRepositoryName.js new file mode 100644 index 0000000000..3c6167fc87 --- /dev/null +++ b/app/scripts/components/utils/validateRepositoryName.js @@ -0,0 +1,7 @@ +'use strict'; + +export const validateRepositoryName = (name) => +{ + var repoNamePattern = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/; + return repoNamePattern.test(name); +}; diff --git a/app/scripts/components/welcome/ActivityFeedItem.jsx b/app/scripts/components/welcome/ActivityFeedItem.jsx new file mode 100644 index 0000000000..f496695aa9 --- /dev/null +++ b/app/scripts/components/welcome/ActivityFeedItem.jsx @@ -0,0 +1,41 @@ +'use strict'; +import React from 'react'; +import moment from 'moment'; + +//TODO: remove this after a better solution is found +var _getNotificationNameForID = function(user, notificationType) { + //TODO: maybe create a link to the user's public profile page if any + if(notificationType === 'trusted_build_fail') { + return 'Automated build failure.'; + } else if (notificationType === 'new_repo_star') { + return user + ' starred a repository'; + } else if (notificationType === 'new_repo_comment') { + return user + ' added a new comment.'; + } else { + return ''; + } +}; + +var ActivityFeedItem = React.createClass({ + displayName: 'ActivityFeed', + getDefaultProps: function() { + return { + notification: 0, + /*eslint-disable camelcase */ + last_occurence: '', + /*eslint-enable camelcase */ + user: '' + }; + }, + render: function() { + return ( +
  • +
    +

    {_getNotificationNameForID(this.props.user, this.props.notification)}

    + {moment(this.props.last_occurence).fromNow()} +
    +
  • ); + } +}); + +module.exports = ActivityFeedItem; diff --git a/app/scripts/components/welcome/ChangePassSuccess.css b/app/scripts/components/welcome/ChangePassSuccess.css new file mode 100644 index 0000000000..d74134b2c6 --- /dev/null +++ b/app/scripts/components/welcome/ChangePassSuccess.css @@ -0,0 +1,9 @@ +@import "dux/css/colors"; +@import "dux/css/box"; + +.passwordReset { + margin-top: 5rem; + border: 1px solid rgb(233, 237, 240); + border-radius: var(--global-radius); + min-height: 270px; +} diff --git a/app/scripts/components/welcome/ChangePassSuccess.jsx b/app/scripts/components/welcome/ChangePassSuccess.jsx new file mode 100644 index 0000000000..ff127d995f --- /dev/null +++ b/app/scripts/components/welcome/ChangePassSuccess.jsx @@ -0,0 +1,30 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import { Button } from 'dux'; +import { Link } from 'react-router'; +import ChangePasswordStore from '../../stores/ChangePasswordStore.js'; +import styles from './ChangePassSuccess.css'; + +var changePassSuccess = React.createClass({ + contextTypes: { + executeAction: PropTypes.func.isRequired + }, + PropTypes: { + changePassStore: PropTypes.shape({ + reset: PropTypes.bool.isRequired, + resetErr: PropTypes.bool.isRequired + }) + }, + render: function() { + return ( +
    +

    Your password has been reset

    +

    You may now login with your new password

    + Back to Login +
    + ); + } +}); + +module.exports = changePassSuccess; diff --git a/app/scripts/components/welcome/DashboardNewsItem.jsx b/app/scripts/components/welcome/DashboardNewsItem.jsx new file mode 100644 index 0000000000..edf3f5902f --- /dev/null +++ b/app/scripts/components/welcome/DashboardNewsItem.jsx @@ -0,0 +1,19 @@ +'use strict'; +const React = require('react'); + +//This is a placeholder for a news item that is shown in the dashboard. Relevant to anything that needs attention. + +var DashboardNewsItem = React.createClass({ + render: function() { + return ( +
    +
    Run Docker in your network.
    + +
    + ); + } +}); + +module.exports = DashboardNewsItem; diff --git a/app/scripts/components/welcome/DashboardTabs.jsx b/app/scripts/components/welcome/DashboardTabs.jsx new file mode 100644 index 0000000000..ed2b80b5db --- /dev/null +++ b/app/scripts/components/welcome/DashboardTabs.jsx @@ -0,0 +1,82 @@ +'use strict'; + +import React, { PropTypes } from 'react'; +import classnames from 'classnames'; + +/* +
    +
    +
    + NO CONTENT HERE +
    +
    +
    + */ + +export default React.createClass({ + displayName: 'DashboardTabs', + propTypes: { + myrepos: React.PropTypes.element.isRequired, + contribs: React.PropTypes.element.isRequired, + starred: React.PropTypes.element.isRequired, + activity: React.PropTypes.element.isRequired + }, + getInitialState() { + return { + tabType: 'myRepos' + }; + }, + _getActiveTab() { + switch(this.state.tabType) { + case 'myRepos': + //Show grid of my repositories + return this.props.myrepos; + case 'contrib': + //Show grid of contributed repositories + return this.props.contribs; + case 'starred': + //Show grid of my starred repositories + return this.props.starred; + case 'activity': + //Show my activity feed + return this.props.activity; + } + }, + _getTitleClassNames(type) { + return classnames({ + 'active': this.state.tabType === type, + 'tab-title': true + }); + }, + _handleTabClick(type, evt) { + evt.preventDefault(); + this.setState({ + tabType: type + }); + }, + render() { + var activeTab = this._getActiveTab(); + + return ( +
    +
    +
      +
    • My Repositories
    • +
    • Contributed
    • +
    • Starred
    • +
    • Recent Activity
    • +
    +
    +
    +
    +
    +
    + {activeTab} +
    +
    +
    +
    +
    + ); + } +}); diff --git a/app/scripts/components/welcome/ForgotPass.css b/app/scripts/components/welcome/ForgotPass.css new file mode 100644 index 0000000000..725901d3a6 --- /dev/null +++ b/app/scripts/components/welcome/ForgotPass.css @@ -0,0 +1,42 @@ +@import "dux/css/colors"; +@import "dux/css/box"; + +.forgotPassPage { + background: var(--docker-dark); + height: 100%; + padding-top: 100px; +} + +.forgotPassSubmit { + margin-top: var(--default-margin); + display: flex; + flex-direction: column; + justify-content: center; +} + +.header { + text-align: center; +} + +.head { + color: var(--white); + font-size: 2rem; + font-weight: 200; +} + +.subHead { + color: var(--info-color); +} + +.subHeadLight { + color: #188bb3; +} + +.logo { + width: 90px; + height: 110px; +} + +.btn { + margin-top: 1rem; +} diff --git a/app/scripts/components/welcome/ForgotPass.jsx b/app/scripts/components/welcome/ForgotPass.jsx new file mode 100644 index 0000000000..08de9a036b --- /dev/null +++ b/app/scripts/components/welcome/ForgotPass.jsx @@ -0,0 +1,98 @@ +'use strict'; + +import React from 'react'; +import { Link } from 'react-router'; +import Button from '@dux/element-button'; +import FancyInput from 'common/FancyInput'; +import forgotPasswordSubmit from '../../actions/forgotPasswordSubmit.js'; +import styles from './ForgotPass.css'; +var debug = require('debug')('Password Reset'); + +module.exports = React.createClass({ + contextTypes: { + executeAction: React.PropTypes.func.isRequired + }, + getInitialState: function() { + return { + email: '', + reset: false + }; + }, + onEmailChange: function(e) { + e.preventDefault(); + this.setState({email: e.target.value}); + }, + forgotPassSubmit: function(e) { + e.preventDefault(); + debug(this.state.email); + if (this.state.email) { + this.context.executeAction(forgotPasswordSubmit, {email: this.state.email}); + this.setState({reset: true}); + } + }, + transitionHome: function(e) { + e.preventDefault(); + this.props.history.pushState(null, '/'); + }, + render: function() { + var disabledState; + if (this.state.email) { + disabledState = false; + } else { + disabledState = true; + } + + if (!this.state.reset) { + return ( +
    +
    +
    + + docker logo + +
    Reset your password
    +
    Enter an email address associated with a Docker ID.
    +
    +
    +
    +
    +
    + +
    +
    +
    + We’ll send a password reset link to the Docker ID’s primary email address. +
    +
    + If you don't have access to your primary email address, contact Docker Support. +
    +
    + +
    +
    +
    +
    +
    + ); + } else { + return ( +
    +
    +
    + + docker logo + +
    Reset request sent!
    +

    Password reset link sent. This link is valid for 24 hours. If you don't see a password reset link, check your spam folder.

    + +
    +
    +
    + ); + } + } +}); diff --git a/app/scripts/components/welcome/LoginForm.css b/app/scripts/components/welcome/LoginForm.css new file mode 100644 index 0000000000..a0ccde463f --- /dev/null +++ b/app/scripts/components/welcome/LoginForm.css @@ -0,0 +1,13 @@ +.formWrapper { + display: inline-block; + width: 100%; + margin: 1.5rem 0; +} + +.buttonWrapper { + display: flex; + justify-content: space-between; + .help { + margin: 0 .75rem; + } +} diff --git a/app/scripts/components/welcome/LoginForm.jsx b/app/scripts/components/welcome/LoginForm.jsx new file mode 100644 index 0000000000..e37ce5fa78 --- /dev/null +++ b/app/scripts/components/welcome/LoginForm.jsx @@ -0,0 +1,83 @@ +'use strict'; + +import React, { createClass, PropTypes } from 'react'; +import _ from 'lodash'; +import { Link } from 'react-router'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import Button from '@dux/element-button'; +var debug = require('debug')('LoginForm'); + +import onChange from '../../actions/common/onChangeUtil'; +import FancyInput from 'common/FancyInput'; +import LoginStore from '../../stores/LoginStore'; +import loginUpdateFormField from '../../actions/loginUpdateFormField'; +import styles from './LoginForm.css'; + +var LoginForm = createClass({ + displayName: 'LoginForm', + contextTypes: { + executeAction: React.PropTypes.func.isRequired + }, + propTypes: { + loginAction: PropTypes.func.isRequired + }, + _onChange: onChange({ + storePrefix: 'LOGIN' + }), + _handleSubmit(event) { + event.preventDefault(); + var loginPayload = this.props.values; + loginPayload.username = loginPayload.username.toLowerCase(); + this.context.executeAction(this.props.loginAction, loginPayload); + }, + render() { + const { fields, values, variant, includeHelp } = this.props; + + var globalFormError = null; + if(this.props.globalFormError) { + globalFormError =

    {this.props.globalFormError}

    ; + } + + let loginButton = (); + if(this.props.STATUS === 'ATTEMPTING_LOGIN') { + loginButton = (); + } + let help; + if (includeHelp) { + help = ( + Can't Login? + ); + } + return ( +
    + {globalFormError} + + +
    +
    + {help} +
    + {loginButton} +
    + + ); + } +}); + +export default connectToStores(LoginForm, + [LoginStore], + function({ getStore }, props){ + return getStore(LoginStore).getState(); + }); diff --git a/app/scripts/components/welcome/ResetPass.css b/app/scripts/components/welcome/ResetPass.css new file mode 100644 index 0000000000..4c5e3cc180 --- /dev/null +++ b/app/scripts/components/welcome/ResetPass.css @@ -0,0 +1,39 @@ +@import "dux/css/colors"; +@import "dux/css/box"; + +.resetPassPage { + background: var(--docker-dark); + height: 100%; + padding-top: 100px; +} + +.resetPassSubmit { + margin-top: var(--default-margin); + display: flex; + flex-direction: column; + justify-content: center; +} + +.header { + text-align: center; +} + +.head { + color: var(--white); + font-size: 2rem; + font-weight: 200; +} + +.subHead { + color: var(--info-color); +} + +.logo { + width: 90px; + height: 110px; +} + +.back { + margin-top: var(--default-margin); + text-align: center; +} diff --git a/app/scripts/components/welcome/ResetPass.jsx b/app/scripts/components/welcome/ResetPass.jsx new file mode 100644 index 0000000000..49a6ecac95 --- /dev/null +++ b/app/scripts/components/welcome/ResetPass.jsx @@ -0,0 +1,152 @@ +/** +MjA2MTU is a base64 encoding of the pk for the user account in hub + +dhiltgen [11:07 AM] +41y-d0f275462b1678b1aeca is a random generated token + **/ +'use strict'; + +import React, { PropTypes } from 'react'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import Button from '@dux/element-button'; +import FancyInput from 'common/FancyInput'; +import { Link } from 'react-router'; +import resetPasswordSubmit from '../../actions/resetPasswordSubmit.js'; +import ChangePasswordStore from '../../stores/ChangePasswordStore.js'; +import clearChangePasswordStore from '../../actions/clearChangePasswordStore'; +import styles from './ResetPass.css'; +var debug = require('debug')('Password Reset Confirmation: '); + +var ResetPassword = React.createClass({ + contextTypes: { + executeAction: PropTypes.func.isRequired + }, + PropTypes: { + changePassStore: PropTypes.shape({ + reset: PropTypes.bool.isRequired, + resetErr: PropTypes.bool.isRequired + }) + }, + getInitialState: function() { + return { + pass1: '', + pass2: '', + passErr: false + }; + }, + onPassChange: function(e) { + e.preventDefault(); + this.setState({pass1: e.target.value}); + }, + onConfirmChange: function(e) { + e.preventDefault(); + var confirm = e.target.value; + this.setState({pass2: confirm, passErr: false}); + }, + onErrorRetry: function(e) { + e.preventDefault(); + this.setState({ + pass1: '', + pass2: '' + }); + this.context.executeAction(clearChangePasswordStore); + }, + transitionLogin: function(e) { + e.preventDefault(); + this.props.history.pushState('/login/'); + }, + resetPassSubmit: function(e) { + e.preventDefault(); + debug('reset pass submit'); + if (this.state.pass1 === this.state.pass2) { + var { uidb64, reset_token } = this.props.params; + this.context.executeAction(resetPasswordSubmit, + {uidb64: uidb64, + reset_token: reset_token, + password_1: this.state.pass1, + password_2: this.state.pass2}); + this.setState({reset: true}); + } else { + this.setState({ passErr: true }); + } + }, + render: function() { + var disabledState = (this.state.passErr || !this.state.pass1); + + if (!this.props.changePassStore.reset && !this.props.changePassStore.hasErr) { + let error; + if (this.state.passErr) { + error = 'Make sure passwords are identical'; + } + return ( +
    +
    +
    +
    Password Reset
    +
    Enter your new password.
    +
    +
    +
    +
    +
    + + + + +
    +
    +
    + ); + } else if (this.props.changePassStore.hasErr) { + return ( +
    +
    +
    +
    There was an error!
    +
    Your password has not been reset, please try again
    +
    +
    +
    +
    + +
    +
    +
    + ); + } else { + return ( +
    +
    +
    +
    Your password has been reset
    +
    You may now login with your new password
    +
    +
    +
    +
    + +
    +
    +
    + ); + } + } +}); + +module.exports = connectToStores(ResetPassword, + [ChangePasswordStore], + function({ getStore }, props) { + return { + changePassStore: getStore(ChangePasswordStore).getState() + }; + }); diff --git a/app/scripts/components/welcome/SignupForm.css b/app/scripts/components/welcome/SignupForm.css new file mode 100644 index 0000000000..8b4531edb7 --- /dev/null +++ b/app/scripts/components/welcome/SignupForm.css @@ -0,0 +1,27 @@ +@import "dux/css/colors"; +@import "dux/css/box"; + +.submit { + padding: var(--default-margin); + button { + float: right; + } +} + +.subtext { + /** new color variable **/ + color: #b9eafa; +} + +.heading { + font-weight: 200; + color: var(--smoke); +} + +.success { + text-align: center; + margin-top: 5rem; + .heading { + font-size: 1.5rem; + } +} diff --git a/app/scripts/components/welcome/SignupForm.jsx b/app/scripts/components/welcome/SignupForm.jsx new file mode 100644 index 0000000000..544020c812 --- /dev/null +++ b/app/scripts/components/welcome/SignupForm.jsx @@ -0,0 +1,105 @@ +'use strict'; + +var debug = require('debug')('SignupForm'); + +import React, { PropTypes } from 'react'; +import _ from 'lodash'; +import connectToStores from 'fluxible-addons-react/connectToStores'; +import Button from '@dux/element-button'; + +import FA from 'common/FontAwesome'; +import FancyInput from 'common/FancyInput'; +import SignupStore from '../../stores/SignupStore'; +import attemptSignup from '../../actions/attemptSignup'; +import onChange from '../../actions/common/onChangeUtil'; +import { STATUS } from '../../stores/signupstore/Constants'; +import { handleFormErrors } from './_utils'; +import styles from './SignupForm.css'; + +var SignupForm = React.createClass({ + displayName: 'SignupForm', + contextTypes: { + executeAction: PropTypes.func.isRequired + }, + PropTypes: { + location: PropTypes.object + }, + _onSubmit(e) { + e.preventDefault(); + const { partner_value, redirect_value } = this.props.location.query; + var payload = this.props.values; + payload.username = payload.username.toLowerCase(); + if (partner_value) { + payload.partner_value = partner_value; + } + if (redirect_value) { + payload.redirect_value = redirect_value; + } + debug(payload); + this.context.executeAction(attemptSignup, payload); + }, + onChange: onChange({ + storePrefix: 'SIGNUP' + }), + render() { + debug(this.props); + if(this.props.STATUS === STATUS.SUCCESSFUL_SIGNUP) { + return ( +
    +
    Sweet! You're almost ready to go!
    +

    Please check your email to activate your account.

    + +
    + ); + } else { + return ( +
    +
    +
    +

    New to Docker?

    +

    Create your free Docker ID to get started.

    +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + ); + } + } +}); + +export default connectToStores(SignupForm, + [SignupStore], + function({ getStore }, props) { + return getStore(SignupStore).getState(); + }); diff --git a/app/scripts/components/welcome/_utils.js b/app/scripts/components/welcome/_utils.js new file mode 100644 index 0000000000..9ac9e62c89 --- /dev/null +++ b/app/scripts/components/welcome/_utils.js @@ -0,0 +1,43 @@ +'use strict'; +import _ from 'lodash'; + +export function handleFormErrors(ctx, rawValueObject) { + + /** + * This function expects a ctx that has a `fields` + * object on the state, and a `_validate` function that + * returns an object `{ hasError: bool, error: string }` + * + * A valid component looks like: + * + * var Component = React.createClasse({ + * getInitialState() { + * return { + * fields: {} + * } + * }, + * _validate(key, value) { + * return { + * hasError: true, + * error: 'It\'s always wrong!' + * } + * } + * }) + */ + + // shortcut keys for State + let fields = ctx.state.fields || {}; + + // loop through `rawValueObject`, validating values + _.forIn(rawValueObject, function(value, key) { + let { hasError, error } = ctx._validate(key, value); + fields[key] = fields[key] || {}; + fields[key].hasError = hasError; + fields[key].error = error; + }, ctx); + + // queue up the new State + ctx.setState({ + fields + }); +} diff --git a/app/scripts/fluxibleRouter.js b/app/scripts/fluxibleRouter.js new file mode 100644 index 0000000000..27194954eb --- /dev/null +++ b/app/scripts/fluxibleRouter.js @@ -0,0 +1,112 @@ +'use strict'; + +import React from 'react'; +import createHashHistory from 'history/lib/createHashHistory'; +import { createRoutes, useRoutes, RoutingContext } from 'react-router'; +import { routes } from 'react-router/lib/PropTypes'; +var debug = require('debug')('FluxibleRouter'); + +const { func, object } = React.PropTypes; + +/** + * A is a high-level API for automatically setting up + * a router that renders a with all the props + * it needs each time the URL changes. + */ +const FluxibleRouter = React.createClass({ + + propTypes: { + history: object, + children: routes, + routes, // alias for children + createElement: func, + onError: func, + onUpdate: func, + parseQueryString: func, + stringifyQuery: func + }, + + getInitialState() { + return { + firstRender: true, + location: null, + routes: null, + params: null, + components: null + }; + }, + + handleError(error) { + if (this.props.onError) { + this.props.onError.call(this, error); + } else { + // Throw errors by default so we don't silently swallow them! + throw error; // This error probably occurred in getChildRoutes or getComponents. + } + }, + + //============================================================================ + //ComponentWillMount is the only addition to this Router + //Needed in order to provide location/pathname to onUpdate from client.js + /* eslint-disable */ + componentWillMount() { + let { history, children, routes, parseQueryString, stringifyQuery } = this.props; + let createHistory = history ? () => history : createHashHistory; + + this.history = useRoutes(createHistory)({ + routes: createRoutes(routes || children), + parseQueryString, + stringifyQuery + }); + + this._unlisten = this.history.listen((error, state) => { + if (error) { + this.handleError(error); + } else { + //Rendering page after setting state, here to make sure the data is loaded `onUpdate` before we show the page + if (this.state.firstRender) { + this.setState({ + firstRender: false, + ...state + }); + } else { + var _this = this; + this.props.onUpdate(state, function () { + _this.setState(state); + }); + } + //End change in `onUpdate` related change to get client side rendering to behave like now + } + }); + }, + //============================================================================ + componentWillUnmount() { + if (this._unlisten) { + this._unlisten(); + } + }, + + render() { + let { location, routes, params, components } = this.state; + let { createElement } = this.props; + + if (location == null) { + return null; // Async match + } + + const routingProps = { + history: this.history, + createElement, + location, + routes, + params, + components + }; + + return ; + } + +}); +/*eslint-enable*/ + +export default FluxibleRouter; diff --git a/app/scripts/middlewares/sdk.js b/app/scripts/middlewares/sdk.js new file mode 100644 index 0000000000..25606daf01 --- /dev/null +++ b/app/scripts/middlewares/sdk.js @@ -0,0 +1,93 @@ +'use strict'; + +import { ATTEMPTING, ERROR, SUCCESS } from '../reduxConsts.js'; + +/** + * SDK middleware for automatically calling SDK actions and storing request + * statuses. + * + * Example: + * + * someAction({ tagName }) => ({ + * type: 'SOME_ACTION', + * meta: { + * sdk: { + * call: SDK.func, // SDK function to call + * args: ['args', 'for', 'func'] // Args to pass in to SDK function + * callback: (err, res) => ({}) // SDK callback + * statusKey: ['SOMETHING'] // Unique identifier for saving status in status reducer + * } + * } + * }) + * + * NOTE: `statusKey` should be an array; the first item should namespace + * the action type and the second item should be unique to the particular + * record. For example, when deleting a tag: + * + * statusKey: ['deleteRepoTag', 'latest'] + * + * Status will be stored in 'state.status.deleteRepoTag.latest'. + * + * NOTE: `args` does not include the SDK callback. + * + * For an example see actions/redux/tags.js + */ + +// dispatchStatus takes the action and status of an SDK request and returns +// a new action to Redux for tracking state. +// +// The 'data' parameter may be either the error or response body from the call +const dispatchStatus = (action, status, data) => ({ + type: `${action.type}_STATUS`, + payload: { + // Add in everything from the initial action payload. This lets us pass + // things such as namespaces and repo names to reducers which handle + // success states (when deleting a tag we need the namespace/repo/tag name) + ...action.payload, + statusKey: action.meta.sdk.statusKey, + status, + data + } +}); + +const sdkMiddleware = (store) => (next) => (action) => { + // If there's no meta.sdk in our action we don't need to process it with our + // middleware + if (typeof action !== 'object' || !action.meta || !action.meta.sdk) { + return next(action); + } + + const { call, args, callback, statusKey } = action.meta.sdk; + + if (!statusKey) { + throw new Error(`action.meta.sdk.statusKey is not defined for ${action.type}`); + } + + // Wrap the callback with a function that automatically dispatches error + // states for the SDK call to Redux. + // Why: this eliminates the need to create error and success dispatches for every + // action we create, and standardizes the format of all status dispatches + const wrapped = (err, res) => { + if (err) { + next(dispatchStatus(action, ERROR, err)); + // TODO: Dispatch that there was an error with this call. + } else { + next(dispatchStatus(action, SUCCESS, res)); + } + + // Ensure we call the original callback supplied for the SDK call. + if (callback) { + callback.apply(null, [err, res]); + } + }; + + // Dispatch that we're attempting the SDK call + next(dispatchStatus(action, ATTEMPTING)); + + // Make the SDK call here. + call.apply(null, [...args, wrapped]); + + return next(action); +}; + +export default sdkMiddleware; diff --git a/app/scripts/normalizers.js b/app/scripts/normalizers.js new file mode 100644 index 0000000000..5b7ca86f53 --- /dev/null +++ b/app/scripts/normalizers.js @@ -0,0 +1,64 @@ +'use strict'; + +import { Schema, arrayOf } from 'normalizr'; + +// Key repositories by the 'reponame' attribute instead of the 'key' field so +// that we can look up repositories by name in our reselect queries +const repository = new Schema('repository', { idAttribute: 'reponame' }); + +// The tag ID shouldn't be via key - it should be tagname +const tag = new Schema('tag', { + idAttribute: (entity) => { + // The natuilus API uses entity.tag as the tagname whereas hub uses + // entity.name + return (entity.tag) ? entity.tag : entity.name; + } +}); +// The scan ID shouldn't use the ID attribute; we need to be able to load +// any scan by checking the repo:tag combination +// TODO: Can we use the sha256sum here instead? +const scan = new Schema('scan', { + idAttribute: (entity) => `${entity.reponame}:${entity.tag}` +}); +// AKA layer +const blob = new Schema('blob', { idAttribute: 'index' }); +// Key components by names to version numbers as they have no unique ID +const component = new Schema('component', { + idAttribute: (entity) => `${entity.component}:${entity.version}` +}); +const vulnerability = new Schema('vulnerability', { idAttribute: 'cve' }); + + +// A repository has many tags +repository.define({ + tags: arrayOf(tag) +}); + +// A tag has many blobs and many scans +tag.define({ + blobs: arrayOf(blob), + // NOTE: Right now a tag can only have the latest scan. + // In the future we'll allow tags to have many scans + scans: arrayOf(scan) +}); + +scan.define({ + blobs: arrayOf(blob) +}); + +blob.define({ + components: arrayOf(component) +}); + +component.define({ + vulnerabilities: arrayOf(vulnerability) +}); + +export { + repository, + tag, + scan, + blob, + component, + vulnerability +}; diff --git a/app/scripts/records.js b/app/scripts/records.js new file mode 100644 index 0000000000..9a603ddf48 --- /dev/null +++ b/app/scripts/records.js @@ -0,0 +1,14 @@ +'use strict'; + +import { Record } from 'immutable'; + +// Records are the same as Maps but with accessors +// and can only have these defined fields set. +// USE: Instead of `shape`, in propTypes, we can use +// status: instanceOf(StatusRecord) +// +// NOTE: All records should be defined in this file +export const StatusRecord = new Record({ + status: '', + error: undefined +}); diff --git a/app/scripts/reducers/_utils.js b/app/scripts/reducers/_utils.js new file mode 100644 index 0000000000..b47be097cf --- /dev/null +++ b/app/scripts/reducers/_utils.js @@ -0,0 +1,45 @@ +'use strict'; + +import Immutable from 'immutable'; + +/** + * mergeEntity takes an entity name and merges it into the current state + * if found. + * + * This is used when your state contains a basic map of entities. + * + * Examples: + * + * mergeEntity('repository'): + * > merge action.payload.entities.repository into the current state + * + */ +export const mergeEntity = (entityType) => (state, action) => { + //TODO: Remove promises stuff with ready / error? + const { payload, ready, error } = action; + if (!ready || error || !payload.entities[entityType]) { + return state; + } + return state.merge(new Immutable.Map(payload.entities[entityType])); +}; + +export const mapToRecord = (map, Record) => { + let records = {}; + Object.keys(map).forEach(item => { records[item] = new Record(map[item]); }); + return records; +}; + +export const mergeEntities = (...entities) => (state, action) => { + + const { payload, ready, error } = action; + if (!ready || error) { + return state; + } + + return state.withMutations( map => { + entities.forEach( item => { + return map.mergeIn([item], new Immutable.Map(payload.entities[item])); + }); + return map; + }); +}; diff --git a/app/scripts/reducers/index.js b/app/scripts/reducers/index.js new file mode 100644 index 0000000000..fd33d66740 --- /dev/null +++ b/app/scripts/reducers/index.js @@ -0,0 +1,21 @@ +'use strict'; + +// This combines all reducers from internal and external packages to create +// a redux store. +import { combineReducers } from 'redux'; +import repos from './repos'; +import scans from './scans'; +import status from './status'; +import tags from './tags'; +import { reducer as ui } from 'redux-ui'; + +export default combineReducers({ + // external reducers + ui, + // middleware reducers + // app-specific reducers + repos, + scans, + status, + tags +}); diff --git a/app/scripts/reducers/repos.js b/app/scripts/reducers/repos.js new file mode 100644 index 0000000000..d96a1fdcd1 --- /dev/null +++ b/app/scripts/reducers/repos.js @@ -0,0 +1,24 @@ +'use strict'; + +import immutable from 'immutable'; +import { + RECEIVE_REPO +} from 'reduxConsts.js'; + +const defaultState = immutable.fromJS( + (typeof window !== 'undefined' && window.ReduxApp.repos) || {} +); + +const reducers = { + [RECEIVE_REPO]: (state, action) => { + return state.clear().merge(action.payload); + } +}; + +export default function(state = defaultState, action) { + const { type } = action; + if (typeof reducers[type] === 'function') { + return reducers[type](state, action); + } + return state; +} diff --git a/app/scripts/reducers/scans.js b/app/scripts/reducers/scans.js new file mode 100644 index 0000000000..e3074ad4e2 --- /dev/null +++ b/app/scripts/reducers/scans.js @@ -0,0 +1,26 @@ +'use strict'; + +//modified from nautilus-ui/src/scripts/reducers/scans.js +import immutable from 'immutable'; +import { RECEIVE_SCANNED_TAG_DATA } from 'reduxConsts.js'; + +// Map of entities within each scan +const defaultState = immutable.fromJS( + (typeof window !== 'undefined' && window.ReduxApp.scans) || {} +); + +const reducers = { + [ RECEIVE_SCANNED_TAG_DATA ]: (state, action) => { + // Here we only ever save this current scan from the repoDetailsScannedTag + // action. This means that our scans reducer only ever has one scan - for + // the current page. + return state.clear().merge(action.payload.entities); + } +}; + +export default function(state = defaultState, action) { + if (typeof reducers[action.type] === 'function') { + return reducers[action.type](state, action); + } + return state; +} diff --git a/app/scripts/reducers/status.js b/app/scripts/reducers/status.js new file mode 100644 index 0000000000..589187b83c --- /dev/null +++ b/app/scripts/reducers/status.js @@ -0,0 +1,40 @@ +'use strict'; + +import immutable, { Map } from 'immutable'; +import endsWith from 'lodash/string/endsWith'; +import isArray from 'lodash/lang/isArray'; +import { ERROR } from 'reduxConsts.js'; +import { StatusRecord } from 'records'; + +const defaultState = immutable.fromJS( + (typeof window !== 'undefined' && window.ReduxApp.status) || {} +); + +// This reducer listens for status updates from the SDK middleware +// and automatically stores the status within the `statusKey`. +// +// Example: If statusKey = ['deleteRepoTag', 'latest'] and status = 'ATTEMPTING', +// then state.status.deleteRepoTag.latest would be `ATTEMPTING`. +export default function(state = defaultState, action) { + // The status reducer only acts on actions ending in _STATUS. + // Ignore anything else and return the default state. + if (!endsWith(action.type, `_STATUS`)) { + return state; + } + + const { statusKey, status, data } = action.payload; + // We're using setIn, so if the statusKey is a string it needs + // to be wrapped in an array. + const sk = isArray(statusKey) ? statusKey : [statusKey]; + + if (status === ERROR) { + // Store the status and error response from the API within + // the state. + return state.setIn(sk, new StatusRecord({ status, error: data })); + } + + // On ATTEMPTING or SUCCESS we only want to store the status; + // storing action.payload.data would store the entire API response + // which our other reducers should be handling. + return state.setIn(sk, new StatusRecord({ status })); +} diff --git a/app/scripts/reducers/tags.js b/app/scripts/reducers/tags.js new file mode 100644 index 0000000000..cb933949cb --- /dev/null +++ b/app/scripts/reducers/tags.js @@ -0,0 +1,91 @@ +'use strict'; + +import immutable, { Map } from 'immutable'; +import { + RECEIVE_NAUTILUS_TAGS_FOR_REPOSITORY, + RECEIVE_TAGS_FOR_REPOSITORY, + DELETE_REPO_TAG, + SUCCESS +} from 'reduxConsts.js'; + +// Use the serialized redux data from the universal app loading if it exists. +// +// Shape of state: +// { +// 'namespace': { +// 'reponame': { +// tags: { +// 'latest': { ...data }, +// '14.04': { ...data }, +// ... +// }, +// result: [1, 2, 3, ...] // Array of repo IDs as ordered by hub API +// }, +// ... +// } +// } +// +// We use a nested map of namespace:reponame keys to a list of tags to ensure +// that we can merge the nautilus and hub API responses together without +// clearing inbetween. +const defaultState = immutable.fromJS( + (typeof window !== 'undefined' && window.ReduxApp.tags) || {} +); + +// mergeTagsIntoState accepts a namespace, reponame and normalized tag +// information and merges them into the given state. +// +// This is used when tags from the hub and nautilus API are loaded. +const mergeTagsIntoState = (state, action) => { + const { namespace, reponame, tags } = action.payload; + const path = [namespace, reponame, 'tags']; + const { tag } = tags.entities; + + return state.setIn( + path, + // Get the existing tags for this namespace/repo and merge the normalized + // tags recursively. If the namespace/repo pair doesn't exist this returns + // a new map. + // + // Merge function ensures that in the event of a conflict where the new value + // is undefined or null, it does not overwrite the existing value + state.getIn(path, new Map()).mergeDeep(tag) + ); +}; + +const maybeDeleteTag = (state, action) => { + if (action.payload.status === SUCCESS) { + // Remove this tag from our reducer. + const { namespace, reponame, tagName } = action.payload; + return state.withMutations(s => { + let result = s.getIn([namespace, reponame, 'result']); + result = result.filter(tag => tag !== tagName); + s.deleteIn([namespace, reponame, 'tags', tagName]); + s.setIn([namespace, reponame, 'result'], result); + return s; + }); + } + return state; +}; + +const reducers = { + [RECEIVE_TAGS_FOR_REPOSITORY]: (state, action) => { + // Add the result array of ordered tags from the hub API response, + // then merge tags in + const { namespace, reponame, tags } = action.payload; + const path = [namespace, reponame, 'result']; + const { result } = tags; + state = state.setIn(path, result); + return mergeTagsIntoState(state, action); + }, + [RECEIVE_NAUTILUS_TAGS_FOR_REPOSITORY]: mergeTagsIntoState, + [`${DELETE_REPO_TAG}_STATUS`]: maybeDeleteTag +}; + +export default function(state = defaultState, action) { + const { type } = action; + if (typeof reducers[type] === 'function') { + return reducers[type](state, action); + } + return state; +} diff --git a/app/scripts/reduxConsts.js b/app/scripts/reduxConsts.js new file mode 100644 index 0000000000..6ee2ee93b4 --- /dev/null +++ b/app/scripts/reduxConsts.js @@ -0,0 +1,19 @@ +'use strict'; +//Consts used for redux actions +const keyMirror = require('keymirror'); + +export default keyMirror({ + //repos + RECEIVE_REPO: null, + + //tags + DELETE_REPO_TAG: null, + RECEIVE_NAUTILUS_TAGS_FOR_REPOSITORY: null, + RECEIVE_SCANNED_TAG_DATA: null, + RECEIVE_TAGS_FOR_REPOSITORY: null, + + // statuses + ATTEMPTING: null, + ERROR: null, + SUCCESS: null +}); diff --git a/app/scripts/reduxStore.js b/app/scripts/reduxStore.js new file mode 100644 index 0000000000..a5aaf0748f --- /dev/null +++ b/app/scripts/reduxStore.js @@ -0,0 +1,38 @@ +'use strict'; + +// This creates a new redux store by importing our reducers and middleware +// and combining the two. +import { applyMiddleware, compose, createStore } from 'redux'; +import { Iterable } from 'immutable'; +import reducers from './reducers'; +import sdkMiddleware from './middlewares/sdk.js'; +import createLogger from 'redux-logger'; +const debug = require('debug')('hub:redux:logger'); + + +// Logger must always be the last middleware in applyMiddleware +const logger = createLogger({ + predicate: () => process.env.ENV === `development`, + // Use the debug import as our logger + logger: {log: debug}, + // Transform any immutableJS maps and iterables into their standard JS + // counterparts. This means you can inspect state within the console. + stateTransformer: (state) => { + let newState = {}; + Object.keys(state).forEach(key => { + newState[key] = state[key]; + if (Iterable.isIterable(state[key])) { + newState[key] = state[key].toJS(); + } + }); + return newState; + } +}); + +// Compose creates a new function by taking store enhancers (such as middleware +// and any external enhancers) which modifies the createStore function. +const enhancedCreateStore = compose( + applyMiddleware(sdkMiddleware, logger) +)(createStore); + +export default enhancedCreateStore; diff --git a/app/scripts/reduxUtils.js b/app/scripts/reduxUtils.js new file mode 100644 index 0000000000..dfba193f92 --- /dev/null +++ b/app/scripts/reduxUtils.js @@ -0,0 +1,27 @@ +'use strict'; + +import { bindActionCreators } from 'redux'; +import React from 'react'; +import consts from './reduxConsts'; +/** + * Given an object of actions, this returns a thunk which returns all actions + * bound to dispatch using the same key names. + * + * This allows us to use `this.props.actions.$actionName` within components + * after being connected to Redux. + * + * Example: + * + * @connect(mapState, mapActions(Actions)) + * class Basic extends Component { + * // this.props.actions now containers all keys in Actions bound to dispatch + * } + * + */ +export let mapActions = (actions) => { + return (dispatch) => { return { actions: bindActionCreators(actions, dispatch) }; }; +}; + +export const mapToArray = (map) => { + return Object.keys(map).map(key => map[key]); +}; diff --git a/app/scripts/selectors/status.js b/app/scripts/selectors/status.js new file mode 100644 index 0000000000..92b5d05e21 --- /dev/null +++ b/app/scripts/selectors/status.js @@ -0,0 +1,3 @@ +'use strict'; + +export const getStatus = (state) => state.status; diff --git a/app/scripts/server.js b/app/scripts/server.js new file mode 100644 index 0000000000..edae4a4263 --- /dev/null +++ b/app/scripts/server.js @@ -0,0 +1,342 @@ +'use strict'; +var debug = require('debug')('hub:server'); +var path = require('path'); +if(process.env.NEW_RELIC_LICENSE_KEY && process.env.NEW_RELIC_APP_NAME) { + process.env.NEW_RELIC_NO_CONFIG_FILE = true; + require('newrelic'); +} +var request = require('superagent'); +var bugsnag = require('bugsnag'); + +import pick from 'lodash/object/pick'; +import merge from 'lodash/object/merge'; +import React from 'react'; +import { match } from 'react-router'; +import RoutingContext from 'react-router/lib/RoutingContext'; +import bootstrapCreateElement from './bootstrapCreateElement'; +import app from './app'; +import cookieParser from 'cookie-parser'; +import bodyParser from 'body-parser'; +import express from 'express'; +import favicon from 'serve-favicon'; +import navigateAction from './actions/navigate'; +import serialize from 'serialize-javascript'; +import HtmlComponent from './components/Html'; + +import { Provider } from 'react-redux'; +import reducers from './reducers'; +import enhancedCreateStore from './reduxStore'; + +const server = express(); + +// don't broadcast we are using express +server.disable('x-powered-by'); + +// add bugsnag for asynch errors +if (process.env.BUGSNAG_API_KEY) { + bugsnag.register(process.env.BUGSNAG_API_KEY); + server.use(bugsnag.requestHandler); +} + +server.use(favicon('./favicon.ico')); +server.use('/public', express.static('./public')); + +// Add a trailing '/' to the path if there is none +server.use(function(req, res, next) { + if (req.path.substr(-1) !== '/' && req.path.length > 1) { + const query = req.url.slice(req.path.length); + res.redirect(301, req.path + '/' + query); + } else { + next(); + } +}); + +// standard health check endpoint +server.get('/_health', function (req, res) { + res.send('OK'); +}); + +(function(){ + const redirectToDockerPricing = function(req, res) { + res.redirect(301, 'https://www.docker.com/pricing'); + }; + + const redirectTrialToDockerStore = function(req, res) { + res.redirect(301, 'https://store.docker.com/bundles/docker-datacenter/purchase?plan=free-trial'); + }; + + server.get('/enterprise/trial/', redirectTrialToDockerStore); // redirect DDC Trial page to new Store page + server.get('/enterprise/', redirectToDockerPricing); + server.get('/subscriptions/', redirectToDockerPricing); +})(); + +server.get('/account/signup/', function(req, res, next) { + res.redirect('/'); +}); + +server.get('/account/forgot-password/', function(req, res, next) { + res.redirect('/reset-password/'); +}); + +server.get('/account/login/', function(req, res, next) { + res.redirect('/login/'); +}); + +server.get('/_/', function(req, res, next) { + res.redirect('/explore/'); +}); + +server.get('/official/', function(req, res, next) { + res.redirect('/explore/'); +}); + +server.get('/account/accounts/', function(req, res, next) { + res.redirect('/account/authorized-services/'); +}); + +server.get('/plans/', function(req, res, next) { + res.redirect('https://www.docker.com/pricing'); +}); + +server.get('/resend-email-confirmation/', function(req, res, next) { + res.redirect('/reset-password/'); +}); + +//There are two cases now: +//Case 1: No query parameter, just the token | Default activation, with confirmation_key sent to the `activate` endpoint +//Case 2: A `ref` query parameter is sent in the email validation URL | For partners, we send both key and ref to the activation endpoint +server.get('/account/confirm-email/:token', function(req, res, next) { + if(req.params.token) { + //If on activate, we get a query parameter called `ref` back from the email link, we store it and send it with the POST + const { ref } = req.query; + const { token } = req.params; + var activateRequestBody = { confirmation_key: token }; + if (ref) { + activateRequestBody.ref = ref; + } + request.post(`${process.env.REGISTRY_API_BASE_URL}/v2/users/activate/`) + .accept('application/json') + .send(activateRequestBody) + .end((err, apiRes) => { + if (err) { + debug('sign up error', err); + //Redirect to Login page for any error. + //We do not have generic error pages for 400s or 500s. + res.redirect('/login/'); + } else if (!apiRes || !apiRes.body) { + debug('api response is empty'); + //Redirect to Login page, when there is no response + //This is a care case that needs to be handled to make sure + //that it doesn't crash. See HUB-2094 for further details. + res.redirect('/login/'); + } else { + const { redirect_url } = apiRes.body; + if (redirect_url) { + //Redirect to the redirect URL returned by the API + res.redirect(redirect_url); + } else { + //Fallback redirect to Login page, when there is no redirect URL + res.redirect('/login/'); + } + } + }); + } else { + res.redirect('/login/'); + } +}); + +server.use(cookieParser()); +// 30 days in ms: 2592000000 +const expiry = 1000 * 60 * 60 * 24 * 30; // ms * s * m * h * days +const cookieOpts = { + domain: process.env.COOKIE_DOMAIN, + httpOnly: true, + secure: true, + maxAge: expiry, + expires: new Date(Date.now() + expiry) +}; +server.post('/attempt-login/', + bodyParser.json(), + function(req, res, next) { + res.cookie('token', req.body.jwt, cookieOpts); + res.end(); +}); + +const isLoggedIn = function(req) { + return !!(req.cookies && (req.cookies.token || req.cookies.jwt)); +}; + +server.get('/account/billing-plans/', function(req, res, next) { + if (!isLoggedIn(req)) { + return res.redirect('/billing-plans/'); + } + next(); +}); + +server.use(function(req, res, next) { + if (req.method !== 'GET') { + return next(); + } + if (isLoggedIn(req) && (['/login/', '/reset-password/', '/register/'].indexOf(req.path) !== -1 || req.path.indexOf('/account/password-reset-confirm') === 0)) { + res.redirect('/'); + } else if (!isLoggedIn(req) && req.path.indexOf('password-reset-confirm') === -1 && req.path.indexOf('/account/') === 0) { + res.redirect('/login/'); + } else { + next(); + } +}); + +server.post('/attempt-logout/', function(req, res, next) { + /** + * Delete the old cookie when we see it on logout. + */ + const oldCookieOpts = merge({}, + cookieOpts, + { + domain: '.docker.com' + }); + res.clearCookie('jwt', oldCookieOpts); + res.clearCookie('token', cookieOpts); + res.end(); +}); + +server.post('/oauth/github-attempt/', + bodyParser.json(), + function(req, res, next) { + res.cookie('ghOauthKey', req.body.ghk, cookieOpts); + res.end(); +}); + +server.post('/oauth/github-done/', function(req, res, next) { + res.clearCookie('ghOauthKey', cookieOpts); + res.end(); +}); + +server.use(function(req, res, next) { + // We may need to whitelist OPTIONS + if (req.method !== 'GET') { + res.end('This server does not respond to non-GET requests'); + } else { + next(); + } +}); + +server.use(function(req, res, next) { + // Within each request create a new Redux store from all of our reducers + // so that state is unique per request. + const store = enhancedCreateStore(reducers); + const context = app.createContext({ + reduxStore: store + }); + + debug('context:', context, context.reduxStore); + + //We get the Routes that have been created in the FluxibleComponent + const routes = app.getComponent(); + + const originalURL = req.originalUrl; + //We use the 'match' API to match the created routes with the current location (req.originalURL) + debug('matching route', originalURL); + match({ routes, location: originalURL }, (routerError, redirectLocation, renderProps) => { + // match uses createRoutes for history + //TODO: handle redirect, not found and errors + //TODO: need to handle generic 404s, 500s, 301s + //if (redirectLocation) { + //TODO: redirects need to be handled here + // res.redirect(301, redirectLocation.pathname + redirectLocation.search); + //} + //else if (error) { + //TODO: Render a nice 500 page with error displayed | HOPE THIS NEVER HAPPENS + // res.send(500, error.message); + //} + //else if (renderProps == null) { + //TODO: Probably render the 404 page here + // res.send(404, 'Not found'); + //} + + //If router errors out, bail + if (routerError) { + debug('Error in the Router', routerError); + res.end(routerError); + } + // whitelist cookies from express into renderProps + if (req.cookies) { + renderProps.cookies = pick(req.cookies, ['token', 'ghOauthKey']); + // For backward compat since we changed the cookie name + renderProps.cookies.jwt = renderProps.cookies.token; + } + + // Set the props, so the server knows if the user is logged in + if (renderProps.cookies.jwt) { + renderProps.JWT = renderProps.cookies.jwt; + } + + /** + * Execute navigate action to load data (we block the render until the data is + * completely loaded) + * You can see the actual server side render happens only after the + * `navigateAction` calls `done()` somewhere + */ + context.executeAction(navigateAction, renderProps, function() { + debug('Exposing context state', context); + debug('EXPOSING RENDER PROPS', renderProps); + let serializedApp; + let reduxApp; + try { + /* + NOTE: If we have any html or request responses saved in the store + - serialize will not be able to parse this and will crash the node server + */ + serializedApp = serialize(app.dehydrate(context)); + reduxApp = serialize(store.getState()); + } catch (err) { + debug('SERIALIZATION FAILURE: ', err); + } + const exposed = `window.App=${serializedApp}; window.ReduxApp = ${reduxApp};`; + debug('Rendering Application component into html'); + + // This is the Router 1.0.0 recommended way of doing server side rendering + // Also add a Provider around the routingContext for Redux. + // NOTE: We're defining our redux store above directly within the app context + const routingContext = ( + + + + ); + + debug('rendering html'); + var html = React.renderToStaticMarkup( + + ); + + res.send(html); + }); + }); +}); + +// add bugsnag for error handling middleware +if(process.env.BUGSNAG_API_KEY) { + server.use(bugsnag.errorHandler); +} + +// add generic error catching middleware so the server doesn't crash +server.use(function catchError(err, req, res, next) { + const message = err.stack ? err.stack.replace(/\n/g, '') : ''; + const errorLog = { + time: (new Date()).toISOString(), + service: 'hub-web-v2', + message + }; + console.error(errorLog); // eslint-disable-line no-console +}); + +const port = process.env.PORT || 3000; + +// Stop the server if the process terminates +const runningServer = server.listen(port, function onListen() { + process.on('exit', runningServer.close.bind(runningServer)); + debug('Listening on port ' + port); +}); diff --git a/app/scripts/stores/AccountInfoFormStore.js b/app/scripts/stores/AccountInfoFormStore.js new file mode 100644 index 0000000000..0102a883c1 --- /dev/null +++ b/app/scripts/stores/AccountInfoFormStore.js @@ -0,0 +1,131 @@ +'use strict'; + +var createStore = require('fluxible/addons/createStore'); +import { STATUS } from './common/Constants'; +var debug = require('debug')('AccountInfoFormStore'); +var _ = require('lodash'); + +var noErrorObj = { + hasError: false, + error: '' +}; + +export default createStore({ + storeName: 'AccountInfoFormStore', + handlers: { + ACCOUNT_INFO_CLEAR_FORM: '_clearForm', + ACCOUNT_INFO_UPDATE_FIELD_WITH_VALUE: '_updateFieldWithValue', + ACCOUNT_INFO_ATTEMPT_START: '_attemptStart', + ACCOUNT_INFO_BAD_REQUEST: '_badRequest', + ACCOUNT_INFO_SUCCESS: '_success', + ACCOUNT_INFO_STATUS_CLEAR: '_clearStatus', + ACCOUNT_INFO_FACEPALM: '_facepalm', + RECEIVE_USER: '_receiveUser' + }, + initialize() { + this.STATUS = STATUS.DEFAULT; + + this.fields = { + full_name: {}, + company: {}, + location: {}, + profile_url: {}, + gravatar_email: {} + }; + + this.values = { + full_name: '', + company: '', + location: '', + profile_url: '', + gravatar_email: '' + }; + }, + _receiveUser(user) { + debug('receive user', user); + this.values.full_name = user.full_name; + this.values.company = user.company; + this.values.location = user.location; + this.values.profile_url = user.profile_url; + this.values.gravatar_email = user.gravatar_email; + this.emitChange(); + }, + _facepalm() { + // this happens if things are screwed and we can't recover gracefully + this.STATUS = STATUS.FACEPALM; + this.emitChange(); + }, + _clearForm() { + this.initialize(); + this.emitChange(); + }, + _updateFieldWithValue({fieldKey, fieldValue}) { + this.STATUS = STATUS.DEFAULT; + this.values[fieldKey] = fieldValue; + this.emitChange(); + }, + _attemptStart() { + this.STATUS = STATUS.ATTEMPTING; + this.fields = { + full_name: {}, + company: {}, + location: {}, + profile_url: {}, + gravatar_email: {} + }; + this.emitChange(); + }, + _success() { + this.STATUS = STATUS.SUCCESSFUL; + this.emitChange(); + }, + _clearStatus() { + this.STATUS = STATUS.DEFAULT; + this.emitChange(); + }, + _badRequest(obj) { + /** + * This function expects keys which match the `this.fields` keys + * with an array of errors: + * + * { + * orgname: ['this field is required'] + * } + */ + let shouldEmitChange = false; + this.STATUS = STATUS.ERROR; + + // cycle through the possible form fields + this.fields = _.mapValues(this.fields, function (errorObject, key) { + if(_.has(obj, key)) { + shouldEmitChange = true; + return { + hasError: !!obj[key], + error: obj[key][0] + }; + } else { + return errorObject; + } + }); + + if(shouldEmitChange) { + this.emitChange(); + } + }, + getState() { + return { + fields: this.fields, + values: this.values, + STATUS: this.STATUS + }; + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + debug('rehydrate', state); + this.fields = state.fields; + this.values = state.values; + this.STATUS = state.STATUS; + } +}); diff --git a/app/scripts/stores/AccountSettingsLicensesStore.js b/app/scripts/stores/AccountSettingsLicensesStore.js new file mode 100644 index 0000000000..40274dddb7 --- /dev/null +++ b/app/scripts/stores/AccountSettingsLicensesStore.js @@ -0,0 +1,41 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('AccountSettingsLicenseStore'); +import _ from 'lodash'; + +export default createStore({ + storeName: 'AccountSettingsLicensesStore', + handlers: { + RECEIVE_LICENSES: '_receiveLicenses' + }, + initialize: function() { + this.licenses = []; + this.attempting = true; + }, + _receiveLicenses: function(licenses) { + this.licenses = _.flatten(licenses); + this.attempting = false; + this.emitChange(); + }, + getAttempt: function() { + return this.attempting; + }, + setAttempt: function(flag) { + this.attempting = flag; + }, + getState: function() { + return { + licenses: this.licenses, + attempting: this.attempting + }; + }, + rehydrate: function(state) { + this.licenses = state.licenses; + this.attempting = state.attempting; + }, + dehydrate: function() { + return this.getState(); + } +}); + diff --git a/app/scripts/stores/AccountSettingsTeamsStore.js b/app/scripts/stores/AccountSettingsTeamsStore.js new file mode 100644 index 0000000000..17b7d9a462 --- /dev/null +++ b/app/scripts/stores/AccountSettingsTeamsStore.js @@ -0,0 +1,29 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; + +export default createStore({ + storeName: 'AccountSettingsTeamsStore', + handlers: { + RECEIVE_LICENSES: '_receiveLicenses' + }, + initialize() { + this.licenses = []; + }, + _receiveLicenses(res) { + this.licenses = res.results; + this.emitChange(); + }, + getState() { + return { + licenses: this.licenses + }; + }, + rehydrate(state) { + this.licenses = state.licenses; + }, + dehydrate() { + return this.getState(); + } +}); + diff --git a/app/scripts/stores/AddOrganizationStore.js b/app/scripts/stores/AddOrganizationStore.js new file mode 100644 index 0000000000..7fd9986b2d --- /dev/null +++ b/app/scripts/stores/AddOrganizationStore.js @@ -0,0 +1,115 @@ +'use strict'; + +var createStore = require('fluxible/addons/createStore'); +import { STATUS } from './addorganizationstore/Constants'; +var debug = require('debug')('AddOrganizationStore'); +var _ = require('lodash'); + +var noErrorObj = { + hasError: false, + error: '' +}; + +export default createStore({ + storeName: 'AddOrganizationStore', + handlers: { + ADD_ORG_CLEAR_FORM: '_clearForm', + ADD_ORG_UPDATE_FIELD_WITH_VALUE: '_updateFieldWithValue', + ADD_ORG_ATTEMPT_START: '_addOrgAttemptStart', + ADD_ORG_BAD_REQUEST: '_badRequest', + ADD_ORG_SUCCESS: '_addOrgSuccess', + ADD_ORG_FACEPALM: '_facepalm', + CREATED_ORGANIZATION: '_clearForm', + CLEAR_ERRORS: '_clearErrors' + }, + initialize() { + this.STATUS = STATUS.DEFAULT; + + this.fields = { + gravatar_email: {}, + orgname: {}, + location: {}, + company: {}, + profile_url: {} + }; + + this.values = { + gravatar_email: '', + orgname: '', + location: '', + company: '', + profile_url: '' + }; + }, + _facepalm() { + // this happens if things are screwed and we can't recover gracefully + this.STATUS = STATUS.FACEPALM; + this.emitChange(); + }, + _clearForm() { + this.initialize(); + this.emitChange(); + }, + _updateFieldWithValue({fieldKey, fieldValue}) { + this._clearErrors(fieldKey); + this.values[fieldKey] = fieldValue; + this.emitChange(); + }, + _attemptStart() { + this.STATUS = STATUS.ATTEMPTING; + this.emitChange(); + }, + _signupSuccess() { + this.STATUS = STATUS.SUCCESSFUL_SIGNUP; + this.emitChange(); + }, + _clearErrors(fieldKey) { + if (this.STATUS === STATUS.BAD_REQUEST || this.STATUS === STATUS.FACEPALM) { + this.fields[fieldKey] = noErrorObj; + this.STATUS = STATUS.DEFAULT; + this.emitChange(); + } + }, + _badRequest(obj) { + /** + * This function expects keys which match the `this.fields` keys + * with an array of errors: + * + * { + * orgname: ['this field is required'] + * } + */ + let shouldEmitChange = false; + this.STATUS = STATUS.BAD_REQUEST; + + // cycle through the possible form fields + this.fields = _.mapValues(this.fields, function (errorObject, key) { + if(_.has(obj, key)) { + shouldEmitChange = true; + return { + hasError: !!obj[key], + error: obj[key][0] + }; + } else { + return errorObject; + } + }); + + if(shouldEmitChange) { + this.emitChange(); + } + }, + getState() { + return { + fields: this.fields, + values: this.values, + STATUS: this.STATUS + }; + }, + dehydrate() { + return {}; + }, + rehydrate(state) { + this.state = state; + } +}); diff --git a/app/scripts/stores/AddTrialLicenseStore.js b/app/scripts/stores/AddTrialLicenseStore.js new file mode 100644 index 0000000000..d448f26f50 --- /dev/null +++ b/app/scripts/stores/AddTrialLicenseStore.js @@ -0,0 +1,64 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import { ATTEMPTING_DOWNLOAD, + BAD_REQUEST, + DEFAULT, + FACEPALM, + SUCCESSFUL_DOWNLOAD } from 'stores/addtriallicensestore/Constants'; +const debug = require('debug')('AddTrialLicenseStore'); + +export default createStore({ + storeName: 'AddTrialLicenseStore', + handlers: { + ATTEMPTING_LICENSE_DOWNLOAD_START: '_attemptingLicenseDownloadStart', + DOWNLOAD_LICENSE_CONTENT_BAD_REQUEST: '_downloadLicenseContentBadRequest', + DOWNLOAD_LICENSE_CONTENT_FACEPALM: '_facepalm', + RECEIVE_LICENSE_DOWNLOAD_CONTENT: '_receiveLicenseDownloadContent' + }, + initialize: function() { + this.error = ''; + this.STATUS = DEFAULT; + }, + _attemptingLicenseDownloadStart: function() { + this.STATUS = ATTEMPTING_DOWNLOAD; + this.error = ''; + this.emitChange(); + }, + _clearFeedbackStates: function() { + this.STATUS = DEFAULT; + this.error = ''; + this.emitChange(); + }, + _downloadLicenseContentBadRequest: function(err) { + this.STATUS = BAD_REQUEST; + this.error = err; + this.emitChange(); + }, + _facepalm: function(err) { + this.STATUS = FACEPALM; + debug(err); + this.error = 'Sorry, an error occured and your license is unavailable at this time.'; + this.emitChange(); + }, + _receiveLicenseDownloadContent: function() { + this.STATUS = SUCCESSFUL_DOWNLOAD; + this.error = ''; + setTimeout(this._clearFeedbackStates.bind(this), 5000); + this.emitChange(); + }, + getState: function() { + return { + error: this.error, + STATUS: this.STATUS + }; + }, + rehydrate: function(state) { + this.error = state.error; + this.STATUS = state.STATUS; + }, + dehydrate: function() { + return this.getState(); + } +}); + diff --git a/app/scripts/stores/AddWebhookFormStore.js b/app/scripts/stores/AddWebhookFormStore.js new file mode 100644 index 0000000000..e315647c2f --- /dev/null +++ b/app/scripts/stores/AddWebhookFormStore.js @@ -0,0 +1,86 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +import last from 'lodash/array/last'; +import { + DEFAULT, + ATTEMPTING, + ERROR +} from './addwebhookformstore/Constants'; +const debug = require('debug')('AddWebhookFormStore'); + +var WebhooksSettingsStore = createStore({ + storeName: 'AddWebhookFormStore', + handlers: { + RECEIVE_WEBHOOKS: '_receiveWebhooks', + ADD_WEBHOOK_CLEAR: '_clear', + ADD_WEBHOOK_START: '_start', + ADD_WEBHOOK_RESET: '_reset', + ADD_WEBHOOK_SUCCESS: '_success', + ADD_WEBHOOK_NEW_HOOK: '_newHook', + ADD_WEBHOOK_REMOVE_HOOK: '_removeHook', + ADD_WEBHOOK_ERROR: '_error', + ADD_WEBHOOK_MISSING_ARGS: '_handleMissingArgs', + ADD_WEBHOOK_VALIDATION_ERRORS: '_handleValidationErrors' + }, + initialize() { + /** + * hookFields represent each `input` pairing that is + * rendered. They contain no data about the content of the input + */ + this.hookFields = [1]; + this.STATUS = DEFAULT; + this.serverErrors = {}; + }, + _error(args) { + // TODO: handle generic error + this.STATUS = ERROR; + this.serverErrors = args; + this.emitChange(); + }, + _handleMissingArgs(args) { + debug('missing args: ', args); + this.serverErrors = args; + }, + _handleValidationErrors(args) { + debug('validation errors: ', args); + this.serverErrors = args; + }, + _newHook() { + const { hookFields: fields } = this; + this.hookFields = fields.concat(last(fields) + 1); + this.emitChange(); + }, + _reset() { + this.initialize(); + this.emitChange(); + }, + _start() { + this.STATUS = ATTEMPTING; + this.emitChange(); + }, + _success() {}, + _receiveWebhooks(payload) { + debug(payload); + this.pipelines = payload.results; + this.emitChange(); + }, + _receiveAddWebhookErrors(error) { + }, + getState() { + return { + STATUS: this.STATUS, + pipelines: this.pipelines, + hookFields: this.hookFields, + serverErrors: this.serverErrors + }; + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + this.pipelines = state.pipelines; + this.hookFields = state.hookFields; + } +}); + +module.exports = WebhooksSettingsStore; diff --git a/app/scripts/stores/ApplicationStore.js b/app/scripts/stores/ApplicationStore.js new file mode 100644 index 0000000000..6b4ea63eb6 --- /dev/null +++ b/app/scripts/stores/ApplicationStore.js @@ -0,0 +1,33 @@ +'use strict'; +const createStore = require('fluxible/addons/createStore'); + +var ApplicationStore = createStore({ + storeName: 'ApplicationStore', + handlers: { + CHANGE_ROUTE: 'handleNavigate' + }, + initialize: function() { + this.currentRoute = null; + }, + handleNavigate: function(route) { + if (this.currentRoute && route.path === this.currentRoute.path) { + return; + } + + this.currentRoute = route; + this.emitChange(); + }, + getState: function() { + return { + route: this.currentRoute + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.currentRoute = state.route; + } +}); + +module.exports = ApplicationStore; diff --git a/app/scripts/stores/AutoBuildSettingsStore.js b/app/scripts/stores/AutoBuildSettingsStore.js new file mode 100644 index 0000000000..117c3c63e1 --- /dev/null +++ b/app/scripts/stores/AutoBuildSettingsStore.js @@ -0,0 +1,235 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +import sortBy from 'lodash/collection/sortBy'; +import { STATUS } from './common/Constants'; + +var AutoBuildSettingsStore = createStore({ + storeName: 'AutoBuildSettingsStore', + handlers: { + AB_TRIGGER_BY_TAG_ERROR: '_triggerByTagError', + AB_TRIGGER_BY_TAG_SUCCESS: '_triggerByTagSuccess', + ATTEMPT_TRIGGER_BY_TAG: '_triggerByTagAttempt', + RECEIVE_AUTOBUILD_SETTINGS: '_receiveAutoBuildSettings', + UPDATE_AUTO_BUILD_SETTINGS: '_updateFields', + RECEIVE_AUTOBUILD_LINKS: '_receiveAutoBuildLinks', + LINK_AUTOBUILD_ERROR: '_linkAutoBuildError', + LINK_AUTOBUILD_SUCCESS: '_linkAutoBuildSuccess', + UPDATE_AUTOBUILD_PUSH_TRIGGER_ITEM: '_updateBuildTriggerItem', + UPDATE_AUTOBUILD_NEW_TAG_ITEM: '_updateBuildNewTagItem', + DELETE_AUTOBUILD_PUSH_TRIGGER_ITEM: '_deleteBuildTriggerItem', + DELETE_AUTOBUILD_NEW_TAG_ITEM: '_deleteBuildsNewTagItem', + ADD_AUTOBUILD_PUSH_TRIGGER_ITEM: '_addBuildTriggerItem', + SAVE_BUILD_TAGS_SUCCESS: '_saveTagsSuccess', + SAVE_BUILD_TAGS_ERROR: '_saveTagsError', + RECEIVE_TRIGGER_STATUS: '_receiveTriggerStatus', + RECEIVE_TRIGGER_LOGS: '_receiveTriggerLogs' + }, + initialize: function() { + this.autoBuildStore = { + repository: '', + build_name: '', + provider: '', + source_url: '', + docker_url: '', + repo_web_url: '', + repo_type: '', + active: false, + deleted: false, + repo_id: '', + build_tags: [], + deploykey: null, + hook_id: '' + }; + this.newTags = []; + this.validations = { + buildTags: { + hasError: false, + success: false, + errors: [] + }, + links: { + hasError: false, + success: false, + error: '' + }, + trigger: { + success: '', + error: '' + } + }; + this.autoBuildBlankSlate = {}; + this.autoBuildLinks = []; + this.triggerLinkForm = { + repoName: '' + }; + this.triggerStatus = { + token: '', + trigger_url: '', + active: false + }; + this.triggerLogs = []; + this.STATUS = STATUS.DEFAULT; + }, + _resetValidations: function(field) { + if (field === 'buildTags') { + this.validations.buildTags = { + hasError: false, + success: false, + errors: [] + }; + } else { + this.validations[field] = { + hasError: false, + success: false, + error: '' + }; + } + this.emitChange(); + }, + _receiveAutoBuildSettings: function(payload) { + this.autoBuildStore = payload; + const sorted = sortBy(payload.build_tags, 'id'); // ensure build_tags received are sorted + this.autoBuildStore.build_tags = sorted; + this.autoBuildBlankSlate = this.autoBuildStore; + this.emitChange(); + }, + _receiveAutoBuildLinks: function(payload) { + this.autoBuildLinks = payload; + this.triggerLinkForm.repoName = ''; + this.emitChange(); + }, + _linkAutoBuildError: function() { + this.validations.links = { + hasError: true, + success: false, + error: 'Failed to link this repository to your Automated Build.' + }; + this.emitChange(); + }, + _linkAutoBuildSuccess: function() { + this.validations.links = { + hasError: false, + success: true, + error: '' + }; + this.emitChange(); + }, + _addBuildTriggerItem: function() { + this.newTags.push({ + name: '', + dockerfile_location: '', + source_name: '', + source_type: 'Branch', + isNew: true + }); + this._resetValidations('buildTags'); + this.emitChange(); + }, + _deleteBuildTriggerItem: function(index) { + this.autoBuildStore.build_tags[index].toDelete = true; + this._resetValidations('buildTags'); + this.emitChange(); + }, + _deleteBuildsNewTagItem: function(index) { + this.newTags[index].toDelete = true; + this._resetValidations('buildTags'); + this.emitChange(); + }, + _updateBuildTriggerItem: function({ index, fieldkey, value}) { + this.autoBuildStore.build_tags[index][fieldkey] = value; + this._resetValidations('buildTags'); + this.emitChange(); + }, + _updateBuildNewTagItem: function({ index, fieldkey, value}) { + this.newTags[index][fieldkey] = value; + this._resetValidations('buildTags'); + this.emitChange(); + }, + _updateFields: function({ field, key, value }) { + this[field][key] = value; + if (field === 'triggerLinkForm') { + this._resetValidations('links'); + } + this.emitChange(); + }, + _saveTagsSuccess: function() { + this.validations.buildTags = { + success: true, + hasError: false, + errors: [] + }; + this.newTags = []; + setTimeout(this._resetValidations.bind(this), 3000, 'buildTags'); + this.emitChange(); + }, + _saveTagsError: function(tag) { + let currentErrors = this.validations.buildTags.errors; + if (tag.error) { + currentErrors.push(`${tag.name}: ${tag.error}`); + } + this.validations.buildTags = { + success: false, + hasError: true, + errors: currentErrors + }; + this.emitChange(); + }, + _receiveTriggerStatus: function(triggerStatus){ + this.triggerStatus = triggerStatus; + this.emitChange(); + }, + _receiveTriggerLogs: function(triggerLogs) { + this.triggerLogs = triggerLogs; + this.emitChange(); + }, + _triggerByTagAttempt: function() { + this.STATUS = STATUS.ATTEMPTING; + this.emitChange(); + }, + _triggerByTagError: function(err) { + this.validations.trigger.error = err; + setTimeout(this._clearTriggerStatus.bind(this), 5000); + this.emitChange(); + }, + _triggerByTagSuccess: function(success) { + this.validations.trigger.success = success; + setTimeout(this._clearTriggerStatus.bind(this), 5000); + this.emitChange(); + }, + _clearTriggerStatus: function() { + this.validations.trigger.success = ''; + this.validations.trigger.error = ''; + this.STATUS = STATUS.DEFAULT; + this.emitChange(); + }, + getState: function() { + return { + autoBuildStore: this.autoBuildStore, + autoBuildBlankSlate: this.autoBuildBlankSlate, + autoBuildLinks: this.autoBuildLinks, + autoTriggerForm: this.autoTriggerForm, + triggerStatus: this.triggerStatus, + triggerLinkForm: this.triggerLinkForm, + triggerLogs: this.triggerLogs, + validations: this.validations, + newTags: this.newTags, + STATUS: this.STATUS + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.autoBuildStore = state.autoBuildStore; + this.autoBuildLinks = state.autoBuildLinks; + this.autoTriggerForm = state.autoTriggerForm; + this.triggerStatus = state.triggerStatus; + this.triggerLinkForm = state.triggerLinkForm; + this.triggerLogs = state.triggerLogs; + this.validations = state.validations; + this.newTags = state.newTags; + this.STATUS = state.STATUS; + } +}); + +module.exports = AutoBuildSettingsStore; diff --git a/app/scripts/stores/AutobuildConfigStore.js b/app/scripts/stores/AutobuildConfigStore.js new file mode 100644 index 0000000000..b4f8190d18 --- /dev/null +++ b/app/scripts/stores/AutobuildConfigStore.js @@ -0,0 +1,134 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +import forEach from 'lodash/collection/forEach'; +import isString from 'lodash/lang/isString'; +import { STATUS } from './common/Constants'; + +var AutobuildConfigStore = createStore({ + storeName: 'AutobuildConfigStore', + handlers: { + ATTEMPTING_AUTOBUILD_CREATION: '_autobuildCreateAttempt', + AUTOBUILD_ERROR: '_autobuildConfigError', + AUTOBUILD_BAD_REQUEST: '_autobuildBadRequest', + AUTOBUILD_UNAUTHORIZED: '_autobuildUnauthorized', + AUTOBUILD_SUCCESS: '_autobuildSuccess', + AUTOBUILD_FORM_UPDATE_FIELD_WITH_VALUE: '_updateFormField', + SELECT_SOURCE_REPO: '_selectSourceRepo', + CLEAR_AUTOBUILD_FORM_ERRORS: '_clearErrorStates', + INITIALIZE_AUTOBUILD_FORM: '_initializeForm', + RECEIVE_PRIVATE_REPOSTATS: '_getPrivateDefault' + }, + initialize: function() { + this.name = ''; + this.namespace = ''; + this.description = ''; + this.isPrivate = 'public'; + this.provider = ''; + this.sourceRepoName = ''; + this.active = true; + this.error = {}; + this.success = ''; + this.STATUS = STATUS.DEFAULT; + }, + _autobuildConfigError: function(err) { + //TODO: handle config error here + this.error.general = 'An error occurred while configuring your automated build. Please try again later.'; + setTimeout(this._clearErrorStates.bind(this), 5000); + this.emitChange(); + }, + _autobuildCreateAttempt: function() { + this.error.buildTags = ''; + this.STATUS = STATUS.ATTEMPTING; + this.emitChange(); + }, + _autobuildBadRequest: function(err) { + forEach(err, (val, key) => { + this.error[key] = val.toString(); + }); + + //For build_tags, make it a global error + if (err.build_tags) { + this.error.buildTags = 'Invalid character(s) provided in build tags configuration. Please check your input.'; + } + + if (err.detail || isString(err)) { + this.error.detail = err.detail || err; + } + + this.STATUS = STATUS.DEFAULT; + this.emitChange(); + }, + _autobuildUnauthorized: function(err) { + this.error.general = 'You have no permissions to create an automated build in this namespace.'; + setTimeout(this._clearErrorStates.bind(this), 5000); + this.STATUS = STATUS.DEFAULT; + this.emitChange(); + }, + _autobuildSuccess: function(err) { + this.success = 'Successfully configured an automated build repository.'; + this.STATUS = STATUS.SUCCESSFUL; + setTimeout(this._clearErrorStates.bind(this), 5000); + this.emitChange(); + }, + _clearErrorStates: function() { + this.error = {}; + this.success = ''; + this.STATUS = STATUS.DEFAULT; + this.emitChange(); + }, + _getPrivateDefault: function(stats) { + this.isPrivate = stats.default_repo_visibility; + }, + _initializeForm: function({ name, namespace }) { + this.name = name; + this.namespace = namespace; + this.description = ''; + }, + _selectSourceRepo: function(repo) { + this.sourceRepoName = repo.full_name; + this.emitChange(); + }, + _updateFormField: function({fieldKey, fieldValue}) { + this[fieldKey] = fieldValue; + if (fieldKey === 'name' || fieldKey === 'namespace') { + delete this.error.dockerhub_repo_name; + } + if (fieldKey === 'description') { + delete this.error.description; + } + delete this.error.detail; + this.STATUS = STATUS.DEFAULT; + this.emitChange(); + }, + getState: function() { + return { + name: this.name, + namespace: this.namespace, + description: this.description, + isPrivate: this.isPrivate, + provider: this.provider, + sourceRepoName: this.sourceRepoName, + active: this.active, + error: this.error, + success: this.success, + STATUS: this.STATUS + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.name = state.name; + this.namespace = state.namespace; + this.description = state.description; + this.isPrivate = state.isPrivate; + this.provider = state.provider; + this.sourceRepoName = state.sourceRepoName; + this.active = state.active; + this.error = state.error; + this.success = state.success; + this.STATUS = state.STATUS; + } +}); + +module.exports = AutobuildConfigStore; diff --git a/app/scripts/stores/AutobuildSourceRepositoriesStore.js b/app/scripts/stores/AutobuildSourceRepositoriesStore.js new file mode 100644 index 0000000000..2b2448afb4 --- /dev/null +++ b/app/scripts/stores/AutobuildSourceRepositoriesStore.js @@ -0,0 +1,46 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +import _ from 'lodash'; + +var AutobuildSourceRepositoriesStore = createStore({ + storeName: 'AutobuildSourceRepositoriesStore', + handlers: { + RECEIVE_LINKED_REPO_SOURCES: '_receiveLinkedRepos', + LINKED_REPO_SOURCES_ERROR: '_linkedReposError', + SET_LINKED_REPO_TYPE: '_setType' + }, + initialize: function() { + this.repos = []; + this.type = ''; + this.error = ''; + }, + _setType: function(type) { + this.type = type; + this.emitChange(); + }, + _receiveLinkedRepos: function(linkedRepos) { + this.repos = linkedRepos; + this.emitChange(); + }, + _linkedReposError: function(err) { + this.error = 'Please check if you have any repositories setup on ' + this.type + '.'; + this.emitChange(); + }, + getState: function() { + return { + repos: this.repos, + type: this.type, + error: this.error + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.repos = state.repos; + this.type = state.type; + this.error = state.error; + } +}); + +module.exports = AutobuildSourceRepositoriesStore; diff --git a/app/scripts/stores/AutobuildStore.js b/app/scripts/stores/AutobuildStore.js new file mode 100644 index 0000000000..b012f99a60 --- /dev/null +++ b/app/scripts/stores/AutobuildStore.js @@ -0,0 +1,54 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +import _ from 'lodash'; + +var AutobuildStore = createStore({ + storeName: 'AutobuildStore', + handlers: { + RECEIVE_SOURCE_REPOS: '_receiveSourceRepos', + RECEIVE_SOURCE_ACCOUNTS: '_receiveSourceAccount' + }, + initialize: function() { + this.githubAccount = null; + this.githubRepos = []; + this.bitbucketAccount = null; + this.bitbucketRepos = []; + this.gitlabAccount = null; + this.gitlabRepos = []; + }, + _receiveSourceRepos: function(res) { + this.githubRepos = res.github.detail ? [] : res.github; + this.bitbucketRepos = res.bitbucket.detail ? [] : res.bitbucket; + this.gitlabRepos = res.gitlab.detail ? [] : res.gitlab; + this.emitChange(); + }, + _receiveSourceAccount: function(res) { + this.githubAccount = res.github.detail ? null : res.github; + this.bitbucketAccount = res.bitbucket.detail ? null : res.bitbucket; + this.gitlabAccount = res.gitlab.detail ? null : res.gitlab; + this.emitChange(); + }, + getState: function() { + return { + githubAccount: this.githubAccount, + githubRepos: this.githubRepos, + bitbucketAccount: this.bitbucketAccount, + bitbucketRepos: this.bitbucketRepos, + gitlabAccount: this.gitlabAccount, + gitlabRepos: this.gitlabRepos + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.githubAccount = state.githubAccount; + this.githubRepos = state.githubRepos; + this.bitbucketAccount = state.bitbucketAccount; + this.bitbucketRepos = state.bitbucketRepos; + this.gitlabAccount = state.gitlabAccount; + this.gitlabRepos = state.gitlabRepos; + } +}); + +module.exports = AutobuildStore; diff --git a/app/scripts/stores/AutobuildTagsStore.js b/app/scripts/stores/AutobuildTagsStore.js new file mode 100644 index 0000000000..56629f1ca7 --- /dev/null +++ b/app/scripts/stores/AutobuildTagsStore.js @@ -0,0 +1,50 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +import _ from 'lodash'; + +var AutobuildTagsStore = createStore({ + storeName: 'AutobuildTagsStore', + handlers: { + AUTOBUILD_TAGS_ERROR: '_autobuildTagsError' + }, + initialize: function() { + //tags is an array of tag + //{ dockerfile_location, source_type['Tag' or 'Branch'], source_name[eg. master] } + this.tags = []; + }, + addTag: function(tag) { + //tag + //{ + // id: 'row-1' + // sourceName: 'master' + // fileLocation: '/' + // buildTag: 'latest', + // sourceType: 'Branch' + //} + this.tags.push(tag); + }, + removeTag: function(id) { + _.remove(this.tags, function(tag) { + return (tag.id === id); + }); + }, + setTagState: function(id, state) { + var tagToUpdate = _.find(this.tags, function(tag) { + return tag.id === id; + }); + _.merge(tagToUpdate, state); + }, + getState: function() { + return { + tags: this.tags + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.tags = state.tags; + } +}); + +module.exports = AutobuildTagsStore; diff --git a/app/scripts/stores/AutobuildTriggerByTagStore.js b/app/scripts/stores/AutobuildTriggerByTagStore.js new file mode 100644 index 0000000000..b3c16f81a1 --- /dev/null +++ b/app/scripts/stores/AutobuildTriggerByTagStore.js @@ -0,0 +1,91 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +import findIndex from 'lodash/array/findIndex'; +import map from 'lodash/collection/map'; +import { STATUS } from './common/Constants'; + +var AutobuildTriggerByTagStore = createStore({ + storeName: 'AutobuildTriggerByTagStore', + handlers: { + INITIALIZE_AB_TRIGGERS: '_initTriggers', + AB_TRIGGER_BY_TAG_ERROR: '_triggerByTagError', + AB_TRIGGER_BY_TAG_SUCCESS: '_triggerByTagSuccess', + ATTEMPT_TRIGGER_BY_TAG: '_triggerByTagAttempt' + }, + initialize: function() { + this.triggers = []; + this.tagStatuses = []; + }, + _initTriggers: function(tags) { + //on load of the build settings page + this.initialize(); + + this.triggers = map(tags, (tag) => { + return { + id: tag.id, + success: '', + error: '' + }; + }); + + this.tagStatuses = map(tags, (tag) => { + return { + id: tag.id, + status: STATUS.DEFAULT + }; + }); + this.emitChange(); + }, + _findIndices: function(id) { + const statusIndex = findIndex(this.tagStatuses, (s) => { + return s.id === id; + }); + const triggerIndex = findIndex(this.triggers, (t) => { + return t.id === id; + }); + return {statusIndex, triggerIndex}; + }, + _triggerByTagAttempt: function(id) { + const {statusIndex, triggerIndex} = this._findIndices(id); + this.tagStatuses[statusIndex].status = STATUS.ATTEMPTING; + this.triggers[triggerIndex].error = ''; + this.triggers[triggerIndex].success = ''; + this.emitChange(); + }, + _triggerByTagError: function(errObj) { + const {id, error} = errObj; + const { triggerIndex } = this._findIndices(id); + this.triggers[triggerIndex].error = error; + setTimeout(this._clearTriggerStatus.bind(this, id), 3000); + this.emitChange(); + }, + _triggerByTagSuccess: function(successObj) { + const {id, success} = successObj; + const { triggerIndex } = this._findIndices(id); + this.triggers[triggerIndex].success = success; + setTimeout(this._clearTriggerStatus.bind(this, id), 3000); + this.emitChange(); + }, + _clearTriggerStatus: function(id) { + const {statusIndex, triggerIndex} = this._findIndices(id); + this.tagStatuses[statusIndex].status = STATUS.DEFAULT; + this.triggers[triggerIndex].error = ''; + this.triggers[triggerIndex].success = ''; + this.emitChange(); + }, + getState: function() { + return { + triggers: this.triggers, + tagStatuses: this.tagStatuses + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.triggers = state.triggers; + this.tagStatuses = state.tagStatuses; + } +}); + +module.exports = AutobuildTriggerByTagStore; diff --git a/app/scripts/stores/BillingInfoFormStore.js b/app/scripts/stores/BillingInfoFormStore.js new file mode 100644 index 0000000000..19473595c5 --- /dev/null +++ b/app/scripts/stores/BillingInfoFormStore.js @@ -0,0 +1,184 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +import _ from 'lodash'; +import { STATUS } from './billingformstore/Constants'; +var debug = require('debug')('STORE::BillingInfoFormStore'); + +var BillingInfoFormStore = createStore({ + storeName: 'BillingInfoFormStore', + handlers: { + BILLING_ACCOUNT_EXISTS: '_accountExists', + BILLING_INFO_EXISTS: '_billingInfoExists', + BILLING_ERRORS: '_updateErrors', + BILLING_INFO_UPDATE_FIELD_WITH_VALUE: '_updateBillingInfoForm', + BILLING_SUBMIT_ERROR: '_submitErrors', + BILLING_SUBMIT_START: '_submitStart', + BILLING_SUBMIT_SUCCESS: '_submitSuccess', + CLEAR_BILLING_FORM: '_clearBillingForm', + GET_RECURLY_ERROR: '_updateRecurlyErrors', + LOGOUT: '_clearStore', + RECEIVE_BILLING_INFO: '_receiveBillingInfo', + UPDATE_COUPON_VALUE: '_updateCouponValue' + }, + initialize: function() { + var D = new Date(); + var month = 1; + var year = D.getFullYear(); + this.billforwardId = ''; + this.accountInfo = { // Billing profile account + account_code: '', + username: '', + email: '', + first_name: '', + last_name: '', + company_name: '', + hasError: false, + newBilling: true + }; + this.billingInfo = { // Billing card information + first_name: '', + last_name: '', + address1: '', + address2: '', + country: '', + state: '', + zip: '', + city: '', + last_four: '', + card_type: '', + month: '', + year: '', + newBilling: true + }; + this.card = { + number: '', + cvv: '', + month: month, + year: year, + last_four: null, + type: '', + coupon_code: '', + coupon: 0 + }; + this.errorMessage = ''; + this.fieldErrors = { + number: false, + expiry: false, + cvv: false, + coupon_code: false, + first_name: false, + last_name: false, + address1: false, + country: false, + state: false, + zip: false, + city: false, + month: false, + year: false + }; + this.STATUS = STATUS.DEFAULT; + }, + _accountExists: function() { + this.accountInfo.newBilling = false; + this.emitChange(); + }, + _billingInfoExists: function() { + this.billingInfo.newBilling = false; + this.emitChange(); + }, + _clearStore: function() { + this.initialize(); + }, + _receiveBillingInfo: function(payload) { + this.initialize(); + if (payload.billingInfo && payload.billingInfo.last_four) { + var cardInfo = { + last_four: payload.billingInfo.last_four, + type: payload.billingInfo.card_type, + month: payload.billingInfo.month, + year: payload.billingInfo.year + }; + } + _.merge(this.billingInfo, payload.billingInfo); + _.merge(this.accountInfo, payload.accountInfo); + _.merge(this.card, cardInfo); + this.billforwardId = payload.billforwardId; + this.emitChange(); + }, + _submitErrors: function(message) { + this.STATUS = STATUS.FORM_ERROR; + this.errorMessage = message; + this.emitChange(); + }, + _submitSuccess: function() { + this.STATUS = STATUS.SUCCESS; + this.errorMessage = ''; + this.emitChange(); + }, + _submitStart: function() { + this.STATUS = STATUS.ATTEMPTING; + this.errorMessage = ''; + this.emitChange(); + }, + _updateBillingInfoForm: function({ field, fieldKey, fieldValue }) { + if (field === 'billing') { + this.billingInfo[fieldKey] = fieldValue; + } else if (field === 'account') { + this.accountInfo[fieldKey] = fieldValue; + } else if (field === 'card') { + this.card[fieldKey] = fieldValue; + } + this.emitChange(); + }, + _updateErrors: function(hasError) { + this.STATUS = STATUS.FORM_ERROR; + _.merge(this.fieldErrors, hasError.fieldErrors); + _.merge(this.accountInfo, hasError.accountErr); + this.errorMessage = 'Please make sure all fields are valid.'; + this.emitChange(); + }, + _updateRecurlyErrors: function(error) { + const errorFields = error.fields; + debug('Recurly Form errors', errorFields); + const fieldErrors = { + number: _.includes(errorFields, 'number'), + expiry: _.includes(errorFields, 'month') || _.includes(errorFields, 'year'), + cvv: _.includes(errorFields, 'cvv'), + first_name: _.includes(errorFields, 'first_name'), + last_name: _.includes(errorFields, 'last_name') + }; + _.merge(this.fieldErrors, fieldErrors); + this.STATUS = STATUS.FORM_ERROR; + this.errorMessage = error.message; + this.emitChange(); + }, + _updateCouponValue: function(value) { + this.card.coupon = value; + this.emitChange(); + }, + getState: function() { + return { + billforwardId: this.billforwardId, + accountInfo: this.accountInfo, + billingInfo: this.billingInfo, + card: this.card, + errorMessage: this.errorMessage, + fieldErrors: this.fieldErrors, + STATUS: this.STATUS + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.billforwardId = state.billforwardId; + this.accountInfo = state.accountInfo; + this.billingInfo = state.billingInfo; + this.card = state.card; + this.errorMessage = state.errorMessage; + this.fieldErrors = state.fieldErrors; + this.STATUS = state.STATUS; + } +}); + +module.exports = BillingInfoFormStore; diff --git a/app/scripts/stores/BillingPlansStore.js b/app/scripts/stores/BillingPlansStore.js new file mode 100644 index 0000000000..3a25234628 --- /dev/null +++ b/app/scripts/stores/BillingPlansStore.js @@ -0,0 +1,128 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +import _ from 'lodash'; + +var BillingPlansStore = createStore({ + storeName: 'BillingPlansStore', + handlers: { + RESET_BILLING_PLANS: '_clearStore', + RECEIVE_BILLING_INFO: '_receiveBillingInfo', + RECEIVE_BILLING_SUBSCRIPTION: '_receiveBillingSubscription', + RECEIVE_INVOICES: '_receiveInvoices', + RESET_CURRENT_PLAN: '_resetCurrentPlan', + UPDATE_PLAN_START: '_updatePlanStart', + UPDATE_PLAN_ERROR: '_updatePlanErr', + DELETE_SUBSCRIPTION_ERR: '_updatePlanErr', + DELETE_SUBSCRIPTION_SUCCESS: '_unsubscribeComplete', + UNSUBSCRIBE_SUBSCRIPTION: '_unsubscribe', // UNUSED - deprecated + UNSUBSCRIBE_PACKAGE: '_unsubscribePackage', // UNUSED - deprecated + UNSUBSCRIBE_PLAN: '_unsubscribePlan', // UNUSED - deprecated + LOGOUT: '_clearStore' + }, + initialize: function() { + this.currentPlan = {}; + this.accountInfo = { + account_code: '', + username: '', + email: '', + first_name: '', + last_name: '', + company_name: '', + hasError: false, + newBilling: true + }; + this.billingInfo = { + first_name: '', + last_name: '', + address1: '', + address2: '', + country: '', + state: '', + zip: '', + city: '', + last_four: '', + card_type: '', + month: '', + year: '', + newBilling: true + }; + this.invoices = []; + this.plansError = ''; + this.unsubscribing = ''; + this.updatePlan = ''; + }, + _clearStore: function() { + this.initialize(); + }, + _receiveBillingInfo: function(payload) { + this.initialize(); + _.merge(this.billingInfo, payload.billingInfo); + _.merge(this.accountInfo, payload.accountInfo); + _.merge(this.currentPlan, payload.currentPlan); + this.emitChange(); + }, + _receiveBillingSubscription: function(payload) { + _.merge(this.currentPlan, payload.currentPlan); + this.updatePlan = ''; + this.emitChange(); + }, + _receiveInvoices: function(payload) { + this.invoices = payload.invoices; + this.emitChange(); + }, + _resetCurrentPlan: function(payload) { + this.currentPlan = payload.currentPlan; + this.emitChange(); + }, + _unsubscribe: function() { // UNUSED - deprecated + this.unsubscribing = 'subscription'; + this.emitChange(); + }, + _unsubscribePackage: function() { // UNUSED - deprecated + this.unsubscribing = 'package'; + this.emitChange(); + }, + _unsubscribePlan: function() { // UNUSED - deprecated + this.unsubscribing = 'plan'; + this.emitChange(); + }, + _unsubscribeComplete: function() { // UNUSED - deprecated + this.unsubscribing = ''; + this.emitChange(); + }, + _updatePlanStart: function(payload) { + this.updatePlan = payload; + this.emitChange(); + }, + _updatePlanErr: function(payload) { + this.unsubscribing = ''; + this.updatePlan = ''; + this.plansError = payload; + this.emitChange(); + }, + getState: function() { + return { + currentPlan: this.currentPlan, + accountInfo: this.accountInfo, + billingInfo: this.billingInfo, + invoices: this.invoices, + plansError: this.plansError, + unsubscribing: this.unsubscribing, + updatePlan: this.updatePlan + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.currentPlan = state.currentPlan; + this.accountInfo = state.accountInfo; + this.billingInfo = state.billingInfo; + this.invoices = state.invoices; + this.plansError = state.plansError; + this.unsubscribing = state.unsubscribing; + this.updatePlan = state.updatePlan; + } +}); + +module.exports = BillingPlansStore; diff --git a/app/scripts/stores/BitbucketLinkStore.js b/app/scripts/stores/BitbucketLinkStore.js new file mode 100644 index 0000000000..bc3c1ffb3e --- /dev/null +++ b/app/scripts/stores/BitbucketLinkStore.js @@ -0,0 +1,60 @@ +'use strict'; + +import _ from 'lodash'; +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('BitbucketLinkStore'); + +var BitbucketLinkStore = createStore({ + storeName: 'BitbucketLinkStore', + handlers: { + RECEIVE_BITBUCKET_AUTH_URL: '_receiveUrl', + BITBUCKET_AUTH_URL_ERROR: '_urlError', + BITBUCKET_ASSOCIATE_ERROR: '_associateError' + }, + initialize: function() { + this.authURL = ''; + this.error = ''; + }, + _associateError: function(body) { + debug(body); + if (_.has(body, 'detail') && _.isString(body.detail)) { + this.error = body.detail; + } else { + this.error = 'Error linking your account to Bitbucket. Please check that you do not have the same Bitbucket account linked to another Docker Hub account.'; + } + this.emitChange(); + setTimeout(this._clearError.bind(this), 5000); + }, + _receiveUrl: function(res) { + this.authURL = res.bitbucket_authorization_url; + this.emitChange(); + }, + _urlError: function(err) { + debug(err); + this.error = 'Error linking your account to bitbucket.'; + this.emitChange(); + setTimeout(this._clearError.bind(this), 5000); + }, + _clearError: function() { + this.error = ''; + this.emitChange(); + }, + setURL: function(url) { + this.authURL = url; + }, + getState: function() { + return { + authURL: this.authURL, + error: this.error + }; + }, + rehydrate: function(state) { + this.authURL = state.authURL; + this.error = state.error; + }, + dehydrate: function() { + return this.getState(); + } +}); + +module.exports = BitbucketLinkStore; diff --git a/app/scripts/stores/ChangePasswordStore.js b/app/scripts/stores/ChangePasswordStore.js new file mode 100644 index 0000000000..f9c210622c --- /dev/null +++ b/app/scripts/stores/ChangePasswordStore.js @@ -0,0 +1,64 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +const debug = require('debug')('stores: ChangePasswordStore'); + +var ChangePasswordStore = createStore({ + storeName: 'ChangePasswordStore', + handlers: { + CHANGE_PASS_UPDATE: '_updateStore', + CHANGE_PASS_SUCCESS: '_changePassSuccess', + CHANGE_PASS_CLEAR: '_clearStore', + RESET_PASSWORD_SUCCESSFUL: '_changePassSuccess', + RESET_PASSWORD_ERROR: '_changePassError' + }, + initialize: function() { + this.oldpass = ''; + this.newpass = ''; + this.confpass = ''; + this.reset = false; + this.hasErr = false; + this.err = ''; + }, + _updateStore: function(payload) { + this.reset = false; + this.hasErr = false; + this.err = ''; + this.oldpass = payload.oldpass; + this.newpass = payload.newpass; + this.confpass = payload.confpass; + this.emitChange(); + }, + _changePassSuccess: function() { + this._clearStore(); + this.reset = true; + this.emitChange(); + }, + _changePassError: function(error) { + this._clearStore(); + this.hasErr = true; + this.err = error; + this.emitChange(); + }, + _clearStore: function() { + this.oldpass = ''; + this.newpass = ''; + this.confpass = ''; + this.reset = false; + this.hasErr = false; + this.err = ''; + this.emitChange(); + }, + getState: function() { + return { + oldpass: this.oldpass, + newpass: this.newpass, + confpass: this.confpass, + reset: this.reset, + hasErr: this.hasErr, + err: this.err + }; + } + +}); + +module.exports = ChangePasswordStore; diff --git a/app/scripts/stores/CloudBillingStore.js b/app/scripts/stores/CloudBillingStore.js new file mode 100644 index 0000000000..4489827ae7 --- /dev/null +++ b/app/scripts/stores/CloudBillingStore.js @@ -0,0 +1,43 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; + +var BillingPlansStore = createStore({ + storeName: 'CloudBillingStore', + handlers: { + RESET_CLOUD_BILLING_PLANS: '_clearStore', + RECEIVE_CLOUD_BILLING_INFO: '_receiveBillingInfo', + LOGOUT: '_clearStore' + }, + initialize: function() { + this.currentPlan = {}; + this.billingInfo = {}; + this.accountInfo = {}; + }, + _clearStore: function() { + this.initialize(); + this.emitChange(); + }, + _receiveBillingInfo: function(payload) { + this.billingInfo = payload.billingInfo; + this.accountInfo = payload.accountInfo; + this.currentPlan = payload.currentPlan; + this.emitChange(); + }, + getState: function() { + return { + currentPlan: this.currentPlan, + accountInfo: this.accountInfo, + billingInfo: this.billingInfo + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.currentPlan = state.currentPlan; + this.accountInfo = state.accountInfo; + this.billingInfo = state.billingInfo; + } +}); + +module.exports = BillingPlansStore; diff --git a/app/scripts/stores/CloudCouponStore.js b/app/scripts/stores/CloudCouponStore.js new file mode 100644 index 0000000000..c3bb7add7a --- /dev/null +++ b/app/scripts/stores/CloudCouponStore.js @@ -0,0 +1,54 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +import _ from 'lodash'; + +var CloudCouponStore = createStore({ + storeName: 'CloudCouponStore', + handlers: { + RECEIVE_BILLING_INFO: '_clearStore', + CLEAR_CLOUD_COUPON: '_clearStore', + UPDATE_COUPON_VALUE: '_updateDiscountValue', + BILLING_INFO_UPDATE_FIELD_WITH_VALUE: '_updateCouponCode', + BILLING_ERRORS: '_updateErrors' + }, + initialize: function() { + this.couponCode = ''; + this.discountValue = 0; + this.hasError = false; + }, + _clearStore: function() { + this.initialize(); + this.emitChange(); + }, + _updateCouponCode: function({field, fieldKey, fieldValue}) { + this.couponCode = fieldKey === 'coupon_code' ? fieldValue : this.couponValue; + this.emitChange(); + }, + _updateDiscountValue: function(discount) { + this.discountValue = discount; + this.emitChange(); + }, + _updateErrors: function({fieldErrors}) { + if (_.has(fieldErrors, 'coupon_code')) { + this.hasError = fieldErrors.coupon_code; + } + this.emitChange(); + }, + getState: function() { + return { + couponCode: this.couponCode, + discountValue: this.discountValue, + hasError: this.hasError + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.couponCode = state.couponCode; + this.discountValue = state.discountValue; + this.hasError = state.hasError; + } +}); + +module.exports = CloudCouponStore; diff --git a/app/scripts/stores/ConvertToOrgStore.js b/app/scripts/stores/ConvertToOrgStore.js new file mode 100644 index 0000000000..f100be9945 --- /dev/null +++ b/app/scripts/stores/ConvertToOrgStore.js @@ -0,0 +1,41 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('stores: ConvertToOrgStore'); + +export default createStore({ + storeName: 'ConvertToOrgStore', + handlers: { + CONVERT_TO_ORG_BAD_REQUEST: '_badRequest', + UPDATE_TO_ORG_OWNER: '_updateOwner' + }, + initialize: function() { + this.convertError = false; + this.error = {}; + this.newOwner = ''; + }, + _badRequest: function(error) { + this.convertError = true; + this.error = error; + this.emitChange(); + }, + _updateOwner: function(payload) { + this.newOwner = payload.newOwner; + this.convertError = false; + this.emitChange(); + }, + getState: function() { + return { + convertError: this.convertError, + error: this.error, + newOwner: this.newOwner + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.convertError = state.convertError; + this.error = state.error; + this.newOwner = state.newOwner; + } +}); diff --git a/app/scripts/stores/CreateRepositoryFormStore.js b/app/scripts/stores/CreateRepositoryFormStore.js new file mode 100644 index 0000000000..e604fa6a50 --- /dev/null +++ b/app/scripts/stores/CreateRepositoryFormStore.js @@ -0,0 +1,147 @@ +'use strict'; + +import _ from 'lodash'; +import { STATUS } from './common/Constants'; +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('CreateRepositoryFormStore'); + +var noErrorObj = { + hasError: false, + error: '' +}; + +export default createStore({ + storeName: 'CreateRepositoryFormStore', + handlers: { + CREATE_REPO_CLEAR_FORM: 'initialize', + CREATE_REPO_UPDATE_FIELD_WITH_VALUE: '_updateFieldWithValue', + CREATE_REPO_ATTEMPT_START: '_attemptStart', + CREATE_REPO_BAD_REQUEST: '_badRequest', + CREATE_REPO_SUCCESS: '_success', + CREATE_REPO_FACEPALM: '_facepalm', + CREATE_REPO_RECEIVE_NAMESPACES: '_receiveNamespaces' + }, + initialize() { + this.STATUS = STATUS.DEFAULT; + + this.namespaces = []; + this.globalFormError = ''; + + this.fields = { + user: {}, + namespace: {}, + name: {}, + description: {}, + full_description: {}, + is_private: {} + }; + + this.values = { + user: '', + namespace: '', + name: '', + description: '', + full_description: '', + is_private: true + }; + }, + _receiveNamespaces({ + namespaces, selectedNamespace + }) { + debug('receiving namespaces', namespaces, selectedNamespace); + /** + * namespaces is equivalent to the response in the namespaces API call + */ + this.namespaces = namespaces.namespaces; + if(_.includes(namespaces.namespaces, selectedNamespace)) { + this.values.namespace = selectedNamespace; + } else { + this.values.namespace = namespaces.namespaces[0]; + } + this.emitChange(); + }, + _facepalm() { + // this happens if things are screwed and we can't recover gracefully + this.STATUS = STATUS.FACEPALM; + this.emitChange(); + }, + _clearForm() { + this.initialize(); + this.emitChange(); + }, + _updateFieldWithValue({fieldKey, fieldValue}) { + debug(fieldKey, fieldValue); + this.fields[fieldKey].hasError = false; + this.fields[fieldKey].error = ''; + this.globalFormError = ''; + this.values[fieldKey] = fieldValue; + this.emitChange(); + }, + _attemptStart() { + this.STATUS = STATUS.ATTEMPTING; + this.emitChange(); + }, + _success() { + this.STATUS = STATUS.SUCCESSFUL_SIGNUP; + this.emitChange(); + }, + _badRequest(obj) { + /** + * This function expects keys which match the `this.fields` keys + * with an array of errors: + * + * { + * orgname: ['this field is required'] + * } + */ + let shouldEmitChange = false; + // So far obj.detail is only returned when there are no more private repo's + // We really need to update the response from the api + if (obj.detail) { + obj.is_private = [obj.detail]; + } + + // cycle through the possible form fields + this.fields = _.mapValues(this.fields, function (errorObject, key) { + if(_.has(obj, key)) { + shouldEmitChange = true; + return { + hasError: !!obj[key], + error: obj[key][0] + }; + } else { + return errorObject; + } + }); + /** + * __all__ occurs when "Repository with this Name and Namespace already exists." + */ + if(obj.__all__) { + this.globalFormError = obj.__all__[0]; + shouldEmitChange = true; + } + + if(shouldEmitChange) { + this.emitChange(); + } + }, + getState() { + return { + fields: this.fields, + values: this.values, + STATUS: this.STATUS, + namespaces: this.namespaces, + globalFormError: this.globalFormError + }; + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + this.fields = state.fields; + this.values = state.values; + this.namespaces = state.namespaces; + this.STATUS = state.STATUS; + this.globalFormError = state.globalFormError; + } +}); diff --git a/app/scripts/stores/DashboardContribsStore.js b/app/scripts/stores/DashboardContribsStore.js new file mode 100644 index 0000000000..cbdae5356f --- /dev/null +++ b/app/scripts/stores/DashboardContribsStore.js @@ -0,0 +1,43 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('DashboardContribsStore'); + +export default createStore({ + storeName: 'DashboardContribsStore', + handlers: { + RECEIVE_CONTRIB: '_receiveContribRepos', + LOGOUT: 'initialize' + }, + initialize: function() { + this.count = 0; + this.contribs = []; + this.next = null; + this.prev = null; + }, + _receiveContribRepos: function(res) { + this.count = res.count; + this.contribs = res.results; + this.next = res.next; + this.prev = res.previous; + this.emitChange(); + }, + getState: function() { + return { + count: this.count, + contribs: this.contribs, + next: this.next, + prev: this.prev + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.count = state.count; + this.contribs = state.contribs; + this.next = state.next; + this.prev = state.prev; + } +}); + diff --git a/app/scripts/stores/DashboardMembersStore.js b/app/scripts/stores/DashboardMembersStore.js new file mode 100644 index 0000000000..0b2df77622 --- /dev/null +++ b/app/scripts/stores/DashboardMembersStore.js @@ -0,0 +1,73 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import _ from 'lodash'; +var debug = require('debug')('DashboardMembersStore'); +import { STATUS } from './orgteamstore/Constants'; + +var DashboardMembersStore = createStore({ + storeName: 'DashboardMembersStore', + handlers: { + RECEIVE_DASHBOARD_TEAM_MEMBERS: '_receiveDashboardTeamMembers', + ORG_DASHBOARD_MEMBERS_ERROR: '_errorReceivingMembers', + TEAM_MEMBER_ERROR: '_teamMemberError', + TEAM_MEMBER_BAD_REQUEST: '_teamMemberBadRequest', + TEAM_MEMBER_UNAUTHORIZED: '_teamMemberUnauthorized', + CLEAR_MEMBER_ERROR: '_clearErrorStates' + }, + initialize() { + this.members = []; + this.count = 0; + this.error = {}; + this.STATUS = STATUS.DEFAULT; + }, + _errorReceivingMembers(err) { + debug(err); + }, + _receiveDashboardTeamMembers(members) { + debug(members); + this.members = members; + this.count = members.length; + this.emitChange(); + }, + _teamMemberError: function(err) { + this.STATUS = STATUS.MEMBER_ERROR; + this.STATUS = STATUS.GENERAL_SERVER_ERROR; + this.error = err; + this.emitChange(); + }, + _teamMemberBadRequest: function(err) { + this.STATUS = STATUS.MEMBER_BAD_REQUEST; + this.error = err; + this.emitChange(); + }, + _teamMemberUnauthorized: function(err) { + this.STATUS = STATUS.MEMBER_UNAUTHORIZED; + this.error = err; + this.emitChange(); + }, + _clearErrorStates: function() { + this.error = {}; + this.STATUS = STATUS.DEFAULT; + this.emitChange(); + }, + getState() { + return { + members: this.members, + count: this.count, + error: this.error, + STATUS: this.STATUS + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.members = state.members; + this.count = state.count; + this.STATUS = state.STATUS; + this.error = state.error; + } +}); + +module.exports = DashboardMembersStore; diff --git a/app/scripts/stores/DashboardNamespacesStore.js b/app/scripts/stores/DashboardNamespacesStore.js new file mode 100644 index 0000000000..35b354f8b6 --- /dev/null +++ b/app/scripts/stores/DashboardNamespacesStore.js @@ -0,0 +1,62 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('DashboardNamespacesStore'); +import _ from 'lodash'; + +export default createStore({ + storeName: 'DashboardNamespacesStore', + handlers: { + RECEIVE_DASHBOARD_NAMESPACES: '_receiveOrgs', + CURRENT_USER_CONTEXT: '_setContext', + CREATE_REPO_RECEIVE_NAMESPACES: '_receiveOwnedNamespaces' + }, + initialize() { + this.namespaces = []; + this.currentUserContext = ''; + this.ownedNamespaces = []; + }, + _receiveOrgs(res) { + //There are two API calls possible to get namespaces + //`/v2/namespaces` -> returns `res.orgs.namespaces` an object with {namespaces: ['ns1', 'ns2', 'etc']} + //`/v2/orgs` -> returns `res.orgs.results` with all orgs the user has read access on. We merge the `res.user` with this list + if (res.orgs.namespaces) { + this.namespaces = res.orgs.namespaces; + } else if (res.orgs.results && _.isArray(res.orgs.results)) { + var nsArray = _.pluck(res.orgs.results, 'orgname'); + nsArray.unshift(res.user); + this.namespaces = nsArray; + } + this.emitChange(); + }, + _receiveOwnedNamespaces({ + namespaces, selectedNamespace + }) { + debug('receiving namespaces', namespaces, selectedNamespace); + /** + * namespaces is equivalent to the response in the namespaces API call + */ + this.ownedNamespaces = namespaces.namespaces; + this.emitChange(); + }, + _setContext: function({username}) { + this.currentUserContext = username; + this.emitChange(); + }, + getState() { + return { + currentUserContext: this.currentUserContext, + namespaces: this.namespaces, + ownedNamespaces: this.ownedNamespaces + }; + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + this.currentUserContext = state.currentUserContext; + this.namespaces = state.namespaces; + this.ownedNamespaces = state.ownedNamespaces; + } +}); + diff --git a/app/scripts/stores/DashboardReposStore.js b/app/scripts/stores/DashboardReposStore.js new file mode 100644 index 0000000000..1501ffc8a4 --- /dev/null +++ b/app/scripts/stores/DashboardReposStore.js @@ -0,0 +1,69 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('DashboardReposStore'); +import { STATUS } from './common/Constants'; +const { + ATTEMPTING, + DEFAULT, + SUCCESSFUL +} = STATUS; + +var DashboardReposStore = createStore({ + storeName: 'DashboardReposStore', + handlers: { + RECEIVE_REPOS: '_receiveRepos', + LOGOUT: 'initialize', + DASHBOARD_REPOS_STORE_ATTEMPTING_GET_REPOS: '_startGetRepos', + DASHBOARD_REPOS_STORE_ATTEMPTING_GET_ALL_REPOS: '_startGetAllRepos', + DASHBOARD_REPOS_STORE_RECEIVE_ALL_REPOS_SUCCESS: '_receiveAllRepos' + }, + initialize() { + this.repos = []; + this.count = 0; + this.next = null; + this.prev = null; + this.STATUS = DEFAULT; + }, + getState() { + return { + repos: this.repos, + count: this.count, + next: this.next, + prev: this.prev, + STATUS: this.STATUS + }; + }, + _startGetRepos: function() { + this.STATUS = DEFAULT; + this.emitChange(); + }, + _startGetAllRepos: function() { + this.STATUS = ATTEMPTING; + this.emitChange(); + }, + _receiveRepos(res) { + debug(res); + this.repos = res.results; + this.count = res.count; + this.next = res.next; + this.prev = res.previous; + this.emitChange(); + }, + _receiveAllRepos(res) { + this.STATUS = SUCCESSFUL; + this._receiveRepos(res); + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + this.repos = state.repos; + this.count = state.count; + this.next = state.next; + this.prev = state.prev; + this.STATUS = state.STATUS; + } +}); + +module.exports = DashboardReposStore; diff --git a/app/scripts/stores/DashboardStarsStore.js b/app/scripts/stores/DashboardStarsStore.js new file mode 100644 index 0000000000..2e8da00d21 --- /dev/null +++ b/app/scripts/stores/DashboardStarsStore.js @@ -0,0 +1,43 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('DashboardStarsStore'); + +export default createStore({ + storeName: 'DashboardStarsStore', + handlers: { + RECEIVE_STARRED: '_receiveStarredRepos', + LOGOUT: 'initialize' + }, + initialize: function() { + this.count = 0; + this.starred = []; + this.next = null; + this.prev = null; + }, + _receiveStarredRepos: function(res) { + this.count = res.count; + this.starred = res.results; + this.next = res.next; + this.prev = res.previous; + this.emitChange(); + }, + getState: function() { + return { + count: this.count, + starred: this.starred, + next: this.next, + prev: this.prev + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.count = state.count; + this.starred = state.starred; + this.next = state.next; + this.prev = state.prev; + } +}); + diff --git a/app/scripts/stores/DashboardStore.js b/app/scripts/stores/DashboardStore.js new file mode 100644 index 0000000000..f8702ca5e7 --- /dev/null +++ b/app/scripts/stores/DashboardStore.js @@ -0,0 +1,63 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('DashboardStore'); + +var DashboardStore = createStore({ + storeName: 'DashboardStore', + handlers: { + RECEIVE_STARRED: '_receiveStarredRepos', + RECEIVE_CONTRIB: '_receiveContribRepos', + RECEIVE_ACTIVITY_FEED: '_receiveActivityFeed', + LOGOUT: 'initialize' + }, + initialize: function() { + this.user = {}; + this.org = ''; + this.starred = []; + this.contribs = []; + this.feed = []; + }, + getInitState: function() { + return { + starred: [], + contribs: [], + org: '', + feed: [], + user: {} + }; + }, + _receiveStarredRepos: function(repos) { + this.starred = repos.results; + this.emitChange(); + }, + _receiveContribRepos: function(repos) { + this.contribs = repos.results; + this.emitChange(); + }, + _receiveActivityFeed: function(feed) { + this.feed = feed; + this.emitChange(); + }, + getState: function() { + return { + starred: this.starred, + contribs: this.contribs, + org: this.org, + feed: this.feed, + user: this.user + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.starred = state.starred; + this.contribs = state.contribs; + this.feed = state.feed; + this.org = state.org; + this.user = state.user; + } +}); + +module.exports = DashboardStore; diff --git a/app/scripts/stores/DashboardTeamsStore.js b/app/scripts/stores/DashboardTeamsStore.js new file mode 100644 index 0000000000..74d1fb2447 --- /dev/null +++ b/app/scripts/stores/DashboardTeamsStore.js @@ -0,0 +1,100 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import _ from 'lodash'; +import { STATUS } from './orgteamstore/Constants'; +var debug = require('debug')('DashboardTeamsStore'); + +var DashboardTeamsStore = createStore({ + storeName: 'DashboardTeamsStore', + handlers: { + RECEIVE_DASHBOARD_ORG_TEAMS: '_receiveDashboardOrgTeams', + TEAM_ERROR: '_orgTeamError', + TEAM_BAD_REQUEST: '_teamBadRequest', + TEAM_UNAUTHORIZED: '_teamUnauthorized', + UPDATE_TEAM_ERROR: '_updateTeamError', + UPDATE_TEAM_SUCCESS: '_updateTeamSuccess', + TEAM_READ_ONLY: '_isTeamReadOnly' + }, + initialize() { + this.teams = []; + this.count = 0; + this.teamReadOnly = false; + this.errorDetails = {detail: ''}; + this.success = ''; + this.STATUS = STATUS.DEFAULT; + }, + _receiveDashboardOrgTeams(orgTeams) { + debug(orgTeams); + this.teams = _.sortBy(orgTeams.results, 'name'); + this.count = orgTeams.count; + this.emitChange(); + }, + _orgTeamError: function(err) { + this.STATUS = STATUS.TEAM_ERROR; + this.STATUS = STATUS.GENERAL_SERVER_ERROR; + this.errorDetails = {detail: 'Username does not exist or it is invalid.'}; + this.emitChange(); + }, + _teamBadRequest: function(err) { + this.STATUS = STATUS.TEAM_BAD_REQUEST; + this.errorDetails = err; + this.emitChange(); + }, + _teamUnauthorized: function(err) { + this.STATUS = STATUS.TEAM_UNAUTHORIZED; + this.errorDetails = err; + this.emitChange(); + }, + _isTeamReadOnly: function(flag) { + this.teamReadOnly = flag; + this.emitChange(); + }, + _updateTeamError: function(err) { + this.STATUS = STATUS.UPDATE_TEAM_ERROR; + if (err.response) { + var errResp = err.response; + if (errResp.badRequest) { + this.errorDetails = err; + } else if (errResp.unauthorized || errResp.forbidden) { + this.errorDetails = {detail: 'You are not permitted to edit this team.'}; + } else { + this.errorDetails = {detail: 'Error updating team. Check if name is between 3 and 30 characters with no spaces.'}; + } + } + this.emitChange(); + }, + _updateTeamSuccess: function(err) { + this.STATUS = STATUS.UPDATE_TEAM_SUCCESS; + this.success = 'Team successfully updated.'; + setTimeout(this._clearErrorStates.bind(this), 5000); + this.emitChange(); + }, + _clearErrorStates: function() { + this.errorDetails = {detail: ''}; + this.success = ''; + this.STATUS = STATUS.DEFAULT; + this.emitChange(); + }, + getState() { + return { + teams: this.teams, + count: this.count, + teamReadOnly: this.teamReadOnly, + success: this.success, + errorDetails: this.errorDetails + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.teams = state.teams; + this.teamReadOnly = state.teamReadOnly; + this.count = state.count; + this.success = state.success; + this.errorDetails = state.errorDetails; + } +}); + +module.exports = DashboardTeamsStore; diff --git a/app/scripts/stores/DeletePipelineStore.js b/app/scripts/stores/DeletePipelineStore.js new file mode 100644 index 0000000000..ef0681321e --- /dev/null +++ b/app/scripts/stores/DeletePipelineStore.js @@ -0,0 +1,46 @@ +'use strict'; + +var createStore = require('fluxible/addons/createStore'); +import { + ATTEMPTING, + DEFAULT, + FACEPALM, + SUCCESSFUL +} from './deletepipelinestore/Constants'; + +var debug = require('debug')('SignupStore'); + +export default createStore({ + storeName: 'DeletePipelineStore', + handlers: { + DELETE_PIPELINE_ATTEMPTING: '_start', + DELETE_PIPELINE_FACEPALM: '_facepalm', + DELETE_PIPELINE_SUCCESS: '_success' + }, + initialize() { + this.STATUS = DEFAULT; + }, + _start() { + this.STATUS = ATTEMPTING; + this.emitChange(); + }, + _facepalm() { + this.STATUS = FACEPALM; + this.emitChange(); + }, + _success() { + this.STATUS = SUCCESSFUL; + this.emitChange(); + }, + getState() { + return { + STATUS: this.STATUS + }; + }, + dehydrate() { + return {}; + }, + rehydrate(state) { + this.state = state; + } +}); diff --git a/app/scripts/stores/DeleteRepoFormStore.js b/app/scripts/stores/DeleteRepoFormStore.js new file mode 100644 index 0000000000..21a60d3d33 --- /dev/null +++ b/app/scripts/stores/DeleteRepoFormStore.js @@ -0,0 +1,75 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import { STATUS } from './deleterepostore/Constants.js'; +const debug = require('debug')('DeleteRepoFormStore'); + +var DeleteRepoFormStore = createStore({ + storeName: 'DeleteRepoFormStore', + handlers: { + DELETE_REPO_ATTEMPT_START: '_deleteRepoAttemptStart', + DELETE_REPO_BAD_REQUEST: '_deleteRepoBadRequest', + DELETE_REPO_ERROR: '_deleteRepoError', + DELETE_REPO_UPDATE_FIELD_WITH_VALUE: '_updateFieldWithValue', + RECEIVE_REPOSITORY: '_receiveRepository', + TOGGLE_DELETE_REPO_NAME_CONFIRM_BOX: '_toggleConfirmBox' + }, + initialize: function() { + this.error = ''; + this.STATUS = STATUS.DEFAULT; + this.values = { + confirmRepoName: '' + }; + }, + _deleteRepoAttemptStart: function() { + this.STATUS = STATUS.ATTEMPTING; + this.emitChange(); + }, + _deleteRepoBadRequest: function(res) { + this.STATUS = STATUS.FORM_ERROR; + this.error = res.detail ? res.detail + : 'Error deleting repository. Please verify if you have permissions.'; + this.emitChange(); + }, + _deleteRepoError: function() { + this.STATUS = STATUS.FORM_ERROR; + this.error = 'Error deleting repository. Please verify if you have permissions.'; + this.emitChange(); + }, + _receiveRepository: function() { + this.initialize(); + this.emitChange(); + }, + _toggleConfirmBox: function() { + if (this.STATUS === STATUS.DEFAULT) { + this.STATUS = STATUS.SHOWING_CONFIRM_BOX; + } else { + this.STATUS = STATUS.DEFAULT; + } + this.error = ''; + this.values.confirmRepoName = ''; + this.emitChange(); + }, + _updateFieldWithValue: function({fieldKey, fieldValue}){ + this.values[fieldKey] = fieldValue; + this.error = ''; + this.emitChange(); + }, + getState: function() { + return { + error: this.error, + STATUS: this.STATUS, + values: this.values + }; + }, + rehydrate: function(state) { + this.error = state.error; + this.STATUS = state.STATUS; + this.values = state.values; + }, + dehydrate: function() { + return this.getState(); + } +}); + +module.exports = DeleteRepoFormStore; diff --git a/app/scripts/stores/EmailNotifStore.js b/app/scripts/stores/EmailNotifStore.js new file mode 100644 index 0000000000..4ada6a99f9 --- /dev/null +++ b/app/scripts/stores/EmailNotifStore.js @@ -0,0 +1,128 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import _ from 'lodash'; +import { STATUS } from './common/Constants'; +var debug = require('debug')('EmailNotifStore:'); + + +export default createStore({ + storeName: 'EmailNotifStore', + handlers: { + RECEIVE_NOTIFICATIONS: '_receiveNotifications', + NOTIF_CHECKBOX_CLICK: '_updateNotifications', + RESET_EMAIL_NOTIFICATIONS_STORE: '_resetBlankSlate', + SAVE_NOTIFICATIONS_ERROR: '_saveNotifError', + SAVE_NOTIFICATIONS_SUCCESS: '_saveNotifSuccess' + }, + initialize: function() { + // initialize with data from db + this.starNotification = false; + this.imgCommentNotification = false; + this.autoBuildNotification = false; + this.starNotificationID = -1; + this.imgCommentNotificationID = -1; + this.autoBuildNotificationID = -1; + this.STATUS = STATUS.DEFAULT; + this.blankNotificationSlate = {}; + }, + _receiveNotifications: function(notifications) { + for (var i = 0; i < notifications.length; ++i) { + switch(notifications[i].notification) { + case 'new_repo_comment': + this.imgCommentNotification = true; + this.imgCommentNotificationID = notifications[i].id; + break; + case 'new_repo_star': + this.starNotification = true; + this.starNotificationID = notifications[i].id; + break; + case 'trusted_build_fail': + this.autoBuildNotification = true; + this.autoBuildNotificationID = notifications[i].id; + break; + } + } + this.blankNotificationSlate = this.getState(); + debug(this.blankNotificationSlate); + this.attempting = false; + this.emitChange(); + }, + _resetBlankSlate: function() { + var slate = this.blankNotificationSlate; + debug('RESET EMAIL NOTIF BLANK SLATE'); + this.starNotification = slate.starNotification; + this.imgCommentNotification = slate.imgCommentNotification; + this.autoBuildNotification = slate.autoBuildNotification; + this.starNotificationID = slate.starNotificationID; + this.imgCommentNotificationID = slate.imgCommentNotificationID; + this.autoBuildNotificationID = slate.autoBuildNotificationID; + this.STATUS = STATUS.DEFAULT; + this.emitChange(); + }, + _updateNotifications: function(cboxType) { + this.STATUS = STATUS.DEFAULT; + switch(cboxType) { + case 'starNotification': + this.starNotification = !this.starNotification; + break; + case 'imgCommentNotification': + this.imgCommentNotification = !this.imgCommentNotification; + break; + case 'autoBuildNotification': + this.autoBuildNotification = !this.autoBuildNotification; + break; + } + this.emitChange(); + }, + _saveNotifError: function() { + this.STATUS = 'ERROR'; + this.emitChange(); + }, + _saveNotifSuccess: function() { + this.STATUS = STATUS.SUCCESSFUL; + this.emitChange(); + }, + hasChanged: function(type) { + switch (type) { + case 'auto': + return (this.autoBuildNotification !== this.blankNotificationSlate.autoBuildNotification); + case 'star': + return (this.starNotification !== this.blankNotificationSlate.starNotificationID); + case 'comment': + return (this.imgCommentNotification !== this.blankNotificationSlate.imgCommentNotificationID); + default: + break; + } + }, + getAttempt: function() { + return this.attempting; + }, + setAttempt: function(flag) { + this.attempting = flag; + }, + getState: function() { + return { + starNotification: this.starNotification, + imgCommentNotification: this.imgCommentNotification, + autoBuildNotification: this.autoBuildNotification, + starNotificationID: this.starNotificationID, + imgCommentNotificationID: this.imgCommentNotificationID, + autoBuildNotificationID: this.autoBuildNotificationID, + STATUS: this.STATUS + }; + }, + dehydrate: function() { + return _.merge({}, this.getState(), {blankNotificationSlate: this.blankNotificationSlate}); + }, + rehydrate: function(state) { + this.starNotification = state.starNotification; + this.imgCommentNotification = state.imgCommentNotification; + this.autoBuildNotification = state.autoBuildNotification; + this.starNotificationID = state.starNotificationID; + this.imgCommentNotificationID = state.imgCommentNotificationID; + this.autoBuildNotificationID = state.autoBuildNotificationID; + this.STATUS = state.STATUS; + this.blankNotificationSlate = state.blankNotificationSlate; + } +}); diff --git a/app/scripts/stores/EmailsStore.js b/app/scripts/stores/EmailsStore.js new file mode 100644 index 0000000000..d48589a878 --- /dev/null +++ b/app/scripts/stores/EmailsStore.js @@ -0,0 +1,155 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import merge from 'lodash/object/merge'; +import has from 'lodash/object/has'; +import find from 'lodash/collection/find'; +import cloneDeep from 'lodash/lang/cloneDeep'; +import { STATUS, EMAILSTATUS } from './emailsstore/Constants'; + +var debug = require('debug')('EmailsStore'); + +var EmailsStore = createStore({ + storeName: 'EmailsStore', + handlers: { + RECEIVE_EMAILS: '_receiveEmails', + CHANGE_ROUTE: '_resetState', + ADD_EMAIL_INVALID: '_addEmailInvalid', + ADD_EMAIL_SUCCESS: '_addEmailSuccess', + START_SAVE_ACTION: '_startSaveAction', + FINISH_SAVE_ACTION: '_finishSaveAction', + RESEND_EMAIL_CONFIRMATION_ATTEMPT_START: '_resendEmailConfirmationAttemptStart', + RESEND_EMAIL_CONFIRMATION_SENT: '_resendEmailConfirmationSent', + RESEND_EMAIL_CONFIRMATION_FAILED: '_resendEmailConfirmationFail', + RESEND_EMAIL_CONFIRMATION_CLEAR: '_resendClear', + UPDATE_ADD_EMAIL: '_updateAddEmail' + }, + initialize: function() { + this.STATUS = STATUS.DEFAULT; + + this._cleanSlate = { + emails: [] + }; + this.emails = []; + this.emailConfirmations = {}; + this.addEmail = ''; + this.addError = ''; + }, + _startSaveAction() { + this.STATUS = STATUS.SAVING; + this.emitChange(); + }, + _finishSaveAction() { + this.STATUS = STATUS.DEFAULT; + this.emitChange(); + }, + _resendEmailConfirmationAttemptStart(emailID) { + debug('RESEND CONFIRMATION START'); + this.emailConfirmations = merge(this.emailConfirmations, { + [emailID]: EMAILSTATUS.ATTEMPTING + }); + this.emitChange(); + }, + _resendEmailConfirmationSent(emailID) { + debug('RESEND CONFIRMATION SENT'); + this.emailConfirmations = merge(this.emailConfirmations, { + [emailID]: EMAILSTATUS.SUCCESS + }); + this.emitChange(); + }, + _resendEmailConfirmationFail(emailID) { + debug('RESEND CONFIRMATION FAIL'); + this.emailConfirmations = merge(this.emailConfirmations, { + [emailID]: EMAILSTATUS.FAILED + }); + this.emitChange(); + }, + _resendClear(emailID) { + debug('RESEND CONFIRMATION CLEAR'); + this.emailConfirmations = merge(this.emailConfirmations, { + [emailID]: '' + }); + this.emitChange(); + }, + _receiveEmails: function(payload) { + debug(payload); + this.initialize(); + this._cleanSlate = { + /** + * We cloneDeep here because otherwise this.emails + * and this._cleanSlate.emails will refer to the + * same array causing unintuitive behavior. + */ + emails: cloneDeep(payload.emails) + }; + this.emails = payload.emails; + this.emitChange(); + }, + _addEmailInvalid(error) { + this.addError = error[0]; + this.emitChange(); + }, + _addEmailSuccess() { + this.addEmail = ''; + this.emitChange(); + }, + _resetState() { + var {emails} = this._cleanSlate; + this.emails = emails.slice(); + this.addError = ''; + this.emitChange(); + }, + _updateAddEmail(email) { + this.addEmail = email; + this.emitChange(); + }, + isCleanSlatePrimaryEmail: function(email: string) { + debug('cleanSlate.emails', this._cleanSlate.emails, email); + /** + * A function that answers "is this email address a primary email + * address?" with respect to the database, not with respect + * to the state of the client side application + */ + var primaryEmail = find(this._cleanSlate.emails, function(obj) { + return obj.email === email && obj.primary === true; + }); + + debug('primaryEmail', !!primaryEmail); + return !!primaryEmail; + }, + getCleanSlatePrimaryEmailID() { + return find(this._cleanSlate.emails, function(obj) { + return obj.primary === true; + }).id; + }, + getEmails: function() { + return { + emails: this.emails + }; + }, + getState: function() { + return { + STATUS: this.STATUS, + emails: this.emails, + addEmail: this.addEmail, + addError: this.addError, + emailConfirmations: this.emailConfirmations + }; + }, + dehydrate() { + return merge({}, + this.getState(), + { + _cleanSlate: this._cleanSlate + }); + }, + rehydrate(state) { + this._cleanSlate = state._cleanSlate; + this.addEmail = state.addEmail; + this.emails = state.emails.slice(0); + this.emailConfirmations = state.emailConfirmations; + this.addError = state.addError; + } +}); + +module.exports = EmailsStore; diff --git a/app/scripts/stores/EnterprisePaidFormStore.js b/app/scripts/stores/EnterprisePaidFormStore.js new file mode 100644 index 0000000000..f77def90e6 --- /dev/null +++ b/app/scripts/stores/EnterprisePaidFormStore.js @@ -0,0 +1,294 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import { STATUS } from './common/Constants'; +var debug = require('debug')('EnterprisePaidFormStore'); +import each from 'lodash/collection/each'; +import includes from 'lodash/collection/includes'; +import merge from 'lodash/object/merge'; +import keys from 'lodash/object/keys'; +import has from 'lodash/object/has'; +import mapValues from 'lodash/object/mapValues'; +import isString from 'lodash/lang/isString'; + +var noErrorObj = { + hasError: false, + error: '' +}; + +export default createStore({ + storeName: 'EnterprisePaidFormStore', + handlers: { + ENTERPRISE_PAID_RECEIVE_ORGS: '_receiveOrgs', + ENTERPRISE_PAID_CLEAR_FORM: '_clearStore', + ENTERPRISE_PAID_UPDATE_FIELD_WITH_VALUE: '_updateFieldWithValue', + + ENTERPRISE_PAID_ATTEMPT_START: '_enterprisePaidAttemptStart', + ENTERPRISE_PAID_BAD_REQUEST: '_badRequest', + ENTERPRISE_PAID_SUCCESS: '_signupSuccess', + ENTERPRISE_PAID_FACEPALM: '_facepalm', + + BILLING_SUBMIT_START: '_enterprisePaidAttemptStart', + BILLING_SUBMIT_SUCCESS: '_signupSuccess', + BILLING_SUBMIT_ERROR: '_badRequest', + + GET_RECURLY_ERROR: '_updateRecurlyErrors', + ENTERPRISE_PAID_GET_RECURLY_ERROR: 'recurlyError', + ENTERPRISE_PAID_API_ERROR: '_apiError', + ENTERPRISE_PAID_ERRORS: '_validateErrors', + ENTERPRISE_PAID_POPULATE_FORM: '_populateForm' + }, + initialize() { + this.STATUS = STATUS.DEFAULT; + + this.globalFormError = ''; + this.orgs = []; + + this.fields = { + first_name: {}, + last_name: {}, + postal_code: {}, + number: {}, + month: {}, + year: {}, + cvv: {}, + address1: {}, + city: {}, + state: {}, + country: {}, + expiry: {}, + email: {} + }; + + this.values = { + first_name: '', + last_name: '', + postal_code: '', + number: '', + month: '01', + year: '2015', + cvv: '', + address1: '', + city: '', + state: '', + country: 'US', + last_four: '', + card_type: '', + account_first: '', + account_last: '', + company_name: '', + email: '' + }; + }, + _clearStore(){ + this.initialize(); + this.emitChange(); + }, + _populateForm({ + first_name, + last_name, + zip, + month, + year, + address1, + address2, + city, + state, + country, + last_four, + card_type, + account_first, + account_last, + company_name, + email + }) { + var D = new Date(); + var defaultMonth = D.getMonth(); + var defaultYear = D.getFullYear() + 1; + var defaultCountry = 'US'; + this.fields = { + first_name: {}, + last_name: {}, + postal_code: {}, + number: {}, + month: {}, + year: {}, + cvv: {}, + address1: {}, + address2: {}, + city: {}, + state: {}, + country: {}, + expiry: {}, + email: {} + }; + this.values = { + first_name, + last_name, + postal_code: zip, + month: month || defaultMonth, + year: year || defaultYear, + address1, + address2, + city, + state, + country: country || defaultCountry, + last_four, + card_type, + account_first, + account_last, + company_name, + email + }; + this.STATUS = STATUS.DEFAULT; + this.globalFormError = ''; + this.emitChange(); + }, + _updateRecurlyErrors(error) { + const errorFields = error.fields; + debug('Recurly Form errors', errorFields); + let fieldErrors = { + number: { + hasError: includes(errorFields, 'number'), + error: 'There was an error processing your card' + }, + expiry: { + hasError: includes(errorFields, 'month') || includes(errorFields, 'year'), + error: 'This field is invalid' + }, + cvv: { + hasError: includes(errorFields, 'cvv'), + error: 'This field is invalid' + }, + first_name: { + hasError: includes(errorFields, 'first_name'), + error: 'This field is required' + }, + last_name: { + hasError: includes(errorFields, 'last_name'), + error: 'This field is required' + }, + postal_code: { + hasError: includes(errorFields, 'postal_code'), + error: 'This field is invalid' + } + }; + merge(this.fields, fieldErrors); + this.STATUS = STATUS.DEFAULT; + this.globalFormError = error.message; + this.emitChange(); + }, + _facepalm() { + // this happens if things are screwed and we can't recover gracefully + this.STATUS = STATUS.FACEPALM; + this.emitChange(); + }, + recurlyError(fields) { + this.STATUS = STATUS.DEFAULT; + var emitChange = false; + each(fields, function(val, idx) { + if(includes(keys(this.fields), val)) { + emitChange = true; + this.fields[val] = { + hasError: true, + error: 'This field is required' + }; + } + }, this); + + if(emitChange) { + this.emitChange(); + } + }, + _validateErrors(hasError) { + each(hasError, (v, k) => { + if (v) { + if (includes(['number', 'cvv', 'month', 'year', 'country'], k)) { + this.fields[k] = { + hasError: true, + error: 'Invalid ' + k + }; + } else { + this.fields[k] = { + hasError: true, + error: 'Required' + }; + } + } + }); + this.emitChange(); + }, + _updateFieldWithValue({fieldKey, fieldValue}) { + debug(fieldKey, fieldValue); + this.fields[fieldKey] = {hasError: false, error: ''}; + if (includes(['month', 'year'], fieldKey)) { + this.fields.expiry = {hasError: false, error: ''}; + } + if (fieldKey === 'number') { + let card_type = window.recurly.validate.cardType(fieldValue); + this.values.card_type = card_type; + } + this.values[fieldKey] = fieldValue; + this.emitChange(); + }, + _signupSuccess() { + this.STATUS = STATUS.SUCCESSFUL; + this.emitChange(); + }, + _receiveOrgs(namespaces) { + this.orgs = namespaces; + this.emitChange(); + }, + _apiError() { + this.STATUS = STATUS.FACEPALM; + this.emitChange(); + }, + _enterprisePaidAttemptStart() { + this.STATUS = STATUS.ATTEMPTING; + this.emitChange(); + }, + _badRequest(obj) { + this.STATUS = STATUS.ERROR; + + // cycle through the possible form fields + this.fields = mapValues(this.fields, function (errorObject, key) { + if(has(obj, key)) { + return { + hasError: !!obj[key], + error: obj[key][0] + }; + } else { + return errorObject; + } + }); + + if(has(obj, 'non_field_errors')) { + this.globalFormError = obj.non_field_errors[0]; + } else if (has(obj, 'detail')) { + this.globalFormError = obj.detail; + } else if (isString(obj)) { + this.globalFormError = obj; + } + + this.emitChange(); + }, + getState() { + return { + fields: this.fields, + values: this.values, + STATUS: this.STATUS, + orgs: this.orgs, + globalFormError: this.globalFormError + }; + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + this.orgs = state.orgs; + this.values = state.values; + this.fields = state.fields; + this.STATUS = state.STATUS; + this.globalFormError = this.globalFormError; + } +}); diff --git a/app/scripts/stores/EnterprisePartnerTrackingStore.js b/app/scripts/stores/EnterprisePartnerTrackingStore.js new file mode 100644 index 0000000000..6c5afaea8b --- /dev/null +++ b/app/scripts/stores/EnterprisePartnerTrackingStore.js @@ -0,0 +1,29 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('EnterprisePartnerTrackingStore'); + +export default createStore({ + storeName: 'EnterprisePartnerTrackingStore', + handlers: { + ENTERPRISE_PARTNER_RECEIVE_CODE: '_receivePartnerTrackingCode' + }, + initialize() { + this.partnervalue = ''; + }, + _receivePartnerTrackingCode({ code }) { + this.partnervalue = code; + this.emitChange(); + }, + getState() { + return { + partnervalue: this.partnervalue + }; + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + this.partnervalue = state.partnervalue; + } +}); diff --git a/app/scripts/stores/EnterpriseTrialFormStore.js b/app/scripts/stores/EnterpriseTrialFormStore.js new file mode 100644 index 0000000000..e967f84876 --- /dev/null +++ b/app/scripts/stores/EnterpriseTrialFormStore.js @@ -0,0 +1,140 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import { ATTEMPTING, DEFAULT, FACEPALM, SUCCESSFUL_SIGNUP } from 'stores/enterprisetrialstore/Constants'; +var debug = require('debug')('EnterpriseTrialFormStore'); +var _ = require('lodash'); + +var noErrorObj = { + hasError: false, + error: '' +}; + +export default createStore({ + storeName: 'EnterpriseTrialFormStore', + handlers: { + ENTERPRISE_TRIAL_RECEIVE_ORGS: '_receiveOrgs', + ENTERPRISE_TRIAL_CLEAR_FORM: '_clearForm', + ENTERPRISE_TRIAL_UPDATE_FIELD_WITH_VALUE: '_updateFieldWithValue', + ENTERPRISE_TRIAL_ATTEMPT_START: '_attemptStart', + ENTERPRISE_TRIAL_BAD_REQUEST: '_badRequest', + ENTERPRISE_TRIAL_SUCCESS: '_signupSuccess', + ENTERPRISE_TRIAL_FACEPALM: '_facepalm', + CREATED_ORGANIZATION: '_clearForm' + }, + initialize() { + this.STATUS = DEFAULT; + this.orgs = []; + this.globalFormError = ''; + + this.fields = { + firstName: {}, + lastName: {}, + companyName: {}, + jobFunction: {}, + email: {}, + phoneNumber: {}, + country: {}, + state: {}, + namespace: {} + }; + + this.values = { + namespace: '', + firstName: '', + lastName: '', + jobFunction: '', + companyName: '', + email: '', + phoneNumber: '', + country: 'US', + state: '' + }; + }, + _facepalm() { + // this happens if things are screwed and we can't recover gracefully + this.STATUS = FACEPALM; + this.globalFormError = 'Something went wrong on the server. We have been alerted to this issue'; + this.emitChange(); + }, + _clearForm() { + this.initialize(); + this.emitChange(); + }, + _clearErrors() { + this.fields = { + firstName: {}, + lastName: {}, + jobFunction: {}, + companyName: {}, + email: {}, + phoneNumber: {}, + country: {}, + state: {}, + namespace: {} + }; + this.globalFormError = ''; + }, + _updateFieldWithValue({fieldKey, fieldValue}) { + this.STATUS = DEFAULT; + this.globalFormError = ''; + this.values[fieldKey] = fieldValue; + this.emitChange(); + }, + _attemptStart() { + this.STATUS = ATTEMPTING; + this.emitChange(); + }, + _signupSuccess() { + this.STATUS = SUCCESSFUL_SIGNUP; + this._clearErrors(); + this.emitChange(); + }, + _receiveOrgs(namespaces) { + this.orgs = namespaces; + //will always have at least current logged in namespace + this.values.namespace = namespaces[0]; + this.emitChange(); + }, + _badRequest(obj) { + this._clearErrors(); + this.STATUS = DEFAULT; + + // cycle through the possible form fields + this.fields = _.mapValues(this.fields, (errorObject, key) => { + if(_.has(obj, key)) { + return { + hasError: !!obj[key], + error: obj[key][0] + }; + } else { + return errorObject; + } + }); + + if(obj && obj.non_field_errors) { + this.globalFormError = obj.non_field_errors[0]; + } + this.emitChange(); + }, + getState() { + return { + fields: this.fields, + values: this.values, + STATUS: this.STATUS, + orgs: this.orgs, + globalFormError: this.globalFormError + }; + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + this.orgs = state.orgs; + this.fields = state.fields; + this.values = state.values; + this.STATUS = state.STATUS; + this.globalFormError = state.globalFormError; + + } +}); diff --git a/app/scripts/stores/EnterpriseTrialSuccessStore.js b/app/scripts/stores/EnterpriseTrialSuccessStore.js new file mode 100644 index 0000000000..b68cf015af --- /dev/null +++ b/app/scripts/stores/EnterpriseTrialSuccessStore.js @@ -0,0 +1,47 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import { DEFAULT, + ERROR } from 'stores/enterprisetrialsuccessstore/Constants'; +const debug = require('debug')('EnterpriseTrialSuccessStore'); + +export default createStore({ + storeName: 'EnterpriseTrialSuccessStore', + handlers: { + RECEIVE_TRIAL_LICENSE_FACEPALM: '_facepalm', + RECEIVE_TRIAL_LICENSE: '_receiveTrialLicense', + RECEIVE_TRIAL_LICENSE_BAD_REQUEST: '_receiveTrialLicenseBadRequest' + }, + initialize: function() { + this.license = {}; + this.STATUS = DEFAULT; + }, + _facepalm: function(err) { + this.STATUS = ERROR; + debug(err); + this.emitChange(); + }, + _receiveTrialLicense: function(license) { + this.license = license; + this.emitChange(); + }, + _receiveTrialLicenseBadRequest: function(err) { + this.STATUS = ERROR; + debug(err); + this.emitChange(); + }, + getState: function() { + return { + license: this.license, + STATUS: this.STATUS + }; + }, + rehydrate: function(state) { + this.license = state.license; + this.STATUS = state.STATUS; + }, + dehydrate: function() { + return this.getState(); + } +}); + diff --git a/app/scripts/stores/GithubLinkStore.js b/app/scripts/stores/GithubLinkStore.js new file mode 100644 index 0000000000..212e90a594 --- /dev/null +++ b/app/scripts/stores/GithubLinkStore.js @@ -0,0 +1,61 @@ +'use strict'; + +import _ from 'lodash'; +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('GithubLinkStore'); + +var GithubLinkStore = createStore({ + storeName: 'GithubLinkStore', + handlers: { + RECEIVE_GITHUB_ID: '_receiveID', + GITHUB_ID_ERROR: '_idError', + GITHUB_SECURITY_ERROR: '_githubSecurityError', + GITHUB_ASSOCIATE_ERROR: '_githubAssociateError' + }, + initialize: function() { + this.githubClientID = ''; + this.error = ''; + }, + _receiveID: function(res) { + this.githubClientID = res.client_id; + this.emitChange(); + }, + _idError: function(err) { + debug(err); + }, + _githubAssociateError: function(body) { + debug(body); + if (_.has(body, 'detail') && _.isString(body.detail)) { + this.error = body.detail; + } else { + this.error = 'There was an error during the Github account link. Please check that you do not have the same Github account linked to another Docker Hub account.'; + } + this.emitChange(); + setTimeout(this._clearError.bind(this), 5000); + }, + _githubSecurityError: function(errorState) { + debug(errorState); + this.error = 'There was a security error during the github account linking process.'; + this.emitChange(); + setTimeout(this._clearError.bind(this), 5000); + }, + _clearError: function() { + this.error = ''; + this.emitChange(); + }, + getState: function() { + return { + githubClientID: this.githubClientID, + error: this.error + }; + }, + rehydrate: function(state) { + this.githubClientID = state.githubClientID; + this.error = state.error; + }, + dehydrate: function() { + return this.getState(); + } +}); + +module.exports = GithubLinkStore; diff --git a/app/scripts/stores/JWTStore.js b/app/scripts/stores/JWTStore.js new file mode 100644 index 0000000000..d7fcd3b368 --- /dev/null +++ b/app/scripts/stores/JWTStore.js @@ -0,0 +1,73 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('stores: JWTStore'); +import cookie from 'cookie'; + +export default createStore({ + storeName: 'JWTStore', + handlers: { + RECEIVE_JWT: '_receiveJWT', + LOGOUT: '_logout', + LOGOUT_ERROR: '_logoutError', + EXPIRED_SIGNATURE: '_setExpiredSignature' + }, + _receiveJWT(jwt) { + this.jwt = jwt; + this.signatureIsExpired = false; + this.emitChange(); + }, + _logoutError(err) { + debug(err + ' Logout did not complete cleanly on the server'); + this._logout(); //we logout on the client side anyway + }, + _logout() { + this.jwt = null; + this.emitChange(); + }, + _logoutWithNotification(){ + debug('Logging out due to invalid Signature'); + this._logout(); + }, + _setExpiredSignature(){ + this.signatureIsExpired = true; + this.jwt = null; + this.emitChange(); + }, + getJWT() { + return this.jwt; + }, + getState() { + return { + jwt: this.jwt, + signatureIsExpired: this.signatureIsExpired + }; + }, + isLoggedIn() { + //Return true if user is logged in + return !!this.jwt; + }, + dehydrate() { + if(this.signatureIsExpired) { + return { + jwt: null, + signatureIsExpired: true + }; + } else { + return { + jwt: this.jwt, + signatureIsExpired: false + }; + } + }, + rehydrate(state) { + debug('rehydrate', state); + if(state.signatureIsExpired) { + debug('signatureIsExpired'); + this._logoutWithNotification(); + } else { + debug('signatureIsValid'); + this.signatureIsExpired = state.signatureIsExpired; + this._receiveJWT(state.jwt); + } + } +}); diff --git a/app/scripts/stores/LoginStore.js b/app/scripts/stores/LoginStore.js new file mode 100644 index 0000000000..a074541f06 --- /dev/null +++ b/app/scripts/stores/LoginStore.js @@ -0,0 +1,108 @@ +'use strict'; + +import _ from 'lodash'; +import { STATUS } from './loginstore/Constants'; +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('LoginStore'); + +export default createStore({ + storeName: 'LoginStore', + handlers: { + LOGIN_ATTEMPT_START: '_loginAttemptStart', + LOGIN_UNAUTHORIZED: '_loginUnauthorized', + LOGIN_UNAUTHORIZED_DETAIL: '_loginUnauthorizedDetail', + LOGIN_BAD_REQUEST: '_badRequest', + LOGIN_ERROR: '_loginError', + LOGIN_UPDATE_FIELD_WITH_VALUE: '_updateFieldWithValue', + LOGIN_CLEAR: '_clearLoginForm' + }, + initialize() { + this.STATUS = STATUS.DEFAULT; + this.globalFormError = ''; + + this.fields = { + username: {}, + password: {} + }; + + this.values = { + username: '', + password: '' + }; + }, + _clearLoginForm() { + debug('Clearing'); + this.initialize(); + this.emitChange(); + }, + _loginAttemptStart() { + this.STATUS = STATUS.ATTEMPTING_LOGIN; + this.emitChange(); + }, + _loginError(err){ + this.STATUS = STATUS.GENERIC_ERROR; + this.globalFormError = 'There was an error contacting the server. Please try again later.'; + this.emitChange(); + }, + _badRequest(obj) { + this.STATUS = STATUS.DEFAULT; + /** + * This function expects keys which match the `this.fields` keys + * with an array of errors: + * + * { + * username: ['this field is required'] + * } + */ + let shouldEmitChange = false; + + // cycle through the possible form fields + this.fields = _.mapValues(this.fields, function (errorObject, key) { + if(_.has(obj, key)) { + shouldEmitChange = true; + return { + hasError: !!obj[key], + error: obj[key][0] + }; + } else { + return errorObject; + } + }); + + if(shouldEmitChange) { + this.emitChange(); + } + }, + _loginUnauthorized() { + this.STATUS = STATUS.ERROR_UNAUTHORIZED; + this.globalFormError = 'Login Failed. The username or password may be incorrect.'; + this.emitChange(); + }, + _loginUnauthorizedDetail({detail}) { + this.STATUS = STATUS.ERROR_UNAUTHORIZED; + this.globalFormError = detail; + this.emitChange(); + }, + getState() { + return { + fields: this.fields, + values: this.values, + STATUS: this.STATUS, + globalFormError: this.globalFormError + }; + }, + _updateFieldWithValue: function({fieldKey, fieldValue}){ + this.fields[fieldKey] = { + hasError: false, + error: '' + }; + this.values[fieldKey] = fieldValue; + this.emitChange(); + }, + dehydrate: function() { + return {}; + }, + rehydrate: function(state) { + this.state = state; + } +}); diff --git a/app/scripts/stores/NotifyStore.js b/app/scripts/stores/NotifyStore.js new file mode 100644 index 0000000000..57c2cc30b5 --- /dev/null +++ b/app/scripts/stores/NotifyStore.js @@ -0,0 +1,44 @@ +'use strict'; + +/** + * displays alert-style dismissable notifications to the user + */ + +const createStore = require('fluxible/addons/createStore'); +const debug = require('debug')('NotifyStore'); + +var NotifyStore = createStore({ + storeName: 'NotifyStore', + handlers: { + NEW_ALERT: '_newAlert', + EXPIRE_ALERT: '_expireAlert', + EXPIRED_SIGNATURE: '_newDetailAlert' + }, + initialize: function() { + // alerts have a timestamp-based key + this.alerts = {}; + }, + _newAlert: function(obj) { + this.alerts[+new Date()] = obj.msg; + debug(this.alerts); + this.emitChange(); + }, + _newDetailAlert: function(msg) { + debug(msg); + this._newAlert({ + msg: 'You have been logged out because your token has expired or was invalid' + }); + }, + getState: function() { + return this.state; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + debug(state); + this.alerts = state.alerts; + } +}); + +module.exports = NotifyStore; diff --git a/app/scripts/stores/OrgTeamStore.js b/app/scripts/stores/OrgTeamStore.js new file mode 100644 index 0000000000..cc8b54ed7f --- /dev/null +++ b/app/scripts/stores/OrgTeamStore.js @@ -0,0 +1,90 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import { STATUS } from './orgteamstore/Constants'; + +var OrgTeamStore = createStore({ + storeName: 'OrgTeamStore', + handlers: { + CREATE_ORG_TEAM: '_createOrgTeam', + RECEIVE_ORG_TEAM: '_receiveOrgTeam', + RECEIVE_TEAM_MEMBERS: '_receiveOrgMembers', + TEAM_ERROR: '_orgTeamError', + TEAM_BAD_REQUEST: '_teamBadRequest', + TEAM_UNAUTHORIZED: '_teamUnauthorized', + ORG_TEAM_CLEAR_ERROR_STATES: '_clearErrorStates' + }, + initialize: function() { + // initialize + this.name = ''; + this.description = ''; + this.members = []; + this.errorDetails = {}; + this.success = ''; + this.STATUS = STATUS.DEFAULT; + }, + //TODO: this will be removed once we have API + _createOrgTeam: function(payload) { + this.name = payload.name; + this.description = payload.description; + this.STATUS = STATUS.CREATE_TEAM_SUCCESS; + this.emitChange(); + }, + _receiveOrgTeam: function(orgTeam) { + this.name = orgTeam.name; + this.description = orgTeam.description; + this.emitChange(); + }, + _receiveOrgMembers: function(members) { + this.members = members; + this.emitChange(); + }, + _orgTeamError: function(err) { + this.STATUS = STATUS.TEAM_ERROR; + this.STATUS = STATUS.GENERAL_SERVER_ERROR; + this.errorDetails = {detail: 'Error updating team. Check if name is between 3 and 30 characters with no spaces.'}; + this.emitChange(); + }, + _teamBadRequest: function(err) { + this.STATUS = STATUS.TEAM_BAD_REQUEST; + this.errorDetails = {detail: 'Please check your input values. The team name may already exist or the characters may be invalid.'}; + this.emitChange(); + }, + _teamUnauthorized: function(err) { + this.STATUS = STATUS.TEAM_UNAUTHORIZED; + this.errorDetails = {detail: 'You have no permission to edit this team.'}; + this.emitChange(); + }, + _clearErrorStates: function() { + this.errorDetails = {}; + this.success = ''; + this.STATUS = STATUS.DEFAULT; + this.emitChange(); + }, + getState: function() { + return { + name: this.name, + description: this.description, + members: this.members, + errorDetails: this.errorDetails, + success: this.success, + STATUS: this.STATUS + }; + }, + getMembers: function() { + return this.members; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.name = state.name; + this.description = state.description; + this.members = state.members; + this.errorDetails = state.errorDetails; + this.success = state.success; + this.STATUS = state.STATUS; + } +}); + +module.exports = OrgTeamStore; diff --git a/app/scripts/stores/OrganizationStore.js b/app/scripts/stores/OrganizationStore.js new file mode 100644 index 0000000000..9b12f12b70 --- /dev/null +++ b/app/scripts/stores/OrganizationStore.js @@ -0,0 +1,130 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import _ from 'lodash'; + +var OrganizationStore = createStore({ + storeName: 'OrganizationStore', + handlers: { + RECEIVE_ORGANIZATION: '_updateOrg', + CREATED_ORGANIZATION: '_onCreateOrg', + SELECT_ORGANIZATION: '_onCurrentOrgChange', + RECEIVE_ORG_TEAMS: '_receiveOrgTeams', + CURRENT_USER_ORGS: '_onGetCurrentOrgs', + UPDATE_ORG_SUCCESS: '_updateOrgSuccess', + UPDATE_ORG_ERROR: '_updateOrgError' + }, + initialize: function() { + // initialize + this.name = ''; + this.gravatarURL = 'https://secure.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y'; + this.currentOrg = {}; + this.orgs = []; + this.currentOrgTeams = []; + this.success = ''; + this.error = ''; + }, + _onCreateOrg: function(payload) { + this.receiveState({ + currentOrg: payload.newOrg, + orgs: payload.userOrgs + }); + this.emitChange(); + }, + _updateOrg: function(payload) { + this.receiveState({ + currentOrg: payload + }); + this.emitChange(); + }, + _onGetCurrentOrgs: function(payload) { + this.receiveState({ + orgs: payload.results + }); + this.emitChange(); + }, + _onCurrentOrgChange: function(payload) { + this.receiveState({ + currentOrg: this.getOrg(payload) + }); + this.emitChange(); + }, + _receiveOrgTeams: function(orgTeams) { + this.receiveState({ + currentOrgTeams: orgTeams.results + }); + this.emitChange(); + }, + _updateOrgSuccess: function() { + this.success = 'Updated Organization Details Successfully!'; + setTimeout(this._clearOrgErrorStates.bind(this), 5000); + this.emitChange(); + }, + _updateOrgError: function(err) { + var errResponse = err.response; + if (errResponse.badRequest) { + _.forIn(errResponse.body, function(v, k) { + this.error += k + ': ' + v.join(',') + '\n'; + }.bind(this)); + } else if(errResponse.unauthorized || errResponse.forbidden) { + this.error = 'You have no permission to edit this organization.'; + } else { + this.error = 'An error occurred during the organization update. Please try again later'; + } + setTimeout(this._clearOrgErrorStates.bind(this), 5000); + this.emitChange(); + }, + _clearOrgErrorStates: function() { + this.success = ''; + this.error = ''; + this.emitChange(); + }, + receiveState: function(payload) { + this.name = payload.orgname || this.name; + this.gravatarURL = payload.gravatar_url || this.gravatarURL; + this.currentOrg = payload.currentOrg || this.currentOrg; + this.currentOrgTeams = payload.currentOrgTeams || this.currentOrgTeams; + this.orgs = payload.orgs || this.orgs; + }, + getState: function() { + return { + name: this.name, + gravatarURL: this.gravatarURL, + currentOrg: this.currentOrg, + currentOrgTeams: this.currentOrgTeams, + orgs: this.orgs, + error: this.error, + success: this.success + }; + }, + getOrgs: function() { + return this.orgs; + }, + getCurrentOrg: function() { + //returns currently selected org + return this.currentOrg; + }, + getOrg: function(name) { + //Assuming org names are unique and expecting filter to return an array of exactly 1 item + return _.filter(this.orgs, function(org) { + return org.orgname === name; + })[0]; + }, + getOrgTeams: function() { + return this.currentOrgTeams; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.name = state.name; + this.gravatarURL = state.gravatarURL; + this.currentOrg = state.currentOrg; + this.currentOrgTeams = state.currentOrgTeams; + this.orgs = state.orgs; + this.error = state.error; + this.success = state.success; + } +}); + +module.exports = OrganizationStore; diff --git a/app/scripts/stores/OutboundCommunicationStore.js b/app/scripts/stores/OutboundCommunicationStore.js new file mode 100644 index 0000000000..6aa321d62c --- /dev/null +++ b/app/scripts/stores/OutboundCommunicationStore.js @@ -0,0 +1,90 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import _ from 'lodash'; +import { STATUS } from './common/Constants'; +var debug = require('debug')('AccountNotifStore:'); + +export default createStore({ + storeName: 'OutboundCommunicationStore', + handlers: { + RECEIVE_EMAIL_SUBSCRIPTIONS: '_receiveEmailSubscriptions', + RESET_OUTBOUND_EMAILS_STORE: '_resetBlankSlate', + SAVE_OUTBOUND_ERROR: '_saveOutboundError', + SAVE_OUTBOUND_SUCCESS: '_saveOutboundSuccess', + UPDATE_OUTBOUND: '_updateOutbound', + UPDATE_BETA_GROUP: '_updateBetaGroup' + }, + initialize: function() { + // initialize with data from db + /*eslint-disable camelcase */ + this.weeklyDigest = { + subscribed_emails: [], + unsubscribed_emails: [] + }; + this.digestEmails = []; + this.betaGroup = { + subscribed_emails: [], + unsubscribed_emails: [] + }; + /*eslint-enable camelcase */ + this.betaEmails = []; + this.STATUS = STATUS.DEFAULT; + this.blankOutboundSlate = {}; + }, + _receiveEmailSubscriptions: function(payload) { + this.weeklyDigest = payload.weeklyDigest; + this.digestEmails = payload.weeklyDigest.subscribed_emails.concat(payload.weeklyDigest.unsubscribed_emails); + this.betaGroup = payload.betaGroup; + this.betaEmails = payload.betaGroup.subscribed_emails.concat(payload.betaGroup.unsubscribed_emails); + this.blankOutboundSlate = this.getState(); + this.emitChange(); + }, + _resetBlankSlate: function() { + var slate = this.blankOutboundSlate; + debug('RESET OUTBOUND BLANK SLATE'); + this.weeklyDigest = slate.weeklyDigest; + this.digestEmails = slate.digestEmails; + this.betaGroup = slate.betaGroup; + this.betaEmails = slate.betaEmails; + this.STATUS = STATUS.DEFAULT; + this.emitChange(); + }, + _updateOutbound: function(newList) { + this.STATUS = STATUS.DEFAULT; + if (newList.list === 'weekly') { + this.weeklyDigest = newList.data; + } else if (newList.list === 'beta') { + this.betaGroup = newList.data; + } + this.emitChange(); + }, + _saveOutboundError: function() { + this.STATUS = 'ERROR'; + this.emitChange(); + }, + _saveOutboundSuccess: function() { + this.STATUS = STATUS.SUCCESSFUL; + this.emitChange(); + }, + getState: function() { + return { + weeklyDigest: this.weeklyDigest, + digestEmails: this.digestEmails, + betaGroup: this.betaGroup, + betaEmails: this.betaEmails, + STATUS: this.STATUS + }; + }, + dehydrate: function() { + return _.merge({}, this.getState(), {blankOutboundSlate: this.blankOutboundSlate}); + }, + rehydrate: function(state) { + this.weeklyDigest = state.weeklyDigest; + this.digestEmails = state.digestEmails; + this.betaGroup = state.betaGroup; + this.betaEmails = state.betaEmails; + this.STATUS = state.STATUS; + this.blankOutboundSlate = state.blankOutboundSlate; + } +}); diff --git a/app/scripts/stores/PipelineHistoryStore.js b/app/scripts/stores/PipelineHistoryStore.js new file mode 100644 index 0000000000..59b9494f2d --- /dev/null +++ b/app/scripts/stores/PipelineHistoryStore.js @@ -0,0 +1,31 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +const debug = require('debug')('PipelineHistory'); + +export default createStore({ + storeName: 'PipelineHistoryStore', + handlers: { + RECEIVE_PIPELINE_HISTORY: '_receivePipelineHistory' + }, + initialize() { + this.results = {}; + }, + _receivePipelineHistory(data) { + this.results[data.slug] = {}; + this.results[data.slug].results = data.payload.results; + this.results[data.slug].count = data.payload.count; + this.emitChange(); + }, + getState() { + return { + results: this.results + }; + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + this.results = state.results; + } +}); diff --git a/app/scripts/stores/PlansStore.js b/app/scripts/stores/PlansStore.js new file mode 100644 index 0000000000..8ac12e6c89 --- /dev/null +++ b/app/scripts/stores/PlansStore.js @@ -0,0 +1,74 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('PlansStore'); + +var PlansStore = createStore({ + storeName: 'PlansStore', + handlers: { + RECEIVE_BILLING_PLANS: '_receivePlans', + RECEIVE_BILLING_INFO: '_receiveBilling', + RECEIVE_BILLING_SUBSCRIPTION: '_receiveBillingSubscription', + RESET_CURRENT_PLAN: '_resetCurrentPlan' + }, + initialize: function() { + this.plansList = []; + this.currentPlan = { + plan: '', + package: '', + subscription_uuid: '', + state: '', + add_ons: [] + }; + }, + _clearPlan: function() { + this.currentPlan = { + plan: '', + package: '', + subscription_uuid: '', + state: '', + add_ons: [] + }; + }, + _receiveBilling: function(payload){ + debug('RECEIVE BILLING: ', payload); + this._clearPlan(); + if (payload.currentPlan) { + this.currentPlan = payload.currentPlan; + } + this.emitChange(); + }, + _receiveBillingSubscription: function(payload) { + this._clearPlan(); + if (payload.currentPlan) { + this.currentPlan = payload.currentPlan; + } + this.emitChange(); + }, + _resetCurrentPlan: function(payload) { + this._clearPlan(); + if (payload.currentPlan) { + this.currentPlan = payload.currentPlan; + } + this.emitChange(); + }, + _receivePlans: function(payload) { + debug(payload); + this.plansList = payload.plansList; + this.emitChange(); + }, + getState() { + return { + plansList: this.plansList, + currentPlan: this.currentPlan + }; + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + this.plansList = state.plansList; + this.currentPlan = state.currentPlan; + } +}); + +module.exports = PlansStore; diff --git a/app/scripts/stores/PrivateRepoUsageStore.js b/app/scripts/stores/PrivateRepoUsageStore.js new file mode 100644 index 0000000000..ab21e79cc1 --- /dev/null +++ b/app/scripts/stores/PrivateRepoUsageStore.js @@ -0,0 +1,63 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('PrivateReposUsageStore'); + +var PrivateRepoUsageStore = createStore({ + storeName: 'PrivateRepoUsageStore', + handlers: { + RECEIVE_PRIVATE_REPOSTATS: '_receivePrivateRepoStats', + PRIVATE_REPOSTATS_NO_PERMISSIONS: '_notAvailable' + }, + initialize: function() { + this.privateRepoUsed = 0; + this.numFreePrivateRepos = 0; + this.defaultRepoVisibility = 'public'; + this.privateRepoAvailable = 0; + this.privateRepoPercentUsed = 0; + this.privateRepoLimit = 0; + this.notAvailable = false; + }, + _receivePrivateRepoStats: function(stats) { + this.notAvailable = false; + /*eslint-disable camelcase */ + this.privateRepoUsed = stats.private_repo_used; + this.numFreePrivateRepos = stats.num_free_private_repos; + this.defaultRepoVisibility = stats.default_repo_visibility; + this.privateRepoAvailable = stats.private_repo_available; + this.privateRepoPercentUsed = stats.private_repo_percent_used; + this.privateRepoLimit = stats.private_repo_limit; + /*eslint-enable camelcase */ + this.emitChange(); + }, + _notAvailable: function(err) { + //No permissions to see the private repo stats for this org + this.notAvailable = true; + this.emitChange(); + }, + getState: function() { + return { + privateRepoUsed: this.privateRepoUsed, + numFreePrivateRepos: this.numFreePrivateRepos, + defaultRepoVisibility: this.defaultRepoVisibility, + privateRepoAvailable: this.privateRepoAvailable, + privateRepoPercentUsed: this.privateRepoPercentUsed, + privateRepoLimit: this.privateRepoLimit, + notAvailable: this.notAvailable + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.privateRepoUsed = state.privateRepoUsed; + this.numFreePrivateRepos = state.numFreePrivateRepos; + this.defaultRepoVisibility = state.defaultRepoVisibility; + this.privateRepoAvailable = state.privateRepoAvailable; + this.privateRepoPercentUsed = state.privateRepoPercentUsed; + this.privateRepoLimit = state.privateRepoLimit; + this.notAvailable = state.notAvailable; + } +}); + +module.exports = PrivateRepoUsageStore; diff --git a/app/scripts/stores/RepoDetailsBuildLogs.js b/app/scripts/stores/RepoDetailsBuildLogs.js new file mode 100644 index 0000000000..9d263b87b7 --- /dev/null +++ b/app/scripts/stores/RepoDetailsBuildLogs.js @@ -0,0 +1,29 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('RepoDetailsBuildLogsStore'); + +export default createStore({ + storeName: 'RepoDetailsBuildLogsStore', + handlers: { + BUILD_LOGS_RECEIVE: '_receiveBuildLogs' + }, + initialize() { + this.build_results = {}; + }, + _receiveBuildLogs(res) { + this.build_results = res.build_results; + this.emitChange(); + }, + getState() { + return { + build_results: this.build_results + }; + }, + rehydrate(state) { + this.build_results = state.build_results; + }, + dehydrate() { + return this.getState(); + } +}); diff --git a/app/scripts/stores/RepoDetailsBuildsStore.js b/app/scripts/stores/RepoDetailsBuildsStore.js new file mode 100644 index 0000000000..793feeddad --- /dev/null +++ b/app/scripts/stores/RepoDetailsBuildsStore.js @@ -0,0 +1,79 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import find from 'lodash/collection/find'; +import map from 'lodash/collection/map'; +import assign from 'lodash/object/assign'; + +const debug = require('debug')('RepoDetailsBuildsStore'); + +const RepoDetailsBuildsStore = createStore({ + storeName: 'RepoDetailsBuildsStore', + handlers: { + RECEIVE_BUILD_HISTORY_FOR_REPOSITORY: '_receiveBuilds', + CANCEL_BUILD_START: '_cancelBuildStart', + CANCEL_BUILD_SUCCESS: '_cancelBuildSuccess', + CANCEL_BUILD_ERROR: '_cancelBuildError' + } + , + initialize() { + this.results = []; + this.canceling = {}; + this.count = 0; + }, + + _cancelBuildStart(id) { + this.canceling = { + ...this.canceling, + [id]: 'queued' + }; + this.emitChange(); + }, + + _cancelBuildSuccess(id) { + this.canceling = { + ...this.canceling, + [id]: 'success' + }; + this.emitChange(); + }, + + _cancelBuildError({ id, detail }) { + this.canceling = { + ...this.canceling, + [id]: 'failed' + }; + this.emitChange(); + }, + + _receiveBuilds(res) { + debug('receiving builds', res); + this.results = res.results; + this.count = res.count; + this.emitChange(); + }, + + getState: function() { + let results = this.results; + if (this.canceling !== undefined) { + results = map(this.results, (v) => assign(v, { canceling: this.canceling[v.id] })); + } + return { + count: this.count, + canceling: this.canceling, + results + }; + }, + + rehydrate(state) { + this.results = state.results; + this.count = state.count; + this.canceling = state.canceling || {}; + }, + + dehydrate() { + return this.getState(); + } +}); + +export default RepoDetailsBuildsStore; diff --git a/app/scripts/stores/RepoDetailsDockerfileStore.js b/app/scripts/stores/RepoDetailsDockerfileStore.js new file mode 100644 index 0000000000..ed1a1aee22 --- /dev/null +++ b/app/scripts/stores/RepoDetailsDockerfileStore.js @@ -0,0 +1,32 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('RepoDetailsDockerfileStore'); + +var RepoDetailsDockerfileStore = createStore({ + storeName: 'RepoDetailsDockerfileStore', + handlers: { + RECEIVE_DOCKERFILE_FOR_REPOSITORY: '_receiveDockerfile' + }, + initialize: function() { + this.dockerfile = ''; + }, + _receiveDockerfile: function(res) { + debug('dockerfile', res); + this.dockerfile = res.contents; + this.emitChange(); + }, + getState: function() { + return { + dockerfile: this.dockerfile + }; + }, + rehydrate: function(state) { + this.dockerfile = state.dockerfile; + }, + dehydrate: function() { + return this.getState(); + } +}); + +module.exports = RepoDetailsDockerfileStore; diff --git a/app/scripts/stores/RepoDetailsLongDescriptionFormStore.js b/app/scripts/stores/RepoDetailsLongDescriptionFormStore.js new file mode 100644 index 0000000000..d9ce6e5b60 --- /dev/null +++ b/app/scripts/stores/RepoDetailsLongDescriptionFormStore.js @@ -0,0 +1,116 @@ +'use strict'; + +import _ from 'lodash'; +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('RepoDetailsLongDescriptionFormStore'); + +export default createStore({ + storeName: 'RepoDetailsLongDescriptionFormStore', + handlers: { + RECEIVE_REPOSITORY: '_receiveRepository', + LONG_DESCRIPTION_ATTEMPT_START: '_longDescriptionAttemptStart', + LONG_DESCRIPTION_SUCCESS: '_longDescriptionSuccess', + DETAILS_UNAUTHORIZED: '_detailsUnauthorized', + DETAILS_UNAUTHORIZED_DETAIL: '_detailsUnauthorizedDetail', + LONG_BAD_REQUEST: '_badRequest', + DETAILS_ERROR: '_detailsError', + LONG_DESCRIPTION_UPDATE_FIELD_WITH_VALUE: '_updateFieldWithValue', + DETAILS_RESET_FORMS: '_detailsResetForms', + TOGGLE_LONG_DESCRIPTION_EDIT: '_toggleEditMode' + }, + initialize: function() { + this.isEditing = false; + this.successfulSave = false; + this.fields = { + longDescription: {} + }; + + this._defaultValues = { + longDescription: '' + }; + + this.values = { + longDescription: '' + }; + }, + _longDescriptionAttemptStart() { + debug('starting long description update'); + }, + _longDescriptionSuccess() { + this.fields.longDescription.success = 'Successfully updated full description.'; + //switch back to viewing mode with green outline + this.successfulSave = true; + this.isEditing = false; + this._defaultValues.longDescription = this.values.longDescription; + //clear the green outline and text on successful save + setTimeout(this._clearFeedbackStates.bind(this), 5000); + setTimeout(this._clearSuccessfulSave.bind(this), 5000); + this.emitChange(); + }, + _receiveRepository(repo) { + this.isEditing = false; + this.values.longDescription = repo.full_description || ''; + this._defaultValues.longDescription = repo.full_description || ''; + this.emitChange(); + }, + _detailsResetForms() { + // reset form value to repo longdescription + this.values.longDescription = this._defaultValues.longDescription; + // reset errors + this.fields.longDescription = {}; + this.emitChange(); + }, + _badRequest(obj) { + this.fields.longDescription.hasError = !!obj.full_description; + this.fields.longDescription.error = obj.full_description[0]; + setTimeout(this._clearFeedbackStates.bind(this), 5000); + this.emitChange(); + }, + _detailsError() { + this.fields.longDescription.hasError = true; + this.fields.longDescription.error = 'Sorry, your long description could not be saved.'; + setTimeout(this._clearFeedbackStates.bind(this), 5000); + this.emitChange(); + }, + _clearFeedbackStates() { + this.fields.longDescription.error = ''; + this.fields.longDescription.hasError = false; + this.fields.longDescription.success = ''; + this.emitChange(); + }, + _clearSuccessfulSave() { + this.successfulSave = false; + this.emitChange(); + }, + _updateFieldWithValue: function({fieldKey, fieldValue}){ + this.values[fieldKey] = fieldValue; + this.emitChange(); + }, + _toggleEditMode( { isEditing }) { + this.isEditing = isEditing; + //if you cancel, clear the old input + this.values.longDescription = this._defaultValues.longDescription; + //in case you recently saved and you now cancel + this._clearSuccessfulSave(); + this.emitChange(); + }, + getState() { + return { + _defaultValues: this._defaultValues, + fields: this.fields, + values: this.values, + isEditing: this.isEditing, + successfulSave: this.successfulSave + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this._defaultValues = state._defaultValues; + this.fields = state.fields; + this.values = state.values; + this.isEditing = state.isEditing; + this.successfulSave = state.successfulSave; + } +}); diff --git a/app/scripts/stores/RepoDetailsShortDescriptionFormStore.js b/app/scripts/stores/RepoDetailsShortDescriptionFormStore.js new file mode 100644 index 0000000000..1f573f12cb --- /dev/null +++ b/app/scripts/stores/RepoDetailsShortDescriptionFormStore.js @@ -0,0 +1,116 @@ +'use strict'; + +import _ from 'lodash'; +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('RepoDetailsShortDescriptionFormStore'); + +export default createStore({ + storeName: 'RepoDetailsShortDescriptionFormStore', + handlers: { + RECEIVE_REPOSITORY: '_receiveRepository', + SHORT_DESCRIPTION_ATTEMPT_START: '_shortDescriptionAttemptStart', + SHORT_DESCRIPTION_SUCCESS: '_shortDescriptionSuccess', + DETAILS_UNAUTHORIZED: '_detailsUnauthorized', + DETAILS_UNAUTHORIZED_DETAIL: '_detailsUnauthorizedDetail', + SHORT_BAD_REQUEST: '_badRequest', + DETAILS_ERROR: '_detailsError', + SHORT_DESCRIPTION_UPDATE_FIELD_WITH_VALUE: '_updateFieldWithValue', + DETAILS_RESET_FORMS: '_detailsResetForms', + TOGGLE_SHORT_DESCRIPTION_EDIT: '_toggleEditMode' + }, + initialize: function() { + this.isEditing = false; + this.successfulSave = false; + this.fields = { + shortDescription: {} + }; + + this._defaultValues = { + shortDescription: '' + }; + + this.values = { + shortDescription: '' + }; + }, + _shortDescriptionAttemptStart() { + debug('starting short description update'); + }, + _shortDescriptionSuccess() { + this.fields.shortDescription.success = 'Successfully updated short description.'; + //switch back to viewing mode with green outline + this.successfulSave = true; + this.isEditing = false; + this._defaultValues.shortDescription = this.values.shortDescription; + //clear the green outline and text on successful save + setTimeout(this._clearFeedbackStates.bind(this), 5000); + setTimeout(this._clearSuccessfulSave.bind(this), 5000); + this.emitChange(); + }, + _receiveRepository(repo) { + this.isEditing = false; + this.values.shortDescription = repo.description; + this._defaultValues.shortDescription = repo.description; + this.emitChange(); + }, + _detailsResetForms() { + // reset form value to repo shortdescription + this.values.shortDescription = this._defaultValues.shortDescription; + // reset errors + this.fields.shortDescription = {}; + this.emitChange(); + }, + _detailsError() { + this.fields.longDescription.hasError = true; + this.fields.longDescription.error = 'Sorry, your long description could not be saved.'; + setTimeout(this._clearFeedbackStates.bind(this), 5000); + this.emitChange(); + }, + _badRequest(obj) { + this.fields.shortDescription.hasError = true; + this.fields.shortDescription.error = obj.description[0]; + setTimeout(this._clearFeedbackStates.bind(this), 5000); + this.emitChange(); + }, + _clearFeedbackStates() { + this.fields.shortDescription.error = ''; + this.fields.shortDescription.hasError = false; + this.fields.shortDescription.success = ''; + this.emitChange(); + }, + _clearSuccessfulSave() { + this.successfulSave = false; + this.emitChange(); + }, + _updateFieldWithValue: function({fieldKey, fieldValue}){ + this.values[fieldKey] = fieldValue; + this.emitChange(); + }, + _toggleEditMode( { isEditing }) { + this.isEditing = isEditing; + //if you cancel, clear the old input + this.values.shortDescription = this._defaultValues.shortDescription; + //in case you recently saved and you now cancel + this._clearSuccessfulSave(); + this.emitChange(); + }, + getState() { + return { + _defaultValues: this._defaultValues, + fields: this.fields, + values: this.values, + isEditing: this.isEditing, + successfulSave: this.successfulSave + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this._defaultValues = state._defaultValues; + this.fields = state.fields; + this.values = state.values; + this.isEditing = state.isEditing; + this.successfulSave = state.successfulSave; + } +}); diff --git a/app/scripts/stores/RepoDetailsVisibilityFormStore.js b/app/scripts/stores/RepoDetailsVisibilityFormStore.js new file mode 100644 index 0000000000..1ef9872139 --- /dev/null +++ b/app/scripts/stores/RepoDetailsVisibilityFormStore.js @@ -0,0 +1,113 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import { STATUS } from './repovisibilitystore/Constants.js'; +const debug = require('debug')('RepoDetailsVisibilityFormStore'); + +var RepoDetailsVisibilityFormStore = createStore({ + storeName: 'RepoDetailsVisibilityFormStore', + handlers: { + VISIBILITY_BAD_REQUEST: '_badRequest', + VISIBILITY_ERROR: '_visibilityError', + TOGGLE_VISIBILITY_ATTEMPT_START: '_toggleVisibilityAttemptStart', + TOGGLE_VISIBILITY_SUCCESS: '_toggleSuccess', + RECEIVE_PRIVATE_REPOSTATS: '_receivePrivateRepoStats', + RECEIVE_REPOSITORY: '_receiveRepository', + REPO_DETAILS_VISIBILITY_UPDATE_FIELD_WITH_VALUE: '_updateFieldWithValue', + TOGGLE_VISIBILITY_REPO_NAME_CONFIRM_BOX: '_toggleConfirmBox' + }, + initialize: function() { + this.badRequest = ''; + this.error = ''; + this.success = ''; + this.isPrivate = false; + this.privateRepoLimit = null; + this.numPrivateReposAvailable = null; + this.STATUS = STATUS.DEFAULT; + this.values = { + confirmRepoName: '' + }; + }, + _badRequest: function(res) { + this.initialize(); + this.badRequest = res.detail; + this.STATUS = STATUS.FORM_ERROR; + this.emitChange(); + }, + _clearErrors: function () { + this.error = ''; + this.badRequest = ''; + }, + _toggleVisibilityAttemptStart: function() { + this.STATUS = STATUS.ATTEMPTING; + this.emitChange(); + }, + _visibilityError: function(maybeError) { + this.STATUS = STATUS.FORM_ERROR; + if (maybeError) { + this.error = maybeError.detail; + } else { + this.error = 'No private repositories available'; + } + this.emitChange(); + }, + _toggleSuccess: function(isPrivate) { + this.initialize(); + this.isPrivate = isPrivate; + this.emitChange(); + }, + _toggleConfirmBox: function() { + if (this.STATUS === STATUS.DEFAULT) { + this.STATUS = STATUS.SHOWING_CONFIRM_BOX; + } else { + this.STATUS = STATUS.DEFAULT; + } + this._clearErrors(); + this.values.confirmRepoName = ''; + this.emitChange(); + }, + _receivePrivateRepoStats: function(stats) { + /*eslint-disable camelcase */ + this.numPrivateReposAvailable = stats.private_repo_available; + this.privateRepoLimit = stats.private_repo_limit; + /*eslint-enable camelcase */ + this.emitChange(); + }, + _receiveRepository: function(res) { + this.initialize(); + this.isPrivate = res.is_private; + this.emitChange(); + }, + _updateFieldWithValue: function({fieldKey, fieldValue}){ + this.values[fieldKey] = fieldValue; + this._clearErrors(); + this.emitChange(); + }, + getState: function() { + return { + badRequest: this.badRequest, + error: this.error, + success: this.success, + isPrivate: this.isPrivate, + privateRepoLimit: this.privateRepoLimit, + numPrivateReposAvailable: this.numPrivateReposAvailable, + values: this.values, + STATUS: this.STATUS + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.badRequest = state.badRequest; + this.error = state.error; + this.success = state.success; + this.isPrivate = state.isPrivate; + this.numPrivateReposAvailable = state.numPrivateReposAvailable; + this.privateRepoLimit = state.privateRepoLimit; + this.values = state.values; + this.STATUS = state.STATUS; + } +}); + +module.exports = RepoDetailsVisibilityFormStore; diff --git a/app/scripts/stores/RepoSettingsCollaborators.jsx b/app/scripts/stores/RepoSettingsCollaborators.jsx new file mode 100644 index 0000000000..20705e93ea --- /dev/null +++ b/app/scripts/stores/RepoSettingsCollaborators.jsx @@ -0,0 +1,104 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import { STATUS } from './collaborators/Constants.js'; +var debug = require('debug')('RepoSettingsCollaborators'); + +export default createStore({ + storeName: 'RepoSettingsCollaborators', + handlers: { + ADD_COLLAB_START: '_addCollabStart', + ADD_COLLAB_ERROR: '_addCollabError', + ADD_COLLAB_SUCCESS: '_addCollabSuccess', + COLLAB_RECEIVE_COLLABORATORS: '_receiveCollaborators', + COLLAB_RECEIVE_TEAMS: '_receiveTeams', + COLLAB_RECEIVE_ALL_TEAMS: '_receiveAllTeams', + DEL_COLLABORATORS_SET_LOADING: 'setLoadingFor', + DEL_COLLABORATORS_SET_ERROR: 'setErrorFor', + DEL_COLLABORATORS_SET_SUCCESS: 'setSuccessFor', + LOGOUT: 'initialize', + ON_ADD_COLLAB_CHANGE: 'onAddCollabChange' + }, + initialize() { + // these are full request objects. Only one will succeed and have a `count` key + this.collaborators = {}; + this.teams = {}; + this.allTeams = {results: []}; + this.newCollaborator = ''; + this.error = ''; + this.requests = {}; + this.STATUS = STATUS.DEFAULT; + }, + getState() { + return { + collaborators: this.collaborators, + teams: this.teams, + allTeams: this.allTeams, + newCollaborator: this.newCollaborator, + error: this.error, + requests: this.requests, + STATUS: this.STATUS + }; + }, + + setLoadingFor(username) { + this.requests[username] = STATUS.ATTEMPTING; + this.emitChange(); + }, + setErrorFor(username) { + this.requests[username] = STATUS.ERROR; + this.emitChange(); + }, + setSuccessFor(username) { + this.requests[username] = STATUS.DEFAULT; + this.newCollaborator = ''; + this.emitChange(); + }, + onAddCollabChange(collaborator) { + this.newCollaborator = collaborator; + this.error = ''; + this.emitChange(); + }, + _addCollabSuccess() { + this.STATUS = STATUS.SUCCESS; + this.newCollaborator = ''; + this.error = ''; + this.emitChange(); + }, + _addCollabError(message) { + this.STATUS = STATUS.ERROR; + this.error = message; + this.emitChange(); + }, + _addCollabStart() { + this.STATUS = STATUS.ATTEMPTING; + this.emitChange(); + }, + _receiveCollaborators(collaborators) { + debug(collaborators); + this.newCollaborator = ''; + this.collaborators = collaborators; + this.emitChange(); + }, + _receiveTeams(teams) { + debug(teams); + this.teams = teams; + this.emitChange(); + }, + _receiveAllTeams(teams) { + this.allTeams = teams; + this.emitChange(); + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + this.collaborators = state.collaborators; + this.teams = state.teams; + this.allTeams = state.allTeams; + this.newCollaborator = state.newCollaborator; + this.error = state.error; + this.requests = state.requests; + this.STATUS = state.STATUS; + } +}); diff --git a/app/scripts/stores/RepositoriesPageStore.js b/app/scripts/stores/RepositoriesPageStore.js new file mode 100644 index 0000000000..9e82aa2480 --- /dev/null +++ b/app/scripts/stores/RepositoriesPageStore.js @@ -0,0 +1,42 @@ +'use strict'; + +import createStore from'fluxible/addons/createStore'; + +var ReposStore = createStore({ + storeName: 'RepositoriesPageStore', + handlers: { + RECEIVE_REPOS: '_receiveRepos' + }, + initialize: function() { + this.repos = []; + this.previous = null; + this.next = null; + this.count = null; + }, + _receiveRepos: function(res) { + this.repos = res.results; + this.previous = res.previous; + this.next = res.next; + this.count = res.count; + this.emitChange(); + }, + getState: function() { + return { + repos: this.repos, + previous: this.previous, + next: this.next, + count: this.count + }; + }, + rehydrate: function(state) { + this.repos = state.repos; + this.previous = state.previous; + this.next = state.next; + this.count = state.count; + }, + dehydrate: function() { + return this.getState(); + } +}); + +module.exports = ReposStore; diff --git a/app/scripts/stores/RepositoryCommentsStore.js b/app/scripts/stores/RepositoryCommentsStore.js new file mode 100644 index 0000000000..f31d5af4e4 --- /dev/null +++ b/app/scripts/stores/RepositoryCommentsStore.js @@ -0,0 +1,42 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('RepositoryCommentsStore'); + +var RepoCommentsStore = createStore({ + storeName: 'RepositoryCommentsStore', + handlers: { + RECEIVE_REPO_COMMENTS: '_receiveRepoComments' + }, + initialize: function() { + this.results = []; + this.prev = null; + this.next = null; + this.count = 0; + }, + _receiveRepoComments: function(res) { + this.results = res.results; + this.prev = res.previous; + this.next = res.next; + this.count = res.count; + this.emitChange(); + }, + getState: function() { + return { + results: this.results, + prev: this.prev, + next: this.next, + count: this.count + }; + }, + rehydrate: function(state) { + this.results = state.results; + this.prev = state.prev; + this.next = state.next; + this.count = state.count; + }, + dehydrate: function() { + return this.getState(); + } +}); + +module.exports = RepoCommentsStore; diff --git a/app/scripts/stores/RepositoryPageStore.js b/app/scripts/stores/RepositoryPageStore.js new file mode 100644 index 0000000000..64a6866b84 --- /dev/null +++ b/app/scripts/stores/RepositoryPageStore.js @@ -0,0 +1,118 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +import _ from 'lodash'; +const STATUS = require('./repostore/Constants').STATUS; +var debug = require('debug')('RepositoryPageStore'); + +var RepoStore = createStore({ + storeName: 'RepositoryPageStore', + handlers: { + RECEIVE_REPOSITORY: '_receiveRepository', + CREATE_REPO_ERROR: '_createRepoError', + TOGGLE_STARRED_STATE: '_toggleStarred', + TOGGLE_VISIBILITY_SUCCESS: '_toggleVisibility', + REPO_NOT_FOUND: '_repoNotFound' + }, + initialize: function() { + this.canEdit = false; + this.description = ''; + this.fullDescription = ''; + this.hasStarred = false; + this.isPrivate = true; + this.isAutomated = false; + this.name = ''; + this.namespace = ''; + this.status = 0; + this.lastUpdated = ''; + this.globalFormError = ''; + this.STATUS = STATUS.DEFAULT; + }, + _createRepoError: function(err) { + if (err) { + var errResponse = err.response.body; + this.globalFormError = ''; + if (!_.isEmpty(errResponse)) { + if (err.response.badRequest) { + this.STATUS = STATUS.BAD_REQUEST; + if (_.has(errResponse, '__all__')) { + this.globalFormError = errResponse.__all__.toString(); + } else if (_.has(errResponse, 'detail')) { + this.globalFormError = errResponse.detail.toString(); + } else { + _.forIn(errResponse, function(v, k) { + this.globalFormError += k + ': ' + v.join(',') + '\n'; + }.bind(this)); + } + } + } else { + this.STATUS = STATUS.GENERAL_SERVER_ERROR; + this.globalFormError = 'An error occurred while creating your repository. Please try again later.'; + } + } + this.emitChange(); + }, + _receiveRepository: function(res) { + debug('receive repo', res); + this.STATUS = STATUS.DEFAULT; + this.canEdit = res.can_edit; + this.description = res.description; + // full_description can come in as null; Default to string + this.fullDescription = res.full_description || ''; + this.hasStarred = res.has_starred; + this.isPrivate = res.is_private; + this.isAutomated = res.is_automated; + this.lastUpdated = res.last_updated; + this.name = res.name; + this.namespace = res.namespace; + this.status = res.status; + + this.emitChange(); + }, + _toggleStarred: function(status) { + this.hasStarred = status; + this.emitChange(); + }, + _toggleVisibility: function(vis) { + this.isPrivate = vis; + this.emitChange(); + }, + _repoNotFound: function(err) { + this.STATUS = STATUS.REPO_NOT_FOUND; + this.emitChange(); + }, + getState: function() { + return { + canEdit: this.canEdit, + description: this.description, + fullDescription: this.fullDescription, + hasStarred: this.hasStarred, + isPrivate: this.isPrivate, + isAutomated: this.isAutomated, + lastUpdated: this.lastUpdated, + name: this.name, + namespace: this.namespace, + status: this.status, + globalFormError: this.globalFormError, + STATUS: this.STATUS + }; + }, + rehydrate: function(state) { + this.canEdit = state.canEdit; + this.description = state.description; + this.fullDescription = state.fullDescription; + this.hasStarred = state.hasStarred; + this.isPrivate = state.isPrivate; + this.isAutomated = state.isAutomated; + this.name = state.name; + this.lastUpdated = state.lastUpdated; + this.namespace = state.namespace; + this.status = state.status; + this.globalFormError = state.globalFormError; + this.STATUS = state.STATUS; + }, + dehydrate: function() { + return this.getState(); + } +}); + +module.exports = RepoStore; diff --git a/app/scripts/stores/SearchStore.js b/app/scripts/stores/SearchStore.js new file mode 100644 index 0000000000..0884946f82 --- /dev/null +++ b/app/scripts/stores/SearchStore.js @@ -0,0 +1,104 @@ +'use strict'; +const createStore = require('fluxible/addons/createStore'); +const _ = require('lodash'); +const debug = require('debug')('STORE:SearchStore'); + +//Store to keep track of searches +//TODO: Autocomplete support? +//Query API on general search and return results in a Search Results Component +var SearchStore = createStore({ + storeName: 'SearchStore', + handlers: { + SUBMIT_SEARCH_QUERY: '_submitSearchQuery', + UPDATE_SEARCH_FILTER: '_updateSearchFilter', + UPDATE_SEARCH_SORT: '_updateSearchSort', + UPDATE_SEARCH_PAGE: '_updateSearchPage', + UPDATE_SEARCH_OTHERFILTERS: '_updateSearchFilters', + PROCESS_SEARCH_RESULTS: '_processSearchResults', + SEARCH_ERROR: '_handleSearchError' + }, + initialize: function() { + this.results = ''; + this.queryResult = {}; + this.page = 1; + this.count = 0; + this.next = false; + this.prev = false; + }, + getQueryParams: function() { + //transition to will always have `q` appended as query param at the very least + //Other query params like: `s` -> sort by | `t=User` -> user | `t=Organization` -> Org | `f=official` + // `f=automated_builds` | `s=date_created`, `s=last_updated`, `s=alphabetical`, `s=stars`, `s=downloads` + // `s=pushes` + var queryParams = { + q: this.query || '', + page: this.page || 1, + isOfficial: this.isOfficial || 0, + isAutomated: this.isAutomated || 0, + pullCount: this.pullCount || 0, + starCount: this.starCount || 0 + }; + return queryParams; + }, + _submitSearchQuery: function(payload: string) { + this.query = payload; + this.results = null; + this.emitChange(); + }, + _processSearchResults: function(searchResult) { + this.queryResult = searchResult; + this.count = searchResult.count; + this.results = searchResult.results; + this.next = searchResult.next; + this.prev = searchResult.previous; + this.emitChange(); + }, + _handleSearchError: function(searchError) { + //TODO: Some form of common error handling across all components + debug(searchError); + }, + _updateSearchPage: function(page) { + this.page = page; + this.emitChange(); + }, + _updateSearchFilters: function(params) { + this.isAutomated = params.isAutomated; + this.isOfficial = params.isOfficial; + this.pullCount = params.pullCount; + this.starCount = params.starCount; + this.emitChange(); + }, + getState: function() { + return { + query: this.query, + page: this.page, + queryResult: this.queryResult, + results: this.results, + isOfficial: this.isOfficial, + isAutomated: this.isAutomated, + pullCount: this.pullCount, + starCount: this.starCount, + count: this.count, + next: this.next, + prev: this.prev + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.query = state.query; + this.page = state.page; + this.queryResult = state.queryResult; + this.results = state.results; + this.isOfficial = state.isOfficial; + this.isAutomated = state.isAutomated; + this.pullCount = state.pullCount; + this.starCount = state.starCount; + this.count = state.count; + this.next = state.next; + this.prev = state.prev; + } +}); + +module.exports = SearchStore; diff --git a/app/scripts/stores/SignupStore.js b/app/scripts/stores/SignupStore.js new file mode 100644 index 0000000000..bedf00db7f --- /dev/null +++ b/app/scripts/stores/SignupStore.js @@ -0,0 +1,117 @@ +'use strict'; + +var createStore = require('fluxible/addons/createStore'); +import { STATUS } from './signupstore/Constants'; +var debug = require('debug')('SignupStore'); +var _ = require('lodash'); + +var noErrorObj = { + hasError: false, + error: '' +}; + +export default createStore({ + storeName: 'SignupStore', + handlers: { + SIGNUP_CLEAR_FORM: '_signupClearForm', + SIGNUP_CLEAR_PASSWORD: '_signupClearPassword', + SIGNUP_UPDATE_FIELD_WITH_VALUE: '_updateFieldWithValue', + SIGNUP_ATTEMPT_START: '_signupAttemptStart', + SIGNUP_BAD_REQUEST: '_badRequest', + SIGNUP_SUCCESS: '_signupSuccess' + }, + initialize() { + this.STATUS = STATUS.DEFAULT; + + this.fields = { + username: {}, + email: {}, + password: {} + }; + + this.values = { + username: '', + email: '', + password: '' + }; + }, + _signupClearForm() { + this.initialize(); + this.emitChange(); + }, + _signupClearPassword() { + this.values.password = ''; + this.emitChange(); + }, + _updateFieldWithValue({fieldKey, fieldValue}){ + this.values[fieldKey] = fieldValue; + if (fieldValue) { + this.fields[fieldKey] = this._validate({fieldKey, fieldValue}); + } + this.emitChange(); + }, + _signupAttemptStart() { + debug('attempting Signup'); + this.STATUS = STATUS.ATTEMPTING_SIGNUP; + this.emitChange(); + }, + _signupSuccess() { + this.STATUS = STATUS.SUCCESSFUL_SIGNUP; + this.emitChange(); + }, + _badRequest(obj) { + let shouldEmitChange = false; + + // cycle through the possible form fields + _.forEach(_.keys(this.fields), + (key) => { + if(_.has(obj, key)) { + shouldEmitChange = true; + var newField = {}; + newField.hasError = !!obj[key]; + newField.error = obj[key][0]; + this.fields[key] = newField; + } + }); + if(shouldEmitChange) { + this.emitChange(); + } + }, + validations: { + username(value) { + if (value.length < 4){ + return { + hasError: true, + error: 'Username must be at least four characters long' + }; + } else if (!/^[A-Za-z0-9]+$/.test(value)) { + return { + hasError: true, + error: 'Username must contain only letters and digits' + }; + } else { + return noErrorObj; + } + } + }, + _validate({fieldKey, fieldValue}) { + if(_.isFunction(this.validations[fieldKey])) { + return this.validations[fieldKey](fieldValue); + } else { + return noErrorObj; + } + }, + getState() { + return { + fields: this.fields, + values: this.values, + STATUS: this.STATUS + }; + }, + dehydrate() { + return {}; + }, + rehydrate(state) { + this.state = state; + } +}); diff --git a/app/scripts/stores/TriggerBuildStore.js b/app/scripts/stores/TriggerBuildStore.js new file mode 100644 index 0000000000..dab896a28a --- /dev/null +++ b/app/scripts/stores/TriggerBuildStore.js @@ -0,0 +1,50 @@ +'use strict'; + +const createStore = require('fluxible/addons/createStore'); +const debug = require('debug')('TriggerBuildStore'); + +var TriggerBuildStore = createStore({ + storeName: 'TriggerBuildStore', + handlers: { + AB_TRIGGER_SUCCESS: '_abTriggerSuccess', + AB_TRIGGER_ERROR: '_abTriggerError' + }, + initialize: function() { + this.abtrigger = { + hasError: false, + success: false + }; + }, + _abTriggerClear: function() { + this.abtrigger = { + hasError: false, + success: false + }; + this.emitChange(); + }, + _abTriggerError: function() { + this.abtrigger.success = false; + this.abtrigger.hasError = true; + setTimeout(this._abTriggerClear.bind(this), 3000); + this.emitChange(); + }, + _abTriggerSuccess: function() { + this.abtrigger.success = true; + this.abtrigger.hasError = false; + setTimeout(this._abTriggerClear.bind(this), 3000); + this.emitChange(); + }, + getState: function() { + return { + abtrigger: this.abtrigger + }; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + this.abtrigger = state.abtrigger; + } +}); + +module.exports = TriggerBuildStore; diff --git a/app/scripts/stores/UnlinkAccountsStore.js b/app/scripts/stores/UnlinkAccountsStore.js new file mode 100644 index 0000000000..22f7b2ad35 --- /dev/null +++ b/app/scripts/stores/UnlinkAccountsStore.js @@ -0,0 +1,36 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('UnlinkAccountsStore'); + +var BitbucketLinkStore = createStore({ + storeName: 'UnlinkAccountsStore', + handlers: { + GITHUB_UNLINK_ERROR: _unlinkGithubError, + BITBUCKET_UNLINK_ERROR: _unlinkBitbucketError + }, + initialize: function() { + this.error = ''; + }, + _unlinkGithubError: function() { + this.error = 'Error unlinking Github Account. Please try again later'; + this.emitChange(); + }, + _unlinkBitbucketError: function() { + this.error = 'Error unlinking Bitbucket Account. Please try again later'; + this.emitChange(); + }, + getState: function() { + return { + error: this.error + }; + }, + rehydrate: function(state) { + this.error = state.error; + }, + dehydrate: function() { + return this.getState(); + } +}); + +module.exports = UnlinkAccountsStore; diff --git a/app/scripts/stores/UserProfileReposStore.js b/app/scripts/stores/UserProfileReposStore.js new file mode 100644 index 0000000000..397a727c14 --- /dev/null +++ b/app/scripts/stores/UserProfileReposStore.js @@ -0,0 +1,46 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import filter from 'lodash/collection/filter'; +import { PENDING_DELETE } from 'common/enums/RepoStatus'; +var debug = require('debug')('stores: UserProfileStore'); + +export default createStore({ + storeName: 'UserProfileReposStore', + handlers: { + RECEIVE_PROFILE_REPOS: 'receiveRepos' + }, + initialize() { + this.repos = []; + this.next = null; + this.prev = null; + }, + removePendingDeleteRepos(repos) { + //Remove repos that are in pending delete state from user profile repos + return filter(repos, (repo) => { + const { status } = repo; + return status !== PENDING_DELETE; + }); + }, + receiveRepos(res) { + this.repos = this.removePendingDeleteRepos(res.results); + this.next = res.next; + this.prev = res.previous; + this.emitChange(); + }, + getState() { + return { + repos: this.repos, + next: this.next, + prev: this.prev + }; + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + this.repos = state.repos; + this.next = state.next; + this.prev = state.prev; + } +}); diff --git a/app/scripts/stores/UserProfileStarsStore.js b/app/scripts/stores/UserProfileStarsStore.js new file mode 100644 index 0000000000..1a70b56588 --- /dev/null +++ b/app/scripts/stores/UserProfileStarsStore.js @@ -0,0 +1,37 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('stores: UserProfileStarsStore'); + +export default createStore({ + storeName: 'UserProfileStarsStore', + handlers: { + RECEIVE_PROFILE_STARRED_REPOS: '_receiveStarredRepos' + }, + initialize() { + this.repos = []; + this.next = null; + this.prev = null; + }, + _receiveStarredRepos(res) { + this.starred = res.results; + this.next = res.next; + this.prev = res.previous; + this.emitChange(); + }, + getState() { + return { + starred: this.starred, + next: this.next, + prev: this.prev + }; + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + this.starred = state.starred; + this.next = state.next; + this.prev = state.prev; + } +}); diff --git a/app/scripts/stores/UserProfileStore.js b/app/scripts/stores/UserProfileStore.js new file mode 100644 index 0000000000..a4c44484e7 --- /dev/null +++ b/app/scripts/stores/UserProfileStore.js @@ -0,0 +1,38 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +var debug = require('debug')('UserProfileStore'); + +export default createStore({ + storeName: 'UserProfileStore', + handlers: { + RECEIVE_PROFILE_USER: '_receiveUser', + USER_PROFILE_404: '_fourOHfour' + }, + initialize() { + this.STATUS = 'DEFAULT'; + this.user = {}; + }, + _fourOHfour() { + this.STATUS = '404'; + this.emitChange(); + }, + _receiveUser(user) { + this.STATUS = 'DEFAULT'; + this.user = user; + this.emitChange(); + }, + getState() { + return { + user: this.user, + STATUS: this.STATUS + }; + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + this.user = state.user; + this.STATUS = state.STATUS; + } +}); diff --git a/app/scripts/stores/UserStore.js b/app/scripts/stores/UserStore.js new file mode 100644 index 0000000000..ff68d1cfac --- /dev/null +++ b/app/scripts/stores/UserStore.js @@ -0,0 +1,137 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import md5 from 'md5'; +var debug = require('debug')('stores: UserStore'); + +var UserStore = createStore({ + storeName: 'UserStore', + handlers: { + RECEIVE_USER: '_receiveUserFromHub', + RECEIVE_NAMESPACES: '_receiveNamespaces', + LOGOUT: '_logout', + EXPIRED_SIGNATURE: '_logout' + }, + initialize: function() { + this.dateJoined = ''; + this.fullName = ''; + this.gravatarEmail = ''; + this.gravatarUrl = ''; + this.isActive = false; + this.isAdmin = false; + this.isStaff = false; + this.profileUrl = ''; + this.company = ''; + this.id = ''; + this.location = ''; + this.userType = 'User'; + this.username = ''; + this.namespaces = []; + }, + _getGravatarUrl: function(email) { + return 'https://secure.gravatar.com/avatar/' + md5( email.trim().toLowerCase() ); + }, + _receiveUserFromHub: function(user) { + + // jscs:disable requireCamelCaseOrUpperCaseIdentifiers + this.dateJoined = user.date_joined; + this.fullName = user.full_name; + this.gravatarEmail = user.gravatar_email; + //TODO: the url has to be handed off from the backend + //This fix should be temporary + this.gravatarUrl = (user.gravatar_url === user.gravatar_email) ? + this._getGravatarUrl(user.gravatar_email) : user.gravatar_url; + this.isActive = user.is_active; + this.isAdmin = user.is_admin; + this.isStaff = user.is_staff; + this.profileUrl = user.profile_url; + // jscs:enable + + this.company = user.company; + this.id = user.id; + this.location = user.location; + this.userType = user.type; + this.username = user.username; + + this.emitChange(); + }, + _receiveNamespaces: function(receivedNamespaces) { + //This is required for creating a repository + //Namespaces are attached to a user, due to permissions/access restrictions + //Eg. { + // "namespaces": [ + // "user", + // "org1", + // "org2" + // ] + //} + this.namespaces = receivedNamespaces.namespaces; + + this.emitChange(); + }, + _logout: function() { + + this.company = ''; + this.dateJoined = ''; + this.fullName = ''; + this.gravatarEmail = ''; + this.gravatarUrl = ''; + this.id = ''; + this.isActive = false; + this.isAdmin = false; + this.isStaff = false; + this.location = ''; + this.profileUrl = ''; + this.userType = 'User'; + this.username = ''; + this.namespaces = []; + + this.emitChange(); + }, + getState: function() { + return { + company: this.company, + dateJoined: this.dateJoined, + fullName: this.fullName, + gravatarEmail: this.gravatarEmail, + gravatarUrl: this.gravatarUrl, + id: this.id, + isActive: this.isActive, + isAdmin: this.isAdmin, + isStaff: this.isStaff, + location: this.location, + profileUrl: this.profileUrl, + userType: this.userType, + username: this.username, + namespaces: this.namespaces + }; + }, + getUsername: function() { + return this.username; + }, + getNamespaces: function() { + return this.namespaces; + }, + dehydrate: function() { + return this.getState(); + }, + rehydrate: function(state) { + debug('rehydrate', state); + this.dateJoined = state.dateJoined; + this.fullName = state.fullName; + this.gravatarEmail = state.gravatarEmail; + this.gravatarUrl = state.gravatarUrl; + this.isActive = state.isActive; + this.isAdmin = state.isAdmin; + this.isStaff = state.isStaff; + this.profileUrl = state.profileUrl; + this.company = state.company; + this.id = state.id; + this.location = state.location; + this.userType = state.userType; + this.username = state.username; + this.namespaces = state.namespaces; + } +}); + +module.exports = UserStore; diff --git a/app/scripts/stores/WebhooksSettingsStore.js b/app/scripts/stores/WebhooksSettingsStore.js new file mode 100644 index 0000000000..8c27103a95 --- /dev/null +++ b/app/scripts/stores/WebhooksSettingsStore.js @@ -0,0 +1,31 @@ +'use strict'; +import createStore from 'fluxible/addons/createStore'; +const debug = require('debug')('WebhooksSettingsStore'); + +var WebhooksSettingsStore = createStore({ + storeName: 'WebhooksSettingsStore', + handlers: { + RECEIVE_WEBHOOKS: '_receiveWebhooks' + }, + initialize() { + this.pipelines = []; + }, + _receiveWebhooks(payload) { + debug(payload); + this.pipelines = payload.results; + this.emitChange(); + }, + getState() { + return { + pipelines: this.pipelines + }; + }, + dehydrate() { + return this.getState(); + }, + rehydrate(state) { + this.pipelines = state.pipelines; + } +}); + +module.exports = WebhooksSettingsStore; diff --git a/app/scripts/stores/addorganizationstore/Constants.js b/app/scripts/stores/addorganizationstore/Constants.js new file mode 100644 index 0000000000..2da1465eb6 --- /dev/null +++ b/app/scripts/stores/addorganizationstore/Constants.js @@ -0,0 +1,15 @@ +'use strict'; +var keyMirror = require('keymirror'); + +// Component-Global form states +var STATUS = keyMirror({ + ATTEMPTING: null, + DEFAULT: null, + FACEPALM: null, + BAD_REQUEST: null, + SUCCESSFUL: null +}); + +module.exports = { + STATUS +}; diff --git a/app/scripts/stores/addtriallicensestore/Constants.js b/app/scripts/stores/addtriallicensestore/Constants.js new file mode 100644 index 0000000000..08e9dfd0ff --- /dev/null +++ b/app/scripts/stores/addtriallicensestore/Constants.js @@ -0,0 +1,11 @@ +'use strict'; +import keyMirror from 'keymirror'; + +const STATUS = keyMirror({ + ATTEMPTING_DOWNLOAD: null, + DEFAULT: null, + FACEPALM: null, + SUCCESSFUL_DOWNLOAD: null +}); + +export default STATUS; diff --git a/app/scripts/stores/addwebhookformstore/Constants.js b/app/scripts/stores/addwebhookformstore/Constants.js new file mode 100644 index 0000000000..d2ed2ffbf4 --- /dev/null +++ b/app/scripts/stores/addwebhookformstore/Constants.js @@ -0,0 +1,10 @@ +'use strict'; +var keyMirror = require('keymirror'); + +export default keyMirror({ + ATTEMPTING: null, + DEFAULT: null, + FACEPALM: null, + SUCCESSFUL: null, + ERROR: null +}); diff --git a/app/scripts/stores/billingformstore/Constants.js b/app/scripts/stores/billingformstore/Constants.js new file mode 100644 index 0000000000..42bb512ddf --- /dev/null +++ b/app/scripts/stores/billingformstore/Constants.js @@ -0,0 +1,14 @@ +'use strict'; +var keyMirror = require('keymirror'); + +// Component-Global form states +var STATUS = keyMirror({ + DEFAULT: null, + ATTEMPTING: null, + SUCCESS: null, + FORM_ERROR: null +}); + +module.exports = { + STATUS +}; diff --git a/app/scripts/stores/collaborators/Constants.js b/app/scripts/stores/collaborators/Constants.js new file mode 100644 index 0000000000..0092c8068c --- /dev/null +++ b/app/scripts/stores/collaborators/Constants.js @@ -0,0 +1,14 @@ +'use strict'; +var keyMirror = require('keymirror'); + +// Component-Global form states +var STATUS = keyMirror({ + DEFAULT: null, + ATTEMPTING: null, + SUCCESS: null, + ERROR: null +}); + +module.exports = { + STATUS +}; diff --git a/app/scripts/stores/common/Constants.js b/app/scripts/stores/common/Constants.js new file mode 100644 index 0000000000..63b6ce6890 --- /dev/null +++ b/app/scripts/stores/common/Constants.js @@ -0,0 +1,25 @@ +'use strict'; +var keyMirror = require('keymirror'); + +// Component-Global form states +export const STATUS = keyMirror({ + ATTEMPTING: null, + DEFAULT: null, + FACEPALM: null, + SUCCESSFUL: null, + ERROR: null +}); + +export const ACCOUNT = 'account'; +export const BILLING = 'billing'; +export const STRIPE_URL = 'https://api.stripe.com/v1/tokens'; +export const STRIPE_STAGE_TOKEN = 'pk_test_mNouiY3uYoBAfQYyTurrxf0Q'; +export const STRIPE_PROD_TOKEN = 'pk_live_89IjovLdwh2MTzV7JsGJK3qk'; +export const BF_STAGE_URL = 'https://api-sandbox.billforward.net:443/v1/tokenization/auth-capture'; +export const BF_PROD_URL = 'https://api.billforward.net/v1/tokenization/auth-capture'; +export const BF_STAGE_TOKEN = 'ec687f76-c1b6-4d71-b919-4fe99202ca13'; +export const BF_PROD_TOKEN = '650cbe35-4aca-4820-a7d1-accec8a7083a'; +export const BILLFORWARD_ACCOUNT_ID = 'billforward-account-id'; +export const v4BillingProfile = (docker_id) => { + return `/api/billing/v4/accounts/${docker_id}/profile`; +}; diff --git a/app/scripts/stores/deletepipelinestore/Constants.js b/app/scripts/stores/deletepipelinestore/Constants.js new file mode 100644 index 0000000000..c619b4befb --- /dev/null +++ b/app/scripts/stores/deletepipelinestore/Constants.js @@ -0,0 +1,9 @@ +'use strict'; +var keyMirror = require('keymirror'); + +export default keyMirror({ + ATTEMPTING: null, + DEFAULT: null, + FACEPALM: null, + SUCCESSFUL: null +}); diff --git a/app/scripts/stores/deleterepostore/Constants.js b/app/scripts/stores/deleterepostore/Constants.js new file mode 100644 index 0000000000..2282e4de76 --- /dev/null +++ b/app/scripts/stores/deleterepostore/Constants.js @@ -0,0 +1,14 @@ +'use strict'; +var keyMirror = require('keymirror'); + +// Component-Global form states +var STATUS = keyMirror({ + ATTEMPTING: null, + DEFAULT: null, + FORM_ERROR: null, + SHOWING_CONFIRM_BOX: null +}); + +module.exports = { + STATUS +}; diff --git a/app/scripts/stores/emailsstore/Constants.js b/app/scripts/stores/emailsstore/Constants.js new file mode 100644 index 0000000000..262d6665c5 --- /dev/null +++ b/app/scripts/stores/emailsstore/Constants.js @@ -0,0 +1,19 @@ +'use strict'; +var keyMirror = require('keymirror'); + +// Component-Global form states +var STATUS = keyMirror({ + DEFAULT: null, + SAVING: null +}); + +var EMAILSTATUS = keyMirror({ + SUCCESS: null, + ATTEMPTING: null, + FAILED: null +}); + +module.exports = { + STATUS, + EMAILSTATUS +}; diff --git a/app/scripts/stores/enterprisetrialstore/Constants.js b/app/scripts/stores/enterprisetrialstore/Constants.js new file mode 100644 index 0000000000..c1c53dce9c --- /dev/null +++ b/app/scripts/stores/enterprisetrialstore/Constants.js @@ -0,0 +1,11 @@ +'use strict'; +import keyMirror from 'keymirror'; + +const STATUS = keyMirror({ + ATTEMPTING: null, + DEFAULT: null, + FACEPALM: null, + SUCCESSFUL_SIGNUP: null +}); + +export default STATUS; diff --git a/app/scripts/stores/enterprisetrialsuccessstore/Constants.js b/app/scripts/stores/enterprisetrialsuccessstore/Constants.js new file mode 100644 index 0000000000..061a46f844 --- /dev/null +++ b/app/scripts/stores/enterprisetrialsuccessstore/Constants.js @@ -0,0 +1,9 @@ +'use strict'; +import keyMirror from 'keymirror'; + +const STATUS = keyMirror({ + DEFAULT: null, + ERROR: null +}); + +export default STATUS; diff --git a/app/scripts/stores/loginstore/Constants.js b/app/scripts/stores/loginstore/Constants.js new file mode 100644 index 0000000000..d8cc11d128 --- /dev/null +++ b/app/scripts/stores/loginstore/Constants.js @@ -0,0 +1,14 @@ +'use strict'; +var keyMirror = require('keymirror'); + +// Component-Global form states +var STATUS = keyMirror({ + DEFAULT: null, + ATTEMPTING_LOGIN: null, + ERROR_UNAUTHORIZED: null, + GENERIC_ERROR: null +}); + +module.exports = { + STATUS +}; diff --git a/app/scripts/stores/loginstore/createFormStore.js b/app/scripts/stores/loginstore/createFormStore.js new file mode 100644 index 0000000000..cf17dd0cbe --- /dev/null +++ b/app/scripts/stores/loginstore/createFormStore.js @@ -0,0 +1,88 @@ +'use strict'; + +import createStore from 'fluxible/addons/createStore'; +import _ from 'lodash'; +var debug = require('debug')('createFormStore'); +/** + * @param {Array} fields - array of objects with field names + * as keys and inital values as values + * @param {Function} init - old initialize function + */ + +export default function createFormStore(fields, oldSpec) { + + var spec = {}; + + spec.initialize = function() { + this.globalFormError = ''; + _.forOwn(fields, function(key, val){ + this.fields[key] = {}; + this.values[key] = val; + }); + spec.initialize(); + }.bind(spec); + + spec._badRequest = function(obj) { + /** + * obj is an Object with keys that are field names + * and values that are arrays of errors + * + * This function should be used as the handler for + * an HTTP 400 BadRequest + * + * obj = { + * username: ["cannot be empty"] + * } + */ + // did we update state? + var dirty = false; + + _.forOwn(obj, function(key, val) { + if(_.includes(fields, key)) { + this.fields[key].hasError = !!val; + this.fields[key].error = val[0]; + dirty = true; + } + }, this); + + if(dirty) { + this.emitChange(); + } + }; + + spec._getState = function() { + return { + fields: this.fields, + values: this.values, + globalFormError: this.globalFormError + }; + }; + + + spec._updateFieldWithValue = function({fieldKey, fieldValue}){ + this.values[fieldKey] = fieldValue; + this.emitChange(); + }; + + + spec.dehydrate = function() { + return {}; + }, + spec.rehydrate = function(state) { + this.state = state; + }; + + _.merge(spec, oldSpec, function(objectValue, sourceValue, key) { + if(key === 'initializer') { + return sourceValue; + } else if(key === 'getState') { + return function() { + debug('state', this.state); + return _.merge({}, + objectValue.getState(), + sourceValue._getState()); + }; + } + }); + return createStore(spec); +} diff --git a/app/scripts/stores/orgteamstore/Constants.js b/app/scripts/stores/orgteamstore/Constants.js new file mode 100644 index 0000000000..0731e53f0e --- /dev/null +++ b/app/scripts/stores/orgteamstore/Constants.js @@ -0,0 +1,22 @@ +'use strict'; +var keyMirror = require('keymirror'); + +// Component-Global form states +var STATUS = keyMirror({ + DEFAULT: null, + MEMBER_UNAUTHORIZED: null, + TEAM_UNAUTHORIZED: null, + MEMBER_ERROR: null, + TEAM_ERROR: null, + MEMBER_BAD_REQUEST: null, + TEAM_BAD_REQUEST: null, + GENERAL_SERVER_ERROR: null, + CREATE_TEAM_SUCCESS: null, + CREATE_MEMBER_SUCCESS: null, + UPDATE_TEAM_ERROR: null, + UPDATE_TEAM_SUCCESS: null +}); + +module.exports = { + STATUS +}; diff --git a/app/scripts/stores/repodetailstags/Constants.js b/app/scripts/stores/repodetailstags/Constants.js new file mode 100644 index 0000000000..42faf1dbcc --- /dev/null +++ b/app/scripts/stores/repodetailstags/Constants.js @@ -0,0 +1,12 @@ +'use strict'; +var keyMirror = require('keymirror'); + +// Component-Global form states +var STATUS = keyMirror({ + DEFAULT: null, + ERROR: null, + DELETING: null, + CONFIRMING: null +}); + +module.exports = STATUS; diff --git a/app/scripts/stores/repostore/Constants.js b/app/scripts/stores/repostore/Constants.js new file mode 100644 index 0000000000..f3faaad35f --- /dev/null +++ b/app/scripts/stores/repostore/Constants.js @@ -0,0 +1,15 @@ +'use strict'; +var keyMirror = require('keymirror'); + +// Component-Global form states +var STATUS = keyMirror({ + DEFAULT: null, + REPO_ALREADY_EXISTS: null, + PRIVATE_REPO_QUOTA_EXCEEDED: null, + BAD_REQUEST: null, + REPO_NOT_FOUND: null +}); + +module.exports = { + STATUS +}; diff --git a/app/scripts/stores/repovisibilitystore/Constants.js b/app/scripts/stores/repovisibilitystore/Constants.js new file mode 100644 index 0000000000..2282e4de76 --- /dev/null +++ b/app/scripts/stores/repovisibilitystore/Constants.js @@ -0,0 +1,14 @@ +'use strict'; +var keyMirror = require('keymirror'); + +// Component-Global form states +var STATUS = keyMirror({ + ATTEMPTING: null, + DEFAULT: null, + FORM_ERROR: null, + SHOWING_CONFIRM_BOX: null +}); + +module.exports = { + STATUS +}; diff --git a/app/scripts/stores/signupstore/Constants.js b/app/scripts/stores/signupstore/Constants.js new file mode 100644 index 0000000000..030ee671ac --- /dev/null +++ b/app/scripts/stores/signupstore/Constants.js @@ -0,0 +1,13 @@ +'use strict'; +var keyMirror = require('keymirror'); + +// Component-Global form states +var STATUS = keyMirror({ + DEFAULT: null, + ATTEMPTING_SIGNUP: null, + SUCCESSFUL_SIGNUP: null +}); + +module.exports = { + STATUS +}; diff --git a/app/scripts/vendor/Blob.js b/app/scripts/vendor/Blob.js new file mode 100644 index 0000000000..294debb35f --- /dev/null +++ b/app/scripts/vendor/Blob.js @@ -0,0 +1,215 @@ +/* eslint-disable */ +/* Blob.js + * A Blob implementation. + * 2014-07-24 + * + * By Eli Grey, http://eligrey.com + * By Devin Samarin, https://github.com/dsamarin + * License: X11/MIT + * See https://github.com/eligrey/Blob.js/blob/master/LICENSE.md + */ + +/*global self, unescape */ +/*jslint bitwise: true, regexp: true, confusion: true, es5: true, vars: true, white: true, + plusplus: true */ + +/*! @source http://purl.eligrey.com/github/Blob.js/blob/master/Blob.js */ + +if(typeof window !== "undefined") { +(function (view) { + "use strict"; + + view.URL = view.URL || view.webkitURL; + + if (view.Blob && view.URL) { + try { + new Blob; + return; + } catch (e) {} + } + + // Internally we use a BlobBuilder implementation to base Blob off of + // in order to support older browsers that only have BlobBuilder + var BlobBuilder = view.BlobBuilder || view.WebKitBlobBuilder || view.MozBlobBuilder || (function(view) { + var + get_class = function(object) { + return Object.prototype.toString.call(object).match(/^\[object\s(.*)\]$/)[1]; + } + , FakeBlobBuilder = function BlobBuilder() { + this.data = []; + } + , FakeBlob = function Blob(data, type, encoding) { + this.data = data; + this.size = data.length; + this.type = type; + this.encoding = encoding; + } + , FBB_proto = FakeBlobBuilder.prototype + , FB_proto = FakeBlob.prototype + , FileReaderSync = view.FileReaderSync + , FileException = function(type) { + this.code = this[this.name = type]; + } + , file_ex_codes = ( + "NOT_FOUND_ERR SECURITY_ERR ABORT_ERR NOT_READABLE_ERR ENCODING_ERR " + + "NO_MODIFICATION_ALLOWED_ERR INVALID_STATE_ERR SYNTAX_ERR" + ).split(" ") + , file_ex_code = file_ex_codes.length + , real_URL = view.URL || view.webkitURL || view + , real_create_object_URL = real_URL.createObjectURL + , real_revoke_object_URL = real_URL.revokeObjectURL + , URL = real_URL + , btoa = view.btoa + , atob = view.atob + + , ArrayBuffer = view.ArrayBuffer + , Uint8Array = view.Uint8Array + + , origin = /^[\w-]+:\/*\[?[\w\.:-]+\]?(?::[0-9]+)?/ + ; + FakeBlob.fake = FB_proto.fake = true; + while (file_ex_code--) { + FileException.prototype[file_ex_codes[file_ex_code]] = file_ex_code + 1; + } + // Polyfill URL + if (!real_URL.createObjectURL) { + URL = view.URL = function(uri) { + var + uri_info = document.createElementNS("http://www.w3.org/1999/xhtml", "a") + , uri_origin + ; + uri_info.href = uri; + if (!("origin" in uri_info)) { + if (uri_info.protocol.toLowerCase() === "data:") { + uri_info.origin = null; + } else { + uri_origin = uri.match(origin); + uri_info.origin = uri_origin && uri_origin[1]; + } + } + return uri_info; + }; + } + URL.createObjectURL = function(blob) { + var + type = blob.type + , data_URI_header + ; + if (type === null) { + type = "application/octet-stream"; + } + if (blob instanceof FakeBlob) { + data_URI_header = "data:" + type; + if (blob.encoding === "base64") { + return data_URI_header + ";base64," + blob.data; + } else if (blob.encoding === "URI") { + return data_URI_header + "," + decodeURIComponent(blob.data); + } if (btoa) { + return data_URI_header + ";base64," + btoa(blob.data); + } else { + return data_URI_header + "," + encodeURIComponent(blob.data); + } + } else if (real_create_object_URL) { + return real_create_object_URL.call(real_URL, blob); + } + }; + URL.revokeObjectURL = function(object_URL) { + if (object_URL.substring(0, 5) !== "data:" && real_revoke_object_URL) { + real_revoke_object_URL.call(real_URL, object_URL); + } + }; + FBB_proto.append = function(data/*, endings*/) { + var bb = this.data; + // decode data to a binary string + if (Uint8Array && (data instanceof ArrayBuffer || data instanceof Uint8Array)) { + var + str = "" + , buf = new Uint8Array(data) + , i = 0 + , buf_len = buf.length + ; + for (; i < buf_len; i++) { + str += String.fromCharCode(buf[i]); + } + bb.push(str); + } else if (get_class(data) === "Blob" || get_class(data) === "File") { + if (FileReaderSync) { + var fr = new FileReaderSync; + bb.push(fr.readAsBinaryString(data)); + } else { + // async FileReader won't work as BlobBuilder is sync + throw new FileException("NOT_READABLE_ERR"); + } + } else if (data instanceof FakeBlob) { + if (data.encoding === "base64" && atob) { + bb.push(atob(data.data)); + } else if (data.encoding === "URI") { + bb.push(decodeURIComponent(data.data)); + } else if (data.encoding === "raw") { + bb.push(data.data); + } + } else { + if (typeof data !== "string") { + data += ""; // convert unsupported types to strings + } + // decode UTF-16 to binary string + bb.push(unescape(encodeURIComponent(data))); + } + }; + FBB_proto.getBlob = function(type) { + if (!arguments.length) { + type = null; + } + return new FakeBlob(this.data.join(""), type, "raw"); + }; + FBB_proto.toString = function() { + return "[object BlobBuilder]"; + }; + FB_proto.slice = function(start, end, type) { + var args = arguments.length; + if (args < 3) { + type = null; + } + return new FakeBlob( + this.data.slice(start, args > 1 ? end : this.data.length) + , type + , this.encoding + ); + }; + FB_proto.toString = function() { + return "[object Blob]"; + }; + FB_proto.close = function() { + this.size = 0; + delete this.data; + }; + return FakeBlobBuilder; + }(view)); + + view.Blob = function(blobParts, options) { + var type = options ? (options.type || "") : ""; + var builder = new BlobBuilder(); + if (blobParts) { + for (var i = 0, len = blobParts.length; i < len; i++) { + if (Uint8Array && blobParts[i] instanceof Uint8Array) { + builder.append(blobParts[i].buffer); + } + else { + builder.append(blobParts[i]); + } + } + } + var blob = builder.getBlob(type); + if (!blob.slice && blob.webkitSlice) { + blob.slice = blob.webkitSlice; + } + return blob; + }; + + var getPrototypeOf = Object.getPrototypeOf || function(object) { + return object.__proto__; + }; + view.Blob.prototype = getPrototypeOf(new view.Blob()); +}(typeof self !== "undefined" && self || typeof window !== "undefined" && window || this.content || this)); +} + /* eslint-enable */ diff --git a/app/scripts/vendor/FileSaver.js b/app/scripts/vendor/FileSaver.js new file mode 100644 index 0000000000..910a217e1e --- /dev/null +++ b/app/scripts/vendor/FileSaver.js @@ -0,0 +1,260 @@ +/* eslint-disable */ +/* FileSaver.js + * A saveAs() FileSaver implementation. + * 2015-05-07.2 + * + * By Eli Grey, http://eligrey.com + * License: X11/MIT + * See https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md + */ + +/*global self */ +/*jslint bitwise: true, indent: 4, laxbreak: true, laxcomma: true, smarttabs: true, plusplus: true */ + +/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */ + +if(typeof window !== "undefined") { + +var saveAs = saveAs || (function(view) { + "use strict"; + // IE <10 is explicitly unsupported + if (typeof navigator !== "undefined" && /MSIE [1-9]\./.test(navigator.userAgent)) { + return; + } + var + doc = view.document + // only get URL when necessary in case Blob.js hasn't overridden it yet + , get_URL = function() { + return view.URL || view.webkitURL || view; + } + , save_link = doc.createElementNS("http://www.w3.org/1999/xhtml", "a") + , can_use_save_link = "download" in save_link + , click = function(node) { + var event = doc.createEvent("MouseEvents"); + event.initMouseEvent( + "click", true, false, view, 0, 0, 0, 0, 0 + , false, false, false, false, 0, null + ); + node.dispatchEvent(event); + } + , webkit_req_fs = view.webkitRequestFileSystem + , req_fs = view.requestFileSystem || webkit_req_fs || view.mozRequestFileSystem + , throw_outside = function(ex) { + (view.setImmediate || view.setTimeout)(function() { + throw ex; + }, 0); + } + , force_saveable_type = "application/octet-stream" + , fs_min_size = 0 + // See https://code.google.com/p/chromium/issues/detail?id=375297#c7 and + // https://github.com/eligrey/FileSaver.js/commit/485930a#commitcomment-8768047 + // for the reasoning behind the timeout and revocation flow + , arbitrary_revoke_timeout = 500 // in ms + , revoke = function(file) { + var revoker = function() { + if (typeof file === "string") { // file is an object URL + get_URL().revokeObjectURL(file); + } else { // file is a File + file.remove(); + } + }; + if (view.chrome) { + revoker(); + } else { + setTimeout(revoker, arbitrary_revoke_timeout); + } + } + , dispatch = function(filesaver, event_types, event) { + event_types = [].concat(event_types); + var i = event_types.length; + while (i--) { + var listener = filesaver["on" + event_types[i]]; + if (typeof listener === "function") { + try { + listener.call(filesaver, event || filesaver); + } catch (ex) { + throw_outside(ex); + } + } + } + } + , auto_bom = function(blob) { + // prepend BOM for UTF-8 XML and text/* types (including HTML) + if (/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) { + return new Blob(["\ufeff", blob], {type: blob.type}); + } + return blob; + } + , FileSaver = function(blob, name) { + blob = auto_bom(blob); + // First try a.download, then web filesystem, then object URLs + var + filesaver = this + , type = blob.type + , blob_changed = false + , object_url + , target_view + , dispatch_all = function() { + dispatch(filesaver, "writestart progress write writeend".split(" ")); + } + // on any filesys errors revert to saving with object URLs + , fs_error = function() { + // don't create more object URLs than needed + if (blob_changed || !object_url) { + object_url = get_URL().createObjectURL(blob); + } + if (target_view) { + target_view.location.href = object_url; + } else { + var new_tab = view.open(object_url, "_blank"); + if (new_tab == undefined && typeof safari !== "undefined") { + //Apple do not allow window.open, see http://bit.ly/1kZffRI + view.location.href = object_url + } + } + filesaver.readyState = filesaver.DONE; + dispatch_all(); + revoke(object_url); + } + , abortable = function(func) { + return function() { + if (filesaver.readyState !== filesaver.DONE) { + return func.apply(this, arguments); + } + }; + } + , create_if_not_found = {create: true, exclusive: false} + , slice + ; + filesaver.readyState = filesaver.INIT; + if (!name) { + name = "download"; + } + if (can_use_save_link) { + object_url = get_URL().createObjectURL(blob); + save_link.href = object_url; + save_link.download = name; + click(save_link); + filesaver.readyState = filesaver.DONE; + dispatch_all(); + revoke(object_url); + return; + } + // Object and web filesystem URLs have a problem saving in Google Chrome when + // viewed in a tab, so I force save with application/octet-stream + // http://code.google.com/p/chromium/issues/detail?id=91158 + // Update: Google errantly closed 91158, I submitted it again: + // https://code.google.com/p/chromium/issues/detail?id=389642 + if (view.chrome && type && type !== force_saveable_type) { + slice = blob.slice || blob.webkitSlice; + blob = slice.call(blob, 0, blob.size, force_saveable_type); + blob_changed = true; + } + // Since I can't be sure that the guessed media type will trigger a download + // in WebKit, I append .download to the filename. + // https://bugs.webkit.org/show_bug.cgi?id=65440 + if (webkit_req_fs && name !== "download") { + name += ".download"; + } + if (type === force_saveable_type || webkit_req_fs) { + target_view = view; + } + if (!req_fs) { + fs_error(); + return; + } + fs_min_size += blob.size; + req_fs(view.TEMPORARY, fs_min_size, abortable(function(fs) { + fs.root.getDirectory("saved", create_if_not_found, abortable(function(dir) { + var save = function() { + dir.getFile(name, create_if_not_found, abortable(function(file) { + file.createWriter(abortable(function(writer) { + writer.onwriteend = function(event) { + target_view.location.href = file.toURL(); + filesaver.readyState = filesaver.DONE; + dispatch(filesaver, "writeend", event); + revoke(file); + }; + writer.onerror = function() { + var error = writer.error; + if (error.code !== error.ABORT_ERR) { + fs_error(); + } + }; + "writestart progress write abort".split(" ").forEach(function(event) { + writer["on" + event] = filesaver["on" + event]; + }); + writer.write(blob); + filesaver.abort = function() { + writer.abort(); + filesaver.readyState = filesaver.DONE; + }; + filesaver.readyState = filesaver.WRITING; + }), fs_error); + }), fs_error); + }; + dir.getFile(name, {create: false}, abortable(function(file) { + // delete file if it already exists + file.remove(); + save(); + }), abortable(function(ex) { + if (ex.code === ex.NOT_FOUND_ERR) { + save(); + } else { + fs_error(); + } + })); + }), fs_error); + }), fs_error); + } + , FS_proto = FileSaver.prototype + , saveAs = function(blob, name) { + return new FileSaver(blob, name); + } + ; + // IE 10+ (native saveAs) + if (typeof navigator !== "undefined" && navigator.msSaveOrOpenBlob) { + return function(blob, name) { + return navigator.msSaveOrOpenBlob(auto_bom(blob), name); + }; + } + + FS_proto.abort = function() { + var filesaver = this; + filesaver.readyState = filesaver.DONE; + dispatch(filesaver, "abort"); + }; + FS_proto.readyState = FS_proto.INIT = 0; + FS_proto.WRITING = 1; + FS_proto.DONE = 2; + + FS_proto.error = + FS_proto.onwritestart = + FS_proto.onprogress = + FS_proto.onwrite = + FS_proto.onabort = + FS_proto.onerror = + FS_proto.onwriteend = + null; + + return saveAs; +}( + typeof self !== "undefined" && self + || typeof window !== "undefined" && window + || this && this.content +)); +// `self` is undefined in Firefox for Android content script context +// while `this` is nsIContentFrameMessageManager +// with an attribute `content` that corresponds to the window + +if (typeof module !== "undefined" && module.exports) { + module.exports.saveAs = saveAs; +} else if ((typeof define !== "undefined" && define !== null) && (define.amd != null)) { + define([], function() { + return saveAs; + }); +} +} else { + // nope +} +/* eslint-enable */ diff --git a/app/styles/common/_repository-list-item.scss b/app/styles/common/_repository-list-item.scss new file mode 100644 index 0000000000..1cd1411c48 --- /dev/null +++ b/app/styles/common/_repository-list-item.scss @@ -0,0 +1,98 @@ +.repository-list-item { + display: flex; + flex-flow: row; + border: 1px solid $secondary-5; + border-radius: $global-radius; + margin: 1rem; + font-weight: $font-weight-bold; + color: $secondary-2; + background: $white; + &:hover { + border-color: $primary-1; + .section { + &.action { + background-color: $primary-1; + color: white; + border-color: $primary-1; + } + } + } + > a { + display: flex; + flex: 1; + } + .avatar { + background: $primary-color; + width: 50px; + height: 50px; + border-radius: $global-radius; + } + .section { + display: flex; + border-left: 1px solid $secondary-5; + padding: 1rem; + flex-flow: row; + &.head { + flex-grow: 1; + } + &:first-child { + border-left: 0; + } + &.stats { + min-width: 100px; + text-align: center; + } + &.action { + background-color: $secondary-5;; + color: $secondary-4; + flex-flow: column; + i { + position: relative; + top: 0.5rem; + left: 1.2rem; + } + .text { + margin-top: 1.2rem; + margin-left: 0.4rem; + font-size: 0.7rem; + } + } + .title { + padding-left: 1rem; + padding-right: 1rem; + padding-top: 0.25rem; + } + .labels { + font-size: rem-calc(12px); + color: $secondary-4; + .official { + color: $primary-1; + } + .public { + color: darken($primary-1, 30%); + } + .private { + color: darken($primary-6, 35%); + } + .automated { + color: $secondary-4; + } + } + .label-value { + padding-top: 0.25rem; + margin: 0 auto; + .value { + font-size: rem-calc(14px); + margin-bottom: 0.25rem; + color: $secondary-3; + } + .sub-label { + font-size: rem-calc(10px); + color: $secondary-3; + } + } + .repo-name { + font-size: rem-calc(18px); + } + } +} diff --git a/app/styles/font-awesome.min.css b/app/styles/font-awesome.min.css new file mode 100644 index 0000000000..9c24364d62 --- /dev/null +++ b/app/styles/font-awesome.min.css @@ -0,0 +1,4 @@ +/*! + * Font Awesome 4.4.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.4.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.4.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.4.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.4.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.4.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.4.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"} \ No newline at end of file diff --git a/app/styles/hub.scss b/app/styles/hub.scss new file mode 100644 index 0000000000..3ec7183cf9 --- /dev/null +++ b/app/styles/hub.scss @@ -0,0 +1,18 @@ +@import 'hub/account'; +@import 'hub/autobuilds'; +@import 'hub/badge'; +@import 'hub/blankslate'; +@import 'hub/flyout-menu'; +@import 'hub/react-tagsinput'; +@import 'hub/main-nav'; +@import 'hub/profile'; +@import 'hub/repositories'; +@import 'hub/repository-settings'; +@import 'hub/reset-password'; +@import 'hub/search'; +@import 'hub/tabs'; +@import 'hub/type'; +@import 'hub/usericons'; +@import 'hub/welcome'; +@import 'hub/forms'; +@import 'hub/docker-trusted-registry'; diff --git a/app/styles/hub/_blankslate.scss b/app/styles/hub/_blankslate.scss new file mode 100644 index 0000000000..7fb8a37d73 --- /dev/null +++ b/app/styles/hub/_blankslate.scss @@ -0,0 +1,84 @@ +/* + * For pages without content + * + */ + +.blankslate { + text-align: center; + background: #fff; + border-bottom: 1px solid #cbd1d7; + padding: 2.4rem; + h1 { + font-size: 2.5em; + } + .fa { + color: #566471; + font-size: 4em; + margin-bottom: 15px; + } +} + +.blankslate-alt { + text-align: center; + padding: 5rem; +} + +/* + * + * buttons : TODO: MOVE TO FOUNDATION OVERRIDES & SPLIT OUT THESE RANDOM BUTTONS + * + */ + +button, +.button, +.profile-settings .change-pass-form .change-pass-save .button, +.settings-wrapper input[type='submit'] { + -moz-appearance: none; + border-radius: $global-radius; + border-color: #1298c6; + border-style: solid; + border-width: 0; + color: #ffffff; + cursor: pointer; + display: inline-block; + font-size: 1rem; + font-weight: normal; + margin: 0 0 1.25rem; + padding: 0.7rem 1rem 0.7rem 0.9rem; + text-align: center; + transition: background-color 300ms ease-out 0s; + box-shadow: none; + &.default { + background-color: #fff; + border: 1px solid #dfe4ea; + color: #000; + } + &.primary { + background: $primary-1; + border-radius: $global-radius; + color: #fff; + &:hover { + background: $primary-2; + } + } + // &.secondary {/** will switch this with .default **/} + &.dashed { + background-color: transparent; + border: 1px dashed #c3ccd9; + color: #2f4559;/** need variable **/ + border-radius: $global-radius; + &:hover { + opacity: .8; + } + } + &.button.disabled { + cursor: not-allowed; + } + &.small { + padding: 0.7rem 1rem 0.7rem 0.9rem; + } + &.xl { + padding: 1rem 2rem 1rem 2rem; + margin-right: 1rem; + } +} diff --git a/app/styles/hub/_profile.scss b/app/styles/hub/_profile.scss new file mode 100644 index 0000000000..cca73f4675 --- /dev/null +++ b/app/styles/hub/_profile.scss @@ -0,0 +1,30 @@ +.gravatar { + margin: 1.5rem 0; + img { + border: 1px solid $secondary-5; + border-radius: $global-radius; + } +} + +.profile-info { + padding: .5rem 1rem 0 1rem; + ul { + padding: .6rem 0; + li { + padding: .2rem 0; + color: $secondary-4; + .fa { + margin: 0 .5rem 0 0; + min-width: .8rem; + text-align: center; + } + } + } +} + +.profile-repos { + padding: 1rem 0; + h4 { + padding-left: 2rem; + } +} diff --git a/app/styles/hub/_type.scss b/app/styles/hub/_type.scss new file mode 100644 index 0000000000..683ab8a3cd --- /dev/null +++ b/app/styles/hub/_type.scss @@ -0,0 +1,19 @@ +/* + * This file is in progress - will contain global typography styles + * + */ +h1, +h2, +h3, +h4, +h5, +h6 { + color: $primary-1; + font-weight: 200; +} +h1 { + font-size: 1.8875rem; +} +h2 { + font-size: 1.3875rem; +} \ No newline at end of file diff --git a/app/styles/hub/account.scss b/app/styles/hub/account.scss new file mode 100644 index 0000000000..5d577764e4 --- /dev/null +++ b/app/styles/hub/account.scss @@ -0,0 +1,518 @@ +.settings-wrapper { + width: 100%; + input { + &[type='text'],&[type='email'],&[type='password'] { + color: $secondary-1; + } + &[type='submit'] { + padding: .5rem; + } + } + .settings-body { + padding: 25px 0px; + .account-section-text { + float: left; + display:inline-block; + } + .account-section-header { + padding-left: 0; + .account-section-title{ + color: $secondary-1; + font-weight: $font-weight-bold; + } + .account-section-subtitle{ + color: $primary-2; + margin-bottom: .5rem; + } + } + .account-section { + margin-bottom: 2rem; + .columns:last-child { + float: left; + } + } + } +} + +.profile-settings { + .default-repo-visibility { + padding: 1rem; + .visibility-toggle { + &:hover { + cursor: pointer; + cursor: hand; + } + input { + margin-right: .5rem; + } + } + } + .email-wrapper { + border: 1px solid $border-input; + background: white; + form { + .add-email { + padding: 2rem 1rem 0; + .email-title { + font-weight: $font-weight-bold; + } + } + .email-element { + height: 4rem; + padding: 0 1rem; + margin: .5rem .5rem; + display: flex; + align-items: left; + .emphasis {/** TODO: Scope to .email-element for the moment, however will mostly be global **/ + color: $secondary-3; + font-weight: 800; + } + .fa-times {/** TODO: Scope to .email-element for the moment, however will mostly be global **/ + color: $primary-5; + font-size: 1.3rem; + &:hover { + color: $secondary-1; + } + } + .close-button {/** TODO: Let's verify we still need this **/ + color: white; + display: inline-block; + background: $docker-dark--placeholders; + border-radius: 100%; + width: 1.5rem; + height: 1.5rem; + text-align: center; + margin-right: .5rem; + float: right; + &:hover { + background: darken($docker-dark--placeholders, 10%); + } + } + .email-action { + text-align: center; + border-radius: 1rem; + &.status-sent { + border: 1px solid $primary-2; + color: $primary-2; + } + &.status-sending { + border: 1px solid $primary-1; + color: $primary-1; + } + &.status-failed { + border: 1px solid $primary-5; + color: $primary-5; + } + } + .email-address { + word-wrap: break-word; + } + } + } + } + .change-pass-form { + border: 1px solid $border-input; + background: white; + padding: 3rem 3rem 1rem 3rem; + .change-pass-save { + height: 250px; + display: flex; + align-items: flex-end; + .button { + padding:.5rem; + } + } + &.success { + border: 1px solid $primary-2; + } + &.form-error { + border: 1px solid $primary-5; + } + } + .account-info-wrapper { + border: 1px solid $border-input; + background: white; + padding: 3rem 3rem 1rem; + .button { + float: right; + } + &.success { + border: 1px solid $primary-2; + } + &.form-error { + border: 1px solid $primary-5; + } + } + .toOrgButton { + padding: .5rem 0; + } +} +.convert-to-org{ + .toOrg-title { + font-weight: $font-weight-bold; + } + .toOrg-body { + margin-bottom: 1rem; + } + .warning { + text-align: center; + color: $primary-5; + } + .convert-org-form { + padding: 3rem 2rem .5rem; + background: white; + border: 1px solid $border-input; + .button { + padding:.5rem; + &:disabled { + background: $secondary-5; + } + } + } +} + +.accounts-services { + .linked-accounts { + margin-bottom: 5rem; + .link-service { + padding: 1.25rem; + margin: 0 1rem; + height: 13rem; + border: 1px solid $border-input; + background: white; + float: left; + .service-title { + display:flex; + align-items: center; + .linked-icon { + width: 100%; + height: auto; + } + .service-name { + font-weight: $font-weight-bold; + margin: 0 1rem; + } + } + .user-access { + margin: .5rem 0 0; + } + .button { + margin: 1rem 0 0; + float: right; + } + } + } +} + +.account-notifications { + form { + background: white; + border: 1px solid $border-input; + padding: 2rem 2rem 1rem; + .button { + margin-right: 1rem; + // padding: .5rem; + float: right; + } + &.success { + border: 1px solid $primary-2; + } + &.form-error { + border: 1px solid $primary-5; + } + } + .event-notifs { + margin-bottom: 5rem; + } + .notification { + margin-bottom: 1rem; + &:hover { + cursor: pointer; + cursor: hand; + } + &.unverified { + color: $secondary-4; + cursor: default; + } + } +} + +.billing-plans { + .columns:last-child { + float: left; + } + .account-billing-info { + margin-left: .5rem; + } + .plans-error-message { + background: $alert-color; + color:white; + } + .plans-q { + .q-title { + font-weight: 500; + color: $primary-2; + } + .q-answer { + padding-left: 1rem; + } + margin-bottom: 2rem; + } + .billing-info { + background: white; + border: 1px solid $border-input; + padding: 2rem 0rem 2rem 2rem; + margin: .5rem; + .row { + margin-bottom: 1rem; + .info-content { + border-left: 1px solid $border-input; + padding: .5rem 3rem; + } + .no-account { + text-align: center; + } + } + } + .billing-form-wrapper { + border: 1px solid $border-input; + background: white; + padding: 3rem 3rem 1rem; + .billing-form-header { + text-align: center; + } + .billing-info-form { + .billing-form-section { + margin-bottom: 1.5rem; + } + .billing-field * { + width: 100%; + } + .billing-dropdown { + width: 100%; + &.error { + border: 1px solid $alert-color; + } + } + .accepted-cards { + margin-bottom: 35px; + .card-icon { + font-size: 1.5rem; + margin-left: .5rem; + } + } + .above-selects .group { + margin-bottom: 15px; + } + .selects { + .date-text { + text-align: center; + height: 2rem; + display: flex; + align-items: center; + } + .columns { + margin-bottom: 20px; + } + } + .back { + margin-left: 2rem; + } + } + } + .preview-box { + border: 1px solid $border-input; + background: white; + padding: 2rem 2rem 0; + margin-left: 1rem; + .total { + margin-bottom: 35px; + } + .coupon_code * { + width: 100%; + } + .price { + text-align: right; + } + } + .invoice-table { + a.not-active { + pointer-events: none; + cursor: default; + color: $secondary-5; + } + } +} +.orgs-body { + padding-top: rem-calc(15px); + padding-bottom: rem-calc(20px); +} + +.orgs-grid { + margin-left: 1.6rem; +} + +.orgs-settings { + h5 { + font-weight: 400; + color: #22b8eb;/** find varible and update */ + float: left; + margin: 0 0 0.8rem 1rem; + // color: $docker-dark; + } + .page-header-buttons { + float: left; + padding-left: 20px; + button { + margin-right: rem-calc(10px); + } + } + .new-org-form { + width: 100%; + } + .org-details { + ul { + list-style: none; + margin-left: 0; + li { + background-color: $panel-bg; + padding: rem-calc(10px); + margin-bottom: rem-calc(5px); + border-radius: rem-calc(2px); + margin: 0 .2rem 0 0; + } + } + .org-team-md { + margin-left: rem-calc(-15px); + .inline-list { + margin: 0; + } + h5 { + margin-left: rem-calc(10px); + font-weight: 300; + font-color: #22b8eb; + } + h6 { + background-color: lighten($panel-bg, 8%); + border-radius: $global-radius; + padding: 10px; + .action-icon { + background-color: lighten($panel-bg, 8%); + } + } + .org-members { + li { + img { + margin-right: rem-calc(10px); + border-radius: 100%; + } + width: rem-calc(200px); + padding: rem-calc(12px); + margin-right: rem-calc(20px); + } + } + // .org-teams {} + } + /** hide border when dux-form is in tabs **/ + .dux-form { + border: 0; + } + } + .tab-title { + border: 1px solid #c4cdda; + border-bottom: none; + border-top-left-radius: $global-radius; + border-top-right-radius: $global-radius; + background-color: #e6edf4; + padding: 0.5rem; + &:hover { + cursor: pointer; + color: #23b8eb; + } + } + .tab-title.active { + background-color: white; + } + .tabs-content { + border-bottom-left-radius: $global-radius; + border-bottom-right-radius: $global-radius; + border-top-right-radius: $global-radius; + border: 1px solid #c4cdda; + background-color: white; + padding: rem-calc(20px); + } +} + +//Teams +.team-item { + a { + margin-left: rem-calc(15px); + } +} + +.add-team-container { + form { + margin-top: 1.6rem; + .alert-box { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; + } + } +} + +.add-team-button-group { + input[type="submit"], input[type="reset"] { + border-radius: $global-radius; + margin-left: 1rem; + } +} + +//global +li.li-no-hover { + &:hover { + background-color: transparent !important; + } +} + +.delete-member-item { + height: rem-calc(25px); + cursor: default; + a { + float: left; + margin-left: 0.7rem; + } + i { + margin-top: 0.3rem; + margin-right: 1.4rem; + cursor: pointer; + color: #FF5151; + } +} +.add-member-item { + height: rem-calc(25px); + cursor: text; + input[type="text"] { + font-size: rem-calc(16px); + margin-left: 0.5rem; + float: left; + border: 0; + height: 2rem; + line-height: 2rem; + color: $gray-2; + width: rem-calc(500px); + &:hover { + color: $gray-2; + } + &:active { + color: $gray-2; + } + &:focus { + color: $gray-2; + } + } + i { + margin-top: 0.3rem; + margin-right: -1.5rem; + cursor: pointer; + } +} diff --git a/app/styles/hub/autobuilds.scss b/app/styles/hub/autobuilds.scss new file mode 100644 index 0000000000..f95ba83e46 --- /dev/null +++ b/app/styles/hub/autobuilds.scss @@ -0,0 +1,108 @@ +.ol-decimal { + list-style-type: decimal; +} + +.auto-build-tags-table { + input[type="text"] { + max-width:180px; + } +} + +.autobuild-checkbox { + font-size: 14px; + font-weight: 300; + cursor: pointer; + * { + cursor: pointer; + } + .checkbox-text { + margin-left: 5px; + } +} + +//GLOBAL? +.two-level-selector { + margin-top: 1.0rem; + .dux-form { + padding: 0; + margin: 0; + } + .row { + padding: 0; + margin: 0; + .columns { + padding: 0; + } + } + //this is actually a lighter filter bar + .searchbar input[type=text] { + background-color: $secondary-4; + width: 100%; + } + .filterbar-container { + margin-left: rem-calc(24px); + } +} + +//Strip out the module class +ul.stripped-module { + background: #fff; + list-style: none; + padding: 0 !important; + margin: 0 !important; + li { + text-align: left; + border-bottom: 1px solid #cbd1d7; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + &:hover { + background: $secondary-6; + cursor: pointer; + } + .list-item { + padding-left: 1rem; + padding-right: 1rem; + } + } + img { + border-radius: $global-radius; + } + .header-bar { + background-color: $secondary-6; + min-height: rem-calc(60px); + cursor: default; + } + .header-title { + margin-left: 1rem; + margin-top: 0.5rem; + font-size: rem-calc(18px); + font-weight: 400; + } + .alert-box { + margin-left: 0.5rem; + margin-right: 0.5rem; + } +} + +ul.left-border { + border-left: 1px solid #cbd1d7; +} + +ul.right-border { + border-right: 1px solid #cbd1d7; +} + +//fa i class needs this +.arrow-sel { + margin-top: 0.25rem; + margin-right: 0.25rem; +} + +.create-autobuild-form { + .form-error { + border: 1px solid $primary-5; + } + .text-error { + color: $primary-5; + } +} diff --git a/app/styles/hub/badge.scss b/app/styles/hub/badge.scss new file mode 100644 index 0000000000..f17d6adac9 --- /dev/null +++ b/app/styles/hub/badge.scss @@ -0,0 +1,11 @@ +.dux-badge { + border-radius: rem-calc(4px); + padding: rem-calc(3px); + &.official { + color: #23b8eb; + } + &.builds { + color: #86d800; + font-size: rem-calc(12px); + } +} diff --git a/app/styles/hub/flyout-menu.scss b/app/styles/hub/flyout-menu.scss new file mode 100644 index 0000000000..e8234f592f --- /dev/null +++ b/app/styles/hub/flyout-menu.scss @@ -0,0 +1,185 @@ +//@extend-elements +//original selectors +//.cssmenu ul, .cssmenu ul li, .cssmenu ul ul +%extend_1 { + list-style: none; + margin: 0; + padding: 0; +} + +//original selectors +//.cssmenu ul li.hover, .cssmenu ul li:hover +%extend_2 { + position: relative; + z-index: 599; + cursor: default; +} + + +@charset "UTF-8"; +.cssmenu { + padding: 0; + margin: 0; + border: 0; + line-height: 1; + width: 100%; + background: $panel-bg; + font-family: "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif; + zoom: 1; + font-size: 12px; + ul { + @extend %extend_1; + position: relative; + z-index: 597; + float: left; + li { + @extend %extend_1; + min-height: 1px; + line-height: 3rem; + vertical-align: middle; + position: relative; + float: none; + &.hover { + @extend %extend_2; + } + &:hover { + @extend %extend_2; + > ul { + visibility: visible; + } + } + &.has-sub > a:after { + content: '+'; + position: absolute; + top: 50%; + right: 15px; + margin-top: -6px; + } + } + ul { + @extend %extend_1; + visibility: hidden; + position: absolute; + z-index: 598; + top: 0; + left: 100%; + margin-top: 0; + width: 100%; + //Instead of the line below you could use @includeborder-radius($radius, $vertical-radius) + border: 1px solid $panel-bg; + li { + float: none; + font-weight: normal; + border-bottom: 1px solid $panel-bg; + &.first { + //Instead of the line below you could use @includeborder-radius($radius, $vertical-radius) + border-radius: 0 3px 0 0; + } + &.last { + //Instead of the line below you could use @includeborder-radius($radius, $vertical-radius) + border-radius: 0 0 3px 0; + border-bottom: 0; + } + &:hover > a { + background: $button-bg-color; + color: $ghost; + } + } + ul { + top: -2px; + right: 0; + } + a { + font-size: 14px; + color: $ghost; + &:hover { + color: $ghost; + } + } + } + } + &:before { + content: ''; + display: block; + } + &:after { + content: ''; + display: table; + clear: both; + } + a { + display: block; + padding: 15px 20px; + color: $ghost; + text-decoration: none; + text-transform: uppercase; + } + li { + position: relative; + } + &.align-right { + float: right; + li { + text-align: right; + } + ul { + ul { + visibility: hidden; + position: absolute; + top: 0; + left: -100%; + z-index: 598; + width: 100%; + //Instead of the line below you could use @includeborder-radius($radius, $vertical-radius) + border-radius: $global-radius 0 0 $global-radius; + li { + &.first { + //Instead of the line below you could use @includeborder-radius($radius, $vertical-radius) + border-radius: $global-radius 0 0 0; + } + &.last { + //Instead of the line below you could use @includeborder-radius($radius, $vertical-radius) + border-radius: 0 0 0 $global-radius; + } + } + } + li.has-sub > a { + &:before { + content: '+'; + position: absolute; + top: 50%; + left: 15px; + margin-top: -6px; + } + &:after { + content: none; + } + } + } + > ul > li > a { + border-left: 2px solid lighten($panel-bg, 10%); + border-right: none; + } + } + > ul { + width: 100%; + > li { + > a { + border-right: 2px solid lighten($panel-bg, 10%); + color: $ghost; + &:hover { + color: $ghost; + } + } + &.active a { + background: lighten($panel-bg, 10%); + } + a:hover { + background: lighten($panel-bg, 10%); + } + &:hover a { + background: lighten($panel-bg, 10%); + } + } + } +} diff --git a/app/styles/hub/main-nav.scss b/app/styles/hub/main-nav.scss new file mode 100644 index 0000000000..4683e7b2c0 --- /dev/null +++ b/app/styles/hub/main-nav.scss @@ -0,0 +1,132 @@ +/* + * These top bar properties still need to be sassified. + * + */ + +.topnav-wrapper { + background-color: $topbar-dark; +} + +.top-bar .top-bar-section ul li:hover:not(.has-form) > a { + background: transparent; +} + +.top-bar-section ul li > a { + font-weight: 400; + font-size: 14px; +} + +.top-bar-section ul li > a.button.secondary:hover, +.top-bar-section ul li > a.button.secondary:focus { + color: #fff; + background: transparent; +} + +.top-bar-section ul li > a.button.secondary { + background-color: transparent; + color: #c4cdda; + font-size: 1em; + height: 40px; + margin-top: -3px; +} +.top-bar-section li:not(.has-form) a:not(.button) { + background: transparent; + line-height: 3.125rem; +} +/* + * + * + */ + +.top-bar-section { + ul li { + background: none; + a.button.tiny { + background-color: #86d800;/** temp color **/ + border-radius: $global-radius; + box-shadow: none; + &:hover { + background-color: #5fa736;/** temp color **/ + } + } + } + .profile-photo { + width: rem-calc(24px); + height: rem-calc(24px); + margin-right: 5px; + border-radius: $global-radius; + } + .title-area { + margin-top: .5rem; + a { + padding: 1rem; + } + img { + height: 36px; + margin-right: 10px + } + } + .nav-user-info { + background: none; + } + /* + * "+" menu + * + */ + .css-dropdown { + &:hover ul { + display: block; + opacity: 1; + visibility: visible; + } + ul:before { + border: 8px solid transparent; + border-bottom-color: #3d4c5a; + content: ""; + display: inline-block; + left: 90px; + position: absolute; + top: -16px; + } + ul { + // background: $topbar-dark; + background: #3d4c5a; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + display: none; + padding: 0; + position: absolute; + top: 48px; + right: 4rem; + opacity: 0; + padding: .5rem 0; + visibility: hidden; + width: 220px; + z-index: 1; + li { + cursor: pointer; + float: none; + padding: .3em; + margin: 0; + position: relative; + a { + display: block; + padding-top: .23rem; + } + a:hover { + background: #7a8491; + } + } + li:not(.has-form) > a { + line-height: 1.7rem; + } + & i.fa { + font-size: 1.2rem; + padding-right: 7px; + } + &.with-button { + right: rem-calc(116px); + } + } + } +} \ No newline at end of file diff --git a/app/styles/hub/react-tagsinput.scss b/app/styles/hub/react-tagsinput.scss new file mode 100644 index 0000000000..70287eee8f --- /dev/null +++ b/app/styles/hub/react-tagsinput.scss @@ -0,0 +1,59 @@ +//colors +$color_celeste_approx: #ccc; +$white: #fff; +$color_conifer_approx: #a5d24a; +$color_deco_approx: #cde69c; +$color_olive_drab_approx: #638421; +$color_cosmos_approx: #fbd8db; +$color_tamarillo_approx: #90111a; +$color_tapa_approx: #777; + +.react-tagsinput { + border: 1px solid lighten($panel-bg, 5%); + background: lighten($panel-bg, 8%); + padding: rem-calc(10px); + overflow-y: auto; + //Instead of the line below you could use @includeborder-radius($radius, $vertical-radius) + border-radius: rem-calc(2px); + input { + color: $charcoal; + } +} +.react-tagsinput-tag { + display: block; + border: 1px solid $secondary-5; + background: $secondary-6; + color: $oil; + font-size: 14px; + float: left; + padding: 15px; + margin-right: 10px; + margin-bottom: 10px; + text-decoration: none; + //Instead of the line below you could use @includeborder-radius($radius, $vertical-radius) + border-radius: $global-radius; +} +.react-tagsinput-invalid { + background: transparent; + color: $primary-5; +} +.react-tagsinput-remove { + font-weight: 300; + color: $secondary-4; + text-decoration: none; + font-size: 14px; + cursor: pointer; + &:before { + content: "x"; + } +} +.react-tagsinput-input { + background: transparent; + color: $charcoal; + border: 0; + font-size: 13px; + padding: 5px; + margin: 0; + width: 80px; + outline: none; +} \ No newline at end of file diff --git a/app/styles/hub/repositories.scss b/app/styles/hub/repositories.scss new file mode 100644 index 0000000000..3323d95ff8 --- /dev/null +++ b/app/styles/hub/repositories.scss @@ -0,0 +1,155 @@ +ul.repositories { + margin: 2.25rem 0.3rem 0 0.3rem; +} +.repo-header { + padding: rem-calc(10px); +} +.repo-content { + padding: rem-calc(10px); +} +.repository { + background: white; + .title { + color: $primary-color; + } + .logo { + width: 64px; + min-width: 64px; + height: 64px; + min-height: 64px; + padding: 10px; + background: $primary-color; + border-radius: $global-radius; + .d-logo { + color: $ghost; + font-size: rem-calc(48px); + margin: 0 auto; + } + } + .header { + padding-top: 0.325rem; + h6 small { + font-size: small; + } + h6 span { + font-size: small; + text-transform: capitalize; + color: #3f5167; + } + } + .repo-wrapper { + margin-left: 0.5rem; + margin-right: 0.5rem; + } + //TODO: These surely should be `scss-ified` + .repo-stars { + border: 1px solid #c4cdda; + border-left: none; + padding: 10px; + p { + font-size: smaller; + } + } + .repo-downloads { + border: 1px solid #c4cdda; + border-left: none; + border-right: none; + padding: 10px; + p { + font-size: smaller; + } + } +} + +.repo-separator { + font-weight: 300; + font-size: rem-calc(24px); + margin-right: 0px; + padding-right: 0px; + width: 20px; +} + +.repo-form-margin { + margin-left: -15px; +} + +//Need this to make foundation block-grid work with our design and borders +.repo-border { + border:1px solid white; + position:relative; + z-index:10 +} + +.repo-border:before { + content:""; + display:block; + position:absolute; + top:2px; + left:2px; + right:2px; + bottom:2px; + border:1px solid #c4cdda; + border-radius: $global-radius; +} + +.repository-page { + .repo-description-dockerfile { + .dockerfile { + font-family: $font-family-monospace; + white-space: pre; + overflow-x: auto; + } + } + .repo-details-content { + .repo-visibility { + margin-bottom: 1.25rem; + background: white; + border: 1px solid $docker-dark--placeholders; + padding: .75rem 1rem; + .privacy { + font-weight: $font-weight-bold; + display: inline-block; + } + .status { + display: inline-block; + } + } + + + + .temp-prof-icon { + height: 50px; + width: 50px; + border-radius: 100%; + border: 1px solid black; + display: inline-block; + margin-right: 10px; + } + } +} + +.explore-repo-list { + margin-top: 1rem; +} + +.add-repository-form { + margin-top: rem-calc(20px); +} + +.repo-tags { + margin: 0; + padding: 0; + list-style-type: none; + li { + float: left; + padding: 0.3rem 0.3rem 1rem 0; + margin-right: 0.5rem; + .repo-tag { + background-color: #3c5164; + color: white; + border-radius: $global-radius; + padding: 0.5rem 0.8rem; + } + } +} + diff --git a/app/styles/hub/repository-settings.scss b/app/styles/hub/repository-settings.scss new file mode 100644 index 0000000000..bf957d5600 --- /dev/null +++ b/app/styles/hub/repository-settings.scss @@ -0,0 +1,49 @@ +.repo-settings { + color: $charcoal; + padding: 1rem; + h1, h2, h3, h4, h5, h6, input, label { + color: $charcoal; + } + .form-panel { + @include panel(); + background: $white; + button { + margin-right: $default-margin; + } + } + .help-text { + padding: 1rem 0 0 .9rem; + } + /** scope this to repo-settings only **/ + input[type="text"], + .bar { + width: 100%; + } + .repo-visibility { + margin-top: 1rem; + .visibility-form { + .visibility-toggle { + cursor: pointer; + * { + cursor: pointer; + } + .text-error { + color: $primary-5; + margin-left: 1rem; + } + } + .disabled { + cursor: default; + color: $secondary-5; + * { + cursor: default; + } + } + input { + &[name='visibility'] { + margin-right: 5px; + } + } + } + } +} diff --git a/app/styles/hub/reset-password.scss b/app/styles/hub/reset-password.scss new file mode 100644 index 0000000000..810a6bf95d --- /dev/null +++ b/app/styles/hub/reset-password.scss @@ -0,0 +1,49 @@ +.pass-reset-wrapper { + align-items: center; + display: flex; + height: 100%; + width: 100%; + &.reset { + text-align: center; + } + .password-reset { + background-color: $white; + border: 1px solid rgb(233, 237, 240);// TODO: create/utilize variable + border-radius: $global-radius; + display: flex; + flex-direction: column; + justify-content: center; + min-height: 270px; + h3 { + color: $primary-1; + font-weight: 300; + } + &.error h3 { + color: $primary-5; + } + p { + color: rgb(113, 125, 144);// TODO: create/utilize variable + } + form { + margin-top: .6rem; + } + .group { + input, + .bar { + color: rgb(113, 125, 144);// TODO: create/utilize variable + width: 100%; + } + } + .resetPassSubmit { + background-color:$primary-color; + border: none; + border-radius: $global-radius; + color: $white; + float: right; + padding: 10px 20px; + &:disabled { + background-color:rgb(233, 237, 240);// TODO: create/utilize variable + } + } + } +} diff --git a/app/styles/hub/search.scss b/app/styles/hub/search.scss new file mode 100644 index 0000000000..a0a665c2e2 --- /dev/null +++ b/app/styles/hub/search.scss @@ -0,0 +1,191 @@ +//search page +.search-page { + width: 100%; + overflow-x: hidden; + font-weight: 300; + .inline-list { + margin-top: rem-calc(12px); + li { + margin-left: 0.7rem; + } + } + select { + background-color: transparent; + border: 1px solid #c4cdda; + transition: border-color 0.15s linear 0s, background 0.15s linear 0s; + outline: 0; + color: #3f5167; + width: rem-calc(100px); + font-size: rem-calc(12px); + font-weight: 200; + border-color: #c4cdda; + border-radius: $global-radius; + } + input[type="text"] { + color: #7A8491; + } + + .search-results-container { + .no-results-item { + text-align: center; + } + } + + //Search Results + .search-results-list { + ul { + list-style-type: none; + text-align: center; + } + ul li { + display: inline-block; + padding-right: 5px; + } + .no-results-item { + list-style-type: none; + font-size: rem-calc(14px); + font-weight: normal; + } + .search-list-item { + background-color: white; + margin-bottom: rem-calc(10px);//5px 5px 5px 5px; + overflow-y: hidden; + font-weight: normal; + border: 1px solid #c4cdda; + border-radius: $global-radius; + + &:hover { + cursor: pointer; + } + + .logo { + width: 48px; + min-width: 48px; + height: 48px; + min-height: 48px; + padding: 10px; + background: $primary-color; + border-radius: $global-radius; + margin-left: rem-calc(10px); + margin-bottom: rem-calc(25px); + .d-logo { + color: $ghost; + font-size: rem-calc(32px); + margin: 0 auto; + } + } + + .search-item-avatar { + } + + .search-bar-details { + line-height: rem-calc(12px); + height: rem-calc(24px); + background-color: #c4cdda; + p { + font-weight: 300; + font-size: rem-calc(12px); + margin-bottom: 0; + line-height: 1.5rem; + } + } + + .search-item-basic-info { + .search-item-name { + font-size: 1.4rem; + font-weight: 600; + } + p { + font-size: 0.8rem; + } + .search-item-description { + font-size: rem-calc(12px); + } + } + + .search-item-other-info { + font-size: rem-calc(10px); + } + + .search-item-badges-stats { + ul li { + line-height: 0.6rem; + border-left: 1px solid #c4cdda; + } + p { + margin-bottom: 0; + font-size: rem-calc(12px); + font-weight: 500; + } + .search-item-badges { + } + .search-item-stats { + span { + padding-left: rem-calc(4px); + } + } + } + } + } +} + +.paging-bar { + display: inline-flex; + float: right; + margin-right: rem-calc(25px); + .paging-info { + align-self: center; + } + .paging-buttons { + align-self: center; + button { + margin: 0; + background-color: $body-bg; + color: $docker-dark; + padding-right: 0; + padding-left: rem-calc(4px); + box-shadow: none; + &:hover { + color: $primary-color; + box-shadow: none; + } + &:focus { + color: #7A8491; + outline: none; + box-shadow: none; + } + i { + font-size: 24; + } + } + button[disabled] { + opacity: 0.3; + box-shadow: none; + } + } +} + +.searchbar { + input[type=text] { + background: #69788a;/** need variable here **/ + border: 1px solid #4c5968;/** need variable here **/ + border-radius: $global-radius; + color: $white; + display: block; + height: 2rem; + padding: .2rem 2rem; + top: .6rem; + } + i.fa.fa-search { + color: #fff; + margin-top: -18px;/** lame **/ + margin-left: 11px;/** lame **/ + } +} + +// target webkit only meh +@media screen and (-webkit-min-device-pixel-ratio: 0) { + i.fa.fa-search { + margin-top: -16px!important;/** super lame, this can not be permanent **/ + } +} diff --git a/app/styles/hub/tabs.scss b/app/styles/hub/tabs.scss new file mode 100644 index 0000000000..ef90208ab0 --- /dev/null +++ b/app/styles/hub/tabs.scss @@ -0,0 +1,26 @@ +.tabs { + position: relative; + float: left; + width: 100%; + li:first-child a { + border-top-left-radius: $global-radius; + } + li:last-child a { + border-top-right-radius: $global-radius; + border-right: 1px solid $docker-dark--placeholders; + } + a.repo-tab { + border-left: 1px solid $docker-dark--placeholders; + border-top: 1px solid $docker-dark--placeholders; + border-bottom: 1px solid $docker-dark--placeholders; + background:lighten($docker-dark--placeholders, 10%); + padding: .5rem 1.8rem; + font-size: 16px; + &.active { + border-bottom: 1px solid white; + background: white; + position: relative; + z-index: 2; + } + } +} diff --git a/app/styles/hub/usericons.scss b/app/styles/hub/usericons.scss new file mode 100644 index 0000000000..ca476b3191 --- /dev/null +++ b/app/styles/hub/usericons.scss @@ -0,0 +1,6 @@ +.profile-photo { + width: rem-calc(24px); + height: rem-calc(24px); + margin-right: 5px; + border-radius: $global-radius; +} \ No newline at end of file diff --git a/app/styles/main.scss b/app/styles/main.scss new file mode 100644 index 0000000000..c7ee2ba6c6 --- /dev/null +++ b/app/styles/main.scss @@ -0,0 +1,170 @@ +@import 'docker-ux'; +@import 'mono-blue'; +@import '_docker'; + +//============================REMOVE THIS====================================== + +$default-margin: 1.5rem; + +.flex-table { + display: flex; + flex-flow: column; + justify-content: center; + border: 1px solid $border-input; + border-radius: $global-radius; + font-size: 1rem; + margin: 0.5rem; + line-height: 1.5; + font-weight: 500; + color: $secondary-2; + .flex-row { + width: 100%; + display: flex; + border-bottom: 1px solid $secondary-5; + background: white; + &:last-child { + border-bottom: 0; + } + &.header { + background-color: $primary-1; + font-weight: 700; + color: white; + font-size: 1.2rem; + border-bottom: 0; + } + .flex-item { + display: flex; + flex-flow: row nowrap; + flex-grow: 1; + flex-basis: 0; + padding: 0.7em 1.2rem 0.7em 1.2rem; + word-break: break-word; + a { + color: $primary-1; + &:hover { + color: darken($primary-1, 10%); + } + } + } + } +} + +//============================================================================= + +@import 'hub'; + +.button { + // seems to feel unresponsive with almost any transition length + transition: 0.1s; + font-weight: 500; +} + +//TODO: could be global style +.create-object-btn { + background-color: white; + border: 4px dashed #c4cdda; + border-radius: $global-radius; + box-shadow: none; + color: $docker-dark; + line-height: 2.65rem; + margin: rem-calc(20px); + padding: 0.7rem; + font-size: large; + text-align: center; + width: 90%; + cursor: pointer; + &:hover { + background-color: $docker-light; + color: $docker-dark; + } + &:focus { + background-color: $docker-light; + color: $docker-dark; + } + i { + float: right; + } +} + +//TODO: could be global style +.blank-slate { + min-height: 600px; +} + +.page-top-header { + background-color: white; + h3 { + color: #22b8eb; + font-weight: 300; + } +} + +.temp-page { + margin-top: 2rem; + text-align: center; + h1 { + font-size: 24px; + font-weight: 300; + color: lighten(gray, 15%); + } +} + + + select { + background-color: transparent; + border: 1px solid #c4cdda; + transition: border-color 0.15s linear 0s, background 0.15s linear 0s; + outline: 0; + color: #3f5167; + width: rem-calc(100px); + font-size: rem-calc(14px); + font-weight: 300; + border-color: #c4cdda; + border-radius: $global-radius; + } + +form .row textarea.columns { + padding-left: 1rem; +} + +.global-select { + .text { + margin-right: 1rem; + color: $secondary-4; + } +} + +.alert-box { + border-radius: $global-radius; +} + +//TODO: fix in dux +.sk-cube { + background-color: #546473 !important; +} + +/* Pre-move to docker-ux */ +@import 'common/_secondary-top-bar'; +@import 'common/_repository-list-item'; + +// Don't kill me +.mktoForm { + margin-top: 10px; + select#Country { + margin-top: 20px; + } + #Phone { + margin-top: 20px; + } + #accept_eval_terms { + margin-top: 30px; + } +} +.mktoForm div.mktoFormRow { + padding-bottom: 20px; +} +.mktoFieldWrap { + label { + top: -15px; + } +} diff --git a/app/styles/vendor-overrides/rc-tooltip.css b/app/styles/vendor-overrides/rc-tooltip.css new file mode 100644 index 0000000000..ab6f60e661 --- /dev/null +++ b/app/styles/vendor-overrides/rc-tooltip.css @@ -0,0 +1,31 @@ +.rc-tooltip-inner { + border: 0 none; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); +} +.rc-tooltip-placement-top .rc-tooltip-arrow { + border-left: 0 none; + border-top: 0 none; + background: #fff; + width: 10px; + height: 10px; + transform: rotate(45deg); + border-right: 1px solid #ddd; + border-bottom: 1px solid #ddd; + z-index: 999; +} + +.rc-tooltip-placement-bottom .rc-tooltip-arrow { + background: #fff; + width: 10px; + height: 10px; + transform: rotate(45deg); + border-right: 0 none; + border-bottom: 0 none; + border-left: 1px solid #ddd; + border-top: 1px solid #ddd; + z-index: 999; +} + +.rc-tooltip-inner * { + word-wrap: break-word; +} diff --git a/app/styles/vendor-overrides/react-select.css b/app/styles/vendor-overrides/react-select.css new file mode 100644 index 0000000000..dbce7cfccd --- /dev/null +++ b/app/styles/vendor-overrides/react-select.css @@ -0,0 +1,279 @@ +/** + * React Select + * ============ + * Created by Jed Watson and Joss Mackison for KeystoneJS, http://www.keystonejs.com/ + * https://twitter.com/jedwatson https://twitter.com/jossmackison https://twitter.com/keystonejs + * MIT License: https://github.com/keystonejs/react-select + * + * Modified to match our styles +*/ + +.Select { + position: relative; +} +.Select-control { + position: relative; + overflow: hidden; + background-color: #fff; + border: 1px solid #ccc; + border-color: #d9d9d9 #ccc #b3b3b3; + border-radius: 3px; + box-sizing: border-box; + color: #333; + cursor: default; + outline: none; + padding: 8px 52px 8px 10px; +} +.Select-control:hover { + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06); +} +.is-searchable.is-open > .Select-control { + cursor: text; +} +.is-open > .Select-control { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + background: #fff; + border-color: #b3b3b3 #ccc #d9d9d9; +} +.is-open > .Select-control > .Select-arrow { + border-color: transparent transparent #999; + border-width: 0 5px 5px; +} +.is-searchable.is-focused:not(.is-open) > .Select-control { + cursor: text; +} +.is-focused:not(.is-open) > .Select-control { + border-color: #08c #0099e6 #0099e6; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1), 0 0 5px -1px rgba(0, 136, 204, 0.5); +} +.Select-placeholder { + color: #aaa; + padding: 4px 52px 8px 10px; + position: absolute; + top: 0; + left: 0; + right: -15px; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.has-value > .Select-control > .Select-placeholder { + color: #333; +} +.Select-value { + color: #aaa; + padding: 4px 52px 8px 10px; + position: absolute; + top: 0; + left: 0; + right: -15px; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.has-value > .Select-control > .Select-value { + color: #333; +} +.Select-input > input { + cursor: default; + background: none transparent; + box-shadow: none; + height: auto; + border: 0 none; + font-family: inherit; + font-size: inherit; + margin: 0; + padding: 0; + outline: none; + display: inline-block; + -webkit-appearance: none; +} +.is-focused .Select-input > input { + cursor: text; +} +.Select-control:not(.is-searchable) > .Select-input { + outline: none; +} +.Select-loading { + -webkit-animation: Select-animation-spin 400ms infinite linear; + -o-animation: Select-animation-spin 400ms infinite linear; + animation: Select-animation-spin 400ms infinite linear; + width: 16px; + height: 16px; + box-sizing: border-box; + border-radius: 50%; + border: 2px solid #ccc; + border-right-color: #333; + display: inline-block; + position: relative; + margin-top: -8px; + position: absolute; + right: 30px; + top: 50%; +} +.has-value > .Select-control > .Select-loading { + right: 46px; +} +.Select-clear { + color: #999; + cursor: pointer; + display: inline-block; + font-size: 12px; + padding: 6px 10px; + position: absolute; + right: 17px; + top: 0; +} +.Select-clear:hover { + color: #c0392b; +} +.Select-clear > span { + font-size: 0.8rem; +} +.Select-arrow-zone { + content: " "; + display: block; + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 30px; + cursor: pointer; +} +.Select-arrow { + border-color: #999 transparent transparent; + border-style: solid; + border-width: 5px 5px 0; + content: " "; + display: block; + height: 0; + margin-top: -ceil(2.5px); + position: absolute; + right: 10px; + top: 14px; + width: 0; + cursor: pointer; +} +.Select-menu-outer { + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; + background-color: #fff; + border: 1px solid #ccc; + border-top-color: #e6e6e6; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06); + box-sizing: border-box; + margin-top: -1px; + max-height: 200px; + position: absolute; + top: 100%; + width: 100%; + z-index: 1000; + -webkit-overflow-scrolling: touch; +} +.Select-menu { + max-height: 198px; + overflow-y: auto; +} +.Select-option { + box-sizing: border-box; + color: #666666; + cursor: pointer; + display: block; + padding: 8px 10px; +} +.Select-option:last-child { + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.Select-option.is-focused { + background-color: #f2f9fc; + color: #333; +} +.Select-option.is-disabled { + color: #cccccc; + cursor: not-allowed; +} +.Select-noresults, +.Select-search-prompt, +.Select-searching { + box-sizing: border-box; + color: #999999; + cursor: default; + display: block; + padding: 8px 10px; +} +.Select.is-multi .Select-control { + padding: 2px 52px 2px 3px; +} +.Select.is-multi .Select-input { + vertical-align: middle; + border: 1px solid transparent; + margin: 2px; + padding: 3px 0; +} +.Select-item { + background-color: #f2f9fc; + border-radius: 2px; + border: 1px solid #c9e6f2; + color: #08c; + display: inline-block; + font-size: 0.8rem; + margin: 2px; +} +.Select-item-icon, +.Select-item-label { + display: inline-block; + vertical-align: middle; +} +.Select-item-label { + cursor: default; + border-bottom-right-radius: 2px; + border-top-right-radius: 2px; + padding: 3px 5px; +} +.Select-item-label .Select-item-label__a { + color: #08c; + cursor: pointer; +} +.Select-item-icon { + cursor: pointer; + border-bottom-left-radius: 2px; + border-top-left-radius: 2px; + border-right: 1px solid #c9e6f2; + padding: 2px 5px 4px; +} +.Select-item-icon:hover, +.Select-item-icon:focus { + background-color: #ddeff7; + color: #0077b3; +} +.Select-item-icon:active { + background-color: #c9e6f2; +} +.Select.is-multi.is-disabled .Select-item { + background-color: #f2f2f2; + border: 1px solid #d9d9d9; + color: #888; +} +.Select.is-multi.is-disabled .Select-item-icon { + cursor: not-allowed; + border-right: 1px solid #d9d9d9; +} +.Select.is-multi.is-disabled .Select-item-icon:hover, +.Select.is-multi.is-disabled .Select-item-icon:focus, +.Select.is-multi.is-disabled .Select-item-icon:active { + background-color: #f2f2f2; +} +@keyframes Select-animation-spin { + to { + transform: rotate(1turn); + } +} +@-webkit-keyframes Select-animation-spin { + to { + -webkit-transform: rotate(1turn); + } +} diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000000..fee94207b8 --- /dev/null +++ b/circle.yml @@ -0,0 +1,41 @@ +machine: + pre: + - echo 'DOCKER_OPTS="-s btrfs -e lxc -D --userland-proxy=false"' | sudo tee -a /etc/default/docker + - sudo curl -L -o /usr/bin/docker 'https://s3-external-1.amazonaws.com/circle-downloads/docker-1.8.2-circleci-cp-workaround' + - sudo chmod 0755 /usr/bin/docker + node: + version: 4.1.0 + services: + - docker +dependencies: + override: + - mkdir -p ./bin + - pip install fabric==1.8.1 + - pip install pycrypto + - make hub-deps +test: + override: + - docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_AUTH + - docker build -f dockerfiles/milky-way-no-bin -t milky-no-bin ./dockerfiles + - make stage + - mkdir .stage/ + - make copy-stage + - docker run -d --name milky milky-no-bin sleep 15m + - cd .stage/.build-prod && docker cp milky:/opt/hub/modules.tar . && docker build -t bagel/hub-stage . + - make prod + - make base-prod-tag + - make copy-prod + - docker run -d --name milky-2 milky-no-bin sleep 15m + - cd .build-prod && docker cp milky-2:/opt/hub/modules.tar . && docker build -t bagel/hub-prod . + - docker run -de ENV=production -p 3000:3000 --name hub-prod-tester bagel/hub-prod + - sleep 60s + - curl $(docker inspect --format '{{ .NetworkSettings.IPAddress }}' hub-prod-tester):3000 +deployment: + autodeploy: + branch: [master, autodeploy] + owner: docker + commands: + - docker push bagel/hub-prod + - docker push bagel/hub-stage + - chmod 400 ~/.ssh/id_console-demo + - fab -H root@console-demo.docker.com -i ~/.ssh/id_console-demo start_project:email=$DOCKER_EMAIL,user=$DOCKER_USER,auth=$DOCKER_AUTH,beta_password=$BETA_PASSWORD,sha=latest,new_relic_key=$NEW_RELIC_KEY,new_relic_app_name=$NEW_RELIC_APP_NAME diff --git a/containers/README.md b/containers/README.md new file mode 100644 index 0000000000..6cb24e12b8 --- /dev/null +++ b/containers/README.md @@ -0,0 +1,48 @@ + +# Containers + +These are the "accessory" containers with which Hub 2.0 is run. + +## dnsmasq + +dnsmasq is used to fake the `Origin` header in CORS requests. This is +necessary because the browser automatically sends `Origin: localhost` +(users can't modify it) and we need it to be in the `*.docker.com` +space, since staging is set up to handle single dot subdomains. + +We've chosen `bagels.docker.com` as the development domain (something +that is unlikely to ever be deployed in production so that we won't +have to change the name in the future). + +### prerequisites + +```bash +cd $PROJECT +make dns +``` + +This runs `$PROJECT/containers/configure_system_dns.sh`, which will +add `bagels.docker.com` to your host system's `/etc/resolver/`. This +makes it so that `bagels.docker.com` will resolver to `boot2docker ip`. + +### run + +```bash +cd $PROJECT/containers/dnsmasq +docker build -t bagelteam/dnsmasq +docker run -itp 53:53/udp bagelteam/dnsmasq +``` + +## HAProxy + +HAProxy is a load balancer used to terminate SSL. + +Currently Out-of-Order. + +```bash +docker run -itp 80:80 -p 443:433 bagelteam/haproxy +``` + +HAProxy will load balance `bagels.docker.com` across a single +container (hah), and more importantly, take care of SSL Offloading at +the load balancer. The image has it's own SSL certificates. diff --git a/containers/dnsmasq/Dockerfile b/containers/dnsmasq/Dockerfile new file mode 100644 index 0000000000..40c00e83d5 --- /dev/null +++ b/containers/dnsmasq/Dockerfile @@ -0,0 +1,12 @@ +FROM debian:jessie + +MAINTAINER Chris Biscardi + +RUN apt-get update && apt-get install -y dnsmasq + +EXPOSE 53/udp + +ADD ./run /opt/run + +CMD "/opt/run" +# docker run -d -p 53:53/udp --name docker-dnsmasq dnsmasq --address=/dev.docker.io/172.16.200.100 diff --git a/containers/dnsmasq/configure_system_dns.sh b/containers/dnsmasq/configure_system_dns.sh new file mode 100755 index 0000000000..d84112198a --- /dev/null +++ b/containers/dnsmasq/configure_system_dns.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Docker Bridge IP - https://docs.docker.com/articles/networking/ +# $DOCKER_HOST will be the IP of the boot2docker or docker-machine +# instance *currently sourced in your shell*. This means something +# like $(docker-machine env dev) or $(boot2docker shellinit) +if [[ $DOCKER_HOST =~ ([0-9]{1,3}[\.]){3}[0-9]{1,3} ]]; then + DAEMON_IPV4=$BASH_REMATCH + echo $DAEMON_IPV4 +else + echo "unable to parse string $DOCKER_HOST" +fi + +set_dev_resolver() { + echo "Bagels need your permission to configure system DNS." + sudo mkdir -p /etc/resolver + echo "nameserver $DAEMON_IPV4" | sudo tee /etc/resolver/bagels.docker.com +} + +if [ ! -f /etc/resolver/bagels.docker.com ]; then + set_dev_resolver +elif [ "$(cat /etc/resolver/bagels.docker.com)" != "nameserver $DAEMON_IPV4" ]; then + set_dev_resolver +fi diff --git a/containers/dnsmasq/run b/containers/dnsmasq/run new file mode 100755 index 0000000000..b2b60ad240 --- /dev/null +++ b/containers/dnsmasq/run @@ -0,0 +1,13 @@ +#!/bin/bash + +# match the ip address from a DOCKER_HOST which is set by boot2docker +# and docker-machine +if [[ $DOCKER_HOST =~ ([0-9]{1,3}[\.]){3}[0-9]{1,3} ]]; then + strresult=$BASH_REMATCH + echo $strresult +else + echo "unable to parse string $DOCKER_HOST" +fi + +/usr/sbin/dnsmasq -q --no-daemon --address=/bagels.docker.com/$strresult +#$strresult diff --git a/containers/haproxy/Dockerfile b/containers/haproxy/Dockerfile new file mode 100644 index 0000000000..034260c7e2 --- /dev/null +++ b/containers/haproxy/Dockerfile @@ -0,0 +1,10 @@ +FROM fish/haproxy + +ADD . /haproxy + +EXPOSE 80 443 + +# Check is haproxy.cfg is valid before we start +# CMD "(haproxy -c -f /haproxy/haproxy.cfg || ( echo 'Bad haproxy config'; exit; )) && /usr/sbin/haproxy -f /haproxy/haproxy.cfg & && wait $!" + +ENTRYPOINT ["/haproxy/run"] \ No newline at end of file diff --git a/containers/haproxy/haproxy.cfg b/containers/haproxy/haproxy.cfg new file mode 100644 index 0000000000..b9d1c5014a --- /dev/null +++ b/containers/haproxy/haproxy.cfg @@ -0,0 +1,41 @@ +global + chroot /var/lib/haproxy + user haproxy + group haproxy + +defaults + log global + mode http + option httplog + option dontlognull + timeout connect 5000 + timeout client 50000 + timeout server 50000 + errorfile 400 /etc/haproxy/errors/400.http + errorfile 403 /etc/haproxy/errors/403.http + errorfile 408 /etc/haproxy/errors/408.http + errorfile 500 /etc/haproxy/errors/500.http + errorfile 502 /etc/haproxy/errors/502.http + errorfile 503 /etc/haproxy/errors/503.http + errorfile 504 /etc/haproxy/errors/504.http + stats enable + stats auth haproxy:hapass + +frontend https + bind :443 ssl crt /haproxy/keys/bagels.docker.com/bagels.docker.pem + acl is-ssl dst_port 443 + + http-request set-header X-Real-IP %ci + + reqadd X-Forwarded-Proto:\ https if is-ssl + reqadd X-Forwarded-Port:\ 443 if is-ssl + rspadd Strict-Transport-Security:\ max-age=31536000 if is-ssl + + acl is_hub_dev hdr(host) -i bagels.docker.com + + use_backend hub_dev if is_hub_dev + +backend hub_dev + balance leastconn + option httpclose + server docker-1 {DOCKER_HOST}:7001 check \ No newline at end of file diff --git a/containers/haproxy/keys/bagels.docker.com/bagels.docker.crt b/containers/haproxy/keys/bagels.docker.com/bagels.docker.crt new file mode 100644 index 0000000000..44765408fb --- /dev/null +++ b/containers/haproxy/keys/bagels.docker.com/bagels.docker.crt @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICNTCCAZ4CCQDY33gN8y9BQzANBgkqhkiG9w0BAQUFADBfMQswCQYDVQQGEwJV +UzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xDzANBgNVBAoT +BkRvY2tlcjEaMBgGA1UEAxMRYmFnZWxzLmRvY2tlci5jb20wHhcNMTUwMTIxMDM1 +NTEzWhcNMTYwMTIxMDM1NTEzWjBfMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0Ex +FjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xDzANBgNVBAoTBkRvY2tlcjEaMBgGA1UE +AxMRYmFnZWxzLmRvY2tlci5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGB +AMluBCvOUrdFkFGCmpKBPduoZgYE/hNKnmX3Cqrn+FsodOBiin1lOs7+XX3EY078 +u5QIULNZ3j/LUSuxgHBS8RcVc3ljCkvwRURwVy6FWunahdTULLEq+qOByv6Hq2/W +itlzT2Rw6Tu29IThb7Mtxb1B6LoAorkWX/YEXankpVPnAgMBAAEwDQYJKoZIhvcN +AQEFBQADgYEAPdqZ2jLxOuZ52wucJN1DoOBUCWnCM5bfBHOU3wBqSPA/mT2Bw5Fo +evqqd+mRWizgmSkDM9NpO9cj9tpeidTrHsTutkqjQttIeNAtZm82sSWH7ul1N1du +4aDDKwAk4j9BYPUmYQFaSRKNtE/OpGVPxxK/ZBS8YeVT0knzTr/a9to= +-----END CERTIFICATE----- diff --git a/containers/haproxy/keys/bagels.docker.com/bagels.docker.csr b/containers/haproxy/keys/bagels.docker.com/bagels.docker.csr new file mode 100644 index 0000000000..b9d96abb7e --- /dev/null +++ b/containers/haproxy/keys/bagels.docker.com/bagels.docker.csr @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBnzCCAQgCAQAwXzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQH +Ew1TYW4gRnJhbmNpc2NvMQ8wDQYDVQQKEwZEb2NrZXIxGjAYBgNVBAMTEWJhZ2Vs +cy5kb2NrZXIuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDJbgQrzlK3 +RZBRgpqSgT3bqGYGBP4TSp5l9wqq5/hbKHTgYop9ZTrO/l19xGNO/LuUCFCzWd4/ +y1ErsYBwUvEXFXN5YwpL8EVEcFcuhVrp2oXU1CyxKvqjgcr+h6tv1orZc09kcOk7 +tvSE4W+zLcW9Qei6AKK5Fl/2BF2p5KVT5wIDAQABoAAwDQYJKoZIhvcNAQEFBQAD +gYEAlAQKhy4j7wenWqnKzfpp/o0cbzQAcve76XSwfWrzONFDZidhQlwAKBdYbYN3 +4ITqNw4MPSCMBkMMCQFFFHM/+NqlAmYYbJHv8uDxKel/7IsxIEPRun0b6k/+wL2e +2nyJJrMwesVrzvDwfB+8eoUOZFJIiX6htpxU4vgq9xMgMAg= +-----END CERTIFICATE REQUEST----- diff --git a/containers/haproxy/keys/bagels.docker.com/bagels.docker.key b/containers/haproxy/keys/bagels.docker.com/bagels.docker.key new file mode 100644 index 0000000000..6659dc144c --- /dev/null +++ b/containers/haproxy/keys/bagels.docker.com/bagels.docker.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICWwIBAAKBgQDJbgQrzlK3RZBRgpqSgT3bqGYGBP4TSp5l9wqq5/hbKHTgYop9 +ZTrO/l19xGNO/LuUCFCzWd4/y1ErsYBwUvEXFXN5YwpL8EVEcFcuhVrp2oXU1Cyx +Kvqjgcr+h6tv1orZc09kcOk7tvSE4W+zLcW9Qei6AKK5Fl/2BF2p5KVT5wIDAQAB +AoGAcA+Qqn5Cbkt5Gp+6Nr9IFqf8+mRUpY7hyIBDowkiljRPsXWg7loe+YFxqcJU +LWFVSenGW8Enb/5AzjoV5md+UAiERbF13SzrEx7J7riwb1ljHe82RqUbyfpnnDWT +aSZ/ce+9LYoYggFVfH7DloT8NNsQzTDi2/g+66dXi8fcIIECQQDqykIcF20sz8Ar +H3StlgITEorJiZRpvbzuQ6G7XoC1XOI1/0+1NbIHm3lv4XfvaRSdKv0mnPaZmi3h +PWZC/zj3AkEA26BE6iEGO3eJ39l1zRpB1jS/VrEAa7pzGeEFeL+k/5XEWqsgHOtQ +qIbPtCyKcN5mtCYEg6GEK/pqNALWL6ZwkQJATT9CRO/IMaggd4+f2cSy5geBthEX +zTppwJJr0bOj8QegPVfEp8AE1M/oQlESHqiZ6aPNKjkWQS8izSpgTMafvQJAILDT +cTInNlTNvfcldLkS0aqaTHIeSOrA1TpMUTPdgHmvd3t/VS6lm+AtLHlwxeokyW3b +QCibftxQUJuXfBI/MQJAAt2m8P0V+U/MFjNhYUd2jwJIFFh7AVYeSH26NxzQMgO0 +YNQAaRKxwuhDrxyVwezryzyBcVWKdfhCtgOK6U5mFw== +-----END RSA PRIVATE KEY----- diff --git a/containers/haproxy/keys/bagels.docker.com/bagels.docker.pem b/containers/haproxy/keys/bagels.docker.com/bagels.docker.pem new file mode 100644 index 0000000000..753429af60 --- /dev/null +++ b/containers/haproxy/keys/bagels.docker.com/bagels.docker.pem @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIICNTCCAZ4CCQDY33gN8y9BQzANBgkqhkiG9w0BAQUFADBfMQswCQYDVQQGEwJV +UzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xDzANBgNVBAoT +BkRvY2tlcjEaMBgGA1UEAxMRYmFnZWxzLmRvY2tlci5jb20wHhcNMTUwMTIxMDM1 +NTEzWhcNMTYwMTIxMDM1NTEzWjBfMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0Ex +FjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xDzANBgNVBAoTBkRvY2tlcjEaMBgGA1UE +AxMRYmFnZWxzLmRvY2tlci5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGB +AMluBCvOUrdFkFGCmpKBPduoZgYE/hNKnmX3Cqrn+FsodOBiin1lOs7+XX3EY078 +u5QIULNZ3j/LUSuxgHBS8RcVc3ljCkvwRURwVy6FWunahdTULLEq+qOByv6Hq2/W +itlzT2Rw6Tu29IThb7Mtxb1B6LoAorkWX/YEXankpVPnAgMBAAEwDQYJKoZIhvcN +AQEFBQADgYEAPdqZ2jLxOuZ52wucJN1DoOBUCWnCM5bfBHOU3wBqSPA/mT2Bw5Fo +evqqd+mRWizgmSkDM9NpO9cj9tpeidTrHsTutkqjQttIeNAtZm82sSWH7ul1N1du +4aDDKwAk4j9BYPUmYQFaSRKNtE/OpGVPxxK/ZBS8YeVT0knzTr/a9to= +-----END CERTIFICATE----- +-----BEGIN RSA PRIVATE KEY----- +MIICWwIBAAKBgQDJbgQrzlK3RZBRgpqSgT3bqGYGBP4TSp5l9wqq5/hbKHTgYop9 +ZTrO/l19xGNO/LuUCFCzWd4/y1ErsYBwUvEXFXN5YwpL8EVEcFcuhVrp2oXU1Cyx +Kvqjgcr+h6tv1orZc09kcOk7tvSE4W+zLcW9Qei6AKK5Fl/2BF2p5KVT5wIDAQAB +AoGAcA+Qqn5Cbkt5Gp+6Nr9IFqf8+mRUpY7hyIBDowkiljRPsXWg7loe+YFxqcJU +LWFVSenGW8Enb/5AzjoV5md+UAiERbF13SzrEx7J7riwb1ljHe82RqUbyfpnnDWT +aSZ/ce+9LYoYggFVfH7DloT8NNsQzTDi2/g+66dXi8fcIIECQQDqykIcF20sz8Ar +H3StlgITEorJiZRpvbzuQ6G7XoC1XOI1/0+1NbIHm3lv4XfvaRSdKv0mnPaZmi3h +PWZC/zj3AkEA26BE6iEGO3eJ39l1zRpB1jS/VrEAa7pzGeEFeL+k/5XEWqsgHOtQ +qIbPtCyKcN5mtCYEg6GEK/pqNALWL6ZwkQJATT9CRO/IMaggd4+f2cSy5geBthEX +zTppwJJr0bOj8QegPVfEp8AE1M/oQlESHqiZ6aPNKjkWQS8izSpgTMafvQJAILDT +cTInNlTNvfcldLkS0aqaTHIeSOrA1TpMUTPdgHmvd3t/VS6lm+AtLHlwxeokyW3b +QCibftxQUJuXfBI/MQJAAt2m8P0V+U/MFjNhYUd2jwJIFFh7AVYeSH26NxzQMgO0 +YNQAaRKxwuhDrxyVwezryzyBcVWKdfhCtgOK6U5mFw== +-----END RSA PRIVATE KEY----- diff --git a/containers/haproxy/run b/containers/haproxy/run new file mode 100755 index 0000000000..b26edf0b91 --- /dev/null +++ b/containers/haproxy/run @@ -0,0 +1,19 @@ +#!/bin/bash + +# match the ip address from a DOCKER_HOST which is set by boot2docker +# and docker-machine +if [[ $DOCKER_HOST =~ ([0-9]{1,3}[\.]){3}[0-9]{1,3} ]]; then + strresult=$BASH_REMATCH + echo $strresult +else + echo "unable to parse string $DOCKER_HOST" +fi + +sed -i s/{DOCKER_HOST}/"$strresult"/g /haproxy/haproxy.cfg + +# Check is haproxy.cfg is valid before we start +haproxy -c -f /haproxy/haproxy.cfg || ( echo 'Bad haproxy config'; exit; ) + +/usr/sbin/haproxy -f /haproxy/haproxy.cfg & + +wait $! diff --git a/containers/prod/build b/containers/prod/build new file mode 100755 index 0000000000..af29f19d61 --- /dev/null +++ b/containers/prod/build @@ -0,0 +1 @@ +docker build -t bagel/haproxy_beta ./haproxy \ No newline at end of file diff --git a/containers/prod/docker-compose.yml b/containers/prod/docker-compose.yml new file mode 100644 index 0000000000..838d385d12 --- /dev/null +++ b/containers/prod/docker-compose.yml @@ -0,0 +1,13 @@ +haproxy: + build: ./haproxy + ports: + - "80:80" + - "443:443" +hub: + build: bagelteam/hubtest + volumes: + - .:/opt/hub + ports: + - "7001:3000" + environment: + ENV: production \ No newline at end of file diff --git a/containers/prod/haproxy/Dockerfile b/containers/prod/haproxy/Dockerfile new file mode 100644 index 0000000000..12a913f9de --- /dev/null +++ b/containers/prod/haproxy/Dockerfile @@ -0,0 +1,9 @@ +FROM fish/haproxy + +ADD ./haproxy.cfg /haproxy/haproxy.cfg +ADD ./run /haproxy/run + +EXPOSE 80 443 + +ENTRYPOINT ["/haproxy/run"] + diff --git a/containers/prod/haproxy/haproxy.cfg b/containers/prod/haproxy/haproxy.cfg new file mode 100644 index 0000000000..72a144b20e --- /dev/null +++ b/containers/prod/haproxy/haproxy.cfg @@ -0,0 +1,46 @@ +global + chroot /var/lib/haproxy + user haproxy + group haproxy + +defaults + log global + mode http + option httplog + option dontlognull + timeout connect 5000 + timeout client 50000 + timeout server 50000 + errorfile 400 /etc/haproxy/errors/400.http + errorfile 403 /etc/haproxy/errors/403.http + errorfile 408 /etc/haproxy/errors/408.http + errorfile 500 /etc/haproxy/errors/500.http + errorfile 502 /etc/haproxy/errors/502.http + errorfile 503 /etc/haproxy/errors/503.http + errorfile 504 /etc/haproxy/errors/504.http + stats enable + stats auth haproxy:hapass + +userlist Bagels + user betalist insecure-password {BETA_PASSWORD} + +frontend https + bind :443 ssl crt /haproxy/keys/hub-beta.docker.com/hub-beta.docker.pem + acl is-ssl dst_port 443 + acl Auth_Bagels http_auth(Bagels) + http-request auth realm HubBeta if !Auth_Bagels + + http-request set-header X-Real-IP %ci + + reqadd X-Forwarded-Proto:\ https if is-ssl + reqadd X-Forwarded-Port:\ 443 if is-ssl + rspadd Strict-Transport-Security:\ max-age=31536000 if is-ssl + + acl is_hub_dev hdr(host) -i hub-beta.docker.com + + use_backend hub_dev if is_hub_dev + +backend hub_dev + balance leastconn + option httpclose + server docker-1 172.17.42.1:7001 check diff --git a/containers/prod/haproxy/keys/bagels.docker.com/bagels.docker.crt b/containers/prod/haproxy/keys/bagels.docker.com/bagels.docker.crt new file mode 100644 index 0000000000..44765408fb --- /dev/null +++ b/containers/prod/haproxy/keys/bagels.docker.com/bagels.docker.crt @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICNTCCAZ4CCQDY33gN8y9BQzANBgkqhkiG9w0BAQUFADBfMQswCQYDVQQGEwJV +UzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xDzANBgNVBAoT +BkRvY2tlcjEaMBgGA1UEAxMRYmFnZWxzLmRvY2tlci5jb20wHhcNMTUwMTIxMDM1 +NTEzWhcNMTYwMTIxMDM1NTEzWjBfMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0Ex +FjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xDzANBgNVBAoTBkRvY2tlcjEaMBgGA1UE +AxMRYmFnZWxzLmRvY2tlci5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGB +AMluBCvOUrdFkFGCmpKBPduoZgYE/hNKnmX3Cqrn+FsodOBiin1lOs7+XX3EY078 +u5QIULNZ3j/LUSuxgHBS8RcVc3ljCkvwRURwVy6FWunahdTULLEq+qOByv6Hq2/W +itlzT2Rw6Tu29IThb7Mtxb1B6LoAorkWX/YEXankpVPnAgMBAAEwDQYJKoZIhvcN +AQEFBQADgYEAPdqZ2jLxOuZ52wucJN1DoOBUCWnCM5bfBHOU3wBqSPA/mT2Bw5Fo +evqqd+mRWizgmSkDM9NpO9cj9tpeidTrHsTutkqjQttIeNAtZm82sSWH7ul1N1du +4aDDKwAk4j9BYPUmYQFaSRKNtE/OpGVPxxK/ZBS8YeVT0knzTr/a9to= +-----END CERTIFICATE----- diff --git a/containers/prod/haproxy/keys/bagels.docker.com/bagels.docker.csr b/containers/prod/haproxy/keys/bagels.docker.com/bagels.docker.csr new file mode 100644 index 0000000000..b9d96abb7e --- /dev/null +++ b/containers/prod/haproxy/keys/bagels.docker.com/bagels.docker.csr @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBnzCCAQgCAQAwXzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQH +Ew1TYW4gRnJhbmNpc2NvMQ8wDQYDVQQKEwZEb2NrZXIxGjAYBgNVBAMTEWJhZ2Vs +cy5kb2NrZXIuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDJbgQrzlK3 +RZBRgpqSgT3bqGYGBP4TSp5l9wqq5/hbKHTgYop9ZTrO/l19xGNO/LuUCFCzWd4/ +y1ErsYBwUvEXFXN5YwpL8EVEcFcuhVrp2oXU1CyxKvqjgcr+h6tv1orZc09kcOk7 +tvSE4W+zLcW9Qei6AKK5Fl/2BF2p5KVT5wIDAQABoAAwDQYJKoZIhvcNAQEFBQAD +gYEAlAQKhy4j7wenWqnKzfpp/o0cbzQAcve76XSwfWrzONFDZidhQlwAKBdYbYN3 +4ITqNw4MPSCMBkMMCQFFFHM/+NqlAmYYbJHv8uDxKel/7IsxIEPRun0b6k/+wL2e +2nyJJrMwesVrzvDwfB+8eoUOZFJIiX6htpxU4vgq9xMgMAg= +-----END CERTIFICATE REQUEST----- diff --git a/containers/prod/haproxy/keys/bagels.docker.com/bagels.docker.key b/containers/prod/haproxy/keys/bagels.docker.com/bagels.docker.key new file mode 100644 index 0000000000..6659dc144c --- /dev/null +++ b/containers/prod/haproxy/keys/bagels.docker.com/bagels.docker.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICWwIBAAKBgQDJbgQrzlK3RZBRgpqSgT3bqGYGBP4TSp5l9wqq5/hbKHTgYop9 +ZTrO/l19xGNO/LuUCFCzWd4/y1ErsYBwUvEXFXN5YwpL8EVEcFcuhVrp2oXU1Cyx +Kvqjgcr+h6tv1orZc09kcOk7tvSE4W+zLcW9Qei6AKK5Fl/2BF2p5KVT5wIDAQAB +AoGAcA+Qqn5Cbkt5Gp+6Nr9IFqf8+mRUpY7hyIBDowkiljRPsXWg7loe+YFxqcJU +LWFVSenGW8Enb/5AzjoV5md+UAiERbF13SzrEx7J7riwb1ljHe82RqUbyfpnnDWT +aSZ/ce+9LYoYggFVfH7DloT8NNsQzTDi2/g+66dXi8fcIIECQQDqykIcF20sz8Ar +H3StlgITEorJiZRpvbzuQ6G7XoC1XOI1/0+1NbIHm3lv4XfvaRSdKv0mnPaZmi3h +PWZC/zj3AkEA26BE6iEGO3eJ39l1zRpB1jS/VrEAa7pzGeEFeL+k/5XEWqsgHOtQ +qIbPtCyKcN5mtCYEg6GEK/pqNALWL6ZwkQJATT9CRO/IMaggd4+f2cSy5geBthEX +zTppwJJr0bOj8QegPVfEp8AE1M/oQlESHqiZ6aPNKjkWQS8izSpgTMafvQJAILDT +cTInNlTNvfcldLkS0aqaTHIeSOrA1TpMUTPdgHmvd3t/VS6lm+AtLHlwxeokyW3b +QCibftxQUJuXfBI/MQJAAt2m8P0V+U/MFjNhYUd2jwJIFFh7AVYeSH26NxzQMgO0 +YNQAaRKxwuhDrxyVwezryzyBcVWKdfhCtgOK6U5mFw== +-----END RSA PRIVATE KEY----- diff --git a/containers/prod/haproxy/keys/bagels.docker.com/bagels.docker.pem b/containers/prod/haproxy/keys/bagels.docker.com/bagels.docker.pem new file mode 100644 index 0000000000..753429af60 --- /dev/null +++ b/containers/prod/haproxy/keys/bagels.docker.com/bagels.docker.pem @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIICNTCCAZ4CCQDY33gN8y9BQzANBgkqhkiG9w0BAQUFADBfMQswCQYDVQQGEwJV +UzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xDzANBgNVBAoT +BkRvY2tlcjEaMBgGA1UEAxMRYmFnZWxzLmRvY2tlci5jb20wHhcNMTUwMTIxMDM1 +NTEzWhcNMTYwMTIxMDM1NTEzWjBfMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0Ex +FjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xDzANBgNVBAoTBkRvY2tlcjEaMBgGA1UE +AxMRYmFnZWxzLmRvY2tlci5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGB +AMluBCvOUrdFkFGCmpKBPduoZgYE/hNKnmX3Cqrn+FsodOBiin1lOs7+XX3EY078 +u5QIULNZ3j/LUSuxgHBS8RcVc3ljCkvwRURwVy6FWunahdTULLEq+qOByv6Hq2/W +itlzT2Rw6Tu29IThb7Mtxb1B6LoAorkWX/YEXankpVPnAgMBAAEwDQYJKoZIhvcN +AQEFBQADgYEAPdqZ2jLxOuZ52wucJN1DoOBUCWnCM5bfBHOU3wBqSPA/mT2Bw5Fo +evqqd+mRWizgmSkDM9NpO9cj9tpeidTrHsTutkqjQttIeNAtZm82sSWH7ul1N1du +4aDDKwAk4j9BYPUmYQFaSRKNtE/OpGVPxxK/ZBS8YeVT0knzTr/a9to= +-----END CERTIFICATE----- +-----BEGIN RSA PRIVATE KEY----- +MIICWwIBAAKBgQDJbgQrzlK3RZBRgpqSgT3bqGYGBP4TSp5l9wqq5/hbKHTgYop9 +ZTrO/l19xGNO/LuUCFCzWd4/y1ErsYBwUvEXFXN5YwpL8EVEcFcuhVrp2oXU1Cyx +Kvqjgcr+h6tv1orZc09kcOk7tvSE4W+zLcW9Qei6AKK5Fl/2BF2p5KVT5wIDAQAB +AoGAcA+Qqn5Cbkt5Gp+6Nr9IFqf8+mRUpY7hyIBDowkiljRPsXWg7loe+YFxqcJU +LWFVSenGW8Enb/5AzjoV5md+UAiERbF13SzrEx7J7riwb1ljHe82RqUbyfpnnDWT +aSZ/ce+9LYoYggFVfH7DloT8NNsQzTDi2/g+66dXi8fcIIECQQDqykIcF20sz8Ar +H3StlgITEorJiZRpvbzuQ6G7XoC1XOI1/0+1NbIHm3lv4XfvaRSdKv0mnPaZmi3h +PWZC/zj3AkEA26BE6iEGO3eJ39l1zRpB1jS/VrEAa7pzGeEFeL+k/5XEWqsgHOtQ +qIbPtCyKcN5mtCYEg6GEK/pqNALWL6ZwkQJATT9CRO/IMaggd4+f2cSy5geBthEX +zTppwJJr0bOj8QegPVfEp8AE1M/oQlESHqiZ6aPNKjkWQS8izSpgTMafvQJAILDT +cTInNlTNvfcldLkS0aqaTHIeSOrA1TpMUTPdgHmvd3t/VS6lm+AtLHlwxeokyW3b +QCibftxQUJuXfBI/MQJAAt2m8P0V+U/MFjNhYUd2jwJIFFh7AVYeSH26NxzQMgO0 +YNQAaRKxwuhDrxyVwezryzyBcVWKdfhCtgOK6U5mFw== +-----END RSA PRIVATE KEY----- diff --git a/containers/prod/haproxy/run b/containers/prod/haproxy/run new file mode 100755 index 0000000000..145832a4d5 --- /dev/null +++ b/containers/prod/haproxy/run @@ -0,0 +1,10 @@ +#!/bin/bash + +sed -i s/{BETA_PASSWORD}/"$BETA_PASSWORD"/g /haproxy/haproxy.cfg + +# Check is haproxy.cfg is valid before we start +haproxy -c -f /haproxy/haproxy.cfg || ( echo 'Bad haproxy config'; exit; ) + +/usr/sbin/haproxy -f /haproxy/haproxy.cfg & + +wait $! diff --git a/deployment/deploy.sh b/deployment/deploy.sh new file mode 100755 index 0000000000..9eded10710 --- /dev/null +++ b/deployment/deploy.sh @@ -0,0 +1,171 @@ +#!/bin/sh + +DOCKER_CMD=docker + +alias AWS_HUB_PROD='aws ec2 describe-instances --filters "Name=tag:aws:cloudformation:stack-name,Values=us-east-1*" "Name=tag:secondary-role,Values=hub" "Name=instance-state-name,Values=running" --output=json' +alias AWS_HUB_STAGE='aws ec2 describe-instances --filters "Name=tag:aws:cloudformation:stack-name,Values=stage-us-east-1*" "Name=tag:secondary-role,Values=hub" "Name=instance-state-name,Values=running" --output=json' +alias AWS_IP="jq -r '.Reservations[].Instances[].PrivateIpAddress'" + +HUB_GATEWAY="https://hub.docker.com" +HUB_SERVICE_NAME="hub-web-v2" + +DEFAULT_IMAGE_PROD="bagel/hub-prod" +DEFAULT_IMAGE_STAGE="bagel/hub-stage" + +NEW_RELIC_APP_NAME="hub.docker.com(aws-node)" +NEW_RELIC_LICENSE_KEY="582e3891446a63a3f99b4d32f9585ec74af1d8d7" + +NO_COLOR="\033[0m" +RED="\033[0;31m" +GREEN="\033[0;32m" +YELLOW="\033[0;33m" + +MESSAGE_MISSING_OR_INVALID_ARGS="${RED}Missing or invalid arguments${NO_COLOR}" + +# $1: prod or stage +getAWSHosts() { + if [ $1 == "prod" ]; then + echo $(AWS_HUB_PROD | ( AWS_IP ; echo ) | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n/:2376 /g') + elif [ $1 == "stage" ]; then + echo $(AWS_HUB_STAGE | ( AWS_IP ; echo ) | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n/:2376 /g') + fi +} + +# $1: Exit code +printUsageAndExit() { + echo + echo "Usage: deploy.sh [prod|stage|-h ] [IMAGE]" + echo + echo " prod A predefined list of hosts for production" + echo " stage A predefined list of hosts for staging" + echo " -h A single host address" + echo + exit $1 +} + +# $1: Image argument +parseImageArg() { + if [ -z "$1" ]; then + echo $MESSAGE_MISSING_OR_INVALID_ARGS + printUsageAndExit 1 + fi + IMAGE=$1 +} + +parseArgs() { + if [ $1 == "-h" ]; then + parseImageArg $3 + HOSTS=$2 + else + if [ $1 == "prod" ]; then + if [ -z "$2" ]; then + IMAGE=$DEFAULT_IMAGE_PROD + else + parseImageArg $2 + fi + HOSTS=$( getAWSHosts "prod" ) + elif [ $1 == "stage" ]; then + if [ -z "$2" ]; then + IMAGE=$DEFAULT_IMAGE_STAGE + else + parseImageArg $2 + fi + HOSTS=$( getAWSHosts "stage" ) + else + echo + echo $MESSAGE_MISSING_OR_INVALID_ARGS + printUsageAndExit 1 + fi + fi +} + +# $1: Host IP +# $2: Image +# $3: Container name +# $4: Container port +runContainer() { + $DOCKER_CMD --tlsverify=false -H tcp://$1 run \ + -de ENV=production \ + -e HUB_API_BASE_URL=$HUB_GATEWAY \ + -e REGISTRY_API_BASE_URL=$HUB_GATEWAY \ + -e SERVICE_NAME=$HUB_SERVICE_NAME \ + -e SERVICE_80_NAME=$HUB_SERVICE_NAME \ + -e NEW_RELIC_LICENSE_KEY=$NEW_RELIC_LICENSE_KEY \ + -e NEW_RELIC_APP_NAME=$NEW_RELIC_APP_NAME \ + -e PORT=80 \ + -p $4:80 \ + --restart "unless-stopped" \ + --name $3 \ + $2 +} + +# $1: Host IP +# $2: Container name +removeContainer() { + $DOCKER_CMD --tlsverify=false -H tcp://$1 stop $2 + $DOCKER_CMD --tlsverify=false -H tcp://$1 rm $2 +} + +# $1: Host IP +# $2: Image name +pullImage() { + $DOCKER_CMD --tlsverify=false -H tcp://$1 pull $2 +} + +# $1: Host IP +# $2: Image +deployHost() { + echo + echo "Starting to deploy ${YELLOW}$IMAGE${NO_COLOR} to ${YELLOW}$1${NO_COLOR}" + + pullImage $1 $2 + + removeContainer $1 "hub_2_0" + runContainer $1 $2 "hub_2_0" 6600 + + removeContainer $1 "hub_2_1" + runContainer $1 $2 "hub_2_1" 6601 + + removeContainer $1 "hub_2_2" + runContainer $1 $2 "hub_2_2" 6602 +} + +# Prerequisites: +# 1- AWS +type aws >/dev/null 2>&1 || { echo >&2 "AWS client is required. Make sure 'aws' command is available:\nhttp://docs.aws.amazon.com/cli/latest/userguide/installing.html"; exit 1; } +# 2- JQ +type jq >/dev/null 2>&1 || { echo >&2 "jq JSON processor is required. Make sure 'jq' command is available:\nbrew install jq"; exit 1; } + +# Case for no paremeters specified +if [ -z "$1" ] + then + echo + echo $MESSAGE_MISSING_OR_INVALID_ARGS + printUsageAndExit 1 +fi + +parseArgs "$@" + +echo +echo "Image: ${YELLOW}$IMAGE ${NO_COLOR}" +echo "Hosts: ${YELLOW}$HOSTS${NO_COLOR}" +echo +read -p "Do you want to proceed? [Y/n]" -s -n 1 KEY +echo +if [[ ! $KEY =~ ^[Yy]$ ]]; then + exit 1 +fi + +# Run deployment for each host +for HUB_HOST in $HOSTS +do + deployHost $HUB_HOST $IMAGE + echo + echo "Sleeping for 10 seconds to let the containers boot up..." + echo + sleep 10 +done + +echo +echo "${GREEN}All done!${NO_COLOR}" +echo diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..c1257d728f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +dnsmasq: + build: ./containers/dnsmasq + ports: + - "53:53/udp" + environment: + - DOCKER_HOST +haproxy: + build: ./containers/haproxy + environment: + - DOCKER_HOST + ports: + - "80:80" + - "443:443" +hub: + build: . + command: node --harmony ./server.js + working_dir: /opt/hub/app/.build + volumes: + - .:/opt/hub + - ./private-deps/docker-ux:/opt/node_modules/docker-ux + - ./private-deps/hub-js-sdk:/opt/node_modules/hub-js-sdk + ports: + - "7001:3000" + environment: + DEBUG: "hub:*" + HUB_API_BASE_URL: "https://hub-beta-stage.docker.com" + REGISTRY_API_BASE_URL: "https://hub-beta-stage.docker.com" + ENV: development diff --git a/dockerfiles/Dockerfile-node-alpine b/dockerfiles/Dockerfile-node-alpine new file mode 100644 index 0000000000..5bfe426dc3 --- /dev/null +++ b/dockerfiles/Dockerfile-node-alpine @@ -0,0 +1,25 @@ +FROM gliderlabs/alpine:3.2 + +ENV VERSION=v4.1.2 CMD=node DOMAIN=nodejs.org CFLAGS="-D__USE_MISC" +# ENV VERSION=v2.2.1 CMD=iojs DOMAIN=iojs.org NO_NPM_UPDATE=true + +# For base builds +ENV CONFIG_FLAGS="--without-npm" RM_DIRS=/usr/include +# ENV CONFIG_FLAGS="--fully-static --without-npm" DEL_PKGS="libgcc libstdc++" RM_DIRS=/usr/include + +RUN apk-install curl make gcc g++ python linux-headers paxctl libgcc libstdc++ && \ + curl -sSL https://${DOMAIN}/dist/${VERSION}/${CMD}-${VERSION}.tar.gz | tar -xz && \ + cd /${CMD}-${VERSION} && \ + ./configure --prefix=/usr ${CONFIG_FLAGS} && \ + make -j$(grep -c ^processor /proc/cpuinfo 2>/dev/null || 1) && \ + make install && \ + paxctl -cm /usr/bin/${CMD} && \ + cd / && \ + if [ -x /usr/bin/npm -a -z "$NO_NPM_UPDATE" ]; then \ + npm install -g npm && \ + find /usr/lib/node_modules/npm -name test -o -name .bin -type d | xargs rm -rf; \ + fi && \ + apk del curl make gcc g++ python linux-headers paxctl ${DEL_PKGS} && \ + rm -rf /etc/ssl /${CMD}-${VERSION} ${RM_DIRS} \ + /usr/share/man /tmp/* /root/.npm /root/.node-gyp \ + /usr/lib/node_modules/npm/man /usr/lib/node_modules/npm/doc /usr/lib/node_modules/npm/html diff --git a/dockerfiles/Dockerfile-prod-build b/dockerfiles/Dockerfile-prod-build new file mode 100644 index 0000000000..98c39b856f --- /dev/null +++ b/dockerfiles/Dockerfile-prod-build @@ -0,0 +1,22 @@ +FROM bagel/universe:337f873f4f23f4b2603972229ae3519c5f61f6d7 + +ENV ENV production +ENV NODE_ENV production + +COPY ./app /opt/hub/app +COPY ./Makefile /opt/hub/Makefile +COPY ./_webpack /opt/hub/_webpack +COPY ./gulpfile.js /opt/hub/gulpfile.js +COPY ./gulp-tasks /opt/hub/gulp-tasks +COPY ./app-server /opt/hub/app-server +COPY ./.eslintrc /opt/hub/.eslintrc + +RUN make server-prod-target +RUN make server-extras +RUN make js-prod +RUN make images-prod +RUN make docker-font-prod +RUN gulp images::prod +RUN make styles-base-prod +RUN make stats-dir +RUN make css-stats diff --git a/dockerfiles/Dockerfile-stage-build b/dockerfiles/Dockerfile-stage-build new file mode 100644 index 0000000000..113fe30cd3 --- /dev/null +++ b/dockerfiles/Dockerfile-stage-build @@ -0,0 +1,22 @@ +FROM bagel/universe:337f873f4f23f4b2603972229ae3519c5f61f6d7 + +ENV ENV staging +ENV NODE_ENV staging + +COPY ./app /opt/hub/app +COPY ./Makefile /opt/hub/Makefile +COPY ./_webpack /opt/hub/_webpack +COPY ./gulpfile.js /opt/hub/gulpfile.js +COPY ./gulp-tasks /opt/hub/gulp-tasks +COPY ./app-server /opt/hub/app-server +COPY ./.eslintrc /opt/hub/.eslintrc + +RUN make server-prod-target +RUN make server-extras +RUN make js-stage +RUN make images-prod +RUN make docker-font-prod +RUN gulp images::prod +RUN make styles-base-prod +RUN make stats-dir +RUN make css-stats diff --git a/dockerfiles/milky-way b/dockerfiles/milky-way new file mode 100644 index 0000000000..2dc5b9c516 --- /dev/null +++ b/dockerfiles/milky-way @@ -0,0 +1,10 @@ +FROM node:4.1.2 + +WORKDIR /opt/hub +ENV PATH /opt/hub/node_modules/.bin/:$PATH + +RUN apt-get update +COPY ./private-deps /opt/hub/private-deps +COPY ./package.json /opt/hub/ +ADD ./node_modules /opt/hub/node_modules +#RUN npm install --production diff --git a/dockerfiles/milky-way-no-bin b/dockerfiles/milky-way-no-bin new file mode 100644 index 0000000000..38a9898e44 --- /dev/null +++ b/dockerfiles/milky-way-no-bin @@ -0,0 +1,7 @@ +FROM bagel/milky-way:337f873f4f23f4b2603972229ae3519c5f61f6d7 + +RUN rm -rf /opt/hub/node_modules/.bin && \ + ls && \ + tar -czf modules.tar ./node_modules/* + +CMD ["cat", "/opt/hub/modules.tar"] diff --git a/dockerfiles/saas-config b/dockerfiles/saas-config new file mode 100644 index 0000000000..c8db476d11 --- /dev/null +++ b/dockerfiles/saas-config @@ -0,0 +1,29 @@ +FROM bagel/universe:337f873f4f23f4b2603972229ae3519c5f61f6d7 + +# Source +COPY ./app /opt/hub/app +# Webpack +COPY ./webpack.config.js /opt/hub/webpack.config.js +COPY ./_webpack /opt/hub/_webpack +# Make +COPY ./Makefile /opt/hub/Makefile +# Gulp +COPY ./gulpfile.js /opt/hub/gulpfile.js +COPY ./gulp-tasks /opt/hub/gulp-tasks +# ESLint +COPY ./.eslintrc /opt/hub/.eslintrc +# Flow +ENV LOGNAME bagels +COPY ./flow-libs /opt/hub/flow-libs +COPY .flowconfig /opt/hub/.flowconfig +ENV PATH /opt/flow/:$PATH + +RUN npm install +RUN DEBUG=* ENV=local webpack -d +RUN make server-target +RUN make styles-base +RUN gulp images::dev +RUN make images +RUN make docker-font-dev +# favicon +COPY ./app/favicon.ico /opt/hub/app/.build/ diff --git a/dockerfiles/universe b/dockerfiles/universe new file mode 100644 index 0000000000..638098c2c4 --- /dev/null +++ b/dockerfiles/universe @@ -0,0 +1,13 @@ +FROM bagel/milky-way:337f873f4f23f4b2603972229ae3519c5f61f6d7 + +ENV NODE_ENV development + +RUN apt-get install libelf-dev unzip -y + +RUN cd /opt && wget http://flowtype.org/downloads/flow-linux64-latest.zip && unzip flow-linux64-latest.zip && rm flow-linux64-latest.zip + +# npm global deps +RUN npm install -g gulp jest-cli + +# npm deps +RUN cd /opt/hub && npm install diff --git a/docs/Dockerfile b/docs/Dockerfile new file mode 100644 index 0000000000..ca1208d173 --- /dev/null +++ b/docs/Dockerfile @@ -0,0 +1,8 @@ +FROM docs/base:oss +MAINTAINER Docker Docs + +ENV PROJECT=docker-hub + +COPY . /src +RUN rm -rf /docs/content/$PROJECT/ +COPY . /docs/content/$PROJECT/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000000..d05c6a9cf9 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,119 @@ +.PHONY: all binary build cross default docs docs-build docs-shell shell test test-unit test-integration test-integration-cli test-docker-py validate + +# env vars passed through directly to Docker's build scripts +# to allow things like `make DOCKER_CLIENTONLY=1 binary` easily +# `docs/sources/contributing/devenvironment.md ` and `project/PACKAGERS.md` have some limited documentation of some of these +DOCKER_ENVS := \ + -e BUILDFLAGS \ + -e DOCKER_CLIENTONLY \ + -e DOCKER_EXECDRIVER \ + -e DOCKER_GRAPHDRIVER \ + -e TESTDIRS \ + -e TESTFLAGS \ + -e TIMEOUT +# note: we _cannot_ add "-e DOCKER_BUILDTAGS" here because even if it's unset in the shell, that would shadow the "ENV DOCKER_BUILDTAGS" set in our Dockerfile, which is very important for our official builds + +# to allow `make DOCSDIR=docs docs-shell` (to create a bind mount in docs) +DOCS_MOUNT := $(if $(DOCSDIR),-v $(CURDIR):/docs/content/docker-hub/) + +# to allow `make DOCSPORT=9000 docs` +DOCSPORT := 8000 + +# Get the IP ADDRESS +DOCKER_IP=$(shell python -c "import urlparse ; print urlparse.urlparse('$(DOCKER_HOST)').hostname or ''") +HUGO_BASE_URL=$(shell test -z "$(DOCKER_IP)" && echo localhost || echo "$(DOCKER_IP)") +HUGO_BIND_IP=0.0.0.0 + +GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null) +DOCKER_IMAGE := docker$(if $(GIT_BRANCH),:$(GIT_BRANCH)) +DOCKER_DOCS_IMAGE := docs-base$(if $(GIT_BRANCH),:$(GIT_BRANCH)) + + +DOCKER_RUN_DOCS := docker run --rm -it $(DOCS_MOUNT) -e AWS_S3_BUCKET -e NOCACHE + +# for some docs workarounds (see below in "docs-build" target) +GITCOMMIT := $(shell git rev-parse --short HEAD 2>/dev/null) + +default: docs + +test: docs-build + $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 \ + -v $(CURDIR):/docs/content/docker-hub/ \ + -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" \ + hugo server \ + --log=true --watch=true \ + --port=$(DOCSPORT) --baseUrl=$(HUGO_BASE_URL) --bind=$(HUGO_BIND_IP) + + +docs: docs-build + $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" hugo server --port=$(DOCSPORT) --baseUrl=$(HUGO_BASE_URL) --bind=$(HUGO_BIND_IP) + +docs-draft: docs-build + $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" hugo server --buildDrafts="true" --port=$(DOCSPORT) --baseUrl=$(HUGO_BASE_URL) --bind=$(HUGO_BIND_IP) + + +docs-shell: docs-build + $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 "$(DOCKER_DOCS_IMAGE)" bash + + +docs-build: +# ( git remote | grep -v upstream ) || git diff --name-status upstream/release..upstream/docs ./ > ./changed-files +# echo "$(GIT_BRANCH)" > GIT_BRANCH +# echo "$(AWS_S3_BUCKET)" > AWS_S3_BUCKET +# echo "$(GITCOMMIT)" > GITCOMMIT + docker build -t "$(DOCKER_DOCS_IMAGE)" . + +# use screenshot container to update screenshots +NOAUTHSCREENSHOT := docker run -it --rm --name screen -v $(CURDIR):/srv --env-file=noauthpasswords.env svendowideit/screenshot +SCREENSHOT := docker run -it --rm --name screen -v $(CURDIR):/srv --env-file=passwords.env svendowideit/screenshot +NOLINKS := docker run -it --rm --name screen -v $(CURDIR):/srv --env-file=passwords.env --env-file=nolinks.env svendowideit/screenshot +GITHUB_DOCSUSER := docker run -it --rm --name screen -v $(CURDIR):/srv --env-file=passwords.env --env-file=githubdocs.env svendowideit/screenshot + +# testing +testimage: + #$(NOAUTHSCREENSHOT) https://hub-beta.docker.com/ images/register-web.png 1280px + #$(SCREENSHOT) https://hub-beta.docker.com/explore/ images/dashboard.png 1280px + $(SCREENSHOT) https://hub-beta.docker.com/organizations/ orgs.png 1280px + +t2: + $(NOAUTHSCREENSHOT) https://hub-beta.docker.com/ images/register-web.png 1280px + +docs-images: + # non-authenticated + $(NOAUTHSCREENSHOT) https://hub-beta.docker.com/ images/register-web.png 1280px + $(NOAUTHSCREENSHOT) https://hub-beta.docker.com/login/ images/login-web.png 1280px + # authenticated + $(SCREENSHOT) https://hub-beta.docker.com/explore/ images/dashboard.png 1280px + $(SCREENSHOT) https://hub-beta.docker.com/organizations/ images/orgs.png 1280px + # $(SCREENSHOT) https://hub-beta.docker.com/ images/deploy_key.png 1280px + $(SCREENSHOT) https://hub-beta.docker.com/r/docsorg/private/~/settings/collaborators/ images/org-repo-collaborators.png 1280px + $(SCREENSHOT) https://hub-beta.docker.com/ images/repos.png 1280px + # $(SCREENSHOT) https://hub-beta.docker.com/ images/invite.png 1280px + # $(SCREENSHOT) https://hub-beta.docker.com/ images/build-trigger.png 1280px + $(SCREENSHOT) https://hub-beta.docker.com/ images/hub.png 1280px + $(SCREENSHOT) https://hub-beta.docker.com/u/docsorg/dashboard/teams/boomteam/ images/groups.png 1280px + $(SCREENSHOT) https://hub-beta.docker.com/r/library/busybox/tags/# images/busybox-image-tags.png 1280px*600px + # docs/images/getting-started.png (needs a new empty account) + +nolinks: + # bitbucket.md, github.md + # uses the `nolinks` user: an account that has no accounts linked + $(NOLINKS) https://hub-beta.docker.com/account/authorized-services/ images/authorized-services.png 1280px + $(NOLINKS) https://hub-beta.docker.com/account/authorized-services/github-permissions/ add-authorized-github-service.png 1280px + +# BROKEN +github: + $(GITHUB_DOCSUSER) https://github.com/docsuser/private/settings/hooks github-side-hook.png 1280px + +# BROKEN, wrong URL and needs hand editing to capture the specific UI elements +broken-gitimages: + $(SCREENSHOT) https://hub-beta.docker.com/ images/gh_settings.png + $(SCREENSHOT) https://hub-beta.docker.com/ images/gh_menu.png + $(SCREENSHOT) https://hub-beta.docker.com/ images/gh_add_ssh_user_key.png + $(SCREENSHOT) https://hub-beta.docker.com/ images/gh_team_members.png + $(SCREENSHOT) https://hub-beta.docker.com/ images/gh-check-user-org-dh-app-access.png + $(SCREENSHOT) https://hub-beta.docker.com/ images/gh_service_hook.png + $(SCREENSHOT) https://hub-beta.docker.com/ images/gh-check-admin-org-dh-app-access.png + $(SCREENSHOT) https://hub-beta.docker.com/ images/gh_org_members.png + $(SCREENSHOT) https://hub-beta.docker.com/ images/gh_docker-service.png + $(SCREENSHOT) https://hub-beta.docker.com/ images/gh_repo_deploy_key.png diff --git a/docs/accounts.md b/docs/accounts.md new file mode 100644 index 0000000000..f58acdef80 --- /dev/null +++ b/docs/accounts.md @@ -0,0 +1,57 @@ ++++ +title = "Your Docker ID" +description = "Your Docker ID" +keywords = ["Docker, docker, trusted, sign-up, registry, accounts, plans, Dockerfile, Docker Hub, docs, documentation"] +[menu.main] +parent="mn_pubhub" +weight=-90 ++++ + +# Your Docker ID + +You can `search` for Docker images and `pull` them from [Docker +Hub](https://hub.docker.com) without signing in or even having an +account. However, to `push` images, leave comments, or to *star* +a repository, you need a free [Docker ID](https://hub.docker.com) to log in to Docker Hub. + +Once you have a personal Docker ID, you can also create or join +Docker Hub [Organizations and Teams](orgs.md). + +## Register for a Docker ID + +If you're not already logged in, go to [Docker Hub](https://hub.docker.com) +to use the sign up page. +A valid email address is required to register. A verification email is sent to this address to activate your account. + +You cannot log in to your Docker ID until you verify the email address. + +#### Confirm your email + +Once you've filled in the registration form, check your email for a welcome message asking for +confirmation so we can activate your account. + +## Login + +After you complete the account creation process, you can log in any time using the web console with your Docker ID: + +![Login using the web console](images/login-web.png) + +Or via the command line with the `docker login` command: + + $ docker login + +Your Docker ID is now active and ready to use. + +> **Note:** +> Your authentication credentials will be stored in the `.dockercfg` +> authentication file in your home directory. + +### Upgrading your account + +Free Hub accounts include one private registry. If you need more private registries, you can [upgrade your account](https://hub.docker.com/account/billing-plans/) to a paid plan directly from the Hub. + +## Password reset process + +If you can't access your account for some reason, you can reset your password +from the [*Password Reset*](https://hub.docker.com/reset-password/) +page. diff --git a/docs/bitbucket.md b/docs/bitbucket.md new file mode 100644 index 0000000000..7bd6d8ec27 --- /dev/null +++ b/docs/bitbucket.md @@ -0,0 +1,50 @@ ++++ +title = "Automated Builds with Bitbucket" +description = "Docker Hub Automated Builds using Bitbucket" +keywords = ["Docker, docker, registry, accounts, plans, Dockerfile, Docker Hub, docs, documentation, trusted, builds, trusted builds, automated builds, bitbucket"] +[menu.main] +parent="mn_pubhub" +weight=8 ++++ + +# Automated Builds with Bitbucket + +If you've previously linked Docker Hub to your Bitbucket account, +you'll be able to skip to [Creating an Automated Build](#creating-an-automated-build). + +## Linking to your Bitbucket account + +In order to set up an Automated Build of a repository on Bitbucket, you need to +link your [Docker Hub](https://hub.docker.com/account/authorized-services/) +account to a Bitbucket account. This will allow the registry to see your Bitbucket +repositories. + +To add, remove or view your linked account, go to the "Linked Accounts & Services" +section of your Hub profile "Settings". + +![authorized-services](images/authorized-services.png) + +Then follow the onscreen instructions to authorize and link your +Bitbucket account to Docker Hub. Once it is linked, you'll be able +to create a Docker Hub repository from which to create the Automatic Build. + +## Creating an Automated Build + +You can [create an Automated Build]( +https://hub.docker.com/add/automated-build/bitbucket/orgs/) from any of your +public or private Bitbucket repositories with a `Dockerfile`. + +To get started, log in to Docker Hub and click the +"Create ▼" menu item at the top right of the screen. Then select +[Create Automated Build](https://hub.docker.com/add/automated-build). + +Select the the linked Bitbucket account, and then choose a repository to set up +an Automated Build for. + +## The Bitbucket webhook + +When you create an Automated Build in Docker Hub, a webhook is added to your Bitbucket repository automatically. + +You can also manually add a webhook from your repository's **Settings** page. Set the URL to `https://registry.hub.docker.com/hooks/bitbucket`, to be triggered for repository pushes. + +![bitbucket-hooks](images/bitbucket-hook.png) diff --git a/docs/builds.md b/docs/builds.md new file mode 100644 index 0000000000..199c437342 --- /dev/null +++ b/docs/builds.md @@ -0,0 +1,219 @@ ++++ +title = "Automated Builds" +description = "Docker Hub Automated Builds" +keywords = ["Dockerfile, Hub, builds, trusted builds, automated builds"] +[menu.main] +parent="mn_pubhub" +weight=6 ++++ + +# Automated Builds on Docker Hub + +You can build your images automatically from a build context stored in a repository. A *build context* is a Dockerfile and any files at a specific location. For an automated build, the build context is a repository containing a Dockerfile. + +Automated Builds have several advantages: + + * Images built in this way are built exactly as specified. + * The `Dockerfile` is available to anyone with access to +your Docker Hub repository. + * Your repository is kept up-to-date with code changes automatically. + +Automated Builds are supported for both public and private repositories +on both [GitHub](http://github.com) and [Bitbucket](https://bitbucket.org/). This document guides you through the process of working with automated builds. + +## Prerequisites + +To use automated builds you must have an [account on Docker Hub](accounts.md) and on the hosted repository provider (GitHub or Bitbucket). If +you have previously linked your Github or Bitbucket account, you must have +chosen the Public and Private connection type. To view your current connection +settings, log in to Docker Hub and choose Profile > Settings > Linked Accounts & Services. + + +## Link to a hosted repository service + +1. Log into Docker Hub. + +2. Navigate to Profile > Settings > Linked Accounts & Services. + +3. Click the service you want to link. + + The system prompts you to choose between Public and Private and Limited Access. The Public and Private connection type is required if you want to use the Automated Builds. + +4. Press Select under Public and Private connection type. + + The system prompts you to enter your service credentials (Bitbucket or GitHub) to login. For example, Bitbucket's prompt looks like this: + + ![Bitbucket](images/bitbucket_creds.png) + + After you grant access to your code repository, the system returns you to Docker Hub and the link is complete. + + ![Linked account](images/linked-acct.png) + +## Create an automated build + +Automated build repositories rely on the integration with your code repository +in order to build. However, you can also push already-built images to these +repositories using the `docker push` command. + +1. Select **Create** > **Create Automated Build** from Docker Hub. + + The system prompts you with a list of User/Organizations and code repositories. + +2. Select from the User/Organizations. + +3. Optionally, type to filter the repository list. + +4. Pick the project to build. + + The system displays the Create Automated Build dialog. + + ![Create dialog](images/create-dialog1.png) + + The dialog assumes some defaults which you can customize. By default, Docker + builds images for each branch in your repository. It assumes the Dockerfile + lives at the root of your source. When it builds an image, Docker tags it with + the branch name. + +6. Customize the automated build by pressing the Click here to customize this behavior link. + + ![Create dialog](images/create-dialog.png) + + Specify which code branches or tags to build from. You can add new + configurations by clicking the + (plus sign). The dialog accepts regular + expressions. + + ![Create dialog](images/regex-help.png) + +9. Click Create. + + The system displays the home page for your AUTOMATED BUILD. + + ![Home page](images/home-page.png) + + Within GitHub, a Docker integration appears in your repositories Settings > Webhooks & services page. + + ![GitHub](images/docker-integration.png) + + A similar page appears in Bitbucket if you use that code repository.Be + careful to leave the Docker integration in place. Removing it causes your + automated builds to stop. + +### Understand the build process + +The first time you create a new automated build, Docker Hub builds your image. +In a few minutes, you should see your new build on the image dashboard. The +Build Details page shows a log of your build systems: + +![Pending](images/first_pending.png) + +During the build process, Docker copies the contents of your `Dockerfile` to +Docker Hub. The Docker community (for public repositories) or approved team +members/orgs (for private repositories) can then view the Dockerfile on your +repository page. + +The build process looks for a `README.md` in the same directory as your +`Dockerfile`. If you have a `README.md` file in your repository, it is used in +the repository as the full description. If you change the full description after +a build, it's overwritten the next time the Automated Build runs. To make +changes, modify the `README.md` in your Git repository. + +You can only trigger one build at a time and no more than one every five +minutes. If you already have a build pending, or if you recently submitted a +build request, Docker ignores new requests. + +### Build statuses explained + +Check your build status through the Build Details screen as seen in the following example. + +![Build statuses](images/build-states-ex.png) + +The statuses are: + +* **Queued**: You're in line and your image will be built soon. Queue time varies depending on number of concurrent builds available to you. +* **Building**: Your image is currently being constructed. +* **Success**: The image has been built with no issues. +* **Error**: There was an issue with your image. Click the row to access the Builds Details screen. The banner at the top of the page displays the last sentence of the log file indicating what the error was. If you need more information, scroll to the bottom of the screen to the logs section. + + +## Use the Build Settings page + +The Build Settings page allows you to manage your existing automated build configurations and add new ones. By default, when new code is merged into your source repository, it triggers a build of your DockerHub image. + +![Default checkbox](images/merge_builds.png) + +Clear the checkbox to turn this behavior off. You can use the other settings on +the page to configure and build images. + +## Add and run a new build + +At the top of the Build Dialog is a list of configured builds. You can build from a code branch or by build tag. + +![Build or tag](images/build-by.png) + +Docker builds everything listed whenever a push is made to the code repository. +If you specify a branch or tag, you can manually build that image by +pressing the Trigger. If you use a regular expression syntax (regex) to define +your build branch or tag, Docker does not give you the option to manually build. +To add a new build: + +1. Press the + (plus sign). + +2. Choose the Type. + + You can build by a code branch or by an image tag. + +3. Enter the Name of the branch or tag. + + You can enter a specific value or use a regex to select multiple values. To + see examples of regex, press the Show More link on the right of the page. + + ![Regexhelp](images/regex-help.png) + +4. Enter a Dockerfile location. + +5. Specify a Tag Name. + +6. Press Save Changes. + +If you make a mistake or want to delete a build, press the - (minus sign) and then Save Changes. + +## Repository links + +Repository links let you link one Automated Build with another. If one Automated +Build gets updated, Docker triggers a build of the other. This makes it easy to +ensure that related images are kept in sync. You can link more than one image +repository. You only need to link one side of two related builds. Linking both +sides causes an endless build loop. + +To add a link: + +1. Go to the Build Settings for an automated build repository. + +2. In the Repository Links section, enter an image repository name. + + A remote repository name should be either an official repository name such as `ubuntu` or a public repository name `namespace/repoName`. + +3. Press Add. + + ![Links](images/repo_links.png) + + +## Remote Build triggers + +To trigger Automated Builds programmatically, you can set up a remote build +trigger in another application such as GitHub or Bitbucket. When you Activate +the build trigger for an Automated Build, it supplies you with a Token and a URL. + +![Build trigger screen](images/build-trigger.png) + +You can use `curl` to trigger a build: + +```bash +$ curl --data build=true -X POST https://registry.hub.docker.com/u/svendowideit/testhook/trigger/be579c +82-7c0e-11e4-81c4-0242ac110020/ +OK +``` + +To verify everything is working correctly, check the **Last 10 Trigger Logs** on the page. + +  diff --git a/docs/github.md b/docs/github.md new file mode 100644 index 0000000000..797c37cc9c --- /dev/null +++ b/docs/github.md @@ -0,0 +1,204 @@ ++++ +title = "Automated Builds from GitHub" +description = "Docker Hub Automated Builds with GitHub" +keywords = ["Docker, docker, registry, accounts, plans, Dockerfile, Docker Hub, docs, documentation, trusted, builds, trusted builds, automated builds, GitHub"] +[menu.main] +parent="mn_pubhub" +weight=9 ++++ + +# Automated Builds from GitHub + +If you've previously linked Docker Hub to your GitHub account, +you'll be able to skip to [Creating an Automated Build](#creating-an-automated-build). + +## Linking Docker Hub to a GitHub account + +> *Note:* +> Automated Builds currently require *read* and *write* access since +> [Docker Hub](https://hub.docker.com) needs to set up a GitHub service +> hook. We have no choice here, this is how GitHub manages permissions. +> We do guarantee nothing else will be touched in your account. + +In order to set up an Automated Build of a repository on GitHub, you need to +link [Docker Hub](https://hub.docker.com/account/authorized-services/) to your GitHub account. This will allow the registry to see your GitHub +repositories. + +To add, remove or view your linked account, go to the "Linked Accounts & Services" section of your Hub profile "Settings". + +![authorized-services](images/authorized-services.png) + +When linking to GitHub, you'll need to select either "Public and Private", +or "Limited Access" linking. + +![add-authorized-github-service.png](images/add-authorized-github-service.png) + +The "Public and Private" option is the easiest to use, +as it grants the Docker Hub full access to all of your repositories. GitHub +also allows you to grant access to repositories belonging to your GitHub +organizations. + +If you choose "Limited Access", Docker Hub only gets permission +to access your public data and public repositories. + +Follow the onscreen instructions to authorize and link your +GitHub account to Docker Hub. Once it is linked, you'll be able to +choose a source repository from which to create the Automatic Build. + +You will be able to review and revoke Docker Hub's access by visiting the +[GitHub User's Applications settings](https://github.com/settings/applications). + +> **Note**: If you delete the GitHub account linkage that is used for one of your +> automated build repositories, the previously built images will still be available. +> If you re-link to that GitHub account later, the automated build can be started +> using the "Start Build" button on the Hub, or if the webhook on the GitHub repository +> still exists, it will be triggered by any subsequent commits. + +## Auto builds and limited linked GitHub accounts. + +If you selected to link your GitHub account with only a "Limited Access" link, then +after creating your automated build, you will need to either manually trigger a +Docker Hub build using the "Start a Build" button, or add the GitHub webhook +manually, as described in [GitHub Service Hooks](#github-service-hooks). + +## Changing the GitHub user link + +If you want to remove, or change the level of linking between your GitHub account +and the Docker Hub, you need to do this in two places. + +First, remove the "Linked Account" from your Docker Hub "Settings". +Then go to your GitHub account's Personal settings, and in the "Applications" +section, "Revoke access". + +You can now re-link your account at any time. + +## GitHub organizations + +GitHub organizations and private repositories forked from organizations will be +made available to auto build using the "Docker Hub Registry" application, which +needs to be added to the organization - and then will apply to all users. + +To check, or request access, go to your GitHub user's "Setting" page, select the +"Applications" section from the left side bar, then click the "View" button for +"Docker Hub Registry". + +![Check User access to GitHub](images/gh-check-user-org-dh-app-access.png) + +The organization's administrators may need to go to the Organization's "Third +party access" screen in "Settings" to grant or deny access to the Docker Hub +Registry application. This change will apply to all organization members. + +![Check Docker Hub application access to Organization](images/gh-check-admin-org-dh-app-access.png) + +More detailed access controls to specific users and GitHub repositories can be +managed using the GitHub "People and Teams" interfaces. + +## Creating an Automated Build + +You can [create an Automated Build]( +https://hub.docker.com/add/automated-build/github/) from any of your +public or private GitHub repositories that have a `Dockerfile`. + +Once you've selected the source repository, you can then configure: + +- The Hub user/org namespace the repository is built to - either your Docker ID name, or the name of any Hub organizations your account is in +- The Docker repository name the image is built to +- The description of the repository +- If the visibility of the Docker repository: "Public" or "Private" + You can change the accessibility options after the repository has been created. + If you add a Private repository to a Hub user namespace, then you can only add other users + as collaborators, and those users will be able to view and pull all images in that + repository. To configure more granular access permissions, such as using teams of + users or allow different users access to different image tags, then you need + to add the Private repository to a Hub organization for which your user has Administrator + privileges. +- Enable or disable rebuilding the Docker image when a commit is pushed to the + GitHub repository. + +You can also select one or more: +- The git branch/tag, +- A repository sub-directory to use as the context, +- The Docker image tag name + +You can modify the description for the repository by clicking the "Description" section +of the repository view. +Note that the "Full Description" will be over-written by the README.md file when the +next build is triggered. + +## GitHub private submodules + +If your GitHub repository contains links to private submodules, you'll get an +error message in your build. + +Normally, the Docker Hub sets up a deploy key in your GitHub repository. +Unfortunately, GitHub only allows a repository deploy key to access a single repository. + +To work around this, you can create a dedicated user account in GitHub and attach +the automated build's deploy key that account. This dedicated build account +can be limited to read-only access to just the repositories required to build. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    StepScreenshotDescription
    1.First, create the new account in GitHub. It should be given read-only + access to the main repository and all submodules that are needed.
    2.This can be accomplished by adding the account to a read-only team in + the organization(s) where the main GitHub repository and all submodule + repositories are kept.
    3.Next, remove the deploy key from the main GitHub repository. This can be done in the GitHub repository's "Deploy keys" Settings section.
    4.Your automated build's deploy key is in the "Build Details" menu + under "Deploy keys".
    5.In your dedicated GitHub User account, add the deploy key from your + Docker Hub Automated Build.
    + +## GitHub service hooks + +A GitHub Service hook allows GitHub to notify the Docker Hub when something has +been committed to a given git repository. + +When you create an Automated Build from a GitHub user that has full "Public and +Private" linking, a Service Hook should get automatically added to your GitHub +repository. + +If your GitHub account link to the Docker Hub is "Limited Access", then you will +need to add the Service Hook manually. + +To add, confirm, or modify the service hook, log in to GitHub, then navigate to +the repository, click "Settings" (the gear), then select "Webhooks & Services". +You must have Administrator privileges on the repository to view or modfy +this setting. + +The image below shows the "Docker" Service Hook. + +![bitbucket-hooks](images/github-side-hook.png) + +If you add the "Docker" service manually, make sure the "Active" checkbox is +selected and click the "Update service" button to save your changes. diff --git a/docs/images/add-authorized-github-service.png b/docs/images/add-authorized-github-service.png new file mode 100644 index 0000000000..a4fd351713 Binary files /dev/null and b/docs/images/add-authorized-github-service.png differ diff --git a/docs/images/authorized-services.png b/docs/images/authorized-services.png new file mode 100644 index 0000000000..ccae6a7256 Binary files /dev/null and b/docs/images/authorized-services.png differ diff --git a/docs/images/bitbucket-hook.png b/docs/images/bitbucket-hook.png new file mode 100644 index 0000000000..3fd37708d8 Binary files /dev/null and b/docs/images/bitbucket-hook.png differ diff --git a/docs/images/bitbucket_creds.png b/docs/images/bitbucket_creds.png new file mode 100644 index 0000000000..b24e185268 Binary files /dev/null and b/docs/images/bitbucket_creds.png differ diff --git a/docs/images/build-by.png b/docs/images/build-by.png new file mode 100644 index 0000000000..d1071da272 Binary files /dev/null and b/docs/images/build-by.png differ diff --git a/docs/images/build-states-ex.png b/docs/images/build-states-ex.png new file mode 100644 index 0000000000..8f068ddd4d Binary files /dev/null and b/docs/images/build-states-ex.png differ diff --git a/docs/images/build-trigger.png b/docs/images/build-trigger.png new file mode 100644 index 0000000000..8f034608ae Binary files /dev/null and b/docs/images/build-trigger.png differ diff --git a/docs/images/busybox-image-tags.png b/docs/images/busybox-image-tags.png new file mode 100644 index 0000000000..c3b07adb5e Binary files /dev/null and b/docs/images/busybox-image-tags.png differ diff --git a/docs/images/create-dialog.png b/docs/images/create-dialog.png new file mode 100644 index 0000000000..1a4bddaf9b Binary files /dev/null and b/docs/images/create-dialog.png differ diff --git a/docs/images/create-dialog1.png b/docs/images/create-dialog1.png new file mode 100644 index 0000000000..c14e099f25 Binary files /dev/null and b/docs/images/create-dialog1.png differ diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png new file mode 100644 index 0000000000..038be4cbba Binary files /dev/null and b/docs/images/dashboard.png differ diff --git a/docs/images/deploy_key.png b/docs/images/deploy_key.png new file mode 100644 index 0000000000..f1d8d92d22 Binary files /dev/null and b/docs/images/deploy_key.png differ diff --git a/docs/images/docker-integration.png b/docs/images/docker-integration.png new file mode 100644 index 0000000000..362e27ac09 Binary files /dev/null and b/docs/images/docker-integration.png differ diff --git a/docs/images/first_pending.png b/docs/images/first_pending.png new file mode 100644 index 0000000000..9deaeeea49 Binary files /dev/null and b/docs/images/first_pending.png differ diff --git a/docs/images/getting-started.png b/docs/images/getting-started.png new file mode 100644 index 0000000000..59f242d797 Binary files /dev/null and b/docs/images/getting-started.png differ diff --git a/docs/images/gh-check-admin-org-dh-app-access.png b/docs/images/gh-check-admin-org-dh-app-access.png new file mode 100644 index 0000000000..0df38c6946 Binary files /dev/null and b/docs/images/gh-check-admin-org-dh-app-access.png differ diff --git a/docs/images/gh-check-user-org-dh-app-access.png b/docs/images/gh-check-user-org-dh-app-access.png new file mode 100644 index 0000000000..13ad6468f6 Binary files /dev/null and b/docs/images/gh-check-user-org-dh-app-access.png differ diff --git a/docs/images/gh_add_ssh_user_key.png b/docs/images/gh_add_ssh_user_key.png new file mode 100644 index 0000000000..7d0092170f Binary files /dev/null and b/docs/images/gh_add_ssh_user_key.png differ diff --git a/docs/images/gh_docker-service.png b/docs/images/gh_docker-service.png new file mode 100644 index 0000000000..7a84c81b7e Binary files /dev/null and b/docs/images/gh_docker-service.png differ diff --git a/docs/images/gh_menu.png b/docs/images/gh_menu.png new file mode 100644 index 0000000000..84458a445f Binary files /dev/null and b/docs/images/gh_menu.png differ diff --git a/docs/images/gh_org_members.png b/docs/images/gh_org_members.png new file mode 100644 index 0000000000..465f5da565 Binary files /dev/null and b/docs/images/gh_org_members.png differ diff --git a/docs/images/gh_repo_deploy_key.png b/docs/images/gh_repo_deploy_key.png new file mode 100644 index 0000000000..983b5eec77 Binary files /dev/null and b/docs/images/gh_repo_deploy_key.png differ diff --git a/docs/images/gh_service_hook.png b/docs/images/gh_service_hook.png new file mode 100644 index 0000000000..c344c24afc Binary files /dev/null and b/docs/images/gh_service_hook.png differ diff --git a/docs/images/gh_settings.png b/docs/images/gh_settings.png new file mode 100644 index 0000000000..2af9cb5138 Binary files /dev/null and b/docs/images/gh_settings.png differ diff --git a/docs/images/gh_team_members.png b/docs/images/gh_team_members.png new file mode 100644 index 0000000000..3bdf4abd95 Binary files /dev/null and b/docs/images/gh_team_members.png differ diff --git a/docs/images/github-side-hook.png b/docs/images/github-side-hook.png new file mode 100644 index 0000000000..c742b4080a Binary files /dev/null and b/docs/images/github-side-hook.png differ diff --git a/docs/images/groups.png b/docs/images/groups.png new file mode 100644 index 0000000000..b725b48ba9 Binary files /dev/null and b/docs/images/groups.png differ diff --git a/docs/images/home-page.png b/docs/images/home-page.png new file mode 100644 index 0000000000..e9c66cec9a Binary files /dev/null and b/docs/images/home-page.png differ diff --git a/docs/images/hub.png b/docs/images/hub.png new file mode 100644 index 0000000000..959f961ae5 Binary files /dev/null and b/docs/images/hub.png differ diff --git a/docs/images/invite.png b/docs/images/invite.png new file mode 100644 index 0000000000..f663340443 Binary files /dev/null and b/docs/images/invite.png differ diff --git a/docs/images/linked-acct.png b/docs/images/linked-acct.png new file mode 100644 index 0000000000..340733602c Binary files /dev/null and b/docs/images/linked-acct.png differ diff --git a/docs/images/login-web.png b/docs/images/login-web.png new file mode 100644 index 0000000000..64e29c9014 Binary files /dev/null and b/docs/images/login-web.png differ diff --git a/docs/images/merge_builds.png b/docs/images/merge_builds.png new file mode 100644 index 0000000000..589bba9325 Binary files /dev/null and b/docs/images/merge_builds.png differ diff --git a/docs/images/org-repo-collaborators.png b/docs/images/org-repo-collaborators.png new file mode 100644 index 0000000000..3d80a1aa66 Binary files /dev/null and b/docs/images/org-repo-collaborators.png differ diff --git a/docs/images/orgs.png b/docs/images/orgs.png new file mode 100644 index 0000000000..fe1b89b31c Binary files /dev/null and b/docs/images/orgs.png differ diff --git a/docs/images/plus-carrot.png b/docs/images/plus-carrot.png new file mode 100644 index 0000000000..8c4cd37ded Binary files /dev/null and b/docs/images/plus-carrot.png differ diff --git a/docs/images/prompt.png b/docs/images/prompt.png new file mode 100644 index 0000000000..a94ccf08c9 Binary files /dev/null and b/docs/images/prompt.png differ diff --git a/docs/images/regex-help.png b/docs/images/regex-help.png new file mode 100644 index 0000000000..ad404de476 Binary files /dev/null and b/docs/images/regex-help.png differ diff --git a/docs/images/register-web.png b/docs/images/register-web.png new file mode 100644 index 0000000000..ea95e1f50b Binary files /dev/null and b/docs/images/register-web.png differ diff --git a/docs/images/repo_links.png b/docs/images/repo_links.png new file mode 100644 index 0000000000..09a4bd63c1 Binary files /dev/null and b/docs/images/repo_links.png differ diff --git a/docs/images/repos.png b/docs/images/repos.png new file mode 100644 index 0000000000..959f961ae5 Binary files /dev/null and b/docs/images/repos.png differ diff --git a/docs/images/scan-drilldown.gif b/docs/images/scan-drilldown.gif new file mode 100644 index 0000000000..e74acc162e Binary files /dev/null and b/docs/images/scan-drilldown.gif differ diff --git a/docs/images/scan-results.png b/docs/images/scan-results.png new file mode 100644 index 0000000000..608674fee3 Binary files /dev/null and b/docs/images/scan-results.png differ diff --git a/docs/images/scan-tags.png b/docs/images/scan-tags.png new file mode 100644 index 0000000000..ec2de8baad Binary files /dev/null and b/docs/images/scan-tags.png differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000000..5a51c99055 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,92 @@ ++++ +title = "Overview of Docker Hub" +description = "Docker Hub overview" +keywords = ["Docker, docker, registry, accounts, plans, Dockerfile, Docker Hub, docs, documentation, accounts, organizations, repositories, groups, teams"] +aliases = "/docker-hub/overview/" +[menu.main] +parent="mn_pubhub" +weight=-99 + ++++ + +# Overview of Docker Hub + +[Docker Hub](https://hub.docker.com) is a cloud-based registry service which +allows you to link to code repositories, build your images and test them, stores +manually pushed images, and links to [Docker Cloud](https://docs.docker.com/docker-cloud/) so you can deploy images to your +hosts. It provides a centralized resource for container image discovery, +distribution and change management, [user and team collaboration](orgs.md), and +workflow automation throughout the development pipeline. + +Log in to Docker Hub and Docker Cloud using [your free Docker ID](accounts.md). + +![Getting started with Docker Hub](./images/getting-started.png) + +Docker Hub provides the following major features: + +* [Image Repositories](repos.md): Find, manage, and push and pull images from community, official, and private image libraries. +* [Automated Builds](builds.md): Automatically create new images when you make changes to a source code repository. +* [Webhooks](webhooks.md): A feature of Automated Builds, Webhooks let you trigger actions after a successful push to a repository. +* [Organizations](orgs.md): Create work groups to manage access to image repositories. +* GitHub and Bitbucket Integration: Add the Hub and your Docker Images to your current workflows. + + +## Create a Docker ID + +To explore Docker Hub, you'll need to create an account by following the +directions in [Your Docker ID](accounts.md). + +> **Note**: You can search for and pull Docker images from Hub without logging in, however to push images you must log in. + +Your Docker ID gives you one private Docker Hub repository for free. If you need +more private repositories, you can upgrade from your free account to a paid +plan. To learn more, log in to Docker Hub and go to [Billing & Plans](https://hub.docker.com/account/billing-plans/), in the Settings menu. + +### Explore repositories + +You can find public repositories and images from Docker Hub in two ways. +You can "Search" from the Docker Hub website, or you can use the Docker command line tool to run the `docker search` command. For example if you were looking for an ubuntu image, you might run the following command line search: + +``` + $ docker search ubuntu +``` + +Both methods list the available public repositories on Docker Hub which match +the search term. + +Private repositories do not appear in the repository search results. To see all +the repositories you can access and their status, view your "Dashboard" page on +[Docker Hub](https://hub.docker.com). + + +You can find more information on working with Docker images in the [Docker userguide](https://docs.docker.com/userguide/dockerimages/). + +### Use Official Repositories + +Docker Hub contains a number of [Official +Repositories](http://hub.docker.com/explore/). These are public, certified +repositories from vendors and contributors to Docker. They contain Docker images +from vendors like Canonical, Oracle, and Red Hat that you can use as the basis +to building your applications and services. + +With Official Repositories you know you're using an optimized and +up-to-date image that was built by experts to power your applications. + +> **Note:** If you would like to contribute an Official Repository for your organization or product, see the documentation on [Official Repositories on Docker Hub](official_repos.md) for more information. + + +## Work with Docker Hub image repositories + +Docker Hub provides a place for you and your team to build and ship Docker images. + +You can configure Docker Hub repositories in two ways: + +* [Repositories](repos.md), which allow you to push images from a local Docker daemon to Docker Hub, and +* [Automated Builds](builds.md), which link to a source code repository and trigger an image rebuild process on Docker Hub when changes are detected in the source code. + +You can create public repositories which can be accessed by any other Hub user, or you can create private repositories with limited access you control. + +### Docker commands and Docker Hub + +Docker itself provides access to Docker Hub services via the [`docker search`](http://docs.docker.com/reference/commandline/search), +[`pull`](http://docs.docker.com/reference/commandline/pull), [`login`](http://docs.docker.com/reference/commandline/login), and [`push`](http://docs.docker.com/reference/commandline/push) commands. diff --git a/docs/menu.md b/docs/menu.md new file mode 100644 index 0000000000..1a89dc09ae --- /dev/null +++ b/docs/menu.md @@ -0,0 +1,14 @@ + + +# Menu topic + +If you can view this content, please raise a bug report. diff --git a/docs/official_repos.md b/docs/official_repos.md new file mode 100644 index 0000000000..56b5689a7f --- /dev/null +++ b/docs/official_repos.md @@ -0,0 +1,125 @@ ++++ +title = "Official Repositories on Docker Hub" +description = "Guidelines for Official Repositories on Docker Hub" +keywords = ["Docker, docker, registry, accounts, plans, Dockerfile, Docker Hub, docs, official, image, documentation"] +[menu.main] +parent="mn_pubhub" +weight=15 ++++ + +# Official Repositories on Docker Hub + +The Docker [Official Repositories](https://hub.docker.com/official/) are a +curated set of Docker repositories that are promoted on Docker Hub. They are designed to: + +* Provide essential base OS repositories (for example, + [ubuntu](https://hub.docker.com/_/ubuntu/), + [centos](https://hub.docker.com/_/centos/)) that serve as the + starting point for the majority of users. + +* Provide drop-in solutions for popular programming language runtimes, data + stores, and other services, similar to what a Platform-as-a-Service (PAAS) + would offer. + +* Exemplify [`Dockerfile` best practices](https://docs.docker.com/articles/dockerfile_best-practices) + and provide clear documentation to serve as a reference for other `Dockerfile` + authors. + +* Ensure that security updates are applied in a timely manner. This is + particularly important as many Official Repositories are some of the most + popular on Docker Hub. + +* Provide a channel for software vendors to redistribute up-to-date and + supported versions of their products. Organization accounts on Docker Hub can + also serve this purpose, without the careful review or restrictions on what + can be published. + +Docker, Inc. sponsors a dedicated team that is responsible for reviewing and +publishing all Official Repositories content. This team works in collaboration +with upstream software maintainers, security experts, and the broader Docker +community. + +While it is preferrable to have upstream software authors maintaining their +corresponding Official Repositories, this is not a strict requirement. Creating +and maintaining images for Official Repositories is a public process. It takes +place openly on GitHub where participation is encouraged. Anyone can provide +feedback, contribute code, suggest process changes, or even propose a new +Official Repository. + +## Should I use Official Repositories? + +New Docker users are encouraged to use the Official Repositories in their +projects. These repositories have clear documentation, promote best practices, +and are designed for the most common use cases. Advanced users are encouraged to +review the Official Repositories as part of their `Dockerfile` learning process. + +A common rationale for diverging from Official Repositories is to optimize for +image size. For instance, many of the programming language stack images contain +a complete build toolchain to support installation of modules that depend on +optimized code. An advanced user could build a custom image with just the +necessary pre-compiled libraries to save space. + +A number of language stacks such as +[python](https://hub.docker.com/_/python/) and +[ruby](https://hub.docker.com/_/ruby/) have `-slim` tag variants +designed to fill the need for optimization. Even when these "slim" variants are +insufficient, it is still recommended to inherit from an Official Repository +base OS image to leverage the ongoing maintenance work, rather than duplicating +these efforts. + +## How do I know the Official Repositories are secure? + +Docker provides a preview version of Docker Cloud's [Security Scanning service](http://docs.docker.com/docker-cloud/builds/image-scan/) for all of the +Official Repositories located on Docker Hub. These security scan results provide +valuable information about which images contain security vulnerabilities, which +you should use to help you choose secure components for your own projects. + +To view the Docker Security Scanning results: + +1. Make sure you're logged in to Docker Hub. + You can view Official Images even while logged out, however the scan results are only available once you log in. +2. Navigate to the official repository whose security scan you want to view. +3. Click the `Tags` tab to see a list of tags and their security scan summaries. + ![](images/scan-drilldown.gif) + +You can click into a tag's detail page to see more information about which +layers in the image and which components within the layer are vulnerable. +Details including a link to the official CVE report for the vulnerability appear +when you click an individual vulnerable component. + +## How can I get involved? + +All Official Repositories contain a **User Feedback** section in their +documentation which covers the details for that specific repository. In most +cases, the GitHub repository which contains the Dockerfiles for an Official +Repository also has an active issue tracker. General feedback and support +questions should be directed to `#docker-library` on Freenode IRC. + +## How do I create a new Official Repository? + +From a high level, an Official Repository starts out as a proposal in the form +of a set of GitHub pull requests. You'll find detailed and objective proposal +requirements in the following GitHub repositories: + +* [docker-library/official-images](https://github.com/docker-library/official-images) + +* [docker-library/docs](https://github.com/docker-library/docs) + +The Official Repositories team, with help from community contributors, formally +review each proposal and provide feedback to the author. This initial review +process may require a bit of back and forth before the proposal is accepted. + +There are also subjective considerations during the review process. These +subjective concerns boil down to the basic question: "is this image generally +useful?" For example, the [python](https://hub.docker.com/_/python/) +Official Repository is "generally useful" to the large Python developer +community, whereas an obscure text adventure game written in Python last week is +not. + +Once a new proposal is accepted, the author is responsibile for keeping +their images up-to-date and responding to user feedback. The Official +Repositories team becomes responsibile for publishing the images and +documentation on Docker Hub. Updates to the Official Repository follow the same +pull request process, though with less review. The Official Repositories team +ultimately acts as a gatekeeper for all changes, which helps mitigate the risk +of quality and security issues from being introduced. diff --git a/docs/orgs.md b/docs/orgs.md new file mode 100644 index 0000000000..d14f5a1dce --- /dev/null +++ b/docs/orgs.md @@ -0,0 +1,53 @@ ++++ +title = "Teams & Organizations" +description = "Docker Hub Teams and Organizations" +keywords = ["Docker, docker, registry, teams, organizations, plans, Dockerfile, Docker Hub, docs, documentation"] +[menu.main] +parent="mn_pubhub" +weight=-80 ++++ + +# Organizations and teams + +Docker Hub [organizations](https://hub.docker.com/organizations/) let you +create teams so you can give colleagues access to shared image repositories. +A Docker Hub organization can contain public and private repositories just like +a user account. +Access to push or pull for these repositories is allocated by defining teams of users and then assigning team rights to specific repositories. Repository +creation is limited to users in the organization owner's group. This allows you +to distribute limited access Docker images, and to select which Docker Hub users +can publish new images. + +### Creating and viewing organizations + +You can see which organizations you belong to and add new organizations by clicking "Organizations" in the top nav bar. + +![organizations](images/orgs.png) + +### Organization teams + +Users in the "Owners" team of an organization can create and modify the +membership of all teams. + +Other users can only see teams they belong to. + +![teams](images/groups.png) + +### Repository team permissions + +Use teams to manage who can interact with your repositories. + +You need to be a member of the organization's "Owners" team to create a new team, +Hub repository, or automated build. As an "Owner", you then delegate the following +repository access rights to a team using the "Collaborators" section of the repository view: + +- `Read` access allows a user to view, search, and pull a private repository in the same way as they can a public repository. +- `Write` access users are able to push to non-automated repositories on the Docker Hub. +- `Admin` access allows the user to modify the repositories "Description", "Collaborators" rights, + "Public/Private" visibility and "Delete". + +> **Note**: A User who has not yet verified their email address will only have +> `Read` access to the repository, regardless of the rights their team +> membership has given them. + +![Organization repository collaborators](images/org-repo-collaborators.png) diff --git a/docs/repos.md b/docs/repos.md new file mode 100644 index 0000000000..7f47b83cf1 --- /dev/null +++ b/docs/repos.md @@ -0,0 +1,270 @@ ++++ +title = "Repositories on Docker Hub" +description = "Your Repositories on Docker Hub" +keywords = ["Docker, docker, trusted, registry, accounts, plans, Dockerfile, Docker Hub, webhooks, docs, documentation"] +[menu.main] +parent="mn_pubhub" +weight=5 ++++ + +# Your Hub repositories + +Docker Hub repositories let you share images with co-workers, +customers, or the Docker community at large. If you're building your images internally, +either on your own Docker daemon, or using your own Continuous integration services, +you can push them to a Docker Hub repository that you add to your Docker Hub user or +organization account. + +Alternatively, if the source code for your Docker image is on GitHub or Bitbucket, +you can use an "Automated build" repository, which is built by the Docker Hub +services. See the [automated builds documentation](builds.md) to read about +the extra functionality provided by those services. + +![repositories](images/repos.png) + +## Searching for images + +You can search the [Docker Hub](https://hub.docker.com) registry via its search +interface or by using the command line interface. Searching can find images by image +name, user name, or description: + + $ docker search centos + NAME DESCRIPTION STARS OFFICIAL AUTOMATED + centos The official build of CentOS. 1034 [OK] + ansible/centos7-ansible Ansible on Centos7 43 [OK] + tutum/centos Centos image with SSH access. For the root... 13 [OK] + ... + +There you can see two example results: `centos` and `ansible/centos7-ansible`. The second +result shows that it comes from the public repository of a user, named +`ansible/`, while the first result, `centos`, doesn't explicitly list a +repository which means that it comes from the top-level namespace for +[Official Repositories](official_repos.md). The `/` character separates +a user's repository from the image name. + +Once you've found the image you want, you can download it with `docker pull `: + + $ docker pull centos + latest: Pulling from centos + 6941bfcbbfca: Pull complete + 41459f052977: Pull complete + fd44297e2ddb: Already exists + centos:latest: The image you are pulling has been verified. Important: image verification is a tech preview feature and should not be relied on to provide security. + Digest: sha256:d601d3b928eb2954653c59e65862aabb31edefa868bd5148a41fa45004c12288 + Status: Downloaded newer image for centos:latest + +You now have an image from which you can run containers. + +## Viewing repository tags + +Docker Hub's repository "Tags" view shows you the available tags and the size +of the associated image. + +Image sizes are the cumulative space taken up by the image and all +its parent images. This is also the disk space used by the contents of the +Tar file created when you `docker save` an image. + +![images/busybox-image-tags.png](images/busybox-image-tags.png) + +## Creating a new repository on Docker Hub + +When you first create a Docker Hub user, you will have a "Get started with Docker Hub." +screen, from which you can click directly into "Create Repository". +You can also use the "Create ▼" menu to "Create Repository". + +When creating a new repository, you can choose to put it in your Docker ID namespace, or that of any [organization](orgs.md) that you +are in the "Owners" team. +The Repository Name will need to be unique in that namespace, can be two to 255 characters, +and can only contain lowercase letters, numbers or `-` and `_`. + +The "Short Description" of 100 characters will be used in the search results, while the +"Full Description" can be used as the Readme for the repository, and can use Markdown to +add simple formatting. + +After you hit the "Create" button, you then need to `docker push` images to that Hub based +repository. + + + +## Pushing a repository image to Docker Hub + +In order to push a repository to the Docker Hub, you need to +name your local image using your Docker Hub username, and the +repository name that you created in the previous step. +You can add multiple images to a repository, by adding a specific `:` to +it (for example `docs/base:testing`). If its not specified, the tag defaults to +`latest`. +You can name your local images either when you build it, using +`docker build -t /[:]`, +by re-tagging an existing local image `docker tag /[:]`, +or by using `docker commit /[:]` to commit +changes. +See [Working with Docker images](https://docs.docker.com/userguide/dockerimages) for a detailed description. + +Now you can push this repository to the registry designated by its name or tag. + + $ docker push /: + +The image will then be uploaded and available for use by your team-mates and/or the +community. + + +## Stars + +Your repositories can be starred and you can star repositories in +return. Stars are a way to show that you like a repository. They are +also an easy way of bookmarking your favorites. + +## Comments + +You can interact with other members of the Docker community and maintainers by +leaving comments on repositories. If you find any comments that are not +appropriate, you can flag them for review. + +## Collaborators and their role + +A collaborator is someone you want to give access to a private +repository. Once designated, they can `push` and `pull` to your +repositories. They will not be allowed to perform any administrative +tasks such as deleting the repository or changing its status from +private to public. + +> **Note:** +> A collaborator cannot add other collaborators. Only the owner of +> the repository has administrative access. + +You can also assign more granular collaborator rights ("Read", "Write", or "Admin") +on Docker Hub by using organizations and teams. For more information +see the [organizations documentation](orgs.md). + +## Private repositories + +Private repositories allow you to have repositories that contain images +that you want to keep private, either to your own account or within an +organization or team. + +To work with a private repository on [Docker +Hub](https://hub.docker.com), you will need to add one via the [Add +Repository](https://hub.docker.com/add/repository/) +button. You get one private repository for free with your Docker Hub +user account (not usable for organizations you're a member of). If +you need more accounts you can upgrade your [Docker +Hub](https://hub.docker.com/account/billing-plans/) plan. + +Once the private repository is created, you can `push` and `pull` images +to and from it using Docker. + +> *Note:* You need to be signed in and have access to work with a +> private repository. + +Private repositories are just like public ones. However, it isn't +possible to browse them or search their content on the public registry. +They do not get cached the same way as a public repository either. + +It is possible to give access to a private repository to those whom you +designate (i.e., collaborators) from its "Settings" page. From there, you +can also switch repository status (*public* to *private*, or +vice-versa). You will need to have an available private repository slot +open before you can do such a switch. If you don't have any available, +you can always upgrade your [Docker +Hub](https://hub.docker.com/account/billing-plans/) plan. + +## Webhooks + +A webhook is an HTTP call-back triggered by a specific event. +You can use a Hub repository webhook to notify people, services, and other +applications after a new image is pushed to your repository (this also happens +for Automated builds). For example, you can trigger an automated test or +deployment to happen as soon as the image is available. + +To get started adding webhooks, go to the desired repository in the Hub, +and click "Webhooks" under the "Settings" box. +A webhook is called only after a successful `push` is +made. The webhook calls are HTTP POST requests with a JSON payload +similar to the example shown below. + +*Example webhook JSON payload:* + +```json +{ + "callback_url": "https://registry.hub.docker.com/u/svendowideit/busybox/hook/2141bc0cdec4hebec411i4c1g40242eg110020/", + "push_data": { + "images": [ + "27d47432a69bca5f2700e4dff7de0388ed65f9d3fb1ec645e2bc24c223dc1cc3", + "51a9c7c1f8bb2fa19bcd09789a34e63f35abb80044bc10196e304f6634cc582c", + "..." + ], + "pushed_at": 1.417566822e+09, + "pusher": "svendowideit" + }, + "repository": { + "comment_count": 0, + "date_created": 1.417566665e+09, + "description": "", + "full_description": "webhook triggered from a 'docker push'", + "is_official": false, + "is_private": false, + "is_trusted": false, + "name": "busybox", + "namespace": "svendowideit", + "owner": "svendowideit", + "repo_name": "svendowideit/busybox", + "repo_url": "https://registry.hub.docker.com/u/svendowideit/busybox/", + "star_count": 0, + "status": "Active" + } +} +``` + + + +>**Note:** If you want to test your webhook, we recommend using a tool like +>[requestb.in](http://requestb.in/). Also note, the Docker Hub server can't be +>filtered by IP address. + +### Webhook chains + +Webhook chains allow you to chain calls to multiple services. For example, +you can use this to trigger a deployment of your container only after +it has been successfully tested, then update a separate Changelog once the +deployment is complete. +After clicking the "Add webhook" button, simply add as many URLs as necessary +in your chain. + +The first webhook in a chain will be called after a successful push. Subsequent +URLs will be contacted after the callback has been validated. + +### Validating a callback + +In order to validate a callback in a webhook chain, you need to + +1. Retrieve the `callback_url` value in the request's JSON payload. +1. Send a POST request to this URL containing a valid JSON body. + +> **Note**: A chain request will only be considered complete once the last +> callback has been validated. + +To help you debug or simply view the results of your webhook(s), +view the "History" of the webhook available on its settings page. + +#### Callback JSON data + +The following parameters are recognized in callback data: + +* `state` (required): Accepted values are `success`, `failure` and `error`. + If the state isn't `success`, the webhook chain will be interrupted. +* `description`: A string containing miscellaneous information that will be + available on the Docker Hub. Maximum 255 characters. +* `context`: A string containing the context of the operation. Can be retrieved + from the Docker Hub. Maximum 100 characters. +* `target_url`: The URL where the results of the operation can be found. Can be + retrieved on the Docker Hub. + +*Example callback payload:* + + { + "state": "success", + "description": "387 tests PASSED", + "context": "Continuous integration by Acme CI", + "target_url": "http://ci.acme.com/results/afd339c1c3d27" + } diff --git a/docs/s3_website.json b/docs/s3_website.json new file mode 100644 index 0000000000..96eea7318e --- /dev/null +++ b/docs/s3_website.json @@ -0,0 +1,8 @@ +{ + "ErrorDocument": { + "Key": "jsearch/index.html" + }, + "IndexDocument": { + "Suffix": "index.html" + } +} diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000000..a50206e3f6 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,50 @@ ++++ +title = "Webhooks for automated builds" +description = "Docker Hub Automated Builds" +keywords = ["Docker, webhookds, hub, builds"] +[menu.main] +parent="mn_pubhub" +weight=7 ++++ + +# Webhooks for automated builds + +If you have an automated build repository in Docker Hub, you can use Webhooks to cause an action in another application in response to an event in the repository. Docker Hub webhooks fire when an image is built in, or a new tag added to, your automated build repository. + +With your webhook, you specify a target URL and a JSON payload to deliver. The example webhook below generates an HTTP POST that delivers a JSON payload: + +```json +{ + "callback_url": "https://registry.hub.docker.com/u/svendowideit/testhook/hook/2141b5bi5i5b02bec211i4eeih0242eg11000a/", + "push_data": { + "images": [ + "27d47432a69bca5f2700e4dff7de0388ed65f9d3fb1ec645e2bc24c223dc1cc3", + "51a9c7c1f8bb2fa19bcd09789a34e63f35abb80044bc10196e304f6634cc582c", + "..." + ], + "pushed_at": 1.417566161e+09, + "pusher": "trustedbuilder" + }, + "repository": { + "comment_count": "0", + "date_created": 1.417494799e+09, + "description": "", + "dockerfile": "#\n# BUILD\u0009\u0009docker build -t svendowideit/apt-cacher .\n# RUN\u0009\u0009docker run -d -p 3142:3142 -name apt-cacher-run apt-cacher\n#\n# and then you can run containers with:\n# \u0009\u0009docker run -t -i -rm -e http_proxy http://192.168.1.2:3142/ debian bash\n#\nFROM\u0009\u0009ubuntu\nMAINTAINER\u0009SvenDowideit@home.org.au\n\n\nVOLUME\u0009\u0009[\/var/cache/apt-cacher-ng\]\nRUN\u0009\u0009apt-get update ; apt-get install -yq apt-cacher-ng\n\nEXPOSE \u0009\u00093142\nCMD\u0009\u0009chmod 777 /var/cache/apt-cacher-ng ; /etc/init.d/apt-cacher-ng start ; tail -f /var/log/apt-cacher-ng/*\n, + full_description: Docker Hub based automated build from a GitHub repo", + "is_official": false, + "is_private": true, + "is_trusted": true, + "name": "testhook", + "namespace": "svendowideit", + "owner": "svendowideit", + "repo_name": "svendowideit/testhook", + "repo_url": "https://registry.hub.docker.com/u/svendowideit/testhook/", + "star_count": 0, + "status": "Active" + } +} +``` + +>**Note:** If you want to test your webhook, we recommend using a tool like +>[requestb.in](http://requestb.in/). Also note, the Docker Hub server can't be +>filtered by IP address. diff --git a/fabfile.py b/fabfile.py new file mode 100644 index 0000000000..80d2a1aedc --- /dev/null +++ b/fabfile.py @@ -0,0 +1,14 @@ +from fabric.api import run + +def start_project(email="none", user="none", auth="none", beta_password="maybejustnotsomeemptyspaceyea?", sha="latest", new_relic_key="", new_relic_app_name="hub-stage-node"): + run('docker rm $(docker ps -a -q) > /dev/null 2>&1 || :') + run('docker rmi $(docker images -q) > /dev/null 2>&1 || :') + run('cd /home/') + run('docker login -e %s -u %s -p %s' % (email, user, auth)) + run('docker pull bagel/hub-prod:%s' % sha) + run('docker pull bagel/haproxy_beta:latest') + run("docker ps | awk '{if($1 != \"CONTAINER\"){print $1}}' | xargs -r docker kill") + # We should tag the image with the git commit and deploy that instead of "latest" + run('docker run -dp 7001:3000 -e ENV=production --restart=on-failure:5 -e HUB_API_BASE_URL=https://hub-beta-stage.docker.com -e REGISTRY_API_BASE_URL=https://hub-beta-stage.docker.com -e NEW_RELIC_LICENSE_KEY=%s -e NEW_RELIC_APP_NAME=%s bagel/hub-prod:%s' % (new_relic_key, new_relic_app_name, sha)) + # HAProxy doesn't change a lot. We should check the image names before killing/rebooting + run('docker run -dp 80:80 -p 443:443 -e BETA_PASSWORD=%s --restart=on-failure:5 -v /opt/haproxy.pem:/haproxy/keys/hub-beta.docker.com/hub-beta.docker.pem bagel/haproxy_beta:latest' % beta_password) diff --git a/flow-libs/async.js b/flow-libs/async.js new file mode 100644 index 0000000000..2d9f0cccef --- /dev/null +++ b/flow-libs/async.js @@ -0,0 +1,12 @@ +type AsyncCallback = (err: ?Object, results: ?any) => void; +type ParallelFuncs = (callback: AsyncCallback) => void; + +declare module 'async' { + declare function parallel(tasks: Array | Object, + callback: AsyncCallback): void + declare function series(tasks: Array, + callback: AsyncCallback): void + declare function each(arr: Array, + func: Function, + callback: Function): void +} \ No newline at end of file diff --git a/flow-libs/debug.js b/flow-libs/debug.js new file mode 100644 index 0000000000..4807c40f8a --- /dev/null +++ b/flow-libs/debug.js @@ -0,0 +1,5 @@ +type DebugFunction = (thing: any) => void; + +declare module 'debug' { + declare function exports(string: string): DebugFunction; +} \ No newline at end of file diff --git a/flow-libs/fluxible.js b/flow-libs/fluxible.js new file mode 100644 index 0000000000..3ad2f4e95f --- /dev/null +++ b/flow-libs/fluxible.js @@ -0,0 +1,4 @@ +export type FluxibleActionContext = { + dispatch(eventName: string, + payload: any): void; +} diff --git a/flow-libs/hub-js-sdk.js b/flow-libs/hub-js-sdk.js new file mode 100644 index 0000000000..79da45ff99 --- /dev/null +++ b/flow-libs/hub-js-sdk.js @@ -0,0 +1,74 @@ +type SuperAgentCallback = (err: any, + res: any) => void; + +type JWT = String; +type ChangePasswordData = { + username: String; + oldpassword: String; + newpassword: String +} + +declare module 'hub-js-sdk' { + declare var Auth: { + getToken(username: string, + password: string, + cb: SuperAgentCallback): void; + } + declare var Repositories: { + createRepository(jwt: JWT, + repository: any, + cb: SuperAgentCallback): void; + getReposForUser(jwt: JWT, + username: String, + cb: SuperAgentCallback): void + } + declare var Emails: { + getEmailSubscriptions(JWT: JWT, + user: String, + cb: SuperAgentCallback): void; + unsubscribeEmails(JWT: JWT, + user: String, + data: Object, + cb: SuperAgentCallback): void; + subscribeEmails(JWT:JWT, + user: String, + data: Object, + cb: SuperAgentCallback): void; + getEmailsJWT(JWT:JWT, + cb:SuperAgentCallback): void; + getEmailsForUser(JWT: JWT, + user: String, + cb: SuperAgentCallback): void; + deleteEmailByID(JWT: JWT, + id: String, + cb: SuperAgentCallback): void; + updateEmailByID(JWT: JWT, + id: String, + data: Object, + cb: SuperAgentCallback): void; + addEmailsForUser(JWT: JWT, + user: Object, + email: string, + cb: SuperAgentCallback): void; + } +} + +declare module 'hub-js-sdk/src/Hub/SDK/Users' { + declare function changePassword(JWT: JWT, + data: ChangePasswordData, + cb: SuperAgentCallback): void; + declare function getUser(JWT: JWT, + user: String, + cb: SuperAgentCallback): void; +} + +declare module 'hub-js-sdk/src/Hub/SDK/Auth' { + declare function getToken(username: String, + password: String, + cb: SuperAgentCallback): void; +} + +declare module 'hub-js-sdk/src/Hub/SDK/Notifications' { + declare function getActivityFeed(JWT: JWT, + cb: SuperAgentCallback): void; +} diff --git a/flow-libs/lodash.js b/flow-libs/lodash.js new file mode 100644 index 0000000000..a047b9e1b5 --- /dev/null +++ b/flow-libs/lodash.js @@ -0,0 +1,5 @@ +declare module 'lodash' { + declare function sortByOrder(arr: Array, + properties: Array, + sortOrder: Array): Array; +} \ No newline at end of file diff --git a/gulp-tasks/img.js b/gulp-tasks/img.js new file mode 100644 index 0000000000..524d9a86f6 --- /dev/null +++ b/gulp-tasks/img.js @@ -0,0 +1,24 @@ +var gulp = require('gulp'); +var imagemin = require('gulp-imagemin'); +var pngquant = require('imagemin-pngquant'); + +//Hub2 Images for dev & production (There is a separate task for docker-ux images) +gulp.task('images::dev', function () { + return gulp.src('app/img/**') + .pipe(imagemin({ + progressive: true, + svgoPlugins: [{removeViewBox: false}], + use: [pngquant({ quality: '65-80', speed: 4 })] + })) + .pipe(gulp.dest('app/.build/public/img')); +}); + +gulp.task('images::prod', function() { + return gulp.src('app/img/**') + .pipe(imagemin({ + progressive: true, + svgoPlugins: [{removeViewBox: false}], + use: [pngquant({ quality: '65-80', speed: 4 })] + })) + .pipe(gulp.dest('.tmp/server/build/img')); +}); diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000000..05734a32b9 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,3 @@ +'use strict'; +require('./gulp-tasks/img'); + diff --git a/local.Dockerfile b/local.Dockerfile new file mode 100644 index 0000000000..34163d2fd2 --- /dev/null +++ b/local.Dockerfile @@ -0,0 +1,22 @@ +FROM bagel/universe:337f873f4f23f4b2603972229ae3519c5f61f6d7 + +ENV ENV local +ENV NODE_ENV local + +COPY ./app /opt/hub/app +COPY ./Makefile /opt/hub/Makefile +COPY ./_webpack /opt/hub/_webpack +COPY ./gulpfile.js /opt/hub/gulpfile.js +COPY ./gulp-tasks /opt/hub/gulp-tasks +COPY ./app-server /opt/hub/app-server +COPY ./.eslintrc /opt/hub/.eslintrc + +RUN make server-prod-target +RUN make server-extras +RUN make js-local +RUN make images-prod +RUN make docker-font-prod +RUN gulp images::prod +RUN make styles-base-prod +RUN make stats-dir +RUN make css-stats diff --git a/package.json b/package.json new file mode 100644 index 0000000000..fe9abf952a --- /dev/null +++ b/package.json @@ -0,0 +1,114 @@ +{ + "name": "docker-2.0", + "version": "0.0.1", + "private": true, + "scripts": { + "test": "jest", + "build:dev": "DEBUG=* webpack -dw" + }, + "jest": { + "rootDir": "./app/scripts", + "scriptPreprocessor": "../../node_modules/babel-jest", + "testFileExtensions": [ + "js" + ], + "moduleFileExtensions": [ + "jsx", + "js", + "json" + ], + "modulePathIgnorePatterns": [ + "/node_modules/" + ], + "unmockedModulePathPatterns": [ + "react" + ] + }, + "dependencies": { + "@dux/element-button": "0.0.3", + "@dux/element-card": "0.0.7", + "@dux/element-markdown": "0.0.8", + "@dux/hub-sdk": "^0.1.1", + "async": "^1.3.0", + "babel": "^5.6.14", + "babel-core": "^5.6.14", + "babel-runtime": "^5.6.18", + "body-parser": "^1.12.2", + "bugsnag": "^1.7.0", + "classnames": "^2.1.2", + "cookie": "^0.2.3", + "cookie-parser": "^1.3.4", + "csurf": "^1.8.0", + "debug": "^2.1.3", + "dux": "file:./private-deps/docker-ux", + "express": "^4.12.3", + "express-state": "^1.2.0", + "fluxible": "^1.0.3", + "fluxible-addons-react": "^0.2.0", + "highlight.js": "^9.0.0", + "history": "^1.17.0", + "hub-js-sdk": "file:./private-deps/hub-js-sdk", + "immutable": "^3.7.6", + "keymirror": "^0.1.1", + "lodash": "^3.6.0", + "marked": "^0.3.3", + "md5": "^2.0.0", + "moment": "^2.10.3", + "newrelic": "christopherbiscardi/node-newrelic#c4ccca3764acafaf9c5899e4a1abece828e1f7b8", + "normalizr": "^1.4.0", + "numeral": "^1.5.3", + "rc-tooltip": "^3.3.0", + "react": "^0.14.7", + "react-document-title": "^2.0.2", + "react-dom": "^0.14.3", + "react-router": "^1.0.0", + "react-select": "^1.0.0-beta6", + "recurly-js": "git://github.com/recurly/recurly-js#d9740eb3ee416fb999635daecfb524a492dbb058", + "redux": "^3.0.5", + "redux-logger": "^2.3.2", + "redux-ui": "0.0.8", + "remarkable": "^1.6.0", + "reselect": "^2.0.1", + "serialize-javascript": "^1.0.0", + "serve-favicon": "^2.2.0", + "superagent": "^1.1.0", + "svg-inline-react": "^0.3.1", + "velocity-animate": "^1.2.3", + "velocity-react": "1.1.3" + }, + "devDependencies": { + "babel-eslint": "^4.0.0", + "babel-jest": "^5.0.1", + "babel-loader": "^5.0.0", + "css-loader": "^0.23.0", + "cssnano": "^3.2.0", + "cssstats": "^1.10.0", + "eslint": "^1.2.1", + "eslint-loader": "^1.0.0", + "extract-text-webpack-plugin": "^0.9.1", + "gulp": "^3.8.11", + "gulp-imagemin": "^2.2.1", + "imagemin-pngquant": "^4.0.0", + "json-loader": "^0.5.2", + "lost": "^6.6.2", + "nodemon": "^1.3.7", + "postcss-browser-reporter": "^0.4.0", + "postcss-constants": "^0.1.1", + "postcss-cssnext": "^2.1.0", + "postcss-cssstats": "^1.0.0", + "postcss-each": "^0.7.0", + "postcss-import": "^7.0.0", + "postcss-loader": "^0.8.0", + "postcss-nested": "^1.0.0", + "postcss-url": "^5.0.1", + "react-redux": "^4.0.3", + "redux": "^3.0.5", + "reselect": "^2.0.1", + "style-loader": "^0.13.0", + "svg-inline-loader": "^0.4.0", + "webpack": "^1.10" + }, + "engines": { + "node": ">=4.0.0" + } +} diff --git a/pr-docs/css.md b/pr-docs/css.md new file mode 100644 index 0000000000..f019c08e6c --- /dev/null +++ b/pr-docs/css.md @@ -0,0 +1,62 @@ +[Demo](https://css-modules.github.io/webpack-demo/) + +The approach is thus: Use Foundation as a "browser reset" stylesheet, +then put everything that isn't a foundation `_settings.scss` variable +in CSSModule sidecar files. This links our javascript modules with our +css and increases the ease with which we can create a module library. + +# Implementation + +## File Structure + +``` +app/scripts/ +|-- ScopedSelectors.js +|-- ScopedSelectors.css +``` + +## Usage + +```javascript +import styles from './ScopedSelectors.css'; + +import React, { Component } from 'react'; + +export default class ScopedSelectors extends Component { + + render() { + return ( +
    +

    Scoped Selectors

    +
    + ); + } + +}; +``` + +```css +.root { + border-width: 2px; + border-style: solid; + border-color: #777; + padding: 0 20px; + margin: 0 6px; + max-width: 400px; +} + +.text { + color: #777; + font-size: 24px; + font-family: helvetica, arial, sans-serif; + font-weight: 600; +} +``` + +# Approach + +* modules should be scoped to themselves and not affect children or + siblings. +* Webpack already has support for css-modules in it's `css-loader`. So + we'll start with that. +* [css-modules and preprocessors (sass)](https://github.com/css-modules/css-modules#usage-with-preprocessors) diff --git a/pr-docs/linting.md b/pr-docs/linting.md new file mode 100644 index 0000000000..e66ad8c472 --- /dev/null +++ b/pr-docs/linting.md @@ -0,0 +1,28 @@ +# Linting + +All code must pass [ESLint][eslint] before being merged into master +branch. The ESLint config can be found in `.eslintrc` and is +integrated into webpack. + +# Running ESLint + +``` +gulp webpack +``` + +Since linting is integrated with webpack, it is possible to lint code +while it is being developed without any extra effort. This is +important because if it is not approximate to effortless to run +linting, it will not be run while developing. + +# We should block deploys for linting errors + +Since we do CI/CD, the static analysis present in ESLint can help us +catch bugs before shipping. We should therefore block deploys if +ESLint detects an error-level (level `2` in `.eslintrc`) issue. + +* [eslint][eslint] +* [babel-eslint][babel-eslint] + +[eslint]: http://eslint.org/ +[babel-eslint]: https://github.com/babel/babel-eslint diff --git a/pr-docs/routes.md b/pr-docs/routes.md new file mode 100644 index 0000000000..99876f2e52 --- /dev/null +++ b/pr-docs/routes.md @@ -0,0 +1,262 @@ +# Routes + +Two items affect this proposal. + +1. [Distribution's work](https://github.com/docker/distribution), + specifically relating to defining Repositories, Images, Manifests, + Digests and Tags. +2. The Current Hub's Routing Issues + +## Distributions Work (partially summarized) + +### Repository + +* A set of blobs +* Subsets of these blobs make up Images + +### Image + +* A set of blobs + - Layers + - Tag + - Signatures + - Manifest +* A Tag (potentially containing signatures) points to a Manifest +* A Manifest points to multiple layers. + +### Manifest + +As defined in the [distribution][manifest-pr] Manifest PR: + +> A [Content Manifest][manifest] is a simple JSON file which contains +> general fields that are typical to any package management +> system. The goal is for these manifests to describe an application +> and its dependencies in a content-addressable and verifiable way. + +### Tag + +As defined in the [distribution][d-tag-pr] PR: + +> A [tag][tag] is simply a named pointer to content. The content can +> be any blob but should mostly be a manifest. One can sign tags to +> later verify that they were created by a trusted party. + +### Additional Content + +Image names will be allowed to have many slashes in the future. + +## Current Hub Issues + +### Collisions + +The URLs for user and repo collide: + +A user's Starred Repos: + +``` +/u/biscarch/starred/ +``` + +A user's repository, named Starred. + +``` +/u/biscarch/starred/ +``` + +## Future Problems + +An image for the user `biscarch`, named `my/repo`: + +``` +/u/biscarch/my/repo/ +``` + +An image for the user `biscarch`, named `my`, tagged `repo`: + +``` +/u/biscarch/my/repo/ +``` + +## Solutions + +Namespace `Users`, `Repos` and `Images` as such (with the user +`biscarch`) + +``` +/u/:user +/r/:user/:repo +/i/:user/:repo/:tag +``` + +### Solving Starred Repos + +Prefix defines whether we are referring to a repo or attribute of a +user: + +``` +/u/biscarch/starred +/r/biscarch/starred +``` + +### Solving Repo/Image Conflicts + +Prefix determines whether we are referring to a Repository or Image: + +``` +/r/biscarch/my/repo/ +/i/biscarch/my/repo/ +``` + +## The new Spec + +``` +/u/ +/u/:user/ +/r/:user/:repo/ +/i/:user/:repo/:tag/ +``` + +### Full List + +### Dashboard + +`/` + +### Official Repositories + +"username" === library, which is represented as the root `_`. +All management of `library` namespaced repos is done from the usual +`/u/library/:repo/` + +``` +/_/:repo/ +/_/:repo/dockerfile/ +/_/:repo/dockerfile/raw +/_/:repo/tags/ +``` + +### Single Endpoints + +* Search + - `/search/` +* Plans + - `/plans/` + +### Account + +Mostly Settings; Add Repository Page; + +`/account/` should redirect to `/account/settings/` + +``` +/account/accounts/ +/account/authorized_services/ +/account/change-password/ +/account/confirm-email// +/account/emails/ +/account/notifications/ +/account/organizations/ +/account/organizations/:org_name/ +/account/organizations/:org_name/groups/:group_id/ +/account/repositories/add/ +/account/settings/ +/account/subscriptions/ +``` + +### Users + +``` +/u/ +/u/:user/ +/u/:user/activity/ +/u/:user/contributed/ +/u/:user/starred/ +``` + +### Repos + +``` +/r/:user/:repo/ +/r/:user/:repo/~/settings/ +/r/:user/:repo/~/settings/collaborators/ +/r/:user/:repo/~/settings/links/ +/r/:user/:repo/~/settings/triggers/ +/r/:user/:repo/~/settings/webhooks/ +/r/:user/:repo/~/settings/tags/ +``` + +Current build history urls: + +``` +/r/:user/:repo/~/builds_history/ +``` + +### Images + +We currently don't do a lot for Images. Repositories have been the +main focus. + +``` +/i/:user/:repo/:tag/ +/i/:user/:repo/:tag/~/dockerfile/ +/i/:user/:repo/:tag/~/dockerfile/raw/ +``` + +### Automated Builds + +``` +/automated-builds/ +/builds/ +/builds/:user/:repo/ +``` + +### Convenience Redirects + +Also, potential pages to build out more agressively. + +* `/official/` + - redirects to `/search?q=library&f=official` + - future: Potentially `Explore` type page for official repos +* `/most_stars/`, `/popular/` + - redirects to `search?q=library&s=stars` +* `/recent_updated/` + - `search?q=library&s=last_updated` + +#### Help + +* `/help` + - `https://www.docker.com/resources/help/` + - Can we rely on this url to stick around? +* `/help/docs` + - `https://docs.docker.com/` + +## Make Separate Sites for: + +### Highland URLs + +We need to pull out the APIs used on the current Hub for this. + +``` +/highland/ +/highland/build-configs/ +/highland/builds/ +/highland/search/ +/highland/stats/ +``` + + +## More Issues + +* There are no links to comments +* `/opensearch.xml` times out on the current site + - Should we re-implement? +* `/sitemap.xml` + +# Concerns with this Proposal + +* Automated Build urls need to be given more thought + +[tag]: https://github.com/stevvooe/distribution/blob/a8d3f3474b7b60576dc64250d95db3717bf07c33/doc/spec/tags.md#tags +[d-tag-pr]: https://github.com/docker/distribution/pull/173/files +[d-manifest-pr]: https://github.com/docker/distribution/pull/62 +[manifest]: https://github.com/jlhawn/distribution/blob/e8b5c8c32b565b9b643c3a0b0e87339bf40eb206/doc/spec/manifest.md diff --git a/production_ready.md b/production_ready.md new file mode 100644 index 0000000000..ee92e5a3b3 --- /dev/null +++ b/production_ready.md @@ -0,0 +1,93 @@ +Production Readiness: Docker Hub Front-End (hub-web-v2) +================================ + +Testing +------- + + * **What is the max traffic load that your service has been tested with?** + Hub UI has not been load tested. + + * **How has the service been soak-tested?** + Hub UI has not been soak tested. + + Monitoring + ---------- + + * **How do you monitor?** + New Relic for server monitoring, BugSnag for JavaScript errors and PagerDuty for alerting. + + * **What’s the link(s) to the dashboard(s)?** + New Relic: https://rpm.newrelic.com/accounts/532547/applications/8853774 + BugSnag: https://bugsnag.com/docker/hub-prod/errors + PagerDuty: https://docker.pagerduty.com/services/PKZG21B + + * **Do you use an exception tracking service (e.g. Bugsnag, Sentry, New Relic)?** + Yes, BugSnag and New Relic. + + * **What’s the health check endpoint? And what checks does that endpoint perform?** + https://hub.docker.com/_health/ + + * **What external services do you depend on? How do you monitor them and handle failures?** + Hub API Gateway and all downstream Docker Cloud services. + Google Tag Manager (gtm.js) + Recurly (recurly.js) + + + + * **What’s the link to view the logs?** + + Alerting + -------- + + * **How do you know if your service is down?** + PagerDuty alerts + Prometheus alerts + + * **What are the metrics that you alert on?** + 500's from Front End containers + + * **Have you tested tripping on an alert to page somebody?** + Not manually tested. But production systems are paging properly. + + * **What’s the link to your on-call schedule?** + https://docker.pagerduty.com/schedules#P88XAI9 + + * **Where is your on-call run-book?** + https://docker.atlassian.net/wiki/display/DE/Hub+UI+Runbook + + Disaster + -------- + + * **What’s the plan if your persistence layer blows up?** + Front-end is stateless so this shouldn't be required, but restart Container if unsure. + + * **What’s the plan if any of your external service dependencies blows up?** + Hub API Gateway or downstream service - find service owner, escalate/alert through PagerDuty, contact service team via Slack. + Google Tag Manager - disable Google Tag Manager from https://tagmanager.google.com - single signon with docker.com account + Recurly problem - check status.recurly.com, escalate/alert Billing team through PagerDuty, contact service team via Slack. + Update status.io describing impact to UI if any. + + + Security + -------- + + * **Is the service exposed on the public internet? Does it require TLS?** + https://hub.docker.com/ + + * **How do you store production secrets?** + Front-End does not store secrets. JWT is stored in user's browser cookie. + + * **What is your authentication model (both user authentication and service-to-service authentication)?** + oauth + + * **Do you store any sensitive user data (emails, phone numbers, intellectual property)?** + JWT in cookie. + + Release process + --------------- + + * **What’s the link to your docs on how to do a release?** + https://docker.atlassian.net/wiki/display/DH/Hub+frontend+Deployment+Process + + * **How long does it take to release a code fix to production?** + 4-8 hours diff --git a/startup-scripts/boot-dev-tmux.sh b/startup-scripts/boot-dev-tmux.sh new file mode 100755 index 0000000000..0907330ac3 --- /dev/null +++ b/startup-scripts/boot-dev-tmux.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +set -e + +eval $(docker-machine env dev) + +############################################################### +# You must have `tmux` installed locally. On OSX, this can be # +# accomplished with `brew install tmux` # +############################################################### + +SESSION=HubDev +DIR=${PWD##*/}_hub_1 +CONTAINER=$(sed s/-//g <<< $DIR) + + +# Create new tmux session +tmux -2 new-session -d -s $SESSION + +# Window 1 + +## webpack task +tmux split-window +tmux select-pane -t 0 +tmux send-keys "DEBUG=* webpack -wd" C-m + +## styles + +tmux select-pane -t 1 +tmux send-keys "DEBUG=* gulp watch::styles::dev" C-m + +## Flow + +tmux split-window -h +tmux select-pane -t 2 +tmux send-keys "flow" C-m + +## docker logs + +tmux select-pane -t 0 +tmux split-window -h +tmux send-keys "docker-compose logs hub" C-m + +# Attach to session +tmux -2 attach-session -t $SESSION diff --git a/startup-scripts/boot-dev.sh b/startup-scripts/boot-dev.sh new file mode 100755 index 0000000000..83b569ffe3 --- /dev/null +++ b/startup-scripts/boot-dev.sh @@ -0,0 +1,2 @@ +DEBUG=* webpack -dw & +cd app/.build && nodemon ./server.js diff --git a/startup-scripts/bootstrap-dev.sh b/startup-scripts/bootstrap-dev.sh new file mode 100755 index 0000000000..454c4620fa --- /dev/null +++ b/startup-scripts/bootstrap-dev.sh @@ -0,0 +1,8 @@ +# run this before the development container to bootstrap your local filesystem +npm install +cp app/favicon.ico app/.build/favicon.ico +make server-target +make styles-base +gulp images::dev +make images +make docker-font-dev diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000000..9a5242e5ec --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,151 @@ +const debug = require('debug')('webpack-debug'); +var ENV_CONFIG = require('./_webpack/_envConfig.js'); +var fs = require('fs'); +var path = require('path'); +var ExtractTextPlugin = require("extract-text-webpack-plugin"); +var _ = require('lodash'); +var webpack = require('webpack'); + +var loaders = require('./_webpack/_commonLoaders'); + +/** + * blacklist this array from being included in `externals`. + * + * This has the effect of making any modules in this list be + * resolved at build time instead of runtime. This affects the + * server bundle + */ +var blacklist = ['.bin', 'hub-js-sdk', 'dux']; +var node_modules = fs.readdirSync('node_modules').filter(function(x) { + return !_.includes(blacklist, x); +}); + +/* Dux Button Config */ +var elementButton = require('@dux/element-button/defaults'); +var buttons = elementButton.mkButtons([{ + name: 'primary', + color: '#FFF', + bg: '#22B8EB' +},{ + name: 'secondary', + color: '#FFF', + bg: '#232C37' +},{ + name: 'coral', + color: '#FFF', + bg: '#FF85AF' +},{ + name: 'success', + color: '#FFF', + bg: '#0FD85A' +},{ + name: 'warning', + color: '#FFF', + bg: '#FF8546' +},{ + name: 'yellow', + color: '#FFF', + bg: '#FFDE50' +},{ + name: 'alert', + color: '#FFF', + bg: '#EB3E46' +}]); +debug('modules that will be runtime require dependencies of the server if the server requires them: ', node_modules); +var commonConfig = { + resolve: { + extensions: ['', '.js', '.jsx', '.json'], + root: [ + path.resolve(__dirname, './app/scripts/'), + path.resolve(__dirname, './app/scripts/components/') + ], + modulesDirectories: ['node_modules', 'app/scripts'] + }, + module: { + preLoaders: loaders.preLoaders, + loaders: loaders.commonLoaders + }, + plugins: [ + ENV_CONFIG, + new webpack.optimize.DedupePlugin(), + new ExtractTextPlugin('public/styles/style.css', { allChunks: true }) + ], + postcss: [ + require('postcss-import')(), + require('postcss-constants')({ + defaults: _.merge(require('@dux/element-card/defaults')({ + capBackground: '#f1f6fb', + borderColor: '#c4cdda' + }), + { + duxElementButton: { + radius: '.25rem', + buttons: buttons + } + }) + }), + require('postcss-each'), + require('postcss-cssnext')({ + browsers: 'last 2 versions', + features: { + // https://github.com/robwierzbowski/node-pixrem/issues/40 + rem: false + } + }), + require('postcss-nested'), + require('lost')({ + gutter: '1.25rem', + flexbox: 'flex' + }), + require('postcss-cssstats')(function(stats) { + /** + * this is in test-phase because it runs on all + * files individually. We should either figure out + * that that is useful or get it to run on the full postcss + * AST or extracted CSS file. + */ + debug(stats); + }), + require('postcss-url')(), + require('cssnano')(), + require('postcss-browser-reporter') + ], + eslint: { + failOnError: true + }, + profile: true +} + +var clientBundle = _.assign({}, + commonConfig, + { + // client.js + entry: './app/scripts/client.js', + devtool: 'eval-source-map', + output: { + path: 'app/.build/public/', + filename: 'js/client.js' + } + }); + +var serverBundle = _.assign({}, + commonConfig, + { + // server.js + entry: './app/scripts/server.js', + output: { + path: 'app/.build/', + filename: 'server.js', + libraryTarget: 'commonjs2' + }, + target: 'node', + externals: node_modules, + node: { + __dirname: '/opt/hub/' + } + }); + +module.exports = [ + clientBundle, + serverBundle +];