diff options
31 files changed, 317 insertions, 205 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 8bf5e22..c6d3cc7 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -404,6 +404,7 @@ Code lifetimes differ because the acts differ: | Code | Default | Setting | |---|---|---| | Member invitation | **7 days** | `[node] invite_ttl_hours` | +| Invitation link | **7 days** | none — fixed, and the hub clamps its ticket to the same | | Operator pairing | 24 h | `[node] pair_ttl_hours` | | Device add request | 1 h | `[node] device_request_ttl_minutes` | @@ -428,7 +429,7 @@ carries **two secrets with two jobs**: | Half | Secret | Checked by | Grants | |---|---|---|---| -| Hub ticket | 128 random bits, stored as `sha256` | the hub, against the redeeming account's **verified address** — the blind index of the address the inviter typed | membership, i.e. the right to *reach* the node (§7.1). Nothing about keys | +| Hub ticket | 128 random bits, stored as `sha256` | the hub: unknown, used by another account, expired or cancelled is refused; otherwise the **first account that redeems it** gets it | membership, i.e. the right to *reach* the node (§7.1). Nothing about keys | | Node code | the ordinary 40-bit code, with `kind = "link"` | the issuing node only | pinning and the group key, as for any code | ``` @@ -461,14 +462,26 @@ The rules the client follows, each of which a test holds: - a link whose origin is not this hub is refused (the desktop client accepts one pasted in), so a ticket is never sent to another hub. -What it holds against, per the convention at the top. **A third party holding the -link** — a messaging service that previews it, a forwarded mail — gets nothing -without the invitee's mailbox. **An active hub** is not stopped: it verifies the -addresses it compares, and could already be anybody. **A hijacked signaling path** -no longer receives the code on a native client; a browser is still **T3**, exactly as -for a typed code. What it costs: the hub learns that an owner invited an address -(as a blind index and a masked form, until the link is used or expires). Addresses -are compared case-insensitively and nothing more, so `+tags` and dots must match. +**Both halves are bearer secrets, and the link is not bound to any address.** It +is meant to be sent by whatever the inviter and the invitee already use — a +messaging application, a text — and an address the invitee must register with +was what made that fail: the person opened the link, registered with the address +they use, and was told it had been sent to another one. The address is therefore +optional and binds nothing: it is where the hub mails the link, if asked, and a +masked label in the owner's list. + +What that gives up, per the convention at the top. **Whoever holds the link first +joins**: a forwarded message, a shared screen, a group chat it was posted in by +mistake. What bounds it is that it works **once** — the person it was meant for +then finds it used, which is noticed — for **seven days**, fixed rather than the +operator's `invite_ttl_hours`; that joining is an explicit click showing the group +and the inviter; that the owner is notified of every redemption; and that +**Cancel** takes an unused one back, and removing the member a used one. A service +that *previews* the link learns nothing: everything is after `#`, which a fetch +never sends. **An active hub** is not stopped, and never was. **A hijacked +signaling path** no longer receives the code on a native client; a browser is +still **T3**, exactly as for a typed code. When an address is given, the hub +learns it was invited (in a masked form, until the link is used or expires). **Mailing the code is the inviter's choice, and it gives up the row above.** The Members tab can ask the hub to mail the code to the invitee's address on file @@ -480,8 +493,8 @@ checked by default, and is **remembered per account** (the `invite_email` preference), so an operator who unticks it once is not asked to again. Unticked, the hub is never called and the table above holds exactly. The CLI mails nothing. -The same box sits under the link form, sharing the same preference. Ticked, the hub -mails the link to the address the ticket is bound to — no second address, and +The same box sits under the link form, sharing the same preference. Ticked, and +with an address typed, the hub mails the link to that address — and **the hub writes the link itself** from its own origin, the ticket it has just minted, and the node key and code it is handed, each checked against its exact shape, so a hub-signed mail cannot be made to carry any other URL. Because that mail @@ -1676,7 +1689,7 @@ Node page: | Setting | Default | What it controls | |---|---|---| -| `invite_ttl_hours` | 168 | how long a member invitation stays valid | +| `invite_ttl_hours` | 168 | how long a member invitation stays valid. Not an invitation link, which is fixed at seven days (§3.4) | | `pair_ttl_hours` | 24 | how long an operator pairing code stays valid | | `device_request_ttl_minutes` | 60 | how long a device request waits for approval. Comfort, not security: the code is bound to the keys by its hash | | `max_concurrent_streams` | 8 | simultaneous video streams. One process per viewer, ~50 MB each; a slot is held for the length of a film, so this counts viewers | @@ -1826,23 +1839,21 @@ appear in the directory and are brokered to strangers. The check is at creation only, which is correct because the update endpoint refuses to change visibility. **An invitation link's hub half is a ticket, and it grants membership to one -account.** `group_invite_links` holds `sha256(ticket)`, the invitee's address as -the same blind index `users` has, and a masked form for the owner's list — never -the address. Redeeming requires a signed-in account whose verified address has -that index; anyone else holding the ticket is told only that it is not for this -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. **The list answered to the +account: the first signed-in account that redeems it.** `group_invite_links` +holds `sha256(ticket)` and, when the inviter gave an address, a masked form of it +for the owner's list — never the address, and nothing a redemption is checked +against. An unknown ticket, one used by another account, an expired or cancelled +one, or a group no longer active, is one uniform refusal. Creating a link says +nothing about whether an address has an account (**M1**). At most twenty +outstanding per group, as on the node; a lifetime clamped to **seven days**, the +node's own fixed lifetime for a link code; a node token may create one for its own +operator's group (the CLI) and may not ask for mail, and mail needs an address. **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. +second tab rather than refused. What a leaked link costs is in §3.4: it is a +bearer secret, bounded by being single use, seven days and cancellable. **Which nodes host a group is answered to its members.** For a public group that is everyone, which is what public means; for a private one it is the membership diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md index 2206971..3351793 100644 --- a/docs/MESHBAY_NODE_PROTOCOL.md +++ b/docs/MESHBAY_NODE_PROTOCOL.md @@ -866,10 +866,11 @@ source of authority is a second thing to get wrong. ### 8.6 Invitation links (`kind = "link"`) A code for someone who may have no account yet, so it names none: it is bound to the -first account that redeems it (`invite_link_create`, §10.4). What stops a stranger -holding it is not the node but the hub, which lets only the account whose verified -address the inviter named reach the node at all (`MESHBAY_DESIGN.md` §3.4). At the -node it is therefore a **bearer code**, and everything else about it is fixed: +first account that redeems it (`invite_link_create`, §10.4). Nothing stops a +stranger holding the whole link — the hub's ticket is a bearer secret too +(`MESHBAY_DESIGN.md` §3.4) — so what bounds it is that it works once, for seven +days, and can be cancelled. At the node it is a **bearer code**, and everything else +about it is fixed: * role `member`, never `operator`; a row that says otherwise is refused, not honoured; * one named group, never node-wide, and only redeemed on a connection authenticated to diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index bf6cc80..a11bb00 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -318,9 +318,10 @@ Send that code however you normally talk to them — message, mail, out loud. It works once, for that account only, for that group only, and the hub never sees it. You do not need to be online when they use it. -Someone with no account yet gets a link instead, bound to their e-mail address: -`meshbay-node member invite alice@example.org --link`. They open it, register -with that address, and land in the group without typing a code +Someone with no account yet gets a link instead, which you send them however you +like — a messaging application, a text: `meshbay-node member invite --link`. It +works once, for seven days, for whoever opens it first. They open it, register, +and land in the group without typing a code ([`USERGUIDE.md` §7](USERGUIDE.md#7-managing-people)). You can do the same thing from the **Members** tab of the group, from the diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 0589929..6a86897 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -226,15 +226,15 @@ The person running the group can send you a **link** instead of a code — the way in when you have no account yet. 1. **Open the link.** It says *"You have been invited"*. -2. **Create an account** with the e-mail address the invitation was sent to — - exactly that address — or sign in if you already have one. Confirm your - address with the code the hub mails you, then sign in. +2. **Create an account**, with any e-mail address you like, or sign in if you + already have one. Confirm your address with the code the hub mails you, + then sign in. 3. You are brought back to the invitation: *"… invites you to join …"*. **Join.** 4. The group opens, and you type no code: the link carried it. -The link works once, and only for the account registered with that address; to -anyone else it says it was sent to another address. In the desktop +The link works once, for seven days: after that, or once somebody has used it, +it says it is no longer valid — ask for a new one. In the desktop application, paste the link into **Join with an invitation link** on the home page. @@ -522,7 +522,7 @@ and running `meshbay-node reload`: | Setting | Default | What it decides | |---|---|---| -| `invite_ttl_hours` | 168 (7 days) | how long an invitation stays usable | +| `invite_ttl_hours` | 168 (7 days) | how long an invitation code stays usable (an invitation link is always 7 days) | | `pair_ttl_hours` | 24 | how long an operator pairing code lasts | | `device_request_ttl_minutes` | 60 | how long a device request waits for approval | | `max_concurrent_streams` | 8 | how many people can watch video at once | @@ -570,16 +570,20 @@ need to be online then. **Inviting someone who has no account yet** is a link: ```bash -meshbay-node member invite alice@example.org --link +meshbay-node member invite --link meshbay-node member cancel <link id> # printed with the link, and in member list ``` -or the **Invite by link** box in the Members tab, under the first one: type -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. Links nobody +or the **Invite by link** box in the Members tab, under the first one: **Create +link**, then **Copy** it — or **Share** it, on a phone — and send it however you +like: a messaging application, a text. The e-mail address is optional: type one +and leave **Send the invitation by e-mail** ticked, and the hub mails the link +there too; it also names the link in the list below. They register (with any +address) and land in the group without typing a code. +**The link is for whoever opens it first**: it works once, for seven days, and +anyone holding it can use it — so send it only to the person it is for, and +**Cancel** it if it went somewhere else. You are notified when somebody joins +through one. 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 @@ -855,7 +859,7 @@ and every member re-admitted. It is small — back it up somewhere safe. | | | |---|---| | Invitation code | 7 days, single use, one account, one group | -| Invitation link | 7 days, single use, one group, the account registered with its address; 20 pending per group | +| Invitation link | 7 days (fixed), single use, one group, whoever opens it first; 20 pending per group | | Operator pairing code | 24 hours | | Device linking code | 1 hour | | Devices per account per node | 5 | 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 f33dc6e..3c50e19 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py @@ -4,14 +4,13 @@ Invitation links — the hub's half (docs/MESHBAY_DESIGN.md §3.4, §7.3). A link carries two secrets with two jobs. The node's code decides who gets the group key, and it is shown to the hub only when the inviter asks the hub to mail the link. The ticket here decides who may *reach* the node — membership, -which is all the hub has to give (§7.1) — and it gives it to one account only: -the one whose verified address the inviter named. A ticket that leaks, through a -messaging service that previews links or a forwarded mail, is therefore useless -without that mailbox. +which is all the hub has to give (§7.1) — and it gives it to one account: the +first to redeem it. Both halves are therefore bearer secrets, so the link can +travel through any messaging service; what bounds a leaked one is that it works +once, for seven days, and that the owner can cancel it. -Stated per the design's convention: that binding holds against third parties and -not against this hub, which verifies the addresses it compares. An active hub -could already be anybody. +The address is optional and binds nothing. When given, it is where the hub +mails the link and a masked label in the owner's list. No route here answers without an account, and nothing tells the inviter whether an address has an account (M1): creating a link looks the same either way. @@ -30,7 +29,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub import mail from meshbay_hub.api.deps import _decode_token, get_current_user, require_user_scope from meshbay_hub.api.middleware import limiter -from meshbay_hub.auth import hash_email_blind from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import Group, GroupInviteLink, GroupMember, User @@ -40,9 +38,9 @@ redeem_router = APIRouter(prefix="/v1/invite-links", tags=["invite-links"]) # The node holds at most as many unredeemed link codes per group; one more # ticket here than codes there would be a link that cannot work. 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) +# The node issues a link's code for exactly this long; the ticket follows it, +# and never outlives it. +MAX_LIFETIME = timedelta(days=7) # 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. @@ -83,6 +81,8 @@ def _aware(when: datetime) -> datetime: def _valid_email(v: str) -> str: v = v.strip() + if not v: + return v local, sep, domain = v.partition("@") if (not sep or not local or not domain or "." not in domain.strip(".") or len(v) > 254 or any(c.isspace() or ord(c) < 32 for c in v)): @@ -91,7 +91,8 @@ def _valid_email(v: str) -> str: class CreateLinkRequest(BaseModel): - email: str + # Optional: only where the hub mails the link, and a label for the owner. + email: str = "" expires_at: str node_invite_id: str # Only when the hub is to mail the link, since only then must it write it: @@ -149,6 +150,8 @@ async def create_invite_link( if not _NODE_INVITE_ID.match(body.node_invite_id): raise HTTPException(status_code=422, detail="Not a node invitation id") if body.send_email: + if not body.email: + raise HTTPException(status_code=422, detail="Mailing a link needs an address") if payload.get("scope") == "node": raise HTTPException(status_code=403, detail="Invitation mail is sent from the interface only") @@ -179,7 +182,7 @@ async def create_invite_link( ticket = secrets.token_urlsafe(16) row = GroupInviteLink( group_id=group_id, created_by=current_user.id, ticket_hash=ticket_hash(ticket), - email_hash=hash_email_blind(body.email), email_masked=_mask(body.email), + email_masked=_mask(body.email) if body.email else None, node_invite_id=body.node_invite_id, expires_at=expires) db.add(row) await db.flush() @@ -225,7 +228,7 @@ async def list_invite_links( now = datetime.now(UTC) return {"links": [{ "link_id": r.id, - "email": r.email_masked, + "email": r.email_masked or "", "node_invite_id": r.node_invite_id, "created_at": _aware(r.created_at).isoformat(), "expires_at": _aware(r.expires_at).isoformat(), @@ -257,14 +260,10 @@ async def delete_invite_link( async def _resolve(db: AsyncSession, ticket: str, user: User ) -> tuple[GroupInviteLink, Group]: """ - The link this ticket names, if this account may use it. - - One uniform refusal for everything that says nothing about the account — - unknown, spent by someone else, expired, a group no longer active — and one - distinct answer, `invite_other_account`, for the case the person can act - on: signed in as somebody other than the address it was sent to. That - answer names no address, and it is given only to someone holding the - ticket, who already knows a link exists. + The link this ticket names, if this account may use it: any account while + nobody has, and afterwards only the one that did. One uniform refusal for + everything else — unknown, spent by someone else, expired, a group no + longer active. """ invalid = HTTPException(status_code=404, detail="invite_not_valid") if not _TICKET.match(ticket or ""): @@ -280,8 +279,6 @@ async def _resolve(db: AsyncSession, ticket: str, user: User raise invalid if not row.redeemed_by and _aware(row.expires_at) <= datetime.now(UTC): raise invalid - if not user.email_hash or user.email_hash != row.email_hash: - raise HTTPException(status_code=403, detail="invite_other_account") return row, group @@ -312,8 +309,8 @@ async def redeem_invite_link( db: AsyncSession = Depends(get_db), ): """ - Membership for the addressed account, once — and the same answer again for - that account, because a second tab or a reload is the same person. + Membership for the first account that asks, once — and the same answer + again for that account, because a second tab or a reload is the same person. """ row, group = await _resolve(db, body.ticket, current_user) if not row.redeemed_by: diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py new file mode 100644 index 0000000..ae9d2c9 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py @@ -0,0 +1,34 @@ +"""invitation links bind no address + +A link is now redeemable by whichever account opens it first, so it can be sent +through any messaging service. The address, when the inviter gives one, is only +where the hub mails the link and a masked label in the owner's list. + +Revision ID: c4d5e6f7a8b9 +Revises: b2c3d4e5f6a7 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "c4d5e6f7a8b9" +down_revision: str | Sequence[str] | None = "b2c3d4e5f6a7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("group_invite_links") as batch: + batch.drop_column("email_hash") + batch.alter_column("email_masked", existing_type=sa.String(128), nullable=True) + + +def downgrade() -> None: + # The bound address cannot be recovered; outstanding links are dropped. + op.execute("DELETE FROM group_invite_links") + with op.batch_alter_table("group_invite_links") as batch: + batch.alter_column("email_masked", existing_type=sa.String(128), nullable=False) + batch.add_column(sa.Column("email_hash", sa.String(64), nullable=False, + server_default="")) diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index 51a3d47..09eb437 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -151,12 +151,12 @@ class GroupInviteLink(Base): A link carries two secrets. The node's code decides whether someone gets the group key, and the hub never sees it. This row decides whether someone may - *reach* the node at all — membership, which is all the hub has to give — and - only for the account whose verified address matches `email_hash`. The ticket - is stored as `sha256(ticket)`, so a copy of this table opens nothing. + *reach* the node at all — membership, which is all the hub has to give — for + the first account that redeems it. The ticket is stored as `sha256(ticket)`, + so a copy of this table opens nothing. - No address in the clear: `email_hash` is the same blind index `users` has, - and `email_masked` is what the owner's list shows (`al***@ex***.com`). + The address is optional and binds nothing: when the inviter gave one, + `email_masked` is what the owner's list shows (`al***@ex***.com`). """ __tablename__ = "group_invite_links" @@ -164,8 +164,7 @@ class GroupInviteLink(Base): group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), nullable=False) created_by: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) ticket_hash: Mapped[str] = mapped_column(String(64), nullable=False) - email_hash: Mapped[str] = mapped_column(String(64), nullable=False) - email_masked: Mapped[str] = mapped_column(String(128), nullable=False) + email_masked: Mapped[str | None] = mapped_column(String(128)) # The node's handle for its half, so cancelling can take back both. node_invite_id: Mapped[str] = mapped_column(String(32), nullable=False, default="") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py index d36a829..b382849 100644 --- a/packages/meshbay-hub/src/meshbay_hub/mail.py +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -469,7 +469,7 @@ def send_invite_link(to: str, link: str, inviter: str, group_name: str) -> None: "\n" f"{link}\n" "\n" - "It works once, and only for an account registered with this address.\n" + "It works once, and for seven days.\n" "If you did not expect it, you can ignore this message.\n" ) _send(msg, purpose="invite_link") 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 529a33d..a510e63 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -897,10 +897,11 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // ── Invitation links (docs/MESHBAY_DESIGN.md §3.4) ────────────────── // // Two halves, in the order that leaves nothing half-made: the node's code - // first, then the hub's ticket bound to the address; a ticket the hub then - // refuses takes the code back with it, since a code nobody can reach the node - // with only occupies one of the group's twenty places. The code reaches the - // hub only when the box asks the hub to write the mail. + // first, then the hub's ticket; a ticket the hub then refuses takes the code + // back with it, since a code nobody can reach the node with only occupies one + // of the group's twenty places. The address is optional and binds nothing: + // the link is for whoever opens it first, so it can go by any messaging app. + // The code reaches the hub only when the box asks the hub to write the mail. const [linkEmail, setLinkEmail] = useState(''); const [linking, setLinking] = useState(false); const [linkError, setLinkError] = useState(''); @@ -932,7 +933,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, const doCreateLink = useCallback(async (e) => { e.preventDefault(); const email = linkEmail.trim(); - if (!email) return; + const mailIt = inviteByEmail && Boolean(email); setLinking(true); setLinkError(''); setNewLink(null); @@ -951,8 +952,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, method: 'POST', token, body: { email, expires_at: node.expires_at, node_invite_id: node.invite_id, - send_email: inviteByEmail, - ...(inviteByEmail ? { node_pk: n, code: node.code } : {}), + send_email: mailIt, + ...(mailIt ? { node_pk: n, code: node.code } : {}), }, }); } catch (err) { @@ -960,7 +961,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, throw err; } setNewLink({ - email, link: inviteLinkHere({ g: groupId, t: ticket.ticket, n, c: node.code }), emailStatus: ticket.email_status, }); @@ -1004,6 +1004,16 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } catch { /* the field is selectable */ } }, [newLink]); + // The system share sheet, where there is one (phones mostly): the way a link + // reaches a messaging app without a round trip through the clipboard. + const canShare = typeof navigator !== 'undefined' && typeof navigator.share === 'function'; + const shareLink = useCallback(async () => { + if (!newLink) return; + try { + await navigator.share({ title: t('members.link_share_title'), url: newLink.link }); + } catch { /* dismissed; the field and Copy are still there */ } + }, [newLink]); + if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`; const isOwner = Boolean(isAdmin); @@ -1067,13 +1077,17 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, <form onSubmit=${doCreateLink}> ${newLink && html` <div class="success-msg" style="margin-bottom:8px"> - <p>${t('members.link_ready', { email: newLink.email })}</p> + <p>${t('members.link_ready')}</p> <div class="form-row"> <input type="text" readonly value=${newLink.link} onFocus=${e => e.target.select()} /> <button class="admin-btn" type="button" onClick=${copyLink}> ${linkCopied ? t('members.link_copied') : t('members.link_copy')} </button> + ${canShare && html` + <button class="admin-btn" type="button" onClick=${shareLink}> + ${t('members.link_share')} + </button>`} </div> ${newLink.emailStatus === 'sent' ? html`<p style="color:var(--success)">${t('members.link_email_sent')}</p>` @@ -1085,7 +1099,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, <div class="form-row"> <input type="email" placeholder=${t('members.link_email_placeholder')} value=${linkEmail} onInput=${e => setLinkEmail(e.target.value)} - disabled=${!connected || !operatorPaired} required /> + disabled=${!connected || !operatorPaired} /> <button class="admin-btn" type="submit" disabled=${linking || !connected || !operatorPaired}> ${linking ? '...' : t('members.link_btn')} @@ -1104,7 +1118,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, ${links.map(l => html` <li key=${l.link_id} style="display:flex;gap:8px;align-items:center; flex-wrap:wrap;word-break:break-word;margin:4px 0"> - <span>${l.email}</span> + <span>${l.email || t('members.link_unlabelled', + { date: new Date(l.created_at).toLocaleDateString() })}</span> <span style="color:var(--text-dim)"> ${l.status === 'expired' ? t('members.link_status_expired') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js b/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js index dd678aa..41b4c4c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js @@ -25,10 +25,7 @@ export function InvitePage({ user, onJoined }) { const [error, setError] = useState(''); const refused = useCallback((err) => { - if (err.message === 'invite_other_account') { - // Kept: signing out and in again as the right account is the fix. - setPhase('other'); - } else if (err.message === 'invite_not_valid') { + if (err.message === 'invite_not_valid') { clearPending(); setPhase('invalid'); } else { @@ -97,12 +94,6 @@ export function InvitePage({ user, onJoined }) { <button class="btn btn-primary" onClick=${join}>${t('invite.join')}</button> <button class="btn btn-secondary" onClick=${ignore}>${t('invite.ignore')}</button> </div>`; - } else if (phase === 'other') { - body = html` - <p class="error-msg">${t('invite.other_account')}</p> - <div style="margin-top:16px"> - <button class="btn btn-secondary" onClick=${ignore}>${t('invite.ignore')}</button> - </div>`; } else if (phase === 'invalid') { body = html`<p class="error-msg">${t('invite.invalid')}</p>`; } else { 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 215f596..7bd5169 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -698,10 +698,10 @@ export default { 'members.invite_email_failed': 'Die E-Mail konnte nicht gesendet werden — bitte teilen Sie den Code manuell mit.', 'members.invite_email_opt': 'Einladung per E-Mail senden (kann im Spam landen)', 'members.link_title': "Per Link einladen", - 'members.link_hint': "Für jemanden, der vielleicht noch kein Konto hat. Der Link funktioniert einmal und nur für ein Konto mit dieser Adresse.", - 'members.link_email_placeholder': "E-Mail-Adresse", + 'members.link_hint': "Für jemanden, der vielleicht noch kein Konto hat. Verschicken Sie ihn, wie Sie möchten — Messenger, SMS. Der Link funktioniert einmal, sieben Tage lang, für die Person, die ihn zuerst öffnet.", + 'members.link_email_placeholder': "E-Mail-Adresse (optional)", 'members.link_btn': "Link erstellen", - 'members.link_ready': "Einladungslink für {email}:", + 'members.link_ready': "Einladungslink — 7 Tage gültig, einmalig verwendbar:", 'members.link_copy': "Kopieren", 'members.link_copied': "Kopiert", 'members.link_email_sent': "Der Link wurde per E-Mail gesendet.", @@ -710,9 +710,12 @@ export default { 'members.link_status_expired': "abgelaufen", 'members.link_expires': "läuft ab am {date}", 'members.link_cancel': "Abbrechen", + 'members.link_share': "Teilen", + 'members.link_share_title': "Einladung", + 'members.link_unlabelled': "Link vom {date}", 'invite.title': "Sie wurden eingeladen", 'invite.none': "In diesem Tab wartet keine Einladung. Öffnen Sie den erhaltenen Link erneut.", - 'invite.signed_out': "Jemand hat Sie in eine Gruppe auf diesem Hub eingeladen. Erstellen Sie ein Konto mit der E-Mail-Adresse, an die die Einladung ging, oder melden Sie sich an, falls Sie bereits eines haben.", + 'invite.signed_out': "Jemand hat Sie in eine Gruppe auf diesem Hub eingeladen. Erstellen Sie ein Konto, oder melden Sie sich an, falls Sie bereits eines haben.", 'invite.register': "Konto erstellen", 'invite.signin': "Anmelden", 'invite.confirm': "{inviter} lädt Sie in {group} ein.", @@ -720,7 +723,6 @@ export default { 'invite.ignore': "Ignorieren", 'invite.open': "Gruppe öffnen", 'invite.already_member': "Sie sind bereits Mitglied von {group}.", - 'invite.other_account': "Diese Einladung wurde an eine andere E-Mail-Adresse gesendet. Melden Sie sich mit dem Konto dieser Adresse an — Aliasse und Punkte müssen genau übereinstimmen.", 'invite.invalid': "Diese Einladung ist nicht mehr gültig: Sie wurde verwendet, widerrufen oder ist abgelaufen. Bitten Sie um eine neue.", 'invite.joining': "Beitritt…", 'invite.after_register': "Melden Sie sich an, um der Gruppe beizutreten, in die Sie eingeladen wurden.", 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 4c4cf38..0c9cb19 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -814,10 +814,10 @@ export default { 'members.invite_email_failed': 'Could not send the email — share the code manually.', 'members.invite_email_opt': 'Send the invitation by e-mail (may land in spam)', 'members.link_title': "Invite by link", - 'members.link_hint': "For someone who may not have an account yet. The link works once, and only for an account registered with this address.", - 'members.link_email_placeholder': "E-mail address", + 'members.link_hint': "For someone who may not have an account yet. Send it however you like — a messaging app, a text. It works once, for seven days, for whoever opens it first.", + 'members.link_email_placeholder': "E-mail address (optional)", 'members.link_btn': "Create link", - 'members.link_ready': "Invitation link for {email}:", + 'members.link_ready': "Invitation link — valid 7 days, single use:", 'members.link_copy': "Copy", 'members.link_copied': "Copied", 'members.link_email_sent': "The link has been sent by e-mail.", @@ -826,9 +826,12 @@ export default { 'members.link_status_expired': "expired", 'members.link_expires': "expires {date}", 'members.link_cancel': "Cancel", + 'members.link_share': "Share", + 'members.link_share_title': "Invitation", + 'members.link_unlabelled': "link created {date}", 'invite.title': "You have been invited", 'invite.none': "There is no invitation waiting in this tab. Open the link you received again.", - 'invite.signed_out': "Someone invited you to a group on this hub. Create an account with the e-mail address the invitation was sent to, or sign in if you already have one.", + 'invite.signed_out': "Someone invited you to a group on this hub. Create an account, or sign in if you already have one.", 'invite.register': "Create an account", 'invite.signin': "Sign in", 'invite.confirm': "{inviter} invites you to join {group}.", @@ -836,7 +839,6 @@ export default { 'invite.ignore': "Ignore", 'invite.open': "Open the group", 'invite.already_member': "You are already a member of {group}.", - 'invite.other_account': "This invitation was sent to another e-mail address. Sign in with the account registered with that address — aliases and dots must match exactly.", 'invite.invalid': "This invitation is no longer valid: it has been used, cancelled or has expired. Ask for a new one.", 'invite.joining': "Joining…", 'invite.after_register': "Sign in to join the group you were invited to.", 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 9f5158f..1399a83 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -693,10 +693,10 @@ export default { 'members.invite_email_failed': 'No se pudo enviar el correo — comparta el código manualmente.', 'members.invite_email_opt': 'Enviar la invitación por correo (puede llegar a spam)', 'members.link_title': "Invitar con un enlace", - 'members.link_hint': "Para alguien que quizá aún no tenga cuenta. El enlace sirve una vez, y solo para una cuenta registrada con esta dirección.", - 'members.link_email_placeholder': "Dirección de correo", + 'members.link_hint': "Para alguien que quizá aún no tenga cuenta. Envíelo como prefiera — mensajería, SMS. El enlace sirve una sola vez, durante siete días, para quien lo abra primero.", + 'members.link_email_placeholder': "Dirección de correo (opcional)", 'members.link_btn': "Crear enlace", - 'members.link_ready': "Enlace de invitación para {email}:", + 'members.link_ready': "Enlace de invitación — válido 7 días, un solo uso:", 'members.link_copy': "Copiar", 'members.link_copied': "Copiado", 'members.link_email_sent': "El enlace se ha enviado por correo.", @@ -705,9 +705,12 @@ export default { 'members.link_status_expired': "caducado", 'members.link_expires': "caduca el {date}", 'members.link_cancel': "Cancelar", + 'members.link_share': "Compartir", + 'members.link_share_title': "Invitación", + 'members.link_unlabelled': "enlace creado el {date}", 'invite.title': "Le han invitado", 'invite.none': "No hay ninguna invitación esperando en esta pestaña. Vuelva a abrir el enlace que recibió.", - 'invite.signed_out': "Alguien le ha invitado a un grupo en este hub. Cree una cuenta con la dirección de correo a la que se envió la invitación, o inicie sesión si ya tiene una.", + 'invite.signed_out': "Alguien le ha invitado a un grupo en este hub. Cree una cuenta, o inicie sesión si ya tiene una.", 'invite.register': "Crear una cuenta", 'invite.signin': "Iniciar sesión", 'invite.confirm': "{inviter} le invita a unirse a {group}.", @@ -715,7 +718,6 @@ export default { 'invite.ignore': "Ignorar", 'invite.open': "Abrir el grupo", 'invite.already_member': "Ya es miembro de {group}.", - 'invite.other_account': "Esta invitación se envió a otra dirección de correo. Inicie sesión con la cuenta registrada con esa dirección — los alias y los puntos deben coincidir exactamente.", 'invite.invalid': "Esta invitación ya no es válida: se ha usado, cancelado o ha caducado. Pida una nueva.", 'invite.joining': "Uniéndose…", 'invite.after_register': "Inicie sesión para unirse al grupo al que le invitaron.", 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 c072d5b..ae278d9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -696,10 +696,10 @@ export default { 'members.invite_email_failed': "Impossible d’envoyer l’e-mail — partagez le code manuellement.", 'members.invite_email_opt': 'Envoyer l’invitation par e-mail (risque d’arriver dans les spams)', 'members.link_title': "Inviter par lien", - 'members.link_hint': "Pour quelqu’un qui n’a peut-être pas encore de compte. Le lien ne sert qu’une fois, et seulement pour un compte créé avec cette adresse.", - 'members.link_email_placeholder': "Adresse e-mail", + 'members.link_hint': "Pour quelqu’un qui n’a peut-être pas encore de compte. Envoyez-le comme vous voulez — messagerie, SMS. Le lien ne sert qu’une fois, pendant sept jours, à la première personne qui l’ouvre.", + 'members.link_email_placeholder': "Adresse e-mail (facultative)", 'members.link_btn': "Créer le lien", - 'members.link_ready': "Lien d’invitation pour {email} :", + 'members.link_ready': "Lien d’invitation — valable 7 jours, usage unique :", 'members.link_copy': "Copier", 'members.link_copied': "Copié", 'members.link_email_sent': "Le lien a été envoyé par e-mail.", @@ -708,9 +708,12 @@ export default { 'members.link_status_expired': "expiré", 'members.link_expires': "expire le {date}", 'members.link_cancel': "Annuler", + 'members.link_share': "Partager", + 'members.link_share_title': "Invitation", + 'members.link_unlabelled': "lien créé le {date}", 'invite.title': "Vous êtes invité", 'invite.none': "Aucune invitation n’attend dans cet onglet. Rouvrez le lien que vous avez reçu.", - 'invite.signed_out': "Quelqu’un vous a invité dans un groupe sur ce hub. Créez un compte avec l’adresse e-mail à laquelle l’invitation a été envoyée, ou connectez-vous si vous en avez déjà un.", + 'invite.signed_out': "Quelqu’un vous a invité dans un groupe sur ce hub. Créez un compte, ou connectez-vous si vous en avez déjà un.", 'invite.register': "Créer un compte", 'invite.signin': "Se connecter", 'invite.confirm': "{inviter} vous invite à rejoindre {group}.", @@ -718,7 +721,6 @@ export default { 'invite.ignore': "Ignorer", 'invite.open': "Ouvrir le groupe", 'invite.already_member': "Vous êtes déjà membre de {group}.", - 'invite.other_account': "Cette invitation a été envoyée à une autre adresse e-mail. Connectez-vous avec le compte créé avec cette adresse — les alias et les points doivent correspondre exactement.", 'invite.invalid': "Cette invitation n’est plus valable : elle a été utilisée, annulée ou a expiré. Demandez-en une nouvelle.", 'invite.joining': "Adhésion…", 'invite.after_register': "Connectez-vous pour rejoindre le groupe auquel vous avez été invité.", 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 28c6a3d..f0fb0d1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -696,10 +696,10 @@ export default { 'members.invite_email_failed': "Impossibile inviare l'e-mail — condivida il codice manualmente.", 'members.invite_email_opt': 'Invia l’invito per e-mail (potrebbe finire nello spam)', 'members.link_title': "Invita tramite link", - 'members.link_hint': "Per chi forse non ha ancora un account. Il link funziona una volta, e solo per un account registrato con questo indirizzo.", - 'members.link_email_placeholder': "Indirizzo e-mail", + 'members.link_hint': "Per chi forse non ha ancora un account. Lo invii come preferisce — messaggistica, SMS. Il link funziona una sola volta, per sette giorni, per chi lo apre per primo.", + 'members.link_email_placeholder': "Indirizzo e-mail (facoltativo)", 'members.link_btn': "Crea link", - 'members.link_ready': "Link d’invito per {email}:", + 'members.link_ready': "Link di invito — valido 7 giorni, uso singolo:", 'members.link_copy': "Copia", 'members.link_copied': "Copiato", 'members.link_email_sent': "Il link è stato inviato per e-mail.", @@ -708,9 +708,12 @@ export default { 'members.link_status_expired': "scaduto", 'members.link_expires': "scade il {date}", 'members.link_cancel': "Annulla", + 'members.link_share': "Condividi", + 'members.link_share_title': "Invito", + 'members.link_unlabelled': "link creato il {date}", 'invite.title': "È stato invitato", 'invite.none': "Nessun invito in attesa in questa scheda. Riapra il link ricevuto.", - 'invite.signed_out': "Qualcuno l’ha invitata in un gruppo su questo hub. Crei un account con l’indirizzo e-mail a cui è stato inviato l’invito, o acceda se ne ha già uno.", + 'invite.signed_out': "Qualcuno l’ha invitata in un gruppo su questo hub. Crei un account, o acceda se ne ha già uno.", 'invite.register': "Crea un account", 'invite.signin': "Accedi", 'invite.confirm': "{inviter} la invita a unirsi a {group}.", @@ -718,7 +721,6 @@ export default { 'invite.ignore': "Ignora", 'invite.open': "Apri il gruppo", 'invite.already_member': "È già membro di {group}.", - 'invite.other_account': "Questo invito è stato inviato a un altro indirizzo e-mail. Acceda con l’account registrato con quell’indirizzo — alias e punti devono corrispondere esattamente.", 'invite.invalid': "Questo invito non è più valido: è stato usato, annullato o è scaduto. Ne chieda uno nuovo.", 'invite.joining': "Adesione…", 'invite.after_register': "Acceda per unirsi al gruppo a cui è stato invitato.", 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 f357881..560332d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -688,10 +688,10 @@ export default { 'members.invite_email_failed': 'メールを送信できませんでした。コードを手動で共有してください。', 'members.invite_email_opt': '招待をメールで送信(迷惑メールに入る場合があります)', 'members.link_title': "リンクで招待", - 'members.link_hint': "まだアカウントを持っていない人向けです。リンクは1回だけ、このアドレスで登録したアカウントでのみ使えます。", - 'members.link_email_placeholder': "メールアドレス", + 'members.link_hint': "まだアカウントを持っていないかもしれない人向けです。メッセージアプリやSMSなど、お好きな方法で送ってください。リンクは7日間有効で、最初に開いた人が一度だけ使えます。", + 'members.link_email_placeholder': "メールアドレス(任意)", 'members.link_btn': "リンクを作成", - 'members.link_ready': "{email} への招待リンク:", + 'members.link_ready': "招待リンク — 7日間有効、1回限り:", 'members.link_copy': "コピー", 'members.link_copied': "コピーしました", 'members.link_email_sent': "リンクをメールで送信しました。", @@ -700,9 +700,12 @@ export default { 'members.link_status_expired': "期限切れ", 'members.link_expires': "{date} に期限切れ", 'members.link_cancel': "取り消す", + 'members.link_share': "共有", + 'members.link_share_title': "招待", + 'members.link_unlabelled': "{date} に作成したリンク", 'invite.title': "招待されています", 'invite.none': "このタブで待機中の招待はありません。受け取ったリンクをもう一度開いてください。", - 'invite.signed_out': "このハブのグループに招待されています。招待が送られたメールアドレスでアカウントを作成するか、すでにお持ちならサインインしてください。", + 'invite.signed_out': "このハブのグループに招待されています。アカウントを作成するか、すでにお持ちならサインインしてください。", 'invite.register': "アカウントを作成", 'invite.signin': "サインイン", 'invite.confirm': "{inviter} さんが {group} への参加に招待しています。", @@ -710,7 +713,6 @@ export default { 'invite.ignore': "無視", 'invite.open': "グループを開く", 'invite.already_member': "すでに {group} のメンバーです。", - 'invite.other_account': "この招待は別のメールアドレスに送られました。そのアドレスで登録したアカウントでサインインしてください。エイリアスやドットも完全に一致する必要があります。", 'invite.invalid': "この招待は無効です。使用済み、取り消し済み、または期限切れです。新しい招待を依頼してください。", 'invite.joining': "参加しています…", 'invite.after_register': "招待されたグループに参加するにはサインインしてください。", 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 64e1f00..adb94c6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -697,10 +697,10 @@ export default { 'members.invite_email_failed': 'Kon de e-mail niet verzenden — deel de code handmatig.', 'members.invite_email_opt': 'Uitnodiging per e-mail versturen (kan in spam belanden)', 'members.link_title': "Uitnodigen via link", - 'members.link_hint': "Voor iemand die misschien nog geen account heeft. De link werkt één keer, en alleen voor een account met dit adres.", - 'members.link_email_placeholder': "E-mailadres", + 'members.link_hint': "Voor iemand die misschien nog geen account heeft. Verstuur hem zoals u wilt — berichtenapp, sms. De link werkt één keer, zeven dagen lang, voor wie hem als eerste opent.", + 'members.link_email_placeholder': "E-mailadres (optioneel)", 'members.link_btn': "Link maken", - 'members.link_ready': "Uitnodigingslink voor {email}:", + 'members.link_ready': "Uitnodigingslink — 7 dagen geldig, eenmalig:", 'members.link_copy': "Kopiëren", 'members.link_copied': "Gekopieerd", 'members.link_email_sent': "De link is per e-mail verstuurd.", @@ -709,9 +709,12 @@ export default { 'members.link_status_expired': "verlopen", 'members.link_expires': "verloopt op {date}", 'members.link_cancel': "Annuleren", + 'members.link_share': "Delen", + 'members.link_share_title': "Uitnodiging", + 'members.link_unlabelled': "link gemaakt op {date}", 'invite.title': "U bent uitgenodigd", 'invite.none': "Er wacht geen uitnodiging in dit tabblad. Open de ontvangen link opnieuw.", - 'invite.signed_out': "Iemand heeft u uitgenodigd voor een groep op deze hub. Maak een account aan met het e-mailadres waarnaar de uitnodiging is gestuurd, of meld u aan als u er al een hebt.", + 'invite.signed_out': "Iemand heeft u uitgenodigd voor een groep op deze hub. Maak een account aan, of meld u aan als u er al een hebt.", 'invite.register': "Account maken", 'invite.signin': "Aanmelden", 'invite.confirm': "{inviter} nodigt u uit voor {group}.", @@ -719,7 +722,6 @@ export default { 'invite.ignore': "Negeren", 'invite.open': "Groep openen", 'invite.already_member': "U bent al lid van {group}.", - 'invite.other_account': "Deze uitnodiging is naar een ander e-mailadres gestuurd. Meld u aan met het account van dat adres — aliassen en punten moeten exact overeenkomen.", 'invite.invalid': "Deze uitnodiging is niet meer geldig: ze is gebruikt, geannuleerd of verlopen. Vraag een nieuwe.", 'invite.joining': "Deelnemen…", 'invite.after_register': "Meld u aan om deel te nemen aan de groep waarvoor u bent uitgenodigd.", 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 1d5f05f..fb5a4ed 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -709,10 +709,10 @@ export default { 'members.invite_email_failed': 'Nie udało się wysłać e-maila — przekaż kod ręcznie.', 'members.invite_email_opt': 'Wyślij zaproszenie e-mailem (może trafić do spamu)', 'members.link_title': "Zaproś linkiem", - 'members.link_hint': "Dla kogoś, kto może jeszcze nie mieć konta. Link działa raz i tylko dla konta założonego na ten adres.", - 'members.link_email_placeholder': "Adres e-mail", + 'members.link_hint': "Dla kogoś, kto może jeszcze nie mieć konta. Wyślij go, jak chcesz — komunikatorem, SMS-em. Link działa raz, przez siedem dni, dla osoby, która otworzy go pierwsza.", + 'members.link_email_placeholder': "Adres e-mail (opcjonalnie)", 'members.link_btn': "Utwórz link", - 'members.link_ready': "Link zaproszenia dla {email}:", + 'members.link_ready': "Link z zaproszeniem — ważny 7 dni, jednorazowy:", 'members.link_copy': "Kopiuj", 'members.link_copied': "Skopiowano", 'members.link_email_sent': "Link został wysłany e-mailem.", @@ -721,9 +721,12 @@ export default { 'members.link_status_expired': "wygasł", 'members.link_expires': "wygasa {date}", 'members.link_cancel': "Anuluj", + 'members.link_share': "Udostępnij", + 'members.link_share_title': "Zaproszenie", + 'members.link_unlabelled': "link utworzony {date}", 'invite.title': "Otrzymano zaproszenie", 'invite.none': "W tej karcie nie czeka żadne zaproszenie. Otwórz ponownie otrzymany link.", - 'invite.signed_out': "Ktoś zaprosił cię do grupy na tym hubie. Załóż konto na adres e-mail, na który wysłano zaproszenie, albo zaloguj się, jeśli już je masz.", + 'invite.signed_out': "Ktoś zaprosił cię do grupy na tym hubie. Załóż konto albo zaloguj się, jeśli już je masz.", 'invite.register': "Załóż konto", 'invite.signin': "Zaloguj się", 'invite.confirm': "{inviter} zaprasza cię do {group}.", @@ -731,7 +734,6 @@ export default { 'invite.ignore': "Ignoruj", 'invite.open': "Otwórz grupę", 'invite.already_member': "Jesteś już członkiem {group}.", - 'invite.other_account': "To zaproszenie wysłano na inny adres e-mail. Zaloguj się na konto założone na ten adres — aliasy i kropki muszą się dokładnie zgadzać.", 'invite.invalid': "To zaproszenie jest już nieważne: zostało użyte, anulowane lub wygasło. Poproś o nowe.", 'invite.joining': "Dołączanie…", 'invite.after_register': "Zaloguj się, aby dołączyć do grupy, do której cię zaproszono.", 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 c2cb35e..7bd11f6 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 @@ -695,10 +695,10 @@ export default { 'members.invite_email_failed': 'Não foi possível enviar o e-mail — compartilhe o código manualmente.', 'members.invite_email_opt': 'Enviar o convite por e-mail (pode cair no spam)', 'members.link_title': "Convidar por link", - 'members.link_hint': "Para alguém que talvez ainda não tenha conta. O link funciona uma vez, e só para uma conta registrada com este endereço.", - 'members.link_email_placeholder': "Endereço de e-mail", + 'members.link_hint': "Para alguém que talvez ainda não tenha conta. Envie como preferir — aplicativo de mensagens, SMS. O link funciona uma vez, por sete dias, para quem abrir primeiro.", + 'members.link_email_placeholder': "Endereço de e-mail (opcional)", 'members.link_btn': "Criar link", - 'members.link_ready': "Link de convite para {email}:", + 'members.link_ready': "Link de convite — válido por 7 dias, uso único:", 'members.link_copy': "Copiar", 'members.link_copied': "Copiado", 'members.link_email_sent': "O link foi enviado por e-mail.", @@ -707,9 +707,12 @@ export default { 'members.link_status_expired': "expirado", 'members.link_expires': "expira em {date}", 'members.link_cancel': "Cancelar", + 'members.link_share': "Compartilhar", + 'members.link_share_title': "Convite", + 'members.link_unlabelled': "link criado em {date}", 'invite.title': "Você foi convidado", 'invite.none': "Não há convite aguardando nesta aba. Abra novamente o link que recebeu.", - 'invite.signed_out': "Alguém convidou você para um grupo neste hub. Crie uma conta com o endereço de e-mail para o qual o convite foi enviado, ou entre se já tiver uma.", + 'invite.signed_out': "Alguém convidou você para um grupo neste hub. Crie uma conta, ou entre se já tiver uma.", 'invite.register': "Criar uma conta", 'invite.signin': "Entrar", 'invite.confirm': "{inviter} convida você para participar de {group}.", @@ -717,7 +720,6 @@ export default { 'invite.ignore': "Ignorar", 'invite.open': "Abrir o grupo", 'invite.already_member': "Você já é membro de {group}.", - 'invite.other_account': "Este convite foi enviado para outro endereço de e-mail. Entre com a conta registrada com esse endereço — aliases e pontos devem coincidir exatamente.", 'invite.invalid': "Este convite não é mais válido: foi usado, cancelado ou expirou. Peça um novo.", 'invite.joining': "Entrando…", 'invite.after_register': "Entre para participar do grupo para o qual foi convidado.", 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 37c57aa..5509332 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 @@ -677,10 +677,10 @@ export default { 'members.invite_email_failed': '无法发送邮件——请手动分享验证码。', 'members.invite_email_opt': '通过电子邮件发送邀请(可能进入垃圾邮件)', 'members.link_title': "通过链接邀请", - 'members.link_hint': "适用于可能还没有账户的人。该链接只能使用一次,且仅限使用此地址注册的账户。", - 'members.link_email_placeholder': "电子邮件地址", + 'members.link_hint': "适用于可能还没有账户的人。可以用任何方式发送——即时通讯应用、短信。链接仅可使用一次,有效期七天,归最先打开的人。", + 'members.link_email_placeholder': "电子邮件地址(可选)", 'members.link_btn': "创建链接", - 'members.link_ready': "发给 {email} 的邀请链接:", + 'members.link_ready': "邀请链接——有效期 7 天,仅限一次:", 'members.link_copy': "复制", 'members.link_copied': "已复制", 'members.link_email_sent': "链接已通过电子邮件发送。", @@ -689,9 +689,12 @@ export default { 'members.link_status_expired': "已过期", 'members.link_expires': "{date} 过期", 'members.link_cancel': "取消", + 'members.link_share': "分享", + 'members.link_share_title': "邀请", + 'members.link_unlabelled': "{date} 创建的链接", 'invite.title': "您收到了邀请", 'invite.none': "此标签页中没有待处理的邀请。请重新打开您收到的链接。", - 'invite.signed_out': "有人邀请您加入此中心上的一个群组。请使用收到邀请的电子邮件地址创建账户,如果已有账户请登录。", + 'invite.signed_out': "有人邀请您加入此中心上的一个群组。请创建账户,如果已有账户请登录。", 'invite.register': "创建账户", 'invite.signin': "登录", 'invite.confirm': "{inviter} 邀请您加入 {group}。", @@ -699,7 +702,6 @@ export default { 'invite.ignore': "忽略", 'invite.open': "打开群组", 'invite.already_member': "您已经是 {group} 的成员。", - 'invite.other_account': "此邀请发送到了另一个电子邮件地址。请使用该地址注册的账户登录——别名和点号必须完全一致。", 'invite.invalid': "此邀请已失效:已被使用、取消或已过期。请索取新的邀请。", 'invite.joining': "正在加入…", 'invite.after_register': "登录以加入您受邀的群组。", diff --git a/packages/meshbay-hub/tests/test_group_purge.py b/packages/meshbay-hub/tests/test_group_purge.py index 9c6a664..40d2d54 100644 --- a/packages/meshbay-hub/tests/test_group_purge.py +++ b/packages/meshbay-hub/tests/test_group_purge.py @@ -84,7 +84,7 @@ async def _group_with_everything(client, db, owner_token: str, member: str, name ip_address="192.0.2.1")) owner_id = (await db.execute(select(Group.admin_id).where(Group.id == gid))).scalar_one() db.add(GroupInviteLink(group_id=gid, created_by=owner_id, ticket_hash=gid[:8] * 8, - email_hash="1" * 64, email_masked="m***@e***.com", + email_masked="m***@e***.com", expires_at=datetime.now(UTC) + timedelta(days=1), redeemed_by=member_id, redeemed_at=datetime.now(UTC))) await db.commit() diff --git a/packages/meshbay-hub/tests/test_invite_link_client.py b/packages/meshbay-hub/tests/test_invite_link_client.py index 2223ad8..313e224 100644 --- a/packages/meshbay-hub/tests/test_invite_link_client.py +++ b/packages/meshbay-hub/tests/test_invite_link_client.py @@ -188,7 +188,9 @@ def test_the_members_tab_sends_the_code_only_for_the_mail(): sends = [m.start() for m in re.finditer(r"code: node\.code", settings)] assert len(sends) == 1 before = settings[settings.rfind("\n", 0, sends[0] - 200):sends[0]] - assert "inviteByEmail ?" in before, "the code reaches the hub only when the box asks" + assert "mailIt ?" in before, "the code reaches the hub only when the box asks" + assert "const mailIt = inviteByEmail && Boolean(email);" in settings, ( + "and the box asks only when there is an address to mail") def test_signing_out_forgets_the_invitation(): diff --git a/packages/meshbay-hub/tests/test_invite_links.py b/packages/meshbay-hub/tests/test_invite_links.py index 461cf8a..2217cfe 100644 --- a/packages/meshbay-hub/tests/test_invite_links.py +++ b/packages/meshbay-hub/tests/test_invite_links.py @@ -63,22 +63,16 @@ def sent(monkeypatch): # ── Who gets in ────────────────────────────────────────────────────────────── @pytest.mark.asyncio -async def test_the_addressed_account_joins_and_nobody_else(client, db_session): +async def test_whoever_opens_it_first_joins_and_nobody_after(client, db_session): + # A link travels by any messaging app, so the address the owner typed binds + # nothing: an account registered with another one redeems it. owner = await _account(client, "link_owner") gid = await _group(client, owner) r = await _link(client, owner, gid, email="Invitee@Example.test") assert r.status_code == 201, r.text ticket = r.json()["ticket"] - mallory = await _account(client, "link_mallory") - for route in ("preview", "redeem"): - r = await client.post(f"/v1/invite-links/{route}", json={"ticket": ticket}, - headers=mallory["h"]) - assert r.status_code == 403 and r.json()["detail"] == "invite_other_account" - assert "invitee" not in r.text.lower(), "the refusal must not name the address" - - # Registered with the address the owner typed, case aside. - invitee = await _account(client, "link_invitee", email="invitee@example.test") + invitee = await _account(client, "link_invitee", email="elsewhere@example.test") r = await client.post("/v1/invite-links/preview", json={"ticket": ticket}, headers=invitee["h"]) assert r.status_code == 200, r.text @@ -102,11 +96,30 @@ async def test_the_addressed_account_joins_and_nobody_else(client, db_session): GroupMember.group_id == gid))).scalars().all() assert len(rows) == 2 - # And the one who comes after, even with the right address, gets nothing: - # a twin account cannot exist (addresses are unique), so try the other. - r = await client.post("/v1/invite-links/redeem", json={"ticket": ticket}, - headers=mallory["h"]) - assert r.status_code == 404 + # Whoever comes after, even with the address the owner typed, gets nothing. + late = await _account(client, "link_late", email="invitee@example.test") + for route in ("preview", "redeem"): + r = await client.post(f"/v1/invite-links/{route}", json={"ticket": ticket}, + headers=late["h"]) + assert r.status_code == 404 and r.json()["detail"] == "invite_not_valid" + + +@pytest.mark.asyncio +async def test_a_link_needs_no_address_unless_it_is_mailed(client, db_session, sent): + owner = await _account(client, "noaddr_owner") + gid = await _group(client, owner) + r = await _link(client, owner, gid, email="") + assert r.status_code == 201, r.text + assert r.json()["email_status"] == "not_requested" + row = (await db_session.execute(select(GroupInviteLink))).scalar_one() + assert row.email_masked is None + listed = (await client.get(f"/v1/groups/{gid}/invite-links", + headers=owner["h"])).json()["links"] + assert [link["email"] for link in listed] == [""] + + r = await _link(client, owner, gid, email="", send_email=True, + node_pk=NODE_PK, code=CODE) + assert r.status_code == 422 and sent == [] @pytest.mark.asyncio @@ -115,7 +128,7 @@ async def test_a_ticket_is_stored_only_as_a_hash(client, db_session): gid = await _group(client, owner) ticket = (await _link(client, owner, gid)).json()["ticket"] row = (await db_session.execute(select(GroupInviteLink))).scalar_one() - assert ticket not in (row.ticket_hash, row.email_masked, row.email_hash) + assert ticket not in (row.ticket_hash, row.email_masked) assert row.ticket_hash == invite_links.ticket_hash(ticket) assert "invitee@" not in row.email_masked diff --git a/packages/meshbay-node/src/meshbay_node/cli/members.py b/packages/meshbay-node/src/meshbay_node/cli/members.py index ba68056..5d26bd0 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/members.py +++ b/packages/meshbay-node/src/meshbay_node/cli/members.py @@ -62,18 +62,15 @@ def member(args) -> None: print("which is what turning the old switch off meant.") sys.exit(1) - if not args.target: - print(f"usage: meshbay-node member {sub} <username>") - sys.exit(1) - if sub == "invite" and args.link: - # A link for someone who may have no account yet, bound to their - # address on the hub. The code and the ticket are both in it, so - # it goes to them and to nobody else — the CLI mails nothing. + # A link for someone who may have no account yet, redeemable by the + # first account that opens it. The code and the ticket are both in + # it, so it goes to them and to nobody else — the CLI mails nothing. + # The address, if given, only labels the link in the owner's list. group_id = _resolve_group(cfg, args.group) + query = f"?email={quote(args.target)}" if args.target else "" out = _daemon_api( - cfg, f"/api/groups/{group_id}/invite-links?email={quote(args.target)}", - method="POST") + cfg, f"/api/groups/{group_id}/invite-links{query}", method="POST") from meshbay_node.roster import write_code_file write_code_file(cfg.data_dir, out["link"], out.get("expires_at", ""), name="invite-link") @@ -81,11 +78,15 @@ def member(args) -> None: print(f"valid until {out.get('expires_at', '?')}") print(f"cancel with meshbay-node member cancel {out['invite_id']}") print() - print(f"Send it to {args.target} yourself. It works once, and only for an") - print("account registered with that address: they open it, create their") - print("account or sign in, and land in the group without typing a code.") + print("Send it yourself, by any messaging app. It works once, for seven") + print("days, for whoever opens it first: they create their account or") + print("sign in, and land in the group without typing a code.") return + if not args.target: + print(f"usage: meshbay-node member {sub} <username>") + sys.exit(1) + if sub == "invite": group_id = _resolve_group(cfg, args.group) out = _daemon_api( diff --git a/packages/meshbay-node/src/meshbay_node/cli/parser.py b/packages/meshbay-node/src/meshbay_node/cli/parser.py index c1f15be..29a9f63 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/parser.py +++ b/packages/meshbay-node/src/meshbay_node/cli/parser.py @@ -52,8 +52,8 @@ def build_parser() -> argparse.ArgumentParser: "install|remove|start|stop|status for autostart and " "for service") parser.add_argument("target", nargs="?", - help="username for member invite|revoke|unpin (an e-mail address with " - "--link, a link id for member cancel); group name " + help="username for member invite|revoke|unpin (an optional e-mail label " + "with --link, a link id for member cancel); group name " "for group add; file id for file rm; identifier for " "denylist clear; download cap for transfers set; " "size in GB for transfers max-size") @@ -73,8 +73,8 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--group", default=None, help="group id (optional if only one is configured)") parser.add_argument("--link", action="store_true", - help="member invite: an invitation link for this e-mail " - "address, for someone who may have no account yet") + help="member invite: an invitation link, for someone who " + "may have no account yet (valid 7 days, single use)") parser.add_argument("--writable", action="store_true", default=None, dest="writable", help="root accepts member uploads (root add/set)") diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 06d3503..49bb920 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -266,7 +266,7 @@ class HubClient: async def create_invite_link(self, group_id: str, email: str, expires_at: str, node_invite_id: str) -> dict: """ - The hub's half of an invitation link: a ticket bound to `email`. + The hub's half of an invitation link: a ticket, labelled `email` if any. Never with `send_email`: the hub refuses mail to a node token, and the CLI mails nothing — the operator sends the link (docs/USERGUIDE.md §7). diff --git a/packages/meshbay-node/src/meshbay_node/ops/members.py b/packages/meshbay-node/src/meshbay_node/ops/members.py index 2fd3899..b5da9c0 100644 --- a/packages/meshbay-node/src/meshbay_node/ops/members.py +++ b/packages/meshbay-node/src/meshbay_node/ops/members.py @@ -159,15 +159,13 @@ async def create_link_invite(state: dict, group_id: str, *, Nothing is registered on the hub here, unlike `create_invite`: there is no account to register yet. The hub half is a ticket the inviter's client asks - the hub for, bound to the invitee's address (docs/MESHBAY_DESIGN.md §7.3). + the hub for, redeemable by the first account that opens the link + (docs/MESHBAY_DESIGN.md §7.3). Seven days, whatever `invite_ttl_hours` says. """ roster = _roster(state) _group_ctx(state, group_id) - config = state.get("config") - ttl = (config.node.invite_ttl_hours if config else 168) * 3600 try: - code, invite_id, expires = await roster.create_link_invite( - group_id, created_by, ttl=ttl) + code, invite_id, expires = await roster.create_link_invite(group_id, created_by) except LinkInviteLimit as e: raise OpError(str(e), status=429) from e log.info("Invitation link issued: group=%s invite=%s", group_id[:8], invite_id[:8]) @@ -199,11 +197,12 @@ def _invite_url(hub_url: str, group_id: str, ticket: str, node_pk_b64: str, code return f"{origin}/#/invite?v=1&g={group_id}&t={ticket}&n={n}&c={code}" -async def create_link_invitation(state: dict, group_id: str, email: str, *, +async def create_link_invitation(state: dict, group_id: str, email: str = "", *, created_by: str = "local-cli") -> dict: """ A whole invitation link, from the operator's own machine: the node's code, - then the hub's ticket bound to `email`, then the link. + then the hub's ticket, then the link. `email` is optional and only labels + the link in the owner's list. In that order because the ticket names the code's handle. A ticket the hub refuses takes the code back with it — a code nobody can reach the node with @@ -211,8 +210,8 @@ async def create_link_invitation(state: dict, group_id: str, email: str, *, the operator sends the link. """ email = (email or "").strip() - if "@" not in email: - raise OpError("An invitation link is bound to an e-mail address", status=422) + if email and "@" not in email: + raise OpError("Not an e-mail address", status=422) hub = _hub(state) sk_node = state.get("sk_node") if sk_node is None: diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 80bf16d..8144373 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -54,6 +54,10 @@ CODE_LEN = 8 # 8 × 5 bits = 40 bits of entropy # node-wide lockout. DEFAULT_INVITE_TTL = 7 * 24 * 3600 # seconds — member invitations DEFAULT_PAIR_TTL = 24 * 3600 # seconds — operator pairing +# An invitation link is a bearer secret that may travel through any messaging +# service, so its lifetime is fixed rather than the operator's setting: the +# hub clamps its ticket to the same seven days. +LINK_INVITE_TTL = 7 * 24 * 3600 # A device-add code is read off one screen and typed into another, in one # sitting. An hour is comfort, not security: the code is bound to the requesting # keys by its hash, so a longer window widens nothing an attacker can use. @@ -1109,7 +1113,7 @@ class Roster: return code async def create_link_invite( - self, group_id: str, created_by: str, ttl: int = DEFAULT_INVITE_TTL, + self, group_id: str, created_by: str, ttl: int = LINK_INVITE_TTL, ) -> tuple[str, str, str]: """ Issue a code bound to no account: `(code, invite_id, expires_at)`. diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index eabe97a..090eb21 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -325,7 +325,7 @@ def create_ui_app(state: dict) -> FastAPI: # Both halves, node and hub, for the CLI: an operator at the machine gets a # whole link, not a code without a ticket. @app.post("/api/groups/{group_id}/invite-links") - async def create_link_invite(group_id: str, email: str): + async def create_link_invite(group_id: str, email: str = ""): return await _op(lambda: ops.create_link_invitation(state, group_id, email)) @app.delete("/api/groups/{group_id}/invite-links/{invite_id}") diff --git a/packages/meshbay-node/tests/golden/cli.json b/packages/meshbay-node/tests/golden/cli.json index 5ddd7fd..8db5abd 100644 --- a/packages/meshbay-node/tests/golden/cli.json +++ b/packages/meshbay-node/tests/golden/cli.json @@ -4,7 +4,7 @@ "asked": [], "exit": 0, "stderr": "", - "stdout": "usage: meshbay-node [-h] [--hub-url HUB_URL] [--username USERNAME] [--dir DIR] [--yes]\n [--config CONFIG] [--group GROUP] [--link] [--writable] [--no-writable]\n [--removable] [--no-removable] [--name NAME]\n [--log-level {DEBUG,INFO,WARNING,ERROR}]\n [{init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}]\n [subcommand] [target] [value]\n\nMeshBay Node daemon\n\npositional arguments:\n {init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}\n init: provision config + keystore | reset: erase all node state | status:\n node state and keys | operator pair: pair a browser with this node |\n member list|invite|cancel|revoke|unpin | group list|add|remove | root\n list|add|remove|set|eject|plug | gek init|rotate | file list|rm | video\n rematch: re-resolve TMDB matches for a group's videos | chat\n status|rotate|encrypt-history|prune | denylist show|clear | stun\n list|add|remove|reset | transfers show|set|max-size|per-member: live\n transfer slots, the node-wide caps, the largest single upload, and how\n many one member may run at once in a group | reload: re-read node.toml\n (hot; systemd or the loopback API) | restart-daemon: restart the node\n (systemd unit, the Windows autostart launcher, or the service task,\n whichever applies) | autostart install|remove|start|stop|status (Windows:\n run meshbay-node at each sign-in, no admin) | service\n install|remove|start|stop|status (Windows: run at boot, before sign-in,\n needs admin once to install) | calibrate-argon2: benchmark\n subcommand 'pair' for operator; list|invite|revoke|unpin for member; list|add|remove\n for group; list|add|remove|set|eject|plug for root; init|rotate for gek;\n list|rm for file; rematch for video; show|clear for denylist;\n list|add|remove|reset for stun; show|set|max-size|per-member for\n transfers; install|remove|start|stop|status for autostart and for service\n target username for member invite|revoke|unpin (an e-mail address with --link, a\n link id for member cancel); group name for group add; file id for file rm;\n identifier for denylist clear; download cap for transfers set; size in GB\n for transfers max-size\n value the second value where a verb takes two: the upload cap for transfers set\n\noptions:\n -h, --help show this help message and exit\n --hub-url HUB_URL hub URL, for init (e.g. https://meshbay.org)\n --username USERNAME hub username, for init\n --dir DIR shared directory, for group add\n --yes skip the confirmation for destructive commands\n --config CONFIG Config file path\n --group GROUP group id (optional if only one is configured)\n --link member invite: an invitation link for this e-mail address, for someone who\n may have no account yet\n --writable root accepts member uploads (root add/set)\n --no-writable root is read-only (root add/set, group add)\n --removable mark root as removable (root set/add)\n --no-removable mark root as not removable (root set)\n --name NAME root name (root add; defaults to directory basename)\n --log-level {DEBUG,INFO,WARNING,ERROR}\n", + "stdout": "usage: meshbay-node [-h] [--hub-url HUB_URL] [--username USERNAME] [--dir DIR] [--yes]\n [--config CONFIG] [--group GROUP] [--link] [--writable] [--no-writable]\n [--removable] [--no-removable] [--name NAME]\n [--log-level {DEBUG,INFO,WARNING,ERROR}]\n [{init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}]\n [subcommand] [target] [value]\n\nMeshBay Node daemon\n\npositional arguments:\n {init,reset,status,gek-init,gek,operator,member,group,root,file,video,chat,denylist,stun,transfers,reload,restart-daemon,autostart,service,calibrate-argon2}\n init: provision config + keystore | reset: erase all node state | status:\n node state and keys | operator pair: pair a browser with this node |\n member list|invite|cancel|revoke|unpin | group list|add|remove | root\n list|add|remove|set|eject|plug | gek init|rotate | file list|rm | video\n rematch: re-resolve TMDB matches for a group's videos | chat\n status|rotate|encrypt-history|prune | denylist show|clear | stun\n list|add|remove|reset | transfers show|set|max-size|per-member: live\n transfer slots, the node-wide caps, the largest single upload, and how\n many one member may run at once in a group | reload: re-read node.toml\n (hot; systemd or the loopback API) | restart-daemon: restart the node\n (systemd unit, the Windows autostart launcher, or the service task,\n whichever applies) | autostart install|remove|start|stop|status (Windows:\n run meshbay-node at each sign-in, no admin) | service\n install|remove|start|stop|status (Windows: run at boot, before sign-in,\n needs admin once to install) | calibrate-argon2: benchmark\n subcommand 'pair' for operator; list|invite|revoke|unpin for member; list|add|remove\n for group; list|add|remove|set|eject|plug for root; init|rotate for gek;\n list|rm for file; rematch for video; show|clear for denylist;\n list|add|remove|reset for stun; show|set|max-size|per-member for\n transfers; install|remove|start|stop|status for autostart and for service\n target username for member invite|revoke|unpin (an optional e-mail label with\n --link, a link id for member cancel); group name for group add; file id\n for file rm; identifier for denylist clear; download cap for transfers\n set; size in GB for transfers max-size\n value the second value where a verb takes two: the upload cap for transfers set\n\noptions:\n -h, --help show this help message and exit\n --hub-url HUB_URL hub URL, for init (e.g. https://meshbay.org)\n --username USERNAME hub username, for init\n --dir DIR shared directory, for group add\n --yes skip the confirmation for destructive commands\n --config CONFIG Config file path\n --group GROUP group id (optional if only one is configured)\n --link member invite: an invitation link, for someone who may have no account yet\n (valid 7 days, single use)\n --writable root accepts member uploads (root add/set)\n --no-writable root is read-only (root add/set, group add)\n --removable mark root as removable (root set/add)\n --no-removable mark root as not removable (root set)\n --name NAME root name (root add; defaults to directory basename)\n --log-level {DEBUG,INFO,WARNING,ERROR}\n", "systemctl": [] }, "autostart no-such-sub": { @@ -348,7 +348,7 @@ "asked": [], "exit": 0, "stderr": "", - "stdout": "INVITATION LINK https://example.invalid/#/invite?v=1\nvalid until \ncancel with meshbay-node member cancel abababababababababababababababab\n\nSend it to bob@example.test yourself. It works once, and only for an\naccount registered with that address: they open it, create their\naccount or sign in, and land in the group without typing a code.\n", + "stdout": "INVITATION LINK https://example.invalid/#/invite?v=1\nvalid until \ncancel with meshbay-node member cancel abababababababababababababababab\n\nSend it yourself, by any messaging app. It works once, for seven\ndays, for whoever opens it first: they create their account or\nsign in, and land in the group without typing a code.\n", "systemctl": [] }, "member list": { diff --git a/packages/meshbay-node/tests/test_ops_links.py b/packages/meshbay-node/tests/test_ops_links.py index 00258d3..9ad01d2 100644 --- a/packages/meshbay-node/tests/test_ops_links.py +++ b/packages/meshbay-node/tests/test_ops_links.py @@ -1,13 +1,15 @@ """ Invitation links from the operator's own machine (`member invite --link`). -The CLI makes both halves itself: the node's code, then the hub's ticket bound -to an address, then the link. What can go wrong is a half left behind — a code -the hub never ticketed, occupying one of the group's places, or a ticket whose -code was cancelled — and the CLI asking the hub to mail, which it may not. +The CLI makes both halves itself: the node's code, then the hub's ticket, then +the link. The address is optional and only labels the link. What can go wrong +is a half left behind — a code the hub never ticketed, occupying one of the +group's places, or a ticket whose code was cancelled — and the CLI asking the +hub to mail, which it may not. """ import re +from datetime import UTC, datetime, timedelta import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey @@ -76,12 +78,30 @@ async def test_a_refused_ticket_takes_the_code_back(roster): assert [i for i in await roster.list_invites() if i["kind"] == KIND_LINK] == [] -async def test_an_address_is_required(roster): +async def test_an_address_is_optional_but_must_be_one_when_given(roster): + hub = _Hub() + out = await ops.create_link_invitation(_state(roster, hub), GROUP) + assert LINK.match(out["link"]), out["link"] + assert hub.created == [(GROUP, "", out["invite_id"])] with pytest.raises(ops.OpError) as refused: - await ops.create_link_invitation(_state(roster, _Hub()), GROUP, "alice") + await ops.create_link_invitation(_state(roster, hub), GROUP, "alice") assert refused.value.status == 422 +async def test_a_link_lives_seven_days_whatever_the_invite_setting(roster): + class _Node: + invite_ttl_hours = 24 * 30 + + class _Config: + node = _Node() + + state = _state(roster, _Hub()) + state["config"] = _Config() + out = await ops.create_link_invite(state, GROUP) + left = datetime.fromisoformat(out["expires_at"]) - datetime.now(UTC) + assert timedelta(days=6, hours=23) < left <= timedelta(days=7) + + async def test_cancelling_takes_back_both_halves(roster): hub = _Hub() state = _state(roster, hub) |