summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore33
-rw-r--r--service/pixelated/application.py2
-rw-r--r--service/pixelated/bitmask_libraries/provider.py2
-rw-r--r--service/pixelated/resources/__init__.py10
-rw-r--r--service/pixelated/resources/auth.py31
-rw-r--r--service/pixelated/resources/login_resource.py13
-rw-r--r--service/pixelated/resources/root_resource.py95
-rw-r--r--service/test/integration/test_feedback_service.py2
-rw-r--r--service/test/unit/resources/test_auth.py85
-rw-r--r--service/test/unit/resources/test_root_resource.py95
-rw-r--r--web-ui/package.json23
-rw-r--r--web-ui/public/dummy.json1
-rw-r--r--web-ui/public/images/pixelated-logo-orange.svg29
-rw-r--r--web-ui/public/images/sent_email.pngbin0 -> 9160 bytes
-rw-r--r--web-ui/public/signup.css174
-rw-r--r--web-ui/public/signup.html19
-rw-r--r--web-ui/src/js/index.js235
17 files changed, 752 insertions, 97 deletions
diff --git a/.gitignore b/.gitignore
index 0c347bf8..ddaadaa5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
new file mode 100644
index 00000000..ddaa11d0
--- /dev/null
+++ b/web-ui/public/images/sent_email.png
Binary files differ
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')
+);