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
+20 -11
View File
@@ -166,23 +166,30 @@ def create_app(config_name='default'):
"""MT-8: push billing state into every template."""
try:
from flask import g
billing_warning = getattr(g, 'billing_warning', None)
tenant = getattr(g, 'tenant', None)
from app.utils.time_utils import now_eastern
billing_warning = getattr(g, 'billing_warning', None)
tenant = getattr(g, 'tenant', None)
subscription_status = tenant.subscription_status if tenant else None
trial_ends_at = tenant.trial_ends_at if tenant else None
trial_ends_at = tenant.trial_ends_at if tenant else None
trial_days_remaining = None
if trial_ends_at is not None:
delta = trial_ends_at - now_eastern()
trial_days_remaining = max(0, delta.days)
return {
'billing_enabled': app.config.get('BILLING_ENABLED', False),
'billing_warning': billing_warning,
'subscription_status': subscription_status,
'trial_ends_at': trial_ends_at,
'billing_enabled': app.config.get('BILLING_ENABLED', False),
'billing_warning': billing_warning,
'subscription_status': subscription_status,
'trial_ends_at': trial_ends_at,
'trial_days_remaining': trial_days_remaining,
}
except Exception:
pass
return {
'billing_enabled': False,
'billing_warning': None,
'subscription_status': None,
'trial_ends_at': None,
'billing_enabled': False,
'billing_warning': None,
'subscription_status': None,
'trial_ends_at': None,
'trial_days_remaining': None,
}
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
@@ -198,6 +205,7 @@ def create_app(config_name='default'):
from app.routes import broadcast # Admin broadcast messages
from app.routes import devices # Admin device management
from app.routes import tenant_settings # MT-7 — tenant self-service
from app.routes import signup # MT-8+ — public self-service signup
from app.billing import bp as billing_bp # MT-8 — Stripe billing
app.register_blueprint(auth.bp)
@@ -216,6 +224,7 @@ def create_app(config_name='default'):
app.register_blueprint(broadcast.bp)
app.register_blueprint(devices.bp)
app.register_blueprint(tenant_settings.bp)
app.register_blueprint(signup.bp)
# Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from
# Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects
# which Flask-WTF does not protect anyway (CSRF only applies to unsafe methods).
+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)
@@ -9,4 +9,19 @@
</div>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif billing_warning == 'trial_ending' %}
<div class="alert alert-info alert-dismissible fade show d-flex align-items-center gap-2 mb-3" role="alert">
<i class="bi bi-hourglass-split flex-shrink-0 fs-5"></i>
<div>
<strong>Trial ending soon.</strong>
{% if trial_days_remaining is not none and trial_days_remaining <= 1 %}
Your free trial expires <strong>today</strong>.
{% else %}
Your free trial expires in <strong>{{ trial_days_remaining }} day{{ 's' if trial_days_remaining != 1 }}</strong>.
{% endif %}
Subscribe now to keep your workspace active.
<a href="{{ url_for('billing.subscribe') }}" class="alert-link ms-1">Subscribe &rsaquo;</a>
</div>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
+158
View File
@@ -0,0 +1,158 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Create your workspace — JQC</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body { background: #f6f7f9; }
.signup-card { max-width: 520px; margin: 2.5rem auto; }
.brand { font-weight: 700; color: #1a56db; font-size: 1.1rem; }
.subdomain-preview { font-size: .8rem; color: #6b7280; margin-top: .25rem; }
</style>
</head>
<body>
<div class="signup-card px-3">
<div class="text-center mb-4 mt-4">
<div class="brand mb-1"><i class="bi bi-check2-circle me-1"></i>Janitorial QC</div>
<h1 class="h4 mb-0">Create your workspace</h1>
<p class="text-muted" style="font-size:.9rem;">14-day free trial · No credit card required</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ 'danger' if cat == 'danger' else cat }} alert-dismissible fade show" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form method="post" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:.875rem;">Company Name</label>
<input type="text" name="company_name" id="company_name"
class="form-control {{ 'is-invalid' if form.company_name.errors }}"
value="{{ form.company_name.data or '' }}"
placeholder="Acme Cleaning Co." autofocus>
{% for e in form.company_name.errors %}
<div class="invalid-feedback">{{ e }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:.875rem;">Your Full Name</label>
<input type="text" name="full_name"
class="form-control {{ 'is-invalid' if form.full_name.errors }}"
value="{{ form.full_name.data or '' }}"
placeholder="Jane Smith">
{% for e in form.full_name.errors %}
<div class="invalid-feedback">{{ e }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:.875rem;">Work Email</label>
<input type="email" name="email"
class="form-control {{ 'is-invalid' if form.email.errors }}"
value="{{ form.email.data or '' }}"
placeholder="you@company.com">
{% for e in form.email.errors %}
<div class="invalid-feedback">{{ e }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:.875rem;">Subdomain</label>
<div class="input-group">
<input type="text" name="subdomain" id="subdomain"
class="form-control {{ 'is-invalid' if form.subdomain.errors }}"
value="{{ form.subdomain.data or '' }}"
placeholder="acme"
pattern="[a-z0-9][a-z0-9\-]{0,30}[a-z0-9]"
style="text-transform:lowercase;">
<span class="input-group-text text-muted" style="font-size:.85rem;">.{{ base_domain }}</span>
{% for e in form.subdomain.errors %}
<div class="invalid-feedback">{{ e }}</div>
{% endfor %}
</div>
<div class="subdomain-preview" id="subdomain_preview"></div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:.875rem;">Plan</label>
<select name="plan" class="form-select {{ 'is-invalid' if form.plan.errors }}">
{% for value, label in form.plan.choices %}
<option value="{{ value }}" {{ 'selected' if form.plan.data == value }}>{{ label }}</option>
{% endfor %}
</select>
{% for e in form.plan.errors %}
<div class="invalid-feedback">{{ e }}</div>
{% endfor %}
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:.875rem;">Password</label>
<input type="password" name="password"
class="form-control {{ 'is-invalid' if form.password.errors }}"
placeholder="Minimum 8 characters">
{% for e in form.password.errors %}
<div class="invalid-feedback">{{ e }}</div>
{% endfor %}
</div>
<div class="mb-4">
<label class="form-label fw-semibold" style="font-size:.875rem;">Confirm Password</label>
<input type="password" name="confirm"
class="form-control {{ 'is-invalid' if form.confirm.errors }}">
{% for e in form.confirm.errors %}
<div class="invalid-feedback">{{ e }}</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-rocket-takeoff me-1"></i> Create Workspace
</button>
</form>
</div>
</div>
<p class="text-center text-muted mt-3" style="font-size:.82rem;">
Already have a workspace?
<a href="https://{{ base_domain }}">Sign in</a>
</p>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Auto-suggest subdomain from company name
var BASE_DOMAIN = {{ base_domain | tojson }};
document.getElementById('company_name').addEventListener('input', function () {
var sub = document.getElementById('subdomain');
if (sub.dataset.userEdited) return;
var slug = this.value.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32);
sub.value = slug;
updatePreview(slug);
});
document.getElementById('subdomain').addEventListener('input', function () {
this.dataset.userEdited = '1';
this.value = this.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
updatePreview(this.value);
});
function updatePreview(slug) {
var el = document.getElementById('subdomain_preview');
el.textContent = slug ? 'Your workspace: ' + slug + '.' + BASE_DOMAIN : '';
}
updatePreview(document.getElementById('subdomain').value);
</script>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Workspace ready — JQC</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>body { background: #f6f7f9; }</style>
</head>
<body>
<div class="container" style="max-width:500px; margin-top:4rem;">
<div class="card border-0 shadow-sm text-center p-5">
<div class="mb-3">
<i class="bi bi-check-circle-fill text-success" style="font-size:3rem;"></i>
</div>
<h2 class="h4 mb-2">Your workspace is ready!</h2>
<p class="text-muted mb-1" style="font-size:.9rem;">
<strong>{{ slug }}.{{ base_domain }}</strong> has been provisioned.
</p>
{% if trial_ends_at %}
<p class="text-muted mb-4" style="font-size:.85rem;">
Your 14-day free trial runs until
<strong>{{ trial_ends_at.strftime('%B %d, %Y') }}</strong>.
</p>
{% endif %}
<a href="{{ login_url }}" class="btn btn-primary btn-lg">
<i class="bi bi-box-arrow-in-right me-1"></i> Go to your workspace
</a>
<p class="text-muted mt-3 mb-0" style="font-size:.8rem;">
<i class="bi bi-info-circle me-1"></i>
Bookmark <code>{{ slug }}.{{ base_domain }}</code> — that's your permanent workspace URL.
</p>
</div>
</div>
</body>
</html>
+22 -6
View File
@@ -55,6 +55,8 @@ _UNKNOWN_TENANT_PAGE = (
def _is_exempt(path):
if path.startswith('/static/'):
return True
if path.startswith('/signup'):
return True # public self-service signup has no tenant context
for prefix in current_app.config.get('MULTI_TENANT_EXEMPT_PATHS', []):
if prefix and path.startswith(prefix):
return True
@@ -146,17 +148,31 @@ def init_tenancy(app):
or _is_exempt(request.path)):
return
status = tenant.subscription_status
status = tenant.subscription_status
trial_ends_at = tenant.trial_ends_at
if status is None or status in ('trial', 'active'):
# Fully authorised — no action needed.
if status is None or status == 'active':
return
if status == 'trial':
if trial_ends_at is not None:
from app.utils.time_utils import now_eastern
now = now_eastern()
if now >= trial_ends_at:
# Trial expired — redirect to subscribe; allow /settings/ so
# they can still see their plan page and the subscribe button.
if not (request.path.startswith('/billing/')
or request.path.startswith('/settings/')):
return redirect(url_for('billing.subscribe'))
else:
days_left = (trial_ends_at - now).days
if days_left <= 3:
g.billing_warning = 'trial_ending'
return
if status == 'past_due':
# Allow access but signal the template to show the payment warning banner.
g.billing_warning = 'past_due'
return
# status == 'cancelled' (or any unrecognised future value)
# Block access and redirect to the subscription management page.
# status == 'cancelled' — block and redirect to subscription page.
return redirect(url_for('billing.suspended'))