Add partial email support and todo list
This commit is contained in:
@@ -1,2 +1,8 @@
|
||||
# Generate a long, random value before deploying.
|
||||
SECRET_KEY=replace-with-a-long-random-secret
|
||||
PUBLIC_URL=https://eternityproject.fi
|
||||
MAIL_HOST=smtp.example.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=your-smtp-username
|
||||
MAIL_PASSWORD=your-smtp-password
|
||||
MAIL_FROM=noreply@eternityproject.fi
|
||||
|
||||
@@ -17,6 +17,12 @@ Open `http://localhost:8000`, create the first account, and publish a field note
|
||||
|
||||
The initial administrator account is `admin` with password `admin`, as requested for first-run access. Sign in, scan the displayed QR code with any iPhone or Android TOTP authenticator, and change this password before exposing the service to the internet. New registrations are held for approval in **Accounts**; accepted users must enroll a TOTP authenticator before they can publish.
|
||||
|
||||
## Email and password recovery
|
||||
|
||||
Registration now requires an email address. Members can change their password from the navigation. The sign-in page provides an email-based recovery link; it expires after one hour and can only be used once. Administrators can send the same recovery email to any approved member from **Accounts**.
|
||||
|
||||
Set `PUBLIC_URL` and the `MAIL_*` values in `.env` to send recovery emails. The SMTP account must support STARTTLS on the configured port. Review [TODO.md](TODO.md) before production deployment.
|
||||
|
||||
## Production notes
|
||||
|
||||
Put this service behind a TLS reverse proxy for `eternityproject.fi` (for example Caddy or Nginx). Set a strong unique `SECRET_KEY`; the Compose file intentionally refuses to start without it. Back up the `eternity_data` Docker volume, which contains accounts and posts.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Eternity Project TODO
|
||||
|
||||
- [x] Dockerized Flask publishing service with persistent storage.
|
||||
- [x] Local accounts, administrator approval, and TOTP MFA.
|
||||
- [x] QR-based MFA enrollment for iPhone and Android authenticators.
|
||||
- [x] Email addresses, member password changes, and expiring password-reset links.
|
||||
- [ ] Configure production SMTP credentials and verify outgoing email delivery.
|
||||
- [ ] Change the initial `admin` password and enroll its authenticator.
|
||||
- [ ] Add CSRF protection to all state-changing forms.
|
||||
- [ ] Add automated database backups and test restoration.
|
||||
- [ ] Configure TLS reverse proxy and production domain for `eternityproject.fi`.
|
||||
Binary file not shown.
@@ -1,11 +1,15 @@
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import smtplib
|
||||
from base64 import b64encode
|
||||
from io import BytesIO
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.message import EmailMessage
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from secrets import token_urlsafe
|
||||
from hashlib import sha256
|
||||
|
||||
from flask import Flask, abort, flash, g, redirect, render_template, request, session, url_for
|
||||
import pyotp
|
||||
@@ -19,6 +23,12 @@ app = Flask(__name__)
|
||||
app.config.update(
|
||||
SECRET_KEY=os.environ.get("SECRET_KEY", "change-this-secret-before-production"),
|
||||
DATABASE=DATABASE,
|
||||
MAIL_HOST=os.environ.get("MAIL_HOST"),
|
||||
MAIL_PORT=int(os.environ.get("MAIL_PORT", "587")),
|
||||
MAIL_USERNAME=os.environ.get("MAIL_USERNAME"),
|
||||
MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD"),
|
||||
MAIL_FROM=os.environ.get("MAIL_FROM", "noreply@eternityproject.fi"),
|
||||
PUBLIC_URL=os.environ.get("PUBLIC_URL", "http://localhost:8000").rstrip("/"),
|
||||
)
|
||||
|
||||
|
||||
@@ -44,6 +54,7 @@ def init_db():
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
is_approved INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -63,6 +74,15 @@ def init_db():
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
user_columns = {row["name"] for row in db.execute("PRAGMA table_info(users)")}
|
||||
@@ -71,10 +91,12 @@ def init_db():
|
||||
"role": "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member'",
|
||||
"mfa_secret": "ALTER TABLE users ADD COLUMN mfa_secret TEXT",
|
||||
"mfa_enabled": "ALTER TABLE users ADD COLUMN mfa_enabled INTEGER NOT NULL DEFAULT 0",
|
||||
"email": "ALTER TABLE users ADD COLUMN email TEXT",
|
||||
}
|
||||
for column, statement in migrations.items():
|
||||
if column not in user_columns:
|
||||
db.execute(statement)
|
||||
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique ON users(email)")
|
||||
|
||||
# Preserve access for accounts created before approvals were introduced.
|
||||
db.execute("UPDATE users SET is_approved = 1 WHERE role = 'member' AND is_approved = 0")
|
||||
@@ -135,6 +157,40 @@ def post_from_request(post_id=None):
|
||||
return title, unique_slug(title, post_id), excerpt, body, category, now
|
||||
|
||||
|
||||
def valid_email(email):
|
||||
return re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) is not None
|
||||
|
||||
|
||||
def create_reset_token(user_id):
|
||||
token = token_urlsafe(32)
|
||||
now = datetime.now(timezone.utc)
|
||||
get_db().execute("UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL", (now.isoformat(), user_id))
|
||||
get_db().execute(
|
||||
"""INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(user_id, sha256(token.encode("utf-8")).hexdigest(), (now + timedelta(hours=1)).isoformat(), now.isoformat()),
|
||||
)
|
||||
get_db().commit()
|
||||
return token
|
||||
|
||||
|
||||
def send_reset_email(user):
|
||||
if not app.config["MAIL_HOST"]:
|
||||
raise RuntimeError("Email is not configured. Set MAIL_HOST and related SMTP settings.")
|
||||
token = create_reset_token(user["id"])
|
||||
reset_url = f"{app.config['PUBLIC_URL']}{url_for('reset_password', token=token)}"
|
||||
message = EmailMessage()
|
||||
message["Subject"] = "Reset your Eternity Project password"
|
||||
message["From"] = app.config["MAIL_FROM"]
|
||||
message["To"] = user["email"]
|
||||
message.set_content(f"Hello {user['username']},\n\nUse this link within one hour to choose a new password:\n{reset_url}\n\nIf you did not request this, you can ignore this email.")
|
||||
with smtplib.SMTP(app.config["MAIL_HOST"], app.config["MAIL_PORT"]) as client:
|
||||
client.starttls()
|
||||
if app.config["MAIL_USERNAME"]:
|
||||
client.login(app.config["MAIL_USERNAME"], app.config["MAIL_PASSWORD"])
|
||||
client.send_message(message)
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_current_user():
|
||||
user = None
|
||||
@@ -168,22 +224,25 @@ def post(slug):
|
||||
def register():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip().lower()
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
password = request.form.get("password", "")
|
||||
if not re.fullmatch(r"[a-z0-9_-]{3,32}", username):
|
||||
flash("Use 3-32 lowercase letters, numbers, hyphens, or underscores.", "error")
|
||||
elif len(password) < 10:
|
||||
flash("Choose a password with at least 10 characters.", "error")
|
||||
elif not valid_email(email):
|
||||
flash("Enter a valid email address.", "error")
|
||||
else:
|
||||
try:
|
||||
get_db().execute(
|
||||
"INSERT INTO users (username, password_hash, created_at, is_approved) VALUES (?, ?, ?, 0)",
|
||||
(username, generate_password_hash(password), datetime.now(timezone.utc).isoformat()),
|
||||
"INSERT INTO users (username, email, password_hash, created_at, is_approved) VALUES (?, ?, ?, ?, 0)",
|
||||
(username, email, generate_password_hash(password), datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
get_db().commit()
|
||||
flash("Registration received. An administrator must approve it before you can sign in.", "success")
|
||||
return redirect(url_for("login"))
|
||||
except sqlite3.IntegrityError:
|
||||
flash("That handle is already in use.", "error")
|
||||
flash("That handle or email address is already in use.", "error")
|
||||
return render_template("auth.html", mode="register")
|
||||
|
||||
|
||||
@@ -206,6 +265,64 @@ def login():
|
||||
return render_template("auth.html", mode="login")
|
||||
|
||||
|
||||
@app.route("/password/forgot", methods=("GET", "POST"))
|
||||
def forgot_password():
|
||||
if request.method == "POST":
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
user = get_db().execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
|
||||
if user is not None:
|
||||
try:
|
||||
send_reset_email(user)
|
||||
except (RuntimeError, OSError, smtplib.SMTPException):
|
||||
app.logger.exception("Unable to send password reset email")
|
||||
flash("If that address belongs to an account, a reset link has been sent.", "success")
|
||||
return redirect(url_for("login"))
|
||||
return render_template("password_form.html", mode="forgot")
|
||||
|
||||
|
||||
@app.route("/password/reset/<token>", methods=("GET", "POST"))
|
||||
def reset_password(token):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
reset = get_db().execute(
|
||||
"""SELECT * FROM password_reset_tokens WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?""",
|
||||
(sha256(token.encode("utf-8")).hexdigest(), now),
|
||||
).fetchone()
|
||||
if reset is None:
|
||||
flash("That password reset link is invalid or has expired.", "error")
|
||||
return redirect(url_for("forgot_password"))
|
||||
if request.method == "POST":
|
||||
password = request.form.get("password", "")
|
||||
if len(password) < 10:
|
||||
flash("Choose a password with at least 10 characters.", "error")
|
||||
else:
|
||||
db = get_db()
|
||||
db.execute("UPDATE users SET password_hash = ? WHERE id = ?", (generate_password_hash(password), reset["user_id"]))
|
||||
db.execute("UPDATE password_reset_tokens SET used_at = ? WHERE id = ?", (now, reset["id"]))
|
||||
db.commit()
|
||||
flash("Password reset. Sign in with your new password.", "success")
|
||||
return redirect(url_for("login"))
|
||||
return render_template("password_form.html", mode="reset")
|
||||
|
||||
|
||||
@app.route("/account/password", methods=("GET", "POST"))
|
||||
@login_required
|
||||
def change_password():
|
||||
if request.method == "POST":
|
||||
user = get_db().execute("SELECT password_hash FROM users WHERE id = ?", (session["user_id"],)).fetchone()
|
||||
current_password = request.form.get("current_password", "")
|
||||
new_password = request.form.get("password", "")
|
||||
if not check_password_hash(user["password_hash"], current_password):
|
||||
flash("Your current password is incorrect.", "error")
|
||||
elif len(new_password) < 10:
|
||||
flash("Choose a new password with at least 10 characters.", "error")
|
||||
else:
|
||||
get_db().execute("UPDATE users SET password_hash = ? WHERE id = ?", (generate_password_hash(new_password), session["user_id"]))
|
||||
get_db().commit()
|
||||
flash("Password updated.", "success")
|
||||
return redirect(url_for("index"))
|
||||
return render_template("password_form.html", mode="change")
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
@@ -268,7 +385,7 @@ def mfa_verify():
|
||||
@admin_required
|
||||
def admin_users():
|
||||
users = get_db().execute(
|
||||
"SELECT id, username, created_at, is_approved, mfa_enabled, role FROM users ORDER BY is_approved, created_at DESC"
|
||||
"SELECT id, username, email, created_at, is_approved, mfa_enabled, role FROM users ORDER BY is_approved, created_at DESC"
|
||||
).fetchall()
|
||||
return render_template("admin_users.html", users=users)
|
||||
|
||||
@@ -282,6 +399,22 @@ def approve_user(user_id):
|
||||
return redirect(url_for("admin_users"))
|
||||
|
||||
|
||||
@app.post("/admin/users/<int:user_id>/password-reset")
|
||||
@admin_required
|
||||
def admin_password_reset(user_id):
|
||||
user = get_db().execute("SELECT * FROM users WHERE id = ? AND role = 'member'", (user_id,)).fetchone()
|
||||
if user is None or not user["email"]:
|
||||
flash("That member does not have an email address for password recovery.", "error")
|
||||
else:
|
||||
try:
|
||||
send_reset_email(user)
|
||||
flash(f"Password reset email sent to {user['email']}.", "success")
|
||||
except (RuntimeError, OSError, smtplib.SMTPException):
|
||||
app.logger.exception("Unable to send administrator password reset email")
|
||||
flash("Password reset email could not be sent. Check SMTP settings.", "error")
|
||||
return redirect(url_for("admin_users"))
|
||||
|
||||
|
||||
@app.route("/write", methods=("GET", "POST"))
|
||||
@login_required
|
||||
def write():
|
||||
|
||||
@@ -7,6 +7,12 @@ services:
|
||||
environment:
|
||||
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before deployment}
|
||||
DATABASE_PATH: /data/eternity.db
|
||||
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:8000}
|
||||
MAIL_HOST: ${MAIL_HOST:-}
|
||||
MAIL_PORT: ${MAIL_PORT:-587}
|
||||
MAIL_USERNAME: ${MAIL_USERNAME:-}
|
||||
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
|
||||
MAIL_FROM: ${MAIL_FROM:-noreply@eternityproject.fi}
|
||||
volumes:
|
||||
- eternity_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -14,5 +14,5 @@ nav { display:flex; align-items:center; gap:22px; font-family:"DM Mono", monospa
|
||||
.auth-layout { min-height:calc(100vh - 160px); padding:70px max(10vw, 24px); display:grid; grid-template-columns:1fr minmax(280px, 390px); gap:10vw; align-items:center; }.auth-copy h1 { font-size:clamp(45px, 6vw, 82px); line-height:.9; margin:15px 0; }.auth-copy p { max-width:380px; font-size:18px; line-height:1.5; color:var(--muted); }.auth-form, .editor-form { display:grid; gap:19px; }.auth-form { padding:27px; border:1px solid var(--ink); background:rgba(241,241,233,.8); }.auth-form label, .editor-form label { display:grid; gap:7px; font:11px "DM Mono", monospace; }input, textarea { width:100%; resize:vertical; background:#fbfbf6; border:1px solid var(--ink); border-radius:0; padding:12px; color:var(--ink); font:15px "Space Grotesk", sans-serif; }input:focus, textarea:focus { outline:3px solid var(--acid); outline-offset:1px; }.form-switch { font-size:13px; color:var(--muted); }.form-switch a { color:var(--ink); text-decoration:underline; }.editor-form { max-width:900px; }.two-col { display:grid; grid-template-columns:1fr 2fr; gap:19px; }
|
||||
.article { max-width:850px; margin:0 auto; padding:80px 24px 100px; }.article h1 { font-size:clamp(48px, 7vw, 90px); line-height:.93; margin:23px 0; }.article-lead { border-left:4px solid var(--orange); padding-left:18px; max-width:720px; font-size:23px; line-height:1.35; }.byline { padding:20px 0; border-top:1px solid var(--ink); border-bottom:1px solid var(--ink); margin:35px 0; }.article-body { max-width:680px; white-space:pre-wrap; font-size:18px; line-height:1.7; margin-bottom:35px; }
|
||||
.flash { margin:16px max(5vw, 24px) 0; padding:11px 14px; font:12px "DM Mono", monospace; border:1px solid var(--ink); }.flash.error { border-color:var(--orange); background:#ffe3d9; }.flash.success { background:var(--acid); }footer { border-top:1px solid var(--ink); padding:23px max(5vw, 24px); display:flex; justify-content:space-between; font:10px "DM Mono", monospace; color:var(--muted); }
|
||||
.mfa-qr { display:block; width:200px; max-width:100%; margin-top:28px; border:8px solid #fff; image-rendering:pixelated; }.mfa-manual { margin-top:18px; color:var(--muted); font:12px "DM Mono", monospace; }.mfa-manual summary { cursor:pointer; }.mfa-secret { display:block; width:max-content; max-width:100%; overflow-wrap:anywhere; margin-top:10px; padding:14px; border:1px solid var(--ink); background:var(--acid); color:var(--ink); font:14px "DM Mono", monospace; }.account-table { border:1px solid var(--ink); }.account-row { display:grid; grid-template-columns:2fr 1.5fr 1fr 1fr; gap:16px; align-items:center; min-height:64px; padding:12px 16px; border-bottom:1px solid var(--ink); font:13px "DM Mono", monospace; }.account-row:last-child { border-bottom:0; }.account-head { min-height:auto; padding:10px 16px; background:var(--ink); color:var(--paper); font-size:10px; }.account-row small { color:var(--orange); font:10px "DM Mono", monospace; }
|
||||
.mfa-qr { display:block; width:200px; max-width:100%; margin-top:28px; border:8px solid #fff; image-rendering:pixelated; }.mfa-manual { margin-top:18px; color:var(--muted); font:12px "DM Mono", monospace; }.mfa-manual summary { cursor:pointer; }.mfa-secret { display:block; width:max-content; max-width:100%; overflow-wrap:anywhere; margin-top:10px; padding:14px; border:1px solid var(--ink); background:var(--acid); color:var(--ink); font:14px "DM Mono", monospace; }.account-table { border:1px solid var(--ink); }.account-row { display:grid; grid-template-columns:2fr 1.5fr 1fr 1fr; gap:16px; align-items:center; min-height:64px; padding:12px 16px; border-bottom:1px solid var(--ink); font:13px "DM Mono", monospace; }.account-row:last-child { border-bottom:0; }.account-head { min-height:auto; padding:10px 16px; background:var(--ink); color:var(--paper); font-size:10px; }.account-row small { color:var(--orange); font:10px "DM Mono", monospace; }.account-row .email { display:block; margin-top:5px; color:var(--muted); overflow-wrap:anywhere; }
|
||||
@media (max-width:720px) { .site-header { align-items:flex-start; }.site-header nav { justify-content:flex-end; flex-wrap:wrap; gap:10px 15px; }.hero { min-height:560px; }.scope { width:220px; height:220px; right:-45px; }.post-grid, .auth-layout, .two-col { grid-template-columns:1fr; }.auth-layout { gap:40px; padding:60px 24px; }footer { flex-wrap:wrap; gap:8px 16px; }.user-chip { display:none; }.account-row { grid-template-columns:1fr 1fr; }.account-head { display:none; } }
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<section class="editor-wrap">
|
||||
<div class="section-head"><span>ACCOUNT QUEUE</span><span>{{ users|length }} REGISTERED</span></div>
|
||||
<div class="account-table" role="table">
|
||||
<div class="account-row account-head" role="row"><span>HANDLE</span><span>REGISTERED</span><span>MFA</span><span>STATUS</span></div>
|
||||
<div class="account-row account-head" role="row"><span>HANDLE / EMAIL</span><span>REGISTERED</span><span>MFA</span><span>STATUS</span></div>
|
||||
{% for user in users %}
|
||||
<div class="account-row" role="row">
|
||||
<strong>{{ user.username }}{% if user.role == 'admin' %} <small>ADMIN</small>{% endif %}</strong>
|
||||
<strong>{{ user.username }}{% if user.role == 'admin' %} <small>ADMIN</small>{% endif %}<small class="email">{{ user.email or 'NO EMAIL' }}</small></strong>
|
||||
<span>{{ user.created_at[:10] }}</span>
|
||||
<span>{{ 'ENABLED' if user.mfa_enabled else 'PENDING' }}</span>
|
||||
<span>{% if user.is_approved %}APPROVED{% elif user.role == 'member' %}<form method="post" action="{{ url_for('approve_user', user_id=user.id) }}"><button class="button compact" type="submit">Approve</button></form>{% else %}ADMIN{% endif %}</span>
|
||||
<span>{% if user.is_approved and user.role == 'member' %}<form method="post" action="{{ url_for('admin_password_reset', user_id=user.id) }}"><button class="button compact" type="submit">Reset password</button></form>{% elif user.is_approved %}APPROVED{% else %}<form method="post" action="{{ url_for('approve_user', user_id=user.id) }}"><button class="button compact" type="submit">Approve</button></form>{% endif %}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -5,9 +5,10 @@
|
||||
<div class="auth-copy"><span class="signal-label">MEMBER ACCESS</span><h1>{{ 'Join the circuit.' if mode == 'register' else 'Resume the signal.' }}</h1><p>Accounts let you publish and maintain your own engineering notes.</p></div>
|
||||
<form class="auth-form" method="post">
|
||||
<label>Handle<input name="username" autocomplete="username" required pattern="[a-z0-9_-]{3,32}" placeholder="your-handle"></label>
|
||||
{% if mode == 'register' %}<label>Email<input name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>{% endif %}
|
||||
<label>Password<input name="password" type="password" autocomplete="{{ 'new-password' if mode == 'register' else 'current-password' }}" required {% if mode == 'register' %}minlength="10" placeholder="10+ characters"{% else %}placeholder="Your password"{% endif %}></label>
|
||||
<button class="button" type="submit">{{ 'Create account' if mode == 'register' else 'Sign in' }} <span aria-hidden="true">→</span></button>
|
||||
<p class="form-switch">{% if mode == 'register' %}Already publishing? <a href="{{ url_for('login') }}">Sign in</a>{% else %}New to the project? <a href="{{ url_for('register') }}">Create an account</a>{% endif %}</p>
|
||||
<p class="form-switch">{% if mode == 'register' %}Already publishing? <a href="{{ url_for('login') }}">Sign in</a>{% else %}New to the project? <a href="{{ url_for('register') }}">Create an account</a><br><a href="{{ url_for('forgot_password') }}">Forgot your password?</a>{% endif %}</p>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
{% if current_user %}
|
||||
<a href="{{ url_for('write') }}">Write</a>
|
||||
{% if current_user.role == 'admin' %}<a href="{{ url_for('admin_users') }}">Accounts</a>{% endif %}
|
||||
<a href="{{ url_for('change_password') }}">Password</a>
|
||||
<span class="user-chip">{{ current_user.username }}</span>
|
||||
<form action="{{ url_for('logout') }}" method="post"><button class="text-button" type="submit">Log out</button></form>
|
||||
{% else %}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Password | Eternity Project{% endblock %}
|
||||
{% block content %}
|
||||
<section class="auth-layout">
|
||||
<div class="auth-copy"><span class="signal-label">ACCOUNT SECURITY</span><h1>{% if mode == 'forgot' %}Recover the<br>signal.{% elif mode == 'reset' %}Set a new<br>password.{% else %}Update your<br>password.{% endif %}</h1><p>{% if mode == 'forgot' %}Enter the email address connected to your account. A secure reset link will arrive by email.{% else %}Choose a password with at least 10 characters.{% endif %}</p></div>
|
||||
<form class="auth-form" method="post">
|
||||
{% if mode == 'forgot' %}<label>Email<input name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>{% endif %}
|
||||
{% if mode == 'change' %}<label>Current password<input name="current_password" type="password" autocomplete="current-password" required></label>{% endif %}
|
||||
{% if mode != 'forgot' %}<label>New password<input name="password" type="password" autocomplete="new-password" required minlength="10" placeholder="10+ characters"></label>{% endif %}
|
||||
<button class="button" type="submit">{% if mode == 'forgot' %}Send reset link{% else %}Save password{% endif %} <span aria-hidden="true">→</span></button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user