agilentics / boiler
"""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})