feat(license): registration + 1-year licenses + tier scaffolding
A complete offline licensing layer (no internet at any step): Core - src/license/ — schema (License, Tier, FeatureFlag), HMAC crypto, JSON storage, LicenseManager singleton with activate/renew/ deactivate/issue_trial. Tier-scaffolded so future SKUs can carve per-tool feature sets without consumer-code edits. - scripts/generate_license.py — creator-only key generator. Mints a DTLIC1: blob the buyer pastes into the activation page. GUI - New activation form component (src/gui/components/activation.py). - hide_streamlit_chrome() now inline-renders the activation form when no valid license is present (every page short-circuits to the form until activated). - Sidebar shows tier + days remaining; renewal warning under 30 days. - New pages/_Activate.py for revisiting the form after activation. CLI - src/license_cli.py — activate / renew / status / trial / deactivate commands. Exempt from the guard. - src/cli_license_guard.py — drop-in guard call added to every tool CLI's main(). Lets --help through; respects DATATOOLS_DEV_MODE. i18n - New activation.* and license.* keys in en.json + es.json (page title, form labels, status badges, renewal warnings, error messages). Pack parity test stays green. Test infrastructure - tests/conftest.py autouse fixture sets DATATOOLS_DEV_MODE=1 so the existing 1916 tests continue to pass. - isolated_license_path / activated_license_manager / unactivated_license_manager fixtures for tests that want to drive the real check. Tests (+79) - tests/test_license.py (40): schema, crypto roundtrip, blob encode/decode, tier→feature mapping, activation flow, name/email mismatch rejection, tamper detection, expiration, renewal, dev-mode bypass. - tests/test_license_cli.py (26): every license_cli command + subprocess tests confirming every tool CLI refuses to run without a license, --help always works, DEV_MODE bypasses. - tests/gui/test_activation.py (13): gate blocks without license, passes with trial, activation form submission unlocks the gate, sidebar status, renewal warning, i18n. Total: 1916 → 1995 tests. All pass under the strict warning filter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
218
src/gui/components/activation.py
Normal file
218
src/gui/components/activation.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
col_activate, col_trial = st.columns(2)
|
||||
with col_activate:
|
||||
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")
|
||||
with col_trial:
|
||||
trial = st.form_submit_button(
|
||||
_t("activation.trial_button"),
|
||||
help=_t("activation.trial_help"),
|
||||
)
|
||||
|
||||
# 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}")
|
||||
|
||||
if trial:
|
||||
try:
|
||||
lic = mgr.issue_trial(name=name, email=email)
|
||||
st.success(_t(
|
||||
"activation.success",
|
||||
name=lic.name, expires=lic.expires_at[:10],
|
||||
))
|
||||
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()``.
|
||||
|
||||
The chrome helper invokes this automatically so individual pages
|
||||
don't have to call it; the explicit *feature* form is provided
|
||||
for tools that want to bypass the global gate but enforce a tier
|
||||
check (future SKU work).
|
||||
"""
|
||||
mgr = get_manager()
|
||||
if mgr.dev_mode:
|
||||
return
|
||||
if mgr.is_valid():
|
||||
# When feature gating gets enabled per-tool, this is the hook.
|
||||
if feature is not None:
|
||||
try:
|
||||
mgr.require_feature(feature)
|
||||
except LicenseError as e:
|
||||
st.error(f"⚠️ {e}")
|
||||
st.stop()
|
||||
return
|
||||
|
||||
# Otherwise: render the activation form inline and stop the page.
|
||||
render_activation_form(key_prefix="gate")
|
||||
st.stop()
|
||||
Reference in New Issue
Block a user