Jun 28 - Update tenant self-service signup, trial period enforcement
This commit is contained in:
+20
-11
@@ -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).
|
||||
|
||||
@@ -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 ›</a>
|
||||
</div>
|
||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user