Settings Routes (#449)

* Building out settings area

* Flow and refactoring

* TeamLogo

* Add temporary profile screen

* 💚

* PR feedback
This commit is contained in:
Tom Moor 2017-11-26 18:09:55 -08:00 committed by GitHub
parent 6aa0390e99
commit 505310c172
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 456 additions and 317 deletions

View File

@ -32,6 +32,11 @@ const RealButton = styled.button`
top: 0.05em;
}
&:disabled {
opacity: 0.8;
cursor: default;
}
${props =>
props.light &&
`
@ -57,10 +62,7 @@ const RealButton = styled.button`
&:hover {
background: ${darken(0.05, color.danger)};
}
`} &:disabled {
background: ${color.slateLight};
cursor: default;
}
`};
`;
const Label = styled.span`

View File

@ -6,10 +6,7 @@ import type { Props } from './Icon';
export default function BackIcon(props: Props) {
return (
<Icon {...props}>
<path
d="M7.20710678,8.79289322 C6.81658249,8.40236893 6.18341751,8.40236893 5.79289322,8.79289322 C5.40236893,9.18341751 5.40236893,9.81658249 5.79289322,10.2071068 L10.7928932,15.2071068 C11.1834175,15.5976311 11.8165825,15.5976311 12.2071068,15.2071068 L17.2071068,10.2071068 C17.5976311,9.81658249 17.5976311,9.18341751 17.2071068,8.79289322 C16.8165825,8.40236893 16.1834175,8.40236893 15.7928932,8.79289322 L11.5,13.0857864 L7.20710678,8.79289322 Z"
id="path-1"
/>
<path d="M7.20710678,8.79289322 C6.81658249,8.40236893 6.18341751,8.40236893 5.79289322,8.79289322 C5.40236893,9.18341751 5.40236893,9.81658249 5.79289322,10.2071068 L10.7928932,15.2071068 C11.1834175,15.5976311 11.8165825,15.5976311 12.2071068,15.2071068 L17.2071068,10.2071068 C17.5976311,9.81658249 17.5976311,9.18341751 17.2071068,8.79289322 C16.8165825,8.40236893 16.1834175,8.40236893 15.7928932,8.79289322 L11.5,13.0857864 L7.20710678,8.79289322 Z" />
</Icon>
);
}

View File

@ -0,0 +1,12 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function ProfileIcon(props: Props) {
return (
<Icon {...props}>
<path d="M6,6 L19,6 C20.1045695,6 21,6.8954305 21,8 L21,16 C21,17.1045695 20.1045695,18 19,18 L6,18 C4.8954305,18 4,17.1045695 4,16 L4,8 L4,8 C4,6.8954305 4.8954305,6 6,6 L6,6 Z M13,14 L13,16 L19,16 L19,14 L13,14 Z M13,11 L13,13 L17,13 L17,11 L13,11 Z M6,8 L6,16 L11,16 L11,8 L6,8 Z M13,8 L13,10 L19,10 L19,8 L13,8 Z" />
</Icon>
);
}

View File

@ -0,0 +1,12 @@
// @flow
import React from 'react';
import Icon from './Icon';
import type { Props } from './Icon';
export default function SettingsIcon(props: Props) {
return (
<Icon {...props}>
<path d="M9.5005126,5.99794961 L9.81063391,4.75746437 L9.81063391,4.75746437 C9.92192566,4.31229737 10.3219088,4 10.7807764,4 L10.7807764,4 L13.2192236,4 L13.2192236,4 C13.6780912,4 14.0780743,4.31229737 14.1893661,4.75746437 L14.4994874,5.99794961 C15.0194857,6.21474677 15.5052885,6.49715027 15.9465564,6.83482093 L17.1775368,6.48268553 L17.1775368,6.48268553 C17.6187086,6.35648351 18.0891576,6.54673036 18.3185914,6.9441214 L18.3185914,6.9441214 L19.537815,9.0558786 C19.7672488,9.45326964 19.6967829,9.95581386 19.3669029,10.2747788 L18.4467943,11.1644429 C18.4819001,11.4380051 18.5,11.7168888 18.5,12 C18.5,12.2831112 18.4819001,12.5619949 18.4467943,12.8355571 L19.3669029,13.7252212 C19.6967829,14.0441861 19.7672488,14.5467304 19.537815,14.9441214 L18.3185914,17.0558786 C18.0891576,17.4532696 17.6187086,17.6435165 17.1775368,17.5173145 L15.9465564,17.1651791 C15.5052885,17.5028497 15.0194857,17.7852532 14.4994874,18.0020504 L14.1893661,19.2425356 C14.0780743,19.6877026 13.6780912,20 13.2192236,20 L10.7807764,20 C10.3219088,20 9.92192566,19.6877026 9.81063391,19.2425356 L9.5005126,18.0020504 C8.98051425,17.7852532 8.49471153,17.5028497 8.0534436,17.1651791 L6.82246321,17.5173145 C6.3812914,17.6435165 5.91084239,17.4532696 5.68140857,17.0558786 L4.46218497,14.9441214 C4.23275115,14.5467304 4.30321706,14.0441861 4.63309711,13.7252212 L5.5532057,12.8355571 C5.51809991,12.5619949 5.5,12.2831112 5.5,12 C5.5,11.7168888 5.51809991,11.4380051 5.5532057,11.1644429 L4.63309711,10.2747788 L4.63309711,10.2747788 C4.30321706,9.95581386 4.23275115,9.45326964 4.46218497,9.0558786 L4.46218497,9.0558786 L5.68140857,6.9441214 L5.68140857,6.9441214 C5.91084239,6.54673036 6.3812914,6.35648351 6.82246321,6.48268553 L8.0534436,6.83482093 C8.49471153,6.49715027 8.98051425,6.21474677 9.5005126,5.99794961 Z M12,15 C13.6568542,15 15,13.6568542 15,12 C15,10.3431458 13.6568542,9 12,9 C10.3431458,9 9,10.3431458 9,12 C9,13.6568542 10.3431458,15 12,15 Z" />
</Icon>
);
}

View File

@ -11,6 +11,7 @@ const RealTextarea = styled.textarea`
outline: none;
background: none;
&:disabled,
&::placeholder {
color: ${color.slate};
}
@ -23,6 +24,7 @@ const RealInput = styled.input`
outline: none;
background: none;
&:disabled,
&::placeholder {
color: ${color.slate};
}
@ -52,13 +54,18 @@ export const LabelText = styled.div`
`;
export type Props = {
type: string,
type?: string,
value?: string,
label?: string,
className?: string,
};
export default function Input({ type, label, className, ...rest }: Props) {
export default function Input({
type = 'text',
label,
className,
...rest
}: Props) {
const InputComponent = type === 'textarea' ? RealTextarea : RealInput;
return (

View File

@ -1,6 +1,6 @@
// @flow
import React from 'react';
import { withRouter } from 'react-router-dom';
import { Switch, Route, withRouter } from 'react-router-dom';
import type { Location } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import styled from 'styled-components';
@ -13,6 +13,7 @@ import { documentEditUrl, homeUrl, searchUrl } from 'utils/routeHelpers';
import { LoadingIndicatorBar } from 'components/LoadingIndicator';
import Sidebar from 'components/Sidebar';
import SettingsSidebar from 'components/Sidebar/Settings';
import Modals from 'components/Modals';
import Toasts from 'components/Toasts';
@ -84,7 +85,12 @@ class Layout extends React.Component {
{this.props.notifications}
<Flex auto>
{showSidebar && <Sidebar />}
{showSidebar && (
<Switch>
<Route path="/settings" component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
)}
<Content auto justify="center" editMode={ui.editMode}>
{this.props.children}

View File

@ -8,7 +8,6 @@ import CollectionEdit from 'scenes/CollectionEdit';
import CollectionDelete from 'scenes/CollectionDelete';
import DocumentDelete from 'scenes/DocumentDelete';
import KeyboardShortcuts from 'scenes/KeyboardShortcuts';
import Settings from 'scenes/Settings';
@observer
class Modals extends Component {
@ -52,9 +51,6 @@ class Modals extends Component {
<Modal name="keyboard-shortcuts" title="Keyboard shortcuts">
<KeyboardShortcuts />
</Modal>
<Modal name="settings" title="Settings">
<Settings />
</Modal>
</span>
);
}

View File

@ -0,0 +1,90 @@
// @flow
import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';
import type { Location } from 'react-router-dom';
import styled from 'styled-components';
import { observer, inject } from 'mobx-react';
import Flex from 'shared/components/Flex';
import { color, layout } from 'shared/styles/constants';
import Scrollable from 'components/Scrollable';
import ProfileIcon from 'components/Icon/ProfileIcon';
import SettingsIcon from 'components/Icon/SettingsIcon';
import CodeIcon from 'components/Icon/CodeIcon';
import Header from './components/Header';
import SidebarLink from './components/SidebarLink';
import HeaderBlock from './components/HeaderBlock';
import AuthStore from 'stores/AuthStore';
type Props = {
history: Object,
location: Location,
auth: AuthStore,
};
@observer
class Sidebar extends Component {
props: Props;
returnToDashboard = () => {
this.props.history.push('/');
};
render() {
const { team } = this.props.auth;
if (!team) return;
return (
<Container column>
<HeaderBlock
subheading="◄ Return to Dashboard"
teamName={team.name}
logoUrl={team.avatarUrl}
onClick={this.returnToDashboard}
/>
<Flex auto column>
<Scrollable>
<Section>
<Header>Account</Header>
<SidebarLink to="/settings" icon={<ProfileIcon />}>
Profile
</SidebarLink>
<SidebarLink to="/settings/tokens" icon={<CodeIcon />}>
API Tokens
</SidebarLink>
</Section>
<Section>
<Header>Team</Header>
<SidebarLink
to="/settings/integrations/slack"
icon={<SettingsIcon />}
>
Integrations
</SidebarLink>
</Section>
</Scrollable>
</Flex>
</Container>
);
}
}
const Container = styled(Flex)`
position: fixed;
top: 0;
bottom: 0;
left: 0;
width: ${layout.sidebarWidth};
background: ${color.smoke};
transition: left 200ms ease-in-out;
`;
const Section = styled(Flex)`
flex-direction: column;
margin: 24px 0;
padding: 0 24px;
position: relative;
`;
export default withRouter(inject('auth')(Sidebar));

View File

@ -8,7 +8,6 @@ import Flex from 'shared/components/Flex';
import { color, layout } from 'shared/styles/constants';
import AccountMenu from 'menus/AccountMenu';
import Avatar from 'components/Avatar';
import Scrollable from 'components/Scrollable';
import HomeIcon from 'components/Icon/HomeIcon';
import SearchIcon from 'components/Icon/SearchIcon';
@ -63,9 +62,11 @@ class Sidebar extends Component {
<Container column editMode={ui.editMode}>
<AccountMenu
label={
<HeaderBlock user={user} team={team}>
<Avatar src={user.avatarUrl} />
</HeaderBlock>
<HeaderBlock
subheading={user.name}
teamName={team.name}
logoUrl={team.avatarUrl}
/>
}
/>

View File

@ -5,8 +5,9 @@ import { observer, inject } from 'mobx-react';
import type { Location } from 'react-router-dom';
import Flex from 'shared/components/Flex';
import styled from 'styled-components';
import { color, fontWeight } from 'shared/styles/constants';
import { color } from 'shared/styles/constants';
import Header from './Header';
import SidebarLink from './SidebarLink';
import DropToImport from 'components/DropToImport';
import PlusIcon from 'components/Icon/PlusIcon';
@ -261,15 +262,6 @@ const StyledDropToImport = styled(DropToImport)`
}
`;
const Header = styled(Flex)`
font-size: 12px;
font-weight: ${fontWeight.semiBold};
text-transform: uppercase;
color: ${color.slate};
letter-spacing: 0.04em;
margin-bottom: 4px;
`;
const Children = styled(Flex)`
margin-left: 12px;
`;

View File

@ -0,0 +1,15 @@
// @flow
import Flex from 'shared/components/Flex';
import styled from 'styled-components';
import { color, fontWeight } from 'shared/styles/constants';
const Header = styled(Flex)`
font-size: 11px;
font-weight: ${fontWeight.semiBold};
text-transform: uppercase;
color: ${color.slateDark};
letter-spacing: 0.04em;
margin-bottom: 4px;
`;
export default Header;

View File

@ -2,33 +2,38 @@
import React from 'react';
import styled from 'styled-components';
import { color } from 'shared/styles/constants';
import type { User, Team } from 'types';
import Flex from 'shared/components/Flex';
import TeamLogo from './TeamLogo';
type Props = {
user: User,
team: Team,
children?: React$Element<any>,
teamName: string,
subheading: string,
logoUrl: string,
};
function HeaderBlock({ user, team, children }: Props) {
function HeaderBlock({ teamName, subheading, logoUrl, ...rest }: Props) {
return (
<Header justify="space-between" align="center">
<Header justify="flex-start" align="center" {...rest}>
<TeamLogo src={logoUrl} />
<Flex align="flex-start" column>
<TeamName>{team.name}</TeamName>
<UserName>{user.name}</UserName>
<TeamName>{teamName}</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
{children}
</Header>
);
}
const UserName = styled.div`
font-size: 13px;
const Subheading = styled.div`
padding-left: 10px;
font-size: 11px;
text-transform: uppercase;
font-weight: 500;
color: ${color.slateDark};
`;
const TeamName = styled.div`
font-weight: bold;
padding-left: 10px;
font-weight: 600;
color: ${color.text};
text-decoration: none;
font-size: 16px;
@ -43,18 +48,9 @@ const Header = styled(Flex)`
&:active,
&:hover {
transition: background 100ms ease-in-out;
background: rgba(0, 0, 0, 0.05);
}
&::after {
content: '';
left: 24px;
right: 24px;
background: rgba(0, 0, 0, 0.075);
height: 1px;
position: absolute;
bottom: 0;
}
`;
export default HeaderBlock;

View File

@ -0,0 +1,13 @@
// @flow
import styled from 'styled-components';
import { color } from 'shared/styles/constants';
const TeamLogo = styled.img`
width: 38px;
height: 38px;
border-radius: 4px;
background: ${color.white};
border: 1px solid ${color.slateLight};
`;
export default TeamLogo;

View File

@ -1,22 +0,0 @@
// @flow
import React from 'react';
import { inject } from 'mobx-react';
import { slackAuth } from 'shared/utils/routeHelpers';
import AuthStore from 'stores/AuthStore';
type Props = {
children: React$Element<*>,
auth: AuthStore,
scopes?: string[],
redirectUri?: string,
};
function SlackAuthLink({ auth, children, scopes, redirectUri }: Props) {
return (
<a href={slackAuth(auth.getOauthState(), scopes, redirectUri)}>
{children}
</a>
);
}
export default inject('auth')(SlackAuthLink);

View File

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="148px" height="147px" viewBox="0 0 148 147" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs></defs>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g>
<g>
<g>
<path d="M12.997,77.78 C7.503,77.822 2.849,74.548 1.133,69.438 C1.067,69.24 1.01,69.048 0.955,68.855 C-0.915,62.311 2.711,55.465 9.21,53.273 L113.45,18.35 C114.717,17.987 115.993,17.802 117.257,17.794 C122.897,17.75 127.679,21.096 129.437,26.314 L129.593,26.818 C131.543,33.634 126.698,39.718 120.893,41.668 C120.889,41.671 119.833,42.028 17.231,77.059 C15.844,77.53 14.421,77.768 12.997,77.78 L12.997,77.78 L12.997,77.78 Z" fill="#70CADB"></path>
<path d="M30.372,129.045 C24.835,129.085 20.165,125.857 18.469,120.82 C18.405,120.628 18.344,120.435 18.289,120.241 C16.393,113.619 20.015,106.701 26.536,104.506 L130.78,69.263 C132.127,68.813 133.518,68.583 134.917,68.57 C140.469,68.528 145.347,71.92 147.068,77.014 L147.228,77.544 C148.235,81.065 147.64,85.022 145.638,88.145 C144.146,90.467 139.44,92.511 139.44,92.511 L34.8,128.29 C33.342,128.777 31.855,129.034 30.372,129.047 L30.372,129.045 L30.372,129.045 Z" fill="#E01765"></path>
<path d="M117.148,129.268 C111.588,129.311 106.665,125.803 104.893,120.545 L70.103,17.205 L69.929,16.625 C68.044,10.035 71.669,3.161 78.166,0.971 C79.466,0.534 80.81,0.306 82.163,0.294 C84.173,0.279 86.118,0.732 87.95,1.637 C91.013,3.162 93.304,5.787 94.399,9.029 L129.186,112.36 L129.287,112.692 C131.241,119.534 127.624,126.412 121.127,128.602 C119.84,129.031 118.5,129.256 117.148,129.268 L117.148,129.268 L117.148,129.268 Z" fill="#E8A723"></path>
<path d="M65.435,146.674 C59.875,146.717 54.948,143.209 53.175,137.944 L18.394,34.608 C18.334,34.418 18.274,34.228 18.216,34.033 C16.336,27.445 19.95,20.57 26.445,18.378 C27.74,17.948 29.079,17.721 30.43,17.71 C35.991,17.666 40.915,21.173 42.687,26.433 L77.469,129.773 C77.534,129.953 77.593,130.152 77.646,130.342 C79.53,136.935 75.914,143.814 69.409,146.006 C68.117,146.437 66.78,146.662 65.431,146.673 L65.435,146.673 L65.435,146.674 Z" fill="#3EB890"></path>
<path d="M99.997,105.996 L124.255,97.702 L116.325,74.152 L92.039,82.359 L99.997,105.996 L99.997,105.996 Z" fill="#CC2027"></path>
<path d="M48.364,123.65 L72.62,115.357 L64.63,91.627 L40.35,99.837 L48.364,123.65 L48.364,123.65 Z" fill="#361238"></path>
<path d="M82.727,54.7 L106.987,46.417 L99.15,23.142 L74.845,31.285 L82.727,54.7 L82.727,54.7 Z" fill="#65863A"></path>
<path d="M31.088,72.33 L55.348,64.047 L47.415,40.475 L23.11,48.617 L31.088,72.33 L31.088,72.33 Z" fill="#1A937D"></path>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -1,3 +0,0 @@
// @flow
import SlackAuthLink from './SlackAuthLink';
export default SlackAuthLink;

View File

@ -0,0 +1,17 @@
// @flow
import styled from 'styled-components';
import { color } from 'shared/styles/constants';
const Subheading = styled.h3`
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
color: ${color.slate};
letter-spacing: 0.04em;
border-bottom: 1px solid ${color.slateLight};
padding-bottom: 8px;
margin-top: 30px;
margin-bottom: 10px;
`;
export default Subheading;

View File

@ -10,6 +10,7 @@ import {
} from 'react-router-dom';
import stores from 'stores';
import SettingsStore from 'stores/SettingsStore';
import DocumentsStore from 'stores/DocumentsStore';
import CollectionsStore from 'stores/CollectionsStore';
import CacheStore from 'stores/CacheStore';
@ -22,6 +23,9 @@ import Starred from 'scenes/Starred';
import Collection from 'scenes/Collection';
import Document from 'scenes/Document';
import Search from 'scenes/Search';
import Settings from 'scenes/Settings';
import Slack from 'scenes/Settings/Slack';
import Tokens from 'scenes/Settings/Tokens';
import SlackAuth from 'scenes/SlackAuth';
import ErrorAuth from 'scenes/ErrorAuth';
import Error404 from 'scenes/Error404';
@ -54,6 +58,7 @@ const Auth = ({ children }: AuthProps) => {
const { user, team } = stores.auth;
const cache = new CacheStore(user.id);
authenticatedStores = {
settings: new SettingsStore(),
documents: new DocumentsStore({
ui: stores.ui,
cache,
@ -110,6 +115,14 @@ render(
<Switch>
<Route exact path="/dashboard" component={Dashboard} />
<Route exact path="/starred" component={Starred} />
<Route exact path="/settings" component={Settings} />
<Route exact path="/settings/tokens" component={Tokens} />
<Route
exact
path="/settings/integrations/slack"
component={Slack}
/>
<Route
exact
path="/collections/:id"

View File

@ -20,7 +20,7 @@ class AccountMenu extends Component {
};
handleOpenSettings = () => {
this.props.ui.setActiveModal('settings');
this.props.history.push('/settings');
};
handleApi = () => {

View File

@ -2,26 +2,15 @@
import React, { Component } from 'react';
import { observable } from 'mobx';
import { observer, inject } from 'mobx-react';
import styled from 'styled-components';
import DocumentsStore from 'stores/DocumentsStore';
import Flex from 'shared/components/Flex';
import DocumentList from 'components/DocumentList';
import PageTitle from 'components/PageTitle';
import Subheading from 'components/Subheading';
import CenteredContent from 'components/CenteredContent';
import { ListPlaceholder } from 'components/LoadingPlaceholder';
const Subheading = styled.h3`
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
color: #9fa6ab;
letter-spacing: 0.04em;
border-bottom: 1px solid #ddd;
padding-bottom: 10px;
margin-top: 30px;
`;
type Props = {
documents: DocumentsStore,
};

View File

@ -1,179 +1,43 @@
// @flow
import React, { Component } from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import ApiKeyRow from './components/ApiKeyRow';
import SettingsStore from './SettingsStore';
import { color } from 'shared/styles/constants';
import { observer, inject } from 'mobx-react';
import Flex from 'shared/components/Flex';
import Button from 'components/Button';
import AuthStore from 'stores/AuthStore';
import Input from 'components/Input';
import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle';
import HelpText from 'components/HelpText';
import { Label } from 'components/Labeled';
import SlackAuthLink from 'components/SlackAuthLink';
@observer
class Settings extends Component {
store: SettingsStore;
constructor() {
super();
this.store = new SettingsStore();
}
render() {
const showSlackSettings = DEPLOYMENT === 'hosted';
return (
<Flex column>
{showSlackSettings && (
<Section>
<SectionLabel>Slack</SectionLabel>
<HelpText>
Connect Outline to your Slack to instantly search for your
documents using <Code>/outline</Code> command.
</HelpText>
<SlackAuthLink
scopes={['commands']}
redirectUri={`${BASE_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>
</Section>
)}
<Section>
<SectionLabel>API Access</SectionLabel>
<HelpText>
Create API tokens to hack on your Outline. Learn more in{' '}
<Link to="/developers">API documentation</Link>.
</HelpText>
{this.store.apiKeys && (
<Table>
<tbody>
{this.store.apiKeys &&
this.store.apiKeys.map(key => (
<ApiKeyRow
id={key.id}
key={key.id}
name={key.name}
secret={key.secret}
onDelete={this.store.deleteApiKey}
/>
))}
</tbody>
</Table>
)}
<InlineForm
placeholder="Token name"
buttonLabel="Create token"
name="inline_form"
value={this.store.keyName}
onChange={this.store.setKeyName}
onSubmit={this.store.createApiKey}
disabled={this.store.isFetching}
/>
</Section>
</Flex>
);
}
}
@observer
class InlineForm extends Component {
props: {
placeholder: string,
buttonLabel: string,
name: string,
value: ?string,
onChange: Function,
onSubmit: Function,
disabled?: ?boolean,
};
@observable validationError: boolean = false;
validationTimeout: number;
componentWillUnmount() {
clearTimeout(this.validationTimeout);
}
handleSubmit = event => {
event.preventDefault();
if (this.props.value) {
this.props.onSubmit();
} else {
this.validationError = true;
this.validationTimeout = setTimeout(
() => (this.validationError = false),
2500
);
}
auth: AuthStore,
};
render() {
const { placeholder, value, onChange, buttonLabel } = this.props;
const { user } = this.props.auth;
if (!user) return null;
return (
<form onSubmit={this.handleSubmit}>
<Flex auto>
<ApiKeyInput
type="text"
placeholder={
this.validationError ? 'Please add a label' : placeholder
}
value={value || ''}
onChange={onChange}
validationError={this.validationError}
/>
<Button type="submit" value={buttonLabel} />
</Flex>
</form>
<CenteredContent>
<PageTitle title="Profile" />
<h1>Profile</h1>
<HelpText>
Youre signed in to Outline with Slack. To update your profile
information here please{' '}
<a href="https://slack.com/account/profile" target="_blank">
update your profile on Slack
</a>{' '}
and re-login to refresh.
</HelpText>
<form>
<Input label="Name" value={user.name} disabled />
<Input label="Email" value={user.email} disabled />
</form>
</CenteredContent>
);
}
}
const Section = styled.div`
margin-bottom: 40px;
`;
const Table = styled.table`
margin-bottom: 20px;
width: 100%;
td {
margin-right: 20px;
color: ${color.slate};
}
`;
const SectionLabel = styled(Label)`
padding-bottom: 12px;
margin-bottom: 20px;
border-bottom: 1px solid #eaebea;
`;
const Code = styled.code`
padding: 4px 6px;
margin: 0 2px;
background: #eaebea;
border-radius: 4px;
`;
const ApiKeyInput = styled(Input)`
width: 100%;
margin-right: 12px;
`;
export default Settings;
export default inject('auth')(Settings);

