diff --git a/frontend/scenes/Application.js b/frontend/scenes/Application.js index ac5e2e65..785a3dfb 100644 --- a/frontend/scenes/Application.js +++ b/frontend/scenes/Application.js @@ -2,25 +2,70 @@ import React from 'react'; import { observer } from 'mobx-react'; import Helmet from 'react-helmet'; -const Application = observer((props) => { - return ( -
- - { props.children } -
- ); -}); +@observer +class Application extends React.Component { + static childContextTypes = { + rebass: React.PropTypes.object, + } -Application.propTypes = { - children: React.PropTypes.node.isRequired, -}; + propTypes = { + children: React.PropTypes.node.isRequired, + } + + getChildContext() { + return { + rebass: { + colors: { + primary: '#171B35', + }, + // color: '#eee', + // backgroundColor: '#fff', + borderRadius: 2, + borderColor: '#eee', + + // fontSizes: [64, 48, 28, 20, 18, 16, 14], + bold: 500, + scale: [ + 0, + 8, + 18, + 36, + 72, + ], + Input: { + // borderBottom: '1px solid #eee', + }, + Button: { + // color: '#eee', + // backgroundColor: '#fff', + // border: '1px solid #ccc', + }, + ButtonOutline: { + color: '#000', + }, + InlineForm: { + + }, + }, + }; + } + + render() { + return ( +
+ + { this.props.children } +
+ ); + } +} export default Application; diff --git a/frontend/scenes/Settings/Settings.js b/frontend/scenes/Settings/Settings.js index 369f65e3..dda64d68 100644 --- a/frontend/scenes/Settings/Settings.js +++ b/frontend/scenes/Settings/Settings.js @@ -2,6 +2,7 @@ import React from 'react'; import { observer } from 'mobx-react'; import { Flex } from 'reflexbox'; +import { Input, ButtonOutline, InlineForm } from 'rebass'; import Layout, { Title } from 'components/Layout'; import CenteredContent from 'components/CenteredContent'; import SlackAuthLink from 'components/SlackAuthLink'; @@ -19,6 +20,11 @@ class Settings extends React.Component { this.store = new SettingsStore(); } + onKeyCreate = (e) => { + e.preventDefault(); + this.store.createApiKey(); + } + render() { const title = ( @@ -34,19 +40,57 @@ class Settings extends React.Component { loading={ this.store.isFetching } > <CenteredContent> - <h2 className={ styles.sectionHeader }>Slack</h2> + <div className={ styles.section }> + <h2 className={ styles.sectionHeader }>Slack</h2> + <p> + Connect Atlas to your Slack to instantly search for your documents + using <code>/atlas</code> command. + </p> - <p> - Connect Atlas to your Slack to instantly search for your documents - using <code>/atlas</code> command. - </p> - - <SlackAuthLink - scopes={ ['commands'] } - redirectUri={ `${URL}/auth/slack/commands` } - > - <img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcSet="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /> - </SlackAuthLink> + <SlackAuthLink + scopes={ ['commands'] } + redirectUri={ `${URL}/auth/slack/commands` } + > + <img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcSet="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /> + </SlackAuthLink> + </div> + + <div className={ styles.section }> + <h2 className={ styles.sectionHeader }>API access</h2> + <p> + Create API tokens to hack on your Atlas. + Learn more in <a href>API documentation</a>. + </p> + + { this.store.apiKeys && ( + <table className={ styles.apiKeyTable }> + { this.store.apiKeys.map(key => ( + <tr> + <td>{ key.name }</td> + <td><code>{ key.secret }</code></td> + {/* <td> + <span className={ styles.deleteAction }>Delete</span> + </td> */} + </tr> + )) } + </table> + ) } + + <div> + <InlineForm + placeholder="Token name" + buttonLabel="Create token" + label="InlineForm" + name="inline_form" + value={ this.store.keyName } + onChange={ this.store.setKeyName } + onClick={ this.onKeyCreate } + style={{ width: '100%' }} + disabled={ this.store.isFetching } + /> + </div> + + </div> </CenteredContent> </Layout> ); diff --git a/frontend/scenes/Settings/Settings.scss b/frontend/scenes/Settings/Settings.scss index 8074d9f0..64f268ed 100644 --- a/frontend/scenes/Settings/Settings.scss +++ b/frontend/scenes/Settings/Settings.scss @@ -1,4 +1,25 @@ +@import '~styles/constants.scss'; + +.section { + margin-bottom: 40px; +} .sectionHeader { border-bottom: 1px solid #eee; } + +.apiKeyTable { + margin-bottom: 20px; + width: 100%; + + td { + margin-right: 20px; + color: #969696; + } +} + +.deleteAction { + font-size: 14px; + cursor: pointer; + color: $textColor; +} diff --git a/frontend/scenes/Settings/SettingsStore.js b/frontend/scenes/Settings/SettingsStore.js index 1b0502eb..67b9940c 100644 --- a/frontend/scenes/Settings/SettingsStore.js +++ b/frontend/scenes/Settings/SettingsStore.js @@ -1,7 +1,52 @@ -import { observable, action, runInAction } from 'mobx'; -// import { client } from 'utils/ApiClient'; +import { observable, action, runInAction, toJS } from 'mobx'; +import { client } from 'utils/ApiClient'; class SearchStore { -}; + @observable apiKeys = []; + @observable keyName; + + @observable isFetching; + + @action fetchApiKeys = async () => { + this.isFetching = true; + + try { + const res = await client.post('/apiKeys.list'); + const { data } = res; + runInAction('fetchApiKeys', () => { + this.apiKeys = data; + }); + } catch (e) { + console.error("Something went wrong"); + } + this.isFetching = false; + } + + @action createApiKey = async () => { + this.isFetching = true; + + try { + const res = await client.post('/apiKeys.create', { + name: `${this.keyName}` || 'Untitled key', + }); + const { data } = res; + runInAction('createApiKey', () => { + this.apiKeys.push(data); + this.keyName = ''; + }); + } catch (e) { + console.error("Something went wrong"); + } + this.isFetching = false; + } + + @action setKeyName = (value) => { + this.keyName = value.target.value; + } + + constructor() { + this.fetchApiKeys(); + } +} export default SearchStore; diff --git a/frontend/styles/constants.scss b/frontend/styles/constants.scss index 7910a5f5..8e565739 100644 --- a/frontend/styles/constants.scss +++ b/frontend/styles/constants.scss @@ -1,7 +1,7 @@ $lightGray: #eee; $textColor: #171B35; -$actionColor: #2da9e1; +$actionColor: #3AA3E3; $darkGray: #ccc; $lightGray: #eee; diff --git a/server/api/apiKeys.js b/server/api/apiKeys.js new file mode 100644 index 00000000..efafe616 --- /dev/null +++ b/server/api/apiKeys.js @@ -0,0 +1,53 @@ +import Router from 'koa-router'; +import httpErrors from 'http-errors'; +import _ from 'lodash'; + +import auth from './authentication'; +import pagination from './middlewares/pagination'; +import { presentApiKey } from '../presenters'; +import { ApiKey } from '../models'; + +const router = new Router(); + +router.post('apiKeys.create', auth(), async (ctx) => { + const { + name, + } = ctx.body; + ctx.assertPresent(name, 'name is required'); + + const user = ctx.state.user; + + const key = await ApiKey.create({ + name, + userId: user.id, + }); + + ctx.body = { + data: presentApiKey(ctx, key), + }; +}); + +router.post('apiKeys.list', auth(), pagination(), async (ctx) => { + const user = ctx.state.user; + const keys = await ApiKey.findAll({ + where: { + userId: user.id, + }, + order: [ + ['createdAt', 'DESC'], + ], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + const data = keys.map(key => { + return presentApiKey(ctx, key); + }); + + ctx.body = { + pagination: ctx.state.pagination, + data, + }; +}); + +export default router; diff --git a/server/api/index.js b/server/api/index.js index ac92836d..c5000d39 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -9,6 +9,7 @@ import user from './user'; import collections from './collections'; import documents from './documents'; import hooks from './hooks'; +import apiKeys from './apiKeys'; import validation from './validation'; import methodOverride from '../middlewares/methodOverride'; @@ -52,6 +53,7 @@ router.use('/', user.routes()); router.use('/', collections.routes()); router.use('/', documents.routes()); router.use('/', hooks.routes()); +router.use('/', apiKeys.routes()); // Router is embedded in a Koa application wrapper, because koa-router does not // allow middleware to catch any routes which were not explicitly defined. diff --git a/server/migrations/20160824061730-add-apikeys.js b/server/migrations/20160824061730-add-apikeys.js new file mode 100644 index 00000000..7cff90f6 --- /dev/null +++ b/server/migrations/20160824061730-add-apikeys.js @@ -0,0 +1,46 @@ +'use strict'; + +module.exports = { + up: function (queryInterface, Sequelize) { + queryInterface.createTable('apiKeys', { + id: { + type: 'UUID', + allowNull: false, + primaryKey: true, + }, + name: { + type: 'CHARACTER VARYING', + allowNull: true, + }, + secret: { + type: 'CHARACTER VARYING', + allowNull: false, + unique: true, + }, + userId: { + type: 'UUID', + allowNull: true, + // references: { + // model: 'users', + // key: 'id', + // }, + }, + createdAt: { + type: 'TIMESTAMP WITH TIME ZONE', + allowNull: false, + }, + updatedAt: { + type: 'TIMESTAMP WITH TIME ZONE', + allowNull: false, + }, + deletedAt: { + type: 'TIMESTAMP WITH TIME ZONE', + allowNull: true, + }, + }); + }, + + down: function (queryInterface, Sequelize) { + queryInterface.createTable('apiKeys'); + }, +}; diff --git a/server/migrations/20160824062457-add-apikey-indeces.js b/server/migrations/20160824062457-add-apikey-indeces.js new file mode 100644 index 00000000..1ed8ae22 --- /dev/null +++ b/server/migrations/20160824062457-add-apikey-indeces.js @@ -0,0 +1,13 @@ +'use strict'; + +module.exports = { + up: function (queryInterface, Sequelize) { + queryInterface.addIndex('apiKeys', ['secret', 'deletedAt']); + queryInterface.addIndex('apiKeys', ['userId', 'deletedAt']); + }, + + down: function (queryInterface, Sequelize) { + queryInterface.removeIndex('apiKeys', ['secret', 'deletedAt']); + queryInterface.removeIndex('apiKeys', ['userId', 'deletedAt']); + }, +}; diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js new file mode 100644 index 00000000..f54636a9 --- /dev/null +++ b/server/models/ApiKey.js @@ -0,0 +1,28 @@ +import { + DataTypes, + sequelize, +} from '../sequelize'; +import randomstring from 'randomstring'; + +const Team = sequelize.define('team', { + id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, + name: DataTypes.STRING, + secret: { type: DataTypes.STRING, unique: true }, + userId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: 'users', + }, + }, +}, { + tableName: 'apiKeys', + paranoid: true, + hooks: { + beforeValidate: (key) => { + key.secret = randomstring.generate(38); + }, + }, +}); + +export default Team; diff --git a/server/models/index.js b/server/models/index.js index 9a238c61..db1d5bc0 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -3,6 +3,7 @@ import Team from './Team'; import Atlas from './Atlas'; import Document from './Document'; import Revision from './Revision'; +import ApiKey from './ApiKey'; export { User, @@ -10,4 +11,5 @@ export { Atlas, Document, Revision, + ApiKey, }; diff --git a/server/presenters.js b/server/presenters.js index 0eb99a51..d5d24705 100644 --- a/server/presenters.js +++ b/server/presenters.js @@ -129,3 +129,11 @@ export function presentCollection(ctx, collection, includeRecentDocuments=false) resolve(data); }); } + +export function presentApiKey(ctx, key) { + return { + id: key.id, + name: key.name, + secret: key.secret, + }; +}