Skip to content
This repository has been archived by the owner on Feb 8, 2018. It is now read-only.

Ability to link an email address to an existing account. #1287

Closed
wants to merge 18 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions branch.sql
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions gittip/models/email_address_with_confirmation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from postgres.orm import Model


class EmailAddressWithConfirmation(Model):

typname = "email_address_with_confirmation"
7 changes: 7 additions & 0 deletions gittip/models/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions gittip/wireup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -32,6 +33,7 @@ def db():

db.register_model(Community)
db.register_model(Participant)
db.register_model(EmailAddressWithConfirmation)

return db

Expand Down
25 changes: 25 additions & 0 deletions templates/connected-accounts.html
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,31 @@ <h2>Connected Accounts</h2>
>on Balanced Payments</a>{% else %}on Balanced Payments{% end %}</div>
</td>
</tr>
{% if user.participant == participant or user.ADMIN %}
<tr>
<td class="account-type">
<img src="/assets/octocat.png" /><!-- This needs to change -->
</td>
<td class="account-details">

{% if participant.email is None %}
<a href="javascript:;" class="email">Link an email address</a>
{% else %}
<a href="javascript:;" class="email">{{ participant.email.address }}</a>
{% end %}
<form class="email-submit">
<input type="email" class="email hidden"
{% if participant.email %}
value="{{ participant.email.address }}"
{% end %}
>
<button type="submit" class="email hidden">Save</button>
<button type="cancel" class="email cancel hidden">Cancel</button>
</form>
<div class="account-type">(not publicly viewable)</div>
</td>
</tr>
{% end %}
</table>


103 changes: 103 additions & 0 deletions tests/test_email_json.py
Original file line number Diff line number Diff line change
@@ -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': '[email protected]'
}
, user='alice'
)

actual = response.code
assert actual == 200, actual

actual = json.loads(response.body)['email']
assert actual == '[email protected]', actual
12 changes: 12 additions & 0 deletions tests/test_participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('[email protected]')
expected = '[email protected]'
actual = Participant.from_username('alice').email.address
assert_equals(actual, expected)

def test_can_confirm_email(self):
Participant.from_username('alice').change_email('[email protected]', 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):
Expand Down
48 changes: 48 additions & 0 deletions www/%username/email.json.spt
Original file line number Diff line number Diff line change
@@ -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}
39 changes: 39 additions & 0 deletions www/assets/%version/gittip/profile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
});