summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md7
-rw-r--r--docs/USERGUIDE.md6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/invite_links.py27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/tests/test_invite_links.py32
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 ───────────────────────────────────────────────────────