diff --git a/branch.sql b/branch.sql new file mode 100644 index 0000000000..602ecd7e4b --- /dev/null +++ b/branch.sql @@ -0,0 +1,30 @@ +BEGIN; + CREATE TYPE email_address_with_confirmation AS + ( + address text, + confirmed boolean + ); + + ALTER TABLE participants ADD email email_address_with_confirmation + DEFAULT NULL; + + CREATE TABLE emails + ( id serial PRIMARY KEY + , email email_address_with_confirmation NOT NULL + , ctime timestamp with time zone NOT NULL + DEFAULT CURRENT_TIMESTAMP + , participant text NOT NULL + REFERENCES participants + ON UPDATE CASCADE + ON DELETE RESTRICT + ); + + + CREATE RULE log_email_changes AS ON UPDATE + TO participants WHERE (OLD.email IS NULL AND NOT NEW.email IS NULL) + OR (NEW.email IS NULL AND NOT OLD.email IS NULL) + OR NEW.email <> OLD.email + DO INSERT INTO emails (email, participant) + VALUES (NEW.email, OLD.username); + +END; diff --git a/gittip/models/email_address_with_confirmation.py b/gittip/models/email_address_with_confirmation.py new file mode 100644 index 0000000000..9996e87e6a --- /dev/null +++ b/gittip/models/email_address_with_confirmation.py @@ -0,0 +1,6 @@ +from postgres.orm import Model + + +class EmailAddressWithConfirmation(Model): + + typname = "email_address_with_confirmation" diff --git a/gittip/models/participant.py b/gittip/models/participant.py index 14ecc8ebb4..90b1eeda7a 100644 --- a/gittip/models/participant.py +++ b/gittip/models/participant.py @@ -290,6 +290,13 @@ def change_username(self, suggested): self.set_attributes(username=suggested, username_lower=lowercased) + def change_email(self, email, confirmed=False): + gittip.db.run("UPDATE participants " + "SET email = ROW(%s, %s) WHERE username=%s", + (email, confirmed, self.username)) + self.set_attributes(email=(email, confirmed)) + + def update_goal(self, goal): typecheck(goal, (Decimal, None)) self.db.run( "UPDATE participants SET goal=%s WHERE username=%s" diff --git a/gittip/wireup.py b/gittip/wireup.py index f2b02eea48..02deede417 100644 --- a/gittip/wireup.py +++ b/gittip/wireup.py @@ -12,6 +12,7 @@ import gittip.utils.mixpanel from gittip.models.community import Community from gittip.models.participant import Participant +from gittip.models.email_address_with_confirmation import EmailAddressWithConfirmation from postgres import Postgres @@ -32,6 +33,7 @@ def db(): db.register_model(Community) db.register_model(Participant) + db.register_model(EmailAddressWithConfirmation) return db diff --git a/templates/connected-accounts.html b/templates/connected-accounts.html index 9fc0eaf704..717a29ade2 100644 --- a/templates/connected-accounts.html +++ b/templates/connected-accounts.html @@ -127,6 +127,31 @@

Connected Accounts

>on Balanced Payments{% else %}on Balanced Payments{% end %} + {% if user.participant == participant or user.ADMIN %} + + + + + + + {% if participant.email is None %} + Link an email address + {% else %} + {{ participant.email.address }} + {% end %} +
+ + + +
+
(not publicly viewable)
+ + + {% end %} diff --git a/tests/test_email_json.py b/tests/test_email_json.py new file mode 100644 index 0000000000..187e308d6b --- /dev/null +++ b/tests/test_email_json.py @@ -0,0 +1,103 @@ +from __future__ import unicode_literals +from nose.tools import assert_equal + +import json + +from aspen.utils import utcnow +from gittip.testing import Harness +from gittip.testing.client import TestClient + +class TestMembernameJson(Harness): + + def make_client_and_csrf(self): + client = TestClient() + + csrf_token = client.get('/').request.context['csrf_token'] + + return client, csrf_token + + + def test_get_returns_405(self): + client, csrf_token = self.make_client_and_csrf() + + self.make_participant("alice", claimed_time=utcnow()) + + response = client.get('/alice/email.json') + + actual = response.code + assert actual == 405, actual + + def test_post_anon_returns_401(self): + client, csrf_token = self.make_client_and_csrf() + + self.make_participant("alice", claimed_time=utcnow()) + + response = client.post('/alice/email.json' + , { 'csrf_token': csrf_token }) + + actual = response.code + assert actual == 401, actual + + def test_post_with_no_email_returns_400(self): + client, csrf_token = self.make_client_and_csrf() + + self.make_participant("alice", claimed_time=utcnow()) + + response = client.post('/alice/email.json' + , { 'csrf_token': csrf_token } + , user='alice' + ) + + actual = response.code + assert actual == 400, actual + + def test_post_with_no_at_in_email_returns_400(self): + client, csrf_token = self.make_client_and_csrf() + + self.make_participant("alice", claimed_time=utcnow()) + + response = client.post('/alice/email.json' + , { + 'csrf_token': csrf_token + , 'email': 'bademail.com' + } + , user='alice' + ) + + actual = response.code + assert actual == 400, actual + + def test_post_with_no_dot_in_email_returns_400(self): + client, csrf_token = self.make_client_and_csrf() + + self.make_participant("alice", claimed_time=utcnow()) + + response = client.post('/alice/email.json' + , { + 'csrf_token': csrf_token + , 'email': 'bad@emailcom' + } + , user='alice' + ) + + actual = response.code + assert actual == 400, actual + + def test_post_with_good_email_is_success(self): + client, csrf_token = self.make_client_and_csrf() + + self.make_participant("alice", claimed_time=utcnow()) + + response = client.post('/alice/email.json' + , { + 'csrf_token': csrf_token + , 'email': 'good@email.com' + } + , user='alice' + ) + + actual = response.code + assert actual == 200, actual + + actual = json.loads(response.body)['email'] + assert actual == 'good@email.com', actual \ No newline at end of file diff --git a/tests/test_participant.py b/tests/test_participant.py index dcdb9f1275..6f5bff234b 100644 --- a/tests/test_participant.py +++ b/tests/test_participant.py @@ -134,6 +134,18 @@ def test_john_is_plural(self): actual = Participant.from_username('john').IS_PLURAL assert_equals(actual, expected) + def test_can_change_email(self): + Participant.from_username('alice').change_email('alice@gmail.com') + expected = 'alice@gmail.com' + actual = Participant.from_username('alice').email.address + assert_equals(actual, expected) + + def test_can_confirm_email(self): + Participant.from_username('alice').change_email('alice@gmail.com', True) + expected = True + actual = Participant.from_username('alice').email.confirmed + assert_equals(actual, expected) + def test_cant_take_over_claimed_participant_without_confirmation(self): bob_twitter = StubAccount('twitter', '2') with assert_raises(NeedConfirmation): diff --git a/www/%username/email.json.spt b/www/%username/email.json.spt new file mode 100644 index 0000000000..c30283aee5 --- /dev/null +++ b/www/%username/email.json.spt @@ -0,0 +1,48 @@ +""" +Change the currently authenticated user's email address. +This will need to send a confirmation email in the future. +""" +import json + +from aspen import Response + + + +[-----------------------------------------] + +if not POST: + body = {'error': 'HTTP method must be POST'} + response = Response(405, json.dumps(body)) + # We can't raise() this response obj because + # aspen will ignore our custom body. + # We must ensure it's named response at the end of the file + # (see raising responses http://aspen.io/api/response/) + +elif user.ANON: + body = {"error": "you're not authenticated"} + response = Response(401, json.dumps(body)) + +elif 'email' not in request.body: + body = {'error': 'missing required parameters'} + response = Response(400, json.dumps(body)) + +else: + # regex validation of email gets real messy + # if you want to take care of all edge cases + + # there's not much point to it if we're making + # users confirm their email addresses anyway + + # but we should at least confirm the presence + # of a '@' and '.' + + address = request.body['email'] + + if not '@' in address or not '.' in address: + body = {'error': 'invalid email address'} + response = Response(400, json.dumps(body)) + else: + # Woohoo! valid request, store it! + user.participant.change_email(address) + + response.body = {'email': address} diff --git a/www/assets/%version/gittip/profile.js b/www/assets/%version/gittip/profile.js index 834b0febfc..0303993023 100644 --- a/www/assets/%version/gittip/profile.js +++ b/www/assets/%version/gittip/profile.js @@ -363,4 +363,43 @@ $(document).ready(function() .on('click', '.recreate', function () { $.post('api-key.json', { action: 'show' }, $('.api-key').data('callback')); }); + + + // Wire up email address input. + // ============================ + + $('a.email').click(function() + { + // "Link email address" text or existing + // email was clicked, show the text box + $('.email').toggle(); + $('input.email').focus(); + }); + + $('.email-submit') + .on('click', '[type=submit]', function () { + var $this = $(this); + + $this.text('Saving...'); + + $.post('email.json' + , { email: $('input.email').val() } + , 'json' + ).done(function (data) { + $('a.email').text(data.email); + $('.email').toggle(); + $this.text('Save'); + }).fail(function (data) { + $this.text('Save'); + alert('Failed to save your email address. ' + + 'Please try again.'); + }); + + return false; + }) + .on('click', '[type=cancel]', function () { + $('.email').toggle(); + + return false; + }); });