feat: enhance user registration flow with validation
This update vastly improves the user experience for registration and email confirmation processes within the app. By integrating Flask-WTF, the commit introduces form handling with enhanced data validation and user feedback. It also refactors the SMTP configuration to utilize dynamic sender selection and improves the handling of SSL settings, ensuring a more reliable email delivery setup. To provide better security and a smoother user interface, we've implemented CSRF protection with FlaskForm and utilized WTForms for input fields, applying validators to ensure data integrity. The introduction of user existence checks before registration helps prevent duplicate usernames in the system, contributing to a cleaner and more manageable user database. Email composition in the send_email function has been streamlined for readability, and several new templates were added to provide users with clear instructions after submitting requests or completing registration, enhancing overall usability. By addressing these areas, the commit not only elevates the security posture of the application but also significantly enriches the user interaction, making the system more robust and user-friendly. Relevant configurations for SMTP and system random secret key generation have been adjusted for better compliance and security standards. Additionally, unnecessary scripts and redundant code blocks were removed for a cleaner code base, and CSS adjustments were made for improved form presentation and application aesthetics. Overall, this comprehensive update lays a stronger foundation for user management and interaction within the application, setting the stage for future enhancements and a better end-user experience.
This commit is contained in:
parent
22c74a489e
commit
79e83aa0a7
9 changed files with 318 additions and 122 deletions
166
app.py
166
app.py
|
@ -1,14 +1,24 @@
|
||||||
from flask import Flask, request, redirect, url_for, render_template
|
from flask import Flask, request, redirect, url_for, render_template
|
||||||
from plankapy import Planka
|
from plankapy import Planka, User, InvalidToken
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, SubmitField, PasswordField
|
||||||
|
from wtforms.validators import DataRequired, Email, ValidationError
|
||||||
|
|
||||||
from configparser import ConfigParser
|
from configparser import ConfigParser
|
||||||
|
from random import SystemRandom
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import smtplib
|
import smtplib
|
||||||
import uuid
|
import uuid
|
||||||
|
import string
|
||||||
|
|
||||||
app = Flask(__name__, static_folder="static", template_folder="templates")
|
app = Flask(__name__, static_folder="static", template_folder="templates")
|
||||||
|
|
||||||
|
app.config["SECRET_KEY"] = "".join(
|
||||||
|
SystemRandom().choice("".join([string.ascii_letters, string.digits]))
|
||||||
|
for _ in range(50)
|
||||||
|
)
|
||||||
|
|
||||||
config = ConfigParser()
|
config = ConfigParser()
|
||||||
config.read("settings.ini")
|
config.read("settings.ini")
|
||||||
|
|
||||||
|
@ -52,7 +62,7 @@ def rate_limit(request):
|
||||||
|
|
||||||
|
|
||||||
def get_mailserver():
|
def get_mailserver():
|
||||||
if config["SMTP"]["ssl"] == "True":
|
if config.getboolean("SMTP", "ssl", fallback=True):
|
||||||
port = config.getint("SMTP", "port", fallback=465)
|
port = config.getint("SMTP", "port", fallback=465)
|
||||||
mailserver = smtplib.SMTP_SSL(config["SMTP"]["host"], port)
|
mailserver = smtplib.SMTP_SSL(config["SMTP"]["host"], port)
|
||||||
else:
|
else:
|
||||||
|
@ -68,25 +78,26 @@ def get_mailserver():
|
||||||
|
|
||||||
def send_email(email, token):
|
def send_email(email, token):
|
||||||
mailserver = get_mailserver()
|
mailserver = get_mailserver()
|
||||||
|
sender = config.get("SMTP", "from", fallback=config["SMTP"]["username"])
|
||||||
|
|
||||||
message = f"""
|
message = f"""
|
||||||
From: {config['SMTP']['from']}
|
From: {sender}
|
||||||
To: {email}
|
To: {email}
|
||||||
Subject: Confirm your email address
|
Subject: {config['App']['name']} - Confirm your email address
|
||||||
|
|
||||||
Hi,
|
Hi,
|
||||||
|
|
||||||
Thank you for registering with {config['App']['name']}! Please click the link below to confirm your email address:
|
Thank you for registering with {config['App']['name']}! Please click the link below to confirm your email address:
|
||||||
|
|
||||||
https://{config['App']['domain']}/confirm/{token}
|
https://{config['App']['host']}/confirm/{token}
|
||||||
|
|
||||||
If you did not register with {config['App']['name']}, please ignore this email.
|
If you did not register with {config['App']['name']}, please ignore this email.
|
||||||
|
|
||||||
Thanks,
|
Thanks,
|
||||||
The {config['App']['name']} Team
|
The {config['App']['name']} Team
|
||||||
"""
|
""".strip()
|
||||||
|
|
||||||
mailserver.sendmail(config["SMTP"]["from"], email, message)
|
mailserver.sendmail(sender, email, message)
|
||||||
|
|
||||||
mailserver.quit()
|
mailserver.quit()
|
||||||
|
|
||||||
|
@ -135,12 +146,19 @@ def process_request(request):
|
||||||
return redirect(url_for("post_request"))
|
return redirect(url_for("post_request"))
|
||||||
|
|
||||||
|
|
||||||
|
class EmailForm(FlaskForm):
|
||||||
|
email = StringField("Email", validators=[DataRequired(), Email()])
|
||||||
|
submit = SubmitField("Submit")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/", methods=["GET", "POST"])
|
@app.route("/", methods=["GET", "POST"])
|
||||||
def start_request():
|
def start_request():
|
||||||
if rate_limit(request):
|
if rate_limit(request):
|
||||||
return render_template("rate_limit.html")
|
return render_template("rate_limit.html")
|
||||||
|
|
||||||
if request.method == "POST":
|
form = EmailForm()
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
return process_request(request)
|
return process_request(request)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
|
@ -148,12 +166,126 @@ def start_request():
|
||||||
app=config["App"]["name"],
|
app=config["App"]["name"],
|
||||||
title="Request Access",
|
title="Request Access",
|
||||||
subtitle="Please enter your email address to request access.",
|
subtitle="Please enter your email address to request access.",
|
||||||
|
form=form,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/check", methods=["GET"])
|
@app.route("/post_request")
|
||||||
def check():
|
def post_request():
|
||||||
return render_template("check.html")
|
return render_template(
|
||||||
|
"post_request.html",
|
||||||
|
app=config["App"]["name"],
|
||||||
|
title="Request Received",
|
||||||
|
subtitle="Your request has been received. Please check your email for further instructions.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SignupForm(FlaskForm):
|
||||||
|
email = StringField("Email")
|
||||||
|
name = StringField("Your Name", validators=[DataRequired()])
|
||||||
|
username = StringField("Username", validators=[DataRequired()])
|
||||||
|
password = PasswordField("Password", validators=[DataRequired()])
|
||||||
|
submit = SubmitField("Submit")
|
||||||
|
|
||||||
|
email.render_kw = {"readonly": True}
|
||||||
|
|
||||||
|
def validate_username(self, field):
|
||||||
|
planka = Planka(
|
||||||
|
url=config["Planka"]["url"],
|
||||||
|
username=config["Planka"]["username"],
|
||||||
|
password=config["Planka"]["password"],
|
||||||
|
)
|
||||||
|
|
||||||
|
users = User(planka)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = users.get(username=field.data)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
raise ValidationError(f"User with username {field.data} already exists")
|
||||||
|
|
||||||
|
except InvalidToken:
|
||||||
|
# This error *should* be specific at this point, but I still don't trust it
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/confirm/<token>", methods=["GET", "POST"])
|
||||||
|
def confirm_request(token):
|
||||||
|
conn = sqlite3.connect("db.sqlite3")
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT email
|
||||||
|
FROM requests
|
||||||
|
WHERE token = ?
|
||||||
|
""",
|
||||||
|
(token,),
|
||||||
|
)
|
||||||
|
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return render_template(
|
||||||
|
"unknown.html",
|
||||||
|
app=config["App"]["name"],
|
||||||
|
title="Invalid Token",
|
||||||
|
subtitle="The token you provided is invalid.",
|
||||||
|
)
|
||||||
|
|
||||||
|
email = row[0]
|
||||||
|
|
||||||
|
form = SignupForm()
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
planka = Planka(
|
||||||
|
url=config["Planka"]["url"],
|
||||||
|
username=config["Planka"]["username"],
|
||||||
|
password=config["Planka"]["password"],
|
||||||
|
)
|
||||||
|
|
||||||
|
users = User(planka)
|
||||||
|
new_user = users.build(
|
||||||
|
username=form.username.data,
|
||||||
|
name=form.name.data,
|
||||||
|
password=form.password.data,
|
||||||
|
email=email,
|
||||||
|
)
|
||||||
|
|
||||||
|
users.create(new_user)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM requests
|
||||||
|
WHERE token = ?
|
||||||
|
""",
|
||||||
|
(token,),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return redirect(url_for("post_signup"))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"signup.html",
|
||||||
|
app=config["App"]["name"],
|
||||||
|
title="Complete Signup",
|
||||||
|
subtitle="Please confirm your email address by filling out the form below.",
|
||||||
|
email=email,
|
||||||
|
form=form,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/post_signup")
|
||||||
|
def post_signup():
|
||||||
|
return render_template(
|
||||||
|
"post_signup.html",
|
||||||
|
app=config["App"]["name"],
|
||||||
|
title="Signup Complete",
|
||||||
|
subtitle="Your account has been created. You may now log in.",
|
||||||
|
planka=config["Planka"]["url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
initialize_database()
|
initialize_database()
|
||||||
|
|
|
@ -1,2 +1,4 @@
|
||||||
Flask
|
Flask
|
||||||
|
Flask-WTF
|
||||||
|
email-validator
|
||||||
git+https://git.private.coffee/PrivateCoffee/plankapy.git
|
git+https://git.private.coffee/PrivateCoffee/plankapy.git
|
||||||
|
|
211
static/style.css
211
static/style.css
|
@ -1,196 +1,203 @@
|
||||||
/* Reset and base styles */
|
/* Reset and base styles */
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Roboto', sans-serif;
|
font-family: "Roboto", sans-serif;
|
||||||
background-color: #f7f7f7;
|
background-color: #f7f7f7;
|
||||||
color: #333;
|
color: #333;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #5d5d5d;
|
color: #5d5d5d;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
header {
|
header {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
color: #5d5d5d;
|
color: #5d5d5d;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
header nav ul {
|
header nav ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
header nav ul li {
|
header nav ul li {
|
||||||
display: inline;
|
display: inline;
|
||||||
margin: 0 15px;
|
margin: 0 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
header nav ul li a {
|
header nav ul li a {
|
||||||
color: #5d5d5d;
|
color: #5d5d5d;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hero Section */
|
/* Hero Section */
|
||||||
.hero {
|
.hero {
|
||||||
background-color: #e8e8e8;
|
background-color: #e8e8e8;
|
||||||
padding: 50px 0;
|
padding: 50px 0;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero h2 {
|
.hero h2 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero p {
|
.hero p {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* IP Display Cards */
|
/* IP Display Cards */
|
||||||
.ip-display {
|
.ip-display {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ip-card {
|
.ip-card {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
padding: 30px 10px;
|
padding: 30px 10px;
|
||||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
flex-basis: calc(50% - 10px);
|
flex-basis: calc(50% - 10px);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ip-card h3 {
|
.ip-card h3 {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
color: #5d5d5d;
|
color: #5d5d5d;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ip-card p {
|
.ip-card p {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: #333;
|
color: #333;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* About Section */
|
/* About Section */
|
||||||
#about {
|
#about {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#about h2 {
|
#about h2 {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#about p {
|
#about p {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* API Section */
|
/* API Section */
|
||||||
#api {
|
#api {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#api h2 {
|
#api h2 {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
color: #333;
|
color: #333;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#api p {
|
#api p {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
|
main {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
box-shadow: 0 -2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 -2px 4px rgba(0, 0, 0, 0.1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer p {
|
footer p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #5d5d5d;
|
color: #5d5d5d;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer a {
|
footer a {
|
||||||
color: #5d5d5d;
|
color: #5d5d5d;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form Styles */
|
||||||
|
|
||||||
input {
|
input {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
border: none;
|
border: none;
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
background-color: #5d5d5d;
|
background-color: #5d5d5d;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus, button:focus {
|
input:focus,
|
||||||
outline: none;
|
button:focus {
|
||||||
border-color: #5d5d5d;
|
outline: none;
|
||||||
box-shadow: 0 0 0 2px rgba(93,93,93,0.5);
|
border-color: #5d5d5d;
|
||||||
}
|
box-shadow: 0 0 0 2px rgba(93, 93, 93, 0.5);
|
||||||
|
}
|
||||||
|
|
|
@ -29,6 +29,5 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<script src="script.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
4
templates/post_request.html
Normal file
4
templates/post_request.html
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{% extends "base.html" %} {% block content %}
|
||||||
|
<div class="container">
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
6
templates/post_signup.html
Normal file
6
templates/post_signup.html
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{% extends "base.html" %} {% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<p>Thank you for signing up!</p>
|
||||||
|
<p>Click <a href="{{ planka }}">here</a> to log in.</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -18,8 +18,12 @@
|
||||||
</small>
|
</small>
|
||||||
</p>
|
</p>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<input type="email" name="email" placeholder="Email address" required />
|
{{ form.hidden_tag() }} {{ form.email.label }}: {{ form.email(size=20) }}<br />
|
||||||
<button type="submit">Request</button>
|
{% for error in form.email.errors %}
|
||||||
|
<span style="color: red">[{{ error }}]</span>
|
||||||
|
{% endfor %}
|
||||||
|
<br />
|
||||||
|
{{ form.submit() }}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
36
templates/signup.html
Normal file
36
templates/signup.html
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
{% extends "base.html" %} {% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<p>Please fill the form below to sign up on our Planka instance.</p>
|
||||||
|
<br />
|
||||||
|
<p>
|
||||||
|
<small>
|
||||||
|
<strong>Privacy notice:</strong>
|
||||||
|
The provided data will be stored in our database to allow you to log in to
|
||||||
|
our Planka instance. Your data will not be used for any other purpose.
|
||||||
|
Note that your name will be visible to other users of the Planka instance.
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
{{ form.hidden_tag() }} {{ form.email.label }}: {{ form.email(size=20, disabled="disabled", value=email) }}<br />
|
||||||
|
{% for error in form.email.errors %}
|
||||||
|
<span style="color: red">[{{ error }}]</span>
|
||||||
|
{% endfor %}
|
||||||
|
<br />
|
||||||
|
{{ form.name.label }}: {{ form.name(size=20) }}<br />
|
||||||
|
{% for error in form.name.errors %}
|
||||||
|
<span style="color: red">[{{ error }}]</span>
|
||||||
|
{% endfor %}
|
||||||
|
<br />
|
||||||
|
{{ form.username.label }}: {{ form.username(size=20) }}<br />
|
||||||
|
{% for error in form.username.errors %}
|
||||||
|
<span style="color: red">[{{ error }}]</span>
|
||||||
|
{% endfor %}
|
||||||
|
<br />
|
||||||
|
{{ form.password.label }}: {{ form.password(size=20) }}<br />
|
||||||
|
{% for error in form.password.errors %}
|
||||||
|
<span style="color: red">[{{ error }}]</span>
|
||||||
|
{% endfor %}
|
||||||
|
{{ form.submit() }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
6
templates/unknown.html
Normal file
6
templates/unknown.html
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{% extends "base.html" %} {% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<p>Unfortunately, we could not find your email address in our database.</p>
|
||||||
|
<p>If you think this is an error, please contact the administrator.</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
Loading…
Reference in a new issue