From 95cec0e0bbc28e930f297f44bbd3dbf4d63f0bc2 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 23 Sep 2026 19:30:47 +0200 Subject: fix(hub): a redeemed invitation link leaves the owner's list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list under "Invite by link" answered every ticket the group had ever minted, so a link that somebody had already used sat there saying "used by " for the thirty days of KEEP_REDEEMED — beside the member row it had just produced, and above the links that still wait for somebody, which are the only ones there is anything to do about. The node's own `member list` had never shown them: it selects `used_at IS NULL`. The listing now selects `redeemed_by IS NULL`, and drops the `redeemed` status and the `redeemed_by` field with it. The row itself still lives for KEEP_REDEEMED, which is what lets a reload or a second tab of the invitation page be answered rather than refused; its comment says that now instead of naming a list it is no longer in. The SPA filters too, because the desktop client's copy of this interface can be newer than the hub it is signed into. Co-Authored-By: Claude Opus 5 --- docs/MESHBAY_DESIGN.md | 7 ++++- docs/USERGUIDE.md | 6 ++-- .../src/meshbay_hub/api/invite_links.py | 27 +++++++++++------- .../src/meshbay_hub/static/group-settings.js | 16 ++++++----- .../src/meshbay_hub/static/locales/de.js | 1 - .../src/meshbay_hub/static/locales/en.js | 1 - .../src/meshbay_hub/static/locales/es.js | 1 - .../src/meshbay_hub/static/locales/fr.js | 1 - .../src/meshbay_hub/static/locales/it.js | 1 - .../src/meshbay_hub/static/locales/ja.js | 1 - .../src/meshbay_hub/static/locales/nl.js | 1 - .../src/meshbay_hub/static/locales/pl.js | 1 - .../src/meshbay_hub/static/locales/pt-BR.js | 1 - .../src/meshbay_hub/static/locales/zh-CN.js | 1 - packages/meshbay-hub/tests/test_invite_links.py | 32 +++++++++++++++++++++- 15 files changed, 67 insertions(+), 31 deletions(-) diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 673ad05..325a3c5 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -1811,7 +1811,12 @@ account, and an unknown, used, expired or cancelled ticket, or a group no longer active, is one uniform refusal. Creating a link says nothing about whether the address has an account (**M1**). At most twenty outstanding per group, as on the node; a lifetime clamped to thirty days; a node token may create one for its own -operator's group (the CLI) and may not ask for mail. What the binding holds +operator's group (the CLI) and may not ask for mail. **The list answered to the +owner holds the links nobody has used yet**, which are the ones there is still +something to do about: a redeemed one has become the member row it produced, and +showing both says the same thing twice. The row itself outlives the list by +thirty days, so the account that used a link is answered on a reload or in a +second tab rather than refused. What the binding holds against, per the convention: **third parties** — a messaging service that previews the link, a forwarded mail — and not this hub, which verifies the addresses it compares and could already be anybody. diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index d3676cf..0589929 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -579,8 +579,10 @@ their e-mail address and **Create link**. Send them the link, or leave **Send the invitation by e-mail** ticked and the hub mails it. They register with that address and land in the group without typing a code. The link works once and only for an account with that address, so a copy that -travels further — a forwarded mail, a chat — lets nobody else in. Pending links -are listed under the box, and **Cancel** takes one back. A hub mails at most ten +travels further — a forwarded mail, a chat — lets nobody else in. Links nobody +has used yet are listed under the box, and **Cancel** takes one back; once +somebody joins through a link it leaves that list, and they are in the member +list above it. A hub mails at most ten links a day for one account (an administrator can change that). The Members tab offers **Send the invitation by e-mail**, ticked by default: the diff --git a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py index d44576b..f33dc6e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py @@ -43,7 +43,9 @@ MAX_OUTSTANDING_PER_GROUP = 20 # A node's invitation lifetime is the operator's setting (7 days by default); # the ticket follows it, up to this. MAX_LIFETIME = timedelta(days=30) -# How long a redeemed link stays in the owner's list, saying who used it. +# How long a spent link is kept before it is forgotten. The owner is not shown +# it — the person is in the group — but while the row is here, a reload or a +# second tab of the invitation page still answers the account that used it. KEEP_REDEEMED = timedelta(days=30) _TICKET = re.compile(r"^[A-Za-z0-9_-]{22}$") # secrets.token_urlsafe(16) @@ -205,14 +207,21 @@ async def list_invite_links( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """The owner's view: who each link was for, masked, and whether it was used.""" + """ + The owner's view: the links nobody has used yet, masked. + + A redeemed one is left out. The person it let in has a row of their own in + the members list, so keeping the link there too says the same thing twice + and pushes down the links that still wait for somebody — which are the ones + the owner can act on, by cancelling them. + """ await _owned_group(db, group_id, current_user) rows = (await db.execute( - select(GroupInviteLink, User.username) - .outerjoin(User, User.id == GroupInviteLink.redeemed_by) - .where(GroupInviteLink.group_id == group_id) + select(GroupInviteLink) + .where(GroupInviteLink.group_id == group_id, + GroupInviteLink.redeemed_by.is_(None)) .order_by(GroupInviteLink.created_at.desc()) - .limit(200))).all() + .limit(200))).scalars().all() now = datetime.now(UTC) return {"links": [{ "link_id": r.id, @@ -220,10 +229,8 @@ async def list_invite_links( "node_invite_id": r.node_invite_id, "created_at": _aware(r.created_at).isoformat(), "expires_at": _aware(r.expires_at).isoformat(), - "status": ("redeemed" if r.redeemed_by - else "expired" if _aware(r.expires_at) <= now else "pending"), - "redeemed_by": name, - } for r, name in rows]} + "status": "expired" if _aware(r.expires_at) <= now else "pending", + } for r in rows]} @router.delete("/{group_id}/invite-links/{link_id}") diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index 121c51b..529a33d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -916,10 +916,14 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, : null; }, [transportRef]); + // A redeemed link is not shown: whoever used it is in the members list above, + // and what belongs here is what the owner can still cancel. The hub leaves + // them out already; the filter is for the desktop client, whose copy of this + // interface can be newer than the hub it is signed into. const loadLinks = useCallback(() => { if (!(group && group.is_admin)) return; hubFetch(`/v1/groups/${groupId}/invite-links`, { token }) - .then(data => setLinks(data.links || [])) + .then(data => setLinks((data.links || []).filter(l => l.status !== 'redeemed'))) .catch(() => {}); }, [groupId, token, group]); @@ -1102,12 +1106,10 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, flex-wrap:wrap;word-break:break-word;margin:4px 0"> ${l.email} - ${l.status === 'redeemed' - ? t('members.link_status_redeemed', { user: l.redeemed_by || '' }) - : l.status === 'expired' - ? t('members.link_status_expired') - : t('members.link_expires', - { date: new Date(l.expires_at).toLocaleDateString() })} + ${l.status === 'expired' + ? t('members.link_status_expired') + : t('members.link_expires', + { date: new Date(l.expires_at).toLocaleDateString() })} ${l.status === 'pending' && html`