From 54fa5b8c87d2b7e2addfbc5359f0d403ee3871d6 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Sun, 28 Jun 2026 18:51:04 -0400 Subject: [PATCH] Jun 28 - Update tenant self-service signup, trial period enforcement --- app/__init__.py | 31 ++-- app/routes/signup.py | 129 +++++++++++++++++ app/templates/billing/_billing_banner.html | 15 ++ app/templates/signup/index.html | 158 +++++++++++++++++++++ app/templates/signup/success.html | 37 +++++ app/tenancy/middleware.py | 28 +++- control/provision.py | 47 ++++-- 7 files changed, 415 insertions(+), 30 deletions(-) create mode 100644 app/routes/signup.py create mode 100644 app/templates/signup/index.html create mode 100644 app/templates/signup/success.html diff --git a/app/__init__.py b/app/__init__.py index caf41ad..5b8b2d2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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). diff --git a/app/routes/signup.py b/app/routes/signup.py new file mode 100644 index 0000000..1cb2f37 --- /dev/null +++ b/app/routes/signup.py @@ -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) diff --git a/app/templates/billing/_billing_banner.html b/app/templates/billing/_billing_banner.html index c380900..6765652 100644 --- a/app/templates/billing/_billing_banner.html +++ b/app/templates/billing/_billing_banner.html @@ -9,4 +9,19 @@ +{% elif billing_warning == 'trial_ending' %} + {% endif %} diff --git a/app/templates/signup/index.html b/app/templates/signup/index.html new file mode 100644 index 0000000..6365ea5 --- /dev/null +++ b/app/templates/signup/index.html @@ -0,0 +1,158 @@ + + + + + + Create your workspace — JQC + + + + + +
+
+
Janitorial QC
+

Create your workspace

+

14-day free trial · No credit card required

+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for cat, msg in messages %} + + {% endfor %} + {% endwith %} + +
+
+
+ + +
+ + + {% for e in form.company_name.errors %} +
{{ e }}
+ {% endfor %} +
+ +
+ + + {% for e in form.full_name.errors %} +
{{ e }}
+ {% endfor %} +
+ +
+ + + {% for e in form.email.errors %} +
{{ e }}
+ {% endfor %} +
+ +
+ +
+ + .{{ base_domain }} + {% for e in form.subdomain.errors %} +
{{ e }}
+ {% endfor %} +
+
+
+ +
+ + + {% for e in form.plan.errors %} +
{{ e }}
+ {% endfor %} +
+ +
+ + + {% for e in form.password.errors %} +
{{ e }}
+ {% endfor %} +
+ +
+ + + {% for e in form.confirm.errors %} +
{{ e }}
+ {% endfor %} +
+ + +
+
+
+ +

+ Already have a workspace? + Sign in +

+
+ + + + + diff --git a/app/templates/signup/success.html b/app/templates/signup/success.html new file mode 100644 index 0000000..582992b --- /dev/null +++ b/app/templates/signup/success.html @@ -0,0 +1,37 @@ + + + + + + Workspace ready — JQC + + + + + +
+
+
+ +
+

Your workspace is ready!

+

+ {{ slug }}.{{ base_domain }} has been provisioned. +

+ {% if trial_ends_at %} +

+ Your 14-day free trial runs until + {{ trial_ends_at.strftime('%B %d, %Y') }}. +

+ {% endif %} + + Go to your workspace + +

+ + Bookmark {{ slug }}.{{ base_domain }} — that's your permanent workspace URL. +

+
+
+ + diff --git a/app/tenancy/middleware.py b/app/tenancy/middleware.py index 5900925..637368e 100644 --- a/app/tenancy/middleware.py +++ b/app/tenancy/middleware.py @@ -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')) diff --git a/control/provision.py b/control/provision.py index 6383195..7cd7fe7 100644 --- a/control/provision.py +++ b/control/provision.py @@ -156,19 +156,31 @@ def drop_mysql_db_and_user(db_name, db_user, user_host='%'): # ── admin seeding (writes into the tenant DB) ──────────────────────────────── -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. +def seed_admin(db_uri, email, username=None, full_name=None, expires_hours=72, + password_hash=None): + """Insert the first admin into a tenant DB. - Returns (username, token). The account is created with password_set=0 and an - unusable placeholder hash; the admin completes setup via the returned link. + When `password_hash` is provided the account is created ready-to-use + (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' full_name = full_name or username - token = secrets.token_hex(32) - placeholder = generate_password_hash(secrets.token_urlsafe(32)) now = now_eastern() from datetime import timedelta - expires = now + timedelta(hours=expires_hours) + + 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) eng = create_engine(db_uri, future=True) try: @@ -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) VALUES (:u, :fn, :em, :ph, 'admin', - :ca, 1, 0, :tok, :exp) - """), {'u': username, 'fn': full_name, 'em': email, 'ph': placeholder, - 'ca': now, 'tok': token, 'exp': expires}) + :ca, 1, :ps, :tok, :exp) + """), {'u': username, 'fn': full_name, 'em': email, 'ph': ph, + 'ca': now, 'ps': password_set, 'tok': token, 'exp': expires}) finally: eng.dispose() 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, 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 ''): raise ValueError(f"Invalid slug '{slug}' (must be a DNS label).") base = _base_domain(base_domain) @@ -238,6 +250,7 @@ def create_tenant(slug, name, plan_code, admin_email, admin_username=None, job_id = None tenant_id = None + trial_ends_at = None db_created = False try: with control_session() as s: @@ -249,13 +262,18 @@ def create_tenant(slug, name, plan_code, admin_email, admin_username=None, db_created = True with control_session() as s: + from datetime import timedelta t = Tenant(slug=slug, name=name, plan_id=plan_id, status='provisioning', db_host=host, db_port=3306, db_name=dbname, db_user=dbuser, 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) s.add(t); s.flush() tenant_id = t.id db_uri = t.db_uri + trial_ends_at = t.trial_ends_at j = s.get(ProvisioningJob, job_id) if j: j.tenant_id = tenant_id @@ -267,7 +285,9 @@ def create_tenant(slug, name, plan_code, admin_email, admin_username=None, bootstrap_tenant(ref) # 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: 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, 'db_user': dbuser, 'primary_domain': primary, '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: