Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


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"]