summaryrefslogtreecommitdiff
path: root/ui/app/components
diff options
context:
space:
mode:
authorelijah <elijah@riseup.net>2016-09-16 14:02:32 -0700
committerKali Kaneko (leap communications) <kali@leap.se>2016-09-22 11:40:11 -0400
commit073393af311d36c8ca7570ff0d3f0a3117c0b544 (patch)
treee59286ac350ba17110392f53b6e48bcedfd12ef1 /ui/app/components
parentae5a20d059209f2027c05820dc3b4cfe7346c8a8 (diff)
[pkg] rename www to ui
Diffstat (limited to 'ui/app/components')
-rw-r--r--ui/app/components/area.js65
-rw-r--r--ui/app/components/center.js39
-rw-r--r--ui/app/components/debug_panel.js40
-rw-r--r--ui/app/components/error_panel.js21
-rw-r--r--ui/app/components/greeter_panel.js34
-rw-r--r--ui/app/components/list_edit.js122
-rw-r--r--ui/app/components/login.js302
-rw-r--r--ui/app/components/main_panel/account_list.js113
-rw-r--r--ui/app/components/main_panel/email_section.js48
-rw-r--r--ui/app/components/main_panel/index.js80
-rw-r--r--ui/app/components/main_panel/main_panel.less212
-rw-r--r--ui/app/components/main_panel/section_layout.js59
-rw-r--r--ui/app/components/main_panel/user_section.js71
-rw-r--r--ui/app/components/main_panel/vpn_section.js0
-rw-r--r--ui/app/components/panel_switcher.js58
-rw-r--r--ui/app/components/spinner/index.js15
-rw-r--r--ui/app/components/spinner/spinner.css42
-rw-r--r--ui/app/components/splash.js132
-rw-r--r--ui/app/components/wizard/add_provider_modal.js94
-rw-r--r--ui/app/components/wizard/index.js38
-rw-r--r--ui/app/components/wizard/provider_select_stage.js86
-rw-r--r--ui/app/components/wizard/stage_layout.js37
-rw-r--r--ui/app/components/wizard/wizard.less44
23 files changed, 1752 insertions, 0 deletions
diff --git a/ui/app/components/area.js b/ui/app/components/area.js
new file mode 100644
index 0000000..e903e5f
--- /dev/null
+++ b/ui/app/components/area.js
@@ -0,0 +1,65 @@
+//
+// A bootstrap panel, but with some extra options
+//
+
+import React from 'react'
+// import {Panel} from 'react-bootstrap'
+
+class Area extends React.Component {
+
+ static get defaultProps() {return{
+ position: null, // top or bottom
+ size: 'small', // small or big
+ type: null, // light or dark
+ className: null
+ }}
+
+ constructor(props) {
+ super(props)
+ }
+
+ render() {
+ let style = {}
+ let innerstyle = {}
+ if (this.props.position == 'top') {
+ style.borderBottomRightRadius = '0px'
+ style.borderBottomLeftRadius = '0px'
+ style.marginBottom = '0px'
+ style.borderBottom = '0px'
+ if (this.props.size == 'big') {
+ innerstyle.padding = '25px'
+ }
+ } else if (this.props.position == 'bottom') {
+ style.borderTopRightRadius = '0px'
+ style.borderTopLeftRadius = '0px'
+ style.borderTop = '0px'
+ if (this.props.size == 'big') {
+ innerstyle.padding = '15px 25px'
+ }
+ }
+
+ let type = this.props.type ? "area-" + this.props.type : ""
+ let className = ['panel', 'panel-default', type, this.props.className].join(' ')
+ return(
+ <div className={className} style={style}>
+ <div className="panel-body" style={innerstyle}>
+ {this.props.children}
+ </div>
+ </div>
+ )
+ }
+
+}
+
+// Area.propTypes = {
+// children: React.PropTypes.oneOfType([
+// React.PropTypes.element,
+// React.PropTypes.arrayOf(React.PropTypes.element)
+// ])
+// }
+
+//Area.propTypes = {
+// children: React.PropTypes.element.isRequired
+//}
+
+export default Area
diff --git a/ui/app/components/center.js b/ui/app/components/center.js
new file mode 100644
index 0000000..6fa6212
--- /dev/null
+++ b/ui/app/components/center.js
@@ -0,0 +1,39 @@
+//
+// puts a block right in the center of the window
+//
+
+import React from 'react'
+
+class Center extends React.Component {
+
+ static get defaultProps() {return{
+ width: null
+ }}
+
+ constructor(props) {
+ super(props)
+ }
+
+ render() {
+ let style = null
+ if (this.props.width) {
+ style = {width: this.props.width + 'px'}
+ }
+ return (
+ <div className="center-container">
+ <div className="center-item" style={style}>
+ {this.props.children}
+ </div>
+ </div>
+ )
+ }
+}
+
+Center.propTypes = {
+ children: React.PropTypes.oneOfType([
+ React.PropTypes.element,
+ React.PropTypes.arrayOf(React.PropTypes.element)
+ ])
+}
+
+export default Center
diff --git a/ui/app/components/debug_panel.js b/ui/app/components/debug_panel.js
new file mode 100644
index 0000000..7515ba8
--- /dev/null
+++ b/ui/app/components/debug_panel.js
@@ -0,0 +1,40 @@
+import React from 'react'
+import App from '../app'
+
+
+class DebugPanel extends React.Component {
+
+ constructor(props) {
+ super(props)
+ this.click = this.click.bind(this)
+ }
+
+ componentDidMount() {
+ this.click(window.location.hash.replace('#', ''))
+ }
+
+ click(panel_name) {
+ window.location.hash = panel_name
+ App.show(panel_name)
+ }
+
+ panel(panel_name) {
+ return elem(
+ 'a',
+ { onClick: () => this.click(panel_name), key: panel_name },
+ panel_name
+ )
+ }
+
+ render() {
+ return elem('div', {className: 'debug-panel'},
+ this.panel('splash'),
+ this.panel('greeter'),
+ this.panel('wizard'),
+ this.panel('main')
+ )
+ }
+
+}
+
+export default DebugPanel \ No newline at end of file
diff --git a/ui/app/components/error_panel.js b/ui/app/components/error_panel.js
new file mode 100644
index 0000000..fc88d45
--- /dev/null
+++ b/ui/app/components/error_panel.js
@@ -0,0 +1,21 @@
+import React from 'react'
+import Center from './center'
+import Area from './area'
+
+export default class ErrorPanel extends React.Component {
+
+ constructor(props) {
+ super(props)
+ }
+
+ render () {
+ return (
+ <Center width="400">
+ <Area>
+ <h1>Error</h1>
+ {this.props.error}
+ </Area>
+ </Center>
+ )
+ }
+}
diff --git a/ui/app/components/greeter_panel.js b/ui/app/components/greeter_panel.js
new file mode 100644
index 0000000..4552db1
--- /dev/null
+++ b/ui/app/components/greeter_panel.js
@@ -0,0 +1,34 @@
+import React from 'react'
+import Login from './login'
+import Center from './center'
+import Splash from './splash'
+import Area from './area'
+import { Glyphicon } from 'react-bootstrap'
+import App from 'app'
+
+export default class GreeterPanel extends React.Component {
+
+ constructor(props) {
+ super(props)
+ }
+
+ newAccount() {
+ App.show('wizard')
+ }
+
+ render () {
+ return <div>
+ <Splash speed="slow" mask={false} />
+ <Center width="400">
+ <Area position="top" type="light" className="greeter">
+ <Login {...this.props} rememberAllowed={false}/>
+ </Area>
+ <Area position="bottom" type="dark" className="greeter">
+ <Glyphicon glyph="user" />
+ &nbsp;
+ <a href="#" onClick={this.newAccount.bind(this)}>Create a new account...</a>
+ </Area>
+ </Center>
+ </div>
+ }
+}
diff --git a/ui/app/components/list_edit.js b/ui/app/components/list_edit.js
new file mode 100644
index 0000000..0d557d2
--- /dev/null
+++ b/ui/app/components/list_edit.js
@@ -0,0 +1,122 @@
+//
+// A simple list of items, with minus and plus buttons to add and remove
+// items.
+//
+
+import React from 'react'
+import {Button, ButtonGroup, ButtonToolbar, Glyphicon, FormControl} from 'react-bootstrap'
+
+const CONTAINER_CSS = {
+ display: "flex",
+ flexDirection: "column"
+}
+const SELECT_CSS = {
+ padding: "0px",
+ flex: "1 1 1000px",
+ overflowY: "scroll"
+}
+const OPTION_CSS = {
+ padding: "10px"
+}
+const TOOLBAR_CSS = {
+ paddingTop: "10px",
+ flex: "0 0 auto"
+}
+
+class ListEdit extends React.Component {
+
+ static get defaultProps() {return{
+ width: null,
+ items: [
+ 'aaaaaaa',
+ 'bbbbbbb',
+ 'ccccccc'
+ ],
+ selected: null,
+ onRemove: null,
+ onAdd: null,
+ }}
+
+ constructor(props) {
+ super(props)
+ let index = 0
+ if (props.selected) {
+ index = props.items.indexOf(props.selected)
+ }
+ this.state = {
+ selected: index
+ }
+ this.click = this.click.bind(this)
+ this.add = this.add.bind(this)
+ this.remove = this.remove.bind(this)
+ }
+
+ setSelected(index) {
+ this.setState({
+ selected: index
+ })
+ }
+
+ click(e) {
+ let row = parseInt(e.target.value)
+ if (row >= 0) {
+ this.setState({selected: row})
+ }
+ }
+
+ add() {
+ if (this.props.onAdd) {
+ this.props.onAdd()
+ }
+ }
+
+ remove() {
+ if (this.state.selected >= 0 && this.props.onRemove) {
+ if (this.props.items.length == this.state.selected + 1) {
+ // if we remove the last item, set the selected item
+ // to the one right before it.
+ this.setState({selected: (this.state.selected - 1)})
+ }
+ this.props.onRemove(this.props.items[this.state.selected])
+ }
+ }
+
+ render() {
+ let options = null
+ if (this.props.items) {
+ options = this.props.items.map((item, i) => {
+ return <option style={OPTION_CSS} key={i} value={i}>{item}</option>
+ }, this)
+ }
+ return(
+ <div style={CONTAINER_CSS}>
+ <FormControl
+ value={this.state.selected}
+ style={SELECT_CSS} className="select-list"
+ componentClass="select" size="5" onChange={this.click}>
+ {options}
+ </FormControl>
+ <ButtonToolbar className="pull-right" style={TOOLBAR_CSS}>
+ <ButtonGroup>
+ <Button onClick={this.add}>
+ <Glyphicon glyph="plus" />
+ </Button>
+ <Button disabled={this.state.selected < 0} onClick={this.remove}>
+ <Glyphicon glyph="minus" />
+ </Button>
+ </ButtonGroup>
+ </ButtonToolbar>
+ </div>
+ )
+ }
+
+}
+
+ListEdit.propTypes = {
+ children: React.PropTypes.oneOfType([
+ React.PropTypes.element,
+ React.PropTypes.arrayOf(React.PropTypes.element)
+ ])
+}
+
+export default ListEdit
diff --git a/ui/app/components/login.js b/ui/app/components/login.js
new file mode 100644
index 0000000..fe4ef5b
--- /dev/null
+++ b/ui/app/components/login.js
@@ -0,0 +1,302 @@
+import React from 'react'
+import ReactDOM from 'react-dom'
+
+import { FormGroup, ControlLabel, FormControl, HelpBlock, Button,
+ Checkbox, Glyphicon, Overlay, Tooltip, Alert } from 'react-bootstrap'
+import Spinner from './spinner'
+
+import Validate from 'lib/validate'
+import App from 'app'
+import Account from 'models/account'
+
+class Login extends React.Component {
+
+ static get defaultProps() {return{
+ rememberAllowed: false, // if set, show remember password checkbox
+ domain: null, // if set, only allow this domain
+ onLogin: null
+ }}
+
+ constructor(props) {
+ super(props)
+
+ // validation states can be null, 'success', 'warning', or 'error'
+
+ this.state = {
+ loading: false,
+
+ authError: false, // authentication error message
+
+ username: "etest1@riseup.net",
+ usernameState: null, // username validation state
+ usernameError: false, // username help message
+
+ password: "whatever",
+ passwordState: null, // password validation state
+ passwordError: false, // password help message
+
+ disabled: false,
+ remember: false // remember is checked?
+ }
+
+ // prebind:
+ this.onUsernameChange = this.onUsernameChange.bind(this)
+ this.onUsernameBlur = this.onUsernameBlur.bind(this)
+ this.onPassword = this.onPassword.bind(this)
+ this.onSubmit = this.onSubmit.bind(this)
+ this.onRemember = this.onRemember.bind(this)
+ }
+
+ componentDidMount() {
+ Validate.loadPasswdLib()
+ }
+
+ render () {
+ let rememberCheck = ""
+ let submitButton = ""
+ let usernameHelp = null
+ let passwordHelp = null
+ let message = null
+
+ if (this.props.rememberAllowed) {
+ let props = {
+ style: {marginTop: "0px"},
+ onChange: this.onRemember
+ }
+
+ if (this.state.remember) {
+ rememberCheck = <Checkbox {...props} checked>
+ Remember username and password
+ </Checkbox>
+ } else {
+ rememberCheck = <Checkbox {...props}>
+ Remember username and password
+ </Checkbox>
+ }
+ }
+
+ if (this.state.authError) {
+ // style may be: success, warning, danger, info
+ message = (
+ <Alert bsStyle="danger">{this.state.authError}</Alert>
+ )
+ }
+
+ if (this.state.usernameError) {
+ usernameHelp = <HelpBlock>{this.state.usernameError}</HelpBlock>
+ // let props = {shouldUpdatePosition: true, show:true, placement:"right",
+ // target:this.refs.username}
+ // usernameHelp = (
+ // <Overlay {...props}>
+ // <Tooltip id="username-tooltip">{this.state.usernameError}</Tooltip>
+ // </Overlay>
+ // )
+ } else {
+ //usernameHelp = <HelpBlock>&nbsp;</HelpBlock>
+ }
+
+ if (this.state.passwordError) {
+ passwordHelp = <HelpBlock>{this.state.passwordError}</HelpBlock>
+ // let props = {shouldUpdatePosition: true, show:true, placement:"right",
+ // target:this.refs.password, component: {this}}
+ // passwordHelp = (
+ // <Overlay {...props}>
+ // <Tooltip id="password-tooltip">{this.state.passwordError}</Tooltip>
+ // </Overlay>
+ // )
+ } else {
+ //passwordHelp = <HelpBlock>&nbsp;</HelpBlock>
+ }
+
+ let buttonProps = {
+ type: "button",
+ onClick: this.onSubmit,
+ disabled: !this.maySubmit()
+ }
+ if (this.state.loading) {
+ submitButton = <Button block {...buttonProps}><Spinner /></Button>
+ } else {
+ submitButton = <Button block {...buttonProps}>Log In</Button>
+ }
+
+ let usernameref = null
+ if (this.props.domain) {
+ usernameref = function(c) {
+ if (c != null) {
+ let textarea = ReactDOM.findDOMNode(c)
+ let start = textarea.value.indexOf('@')
+ if (textarea.selectionStart > start) {
+ textarea.setSelectionRange(start, start)
+ }
+ }
+ }
+ }
+
+ let form = <form onSubmit={this.onSubmit}>
+ {message}
+ <FormGroup style={{marginBottom: '10px' }} controlId="loginUsername" validationState={this.state.usernameState}>
+ <ControlLabel>Username</ControlLabel>
+ <FormControl
+ componentClass="textarea"
+ style={{resize: "none"}}
+ rows="1"
+ ref={usernameref}
+ autoFocus
+ value={this.state.username}
+ onChange={this.onUsernameChange}
+ onBlur={this.onUsernameBlur} />
+ {this.state.usernameState == 'success' ? null : <FormControl.Feedback/>}
+ {usernameHelp}
+ </FormGroup>
+
+ <FormGroup controlId="loginPassword" validationState={this.state.passwordState}>
+ <ControlLabel>Password</ControlLabel>
+ <FormControl
+ type="password"
+ ref="password"
+ value={this.state.password}
+ onChange={this.onPassword} />
+ {this.state.passwordState == 'success' ? null : <FormControl.Feedback/>}
+ {passwordHelp}
+ </FormGroup>
+
+ {submitButton}
+ {rememberCheck}
+ </form>
+
+ return form
+ }
+
+ //
+ // Here we do a partial validation, because the user has not stopped typing.
+ //
+ onUsernameChange(e) {
+ let username = e.target.value.toLowerCase().replace("\n", "")
+ if (this.props.domain) {
+ let [userpart, domainpart] = username.split(
+ new RegExp('@|' + this.props.domain.replace(".", "\\.") + '$')
+ )
+ username = [userpart, this.props.domain].join('@')
+ }
+ let error = Validate.usernameInteractive(username, this.props.domain)
+ let state = null
+ if (error) {
+ state = 'error'
+ } else {
+ if (username && username.length > 0) {
+ let finalError = Validate.username(username)
+ state = finalError ? null : 'success'
+ }
+ }
+ this.setState({
+ username: username,
+ usernameState: state,
+ usernameError: error ? error : null
+ })
+ }
+
+ //
+ // Here we do a more complete validation, since the user have left the field.
+ //
+ onUsernameBlur(e) {
+ let username = e.target.value.toLowerCase()
+ this.setState({
+ username: username
+ })
+ if (username.length > 0) {
+ this.validateUsername(username)
+ } else {
+ this.setState({
+ usernameState: null,
+ usernameError: null
+ })
+ }
+ }
+
+ onPassword(e) {
+ let password = e.target.value
+ this.setState({password: password})
+ if (password.length > 0) {
+ this.validatePassword(password)
+ } else {
+ this.setState({
+ passwordState: null,
+ passwordError: null
+ })
+ }
+ }
+
+ onRemember(e) {
+ let currentValue = e.target.value == 'on' ? true : false
+ let value = !currentValue
+ this.setState({remember: value})
+ }
+
+ validateUsername(username) {
+ let error = Validate.username(username, this.props.domain)
+ this.setState({
+ usernameState: error ? 'error' : 'success',
+ usernameError: error ? error : null
+ })
+ }
+
+ validatePassword(password) {
+ let state = null
+ let message = null
+ let result = Validate.passwordStrength(password)
+ if (result) {
+ message = "Time to crack: " + result.crack_times_display.offline_slow_hashing_1e4_per_second
+ if (result.score == 0) {
+ state = 'error'
+ } else if (result.score == 1 || result.score == 2) {
+ state = 'warning'
+ } else {
+ state = 'success'
+ }
+ }
+ this.setState({
+ passwordState: state,
+ passwordError: message
+ })
+ }
+
+ maySubmit() {
+ return(
+ !this.stateLoading &&
+ !this.state.usernameError &&
+ this.state.username != "" &&
+ this.state.password != ""
+ )
+ }
+
+ onSubmit(e) {
+ e.preventDefault() // don't reload the page please!
+ if (!this.maySubmit()) { return }
+ this.setState({loading: true})
+
+ let account = Account.find(this.state.username)
+ account.login(this.state.password).then(
+ account => {
+ this.setState({loading: false})
+ if (this.props.onLogin) {
+ this.props.onLogin(account)
+ }
+ },
+ error => {
+ console.log(error)
+ if (error == "") {
+ error = 'Something failed, but we did not get a message'
+ }
+ this.setState({
+ loading: false,
+ usernameState: 'error',
+ passwordState: 'error',
+ authError: error
+ })
+ }
+ )
+ }
+
+}
+
+export default Login \ No newline at end of file
diff --git a/ui/app/components/main_panel/account_list.js b/ui/app/components/main_panel/account_list.js
new file mode 100644
index 0000000..d0ef092
--- /dev/null
+++ b/ui/app/components/main_panel/account_list.js
@@ -0,0 +1,113 @@
+import React from 'react'
+import {Button, ButtonGroup, ButtonToolbar, Glyphicon} from 'react-bootstrap'
+
+import App from 'app'
+import Account from 'models/account'
+
+export default class AccountList extends React.Component {
+
+ static get defaultProps() {return{
+ account: null,
+ accounts: [],
+ onAdd: null,
+ onRemove: null,
+ onSelect: null
+ }}
+
+ constructor(props) {
+ super(props)
+
+ this.state = {
+ mode: 'expanded'
+ }
+
+ // prebind:
+ this.select = this.select.bind(this)
+ this.add = this.add.bind(this)
+ this.remove = this.remove.bind(this)
+ this.expand = this.expand.bind(this)
+ this.collapse = this.collapse.bind(this)
+ }
+
+ select(e) {
+ let account = this.props.accounts.find(
+ account => account.id == e.currentTarget.dataset.id
+ )
+ if (this.props.onSelect) {
+ this.props.onSelect(account)
+ }
+ }
+
+ add() {
+ App.show('wizard')
+ }
+
+ remove() {
+ }
+
+ expand() {
+ this.setState({mode: 'expanded'})
+ }
+
+ collapse() {
+ this.setState({mode: 'collapsed'})
+ }
+
+ render() {
+ let style = {}
+ let expandButton = null
+ let plusminusButtons = null
+
+ if (this.state.mode == 'expanded') {
+ expandButton = (
+ <Button onClick={this.collapse} className="expander btn-inverse btn-flat pull-right">
+ <Glyphicon glyph="triangle-left" />
+ </Button>
+ )
+ plusminusButtons = (
+ <ButtonGroup style={style}>
+ <Button onClick={this.add} className="btn-inverse">
+ <Glyphicon glyph="plus" />
+ </Button>
+ <Button disabled={this.props.account == null} onClick={this.remove} className="btn-inverse">
+ <Glyphicon glyph="minus" />
+ </Button>
+ </ButtonGroup>
+ )
+ } else {
+ style.width = '60px'
+ expandButton = (
+ <Button onClick={this.expand} className="expander btn-inverse btn-flat pull-right">
+ <Glyphicon glyph="triangle-right" />
+ </Button>
+ )
+ }
+
+ let items = this.props.accounts.map((account, i) => {
+ let className = account == this.props.account ? 'active' : 'inactive'
+ return (
+ <li key={i} className={className} onClick={this.select} data-id={account.id}>
+ <span className="username">{account.userpart}</span>
+ <span className="domain">{account.domain}</span>
+ <span className="arc top"></span>
+ <span className="arc bottom"></span>
+ </li>
+ )
+ })
+
+
+ return (
+ <div className="accounts" style={style}>
+ <ul>
+ {items}
+ </ul>
+ <ButtonToolbar>
+ {plusminusButtons}
+ {expandButton}
+ </ButtonToolbar>
+ </div>
+ )
+ }
+
+
+}
diff --git a/ui/app/components/main_panel/email_section.js b/ui/app/components/main_panel/email_section.js
new file mode 100644
index 0000000..a6525d9
--- /dev/null
+++ b/ui/app/components/main_panel/email_section.js
@@ -0,0 +1,48 @@
+import React from 'react'
+//import { Button, Glyphicon, Alert } from 'react-bootstrap'
+import SectionLayout from './section_layout'
+import Account from 'models/account'
+import Spinner from 'components/spinner'
+import bitmask from 'lib/bitmask'
+
+export default class EmailSection extends React.Component {
+
+ static get defaultProps() {return{
+ account: null
+ }}
+
+ constructor(props) {
+ super(props)
+ this.state = {
+ status: null
+ }
+ this.openKeys = this.openKeys.bind(this)
+ this.openApp = this.openApp.bind(this)
+ this.openPrefs = this.openPrefs.bind(this)
+
+ console.log('email constructor')
+ }
+
+ openKeys() {}
+ openApp() {}
+ openPrefs() {}
+
+ render () {
+ //let message = null
+ //if (this.state.error) {
+ // // style may be: success, warning, danger, info
+ // message = (
+ // <Alert bsStyle="danger">{this.state.error}</Alert>
+ // )
+ //}
+ let button = null
+ if (this.state.status == 'ready') {
+ button = <Button onClick={this.openApp}>Open Email</Button>
+ }
+ return (
+ <SectionLayout icon="envelope" status="on" button={button}>
+ <h1>inbox: </h1>
+ </SectionLayout>
+ )
+ }
+}
diff --git a/ui/app/components/main_panel/index.js b/ui/app/components/main_panel/index.js
new file mode 100644
index 0000000..3cc6c11
--- /dev/null
+++ b/ui/app/components/main_panel/index.js
@@ -0,0 +1,80 @@
+//
+// The main panel manages the current account and the list of available accounts
+//
+// It displays multiple sections, one for each service.
+//
+
+import React from 'react'
+import App from 'app'
+import Login from 'components/login'
+import Account from 'models/account'
+import DummyAccount from 'models/dummy_account'
+
+import './main_panel.less'
+import AccountList from './account_list'
+import UserSection from './user_section'
+import EmailSection from './email_section'
+
+export default class MainPanel extends React.Component {
+
+ static get defaultProps() {return{
+ initialAccount: null
+ }}
+
+ constructor(props) {
+ super(props)
+ this.state = {
+ account: null,
+ accounts: []
+ }
+ this.activateAccount = this.activateAccount.bind(this)
+ }
+
+ componentWillMount() {
+ if (this.props.initialAccount) {
+ console.log(Account.list)
+ Account.add(this.props.initialAccount)
+ Account.add(new DummyAccount(this.props.initialAccount))
+ this.setState({
+ account: this.props.initialAccount,
+ accounts: Account.list
+ })
+ }
+ }
+
+ activateAccount(account) {
+ this.setState({
+ account: account,
+ accounts: Account.list
+ })
+ }
+
+ //setAccounts(accounts) {
+ // this.setState({
+ // accounts: accounts
+ // })
+ //}
+
+ render() {
+ let emailSection = null
+ let vpnSection = null
+
+ if (this.state.account.authenticated) {
+ if (this.state.account.hasEmail) {
+ emailSection = <EmailSection account={this.state.account} />
+ }
+ }
+
+ return (
+ <div className="main-panel">
+ <AccountList account={this.state.account} accounts={this.state.accounts} onSelect={this.activateAccount} />
+ <div className="body">
+ <UserSection account={this.state.account} onLogin={this.activateAccount} onLogout={this.activateAccount}/>
+ {vpnSection}
+ {emailSection}
+ </div>
+ </div>
+ )
+ }
+
+}
diff --git a/ui/app/components/main_panel/main_panel.less b/ui/app/components/main_panel/main_panel.less
new file mode 100644
index 0000000..4e0ecb0
--- /dev/null
+++ b/ui/app/components/main_panel/main_panel.less
@@ -0,0 +1,212 @@
+// The space around account entries:
+@accounts-padding: 8px;
+@accounts-corner: 6px;
+@accounts-width: 200px;
+
+//
+// LAYOUT
+//
+
+.main-panel {
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ display: -webkit-flex;
+ -webkit-flex-direction: row;
+
+ > .body {
+ -webkit-flex: 1 1 auto;
+ overflow: auto;
+ }
+
+ .accounts {
+ -webkit-flex: 0 0 auto;
+ overflow-y: auto;
+ overflow-x: hidden;
+
+ display: -webkit-flex;
+ -webkit-flex-direction: column;
+ ul {
+ -webkit-flex: 1 1 1000px;
+ }
+ .btn-toolbar {
+ -webkit-flex: 0 0 auto;
+ }
+
+ }
+
+}
+
+//
+// Style
+//
+
+
+.main-panel > .body {
+ padding: 20px;
+}
+
+.main-panel .accounts {
+ background-color: #333;
+ width: @accounts-width;
+ padding: @accounts-padding;
+ padding-right: 0px;
+}
+
+.main-panel .accounts ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.main-panel .accounts li {
+ position: relative;
+ cursor: pointer;
+ color: white;
+ padding: 15px;
+ background-color: #444;
+ margin-bottom: @accounts-padding;
+ border-top-left-radius: @accounts-corner - 1;
+ border-bottom-left-radius: @accounts-corner - 1;
+ z-index: 100;
+}
+
+.main-panel .accounts li span.domain {
+ display: block;
+ font-weight: bold;
+ //margin-left: 40px;
+}
+
+.main-panel .accounts li span.username {
+ display: block;
+ //margin-left: 40px;
+ //line-height: 7px;
+ //margin-bottom: 4px;
+}
+
+/*.main-panel .accounts li span.icon {
+ display: block;
+ height: 32px;
+ width: 32px;
+ background-color: #999;
+ float: left;
+}
+*/
+
+.main-panel .accounts li.active {
+ background-color: white;
+ color: #333;
+}
+
+.main-panel .accounts li.active span.arc {
+ display: block;
+ height: @accounts-corner;
+ width: @accounts-corner;
+ background-color: white;
+ position: absolute;
+ right: 0;
+}
+
+.main-panel .accounts li.active span.arc.top {
+ top: 0;
+ margin-top: -@accounts-corner;
+}
+.main-panel .accounts li.active span.arc.bottom {
+ bottom: 0;
+ margin-bottom: -@accounts-corner;
+}
+.main-panel .accounts li.active span.arc:after {
+ display: block;
+ content: "";
+ border-radius: 100%;
+ height: 0px;
+ width: 0px;
+ margin-left: -@accounts-corner;
+}
+.main-panel .accounts li.active span.arc.top:after {
+ border: @accounts-corner solid transparent;
+ border-right: @accounts-corner solid #333;
+ margin-top: -@accounts-corner;
+ -webkit-transform: rotate(45deg);
+ transform: rotate(45deg);
+}
+.main-panel .accounts li.active span.arc.bottom:after {
+ border: @accounts-corner solid #333;
+}
+
+.main-panel .accounts .btn.expander {
+ margin-right: @accounts-padding;
+}
+
+//
+// SECTIONS
+//
+
+@icon-size: 32px;
+@status-size: 24px;
+@section-padding: 10px;
+
+// service sections layout
+
+.main-panel .service-section {
+ display: -webkit-flex;
+ -webkit-flex-direction: row;
+ > .icon {
+ -webkit-flex: 0 0 auto;
+ }
+ > .body {
+ -webkit-flex: 1 1 auto;
+ }
+ > .buttons {
+ -webkit-flex: 0 0 auto;
+ }
+ > .status {
+ -webkit-flex: 0 0 auto;
+ display: -webkit-flex;
+ -webkit-align-items: center;
+ }
+
+}
+
+.main-panel .service-section div {
+ //outline: 1px solid rgba(0,0,0,0.1);
+}
+
+// service sections style
+
+.main-panel .service-section {
+ background: #f6f6f6;
+ border-radius: 4px;
+ padding: 10px;
+ margin-bottom: 10px;
+ &.wide-margin {
+ padding: 20px 20px 20px 10px; // arbitrary, looks nice
+ }
+ > .icon {
+ padding-right: @section-padding;
+ img {
+ width: @icon-size;
+ height: @icon-size;
+ }
+ }
+ > .body {
+ h1 {
+ margin: 0;
+ padding: 0;
+ font-size: @icon-size - 10;
+ line-height: @icon-size;
+ }
+ }
+ > .buttons {
+ padding-left: 10px;
+ }
+ > .status {
+ padding-left: @section-padding;
+ width: @section-padding + @status-size;
+ img {
+ width: @status-size;
+ height: @status-size;
+ }
+ }
+}
+
diff --git a/ui/app/components/main_panel/section_layout.js b/ui/app/components/main_panel/section_layout.js
new file mode 100644
index 0000000..e7c6f2a
--- /dev/null
+++ b/ui/app/components/main_panel/section_layout.js
@@ -0,0 +1,59 @@
+//
+// This is the layout for a service section in the main window.
+// It does not do anything except for arrange items using css and html.
+//
+
+import React from 'react'
+
+export default class SectionLayout extends React.Component {
+
+ static get defaultProps() {return{
+ icon: null,
+ buttons: null,
+ status: null,
+ className: "",
+ style: {}
+ }}
+
+ constructor(props) {
+ super(props)
+ }
+
+ render() {
+ let className = ["service-section", this.props.className].join(' ')
+ let status = null
+ let icon = null
+ let buttons = null
+
+ if (this.props.status) {
+ status = (
+ <div className="status">
+ <img src={'img/' + this.props.status + '.svg' } />
+ </div>
+ )
+ }
+ if (this.props.icon) {
+ icon = (
+ <div className="icon">
+ <img src={'img/' + this.props.icon + '.svg'} />
+ </div>
+ )
+ }
+ if (this.props.buttons)
+ buttons = (
+ <div className="buttons">
+ {this.props.buttons}
+ </div>
+ )
+ return(
+ <div className={className} style={this.props.style}>
+ {icon}
+ <div className="body">
+ {this.props.children}
+ </div>
+ {buttons}
+ {status}
+ </div>
+ )
+ }
+}
diff --git a/ui/app/components/main_panel/user_section.js b/ui/app/components/main_panel/user_section.js
new file mode 100644
index 0000000..0b4ba13
--- /dev/null
+++ b/ui/app/components/main_panel/user_section.js
@@ -0,0 +1,71 @@
+import React from 'react'
+import { Button, Glyphicon, Alert } from 'react-bootstrap'
+import SectionLayout from './section_layout'
+import Login from 'components/login'
+import Spinner from 'components/spinner'
+import Account from 'models/account'
+
+import bitmask from 'lib/bitmask'
+
+export default class UserSection extends React.Component {
+
+ static get defaultProps() {return{
+ account: null,
+ onLogout: null,
+ onLogin: null
+ }}
+
+ constructor(props) {
+ super(props)
+ this.state = {
+ error: null,
+ loading: false
+ }
+ this.logout = this.logout.bind(this)
+ }
+
+ logout() {
+ this.setState({loading: true})
+ this.props.account.logout().then(
+ account => {
+ this.setState({error: null, loading: false})
+ if (this.props.onLogout) {
+ this.props.onLogout(account)
+ }
+ }, error => {
+ this.setState({error: error, loading: false})
+ }
+ )
+ }
+
+ render () {
+ let message = null
+ if (this.state.error) {
+ // style may be: success, warning, danger, info
+ message = (
+ <Alert bsStyle="danger">{this.state.error}</Alert>
+ )
+ }
+
+ if (this.props.account.authenticated) {
+ let button = null
+ if (this.state.loading) {
+ button = <Button disabled={true}><Spinner /></Button>
+ } else {
+ button = <Button onClick={this.logout}>Log Out</Button>
+ }
+ return (
+ <SectionLayout icon="user" buttons={button} status="on">
+ <h1>{this.props.account.address}</h1>
+ {message}
+ </SectionLayout>
+ )
+ } else {
+ return (
+ <SectionLayout icon="user" className="wide-margin">
+ <Login onLogin={this.props.onLogin} domain={this.props.account.domain} />
+ </SectionLayout>
+ )
+ }
+ }
+}
diff --git a/ui/app/components/main_panel/vpn_section.js b/ui/app/components/main_panel/vpn_section.js
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ui/app/components/main_panel/vpn_section.js
diff --git a/ui/app/components/panel_switcher.js b/ui/app/components/panel_switcher.js
new file mode 100644
index 0000000..aaf2dc5
--- /dev/null
+++ b/ui/app/components/panel_switcher.js
@@ -0,0 +1,58 @@
+import React from 'react'
+import ReactDOM from 'react-dom'
+
+import DebugPanel from './debug_panel'
+import Splash from './splash'
+import GreeterPanel from './greeter_panel'
+import MainPanel from './main_panel'
+import Wizard from './wizard'
+
+import App from 'app'
+import 'lib/common'
+
+export default class PanelSwitcher extends React.Component {
+
+ constructor(props) {
+ super(props)
+ this.state = {
+ panel: null,
+ panel_properties: null,
+ debug: false
+ }
+ App.switcher = this
+ }
+
+ show(component_name, properties={}) {
+ this.setState({panel: component_name, panel_properties: properties})
+ }
+
+ render() {
+ let elems = []
+ if (this.panelExist(this.state.panel)) {
+ elems.push(
+ this.panelRender(this.state.panel, this.state.panel_properties)
+ )
+ }
+ if (this.state.debug) {
+ elems.push(
+ elem(DebugPanel, {key: 'debug'})
+ )
+ }
+ return <div id="root">{elems}</div>
+ }
+
+ panelExist(panel) {
+ return panel && this['render_'+panel]
+ }
+
+ panelRender(panel_name, props) {
+ let panel = this['render_'+panel_name](props)
+ return elem('div', {key: 'panel'}, panel)
+ }
+
+ render_splash(props) {return elem(Splash, props)}
+ render_wizard(props) {return elem(Wizard, props)}
+ render_greeter(props) {return elem(GreeterPanel, props)}
+ render_main(props) {return elem(MainPanel, props)}
+
+}
diff --git a/ui/app/components/spinner/index.js b/ui/app/components/spinner/index.js
new file mode 100644
index 0000000..ffc3285
--- /dev/null
+++ b/ui/app/components/spinner/index.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import './spinner.css';
+
+class Spinner extends React.Component {
+ render() {
+ let props = {}
+ return <div className="spinner">
+ <div className="spin1"></div>
+ <div className="spin2"></div>
+ <div className="spin3"></div>
+ </div>
+ }
+}
+
+export default Spinner \ No newline at end of file
diff --git a/ui/app/components/spinner/spinner.css b/ui/app/components/spinner/spinner.css
new file mode 100644
index 0000000..5e8535c
--- /dev/null
+++ b/ui/app/components/spinner/spinner.css
@@ -0,0 +1,42 @@
+.spinner {
+ height: 18px;
+ display: inline-block;
+}
+
+.spinner > div {
+ width: 18px;
+ height: 18px;
+ background-color: #000;
+ vertical-align: middle;
+ border-radius: 100%;
+ display: inline-block;
+ -webkit-animation: bouncedelay 1.5s infinite ease-in-out;
+ animation: bouncedelay 1.5s infinite ease-in-out;
+ -webkit-animation-fill-mode: both;
+ animation-fill-mode: both;
+}
+
+.spinner .spin1 {
+ -webkit-animation-delay: -.46s;
+ animation-delay: -.46s;
+}
+.spinner .spin2 {
+ -webkit-animation-delay: -.24s;
+ animation-delay: -.24s;
+}
+
+@-webkit-keyframes bouncedelay {
+ 0%, 80%, 100% {
+ -webkit-transform: scale(0.5);
+ } 40% {
+ -webkit-transform: scale(0.9);
+ }
+}
+
+@keyframes bouncedelay {
+ 0%, 80%, 100% {
+ transform: scale(0.5);
+ } 40% {
+ transform: scale(0.9);
+ }
+}
diff --git a/ui/app/components/splash.js b/ui/app/components/splash.js
new file mode 100644
index 0000000..46170d2
--- /dev/null
+++ b/ui/app/components/splash.js
@@ -0,0 +1,132 @@
+/*
+ * A simple animated splash screen
+ */
+
+import React from 'react'
+import * as COLOR from '../lib/colors'
+
+const colorList = [
+ COLOR.red200, COLOR.pink200, COLOR.purple200, COLOR.deepPurple200,
+ COLOR.indigo200, COLOR.blue200, COLOR.lightBlue200, COLOR.cyan200,
+ COLOR.teal200, COLOR.green200, COLOR.lightGreen200, COLOR.lime200,
+ COLOR.yellow200, COLOR.amber200, COLOR.orange200, COLOR.deepOrange200
+]
+
+export default class Splash extends React.Component {
+
+ static get defaultProps() {return{
+ speed: "fast",
+ mask: true,
+ onClick: null
+ }}
+
+ constructor(props) {
+ super(props)
+ this.counter = 0
+ this.interval = null
+ this.ctx = null
+ this.stepAngle = 0
+ this.resize = this.resize.bind(this)
+ this.click = this.click.bind(this)
+ if (this.props.speed == "fast") {
+ this.fps = 30
+ this.stepAngle = 0.005
+ } else {
+ this.fps = 30
+ this.stepAngle = 0.0005
+ }
+ }
+
+ componentDidMount() {
+ this.interval = setInterval(this.tick.bind(this), 1000/this.fps)
+ this.canvas = this.refs.canvas
+ this.ctx = this.canvas.getContext('2d')
+ window.addEventListener('resize', this.resize)
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.interval)
+ window.removeEventListener('resize', this.resize)
+ }
+
+ click() {
+ if (this.props.onClick) {
+ this.props.onClick()
+ }
+ }
+
+ tick() {
+ this.counter++
+ this.updateCanvas()
+ }
+
+ resize() {
+ this.canvas.width = window.innerWidth
+ this.canvas.height = window.innerHeight
+ this.updateCanvas()
+ }
+
+ updateCanvas() {
+ const arcCount = 16
+ const arcAngle = 1 / arcCount
+ const x = this.canvas.width / 2
+ const y = this.canvas.height / 2
+ const radius = screen.height + screen.width
+
+ for (let i = 0; i < arcCount; i++) {
+ let startAngle = Math.PI * 2 * i/arcCount + this.stepAngle*this.counter
+ let endAngle = Math.PI * 2 * (i+1)/arcCount + this.stepAngle*this.counter
+
+ this.ctx.fillStyle = colorList[i % colorList.length]
+ this.ctx.strokeStyle = colorList[i % colorList.length]
+ this.ctx.beginPath()
+ this.ctx.moveTo(x, y)
+ this.ctx.arc(x, y, radius, startAngle, endAngle)
+ this.ctx.lineTo(x, y)
+ this.ctx.fill()
+ this.ctx.stroke()
+ }
+
+ }
+
+ render () {
+ let overlay = null
+ let mask = null
+ if (this.props.onClick) {
+ overlay = React.DOM.div({
+ style: {
+ position: 'absolute',
+ height: '100%',
+ width: '100%',
+ backgroundColor: 'transparent'
+ },
+ onClick: this.click
+ })
+ }
+ if (this.props.mask) {
+ mask = React.DOM.img({
+ src: 'img/mask.svg',
+ style: {
+ position: 'absolute',
+ left: '50%',
+ top: '50%',
+ marginLeft: -330/2 + 'px',
+ marginTop: -174/2 + 'px',
+ }
+ })
+ }
+ return React.DOM.div(
+ {style: {overflow: 'hidden'}},
+ React.DOM.canvas({
+ ref: 'canvas',
+ style: {position: 'absolute'},
+ width: window.innerWidth,
+ height: window.innerHeight,
+ }),
+ mask,
+ overlay
+ )
+ }
+
+}
+
diff --git a/ui/app/components/wizard/add_provider_modal.js b/ui/app/components/wizard/add_provider_modal.js
new file mode 100644
index 0000000..bc5e023
--- /dev/null
+++ b/ui/app/components/wizard/add_provider_modal.js
@@ -0,0 +1,94 @@
+//
+// A modal popup to add a new provider.
+//
+
+import React from 'react'
+import { FormGroup, ControlLabel, FormControl, HelpBlock, Button, Modal } from 'react-bootstrap'
+import Spinner from '../spinner'
+import Validate from '../../lib/validate'
+import App from '../../app'
+
+class AddProviderModal extends React.Component {
+
+ static get defaultProps() {return{
+ title: 'Add a provider',
+ onClose: null
+ }}
+
+ constructor(props) {
+ super(props)
+ this.state = {
+ validationState: null,
+ errorMsg: null,
+ domain: ""
+ }
+ this.accept = this.accept.bind(this)
+ this.cancel = this.cancel.bind(this)
+ this.changed = this.changed.bind(this)
+ }
+
+ accept() {
+ if (this.state.domain) {
+ App.providers.add(this.state.domain)
+ }
+ this.props.onClose()
+ }
+
+ cancel() {
+ this.props.onClose()
+ }
+
+ changed(e) {
+ let domain = e.target.value
+ let newState = null
+ let newMsg = null
+
+ if (domain.length > 0) {
+ let error = Validate.domain(domain)
+ newState = error ? 'error' : 'success'
+ newMsg = error
+ }
+ this.setState({
+ domain: domain,
+ validationState: newState,
+ errorMsg: newMsg
+ })
+ }
+
+ render() {
+ let help = null
+ if (this.state.errorMsg) {
+ help = <HelpBlock>{this.state.errorMsg}</HelpBlock>
+ } else {
+ help = <HelpBlock>&nbsp;</HelpBlock>
+ }
+ let form = <form onSubmit={this.accept} autoComplete="off">
+ <FormGroup controlId="addprovider" validationState={this.state.validationState}>
+ <ControlLabel>Domain</ControlLabel>
+ <FormControl
+ type="text"
+ ref="domain"
+ autoFocus
+ value={this.state.domain}
+ onChange={this.changed}
+ onBlur={this.changed} />
+ <FormControl.Feedback/>
+ {help}
+ </FormGroup>
+ <Button onClick={this.accept}>Add</Button>
+ </form>
+
+ return(
+ <Modal show={true} onHide={this.cancel}>
+ <Modal.Header closeButton>
+ <Modal.Title>{this.props.title}</Modal.Title>
+ </Modal.Header>
+ <Modal.Body>
+ {form}
+ </Modal.Body>
+ </Modal>
+ )
+ }
+}
+
+export default AddProviderModal \ No newline at end of file
diff --git a/ui/app/components/wizard/index.js b/ui/app/components/wizard/index.js
new file mode 100644
index 0000000..613b88f
--- /dev/null
+++ b/ui/app/components/wizard/index.js
@@ -0,0 +1,38 @@
+//
+// The provider setup wizard
+//
+
+import React from 'react'
+import App from 'app'
+
+import ProviderSelectStage from './provider_select_stage'
+import './wizard.less'
+
+export default class Wizard extends React.Component {
+
+ constructor(props) {
+ super(props)
+ this.state = {
+ stage: 'provider'
+ }
+ }
+
+ setStage(stage) {
+ this.setState({stage: stage})
+ }
+
+ render() {
+ let stage = null
+ switch(this.state.stage) {
+ case 'provider':
+ stage = <ProviderSelectStage />
+ break
+ }
+ return(
+ <div className="wizard">
+ {stage}
+ </div>
+ )
+ }
+
+}
diff --git a/ui/app/components/wizard/provider_select_stage.js b/ui/app/components/wizard/provider_select_stage.js
new file mode 100644
index 0000000..20674be
--- /dev/null
+++ b/ui/app/components/wizard/provider_select_stage.js
@@ -0,0 +1,86 @@
+import React from 'react'
+import {Button, ButtonGroup, ButtonToolbar, Glyphicon} from 'react-bootstrap'
+
+import App from 'app'
+import ListEdit from 'components/list_edit'
+import StageLayout from './stage_layout'
+import AddProviderModal from './add_provider_modal'
+
+export default class ProviderSelectStage extends React.Component {
+
+ static get defaultProps() {return{
+ title: "Choose a provider",
+ subtitle: "This doesn't work yet"
+ }}
+
+ constructor(props) {
+ super(props)
+ let domains = this.currentDomains()
+ this.state = {
+ domains: domains,
+ showModal: false
+ }
+ this.add = this.add.bind(this)
+ this.remove = this.remove.bind(this)
+ this.close = this.close.bind(this)
+ this.previous = this.previous.bind(this)
+ }
+
+ currentDomains() {
+ // return(App.providers.domains().slice() || [])
+ return ['domain1', 'domain2', 'domain3']
+ }
+
+ add() {
+ this.setState({showModal: true})
+ }
+
+ remove(provider) {
+ // App.providers.remove(provider)
+ this.setState({domains: this.currentDomains()})
+ }
+
+ close() {
+ let domains = this.currentDomains()
+ if (domains.length != this.state.domains.length) {
+ // this is ugly, but i could not get selection working
+ // by passing it as a property
+ this.refs.list.setSelected(0)
+ }
+ this.setState({
+ domains: domains,
+ showModal: false
+ })
+ }
+
+ previous() {
+ App.start()
+ }
+
+ render() {
+ let modal = null
+ if (this.state.showModal) {
+ modal = <AddProviderModal onClose={this.close} />
+ }
+ let buttons = (
+ <ButtonToolbar className="pull-right">
+ <Button onClick={this.previous}>
+ <Glyphicon glyph="chevron-left" />
+ Previous
+ </Button>
+ <Button>
+ Next
+ <Glyphicon glyph="chevron-right" />
+ </Button>
+ </ButtonToolbar>
+ )
+ let select = <ListEdit ref="list" items={this.state.domains}
+ onRemove={this.remove} onAdd={this.add} />
+ return(
+ <StageLayout title={this.props.title} subtitle={this.props.subtitle} buttons={buttons}>
+ {select}
+ {modal}
+ </StageLayout>
+ )
+ }
+}
diff --git a/ui/app/components/wizard/stage_layout.js b/ui/app/components/wizard/stage_layout.js
new file mode 100644
index 0000000..3154022
--- /dev/null
+++ b/ui/app/components/wizard/stage_layout.js
@@ -0,0 +1,37 @@
+import React from 'react'
+
+class StageLayout extends React.Component {
+
+ static get defaultProps() {return{
+ title: 'untitled',
+ subtitle: null,
+ buttons: null
+ }}
+
+ constructor(props) {
+ super(props)
+ }
+
+ render() {
+ let subtitle = null
+ if (this.props.subtitle) {
+ subtitle = <span>{this.props.subtitle}</span>
+ }
+ return(
+ <div className="stage">
+ <div className="header">
+ {this.props.title}
+ {subtitle}
+ </div>
+ <div className="body">
+ {this.props.children}
+ </div>
+ <div className="footer">
+ {this.props.buttons}
+ </div>
+ </div>
+ )
+ }
+}
+
+export default StageLayout \ No newline at end of file
diff --git a/ui/app/components/wizard/wizard.less b/ui/app/components/wizard/wizard.less
new file mode 100644
index 0000000..29efc20
--- /dev/null
+++ b/ui/app/components/wizard/wizard.less
@@ -0,0 +1,44 @@
+.wizard .stage {
+ position: absolute;
+ height: 100%;
+ width: 100%;
+
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: column;
+ flex-direction: column;
+ -webkit-flex: 1;
+ flex: 1;
+}
+
+.wizard .stage .footer {
+ -webkit-flex: 0 0 auto;
+ flex: 0 0 auto;
+ background-color: #ddd;
+ padding: 20px;
+ text-align: right;
+}
+
+.wizard .stage .header {
+ -webkit-flex: 0 0 auto;
+ flex: 0 0 auto;
+ padding: 20px;
+ background-color: #333;
+ color: white;
+ font-size: 2em;
+ span {
+ margin-left: 10px;
+ font-size: 0.5em;
+ }
+}
+
+.wizard .stage .body {
+ -webkit-flex: 1 1 auto;
+ flex: 1 1 auto;
+ padding: 20px;
+ overflow: auto;
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: column;
+ flex-direction: column;
+} \ No newline at end of file