"""Self-service sign-up, and the two waiting rooms it leads to.
The flow, end to end:
POST /api/signup creates the organisation and its owner, both gates shut,
and mails a verification link
GET /verify consumes that link, stamps email_verified_at
GET /awaiting-approval
where a verified account waits for an operator
GET /onboarding the first screen after both gates open
Every response that names an address answers identically whether or not that
address has an account. A sign-up form and a resend form are both places a
stranger can type somebody else's address, and telling them apart is what turns
either into an account enumerator.
"""
import logging
from flask import jsonify, redirect, render_template, request
from flask import session as flask_session
from app.core import auth as auth_core
from app.core import mail as mail_core
from app.core import registration as registration_core
from utils.db_wrapper import db_session
from .crud import USER_KEY, endpoint, public_base_url
logger = logging.getLogger(__name__)
def _verify_url(token: str) -> str:
return f"{public_base_url()}/verify?token={token}"
def _send_verification(user, token: str) -> None:
"""Mail the link, and never let a mail failure change the answer.
Swallowed and logged for the same reason the reset mail is: an error that
surfaced only for real addresses would make a broken SMTP server into an
enumeration oracle. The account still exists and `/api/signup/resend` is the
way back, which is why that endpoint is not optional.
"""
try:
mail_core.send_email_verification(user.email, _verify_url(token))
except Exception:
logger.exception("Could not send a verification mail to %s.", user.email)
# --- pages ------------------------------------------------------------------
def page_signup():
if flask_session.get(USER_KEY):
return redirect("/")
# `google_enabled` is a context-processor function (main.py), not a value
# passed from here - one source, so a template cannot be handed a different
# answer depending on which endpoint rendered it.
return render_template("signup.html")
def page_verify():
"""Consume the emailed link.
A GET that changes state, which is normally a mistake - but this is a link in
an email, and there is no other verb a mail client can produce. What makes it
safe is that the token is single-use, short-lived and unguessable, so the
request is authenticated by the thing it carries rather than by a session.
It renders the outcome rather than redirecting to a flash message, so a
person who reopens the link a week later reads why it no longer works instead
of arriving at a sign-in page with no explanation.
"""
token = request.args.get("token", "")
try:
user = auth_core.verify_email(db_session, token)
except Exception as e:
db_session.rollback()
return render_template("verify.html", ok=False, message=str(e)), 400
# Verified is not admitted. Say which of the two gates just opened and which
# one has not, so the wait that follows is expected rather than a dead end.
return render_template("verify.html", ok=True,
awaiting_approval=auth_core.awaiting_approval(user))
def page_awaiting_approval():
return render_template("awaiting_approval.html")
def page_onboarding():
"""The first screen after both gates open.
Deliberately almost empty in the skeleton. It is here because the flow needs
somewhere to land that is not the sign-in page, and because "where does a new
tenant start" is a question every product answers differently - so this is
the seam, not the answer.
"""
if not flask_session.get(USER_KEY):
return redirect("/login")
return render_template("onboarding.html")
# --- api --------------------------------------------------------------------
@endpoint
def api_signup():
"""Create an organisation and its owner, then mail the verification link.
Answers 200 with `pending: true` on success. The page shows "check your
inbox" - it does not sign anybody in, because neither gate is open yet.
"""
body = request.get_json(silent=True) or {}
user, org = registration_core.register_org_and_owner(
db_session,
body.get("email", ""),
body.get("password", ""),
body.get("org_name") or None,
)
_send_verification(user, auth_core.request_email_verification(db_session, user))
logger.info("Signed up %s (org %r), awaiting verification.", user.email, org.slug)
return jsonify({"pending": True, "email": user.email})
@endpoint
def api_signup_resend():
"""Send a fresh verification link, or pretend to.
Always 200, whatever the address. Without this endpoint the only copy of a
verification token is the one in a mail that may never have arrived, and an
account whose mail bounced is permanently unable to sign in with no way back
that does not involve an operator.
"""
email = (request.get_json(silent=True) or {}).get("email", "")
result = auth_core.resend_email_verification(db_session, email)
if result is not None:
user, token = result
_send_verification(user, token)
return jsonify({"ok": True})