commit c76e50a920908ea532ad3c90cd40cc01c8f85c17 equwal <truex@equwal.com> 2026-09-22 17:25:15 -0700 Link the Subrep control panel with the email and a setup key The Licence card of the Subrep desktop control panel asks for the email that paid and a setup key. It sends the two to /refresh as "account" and "refresh_key". The server now accepts the email of the account in place of its id. - /api/subrep/refresh finds the account by its email when "account" has an "@". A wrong email gets the same refusal as a wrong key. - A licence has a "paid" claim: the end of the paid period, which the desktop app shows. "exp" stays the end of the token (31 days at most). - /subrep.html shows the email and the setup key for the control panel, and the link command for the command line.
backend/subrep.py | 26 ++++++++++++++++++++------ frontend/subrep.html | 21 ++++++++++++++++++--- tests/test_subrep.py | 27 +++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 9 deletions(-)
diff --git a/backend/subrep.py b/backend/subrep.py index 914feac..9fddc4c 100644 --- a/backend/subrep.py +++ b/backend/subrep.py @@ -14,8 +14,10 @@ How the desktop app gets Pro: 1. The customer pays here. A Stripe subscription makes the account Pro until the end of the paid period (SubrepLicence). -2. The Subrep page (/subrep.html) shows a link command with the account id - and a refresh key. The customer runs it once on each computer. +2. The Subrep page (/subrep.html) shows the email of the account and a setup + key (the refresh key). The customer enters the two in the Licence card of + the desktop control panel, or runs the link command, once on each + computer. 3. The app sends the two to POST /api/subrep/refresh and gets a licence token: JSON claims, signed with the Ed25519 key of this server (SUBPLZ_WEB_SUBREP_LICENSE_KEY, made by tools/subrep_key.py). The app @@ -239,9 +241,17 @@ class LicenceRefused(Exception): def token(session: Session, account_id: str, refresh_key: str, now: datetime | None = None) -> str: - """A licence token for the desktop app, or LicenceRefused.""" + """A licence token for the desktop app, or LicenceRefused. + + `account_id` is the id of the account, or its email: the Licence card of + the desktop control panel asks for the email that paid. + """ now = now or utcnow() - account = session.get(Account, account_id or "") + ident = (account_id or "").strip() + if "@" in ident: + account = accounts.by_email(session, ident.lower()) + else: + account = session.get(Account, ident) if account is not None: account = accounts.resolve(session, account) row = licence(session, account.id) if account is not None else None @@ -253,10 +263,13 @@ def token(session: Session, account_id: str, refresh_key: str, if not is_pro(row, now): raise LicenceRefused(402, "This account has no active Subrep Pro plan.") latest = now + timedelta(days=TOKEN_DAYS) - end = min(billing._aware(row.period_end) or latest, latest) + # "paid" is what the customer sees: the end of the paid period. "exp" is + # when the app must ask again. + paid = billing._aware(row.period_end) or latest return sign({"sub": account.email or account.id, "tier": "pro", "plan": row.plan_id, "iat": int(now.timestamp()), - "exp": int(end.timestamp())}) + "paid": int(paid.timestamp()), + "exp": int(min(paid, latest).timestamp())}) def link(session: Session, account: Account) -> dict: @@ -270,6 +283,7 @@ def link(session: Session, account: Account) -> dict: server = f"{settings.public_base_url.rstrip('/')}/api/subrep" return { "account": account.id, + "email": account.email, "refresh_key": row.refresh_key, "server": server, "command": f'subrep license --link "{account.id}" "{row.refresh_key}" "{server}"', diff --git a/frontend/subrep.html b/frontend/subrep.html index 980b9c0..6c1ea8e 100644 --- a/frontend/subrep.html +++ b/frontend/subrep.html @@ -49,13 +49,21 @@ <section class="panel" id="pro" hidden> <h2>Your Subrep Pro</h2> <p id="pro-status"></p> - <p>Run this command once on each computer that has Subrep. It links the computer to this - account, and the computer then renews its licence by itself:</p> + <p>On each computer that has Subrep, open the control panel and find <strong>Licence</strong>. + Enter this email and this setup key. The computer then renews its licence by itself.</p> + <div class="command"> + <code id="link-email"></code> + </div> + <div class="command"> + <code id="link-key"></code> + <button class="ghost compact" id="copy-key" type="button">Copy</button> + </div> + <p class="muted small">Or run this command:</p> <div class="command"> <code id="command"></code> <button class="ghost compact" id="copy" type="button">Copy</button> </div> - <p class="muted small">Keep the command private: it gives Pro to any computer that runs it.</p> + <p class="muted small">Keep the setup key private: it gives Pro to any computer that has it.</p> <div class="actions"> <button class="ghost" id="manage" type="button">Manage plan</button> </div> @@ -139,11 +147,18 @@ const ends = new Date(state.period_end).toLocaleDateString(); const plan = state.plans.find((p) => p.id === state.plan_id); $('pro-status').textContent = `${plan ? plan.name : 'Subrep Pro'}: paid until ${ends}.`; + $('link-email').textContent = `Email: ${state.link.email || '(none)'}`; + $('link-key').textContent = state.link.refresh_key; $('command').textContent = state.link.command; } return state; } + $('copy-key').addEventListener('click', async () => { + await navigator.clipboard.writeText($('link-key').textContent); + $('copy-key').textContent = 'Copied'; + }); + $('copy').addEventListener('click', async () => { await navigator.clipboard.writeText($('command').textContent); $('copy').textContent = 'Copied'; diff --git a/tests/test_subrep.py b/tests/test_subrep.py index 5205975..c71109c 100644 --- a/tests/test_subrep.py +++ b/tests/test_subrep.py @@ -247,6 +247,33 @@ def test_an_account_without_a_link_command_gets_no_licence(client, monkeypatch): assert refresh(client, acct, "").status_code == 403 +def test_the_control_panel_links_with_the_email_and_the_setup_key(client, monkeypatch): + # The Licence card of the desktop control panel asks for the email that + # paid and the setup key (the refresh key). The email names the account. + email = f"Panel-{time.time_ns()}@Example.com" + buy(client, monkeypatch, email=email) + link = state(client)["link"] + assert link["email"] == email.lower() + r = refresh(client, email, link["refresh_key"]) + assert r.status_code == 200 and verify(r.json()["token"])["sub"] == email.lower() + + +def test_another_email_gets_no_licence(client, monkeypatch): + buy(client, monkeypatch) + key = state(client)["link"]["refresh_key"] + assert refresh(client, f"other-{time.time_ns()}@example.com", key).status_code == 403 + + +def test_a_licence_says_when_the_paid_period_ends(client, monkeypatch): + # The desktop app shows "paid until" from the "paid" claim. The token + # itself ends after TOKEN_DAYS at most. + acct = buy(client, monkeypatch) + end = int(time.time()) + 365 * DAY + post_webhook(client, sub_event("updated", sub_object(acct, "subrep_year", period_end=end))) + claims = verify(refresh(client, acct, state(client)["link"]["refresh_key"]).json()["token"]) + assert claims["paid"] == end and claims["exp"] < end + + def test_a_cancelled_plan_gets_no_licence(client, monkeypatch): acct = buy(client, monkeypatch, plan_id="subrep_month") key = state(client)["link"]["refresh_key"]