View File

@ -0,0 +1,39 @@
// @flow
import React, { Component } from 'react';
import { observer } from 'mobx-react';
import styled from 'styled-components';
import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle';
import HelpText from 'components/HelpText';
import SlackButton from './components/SlackButton';
@observer
class Slack extends Component {
render() {
return (
<CenteredContent>
<PageTitle title="Slack" />
<h1>Slack</h1>
<HelpText>
Connect Outline to your Slack team to instantly search for documents
using the <Code>/outline</Code> command.
</HelpText>
<SlackButton
scopes={['commands']}
redirectUri={`${BASE_URL}/auth/slack/commands`}
/>
</CenteredContent>
);
}
}
const Code = styled.code`
padding: 4px 6px;
margin: 0 2px;
background: #eaebea;
border-radius: 4px;
`;
export default Slack;

View File

@ -0,0 +1,99 @@
// @flow
import React, { Component } from 'react';
import { observable } from 'mobx';
import { observer, inject } from 'mobx-react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import ApiToken from './components/ApiToken';
import SettingsStore from 'stores/SettingsStore';
import { color } from 'shared/styles/constants';
import Button from 'components/Button';
import Input from 'components/Input';
import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle';
import HelpText from 'components/HelpText';
import Subheading from 'components/Subheading';
@observer
class Settings extends Component {
@observable name: string = '';
props: {
settings: SettingsStore,
};
componentDidMount() {
this.props.settings.fetchApiKeys();
}
handleUpdate = (ev: SyntheticInputEvent) => {
this.name = ev.target.value;
};
handleSubmit = async (ev: SyntheticEvent) => {
ev.preventDefault();
await this.props.settings.createApiKey(this.name);
this.name = '';
};
render() {
const { settings } = this.props;
const hasApiKeys = settings.apiKeys.length > 0;
return (
<CenteredContent>
<PageTitle title="API Tokens" />
<h1>API Tokens</h1>
{hasApiKeys && [
<Subheading>Your tokens</Subheading>,
<Table>
<tbody>
{settings.apiKeys.map(key => (
<ApiToken
id={key.id}
key={key.id}
name={key.name}
secret={key.secret}
onDelete={settings.deleteApiKey}
/>
))}
</tbody>
</Table>,
<Subheading>Create a token</Subheading>,
]}
<HelpText>
You can create unlimited personal API tokens to hack on your wiki.
Learn more in the <Link to="/developers">API documentation</Link>.
</HelpText>
<form onSubmit={this.handleSubmit}>
<Input
onChange={this.handleUpdate}
placeholder="Token label (eg. development)"
value={this.name}
required
/>
<Button
type="submit"
value="Create Token"
disabled={settings.isSaving}
/>
</form>
</CenteredContent>
);
}
}
const Table = styled.table`
margin-bottom: 30px;
width: 100%;
td {
margin-right: 20px;
color: ${color.slate};
}
`;
export default inject('settings')(Settings);

