Email verification (#172)
* Add email_verification table and endpoints, setup email verification page, adjust emails to actually verify. * Add User.create method
This commit is contained in:
parent
f823488abb
commit
cdc3ea0107
|
@ -1,6 +1,7 @@
|
|||
# Environment variable overrides for local development
|
||||
FLASK_APP=app.py
|
||||
FLASK_ENV=development
|
||||
SITE_URL="https://grant.io" # No trailing slash
|
||||
DATABASE_URL="sqlite:////tmp/dev.db"
|
||||
REDISTOGO_URL="redis://localhost:6379"
|
||||
SECRET_KEY="not-so-secret"
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
|
||||
from grant import commands, proposal, user, comment, milestone, admin
|
||||
from grant import commands, proposal, user, comment, milestone, admin, email
|
||||
from grant.extensions import bcrypt, migrate, db, ma, mail
|
||||
|
||||
|
||||
|
@ -35,6 +35,7 @@ def register_blueprints(app):
|
|||
app.register_blueprint(user.views.blueprint)
|
||||
app.register_blueprint(milestone.views.blueprint)
|
||||
app.register_blueprint(admin.views.blueprint)
|
||||
app.register_blueprint(email.views.blueprint)
|
||||
|
||||
|
||||
def register_shellcontext(app):
|
||||
|
@ -55,3 +56,4 @@ def register_commands(app):
|
|||
app.cli.add_command(commands.urls)
|
||||
|
||||
app.cli.add_command(proposal.commands.create_proposal)
|
||||
app.cli.add_command(user.commands.delete_user)
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
from . import views
|
||||
from . import models
|
|
@ -0,0 +1,28 @@
|
|||
from grant.extensions import ma, db
|
||||
from grant.utils.misc import gen_random_code
|
||||
|
||||
class EmailVerification(db.Model):
|
||||
__tablename__ = "email_verification"
|
||||
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
|
||||
code = db.Column(db.String(255), unique=True, nullable=False)
|
||||
has_verified = db.Column(db.Boolean)
|
||||
|
||||
user = db.relationship("User", back_populates="email_verification")
|
||||
|
||||
def __init__(self, user_id: int):
|
||||
self.user_id = user_id
|
||||
self.code = gen_random_code(32)
|
||||
self.has_verified = False
|
||||
|
||||
class EmailVerificationSchema(ma.Schema):
|
||||
class Meta:
|
||||
model = EmailVerification
|
||||
# Fields to expose
|
||||
fields = (
|
||||
"user_id",
|
||||
"code",
|
||||
"has_verified"
|
||||
)
|
||||
|
||||
email_verification_schema = EmailVerificationSchema()
|
|
@ -0,0 +1,30 @@
|
|||
from flask import Blueprint, jsonify
|
||||
from animal_case import animalify
|
||||
from flask_yoloapi import endpoint, parameter
|
||||
|
||||
from .models import EmailVerification, db
|
||||
|
||||
|
||||
blueprint = Blueprint("email", __name__, url_prefix="/api/v1/email")
|
||||
|
||||
|
||||
@blueprint.route("/<code>/verify", methods=["POST"])
|
||||
@endpoint.api()
|
||||
def verify_email(code):
|
||||
ev = EmailVerification.query.filter_by(code=code).first()
|
||||
if ev:
|
||||
ev.has_verified = True
|
||||
db.session.commit()
|
||||
return {"message": "Email verified"}, 200
|
||||
else:
|
||||
return {"message": "Invalid email code"}, 400
|
||||
|
||||
|
||||
@blueprint.route("/<code>/unsubscribe", methods=["POST"])
|
||||
@endpoint.api()
|
||||
def unsubscribe_email():
|
||||
ev = EmailVerification.query.filter_by(code=code).first()
|
||||
if ev:
|
||||
return {"message": "Not yet implemented"}, 500
|
||||
else:
|
||||
return {"message": "Invalid email code"}, 400
|
|
@ -13,6 +13,7 @@ env.read_env()
|
|||
|
||||
ENV = env.str("FLASK_ENV", default="production")
|
||||
DEBUG = ENV == "development"
|
||||
SITE_URL = env.str('SITE_URL', default='https://grant.io')
|
||||
AUTH_URL = env.str('AUTH_URL', default='https://eip-712.herokuapp.com')
|
||||
SQLALCHEMY_DATABASE_URI = env.str("DATABASE_URL")
|
||||
QUEUES = ["default"]
|
||||
|
|
|
@ -7,9 +7,9 @@
|
|||
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="border-radius: 3px;" bgcolor="#1890ff">
|
||||
<a href="{{ args.confirm_url }}" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 40px; border-radius: 4px; border: 1px solid #1890ff; display: inline-block;">
|
||||
Confirm Account
|
||||
<td align="center" style="border-radius: 3px;" bgcolor="#530EEC">
|
||||
<a href="{{ args.confirm_url }}" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid #530EEC; display: inline-block;">
|
||||
Confirm Email
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -18,12 +18,12 @@
|
|||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 10px; font-size: 16px;">
|
||||
If that doesn't work, copy and paste the following link in your browser:
|
||||
<p style="margin: 0 0 10px; font-size: 14px; text-align: center;">
|
||||
If that doesn't work, copy and paste the following link in your browser
|
||||
</p>
|
||||
|
||||
<p style="margin: 0 0 30px; font-size: 12px;">
|
||||
<a href="{{ args.confirm_url }}" target="_blank" style="color: #1890ff;">{{ args.confirm_url }}</a>
|
||||
<p style="margin: 0 0 30px; font-size: 12px; text-align: center;">
|
||||
<a href="{{ args.confirm_url }}" target="_blank" style="color: #530EEC;">{{ args.confirm_url }}</a>
|
||||
</p>
|
||||
|
||||
<p style="margin: 0; font-size: 10px; color: #AAA; text-align: center;">
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<!-- LOGO -->
|
||||
<tr>
|
||||
<td bgcolor="#4a4a4a" align="center">
|
||||
<td bgcolor="#530EEC" align="center">
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
|
||||
<tr>
|
||||
|
@ -78,7 +78,7 @@
|
|||
</tr>
|
||||
<!-- TITLE -->
|
||||
<tr>
|
||||
<td bgcolor="#4a4a4a" align="center" style="padding: 0px 10px 0px 10px;">
|
||||
<td bgcolor="#530EEC" align="center" style="padding: 0px 10px 0px 10px;">
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
|
||||
<tr>
|
||||
|
@ -86,7 +86,7 @@
|
|||
<![endif]-->
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
|
||||
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #221F1F; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
|
||||
<h1 style="font-size: 42px; font-weight: 400; margin: 0;">
|
||||
{{ args.title }}
|
||||
</h1>
|
||||
|
@ -135,9 +135,9 @@
|
|||
<tr>
|
||||
<td bgcolor="#f4f4f4" align="center" style="padding: 30px 30px 30px 30px; color: #666666; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;" >
|
||||
<p style="margin: 0;">
|
||||
<a href="{{ args.home_url }}" target="_blank" style="color: #111111; font-weight: 700;">Grant.io</a> -
|
||||
<a href="{{ args.account_url }}" target="_blank" style="color: #111111; font-weight: 700;">Your Account</a> -
|
||||
<a href="{{ args.email_settings_url }}" target="_blank" style="color: #111111; font-weight: 700;">Email Settings</a>
|
||||
<a href="{{ args.home_url }}" target="_blank" style="color: #221F1F; font-weight: 700;">Grant.io</a> -
|
||||
<a href="{{ args.account_url }}" target="_blank" style="color: #221F1F; font-weight: 700;">Your Account</a> -
|
||||
<a href="{{ args.email_settings_url }}" target="_blank" style="color: #221F1F; font-weight: 700;">Email Settings</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -146,7 +146,7 @@
|
|||
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 30px 30px 30px; color: #666666; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;" >
|
||||
<p style="margin: 0;">
|
||||
Don’t want anymore emails?
|
||||
<a href="{{ args.unsubscribe_url }}" target="_blank" style="color: #111111; font-weight: 700;">
|
||||
<a href="{{ args.unsubscribe_url }}" target="_blank" style="color: #221F1F; font-weight: 700;">
|
||||
Click here to unsubscribe
|
||||
</a>
|
||||
.
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
from . import commands
|
||||
from . import views
|
||||
from . import models
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import click
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
from .models import User, db
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument('identity')
|
||||
@with_appcontext
|
||||
def delete_user(identity):
|
||||
print(identity)
|
||||
user = None
|
||||
if str.isdigit(identity):
|
||||
user = User.query.filter(id=identity).first()
|
||||
else:
|
||||
user = User.query.filter(
|
||||
(User.account_address == identity) |
|
||||
(User.email_address == identity)
|
||||
).first()
|
||||
|
||||
if user:
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
click.echo(f'Succesfully deleted {user.display_name} (uid {user.id})')
|
||||
else:
|
||||
raise click.BadParameter('Invalid user identity. Must be a userid, '\
|
||||
'account address, or email address of an '\
|
||||
'existing user.')
|
|
@ -1,5 +1,8 @@
|
|||
from grant.comment.models import Comment
|
||||
from grant.email.models import EmailVerification
|
||||
from grant.extensions import ma, db
|
||||
from grant.utils.misc import make_url
|
||||
from grant.email.send import send_email
|
||||
|
||||
|
||||
class SocialMedia(db.Model):
|
||||
|
@ -40,6 +43,7 @@ class User(db.Model):
|
|||
social_medias = db.relationship(SocialMedia, backref="user", lazy=True)
|
||||
comments = db.relationship(Comment, backref="user", lazy=True)
|
||||
avatar = db.relationship(Avatar, uselist=False, back_populates="user")
|
||||
email_verification = db.relationship(EmailVerification, uselist=False, back_populates="user", lazy=True)
|
||||
|
||||
# TODO - add create and validate methods
|
||||
|
||||
|
@ -52,6 +56,29 @@ class User(db.Model):
|
|||
self.display_name = display_name
|
||||
self.title = title
|
||||
|
||||
@staticmethod
|
||||
def create(email_address=None, account_address=None, display_name=None, title=None):
|
||||
user = User(
|
||||
account_address=account_address,
|
||||
email_address=email_address,
|
||||
display_name=display_name,
|
||||
title=title
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
|
||||
# Setup & send email verification
|
||||
ev = EmailVerification(user_id=user.id)
|
||||
db.session.add(ev)
|
||||
db.session.commit()
|
||||
|
||||
send_email(user.email_address, 'signup', {
|
||||
'display_name': user.display_name,
|
||||
'confirm_url': make_url(f'/email/verify?code={ev.code}')
|
||||
})
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def get_by_email_or_account_address(email_address: str = None, account_address: str = None):
|
||||
if not email_address and not account_address:
|
||||
|
@ -62,7 +89,6 @@ class User(db.Model):
|
|||
(User.email_address == email_address)
|
||||
).first()
|
||||
|
||||
|
||||
class UserSchema(ma.Schema):
|
||||
class Meta:
|
||||
model = User
|
||||
|
|
|
@ -2,10 +2,10 @@ from animal_case import animalify
|
|||
from flask import Blueprint, g, jsonify
|
||||
from flask_yoloapi import endpoint, parameter
|
||||
|
||||
|
||||
from .models import User, SocialMedia, Avatar, users_schema, user_schema, db
|
||||
from ..email.send import send_email
|
||||
from ..proposal.models import Proposal, proposal_team
|
||||
from ..utils.auth import requires_sm
|
||||
from grant.proposal.models import Proposal, proposal_team
|
||||
from grant.utils.auth import requires_sm
|
||||
|
||||
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
|
||||
|
||||
|
@ -56,22 +56,12 @@ def create_user(account_address, email_address, display_name, title):
|
|||
return {"message": "User with that address or email already exists"}, 409
|
||||
|
||||
# TODO: Handle avatar & social stuff too
|
||||
user = User(
|
||||
user = User.create(
|
||||
account_address=account_address,
|
||||
email_address=email_address,
|
||||
display_name=display_name,
|
||||
title=title
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
db.session.commit()
|
||||
|
||||
send_email(email_address, 'signup', {
|
||||
'display_name': display_name,
|
||||
# TODO: Make this dynamic
|
||||
'confirm_url': 'https://grant.io/user/confirm',
|
||||
})
|
||||
|
||||
result = user_schema.dump(user)
|
||||
return result
|
||||
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import datetime
|
||||
import time
|
||||
import random
|
||||
import string
|
||||
from grant.settings import SITE_URL
|
||||
|
||||
epoch = datetime.datetime.utcfromtimestamp(0)
|
||||
|
||||
|
@ -15,3 +18,11 @@ def dt_to_ms(dt):
|
|||
|
||||
def dt_to_unix(dt):
|
||||
return int(time.mktime(dt.timetuple()))
|
||||
|
||||
def gen_random_code(length=32):
|
||||
return ''.join(
|
||||
[random.choice(string.ascii_letters + string.digits) for n in range(length)]
|
||||
)
|
||||
|
||||
def make_url(path: str):
|
||||
return f'{SITE_URL}{path}'
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 6e02ee4b9ca3
|
||||
Revises: 5f38d8603897
|
||||
Create Date: 2018-11-01 16:29:11.190975
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '6e02ee4b9ca3'
|
||||
down_revision = '5f38d8603897'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('email_verification',
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('code', sa.String(length=255), nullable=False),
|
||||
sa.Column('has_verified', sa.Boolean(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('user_id'),
|
||||
sa.UniqueConstraint('code')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('email_verification')
|
||||
# ### end Alembic commands ###
|
|
@ -26,6 +26,7 @@ const Tos = loadable(() => import('pages/tos'));
|
|||
const About = loadable(() => import('pages/about'));
|
||||
const Privacy = loadable(() => import('pages/privacy'));
|
||||
const Contact = loadable(() => import('pages/contact'));
|
||||
const VerifyEmail = loadable(() => import('pages/email-verify'));
|
||||
|
||||
import 'styles/style.less';
|
||||
|
||||
|
@ -192,6 +193,17 @@ const routeConfigs: RouteConfig[] = [
|
|||
title: 'Signed out',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Verify email
|
||||
route: {
|
||||
path: '/email/verify',
|
||||
component: VerifyEmail,
|
||||
exact: true,
|
||||
},
|
||||
template: {
|
||||
title: 'Verify email',
|
||||
},
|
||||
},
|
||||
{
|
||||
// 404
|
||||
route: {
|
||||
|
|
|
@ -73,3 +73,7 @@ export function updateUser(user: TeamMember): Promise<{ data: TeamMember }> {
|
|||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyEmail(code: string): Promise<any> {
|
||||
return axios.post(`/api/v1/email/${code}/verify`);
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
import React from 'react';
|
||||
import { Spin, Button } from 'antd';
|
||||
import qs from 'query-string';
|
||||
import { withRouter, RouteComponentProps, Link } from 'react-router-dom';
|
||||
import Result from 'ant-design-pro/lib/Result';
|
||||
import { verifyEmail } from 'api/api';
|
||||
|
||||
interface State {
|
||||
isVerifying: boolean;
|
||||
hasVerified: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
class VerifyEmail extends React.Component<RouteComponentProps, State> {
|
||||
state: State = {
|
||||
isVerifying: false,
|
||||
hasVerified: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const args = qs.parse(this.props.location.search);
|
||||
if (args.code) {
|
||||
this.setState({ isVerifying: true });
|
||||
verifyEmail(args.code)
|
||||
.then(() => {
|
||||
this.setState({
|
||||
hasVerified: true,
|
||||
isVerifying: false,
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
this.setState({
|
||||
error: err.message || err.toString(),
|
||||
isVerifying: false,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
error: `
|
||||
Missing code parameter from email.
|
||||
Make sure you copied the full link.
|
||||
`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { hasVerified, error } = this.state;
|
||||
|
||||
const actions = (
|
||||
<div>
|
||||
<Link to="/profile">
|
||||
<Button size="large" type="primary">
|
||||
View profile
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/proposals">
|
||||
<Button size="large" style={{ marginLeft: '0.5rem' }}>
|
||||
Browse proposals
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (hasVerified) {
|
||||
return (
|
||||
<Result
|
||||
type="success"
|
||||
title="Email has been verified"
|
||||
description="You now have full access to Grant.io"
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
} else if (error) {
|
||||
return (
|
||||
<Result
|
||||
type="error"
|
||||
title="Unable to verify email"
|
||||
description={error}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <Spin size="large" />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(VerifyEmail);
|
Loading…
Reference in New Issue