diff --git a/emails/identity-viewed.spt b/emails/identity-viewed.spt new file mode 100644 index 0000000000..43dae3dae7 --- /dev/null +++ b/emails/identity-viewed.spt @@ -0,0 +1,14 @@ +{{ _("Identity Viewed") }} +[---] text/html +{{ _( "This is a transactional email to let you know that {a_viewer}{viewer}{_a} viewed your identity information for {a_country}{country_name}{_a} on Gratipay." + , viewer=viewer + , country_name=country_name + , a_viewer=(''|safe).format(viewer) + , a_country=(''|safe).format(country_code) + , _a=''|safe + ) }} +[---] text/plain +{{ _( "This is a transactional email to let you know that {viewer} viewed your identity information for {country_name} on Gratipay." + , viewer=viewer + , country_name=country_name + ) }} diff --git a/gratipay/models/country.py b/gratipay/models/country.py index 080605d7a2..8a9e9395c2 100644 --- a/gratipay/models/country.py +++ b/gratipay/models/country.py @@ -13,3 +13,7 @@ class Country(Model): """ typname = 'countries' + + @classmethod + def from_code(cls, code): + return cls.db.one("SELECT countries.*::countries FROM countries WHERE code=%s", (code,)) diff --git a/js/gratipay.js b/js/gratipay.js index aa0a5e92b1..68eb6483df 100644 --- a/js/gratipay.js +++ b/js/gratipay.js @@ -18,6 +18,7 @@ Gratipay.init = function() { Gratipay.signOut(); Gratipay.payments.initSupportGratipay(); Gratipay.tabs.init(); + Gratipay.countryChooser.init(); }; Gratipay.warnOffUsersFromDeveloperConsole = function() { diff --git a/js/gratipay/countryChooser.js b/js/gratipay/countryChooser.js new file mode 100644 index 0000000000..9d88c4bcaf --- /dev/null +++ b/js/gratipay/countryChooser.js @@ -0,0 +1,18 @@ +Gratipay.countryChooser = {} + +Gratipay.countryChooser.init = function() { + $('.open-country-chooser').click(Gratipay.countryChooser.open); + $('.close-country-chooser').click(Gratipay.countryChooser.close); + $('#grayout').click(Gratipay.countryChooser.close); +}; + +Gratipay.countryChooser.open = function() { + $('.open-country-chooser').blur(); + $('#grayout').show() + $('#country-chooser').show(); +}; + +Gratipay.countryChooser.close = function() { + $('#country-chooser').hide() + $('#grayout').hide() +}; diff --git a/scss/pages/cc-ba.scss b/scss/components/long-form.scss similarity index 89% rename from scss/pages/cc-ba.scss rename to scss/components/long-form.scss index c2f82e3785..0f4ae26581 100644 --- a/scss/pages/cc-ba.scss +++ b/scss/components/long-form.scss @@ -1,4 +1,4 @@ -.cc-ba { +.long-form { width: 300px; form { @@ -113,4 +113,19 @@ input.invalid:focus + .invalid-msg { display: block; } + + .danger-zone { + margin-top: 64px; + border: 1px solid $red; + @include border-radius(5px); + padding: 20px; + h2 { + margin: 0 0 10px; + padding: 0; + color: $red; + } + button { + background: $red; + } + } } diff --git a/scss/pages/identities.scss b/scss/pages/identities.scss new file mode 100644 index 0000000000..99e0e9c238 --- /dev/null +++ b/scss/pages/identities.scss @@ -0,0 +1,139 @@ +#identities { + padding-top: 20px; + + .card { + height: 96px; + width: 46%; + float: left; + margin: 0 8% 8% 0; + padding: 20px; + position: relative; + @include border-radius(5px); + @include box-shadow(0, 0, 10px, $black); + + &:nth-child(even) { + margin-right: 0; + } + + &.verified { + background-image: radial-gradient( circle at 36px 60px + , lighten($green, 55%) 0% + , lighten($green, 30%) 100% + ); + color: $green; + } + + &.unverified { + color: $red; + } + + &.add { + background: $lightest-gray; + color: $medium-gray; + border-style: dashed; + @include box-shadow(0,0,0); + border: 2px dashed $darker-gray; + + &:hover { + color: $black; + background-image: radial-gradient( ellipse farthest-corner at 50% 50% + , $white 0% + , $lightest-gray 50% + ); + } + } + + img { + position: absolute; + bottom: 20px; + left: 20px; + } + + h2 { + white-space: nowrap; + margin: 0; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + .status { + position: absolute; + bottom: 20px; + right: 20px; + font: normal 9px $Mono; + } + } + + #country-chooser { + z-index: 1001; + display: none; + background: white; + @include border-radius(5px); + @include box-shadow(0, 0, 10px, $black); + position: fixed; + width: 240px; + height: 50vh; + top: 25%; + left: 50%; + margin-left: -120px; + + header { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 240px; + height: 36px; + background: white; + border-bottom: 1px solid $black; + @include border-radius(5px 5px 0 0); + @include box-shadow(0, 0, 10px, $black); + + h2 { + margin: 0; + padding: 12px 20px 0; + } + button { + position: absolute; + top: 5px; + right: 20px; + } + } + + section { + overflow: auto; + height: 50vh; + z-index: 0; + margin-top: 36px; + background: $lightest-gray; + @include border-radius(0 0 5px 5px); + @include box-shadow(0, 0, 10px, $black); + + a { + padding: 5px 20px; + display: block; + img { + margin: 0 5px -10px 0; + } + span { + font: bold 18px/18px $Ideal; + } + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + #grayout { + width: 100vw; + height: 100vh; + position: fixed; + top: 0; + left: 0; + background: transparentize($black, 0.3); + z-index: 1000; + display: none; + } +} diff --git a/scss/variables.scss b/scss/variables.scss index 7475db78e8..17e846b23a 100644 --- a/scss/variables.scss +++ b/scss/variables.scss @@ -16,6 +16,7 @@ $medium-gray: #999; $gray: #555; $light-gray: #DDD; $lighter-gray: #EEE; +$lightest-gray: #F6F6F6; $red: #C00; $light-red: #F99; diff --git a/tests/py/test_identity_pages.py b/tests/py/test_identity_pages.py new file mode 100644 index 0000000000..be5a25c4ea --- /dev/null +++ b/tests/py/test_identity_pages.py @@ -0,0 +1,135 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +from gratipay.models.country import Country +from gratipay.models.participant import Participant +from gratipay.testing.emails import EmailHarness + + +class Tests(EmailHarness): + + def setUp(self): + super(Tests, self).setUp() + self.make_participant('alice', claimed_time='now', is_admin=True) + self.make_participant('whit537', id=1451, email_address='chad@zetaweb.com', + claimed_time='now', is_admin=True) + self.make_participant('bob', claimed_time='now', email_address='bob@example.com') + self.verify('bob', 'TT') + + def identify(self, username, *codes): + participant = Participant.from_username(username) + for code in codes: + country_id = Country.from_code(code).id + participant.store_identity_info(country_id, 'nothing-enforced', {}) + return participant + + def verify(self, username, *codes): + participant = Participant.from_username(username) + for code in codes: + country_id = Country.from_code(code).id + participant.store_identity_info(country_id, 'nothing-enforced', {}) + participant.set_identity_verification(country_id, True) + return participant + + + # il - identities listing + + def test_il_is_403_for_anon(self): + assert self.client.GxT('/~bob/identities/').code == 403 + + def test_il_is_403_for_non_admin(self): + assert self.client.GxT('/~bob/identities/').code == 403 + + def test_il_is_200_for_self(self): + assert self.client.GET('/~bob/identities/', auth_as='bob').code == 200 + + def test_il_is_200_for_admin(self): + assert self.client.GET('/~bob/identities/', auth_as='alice').code == 200 + + + # ip - identity page + + def test_ip_disallows_methods(self): + assert self.client.hxt('HEAD', '/~bob/identities/TT').code == 405 + + def test_ip_is_403_for_anon(self): + assert self.client.GxT('/~bob/identities/TT').code == 403 + + def test_ip_is_403_for_non_admin(self): + assert self.client.GxT('/~bob/identities/TT').code == 403 + + def test_ip_is_200_for_self(self): + assert self.client.GET('/~bob/identities/TT', auth_as='bob').code == 200 + + def test_ip_is_403_for_most_admins(self): + assert self.client.GxT('/~bob/identities/TT', auth_as='alice').code == 403 + + def test_ip_is_200_for_whit537_yikes_O_O(self): + assert self.client.GET('/~bob/identities/TT', auth_as='whit537').code == 200 + + def test_ip_notifies_participant_when_whit537_views(self): + self.client.GET('/~bob/identities/TT', auth_as='whit537') + assert 'whit537 viewed your identity' in self.get_last_email()['body_text'] + + def test_ip_is_404_for_unknown_code(self): + assert self.client.GxT('/~bob/identities/XX', auth_as='bob').code == 404 + + def test_ip_is_302_if_no_verified_email(self): + response = self.client.GxT('/~alice/identities/TT', auth_as='alice') + assert response.code == 302 + assert response.headers['Location'] == '/about/me/emails/' + + + def test_ip_is_200_for_third_identity(self): + self.verify('bob', 'TT', 'US') + assert self.client.GET('/~bob/identities/US', auth_as='bob').code == 200 + + def test_ip_is_302_for_fourth_identity(self): + self.verify('bob', 'TT', 'US', 'GB') + assert self.client.GxT('/~bob/identities/CA', auth_as='bob').code == 302 + + def test_ip_is_302_for_fifth_identities(self): + self.verify('bob', 'TT', 'US', 'GB', 'GH') + assert self.client.GxT('/~bob/identities/CA', auth_as='bob').code == 302 + + def test_but_ip_always_loads_for_own_identity(self): + self.verify('bob', 'TT', 'US', 'GB', 'GH') + assert self.client.GET('/~bob/identities/TT', auth_as='bob').code == 200 + + def test_ip_always_loads_for_own_identity_even_if_unverified(self): + self.verify('bob', 'US', 'GB', 'GH') + self.identify('bob', 'TT') + assert self.client.GET('/~bob/identities/TT', auth_as='bob').code == 200 + + + def test_ip_removes_identity(self): + bob = self.verify('bob', 'TT') + assert len(bob.list_identity_metadata()) == 1 + data = {'action': 'remove'} + assert self.client.PxST('/~bob/identities/TT', auth_as='bob', data=data).code == 302 + assert len(bob.list_identity_metadata()) == 0 + + def test_ip_stores_identity(self): + bob = Participant.from_username('bob') + assert len(bob.list_identity_metadata()) == 1 + data = { 'id_type': '' + , 'id_number': '' + , 'legal_name': 'Bobsworth B. Bobbleton, IV' + , 'dob': '' + , 'address_1': '' + , 'address_2': '' + , 'city': '' + , 'region': '' + , 'postcode': '' + , 'action': 'store' + } + assert self.client.PxST('/~bob/identities/US', auth_as='bob', data=data).code == 302 + assert len(bob.list_identity_metadata()) == 2 + info = bob.retrieve_identity_info(Country.from_code('US').id) + assert info['legal_name'] == 'Bobsworth B. Bobbleton, IV' + + def test_ip_validates_action(self): + bob = Participant.from_username('bob') + assert len(bob.list_identity_metadata()) == 1 + data = {'action': 'cheese'} + assert self.client.PxST('/~bob/identities/TT', auth_as='bob', data=data).code == 400 + assert len(bob.list_identity_metadata()) == 1 diff --git a/tests/py/test_pages.py b/tests/py/test_pages.py index f89f1b1b29..9048434bcc 100644 --- a/tests/py/test_pages.py +++ b/tests/py/test_pages.py @@ -31,6 +31,7 @@ def browse(self, setup=None, **kw): .replace('/%platform/', '/github/') \ .replace('/%user_name/', '/gratipay/') \ .replace('/%membername', '/alan') \ + .replace('/%country', '/TT') \ .replace('/%exchange_id.int', '/%s' % exchange_id) \ .replace('/%redirect_to', '/giving') \ .replace('/%endpoint', '/public') \ diff --git a/www/assets/gratipay.css.spt b/www/assets/gratipay.css.spt index 7897a39f78..ba695d7dda 100644 --- a/www/assets/gratipay.css.spt +++ b/www/assets/gratipay.css.spt @@ -35,6 +35,7 @@ @import "scss/components/js-edit"; @import "scss/components/linear_gradient"; @import "scss/components/loading-indicators"; +@import "scss/components/long-form"; @import "scss/components/memberships"; @import "scss/components/nav"; @import "scss/components/payments-by"; @@ -59,11 +60,11 @@ @import "scss/pages/homepage"; @import "scss/pages/history"; +@import "scss/pages/identities"; @import "scss/pages/team"; @import "scss/pages/profile-edit"; @import "scss/pages/giving"; @import "scss/pages/settings"; -@import "scss/pages/cc-ba"; @import "scss/pages/on-confirm"; @import "scss/pages/search"; @import "scss/pages/hall-of-fame"; diff --git a/www/~/%username/identities/%country.spt b/www/~/%username/identities/%country.spt new file mode 100644 index 0000000000..ec15c82f1d --- /dev/null +++ b/www/~/%username/identities/%country.spt @@ -0,0 +1,147 @@ +from aspen import Response +from gratipay.utils import get_participant +from gratipay.models.country import Country +[---] +request.allow('GET', 'POST') +participant = get_participant(state, restrict=True) + +# hard-code HR auth group for Team Gratipay O.O +if participant != user.participant: + w = user.participant + assert user.ADMIN, w.username # sanity check + if (w.id, w.username, w.email_address) != (1451, 'whit537', 'chad@zetaweb.com'): + raise Response(403) + +# require email +if not participant.email_address: + website.redirect('/about/me/emails/') + +# load country +country_code = request.path['country'] +country = Country.from_code(country_code) +title = country_name = locale.countries.get(country_code) +if country is None or title is None: + raise Response(404) + +# load identities & info +identity = None +info = {} +identities = participant.list_identity_metadata() +nidentities = len(identities) +for _identity in identities: + if _identity.country.code == country.code: + identity = _identity + info = participant.retrieve_identity_info(_identity.country.id) + break +if identity is None and nidentities >= 3: + website.redirect('./', base_url='') # Not allowed to add any more! + +# notify users whenever someone views their info +if identity is not None and participant != user.participant: + participant.send_email( 'identity-viewed' + , viewer=user.participant.username + , country_name=country_name + , country_code=country.code + , include_unsubscribe=False + ) + +# handle POST requests +if request.method == 'POST': + action = request.body['action'] + if action == 'remove': + participant.clear_identity(country.id) + elif action == 'store': + info = {} + info['id_type'] = request.body['id_type'] + info['id_number'] = request.body['id_number'] + info['legal_name'] = request.body['legal_name'] + info['dob'] = request.body['dob'] + info['address_1'] = request.body['address_1'] + info['address_2'] = request.body['address_2'] + info['city'] = request.body['city'] + info['region'] = request.body['region'] + info['postcode'] = request.body['postcode'] + participant.store_identity_info(country.id, 'nothing-enforced', info) + else: + raise Response(400) + website.redirect('./', base_url='') + +[---] text/html +{% extends "templates/profile.html" %} +{% block content %} + +
{{ _( "Your identity is verified, which means you may add a {0}bank account{1}." - , ''|safe - , ''|safe) }}
-{% endif %} - -{{ last_bill_result }}