Two coupled changes:
1. Lite tier
- New Tier.LITE in src/license/schema.py.
- FEATURES_BY_TIER[Tier.LITE] = {Deduplicator, Text Cleaner,
Format Standardizer}. The three universally-useful tools that
cover the most common bookkeeping / RevOps / Klaviyo prep
workflows. Other six tools require Core.
- i18n: license.tier_lite, license.feature_locked_title,
license.feature_locked_body, license.upgrade_link,
license.status_locked (en + es).
- Per-tool feature gate at every GUI tool page
(require_feature_or_render_upgrade) and every tool CLI
(guard(feature=...)). A locked tool renders an upgrade
prompt + Manage-license button (GUI) or exits with code 2
(CLI).
- Home grid: tool cards the user's tier doesn't unlock get a
red 🔒 Locked badge in place of green Ready.
2. Trial removed
- Activation form's "Start 1-year trial" button removed.
- license_cli's `trial` subcommand removed.
- activation.trial_button / activation.trial_help i18n keys
dropped (pack parity test stays green).
- Tier.TRIAL stays in the enum (back-compat with any field-
tested trial licenses); LicenseManager._mint stays internal
for tests and the seller's key generator.
- Decision logged in DECISIONS §9b: a 1-year all-features
trial undercuts paid Lite; paid-only keeps tier economics
clean.
Tests (+29 net): +17 Lite-tier unit/guard tests + 13 Lite-tier
GUI tests + 1 trial-absent assertion - 2 trial CLI tests - 1
trial GUI button test. Total: 1995 → 2024.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
238 lines
8.2 KiB
Python
238 lines
8.2 KiB
Python
"""Activation page rendering + license gate inline-renderer.
|
|
|
|
Two public callables:
|
|
|
|
- :func:`render_activation_form` — the activation page body. Used by
|
|
``pages/_Activate.py`` for the explicit nav entry and by
|
|
:func:`require_license_or_render_activation` for the gate-injected
|
|
inline form.
|
|
|
|
- :func:`require_license_or_render_activation` — call after
|
|
``hide_streamlit_chrome`` from every page that needs a valid
|
|
license. When the license is missing, expired, or invalid it
|
|
renders the activation form in place of the page body and calls
|
|
``st.stop()`` so the rest of the page doesn't execute.
|
|
|
|
- :func:`render_license_status_sidebar` — small chrome widget the
|
|
sidebar uses to show "Core · 327 days left" or "🛑 Expired".
|
|
|
|
The component is kept dependency-free of the page modules so any
|
|
page can import these helpers without circular imports through
|
|
``components.__init__``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
import streamlit as st
|
|
|
|
from src.i18n import t as _t
|
|
from src.license import (
|
|
ExpiredLicenseError,
|
|
InvalidLicenseError,
|
|
LicenseError,
|
|
NotActivatedError,
|
|
get_manager,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sidebar status widget
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def render_license_status_sidebar() -> None:
|
|
"""Render the per-page sidebar license badge.
|
|
|
|
Cheap to call — the manager caches the parsed license, so multiple
|
|
pages mounting the same chrome don't pay multiple disk reads.
|
|
"""
|
|
state = get_manager().current_state()
|
|
target = st.sidebar
|
|
if not state.activated:
|
|
target.caption(f"🔒 {_t('license.status_not_activated')}")
|
|
return
|
|
if state.error_kind == "invalid":
|
|
target.caption(f"⚠️ {_t('license.status_invalid')}")
|
|
return
|
|
if not state.valid:
|
|
# Activated but expired.
|
|
target.caption(f"🛑 {_t('license.status_expired')}")
|
|
return
|
|
|
|
tier_label = _t(f"license.tier_{state.tier}") or state.tier.title()
|
|
if state.tier == "trial":
|
|
label = _t("license.status_trial", days=state.days_remaining)
|
|
else:
|
|
label = _t(
|
|
"license.status_active",
|
|
tier=tier_label,
|
|
days=state.days_remaining,
|
|
)
|
|
target.caption(label)
|
|
if 0 < state.days_remaining <= 30:
|
|
target.warning(
|
|
_t("license.renewal_warning_30", days=state.days_remaining)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Activation page body
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def render_activation_form(*, key_prefix: str = "act") -> None:
|
|
"""Render the full activation page body.
|
|
|
|
*key_prefix* is appended to every Streamlit widget key so the
|
|
form can be embedded inline by ``require_license_or_render_activation``
|
|
without colliding with an instance that's mounted by the
|
|
explicit ``_Activate.py`` page.
|
|
"""
|
|
st.title(_t("activation.title"))
|
|
st.caption(_t("activation.intro"))
|
|
|
|
mgr = get_manager()
|
|
state = mgr.current_state()
|
|
|
|
# Surface the current state explicitly so a user with an expired /
|
|
# invalid license sees what's wrong before they paste anything.
|
|
if state.activated and state.error_kind == "expired":
|
|
st.warning(
|
|
_t("license.renewal_warning_expired", date=state.expires_at[:10])
|
|
)
|
|
elif state.activated and state.error_kind == "invalid":
|
|
st.error(f"⚠️ {state.error_message}")
|
|
|
|
st.divider()
|
|
|
|
with st.form(key=f"{key_prefix}_form"):
|
|
name = st.text_input(
|
|
_t("activation.name_label"),
|
|
value=state.name,
|
|
help=_t("activation.name_help"),
|
|
key=f"{key_prefix}_name",
|
|
)
|
|
email = st.text_input(
|
|
_t("activation.email_label"),
|
|
value=state.email,
|
|
help=_t("activation.email_help"),
|
|
key=f"{key_prefix}_email",
|
|
)
|
|
blob = st.text_area(
|
|
_t("activation.blob_label"),
|
|
help=_t("activation.blob_help"),
|
|
key=f"{key_prefix}_blob",
|
|
height=120,
|
|
)
|
|
|
|
is_renewal = state.activated
|
|
label = (
|
|
_t("activation.renew_button") if is_renewal
|
|
else _t("activation.activate_button")
|
|
)
|
|
submit = st.form_submit_button(label, type="primary")
|
|
|
|
# Process the submission.
|
|
if submit:
|
|
if not blob.strip():
|
|
st.error(_t("activation.errors_heading") + ": license blob is empty.")
|
|
return
|
|
try:
|
|
if is_renewal:
|
|
lic = mgr.renew(blob)
|
|
st.success(_t("activation.renewed", expires=lic.expires_at[:10]))
|
|
else:
|
|
lic = mgr.activate_from_blob(blob, name=name, email=email)
|
|
st.success(_t(
|
|
"activation.success",
|
|
name=lic.name, expires=lic.expires_at[:10],
|
|
))
|
|
# Force a clean re-render so the gate sees the new state.
|
|
st.rerun()
|
|
except LicenseError as e:
|
|
st.error(f"{_t('activation.errors_heading')}: {e}")
|
|
|
|
# Deactivate (only useful when already activated).
|
|
if state.activated:
|
|
st.divider()
|
|
with st.expander(_t("activation.deactivate_button")):
|
|
st.caption(_t("activation.deactivate_help"))
|
|
if st.button(
|
|
_t("activation.deactivate_button"),
|
|
key=f"{key_prefix}_deactivate",
|
|
type="secondary",
|
|
):
|
|
mgr.deactivate()
|
|
st.rerun()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def require_license_or_render_activation(*, feature: Optional[str] = None) -> None:
|
|
"""Page-level guard. Call after ``hide_streamlit_chrome``.
|
|
|
|
If a valid license exists (and *feature*, when supplied, is
|
|
unlocked), this is a no-op. Otherwise it renders the activation
|
|
form in place of the page body and calls ``st.stop()``.
|
|
|
|
Tool pages that need per-tool gating call
|
|
:func:`require_feature_or_render_upgrade` *after* this — that
|
|
keeps the global gate (activate / renew flow) separate from the
|
|
per-tier feature gate (upgrade flow).
|
|
"""
|
|
mgr = get_manager()
|
|
if mgr.dev_mode:
|
|
return
|
|
if mgr.is_valid():
|
|
return
|
|
|
|
# Otherwise: render the activation form inline and stop the page.
|
|
render_activation_form(key_prefix="gate")
|
|
st.stop()
|
|
|
|
|
|
def require_feature_or_render_upgrade(feature: str) -> None:
|
|
"""Per-tool feature gate. Renders an upgrade prompt + stops the
|
|
page when the active license's tier doesn't unlock *feature*.
|
|
|
|
Call from tool pages after ``hide_streamlit_chrome``. The page
|
|
body should be wrapped after this call so the upgrade prompt
|
|
cleanly replaces the body when the user's tier is too low.
|
|
|
|
Distinguished from :func:`require_license_or_render_activation`
|
|
by what it offers the user: that one drops them into the
|
|
activation form (paste a paid blob to activate at all); this one
|
|
surfaces an upgrade prompt with a button to the Activate page
|
|
(paste a higher-tier blob to upgrade).
|
|
"""
|
|
from src.license import UnsupportedFeatureError
|
|
|
|
mgr = get_manager()
|
|
if mgr.dev_mode:
|
|
return
|
|
try:
|
|
mgr.require_feature(feature)
|
|
return
|
|
except UnsupportedFeatureError:
|
|
pass # render the upgrade prompt below
|
|
except LicenseError as e:
|
|
# Not-activated / expired / invalid — fall through to the
|
|
# global gate (which would already have run; this is belt
|
|
# and braces for callers that bypass it).
|
|
st.error(f"⚠️ {e}")
|
|
st.stop()
|
|
|
|
state = mgr.current_state()
|
|
tier_label = _t(f"license.tier_{state.tier}") or state.tier.title()
|
|
feature_names = ", ".join(
|
|
_t(f"tools.{f}.name") if f.startswith("0") and "_" in f else f
|
|
for f in state.features
|
|
)
|
|
st.warning(_t("license.feature_locked_title", tier=tier_label))
|
|
st.caption(_t("license.feature_locked_body", features=feature_names))
|
|
if st.button(_t("license.upgrade_link"), key="upgrade_to_activate"):
|
|
st.switch_page("pages/_Activate.py")
|
|
st.stop()
|