View File

@ -2,18 +2,17 @@
import React from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import styled from 'styled-components';
import { color } from 'shared/styles/constants';
import Button from 'components/Button';
type Props = {
id: string,
name: ?string,
secret: string,
onDelete: Function,
onDelete: (id: string) => *,
};
@observer
class ApiKeyRow extends React.Component {
class ApiToken extends React.Component {
props: Props;
@observable disabled: boolean;
@ -32,21 +31,14 @@ class ApiKeyRow extends React.Component {
<td>
<code>{secret}</code>
</td>
<td>
<Action role="button" onClick={this.onClick} disabled={disabled}>
Action
</Action>
<td align="right">
<Button onClick={this.onClick} disabled={disabled} neutral>
Revoke
</Button>
</td>
</tr>
);
}
}
const Action = styled.span`
font-size: 14px;
color: ${color.text};
opacity: ${({ disabled }) => (disabled ? 0.5 : 1)};
`;
export default ApiKeyRow;
export default ApiToken;

View File

@ -0,0 +1,35 @@
// @flow
import React from 'react';
import styled from 'styled-components';
import { inject } from 'mobx-react';
import { slackAuth } from 'shared/utils/routeHelpers';
import Button from 'components/Button';
import SlackLogo from 'shared/components/SlackLogo';
import AuthStore from 'stores/AuthStore';
type Props = {
auth: AuthStore,
scopes?: string[],
redirectUri?: string,
};
function SlackButton({ auth, scopes, redirectUri }: Props) {
const handleClick = () =>
(window.location.href = slackAuth(
auth.getOauthState(),
scopes,
redirectUri
));
return (
<Button onClick={handleClick} icon={<SpacedSlackLogo size={24} />} neutral>
Add to <strong>Slack</strong>
</Button>
);
}
const SpacedSlackLogo = styled(SlackLogo)`
padding-right: 4px;
`;
export default inject('auth')(SlackButton);

View File

@ -4,11 +4,10 @@ import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import type { ApiKey } from 'types';
class SearchStore {
class SettingsStore {
@observable apiKeys: ApiKey[] = [];
@observable keyName: ?string;
@observable isFetching: boolean = false;
@observable isSaving: boolean = false;
@action
fetchApiKeys = async () => {
@ -29,50 +28,33 @@ class SearchStore {
};
@action
createApiKey = async () => {
this.isFetching = true;
createApiKey = async (name: string) => {
this.isSaving = true;
try {
const res = await client.post('/apiKeys.create', {
name: this.keyName ? this.keyName : 'Untitled key',
});
const res = await client.post('/apiKeys.create', { name });
invariant(res && res.data, 'Data should be available');
const { data } = res;
runInAction('createApiKey', () => {
this.apiKeys.push(data);
this.keyName = '';
});
} catch (e) {
console.error('Something went wrong');
}
this.isFetching = false;
this.isSaving = false;
};
@action
deleteApiKey = async (id: string) => {
this.isFetching = true;
try {
await client.post('/apiKeys.delete', {
id,
});
await client.post('/apiKeys.delete', { id });
runInAction('deleteApiKey', () => {
this.fetchApiKeys();
});
} catch (e) {
console.error('Something went wrong');
}
this.isFetching = false;
};
@action
setKeyName = (value: SyntheticInputEvent) => {
this.keyName = value.target.value;
};
constructor() {
this.fetchApiKeys();
}
}
export default SearchStore;
export default SettingsStore;

View File

@ -3,12 +3,14 @@ export type User = {
avatarUrl: string,
id: string,
name: string,
email: string,
username: string,
};
export type Team = {
id: string,
name: string,
avatarUrl: string,
};
export type NavigationNode = {

View File

@ -13,6 +13,7 @@ exports[`#user.info should return known user 1`] = `
Object {
"data": Object {
"avatarUrl": "http://example.com/avatar.png",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"name": "User 1",
"username": "user1",

View File

@ -3,6 +3,7 @@
exports[`presents a user 1`] = `
Object {
"avatarUrl": "http://example.com/avatar.png",
"email": undefined,
"id": "123",
"name": "Test User",
"username": "testuser",
@ -12,6 +13,7 @@ Object {
exports[`presents a user without slack data 1`] = `
Object {
"avatarUrl": null,
"email": undefined,
"id": "123",
"name": "Test User",
"username": "testuser",

View File

@ -7,6 +7,8 @@ function present(ctx: Object, team: Team) {
return {
id: team.id,
name: team.name,
avatarUrl:
team.avatarUrl || (team.slackData ? team.slackData.image_88 : null),
};
}

View File

@ -8,6 +8,7 @@ function present(ctx: Object, user: User) {
id: user.id,
username: user.username,
name: user.name,
email: user.email,
avatarUrl:
user.avatarUrl || (user.slackData ? user.slackData.image_192 : null),
};

View File

@ -1,14 +1,21 @@
// @flow
import React from 'react';
function SlackLogo() {
type Props = {
size?: number,
fill?: string,
className?: string,
};
function SlackLogo({ size = 34, fill = '#FFF', className }: Props) {
return (
<svg
fill="#fff"
width="34"
height="34"
fill={fill}
width={size}
height={size}
viewBox="0 0 34 34"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g>
<rect