diff options
-rw-r--r-- | .gitignore | 33 | ||||
-rw-r--r-- | service/pixelated/application.py | 2 | ||||
-rw-r--r-- | service/pixelated/bitmask_libraries/provider.py | 2 | ||||
-rw-r--r-- | service/pixelated/resources/__init__.py | 10 | ||||
-rw-r--r-- | service/pixelated/resources/auth.py | 31 | ||||
-rw-r--r-- | service/pixelated/resources/login_resource.py | 13 | ||||
-rw-r--r-- | service/pixelated/resources/root_resource.py | 95 | ||||
-rw-r--r-- | service/test/integration/test_feedback_service.py | 2 | ||||
-rw-r--r-- | service/test/unit/resources/test_auth.py | 85 | ||||
-rw-r--r-- | service/test/unit/resources/test_root_resource.py | 95 | ||||
-rw-r--r-- | web-ui/package.json | 23 | ||||
-rw-r--r-- | web-ui/public/dummy.json | 1 | ||||
-rw-r--r-- | web-ui/public/images/pixelated-logo-orange.svg | 29 | ||||
-rw-r--r-- | web-ui/public/images/sent_email.png | bin | 0 -> 9160 bytes | |||
-rw-r--r-- | web-ui/public/signup.css | 174 | ||||
-rw-r--r-- | web-ui/public/signup.html | 19 | ||||
-rw-r--r-- | web-ui/src/js/index.js | 235 |
17 files changed, 752 insertions, 97 deletions
@@ -3,35 +3,36 @@ *.log *.DS_Store *.egg-info -web-ui/node_modules -web-ui/app/bower_components -web-ui/target +/web-ui/node_modules +/web-ui/app/bower_components +/web-ui/lib/ +/web-ui/public/signup.js .tmp .sass-cache/ dist/ *archive.zip *.swp *.swo -web-ui/app/js/generated -web-ui/app/css +/web-ui/app/js/generated +/web-ui/app/css test-results.xml -control_tower.html -state.yml -.server.pid +/control_tower.html +/state.yml +*.pid *archive.zip artifacts/ -public/ +/public/ .DS_Store -screenshot* +/screenshot* *.pyc -env/ +/env/ .vagrant/ __pycache__/ .virtualenv # custom config file that can be used with the useragent /config -credentials.ini -pixelated.cfg -service/_trial_temp/ -_trial_temp -web-ui/coverage +/credentials.ini +/pixelated.cfg +/service/_trial_temp/ +/_trial_temp +/web-ui/coverage diff --git a/service/pixelated/application.py b/service/pixelated/application.py index 46e5ba85..fa6568e6 100644 --- a/service/pixelated/application.py +++ b/service/pixelated/application.py @@ -159,7 +159,7 @@ def set_up_protected_resources(root_resource, provider, services_factory, banner _portal = portal.Portal(realm, [session_checker, AllowAnonymousAccess()]) anonymous_resource = LoginResource(services_factory, provider, disclaimer_banner=banner, authenticator=authenticator) - protected_resource = PixelatedAuthSessionWrapper(_portal, root_resource, anonymous_resource, []) + protected_resource = PixelatedAuthSessionWrapper(_portal, root_resource, anonymous_resource) root_resource.initialize(provider, disclaimer_banner=banner, authenticator=authenticator) return protected_resource diff --git a/service/pixelated/bitmask_libraries/provider.py b/service/pixelated/bitmask_libraries/provider.py index 96935fbc..bc19f79e 100644 --- a/service/pixelated/bitmask_libraries/provider.py +++ b/service/pixelated/bitmask_libraries/provider.py @@ -193,7 +193,7 @@ class LeapProvider(object): fin.close() def setup_ca_bundle(self): - path = os.path.join(leap_config.leap_home, 'providers', self.server_name, 'keys', 'client') + path = os.path.dirname(self.provider_api_cert) if not os.path.isdir(path): os.makedirs(path, 0700) self._download_cert(self.provider_api_cert) diff --git a/service/pixelated/resources/__init__.py b/service/pixelated/resources/__init__.py index 11611f0b..97346a6f 100644 --- a/service/pixelated/resources/__init__.py +++ b/service/pixelated/resources/__init__.py @@ -13,8 +13,9 @@ # # You should have received a copy of the GNU Affero General Public License # along with Pixelated. If not, see <http://www.gnu.org/licenses/>. - +import hashlib import json +import os from twisted.web.http import UNAUTHORIZED from twisted.web.resource import Resource @@ -26,6 +27,8 @@ from twisted.web.http import INTERNAL_SERVER_ERROR, SERVICE_UNAVAILABLE log = Logger() +CSRF_TOKEN_LENGTH = 32 + class SetEncoder(json.JSONEncoder): def default(self, obj): @@ -62,6 +65,11 @@ class BaseResource(Resource): Resource.__init__(self) self._services_factory = services_factory + def _add_csrf_cookie(self, request): + csrf_token = hashlib.sha256(os.urandom(CSRF_TOKEN_LENGTH)).hexdigest() + request.addCookie('XSRF-TOKEN', csrf_token) + log.debug('XSRF-TOKEN added: %s' % csrf_token) + def _get_user_id_from_request(self, request): if self._services_factory.mode.is_single_user: return None # it doesn't matter diff --git a/service/pixelated/resources/auth.py b/service/pixelated/resources/auth.py index adac985f..a2054f18 100644 --- a/service/pixelated/resources/auth.py +++ b/service/pixelated/resources/auth.py @@ -64,10 +64,18 @@ class SessionChecker(object): class PixelatedRealm(object): implements(portal.IRealm) + def __init__(self, authenticated_resource, public_resource): + self._authenticated_resource = authenticated_resource + self._public_resource = public_resource + def requestAvatar(self, avatarId, mind, *interfaces): - if IResource in interfaces: - return IResource, avatarId, lambda: None - raise NotImplementedError() + if IResource not in interfaces: + raise NotImplementedError() + if avatarId == checkers.ANONYMOUS: + avatar = self._public_resource + else: + avatar = self._authenticated_resource + return IResource, avatar, lambda: None @implementer(IResource) @@ -75,7 +83,7 @@ class PixelatedAuthSessionWrapper(object): isLeaf = False - def __init__(self, portal, root_resource, anonymous_resource, credentialFactories): + def __init__(self, portal, root_resource, anonymous_resource, credentialFactories=[]): self._portal = portal self._credentialFactories = credentialFactories self._root_resource = root_resource @@ -93,23 +101,18 @@ class PixelatedAuthSessionWrapper(object): return util.DeferredResource(self._login(creds, request)) def _login(self, credentials, request): - pattern = re.compile("^/sandbox/") - def loginSucceeded(args): interface, avatar, logout = args - if avatar == checkers.ANONYMOUS and not pattern.match(request.path): - return self._anonymous_resource - else: - return self._root_resource + # TODO: make sandbox public + return avatar def loginFailed(result): if result.check(error.Unauthorized, error.LoginFailed): return UnauthorizedResource(self._credentialFactories) else: - log.err( - result, - "HTTPAuthSessionWrapper.getChildWithDefault encountered " - "unexpected error") + log.error( + "PixelatedAuthSessionWrapper.getChildWithDefault encountered " + "unexpected error: %s" % result) return ErrorPage(500, None, None) d = self._portal.login(credentials, None, IResource) diff --git a/service/pixelated/resources/login_resource.py b/service/pixelated/resources/login_resource.py index aadc435e..fec4307e 100644 --- a/service/pixelated/resources/login_resource.py +++ b/service/pixelated/resources/login_resource.py @@ -39,6 +39,17 @@ def _get_startup_folder(): return os.path.join(path, '..', 'assets') +def _get_public_folder(): + static_folder = os.path.abspath(os.path.join(os.path.abspath(__file__), "..", "..", "..", "web-ui", "public")) + # this is a workaround for packaging + if not os.path.exists(static_folder): + static_folder = os.path.abspath( + os.path.join(os.path.abspath(__file__), "..", "..", "..", "..", "web-ui", "public")) + if not os.path.exists(static_folder): + static_folder = os.path.join('/', 'usr', 'share', 'pixelated-user-agent') + return static_folder + + def _get_static_folder(): static_folder = os.path.abspath(os.path.join(os.path.abspath(__file__), "..", "..", "..", "web-ui", "app")) # this is a workaround for packaging @@ -107,6 +118,7 @@ class LoginResource(BaseResource): def __init__(self, services_factory, provider=None, disclaimer_banner=None, authenticator=None): BaseResource.__init__(self, services_factory) self._static_folder = _get_static_folder() + self._public_folder = _get_public_folder() self._startup_folder = _get_startup_folder() self._disclaimer_banner = disclaimer_banner self._provider = provider @@ -114,6 +126,7 @@ class LoginResource(BaseResource): self._bootstrap_user_services = BootstrapUserServices(services_factory, provider) self.putChild('startup-assets', File(self._startup_folder)) + self.putChild('public-assets', File(self._public_folder)) with open(os.path.join(self._startup_folder, 'Interstitial.html')) as f: self.interstitial = f.read() diff --git a/service/pixelated/resources/root_resource.py b/service/pixelated/resources/root_resource.py index 8df76c70..0788ffb1 100644 --- a/service/pixelated/resources/root_resource.py +++ b/service/pixelated/resources/root_resource.py @@ -13,12 +13,11 @@ # # You should have received a copy of the GNU Affero General Public License # along with Pixelated. If not, see <http://www.gnu.org/licenses/>. -import hashlib import json import os -from string import Template from pixelated.resources.users import UsersResource +import pixelated from pixelated.resources import BaseResource, UnAuthorizedResource, UnavailableResource from pixelated.resources import IPixelatedSession from pixelated.resources.attachments_resource import AttachmentsResource @@ -33,43 +32,48 @@ from pixelated.resources.mail_resource import MailResource from pixelated.resources.mails_resource import MailsResource from pixelated.resources.tags_resource import TagsResource from pixelated.resources.keys_resource import KeysResource +from pixelated.resources.inbox_resource import InboxResource, MODE_STARTUP, MODE_RUNNING from twisted.web.resource import NoResource from twisted.web.static import File from twisted.logger import Logger -log = Logger() +logger = Logger() -CSRF_TOKEN_LENGTH = 32 +class PublicRootResource(BaseResource): -MODE_STARTUP = 1 -MODE_RUNNING = 2 + def __init__(self, services_factory): + BaseResource.__init__(self, services_factory) -class RootResource(BaseResource): +class RootResource(PublicRootResource): + def __init__(self, services_factory): - BaseResource.__init__(self, services_factory) + PublicRootResource.__init__(self, services_factory) + self._assets_folder = self._get_assets_folder() self._startup_assets_folder = self._get_startup_folder() self._static_folder = self._get_static_folder() self._html_template = open(os.path.join(self._static_folder, 'index.html')).read() self._services_factory = services_factory - self._child_resources = ChildResourcesMap() with open(os.path.join(self._startup_assets_folder, 'Interstitial.html')) as f: self.interstitial = f.read() + self._inbox_resource = InboxResource(services_factory) self._startup_mode() def _startup_mode(self): + self.putChild('assets', File(self._assets_folder)) self.putChild('startup-assets', File(self._startup_assets_folder)) self._mode = MODE_STARTUP + logger.debug('Root in STARTUP mode. %s' % self) - def getChild(self, path, request): + def getChildWithDefault(self, path, request): if path == '': - return self + return self._inbox_resource if self._mode == MODE_STARTUP: return UnavailableResource() if self._is_xsrf_valid(request): - return self._child_resources.get(path) + return BaseResource.getChildWithDefault(self, path, request) return UnAuthorizedResource() def _is_xsrf_valid(self, request): @@ -78,7 +82,9 @@ class RootResource(BaseResource): return True xsrf_token = request.getCookie('XSRF-TOKEN') + logger.debug('CSRF token: %s' % xsrf_token) + # TODO: how is comparing the cookie-csrf with the HTTP-header-csrf adding any csrf protection? ajax_request = (request.getHeader('x-requested-with') == 'XMLHttpRequest') if ajax_request: xsrf_header = request.getHeader('x-xsrf-token') @@ -88,24 +94,30 @@ class RootResource(BaseResource): return csrf_input and csrf_input == xsrf_token def initialize(self, provider=None, disclaimer_banner=None, authenticator=None): - self._child_resources.add('sandbox', SandboxResource(self._static_folder)) - self._child_resources.add('assets', File(self._static_folder)) - self._child_resources.add('keys', KeysResource(self._services_factory)) - self._child_resources.add(AttachmentsResource.BASE_URL, AttachmentsResource(self._services_factory)) - self._child_resources.add('contacts', ContactsResource(self._services_factory)) - self._child_resources.add('features', FeaturesResource(provider)) - self._child_resources.add('tags', TagsResource(self._services_factory)) - self._child_resources.add('mails', MailsResource(self._services_factory)) - self._child_resources.add('mail', MailResource(self._services_factory)) - self._child_resources.add('feedback', FeedbackResource(self._services_factory)) - self._child_resources.add('user-settings', UserSettingsResource(self._services_factory)) - self._child_resources.add('users', UsersResource(self._services_factory)) - self._child_resources.add(LoginResource.BASE_URL, - LoginResource(self._services_factory, provider, disclaimer_banner=disclaimer_banner, authenticator=authenticator)) - self._child_resources.add(LogoutResource.BASE_URL, LogoutResource(self._services_factory)) - + self.putChild('sandbox', SandboxResource(self._static_folder)) + self.putChild('keys', KeysResource(self._services_factory)) + self.putChild(AttachmentsResource.BASE_URL, AttachmentsResource(self._services_factory)) + self.putChild('contacts', ContactsResource(self._services_factory)) + self.putChild('features', FeaturesResource(provider)) + self.putChild('tags', TagsResource(self._services_factory)) + self.putChild('mails', MailsResource(self._services_factory)) + self.putChild('mail', MailResource(self._services_factory)) + self.putChild('feedback', FeedbackResource(self._services_factory)) + self.putChild('user-settings', UserSettingsResource(self._services_factory)) + self.putChild('users', UsersResource(self._services_factory)) + self.putChild(LoginResource.BASE_URL, + LoginResource(self._services_factory, provider, disclaimer_banner=disclaimer_banner, authenticator=authenticator)) + self.putChild(LogoutResource.BASE_URL, LogoutResource(self._services_factory)) + + self._inbox_resource.initialize() self._mode = MODE_RUNNING + logger.debug('Root in RUNNING mode. %s' % self) + + def _get_assets_folder(self): + pixelated_path = os.path.dirname(os.path.abspath(pixelated.__file__)) + return os.path.join(pixelated_path, '..', '..', 'web-ui', 'public') + # TODO: use the public folder for this def _get_startup_folder(self): path = os.path.dirname(os.path.abspath(__file__)) return os.path.join(path, '..', 'assets') @@ -119,30 +131,3 @@ class RootResource(BaseResource): if not os.path.exists(static_folder): static_folder = os.path.join('/', 'usr', 'share', 'pixelated-user-agent') return static_folder - - def _is_starting(self): - return self._mode == MODE_STARTUP - - def _add_csrf_cookie(self, request): - csrf_token = hashlib.sha256(os.urandom(CSRF_TOKEN_LENGTH)).hexdigest() - request.addCookie('XSRF-TOKEN', csrf_token) - - def render_GET(self, request): - self._add_csrf_cookie(request) - if self._is_starting(): - return self.interstitial - else: - account_email = self.mail_service(request).account_email - response = Template(self._html_template).safe_substitute(account_email=account_email) - return str(response) - - -class ChildResourcesMap(object): - def __init__(self): - self._registry = {} - - def add(self, path, resource): - self._registry[path] = resource - - def get(self, path): - return self._registry.get(path) or NoResource() diff --git a/service/test/integration/test_feedback_service.py b/service/test/integration/test_feedback_service.py index c50c1883..ff659396 100644 --- a/service/test/integration/test_feedback_service.py +++ b/service/test/integration/test_feedback_service.py @@ -1,4 +1,4 @@ -import unittest +from twisted.trial import unittest from httmock import urlmatch, HTTMock from mockito import when from twisted.internet import defer diff --git a/service/test/unit/resources/test_auth.py b/service/test/unit/resources/test_auth.py new file mode 100644 index 00000000..6bd0338a --- /dev/null +++ b/service/test/unit/resources/test_auth.py @@ -0,0 +1,85 @@ +from mockito import mock, when, any as ANY +from pixelated.resources.auth import SessionChecker, PixelatedRealm, PixelatedAuthSessionWrapper +from pixelated.resources.login_resource import LoginResource +from pixelated.resources.root_resource import RootResource +from test.unit.resources import DummySite +from twisted.cred import error +from twisted.cred.checkers import ANONYMOUS, AllowAnonymousAccess +from twisted.cred.portal import Portal +from twisted.internet.defer import succeed, fail +from twisted.python import failure +from twisted.trial import unittest +from twisted.web._auth.wrapper import UnauthorizedResource +from twisted.web.resource import IResource, getChildForRequest +from twisted.web.test.requesthelper import DummyRequest + + +class TestPixelatedRealm(unittest.TestCase): + + def setUp(self): + self.authenticated_root_resource = mock() + self.public_root_resource = mock() + self.realm = PixelatedRealm(self.authenticated_root_resource, self.public_root_resource) + + def test_anonymous_user_gets_anonymous_resource(self): + interface, avatar, logout_handler = self.realm.requestAvatar(ANONYMOUS, None, IResource) + self.assertEqual(interface, IResource) + self.assertIs(avatar, self.public_root_resource) + + def test_authenticated_user_gets_root_resource(self): + interface, avatar, logout_handler = self.realm.requestAvatar('username', None, IResource) + self.assertEqual(interface, IResource) + self.assertIs(avatar, self.authenticated_root_resource) + + +class TestPixelatedAuthSessionWrapper(unittest.TestCase): + + def setUp(self): + self.realm_mock = mock() + services_factory = mock() + session_checker = SessionChecker(services_factory) + self.portal = Portal(self.realm_mock, [session_checker, AllowAnonymousAccess()]) + self.user_uuid_mock = mock() + self.root_resource = RootResource(services_factory) + self.anonymous_resource_mock = mock() + + self.session_wrapper = PixelatedAuthSessionWrapper(self.portal, self.root_resource, self.anonymous_resource_mock) + self.request = DummyRequest([]) + self.request.prepath = [''] + self.request.path = '/' + + def test_should_proxy_to_login_resource_when_the_user_is_not_logged_in(self): + when(self.realm_mock).requestAvatar(ANONYMOUS, None, IResource).thenReturn((IResource, self.anonymous_resource_mock, lambda: None)) + + deferred_resource = self.session_wrapper.getChildWithDefault('', self.request) + d = deferred_resource.d + + def assert_anonymous_resource(resource): + self.assertIs(resource, self.anonymous_resource_mock) + + d.addCallback(assert_anonymous_resource) + return d + + def test_should_proxy_to_root_resource_when_the_user_is_logged_in(self): + when(self.realm_mock).requestAvatar(ANY(), None, IResource).thenReturn((IResource, self.root_resource, lambda: None)) + + deferred_resource = self.session_wrapper.getChildWithDefault('', self.request) + d = deferred_resource.d + + def assert_root_resource(resource): + self.assertIs(resource, self.root_resource) + + d.addCallback(assert_root_resource) + return d + + def test_should_X_when_unauthenticated_user_requests_non_public_resource(self): + when(self.realm_mock).requestAvatar(ANONYMOUS, None, IResource).thenReturn((IResource, self.anonymous_resource_mock, lambda: None)) + + deferred_resource = self.session_wrapper.getChildWithDefault('', self.request) + d = deferred_resource.d + + def assert_unauthorized_resource(resource): + self.assertIs(resource, self.anonymous_resource_mock) + + d.addCallback(assert_unauthorized_resource) + return d diff --git a/service/test/unit/resources/test_root_resource.py b/service/test/unit/resources/test_root_resource.py index 4ff11ce8..2c74d7b9 100644 --- a/service/test/unit/resources/test_root_resource.py +++ b/service/test/unit/resources/test_root_resource.py @@ -1,14 +1,20 @@ -import unittest +import os import re from mock import MagicMock, patch from mockito import mock, when, any as ANY +import pixelated from pixelated.application import UserAgentMode from pixelated.resources.features_resource import FeaturesResource from test.unit.resources import DummySite +from twisted.cred.checkers import ANONYMOUS +from twisted.internet.defer import succeed +from twisted.trial import unittest +from twisted.web.resource import IResource +from twisted.web.static import File from twisted.web.test.requesthelper import DummyRequest -from pixelated.resources.root_resource import RootResource, MODE_STARTUP, MODE_RUNNING +from pixelated.resources.root_resource import InboxResource, RootResource, MODE_STARTUP, MODE_RUNNING class TestRootResource(unittest.TestCase): @@ -25,12 +31,13 @@ class TestRootResource(unittest.TestCase): self.mail_service.account_email = self.MAIL_ADDRESS root_resource = RootResource(self.services_factory) - root_resource._html_template = "<html><head><title>$account_email</title></head></html>" - root_resource._mode = root_resource self.web = DummySite(root_resource) self.root_resource = root_resource def test_render_GET_should_template_account_email(self): + self.root_resource._inbox_resource._html_template = "<html><head><title>$account_email</title></head></html>" + self.root_resource.initialize(provider=mock(), authenticator=mock()) + request = DummyRequest(['']) request.addCookie = lambda key, value: 'stubbed' @@ -88,6 +95,8 @@ class TestRootResource(unittest.TestCase): request.requestHeaders.setRawHeaders('x-xsrf-token', [csrf_token]) def test_should_unauthorize_child_resource_ajax_requests_when_csrf_mismatch(self): + self.root_resource.initialize(provider=mock(), authenticator=mock()) + request = DummyRequest(['/child']) request.method = 'POST' self._mock_ajax_csrf(request, 'stubbed csrf token') @@ -103,11 +112,41 @@ class TestRootResource(unittest.TestCase): d.addCallback(assert_unauthorized) return d + def test_GET_should_return_503_for_uninitialized_resource(self): + request = DummyRequest(['/sandbox/']) + request.method = 'GET' + + request.getCookie = MagicMock(return_value='stubbed csrf token') + + d = self.web.get(request) + + def assert_unavailable(_): + self.assertEqual(503, request.responseCode) + + d.addCallback(assert_unavailable) + return d + + def test_GET_should_return_404_for_non_existing_resource(self): + self.root_resource.initialize(provider=mock(), authenticator=mock()) + + request = DummyRequest(['/non-existing-child']) + request.method = 'GET' + request.getCookie = MagicMock(return_value='stubbed csrf token') + + d = self.web.get(request) + + def assert_not_found(_): + self.assertEqual(404, request.responseCode) + + d.addCallback(assert_not_found) + return d + def test_should_404_non_existing_resource_with_valid_csrf(self): + self.root_resource.initialize(provider=mock(), authenticator=mock()) + request = DummyRequest(['/non-existing-child']) request.method = 'POST' self._mock_ajax_csrf(request, 'stubbed csrf token') - request.getCookie = MagicMock(return_value='stubbed csrf token') d = self.web.get(request) @@ -123,7 +162,7 @@ class TestRootResource(unittest.TestCase): request = DummyRequest(['features']) request.getCookie = MagicMock(return_value='irrelevant -- stubbed') - self.root_resource._child_resources.add('features', FeaturesResource()) + self.root_resource.putChild('features', FeaturesResource()) self.root_resource._mode = MODE_RUNNING d = self.web.get(request) @@ -135,6 +174,8 @@ class TestRootResource(unittest.TestCase): return d def test_should_unauthorize_child_resource_non_ajax_POST_requests_when_csrf_input_mismatch(self): + self.root_resource.initialize(provider=mock(), authenticator=mock()) + request = DummyRequest(['mails']) request.method = 'POST' request.addArg('csrftoken', 'some csrf token') @@ -152,3 +193,45 @@ class TestRootResource(unittest.TestCase): d.addCallback(assert_unauthorized) return d + + def test_assets_should_be_publicly_available(self): + self.root_resource.initialize(provider=mock(), authenticator=mock()) + + request = DummyRequest(['assets', 'dummy.json']) + d = self.web.get(request) + + def assert_response(_): + self.assertEqual(200, request.responseCode) + + d.addCallback(assert_response) + return d + + def test_login_should_be_publicly_available(self): + self.root_resource.initialize(provider=mock(), authenticator=mock()) + + request = DummyRequest(['login']) + d = self.web.get(request) + + def assert_response(_): + self.assertEqual(200, request.responseCode) + + d.addCallback(assert_response) + return d + + def test_root_should_be_handled_by_inbox_resource(self): + request = DummyRequest([]) + request.prepath = [''] + request.path = '/' + # TODO: setup mocked portal + + resource = self.root_resource.getChildWithDefault(request.prepath[-1], request) + self.assertIsInstance(resource, InboxResource) + + def test_inbox_should_not_be_public(self): + request = DummyRequest([]) + request.prepath = [''] + request.path = '/' + # TODO: setup mocked portal + + resource = self.root_resource.getChildWithDefault(request.prepath[-1], request) + self.assertIsInstance(resource, InboxResource) diff --git a/web-ui/package.json b/web-ui/package.json index 2a0056e4..b937502f 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -5,20 +5,23 @@ "repository": "https://github.com/pixelated-project/pixelated-user-agent", "private": true, "devDependencies": { + "babel": "^6.5.2", + "babel-cli": "^6.18.0", "bower": "1.7.9", + "browserify": "^13.1.1", "handlebars": "4.0.5", "html-minifier": "2.1.6", "imagemin": "5.2.1", "jshint": "2.9.2", "karma": "0.13.19", "karma-chrome-launcher": "0.2.2", + "karma-coverage": "0.2.7", "karma-firefox-launcher": "0.1.7", "karma-jasmine": "0.2.2", "karma-jasmine-ajax": "0.1.13", "karma-junit-reporter": "0.2.2", "karma-phantomjs-launcher": "1.0.1", "karma-requirejs": "1.0.0", - "karma-coverage": "0.2.7", "minify": "2.0.9", "requirejs": "2.2.0", "watch": "0.19.1" @@ -32,7 +35,8 @@ "handlebars-watch": "node_modules/.bin/watch 'npm run handlebars' app/templates", "compass": "compass compile", "compass-watch": "compass watch", - "build": "npm run clean && npm run handlebars && npm run add_git_version && npm run compass", + "build": "npm run clean && npm run handlebars && npm run add_git_version && npm run compass && npm run build-signup", + "build-signup": "babel src/js -d lib/js && browserify lib/js/index.js >public/signup.js", "jshint": "node_modules/jshint/bin/jshint --config=.jshintrc app test", "clean": "rm -rf .tmp/ 'dist/*' app/js/generated/hbs/* app/css/*", "buildmain": "node_modules/requirejs/bin/r.js -o config/buildoptions.js", @@ -41,5 +45,20 @@ "minify_html": "node_modules/.bin/html-minifier app/index.html --collapse-whitespace | sed 's|<!--usemin_start-->.*<!--usemin_end-->|<script src=\"assets/app.min.js\" type=\"text/javascript\"></script>|' > dist/index.html", "minify_sandbox": "node_modules/.bin/html-minifier app/sandbox.html --collapse-whitespace | sed 's|<!--usemin_start-->.*<!--usemin_end-->|<script src=\"sandbox.min.js\" type=\"text/javascript\"></script>|' > dist/sandbox.html", "add_git_version": "/bin/bash config/add_git_version.sh" + }, + "dependencies": { + "babel-preset-es2015": "^6.18.0", + "babel-preset-react": "^6.16.0", + "immutable": "^3.8.1", + "react": "^15.3.2", + "react-dom": "^15.3.2", + "redux": "^3.6.0", + "whatwg-fetch": "^2.0.0" + }, + "babel": { + "presets": [ + "es2015", + "react" + ] } } diff --git a/web-ui/public/dummy.json b/web-ui/public/dummy.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/web-ui/public/dummy.json @@ -0,0 +1 @@ +{} diff --git a/web-ui/public/images/pixelated-logo-orange.svg b/web-ui/public/images/pixelated-logo-orange.svg new file mode 100644 index 00000000..7e0ef43d --- /dev/null +++ b/web-ui/public/images/pixelated-logo-orange.svg @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 16.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + width="509.707px" height="142.439px" viewBox="0 0 509.707 142.439" enable-background="new 0 0 509.707 142.439" + xml:space="preserve"> +<g> + <path fill="#F9A731" d="M0,35.469v71.365l62.837,35.605l62.833-35.605V35.469L62.813,0L0,35.469z M60.262,116.617L23.735,96.332 + V52.46l36.586,20.999L60.262,116.617z M101.936,96.332l-36.148,20.285l0.067-43.123l36.081-21.034V96.332z M101.936,46.44 + L62.951,69.553L23.733,46.44l39.218-21.131L101.936,46.44z"/> + <path fill="#F9A731" d="M169.505,42.332h-19.968v59.328h13.52V79.655h6.448c11.579,0,20.279-6.832,20.279-19.056 + C189.784,48.302,181.084,42.332,169.505,42.332z M166.866,68.868h-3.809v-15.75h3.809c5.323,0,10.357,1.798,10.357,7.91 + C177.224,67.07,172.189,68.868,166.866,68.868z"/> + <rect x="194.309" y="42.332" fill="#F9A731" width="13.52" height="59.328"/> + <polygon fill="#F9A731" points="266.516,42.332 249.689,42.332 238.759,58.514 227.827,42.332 211.721,42.332 230.417,69.73 + 210.228,101.66 226.982,101.66 238.759,81.453 250.534,101.66 268.01,101.66 247.099,69.73 "/> + <polygon fill="#F9A731" points="270.128,101.66 304.069,101.66 304.069,89.795 283.647,89.795 283.647,77.857 303.207,77.857 + 303.207,65.991 283.647,65.991 283.647,54.199 304.069,54.199 304.069,42.332 270.128,42.332 "/> + <path fill="#F9A731" d="M354.807,42.332l-19.156,47.463H322.33V42.332h-13.52v59.328h22.053h11.888h2.636l4.386-11.865h22.578 + l4.391,11.865h14.524l-23.944-59.328H354.807z M354.377,77.928l6.614-17.257h0.145l6.615,17.257H354.377z"/> + <polygon fill="#F9A731" points="379.939,54.199 394.073,54.199 394.073,101.66 407.592,101.66 407.592,54.199 421.687,54.199 + 421.687,42.332 379.939,42.332 "/> + <polygon fill="#F9A731" points="426.265,101.66 460.207,101.66 460.207,89.795 439.785,89.795 439.785,77.857 459.344,77.857 + 459.344,65.991 439.785,65.991 439.785,54.199 460.207,54.199 460.207,42.332 426.265,42.332 "/> + <path fill="#F9A731" d="M479.792,42.332h-14.94v59.328h14.94c16.324,0,29.914-12.37,29.914-29.699 + C509.707,54.701,496.044,42.332,479.792,42.332z M480.457,89.577h-2.084V54.414h2.084c10.067,0,16.9,7.695,16.9,17.619 + C497.285,81.955,490.455,89.577,480.457,89.577z"/> +</g> +</svg> diff --git a/web-ui/public/images/sent_email.png b/web-ui/public/images/sent_email.png Binary files differnew file mode 100644 index 00000000..ddaa11d0 --- /dev/null +++ b/web-ui/public/images/sent_email.png diff --git a/web-ui/public/signup.css b/web-ui/public/signup.css new file mode 100644 index 00000000..61ac8587 --- /dev/null +++ b/web-ui/public/signup.css @@ -0,0 +1,174 @@ +body { + font-family: "Open Sans", "Microsoft YaHei", "Hiragino Sans GB", "Hiragino Sans GB W3", "微软雅黑", "Helvetica Neue", Arial, sans-serif; +} + +.field-group { + position:relative; + margin-bottom: 35px; +} + +label { + font-size: 0.9em; + margin-bottom: 10px; + display: inline-block; +} + +input { + display: block; + border: solid 1px #4da3b6; + width: 100%; + height: auto; + padding: 10px 5px; + margin-bottom: 20px; +} + +.animated-label { + color:#999; + position:absolute; + pointer-events:none; + left: 6px; + top:10px; + transition:0.2s ease all; + -moz-transition:0.2s ease all; + -webkit-transition:0.2s ease all; +} + +input:focus { + outline:none; +} + +input:focus ~ .animated-label, input:valid ~ .animated-label{ + top:-20px; + left: 0; + font-size:0.8em; + color:#4da3b6; +} + +.blue-button { + background: #178ca6; + color: white; + display: block; + text-decoration: none; + text-align: center; + padding: 10px 0 10px 0; + width: 104%; + margin: 0 auto; +} + +.blue-button:hover { + background: #4da3b6; +} + +a { + text-decoration: none; + color: #4da3b6; +} + +h1 { + font-size: 1.5em; + text-align: center; +} + +header { + width: 18%; + margin: 0 auto; +} + +.link-message { + text-align: center; + font-size: 0.8em; +} + +.logo { + width: 100%; + height: auto; + padding-top: 20%; + margin-bottom: 30px; +} + +.message h1 { + margin-bottom: 35px; +} + +.message p { + padding-left: 5%; + padding-right: 5%; + width: 40%; + margin: 0 auto; + text-align: center; + line-height: 1.8em; + font-size: 0.9em; +} + +.form-container { + width: 20%; + margin: 0 auto; + padding-top: 40px; +} + +.domain-label { + position: relative; + top: 26px; + padding-left: 20px; + left: 100%; +} + +.sent-email-icon { + width: 60px; +} + +.disabled { + pointer-events: none; + background: #d4d4d4; +} + +.link-message .disabled { + pointer-events: none; + color: #d4d4d4; + background: none; +} + +/* Medium Devices, Desktops */ +@media only screen and (max-width : 992px) { + header { + width: 20%; + } + + .form-container { + width: 30%; + } + + .message p { + width: 70% + } +} + +/* Small Devices, Tablets */ +@media only screen and (max-width : 768px) { + header { + width: 30%; + } + + .form-container { + width: 50%; + } + + .message p { + width: 80% + } +} + +/* Extra Small Devices, Phones */ +@media only screen and (max-width : 480px) { + header { + width: 60%; + } + + .form-container { + width: 80%; + } + + .message p { + width: 85% + } +} diff --git a/web-ui/public/signup.html b/web-ui/public/signup.html new file mode 100644 index 00000000..9bc6cdad --- /dev/null +++ b/web-ui/public/signup.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> + <title>Pixelated Mail</title> + <meta name="description" content=""> + <meta name="viewport" content="width=device-width"> + <link rel="stylesheet" type="text/css" href="/startup-assets/normalize.min.css" /> + <link rel="stylesheet" type="text/css" href="/public-assets/signup.css" /> + </head> + <body> + <header><img src="images/pixelated-logo-orange.svg" alt="Pixelated" class="logo"/></header> + <div class="message"> + <div id="app"></div> + <script src="/public-assets/signup.js"></script> + </div> + </body> +</html> diff --git a/web-ui/src/js/index.js b/web-ui/src/js/index.js new file mode 100644 index 00000000..57ec42a6 --- /dev/null +++ b/web-ui/src/js/index.js @@ -0,0 +1,235 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import {createStore} from 'redux'; +import {Map} from 'immutable'; +import 'whatwg-fetch'; + + +class PixelatedComponent extends React.Component { + _updateStateFromStore() { + this.setState(this.props.store.getState().toJS()); + } + + componentWillMount() { + this.unsubscribe = this.props.store.subscribe(() => this._updateStateFromStore()); + this._updateStateFromStore(); + } + + componentWillUnmount() { + this.unsubscribe() + } +} + + +class PixelatedForm extends PixelatedComponent { + _fetchAndDispatch(url, actionProperties) { + const immutableActionProperties = new Map(actionProperties); + this.props.store.dispatch(immutableActionProperties.merge({status: 'STARTED'}).toJS()); + fetch(url).then((response) => { + return response.json() + }).then((json) => { + setTimeout(() => { + this.props.store.dispatch(immutableActionProperties.merge({status: 'SUCCESS', json: json}).toJS()); + }, 3000); + }).catch((error) => { + console.error('something went wrong', error); + this.props.store.dispatch(immutableActionProperties.merge({status: 'ERROR', error: error}).toJS()); + }); + } +} + + +class InviteCodeForm extends PixelatedForm { + render() { + let className = "blue-button validation-link"; + + if(!this.state.inviteCodeValidation) { + className = className + " disabled"; + } + + return ( + <form onSubmit={this._handleClick.bind(this)}> + <div className="field-group"> + <input type="text" name="invite-code" className="invite-code" onChange={this._handleInputEmpty.bind(this)} required/> + <label className="animated-label" htmlFor="invite-code">invite code</label> + </div> + <input type="submit" value="Get Started" className={className} /> + </form> + ); + } + + _handleClick(event) { + event.stopPropagation(); + event.preventDefault(); + this.props.store.dispatch({type: 'SUBMIT_INVITE_CODE', inviteCode: event.target['invite-code'].value}); + } + + _handleInputEmpty(event) { + this.props.store.dispatch({type: 'VALIDATE_INVITE_CODE', inviteCode: event.target.value}); + } +} + + +class CreateAccountForm extends PixelatedForm { + render() { + return ( + <form onSubmit={this._handleClick.bind(this)}> + <span className="domain-label"> @domain.com </span> + <div className="field-group"> + <input type="text" name="username" className="username" required/> + <label className="animated-label" htmlFor="username">username</label> + </div> + + <div className="field-group"> + <input type="password" name="password" className="password" required/> + <label className="animated-label" htmlFor="password">password</label> + </div> + + <input type="submit" value="Create my account" className="blue-button validation-link" /> + </form> + ); + } + + _handleClick(event) { + event.stopPropagation(); + event.preventDefault(); + this.props.store.dispatch({type: 'SUBMIT_CREATE_ACCOUNT', username: event.target['username'].value, password: event.target['password'].value}); + } +} + + +class BackupEmailForm extends PixelatedForm { + render() { + return ( + <form onSubmit={this._handleClick.bind(this)}> + <div className="field-group"> + <input type="text" name="backup-email" required/> + <label className="animated-label" htmlFor="password">type your backup email</label> + </div> + + <input type="submit" value="Send Email" className="blue-button validation-link" /> + <p className="link-message"> + <a href="#" className="validation-link">I didn't receive anything. Send the email again</a> + </p> + </form> + ); + } + + _handleClick(event) { + event.stopPropagation(); + event.preventDefault(); + this._fetchAndDispatch('dummy.json', {type: 'SUBMIT_BACKUP_EMAIL', backupEmail: event.target['backup-email'].value}); + } +} + + +class BackupEmailSentForm extends PixelatedForm { + render() { + return ( + <form onSubmit={this._handleClick.bind(this)}> + {this.state.isFetching || <a href="/" className="blue-button">I received the codes. <br/>Go to my inbox</a>} + <p className="link-message"> + <a href="#">I didn't receive anything. Send the email again</a> + </p> + </form> + ); + } + + _handleClick(event) { + event.stopPropagation(); + event.preventDefault(); + } +} + + +class SignUp extends PixelatedComponent { + render() { + return ( + <div> + <div className="message"> + <h1>{this.state.header}</h1> + {this.state.icon} + <p>{this.state.summary}</p> + </div> + <div className="form-container"> + {this._form()} + </div> + </div> + ); + } + + _form() { + switch(this.state.form) { + case 'invite_code': return <InviteCodeForm store={store} />; + case 'create_account': return <CreateAccountForm store={store} />; + case 'backup_email': return <BackupEmailForm store={store} />; + case 'backup_email_sent': return <BackupEmailSentForm store={store} />; + default: throw Error('TODO'); + } + } +} + + +const initialState = new Map({ + isFetching: false, + form: 'invite_code', + header: 'Welcome', + icon: null, + summary: ['Do you have an invite code?', <br key='br1' />, 'Type it below'], +}); + + +const store = createStore((state=initialState, action) => { + switch (action.type) { + case 'SUBMIT_INVITE_CODE': + return state.merge({ + inviteCode: action.inviteCode, + form: 'create_account', + header: 'Create your account', + summary: 'Choose your username, and be careful about your password, it must be strong and easy to remember. If you have a password manager, we strongly advise you to use one.', + }); + case 'SUBMIT_CREATE_ACCOUNT': + return state.merge({ + username: action.username, + password: action.password, + form: 'backup_email', + header: 'In case you lose your password...', + summary: 'Set up a backup email account. You\'ll receive an email with a code so you can recover your account in the future, other will be sent to your account administrator.', + }); + case 'SUBMIT_BACKUP_EMAIL': + switch (action.status) { + case 'STARTED': + return state.merge({ + isFetching: true, + backupEmail: action.backupEmail, + form: 'backup_email_sent', + icon: <p><img key="img1" src="images/sent_email.png" className="sent-email-icon"/></p>, + summary: 'An email was sent to the email you provided. Check your spam folder, just in case.', + }); + case 'SUCCESS': + return state.merge({ + isFetching: false, + }); + case 'ERROR': + return state.merge({ + isFetching: false, + }); + default: + return state; + } + case 'SUBMIT_BACKUP_EMAIL_SENT': + return state.merge({}); + case 'VALIDATE_INVITE_CODE': + return state.merge({ + inviteCodeValidation: Boolean(action.inviteCode) + }); + default: + return state; + } +}); + + +ReactDOM.render( + <SignUp store={store}/>, + document.getElementById('app') +); |