Jun 28 - Update tenant self-service signup, trial period enforcement

This commit is contained in:
2026-06-28 18:51:04 -04:00
parent e6b5be8c4d
commit 54fa5b8c87
7 changed files with 415 additions and 30 deletions
+129
View File
@@ -0,0 +1,129 @@
"""
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: every signup starts a 14-day free trial (subscription_status =
'trial', trial_ends_at = now + 14 days). After the trial the billing gate
redirects to /billing/subscribe.
"""
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__)
_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
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=14,
)
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'])
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)