Jun 28 - Update tenant self-service signup, trial period enforcement
This commit is contained in:
@@ -166,15 +166,21 @@ def create_app(config_name='default'):
|
|||||||
"""MT-8: push billing state into every template."""
|
"""MT-8: push billing state into every template."""
|
||||||
try:
|
try:
|
||||||
from flask import g
|
from flask import g
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
billing_warning = getattr(g, 'billing_warning', None)
|
billing_warning = getattr(g, 'billing_warning', None)
|
||||||
tenant = getattr(g, 'tenant', None)
|
tenant = getattr(g, 'tenant', None)
|
||||||
subscription_status = tenant.subscription_status if tenant else 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 {
|
return {
|
||||||
'billing_enabled': app.config.get('BILLING_ENABLED', False),
|
'billing_enabled': app.config.get('BILLING_ENABLED', False),
|
||||||
'billing_warning': billing_warning,
|
'billing_warning': billing_warning,
|
||||||
'subscription_status': subscription_status,
|
'subscription_status': subscription_status,
|
||||||
'trial_ends_at': trial_ends_at,
|
'trial_ends_at': trial_ends_at,
|
||||||
|
'trial_days_remaining': trial_days_remaining,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -183,6 +189,7 @@ def create_app(config_name='default'):
|
|||||||
'billing_warning': None,
|
'billing_warning': None,
|
||||||
'subscription_status': None,
|
'subscription_status': None,
|
||||||
'trial_ends_at': None,
|
'trial_ends_at': None,
|
||||||
|
'trial_days_remaining': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
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 broadcast # Admin broadcast messages
|
||||||
from app.routes import devices # Admin device management
|
from app.routes import devices # Admin device management
|
||||||
from app.routes import tenant_settings # MT-7 — tenant self-service
|
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
|
from app.billing import bp as billing_bp # MT-8 — Stripe billing
|
||||||
|
|
||||||
app.register_blueprint(auth.bp)
|
app.register_blueprint(auth.bp)
|
||||||
@@ -216,6 +224,7 @@ def create_app(config_name='default'):
|
|||||||
app.register_blueprint(broadcast.bp)
|
app.register_blueprint(broadcast.bp)
|
||||||
app.register_blueprint(devices.bp)
|
app.register_blueprint(devices.bp)
|
||||||
app.register_blueprint(tenant_settings.bp)
|
app.register_blueprint(tenant_settings.bp)
|
||||||
|
app.register_blueprint(signup.bp)
|
||||||
# Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from
|
# Billing blueprint is CSRF-exempt: /billing/webhook receives raw POST from
|
||||||
# Stripe and cannot carry a CSRF token. Subscribe/portal are GET redirects
|
# 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).
|
# 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>
|
</div>
|
||||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</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 %}
|
{% 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):
|
def _is_exempt(path):
|
||||||
if path.startswith('/static/'):
|
if path.startswith('/static/'):
|
||||||
return True
|
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', []):
|
for prefix in current_app.config.get('MULTI_TENANT_EXEMPT_PATHS', []):
|
||||||
if prefix and path.startswith(prefix):
|
if prefix and path.startswith(prefix):
|
||||||
return True
|
return True
|
||||||
@@ -147,16 +149,30 @@ def init_tenancy(app):
|
|||||||
return
|
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'):
|
if status is None or status == 'active':
|
||||||
# Fully authorised — no action needed.
|
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
|
return
|
||||||
|
|
||||||
if status == 'past_due':
|
if status == 'past_due':
|
||||||
# Allow access but signal the template to show the payment warning banner.
|
|
||||||
g.billing_warning = 'past_due'
|
g.billing_warning = 'past_due'
|
||||||
return
|
return
|
||||||
|
|
||||||
# status == 'cancelled' (or any unrecognised future value)
|
# status == 'cancelled' — block and redirect to subscription page.
|
||||||
# Block access and redirect to the subscription management page.
|
|
||||||
return redirect(url_for('billing.suspended'))
|
return redirect(url_for('billing.suspended'))
|
||||||
|
|||||||
+33
-12
@@ -156,18 +156,30 @@ def drop_mysql_db_and_user(db_name, db_user, user_host='%'):
|
|||||||
|
|
||||||
# ── admin seeding (writes into the tenant DB) ────────────────────────────────
|
# ── admin seeding (writes into the tenant DB) ────────────────────────────────
|
||||||
|
|
||||||
def seed_admin(db_uri, email, username=None, full_name=None, expires_hours=72):
|
def seed_admin(db_uri, email, username=None, full_name=None, expires_hours=72,
|
||||||
"""Insert the first admin into a tenant DB with a set-password token.
|
password_hash=None):
|
||||||
|
"""Insert the first admin into a tenant DB.
|
||||||
|
|
||||||
Returns (username, token). The account is created with password_set=0 and an
|
When `password_hash` is provided the account is created ready-to-use
|
||||||
unusable placeholder hash; the admin completes setup via the returned link.
|
(password_set=1, no token). Otherwise a set-password token is generated and
|
||||||
|
returned so the admin completes setup via a one-time link.
|
||||||
|
|
||||||
|
Returns (username, token). token is None when password_hash is supplied.
|
||||||
"""
|
"""
|
||||||
username = username or re.sub(r'[^a-zA-Z0-9_.-]', '', email.split('@')[0]) or 'admin'
|
username = username or re.sub(r'[^a-zA-Z0-9_.-]', '', email.split('@')[0]) or 'admin'
|
||||||
full_name = full_name or username
|
full_name = full_name or username
|
||||||
token = secrets.token_hex(32)
|
|
||||||
placeholder = generate_password_hash(secrets.token_urlsafe(32))
|
|
||||||
now = now_eastern()
|
now = now_eastern()
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
if password_hash:
|
||||||
|
ph = password_hash
|
||||||
|
token = None
|
||||||
|
password_set = 1
|
||||||
|
expires = None
|
||||||
|
else:
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
ph = generate_password_hash(secrets.token_urlsafe(32))
|
||||||
|
password_set = 0
|
||||||
expires = now + timedelta(hours=expires_hours)
|
expires = now + timedelta(hours=expires_hours)
|
||||||
|
|
||||||
eng = create_engine(db_uri, future=True)
|
eng = create_engine(db_uri, future=True)
|
||||||
@@ -180,9 +192,9 @@ def seed_admin(db_uri, email, username=None, full_name=None, expires_hours=72):
|
|||||||
set_password_token, set_password_token_expires)
|
set_password_token, set_password_token_expires)
|
||||||
VALUES
|
VALUES
|
||||||
(:u, :fn, :em, :ph, 'admin',
|
(:u, :fn, :em, :ph, 'admin',
|
||||||
:ca, 1, 0, :tok, :exp)
|
:ca, 1, :ps, :tok, :exp)
|
||||||
"""), {'u': username, 'fn': full_name, 'em': email, 'ph': placeholder,
|
"""), {'u': username, 'fn': full_name, 'em': email, 'ph': ph,
|
||||||
'ca': now, 'tok': token, 'exp': expires})
|
'ca': now, 'ps': password_set, 'tok': token, 'exp': expires})
|
||||||
finally:
|
finally:
|
||||||
eng.dispose()
|
eng.dispose()
|
||||||
return username, token
|
return username, token
|
||||||
@@ -219,7 +231,7 @@ def _add_domains(session, tenant_id, slug, base_domain, custom_domain=None):
|
|||||||
|
|
||||||
def create_tenant(slug, name, plan_code, admin_email, admin_username=None,
|
def create_tenant(slug, name, plan_code, admin_email, admin_username=None,
|
||||||
admin_full_name=None, custom_domain=None, base_domain=None,
|
admin_full_name=None, custom_domain=None, base_domain=None,
|
||||||
db_host=None, user_host='%'):
|
db_host=None, user_host='%', admin_password=None, trial_days=14):
|
||||||
if not _SLUG_RE.match(slug or ''):
|
if not _SLUG_RE.match(slug or ''):
|
||||||
raise ValueError(f"Invalid slug '{slug}' (must be a DNS label).")
|
raise ValueError(f"Invalid slug '{slug}' (must be a DNS label).")
|
||||||
base = _base_domain(base_domain)
|
base = _base_domain(base_domain)
|
||||||
@@ -238,6 +250,7 @@ def create_tenant(slug, name, plan_code, admin_email, admin_username=None,
|
|||||||
|
|
||||||
job_id = None
|
job_id = None
|
||||||
tenant_id = None
|
tenant_id = None
|
||||||
|
trial_ends_at = None
|
||||||
db_created = False
|
db_created = False
|
||||||
try:
|
try:
|
||||||
with control_session() as s:
|
with control_session() as s:
|
||||||
@@ -249,13 +262,18 @@ def create_tenant(slug, name, plan_code, admin_email, admin_username=None,
|
|||||||
db_created = True
|
db_created = True
|
||||||
|
|
||||||
with control_session() as s:
|
with control_session() as s:
|
||||||
|
from datetime import timedelta
|
||||||
t = Tenant(slug=slug, name=name, plan_id=plan_id, status='provisioning',
|
t = Tenant(slug=slug, name=name, plan_id=plan_id, status='provisioning',
|
||||||
db_host=host, db_port=3306, db_name=dbname, db_user=dbuser,
|
db_host=host, db_port=3306, db_name=dbname, db_user=dbuser,
|
||||||
created_at=now_eastern())
|
created_at=now_eastern())
|
||||||
|
if trial_days:
|
||||||
|
t.subscription_status = 'trial'
|
||||||
|
t.trial_ends_at = now_eastern() + timedelta(days=trial_days)
|
||||||
t.set_db_password(password)
|
t.set_db_password(password)
|
||||||
s.add(t); s.flush()
|
s.add(t); s.flush()
|
||||||
tenant_id = t.id
|
tenant_id = t.id
|
||||||
db_uri = t.db_uri
|
db_uri = t.db_uri
|
||||||
|
trial_ends_at = t.trial_ends_at
|
||||||
j = s.get(ProvisioningJob, job_id)
|
j = s.get(ProvisioningJob, job_id)
|
||||||
if j:
|
if j:
|
||||||
j.tenant_id = tenant_id
|
j.tenant_id = tenant_id
|
||||||
@@ -267,7 +285,9 @@ def create_tenant(slug, name, plan_code, admin_email, admin_username=None,
|
|||||||
bootstrap_tenant(ref)
|
bootstrap_tenant(ref)
|
||||||
|
|
||||||
# first admin + setup link
|
# first admin + setup link
|
||||||
username, token = seed_admin(db_uri, admin_email, admin_username, admin_full_name)
|
pw_hash = generate_password_hash(admin_password) if admin_password else None
|
||||||
|
username, token = seed_admin(db_uri, admin_email, admin_username, admin_full_name,
|
||||||
|
password_hash=pw_hash)
|
||||||
|
|
||||||
with control_session() as s:
|
with control_session() as s:
|
||||||
primary = _add_domains(s, tenant_id, slug, base, custom_domain)
|
primary = _add_domains(s, tenant_id, slug, base, custom_domain)
|
||||||
@@ -282,7 +302,8 @@ def create_tenant(slug, name, plan_code, admin_email, admin_username=None,
|
|||||||
'tenant_id': tenant_id, 'slug': slug, 'db_name': dbname,
|
'tenant_id': tenant_id, 'slug': slug, 'db_name': dbname,
|
||||||
'db_user': dbuser, 'primary_domain': primary,
|
'db_user': dbuser, 'primary_domain': primary,
|
||||||
'custom_domain': custom_domain, 'admin_username': username,
|
'custom_domain': custom_domain, 'admin_username': username,
|
||||||
'setup_link': setup_link(primary, token),
|
'setup_link': setup_link(primary, token) if token else None,
|
||||||
|
'trial_ends_at': trial_ends_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user