173 lines
7.3 KiB
Python
173 lines
7.3 KiB
Python
"""
|
|
app/routes/signup.py
|
|
--------------------
|
|
Public self-service tenant signup (MT-8+).
|
|
|
|
Accessible at /signup on any domain — the path is hardcoded as exempt in the
|
|
tenant middleware so no tenant context is required. On success, a new tenant
|
|
database is provisioned (schema + first admin) and the user is redirected to
|
|
their subdomain's login page.
|
|
|
|
Trial period: paid-plan signups start a 14-day free trial (subscription_status
|
|
= 'trial', trial_ends_at = now + 14 days); after the trial the billing gate
|
|
redirects to /billing/subscribe. The Free plan is free-forever — it is
|
|
provisioned with trial_days=0 (subscription_status = 'active', no expiry) so
|
|
the billing gate never blocks it. See CLAUDE.md rule 86.
|
|
"""
|
|
|
|
import re
|
|
import logging
|
|
import os
|
|
|
|
from flask import Blueprint, render_template, request, flash, redirect, current_app
|
|
from flask_wtf import FlaskForm
|
|
from wtforms import StringField, PasswordField, SelectField
|
|
from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError
|
|
|
|
bp = Blueprint('signup', __name__, url_prefix='/signup')
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _send_welcome_email(to_email, login_url, tenant_name, plan_code, trial_ends_at):
|
|
"""Fire a welcome email in a background thread — never blocks the request.
|
|
|
|
The wording adapts to the plan: the Free plan has no trial/expiry, paid
|
|
plans start a 14-day trial. `trial_note` carries the plan-appropriate line
|
|
and `trial_ends_at` is blank for Free so the template hides the trial row.
|
|
"""
|
|
try:
|
|
from app.billing.emails import send_billing_email
|
|
is_free = (plan_code == 'free')
|
|
ends_str = (trial_ends_at.strftime('%B %d, %Y')
|
|
if hasattr(trial_ends_at, 'strftime') else str(trial_ends_at or ''))
|
|
if is_free:
|
|
trial_note = "You're on the Free plan — no credit card, no expiry."
|
|
ends_str = ''
|
|
else:
|
|
trial_note = f'Your 14-day free trial runs through {ends_str}.'
|
|
send_billing_email(to_email, 'welcome', {
|
|
'tenant_name': tenant_name,
|
|
'login_url': login_url,
|
|
'trial_days': 0 if is_free else 14,
|
|
'trial_ends_at': ends_str,
|
|
'trial_note': trial_note,
|
|
'plan_name': plan_code.title(),
|
|
})
|
|
except Exception as exc:
|
|
logger.error('SIGNUP | welcome_email_failed | to=%s err=%s', to_email, exc)
|
|
|
|
_SLUG_RE = re.compile(r'^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$')
|
|
|
|
|
|
def _paid_plan_choices():
|
|
"""Return plan choices from the control DB, ordered by price."""
|
|
try:
|
|
from control.base import control_session
|
|
from control.models import Plan
|
|
with control_session() as s:
|
|
plans = (s.query(Plan)
|
|
.order_by(Plan.price_cents.nullslast(), Plan.id)
|
|
.all())
|
|
return [(p.code, f'{p.name} — '
|
|
+ (f'{p.max_users} users, {p.max_facilities} facilities'
|
|
if p.max_users else 'Unlimited'))
|
|
for p in plans]
|
|
except Exception:
|
|
return [
|
|
('free', 'Free — 3 users, 2 facilities'),
|
|
('starter', 'Starter — 15 users, 10 facilities'),
|
|
('pro', 'Pro — 50 users, 50 facilities'),
|
|
('enterprise', 'Enterprise — Unlimited'),
|
|
]
|
|
|
|
|
|
class SignupForm(FlaskForm):
|
|
company_name = StringField('Company Name',
|
|
validators=[DataRequired(), Length(max=150)])
|
|
full_name = StringField('Your Full Name',
|
|
validators=[DataRequired(), Length(max=150)])
|
|
email = StringField('Work Email',
|
|
validators=[DataRequired(), Email(), Length(max=255)])
|
|
subdomain = StringField('Subdomain',
|
|
validators=[DataRequired(), Length(min=2, max=32)])
|
|
plan = SelectField('Plan', choices=[]) # populated in view
|
|
password = PasswordField('Password',
|
|
validators=[DataRequired(), Length(min=8, max=128)])
|
|
confirm = PasswordField('Confirm Password',
|
|
validators=[EqualTo('password', 'Passwords must match.')])
|
|
|
|
def validate_subdomain(self, field):
|
|
slug = field.data.lower().strip()
|
|
field.data = slug
|
|
if not _SLUG_RE.match(slug):
|
|
raise ValidationError(
|
|
'Subdomain may only contain lowercase letters, numbers, and hyphens, '
|
|
'and must start and end with a letter or number.')
|
|
# Check uniqueness against control DB
|
|
try:
|
|
from control.base import control_session
|
|
from control.models import Tenant
|
|
with control_session() as s:
|
|
if s.query(Tenant).filter_by(slug=slug).first():
|
|
raise ValidationError(f'"{slug}" is already taken. Please choose another.')
|
|
except ValidationError:
|
|
raise
|
|
except Exception:
|
|
pass # control DB unreachable — let provisioner surface the error
|
|
|
|
|
|
@bp.route('', methods=['GET', 'POST'])
|
|
def index():
|
|
form = SignupForm()
|
|
form.plan.choices = _paid_plan_choices()
|
|
|
|
if form.validate_on_submit():
|
|
slug = form.subdomain.data
|
|
company = form.company_name.data.strip()
|
|
full_name = form.full_name.data.strip()
|
|
email = form.email.data.strip().lower()
|
|
plan_code = form.plan.data
|
|
password = form.password.data
|
|
|
|
# Free plan is free forever — no trial, no expiry. Paid plans start a
|
|
# 14-day trial. trial_days=0 makes create_tenant provision the tenant as
|
|
# 'active' (see control/provision.py) so the billing gate never blocks it.
|
|
trial_days = 0 if plan_code == 'free' else 14
|
|
|
|
try:
|
|
from control.provision import create_tenant
|
|
info = create_tenant(
|
|
slug=slug,
|
|
name=company,
|
|
plan_code=plan_code,
|
|
admin_email=email,
|
|
admin_full_name=full_name,
|
|
admin_password=password,
|
|
trial_days=trial_days,
|
|
)
|
|
except ValueError as exc:
|
|
flash(str(exc), 'danger')
|
|
except Exception as exc:
|
|
logger.error('SIGNUP | provision_failed | slug=%s err=%s', slug, exc)
|
|
flash('Provisioning failed. Please try again or contact support.', 'danger')
|
|
else:
|
|
base_domain = os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app')
|
|
login_url = f'https://{slug}.{base_domain}/auth/login'
|
|
logger.info('SIGNUP | provisioned | slug=%s plan=%s tenant_id=%s',
|
|
slug, plan_code, info['tenant_id'])
|
|
_send_welcome_email(
|
|
to_email = email,
|
|
login_url = login_url,
|
|
tenant_name = company,
|
|
plan_code = plan_code,
|
|
trial_ends_at = info.get('trial_ends_at'),
|
|
)
|
|
return render_template('signup/success.html',
|
|
login_url=login_url,
|
|
slug=slug,
|
|
base_domain=base_domain,
|
|
trial_ends_at=info.get('trial_ends_at'))
|
|
|
|
base_domain = os.environ.get('TENANT_BASE_DOMAIN', 'jqc.app')
|
|
return render_template('signup/index.html', form=form, base_domain=base_domain)
|