diff options
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"> <span>${l.email}</span> <span style="color:var(--text-dim)"> - ${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() })} </span> ${l.status === 'pending' && html` <button class="admin-btn" type="button" diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 90f5c00..7008f79 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -697,7 +697,6 @@ export default { 'members.link_email_sent': "Der Link wurde per E-Mail gesendet.", 'members.link_email_refused': "Die E-Mail konnte nicht gesendet werden — teilen Sie den Link selbst.", 'members.links_pending': "Einladungslinks", - 'members.link_status_redeemed': "verwendet von {user}", 'members.link_status_expired': "abgelaufen", 'members.link_expires': "läuft ab am {date}", 'members.link_cancel': "Abbrechen", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 2b55d08..1ea6eda 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -813,7 +813,6 @@ export default { 'members.link_email_sent': "The link has been sent by e-mail.", 'members.link_email_refused': "The e-mail could not be sent — share the link yourself.", 'members.links_pending': "Invitation links", - 'members.link_status_redeemed': "used by {user}", 'members.link_status_expired': "expired", 'members.link_expires': "expires {date}", 'members.link_cancel': "Cancel", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 5265976..df65367 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -692,7 +692,6 @@ export default { 'members.link_email_sent': "El enlace se ha enviado por correo.", 'members.link_email_refused': "No se pudo enviar el correo — comparta el enlace usted mismo.", 'members.links_pending': "Enlaces de invitación", - 'members.link_status_redeemed': "usado por {user}", 'members.link_status_expired': "caducado", 'members.link_expires': "caduca el {date}", 'members.link_cancel': "Cancelar", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index b6b909e..e5d542a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -695,7 +695,6 @@ export default { 'members.link_email_sent': "Le lien a été envoyé par e-mail.", 'members.link_email_refused': "L’e-mail n’a pas pu être envoyé — partagez le lien vous-même.", 'members.links_pending': "Liens d’invitation", - 'members.link_status_redeemed': "utilisé par {user}", 'members.link_status_expired': "expiré", 'members.link_expires': "expire le {date}", 'members.link_cancel': "Annuler", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 2a95bf7..e376550 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -695,7 +695,6 @@ export default { 'members.link_email_sent': "Il link è stato inviato per e-mail.", 'members.link_email_refused': "Impossibile inviare l’e-mail — condivida il link di persona.", 'members.links_pending': "Link d’invito", - 'members.link_status_redeemed': "usato da {user}", 'members.link_status_expired': "scaduto", 'members.link_expires': "scade il {date}", 'members.link_cancel': "Annulla", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 4068e44..622fa5a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -687,7 +687,6 @@ export default { 'members.link_email_sent': "リンクをメールで送信しました。", 'members.link_email_refused': "メールを送信できませんでした。リンクを直接共有してください。", 'members.links_pending': "招待リンク", - 'members.link_status_redeemed': "{user} が使用", 'members.link_status_expired': "期限切れ", 'members.link_expires': "{date} に期限切れ", 'members.link_cancel': "取り消す", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index edb6f6a..be0b43b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -696,7 +696,6 @@ export default { 'members.link_email_sent': "De link is per e-mail verstuurd.", 'members.link_email_refused': "De e-mail kon niet worden verstuurd — deel de link zelf.", 'members.links_pending': "Uitnodigingslinks", - 'members.link_status_redeemed': "gebruikt door {user}", 'members.link_status_expired': "verlopen", 'members.link_expires': "verloopt op {date}", 'members.link_cancel': "Annuleren", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index d042c48..6e88a65 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -708,7 +708,6 @@ export default { 'members.link_email_sent': "Link został wysłany e-mailem.", 'members.link_email_refused': "Nie udało się wysłać e-maila — przekaż link samodzielnie.", 'members.links_pending': "Linki zaproszeń", - 'members.link_status_redeemed': "użyty przez {user}", 'members.link_status_expired': "wygasł", 'members.link_expires': "wygasa {date}", 'members.link_cancel': "Anuluj", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 61e4d44..edb9146 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -694,7 +694,6 @@ export default { 'members.link_email_sent': "O link foi enviado por e-mail.", 'members.link_email_refused': "Não foi possível enviar o e-mail — compartilhe o link você mesmo.", 'members.links_pending': "Links de convite", - 'members.link_status_redeemed': "usado por {user}", 'members.link_status_expired': "expirado", 'members.link_expires': "expira em {date}", 'members.link_cancel': "Cancelar", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 34fb439..4f5034d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -676,7 +676,6 @@ export default { 'members.link_email_sent': "链接已通过电子邮件发送。", 'members.link_email_refused': "无法发送电子邮件——请自行分享链接。", 'members.links_pending': "邀请链接", - 'members.link_status_redeemed': "已由 {user} 使用", 'members.link_status_expired': "已过期", 'members.link_expires': "{date} 过期", 'members.link_cancel': "取消", diff --git a/packages/meshbay-hub/tests/test_invite_links.py b/packages/meshbay-hub/tests/test_invite_links.py index 1b60dc1..461cf8a 100644 --- a/packages/meshbay-hub/tests/test_invite_links.py +++ b/packages/meshbay-hub/tests/test_invite_links.py @@ -224,8 +224,38 @@ async def test_a_used_link_cannot_be_cancelled_here(client): r = await client.delete(f"/v1/groups/{gid}/invite-links/{link['link_id']}", headers=owner["h"]) assert r.status_code == 409 + + +@pytest.mark.asyncio +async def test_a_used_link_leaves_the_owners_list(client, db_session): + """The invitee is a member now; the link saying so as well is clutter. + + The row itself stays for `KEEP_REDEEMED`, which is what lets a reload of + the invitation page answer the account that used it instead of refusing. + """ + owner = await _account(client, "gone_owner") + invitee = await _account(client, "gone_invitee", email="invitee@example.test") + gid = await _group(client, owner) + waiting = (await _link(client, owner, gid, email="other@example.test")).json() + link = (await _link(client, owner, gid)).json() + r = await client.get(f"/v1/groups/{gid}/invite-links", headers=owner["h"]) - assert r.json()["links"][0]["redeemed_by"] == "used_invitee" + assert {row["link_id"] for row in r.json()["links"]} == {waiting["link_id"], + link["link_id"]} + + r = await client.post("/v1/invite-links/redeem", json={"ticket": link["ticket"]}, + headers=invitee["h"]) + assert r.status_code == 200, r.text + + r = await client.get(f"/v1/groups/{gid}/invite-links", headers=owner["h"]) + rows = r.json()["links"] + assert [row["link_id"] for row in rows] == [waiting["link_id"]] + assert "redeemed" not in r.text + # Still on the hub, so the invitee's second tab is answered, not refused. + assert (await db_session.get(GroupInviteLink, link["link_id"])) is not None + r = await client.post("/v1/invite-links/preview", json={"ticket": link["ticket"]}, + headers=invitee["h"]) + assert r.status_code == 200 and r.json()["already_member"] is True # ── What the hub mails ─────────────────────────────────────────────────────── |