Merge pull request #622 from ngoduykhanh/email_verification

Add user email verification
This commit is contained in:
Khanh Ngo 2019-12-22 10:06:43 +07:00 committed by GitHub
commit 3bf6e6e9f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 511 additions and 21 deletions

View File

@ -20,6 +20,16 @@ SQLALCHEMY_TRACK_MODIFICATIONS = True
### DATABASE - SQLite ### DATABASE - SQLite
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db') SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db')
### SMTP config
# MAIL_SERVER = 'localhost'
# MAIL_PORT = 25
# MAIL_DEBUG = False
# MAIL_USE_TLS = False
# MAIL_USE_SSL = False
# MAIL_USERNAME = None
# MAIL_PASSWORD = None
# MAIL_DEFAULT_SENDER = ('PowerDNS-Admin', 'noreply@domain.ltd')
# SAML Authnetication # SAML Authnetication
SAML_ENABLED = False SAML_ENABLED = False
# SAML_DEBUG = True # SAML_DEBUG = True

View File

@ -1,6 +1,6 @@
# Defaults for Docker image # Defaults for Docker image
BIND_ADDRESS='0.0.0.0' BIND_ADDRESS = '0.0.0.0'
PORT=80 PORT = 80
legal_envvars = ( legal_envvars = (
'SECRET_KEY', 'SECRET_KEY',
@ -10,6 +10,14 @@ legal_envvars = (
'SALT', 'SALT',
'SQLALCHEMY_TRACK_MODIFICATIONS', 'SQLALCHEMY_TRACK_MODIFICATIONS',
'SQLALCHEMY_DATABASE_URI', 'SQLALCHEMY_DATABASE_URI',
'MAIL_SERVER',
'MAIL_PORT',
'MAIL_DEBUG',
'MAIL_USE_TLS',
'MAIL_USE_SSL',
'MAIL_USERNAME',
'MAIL_PASSWORD',
'MAIL_DEFAULT_SENDER',
'SAML_ENABLED', 'SAML_ENABLED',
'SAML_DEBUG', 'SAML_DEBUG',
'SAML_PATH', 'SAML_PATH',
@ -37,14 +45,14 @@ legal_envvars = (
'SAML_LOGOUT_URL', 'SAML_LOGOUT_URL',
) )
legal_envvars_int = ( legal_envvars_int = ('PORT', 'MAIL_PORT', 'SAML_METADATA_CACHE_LIFETIME')
'PORT',
'SAML_METADATA_CACHE_LIFETIME',
)
legal_envvars_bool = ( legal_envvars_bool = (
'SQLALCHEMY_TRACK_MODIFICATIONS', 'SQLALCHEMY_TRACK_MODIFICATIONS',
'HSTS_ENABLED', 'HSTS_ENABLED',
'MAIL_DEBUG',
'MAIL_USE_TLS',
'MAIL_USE_SSL',
'SAML_ENABLED', 'SAML_ENABLED',
'SAML_DEBUG', 'SAML_DEBUG',
'SAML_SIGN_REQUEST', 'SAML_SIGN_REQUEST',

View File

@ -0,0 +1,27 @@
"""Add user.confirmed column
Revision ID: 3f76448bb6de
Revises: b0fea72a3f20
Create Date: 2019-12-21 17:11:36.564632
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3f76448bb6de'
down_revision = 'b0fea72a3f20'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('user') as batch_op:
batch_op.add_column(
sa.Column('confirmed', sa.Boolean(), nullable=False,
default=False))
def downgrade():
with op.batch_alter_table('user') as batch_op:
batch_op.drop_column('confirmed')

View File

@ -2,6 +2,7 @@ import os
import logging import logging
from flask import Flask from flask import Flask
from flask_seasurf import SeaSurf from flask_seasurf import SeaSurf
from flask_mail import Mail
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from .lib import utils from .lib import utils
@ -64,6 +65,9 @@ def create_app(config=None):
from flask_sslify import SSLify from flask_sslify import SSLify
_sslify = SSLify(app) # lgtm [py/unused-local-variable] _sslify = SSLify(app) # lgtm [py/unused-local-variable]
# SMTP
app.mail = Mail(app)
# Load app's components # Load app's components
assets.init_app(app) assets.init_app(app)
models.init_app(app) models.init_app(app)

View File

@ -4,7 +4,7 @@ from functools import wraps
from flask import g, request, abort, current_app, render_template from flask import g, request, abort, current_app, render_template
from flask_login import current_user from flask_login import current_user
from .models import User, ApiKey, Setting, Domain from .models import User, ApiKey, Setting, Domain, Setting
from .lib.errors import RequestIsNotJSON, NotEnoughPrivileges from .lib.errors import RequestIsNotJSON, NotEnoughPrivileges
from .lib.errors import DomainAccessForbidden from .lib.errors import DomainAccessForbidden
@ -121,6 +121,12 @@ def api_basic_auth(f):
plain_text_password=password) plain_text_password=password)
try: try:
if Setting().get('verify_user_email') and user.email and not user.confirmed:
current_app.logger.warning(
'Basic authentication failed for user {} because of unverified email address'
.format(username))
abort(401)
auth_method = request.args.get('auth_method', 'LOCAL') auth_method = request.args.get('auth_method', 'LOCAL')
auth_method = 'LDAP' if auth_method != 'LOCAL' else 'LOCAL' auth_method = 'LDAP' if auth_method != 'LOCAL' else 'LOCAL'
auth = user.is_validate(method=auth_method, auth = user.is_validate(method=auth_method,

View File

@ -25,6 +25,7 @@ class Setting(db.Model):
'allow_user_create_domain': False, 'allow_user_create_domain': False,
'bg_domain_updates': False, 'bg_domain_updates': False,
'site_name': 'PowerDNS-Admin', 'site_name': 'PowerDNS-Admin',
'site_url': 'http://localhost:9191',
'session_timeout': 10, 'session_timeout': 10,
'warn_session_timeout': True, 'warn_session_timeout': True,
'pdns_api_url': '', 'pdns_api_url': '',
@ -33,6 +34,7 @@ class Setting(db.Model):
'pdns_version': '4.1.1', 'pdns_version': '4.1.1',
'local_db_enabled': True, 'local_db_enabled': True,
'signup_enabled': True, 'signup_enabled': True,
'verify_user_email': False,
'ldap_enabled': False, 'ldap_enabled': False,
'ldap_type': 'ldap', 'ldap_type': 'ldap',
'ldap_uri': '', 'ldap_uri': '',

View File

@ -26,6 +26,7 @@ class User(db.Model):
lastname = db.Column(db.String(64)) lastname = db.Column(db.String(64))
email = db.Column(db.String(128)) email = db.Column(db.String(128))
otp_secret = db.Column(db.String(16)) otp_secret = db.Column(db.String(16))
confirmed = db.Column(db.SmallInteger, nullable=False, default=0)
role_id = db.Column(db.Integer, db.ForeignKey('role.id')) role_id = db.Column(db.Integer, db.ForeignKey('role.id'))
def __init__(self, def __init__(self,
@ -38,6 +39,7 @@ class User(db.Model):
role_id=None, role_id=None,
email=None, email=None,
otp_secret=None, otp_secret=None,
confirmed=False,
reload_info=True): reload_info=True):
self.id = id self.id = id
self.username = username self.username = username
@ -48,6 +50,7 @@ class User(db.Model):
self.role_id = role_id self.role_id = role_id
self.email = email self.email = email
self.otp_secret = otp_secret self.otp_secret = otp_secret
self.confirmed = confirmed
if reload_info: if reload_info:
user_info = self.get_user_info_by_id( user_info = self.get_user_info_by_id(
@ -61,6 +64,7 @@ class User(db.Model):
self.email = user_info.email self.email = user_info.email
self.role_id = user_info.role_id self.role_id = user_info.role_id
self.otp_secret = user_info.otp_secret self.otp_secret = user_info.otp_secret
self.confirmed = user_info.confirmed
def is_authenticated(self): def is_authenticated(self):
return True return True
@ -504,11 +508,25 @@ class User(db.Model):
user.firstname = self.firstname if self.firstname else user.firstname user.firstname = self.firstname if self.firstname else user.firstname
user.lastname = self.lastname if self.lastname else user.lastname user.lastname = self.lastname if self.lastname else user.lastname
user.email = self.email if self.email else user.email
user.password = self.get_hashed_password( user.password = self.get_hashed_password(
self.plain_text_password).decode( self.plain_text_password).decode(
"utf-8") if self.plain_text_password else user.password "utf-8") if self.plain_text_password else user.password
if self.email:
# Can not update to a new email that
# already been used.
existing_email = User.query.filter(
User.email == self.email,
User.username != self.username).first()
if existing_email:
return False
# If need to verify new email,
# update the "confirmed" status.
if user.email != self.email:
user.email = self.email
if Setting().get('verify_user_email'):
user.confirmed = 0
if enable_otp is not None: if enable_otp is not None:
user.otp_secret = "" user.otp_secret = ""
@ -524,6 +542,13 @@ class User(db.Model):
db.session.rollback() db.session.rollback()
return False return False
def update_confirmed(self, confirmed):
"""
Update user email confirmation status
"""
self.confirmed = confirmed
db.session.commit()
def get_domains(self): def get_domains(self):
""" """
Get list of domains which the user is granted to have Get list of domains which the user is granted to have

View File

@ -501,7 +501,7 @@ def setting_basic():
'pretty_ipv6_ptr', 'dnssec_admins_only', 'pretty_ipv6_ptr', 'dnssec_admins_only',
'allow_user_create_domain', 'bg_domain_updates', 'site_name', 'allow_user_create_domain', 'bg_domain_updates', 'site_name',
'session_timeout', 'warn_session_timeout', 'ttl_options', 'session_timeout', 'warn_session_timeout', 'ttl_options',
'pdns_api_timeout' 'pdns_api_timeout', 'verify_user_email'
] ]
return render_template('admin_setting_basic.html', settings=settings) return render_template('admin_setting_basic.html', settings=settings)

View File

@ -29,6 +29,8 @@ from ..services.github import github_oauth
from ..services.azure import azure_oauth from ..services.azure import azure_oauth
from ..services.oidc import oidc_oauth from ..services.oidc import oidc_oauth
from ..services.saml import SAML from ..services.saml import SAML
from ..services.token import confirm_token
from ..services.email import send_account_verification
google = None google = None
github = None github = None
@ -280,6 +282,12 @@ def login():
plain_text_password=password) plain_text_password=password)
try: try:
if Setting().get('verify_user_email') and user.email and not user.confirmed:
return render_template(
'login.html',
saml_enabled=SAML_ENABLED,
error='Please confirm your email address first')
auth = user.is_validate(method=auth_method, auth = user.is_validate(method=auth_method,
src_ip=request.remote_addr) src_ip=request.remote_addr)
if auth == False: if auth == False:
@ -411,6 +419,8 @@ def register():
try: try:
result = user.create_local_user() result = user.create_local_user()
if result and result['status']: if result and result['status']:
if Setting().get('verify_user_email'):
send_account_verification(email)
return redirect(url_for('index.login')) return redirect(url_for('index.login'))
else: else:
return render_template('register.html', return render_template('register.html',
@ -421,6 +431,50 @@ def register():
return render_template('errors/404.html'), 404 return render_template('errors/404.html'), 404
@index_bp.route('/confirm/<token>', methods=['GET'])
def confirm_email(token):
email = confirm_token(token)
if not email:
# Cannot confirm email
return render_template('email_confirmation.html', status=0)
user = User.query.filter_by(email=email).first_or_404()
if user.confirmed:
# Already confirmed
current_app.logger.info(
"User email {} already confirmed".format(email))
return render_template('email_confirmation.html', status=2)
else:
# Confirm email is valid
user.update_confirmed(confirmed=1)
current_app.logger.info(
"User email {} confirmed successfully".format(email))
return render_template('email_confirmation.html', status=1)
@index_bp.route('/resend-confirmation-email', methods=['GET', 'POST'])
def resend_confirmation_email():
if current_user.is_authenticated:
return redirect(url_for('index.index'))
if request.method == 'GET':
return render_template('resend_confirmation_email.html')
elif request.method == 'POST':
email = request.form.get('email')
user = User.query.filter(User.email == email).first()
if not user:
# Email not found
status = 0
elif user.confirmed:
# Email already confirmed
status = 1
else:
# Send new confirmed email
send_account_verification(user.email)
status = 2
return render_template('resend_confirmation_email.html', status=status)
@index_bp.route('/nic/checkip.html', methods=['GET', 'POST']) @index_bp.route('/nic/checkip.html', methods=['GET', 'POST'])
def dyndns_checkip(): def dyndns_checkip():
# This route covers the default ddclient 'web' setting for the checkip service # This route covers the default ddclient 'web' setting for the checkip service

View File

@ -0,0 +1,27 @@
import traceback
from flask_mail import Message
from flask import current_app, render_template, url_for
from .token import generate_confirmation_token
from ..models.setting import Setting
def send_account_verification(user_email):
"""
Send welcome message for the new registration
"""
try:
token = generate_confirmation_token(user_email)
verification_link = url_for('index.confirm_email', token=token, _external=True)
subject = "Welcome to {}".format(Setting().get('site_name'))
msg = Message(subject=subject)
msg.recipients = [user_email]
msg.body = "Please access the following link verify your email address. {}".format(
verification_link)
msg.html = render_template('emails/account_verification.html',
verification_link=verification_link)
current_app.mail.send(msg)
except Exception as e:
current_app.logger.error("Cannot send account verification email. Error: {}".format(e))
current_app.logger.debug(traceback.format_exc())

View File

@ -0,0 +1,19 @@
from flask import current_app
from itsdangerous import URLSafeTimedSerializer
def generate_confirmation_token(email):
serializer = URLSafeTimedSerializer(current_app.config['SECRET_KEY'])
return serializer.dumps(email, salt=current_app.config['SALT'])
def confirm_token(token, expiration=86400):
serializer = URLSafeTimedSerializer(current_app.config['SECRET_KEY'])
try:
email = serializer.loads(token,
salt=current_app.config['SALT'],
max_age=expiration)
except Exception as e:
current_app.logger.debug(e)
return False
return email

View File

@ -177,7 +177,7 @@
{% block scripts %} {% block scripts %}
{% assets "js_main" -%} {% assets "js_main" -%}
<script type="text/javascript" src="{{ ASSET_URL }}"></script> <script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% if SETTING.get('warn_session_timeout') %} {% if SETTING.get('warn_session_timeout') and current_user.is_authenticated %}
<script> <script>
// close the session warning popup when receive // close the session warning popup when receive
// a boradcast message // a boradcast message

View File

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}<title>Email verification - {{ SITE_NAME }}</title>{% endblock %}
{% block dashboard_stat %}
{% endblock %}
{% block content %}
<!-- Main content -->
<section class="content">
<div class="error-page">
<div class="error-content">
{% if status == 1 %}
<h3>
<i class="fa fa-thumbs-o-up text-success"></i> Email verification successful!
</h3>
<p>
You have confirmed your account. <a href="{{ url_for('index.login') }}">Click here</a> to login.
</p>
{% elif status == 2 %}
<h3>
<i class="fa fa-hand-stop-o text-info"></i> Already verified!
</h3>
<p>
You have confirmed your account already. <a href="{{ url_for('index.login') }}">Click here</a> to login.
</p>
{% else %}
<h3>
<i class="fa fa-warning text-yellow"></i> Email verification failed!
</h3>
<p>
The confirmation link is invalid or has expired. <a href="{{ url_for('index.resend_confirmation_email') }}">Click here</a> if you want to resend a new link.
</p>
{% endif %}
</div>
<!-- /.error-content -->
</div>
<!-- /.error-page -->
</section>
<!-- /.content -->
{% endblock %}

View File

@ -0,0 +1,220 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>{{ SITE_NAME }} user verification email</title>
<style>
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #34495e !important;
}
.btn-primary a:hover {
background-color: #34495e !important;
border-color: #34495e !important;
}
}
</style>
</head>
<body class=""
style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<span class="preheader"
style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">This
is preheader text. Some clients will show this text as a preview.</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f6f6f6; width: 100%;"
width="100%" bgcolor="#f6f6f6">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top">&nbsp;</td>
<td class="container"
style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; max-width: 580px; padding: 10px; width: 580px; margin: 0 auto;"
width="580" valign="top">
<div class="content"
style="box-sizing: border-box; display: block; margin: 0 auto; max-width: 580px; padding: 10px;">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" class="main"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;"
width="100%">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper"
style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;"
valign="top">
<table role="presentation" border="0" cellpadding="0" cellspacing="0"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;"
width="100%">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"
valign="top">
<p
style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">
Hello!</p>
<p
style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">
Please click the button below to verify your email address.</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0"
class="btn btn-primary"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; box-sizing: border-box; width: 100%;"
width="100%">
<tbody>
<tr>
<td align="left"
style="font-family: sans-serif; font-size: 14px; vertical-align: top; padding-bottom: 15px;"
valign="top">
<table role="presentation" border="0" cellpadding="0"
cellspacing="0"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top; border-radius: 5px; text-align: center; background-color: #3498db;"
valign="top" align="center"
bgcolor="#3498db"> <a
href="{{ verification_link }}"
target="_blank"
rel="noopener noreferrer"
style="border: solid 1px #3498db; border-radius: 5px; box-sizing: border-box; cursor: pointer; display: inline-block; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-decoration: none; text-transform: capitalize; background-color: #3498db; border-color: #3498db; color: #ffffff;">Verify
Email Address</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p
style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">
Your confirmation link will be expired in 24 hours. If you did not create an account, no further action is required.</p>
<p
style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 15px;">
Cheers!</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
<!-- START FOOTER -->
<div class="footer" style="clear: both; margin-top: 10px; text-align: center; width: 100%;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;"
width="100%">
<tr>
<td class="content-block"
style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;"
valign="top" align="center">
<span class="apple-link"
style="color: #999999; font-size: 12px; text-align: center;">This email was sent
from <a href="{{ SETTING.get('site_url') }}"
target="_blank" rel="noopener noreferrer">{{ SITE_NAME }}</a></span>
</td>
</tr>
<tr>
<td class="content-block powered-by"
style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; color: #999999; font-size: 12px; text-align: center;"
valign="top" align="center">
Powered by <a href="https://github.com/ngoduykhanh/PowerDNS-Admin"
style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">PowerDNS-Admin</a>.
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
</div>
</td>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;" valign="top">&nbsp;</td>
</tr>
</table>
</body>
</html>

View File

@ -120,6 +120,10 @@
{% if SETTING.get('signup_enabled') %} {% if SETTING.get('signup_enabled') %}
<br> <br>
<a href="{{ url_for('index.register') }}" class="text-center">Create an account </a> <a href="{{ url_for('index.register') }}" class="text-center">Create an account </a>
{% if SETTING.get('verify_user_email') %}
<br/>
<a href="{{ url_for('index.resend_confirmation_email') }}" class="text-center">Resend confirmation email</a>
{% endif %}
{% endif %} {% endif %}
</div> </div>
<!-- /.login-box-body --> <!-- /.login-box-body -->

View File

@ -48,8 +48,7 @@
</div> </div>
<div class="form-group has-feedback"> <div class="form-group has-feedback">
<input type="email" class="form-control" placeholder="Email" name="email" <input type="email" class="form-control" placeholder="Email" name="email"
data-error="Please input your valid email address" data-error="Please input your valid email address" required>
pattern="^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$" required>
<span class="glyphicon glyphicon-envelope form-control-feedback"></span> <span class="glyphicon glyphicon-envelope form-control-feedback"></span>
<span class="help-block with-errors"></span> <span class="help-block with-errors"></span>
</div> </div>

View File

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}<title>Resend confirmation email - {{ SITE_NAME }}</title>{% endblock %}
{% block dashboard_stat %}
{% endblock %}
{% block content %}
<!-- Main content -->
<section class="content">
<div class="error-page">
<div class="error-content">
<h3>
<i class="fa fa-hand-o-right text-info"></i> Resend a confirmation email
</h3>
<p>
Enter your email address to get new account confirmation link.
</p>
<form class="search-form" method="post">
<div class="input-group">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="text" name="email" class="form-control" placeholder="Email address" data-error="Please input your email" required>
<div class="input-group-btn">
<button type="submit" name="submit" class="btn btn-success btn-flat"><i class="fa fa-mail-reply"></i>
</button>
</div>
</div>
<!-- /.input-group -->
<p>
{% if status == 0 %}
<font color="red">Email not found!</font>
{% elif status == 1 %}
<font color="red">Email already confirmed!</font>
{% elif status == 2 %}
<font color="green">Confirmation email sent!</font>
{% endif %}
</p>
</form>
</div>
<!-- /.error-content -->
</div>
<!-- /.error-page -->
</section>
<!-- /.content -->
{% endblock %}

View File

@ -26,3 +26,4 @@ pytest==5.0.1
pytimeparse==1.1.8 pytimeparse==1.1.8
PyYAML==5.1.1 PyYAML==5.1.1
Flask-SSLify==0.1.5 Flask-SSLify==0.1.5
Flask-Mail==0.9.1