diff options
Diffstat (limited to 'packages/meshbay-hub/src')
21 files changed, 731 insertions, 33 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index df2f336..10049b2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -18,8 +18,11 @@ from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import ( FederatedGroup, Group, + GroupHost, + GroupInvitation, GroupMember, IPLog, + Node, User, ) @@ -80,6 +83,71 @@ async def my_groups( } +@router.get("/invitations") +async def my_invitations( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Groups somebody added this account to, waiting for it to say yes. + + Nothing here is dialled or searched: until the invitation is accepted the + group is not in `/mine`, is not named in any MNP token, and signaling to + its nodes is refused like any non-member's. + """ + rows = (await db.execute( + select(GroupInvitation, Group, User.username) + .join(Group, Group.id == GroupInvitation.group_id) + .outerjoin(User, User.id == GroupInvitation.invited_by) + .where(GroupInvitation.user_id == current_user.id, Group.status == "active") + .order_by(GroupInvitation.created_at.desc()))).all() + owners = dict((await db.execute( + select(User.id, User.username).where( + User.id.in_({g.admin_id for _, g, _ in rows})))).all()) if rows else {} + return {"invitations": [ + {"group_id": g.id, "name": g.name, + "owner_username": owners.get(g.admin_id, ""), + "invited_by": inviter or "", + "created_at": inv.created_at.isoformat() if inv.created_at else None} + for inv, g, inviter in rows + ]} + + +@router.post("/{group_id}/invitation/accept") +async def accept_invitation( + group_id: str, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + inv = await db.get(GroupInvitation, (group_id, current_user.id)) + group = await db.get(Group, group_id) + if inv is None or group is None or group.status != "active": + raise HTTPException(status_code=404, detail="No such invitation") + await db.delete(inv) + if not await db.get(GroupMember, (group_id, current_user.id)): + db.add(GroupMember(group_id=group_id, user_id=current_user.id)) + db.add(IPLog(user_id=current_user.id, event="group_join", + ip_address=client_ip(request), detail=group.name)) + await db.commit() + owner = await db.scalar(select(User.username).where(User.id == group.admin_id)) + return {"status": "joined", "group_id": group_id, "name": group.name, + "owner_username": owner} + + +@router.post("/{group_id}/invitation/decline") +async def decline_invitation( + group_id: str, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + inv = await db.get(GroupInvitation, (group_id, current_user.id)) + if inv is None: + raise HTTPException(status_code=404, detail="No such invitation") + await db.delete(inv) + await db.commit() + return {"status": "declined", "group_id": group_id} + + @router.post("/{group_id}/activity") async def touch_group_activity( group_id: str, @@ -226,11 +294,21 @@ async def group_members( .where(GroupMember.group_id == group_id, User.status != "deleted") ) members = [{"user_id": uid, "username": uname} for uid, uname in result.all()] - return { + out = { "group_id": group_id, "admin_id": group.admin_id, "members": members, } + if group.admin_id == current_user.id: + # Who has been asked and not answered, for the owner only: another + # member learns nothing about people who have not joined. + invited = await db.execute( + select(User.id, User.username) + .join(GroupInvitation, User.id == GroupInvitation.user_id) + .where(GroupInvitation.group_id == group_id, User.status != "deleted")) + out["invited"] = [{"user_id": uid, "username": uname} + for uid, uname in invited.all()] + return out @router.post("/{group_id}/join") @@ -258,6 +336,9 @@ async def join_group( raise HTTPException(status_code=409, detail="Already a member") db.add(GroupMember(group_id=group_id, user_id=current_user.id)) + inv = await db.get(GroupInvitation, (group_id, current_user.id)) + if inv is not None: + await db.delete(inv) db.add(IPLog(user_id=current_user.id, event="group_join", ip_address=client_ip(request), detail=group.name)) await db.commit() @@ -437,10 +518,15 @@ async def remove_group_member( "group over or delete it.") membership = await db.get(GroupMember, (group_id, target.id)) - if not membership: + invitation = await db.get(GroupInvitation, (group_id, target.id)) + if not membership and not invitation: raise HTTPException(status_code=404, detail="Not a member of this group") - await db.delete(membership) + # An unanswered invitation is taken back the same way, by the same button. + if invitation is not None: + await db.delete(invitation) + if membership is not None: + await db.delete(membership) db.add(IPLog(user_id=current_user.id, event="group_leave", ip_address=client_ip(request), detail=f"{username} removed from {group.name}")) @@ -554,23 +640,23 @@ async def add_group_member( if not target: raise HTTPException(status_code=404, detail="User not found") - new_member = False - mem = await db.get(GroupMember, (group_id, target.id)) - if not mem: - db.add(GroupMember(group_id=group_id, user_id=target.id)) - new_member = True - - if new_member: + # An invitation, not a membership: the invitee has not agreed to anything, + # and a membership is what makes their client dial this group's nodes and + # name it in the tokens it hands them. They accept it themselves + # (`POST /{id}/invitation/accept`); until then the group is not theirs. + if await db.get(GroupMember, (group_id, target.id)): + return {"status": "member", "group_id": group_id, "username": username} + if await db.get(GroupInvitation, (group_id, target.id)) is None: + db.add(GroupInvitation(group_id=group_id, user_id=target.id, + invited_by=current_user.id)) from meshbay_hub.api.notifications import create_notification await create_notification( db, target.id, "group_invite", - f"You were added to {group.name}", - link=f"#/group/{group_id}", - group_id=group_id, + f"{current_user.username} invited you to a group", + link="#/", ) - await db.commit() - return {"status": "stored", "group_id": group_id, "username": username} + return {"status": "invited", "group_id": group_id, "username": username} class MuteRequest(BaseModel): @@ -695,3 +781,93 @@ async def invite_notify( return {"status": "sent"} + + +# ── Hosts: which nodes may serve this group ───────────────────────────────── +# +# A node owned by the group's owner hosts it without asking. Any other node — +# a member's, or the owner's own on another account — is registered for the +# group only once the owner approves it here. A node that claims a group it may +# not host appears in this list as `pending`, and the owner was notified. + +async def _owned(db: AsyncSession, group_id: str, user: User) -> Group: + group = await db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + if group.admin_id != user.id: + raise HTTPException(status_code=403, + detail="Only the group owner can choose its hosts") + return group + + +@router.get("/{group_id}/hosts") +async def list_hosts( + group_id: str, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + from meshbay_hub.api.revocation import is_node_connected + await _owned(db, group_id, current_user) + rows = (await db.execute( + select(GroupHost, Node, User.username) + .join(Node, Node.id == GroupHost.node_id) + .outerjoin(User, User.id == Node.user_id) + .where(GroupHost.group_id == group_id) + .order_by(GroupHost.requested_at))).all() + return {"hosts": [ + {"node_id": n.id, "pk_node": n.pk_node, "username": uname or "", + "status": h.status, "online": is_node_connected(n.id), + "requested_at": h.requested_at.isoformat() if h.requested_at else None} + for h, n, uname in rows + ]} + + +@router.post("/{group_id}/hosts/{node_id}") +async def approve_host( + group_id: str, + node_id: str, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """Approve a node that asked to host this group. Only a request the node + itself made can be approved: the owner picks from what asked, and never + names a node that did not.""" + from meshbay_hub.api.revocation import refresh_node_groups + group = await _owned(db, group_id, current_user) + host = await db.get(GroupHost, (group_id, node_id)) + if host is None: + raise HTTPException(status_code=404, detail="That node has not asked to host this group") + host.status = "approved" + host.decided_at = datetime.now(UTC) + db.add(IPLog(user_id=current_user.id, event="group_host_approve", + ip_address=client_ip(request), detail=f"{group.name}: {node_id[:8]}")) + await db.commit() + await refresh_node_groups(node_id) + return {"status": "approved", "group_id": group_id, "node_id": node_id} + + +@router.delete("/{group_id}/hosts/{node_id}") +async def remove_host( + group_id: str, + node_id: str, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """Withdraw an approval, or turn a request down. Takes effect at once for a + connected node. The row stays, marked `refused`, so the node asking again + on every reconnection does not notify the owner every time; approving it + later is still one call.""" + from meshbay_hub.api.revocation import refresh_node_groups + group = await _owned(db, group_id, current_user) + host = await db.get(GroupHost, (group_id, node_id)) + if host is None: + raise HTTPException(status_code=404, detail="No such host") + host.status = "refused" + host.decided_at = datetime.now(UTC) + db.add(IPLog(user_id=current_user.id, event="group_host_remove", + ip_address=client_ip(request), detail=f"{group.name}: {node_id[:8]}")) + await db.commit() + await refresh_node_groups(node_id) + return {"status": "refused", "group_id": group_id, "node_id": node_id} 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 3c50e19..86fbbb4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py @@ -30,7 +30,7 @@ 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.db.engine import get_db -from meshbay_hub.db.models import Group, GroupInviteLink, GroupMember, User +from meshbay_hub.db.models import Group, GroupInvitation, GroupInviteLink, GroupMember, User router = APIRouter(prefix="/v1/groups", tags=["invite-links"]) redeem_router = APIRouter(prefix="/v1/invite-links", tags=["invite-links"]) @@ -322,6 +322,11 @@ async def redeem_invite_link( raise HTTPException(status_code=404, detail="invite_not_valid") if not await db.get(GroupMember, (group.id, current_user.id)): db.add(GroupMember(group_id=group.id, user_id=current_user.id)) + # Redeeming a link is the invitee's own act, so it answers an + # invitation to the same group as well. + pending = await db.get(GroupInvitation, (group.id, current_user.id)) + if pending is not None: + await db.delete(pending) from meshbay_hub.api.notifications import create_notification await create_notification( db, row.created_by, "invite_link_redeemed", diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index 403f450..decd20e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -26,6 +26,10 @@ class MnpTokenRequest(BaseModel): # to it (E10), so it cannot be replayed to another node. The client knows it # from `/v1/groups/{id}/nodes` before it connects. node_pk: str = "" + # The one group this connection is for. The token names that group and no + # other: it is handed to the node's operator, who has no business learning + # every other group the member belongs to. + group_id: str = "" @router.post("/mnp-token") @@ -50,11 +54,16 @@ async def mnp_token( only *restricts* the token to whatever node holds that key, which is the one the client is connecting to; a wrong key yields a token no node will accept. """ - rows = await db.execute( - select(GroupMember.group_id).where(GroupMember.user_id == current_user.id)) - group_ids = [gid for (gid,) in rows.all()] + group_id = (body.group_id if body else "").strip() + if not group_id: + raise HTTPException(status_code=422, + detail="Name the group this connection is for (group_id)") + member = await db.get(GroupMember, (group_id, current_user.id)) return { - "mnp_token": issue_mnp_token(current_user.id, groups=group_ids, + # Empty when the account is not a member: the node then refuses with + # `not_a_member`, which is the answer that case has always had. + "mnp_token": issue_mnp_token(current_user.id, + groups=[group_id] if member else [], node_pk=(body.node_pk if body else "")), "expires_in": 900, } diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index fe9edf3..6a6baa7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -52,6 +52,10 @@ router = APIRouter(tags=["revocation"]) _connected_nodes: dict[str, WebSocket] = {} # node_id → websocket _node_groups: dict[str, list[str]] = {} # node_id → [group_id, ...] +# What each connected node asked for, and whose it is, so that an owner +# approving or withdrawing a host takes effect without the node reconnecting. +_node_claims: dict[str, list[str]] = {} # node_id → claimed group ids +_node_users: dict[str, str] = {} # node_id → owning account _punch_events: dict[str, asyncio.Event] = {} # node_id → signaling event # How long an unauthenticated socket may stay open before saying who it is. The @@ -98,6 +102,8 @@ def forget_node(node_id: str) -> None: """ _connected_nodes.pop(node_id, None) _node_groups.pop(node_id, None) + _node_claims.pop(node_id, None) + _node_users.pop(node_id, None) def _notify_budget(node_id: str) -> bool: @@ -283,6 +289,72 @@ async def _authorized_groups(user_id: str) -> set[str]: return {gid for (gid,) in result.all()} +async def _hostable_groups(db: AsyncSession, node_id: str, user_id: str) -> set[str]: + """The groups this node may host: its account's own, and those whose owner + approved it (`GroupHost`). Membership alone is not enough — every member + holds the group key, so a member's node would pass the handshake as though + it were the real host.""" + from meshbay_hub.db.models import GroupHost + owned = set((await db.execute( + select(Group.id).where(Group.admin_id == user_id))).scalars().all()) + approved = set((await db.execute( + select(GroupHost.group_id).where(GroupHost.node_id == node_id, + GroupHost.status == "approved"))).scalars().all()) + return owned | approved + + +async def _record_host_requests(db: AsyncSession, node_id: str, user_id: str, + group_ids: set[str]) -> None: + """Remember that this node would like to host these groups, and tell each + owner once — the first time — rather than on every reconnection.""" + from meshbay_hub.api.notifications import create_notification + from meshbay_hub.db.models import GroupHost + if not group_ids: + return + known = set((await db.execute( + select(GroupHost.group_id).where(GroupHost.node_id == node_id, + GroupHost.group_id.in_(group_ids)))).scalars().all()) + fresh = group_ids - known + if not fresh: + return + who = await db.scalar(select(User.username).where(User.id == user_id)) or "" + for gid in sorted(fresh): + db.add(GroupHost(group_id=gid, node_id=node_id, status="pending")) + group = await db.get(Group, gid) + if group is not None: + await create_notification( + db, group.admin_id, "host_request", + f"A node of {who} asks to host {group.name}", + link=f"#/group/{gid}", group_id=gid) + await db.commit() + + +async def resolve_node_groups(node_id: str, user_id: str, claimed_groups) -> list[str]: + """What a node is registered for: what it claims, within what its account + belongs to and what it may host. The rest of its claim becomes a request + the owner can approve.""" + from meshbay_hub.db.engine import get_session_factory + async with get_session_factory()() as db: + result = await db.execute( + select(GroupMember.group_id).where(GroupMember.user_id == user_id)) + authorized = {gid for (gid,) in result.all()} + allowed = _claimable(claimed_groups, authorized) + hostable = await _hostable_groups(db, node_id, user_id) + await _record_host_requests(db, node_id, user_id, + set(allowed) - hostable) + return [gid for gid in allowed if gid in hostable] + + +async def refresh_node_groups(node_id: str) -> None: + """Re-evaluate a connected node's registration after a host decision.""" + if node_id not in _connected_nodes: + return + new_gids = await resolve_node_groups( + node_id, _node_users.get(node_id, ""), _node_claims.get(node_id)) + _node_groups[node_id] = new_gids + await _mark_hosted(new_gids) + + def _claimable(claimed_groups, authorized: set[str]) -> list[str]: """What a node actually gets registered for. Two rules. @@ -337,14 +409,10 @@ async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tup if user is None or user.status != "active": return None, "Account not active" - # Groups come from the database. The node may narrow the set to what it - # actually hosts, but it cannot widen it to groups it is not a member of — - # otherwise it could advertise itself as a source for any group on the hub. - result = await db.execute( - select(GroupMember.group_id).where(GroupMember.user_id == user_id)) - authorized = {gid for (gid,) in result.all()} - - return claimed_id, _claimable(claimed_groups, authorized) + # Groups come from the database. The node may narrow the set to what it + # actually hosts, but it cannot widen it past what its account belongs to + # (C2) — nor, within that, past what its account owns or the owner approved. + return claimed_id, await resolve_node_groups(claimed_id, user_id, claimed_groups) @router.websocket("/v1/nodes/ws") @@ -404,6 +472,8 @@ async def node_websocket(ws: WebSocket): node_id = resolved_id _connected_nodes[node_id] = ws _node_groups[node_id] = group_ids + _node_claims[node_id] = list(msg.get("group_ids") or []) + _node_users[node_id] = user_id await _mark_hosted(group_ids) log.info("Node WS connected: %s (user=%s, groups=%d)", node_id[:8], user_id[:8], len(group_ids)) @@ -427,8 +497,9 @@ async def node_websocket(ws: WebSocket): # assign the message's list verbatim, so the ceiling that makes # C2 hold at authentication could be stepped over one message # later: a node had only to reload to claim any group on the hub. - new_gids = _claimable(msg.get("group_ids"), - await _authorized_groups(user_id)) + _node_claims[node_id] = list(msg.get("group_ids") or []) + new_gids = await resolve_node_groups( + node_id, user_id, msg.get("group_ids")) _node_groups[node_id] = new_gids await _mark_hosted(new_gids) log.info("Node %s updated groups: %d", node_id[:8], len(new_gids)) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 3e996a7..660bd76 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -35,6 +35,8 @@ from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import ( EmailVerification, Group, + GroupHost, + GroupInvitation, GroupInviteLink, GroupMember, IPLog, @@ -1415,6 +1417,11 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus await db.execute(delete(GroupMember).where(GroupMember.user_id == user.id)) await db.execute(delete(Notification).where(Notification.user_id == user.id)) await db.execute(delete(RefreshToken).where(RefreshToken.user_id == user.id)) + await db.execute(delete(GroupInvitation).where(GroupInvitation.user_id == user.id)) + await db.execute(update(GroupInvitation).where(GroupInvitation.invited_by == user.id) + .values(invited_by=None)) + await db.execute(delete(GroupHost).where( + GroupHost.node_id.in_(select(Node.id).where(Node.user_id == user.id)))) await db.execute(delete(Node).where(Node.user_id == user.id)) await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id)) await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id)) diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a1b2c3d4e5f7_invitations_and_group_hosts.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a1b2c3d4e5f7_invitations_and_group_hosts.py new file mode 100644 index 0000000..b47fca0 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a1b2c3d4e5f7_invitations_and_group_hosts.py @@ -0,0 +1,49 @@ +"""an owner's addition is an invitation; hosts are designated by the owner + +Adding somebody to a group no longer makes them a member until they accept, and +a node may host a group only if its account owns it or the owner approved it. + +Revision ID: a1b2c3d4e5f7 +Revises: f7a8b9c0d1e2 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "a1b2c3d4e5f7" +down_revision: str | Sequence[str] | None = "f7a8b9c0d1e2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "group_invitations", + sa.Column("group_id", sa.String(36), sa.ForeignKey("groups.id"), nullable=False), + sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False), + sa.Column("invited_by", sa.String(36), sa.ForeignKey("users.id"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now()), + sa.PrimaryKeyConstraint("group_id", "user_id"), + ) + op.create_index("ix_group_invitations_user", "group_invitations", ["user_id"]) + op.create_table( + "group_hosts", + sa.Column("group_id", sa.String(36), sa.ForeignKey("groups.id"), nullable=False), + sa.Column("node_id", sa.String(36), sa.ForeignKey("nodes.id"), nullable=False), + sa.Column("status", sa.String(16), nullable=False, server_default="pending"), + sa.Column("requested_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now()), + sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("group_id", "node_id"), + ) + op.create_index("ix_group_hosts_node", "group_hosts", ["node_id"]) + + +def downgrade() -> None: + op.drop_index("ix_group_hosts_node", table_name="group_hosts") + op.drop_table("group_hosts") + op.drop_index("ix_group_invitations_user", table_name="group_invitations") + op.drop_table("group_invitations") diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index 5b140f1..a0437d6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -145,6 +145,53 @@ class GroupMember(Base): user: Mapped["User"] = relationship(back_populates="group_memberships") +class GroupInvitation(Base): + """ + Somebody asked to add this account to a group, and it has not said yes. + + Separate from `GroupMember` on purpose. A membership row is what the hub + acts on — it lets an account's client dial the group's nodes, names the + group in the account's MNP tokens and lists the group in its sidebar and in + Search — and an owner could create one for any username, unasked. That made + any account able to have any other account's client connect to a node of + its choosing. So an owner's addition is an invitation until the invitee + accepts it; redeeming an invitation link, joining an open group and creating + a group are the account's own acts and still write the membership directly. + """ + __tablename__ = "group_invitations" + + group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), primary_key=True) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), primary_key=True) + invited_by: Mapped[str | None] = mapped_column(ForeignKey("users.id")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + __table_args__ = (Index("ix_group_invitations_user", "user_id"),) + + +class GroupHost(Base): + """ + A node the group's owner has approved as a host, refused, or not yet + answered (`approved` / `refused` / `pending`). + + A node registers for the groups it claims, and clients connect to whichever + registered node answers first. Every member holds the group key, so a + member's node passes the handshake like the real host would: the ceiling on + what a node may claim cannot be "groups its account belongs to". It is + "groups its account owns", plus the nodes listed here as `approved`. A node + that claims a group it may not host is recorded `pending`, and the owner is + told, so a legitimate second host is one click away rather than refused. + """ + __tablename__ = "group_hosts" + + group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), primary_key=True) + node_id: Mapped[str] = mapped_column(ForeignKey("nodes.id"), primary_key=True) + status: Mapped[str] = mapped_column(String(16), default="pending", nullable=False) + requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + __table_args__ = (Index("ix_group_hosts_node", "node_id"),) + + class GroupInviteLink(Base): """ The hub's half of an invitation link (docs/MESHBAY_DESIGN.md §7.3). diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py index b382849..6980da9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/mail.py +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -443,7 +443,8 @@ def send_invite_notification( "\n" f"Your one-time code is: {code}\n" "\n" - "Open the group and enter this code when prompted.\n" + "Accept the invitation on your MeshBay home page, then open the group\n" + "and enter this code when prompted.\n" "The code works once and expires in 7 days.\n" "\n" f"{_hub_url}\n" diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 875b1aa..3a85bf7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -528,7 +528,66 @@ function NotificationFeed({ notifications, onMarkRead, onPurge }) { `; } -function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroups = true }) { +/** + * Groups somebody added this account to, waiting for an answer. + * + * Being added is an invitation, not a membership (the hub's `group_invitations`): + * until it is accepted the group is not in the sidebar, not dialled, not in + * Search and not named in any token this browser hands a node. Accepting is + * what makes it a group of ours; declining forgets it. + */ +function PendingInvitations({ token, onAccepted }) { + const [items, setItems] = useState([]); + const [busy, setBusy] = useState(''); + const [error, setError] = useState(''); + const load = useCallback(() => { + if (!token) return; + hubFetch('/v1/groups/invitations', { token }) + .then(data => setItems(data.invitations || [])) + .catch(() => setItems([])); + }, [token]); + useEffect(() => { load(); }, [load]); + + const answer = async (inv, verb) => { + setBusy(inv.group_id); setError(''); + try { + await hubFetch(`/v1/groups/${inv.group_id}/invitation/${verb}`, + { method: 'POST', token }); + setItems(prev => prev.filter(i => i.group_id !== inv.group_id)); + if (verb === 'accept' && onAccepted) onAccepted(inv.group_id); + } catch (err) { + setError(err.message); + } finally { + setBusy(''); + } + }; + + if (!items.length) return null; + return html` + <div class="settings-section"> + <h3 class="settings-heading">${t('home.invitations')}</h3> + <p class="settings-hint">${t('home.invitation_hint')}</p> + ${error && html`<p class="error-msg">${error}</p>`} + <ul class="invite-links"> + ${items.map(inv => html` + <li key=${inv.group_id} style="display:flex;gap:8px;align-items:center; + flex-wrap:wrap;word-break:break-word;margin:4px 0"> + <strong><${GroupName} name=${inv.name} owner=${inv.owner_username} /></strong> + ${inv.invited_by && html`<span style="color:var(--text-dim)"> + ${t('home.invited_by', { name: inv.invited_by })}</span>`} + <button class="admin-btn" type="button" disabled=${busy === inv.group_id} + onClick=${() => answer(inv, 'accept')}>${t('home.accept')}</button> + <button class="admin-btn" type="button" disabled=${busy === inv.group_id} + onClick=${() => answer(inv, 'decline')}>${t('home.decline')}</button> + </li> + `)} + </ul> + </div> + `; +} + +function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroups = true, + token, onGroupsChanged }) { const [setupDismissed, setSetupDismissed] = useState(false); if (groups.length === 0) { @@ -539,6 +598,7 @@ function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroup return html` <div> <h2>${t('home.welcome')}</h2> + <${PendingInvitations} token=${token} onAccepted=${onGroupsChanged} /> <${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} onPurge=${onPurge} /> <${JoinByLink} hubOrigin=${platform.hubOrigin()} /> @@ -555,6 +615,7 @@ function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroup return html` <div> <h2>${t('home.my_groups')}</h2> + <${PendingInvitations} token=${token} onAccepted=${onGroupsChanged} /> <${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} onPurge=${onPurge} /> <div class="group-grid"> @@ -958,6 +1019,15 @@ function App() { fetchNotifications(); }, [user]); + // After an invitation is accepted: the group is ours now, and only the hub + // knows its row the way `/mine` answers it. + const reloadGroups = useCallback(() => { + if (!user) return; + hubFetch('/v1/groups/mine', { token: user.token }) + .then(data => setGroups(data.groups || [])) + .catch(() => {}); + }, [user]); + // The group list lives here, so an edit made three components down has to come // back up rather than be re-fetched: a reload would drop the WebRTC connection // the page is holding. @@ -1216,6 +1286,7 @@ function App() { ? html`<${LazyAdminPage} token=${user.token} role=${user.role} />` : html`<${HomePage} groups=${groups} notifications=${notifications} allowPublicGroups=${allowPublicGroups} + token=${user.token} onGroupsChanged=${reloadGroups} onMarkRead=${markRead} onPurge=${purgeNotifications} />`; } else if (route === '/settings') { page = html`<${SettingsPage} user=${user} theme=${theme} @@ -1233,6 +1304,7 @@ function App() { } else { page = html`<${HomePage} groups=${groups} notifications=${notifications} allowPublicGroups=${allowPublicGroups} + token=${user.token} onGroupsChanged=${reloadGroups} onMarkRead=${markRead} onPurge=${purgeNotifications} />`; } 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 fba8a0e..048f831 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -410,7 +410,14 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, onAppDirectories, onRefreshIndex, onPaired, onLeft }) { const [members, setMembers] = useState([]); + // Asked and not answered — the owner is the only one told (the hub leaves + // the field out for anyone else). + const [invited, setInvited] = useState([]); const [adminId, setAdminId] = useState(''); + // Nodes other than the owner's that asked to serve this group, and what the + // owner answered. Only the owner reads it. + const [hosts, setHosts] = useState([]); + const [hostBusy, setHostBusy] = useState(''); const [loading, setLoading] = useState(true); const [inviteUser, setInviteUser] = useState(''); const [inviting, setInviting] = useState(false); @@ -802,6 +809,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, hubFetch(`/v1/groups/${groupId}/members`, { token }) .then(data => { setMembers(data.members || []); + setInvited(data.invited || []); setAdminId(data.admin_id || ''); }) .catch(() => {}) @@ -810,6 +818,30 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, useEffect(() => { loadMembers(); }, [loadMembers]); + const ownsGroup = Boolean(group && group.is_admin); + const loadHosts = useCallback(() => { + if (!ownsGroup) return; + hubFetch(`/v1/groups/${groupId}/hosts`, { token }) + .then(data => setHosts(data.hosts || [])) + .catch(() => setHosts([])); + }, [groupId, token, ownsGroup]); + + useEffect(() => { loadHosts(); }, [loadHosts]); + + const decideHost = useCallback(async (host, approve) => { + setHostBusy(host.node_id); + setError(''); + try { + await hubFetch(`/v1/groups/${groupId}/hosts/${host.node_id}`, + { method: approve ? 'POST' : 'DELETE', token }); + loadHosts(); + } catch (err) { + setError(err.message); + } finally { + setHostBusy(''); + } + }, [groupId, token, loadHosts]); + useEffect(() => { hubFetch('/v1/users/me/preferences', { token }) .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] === 'true')) @@ -1382,12 +1414,58 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, </td> </tr> `)} + ${invited.map(m => html` + <tr key=${m.user_id}> + <td>${m.username}</td> + <td><span class="badge">${t('members.invited')}</span></td> + <td class="admin-actions"> + ${isAdmin && html` + <button class="admin-btn danger" disabled=${removing === m.user_id} + onClick=${async () => { + if (!await ask(t('members.remove_confirm', { user: m.username }))) return; + removeMember(m); + }}> + ${removing === m.user_id ? '...' : t('members.remove')} + </button> + `} + </td> + </tr> + `)} </tbody> </table> ${isAdmin && members.length > 1 && html` <p class="settings-hint">${t('members.remove_hint')}</p> `} </${CollapsibleSection}> + + ${ownsGroup && html` + <${CollapsibleSection} title=${t('hosts.title')}> + <p class="settings-hint">${t('hosts.hint')}</p> + ${hosts.length === 0 && html`<p class="settings-hint">${t('hosts.none')}</p>`} + ${hosts.length > 0 && html` + <table class="admin-table"> + <tbody> + ${hosts.map(h => html` + <tr key=${h.node_id}> + <td>${t('hosts.from', { name: h.username })} + <br /><code class="node-key">${h.pk_node}</code></td> + <td><span class="badge">${t(`hosts.status_${h.status}`)}</span> + ${h.online && html` <span class="badge">${t('hosts.online')}</span>`}</td> + <td class="admin-actions"> + ${h.status !== 'approved' && html` + <button class="admin-btn" disabled=${hostBusy === h.node_id} + onClick=${() => decideHost(h, true)}>${t('hosts.approve')}</button>`} + ${h.status !== 'refused' && html` + <button class="admin-btn danger" disabled=${hostBusy === h.node_id} + onClick=${() => decideHost(h, false)}>${t('hosts.refuse')}</button>`} + </td> + </tr> + `)} + </tbody> + </table> + `} + </${CollapsibleSection}> + `} </div> `; } 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 a83f44b..3c0f1ec 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -1222,4 +1222,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Umbenannt: „{name}“ ist nicht auf jedem System ein gültiger Name", 'transfers.renamed_n': "Namen geändert, damit sie auf jedem System gültig sind: {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Einladungen", + 'home.invitation_hint': "Jemand hat Sie zu diesen Gruppen hinzugefügt. Nehmen Sie nur die an, die Sie erwarten: Eine Gruppe, der Sie beitreten, sieht Ihre Adresse, wenn Sie sie öffnen.", + 'home.invited_by': "eingeladen von {name}", + 'home.accept': "Annehmen", + 'home.decline': "Ablehnen", + 'members.invited': "eingeladen", + 'hosts.title': "Hosts", + 'hosts.hint': "Ihre eigenen Nodes stellen diese Gruppe ohne Rückfrage bereit. Ein anderer Node tut es erst, wenn Sie ihn hier genehmigen; ein Node, der anfragt, steht unten.", + 'hosts.none': "Kein anderer Node hat angefragt, diese Gruppe bereitzustellen.", + 'hosts.from': "Ein Node von {name}", + 'hosts.status_pending': "wartet auf Ihre Antwort", + 'hosts.status_approved': "genehmigt", + 'hosts.status_refused': "abgelehnt", + 'hosts.approve': "Genehmigen", + 'hosts.refuse': "Ablehnen", + 'hosts.online': "online", }; 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 46c5094..947c61d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -1203,4 +1203,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Renamed: “{name}” is not a valid name on every system", 'transfers.renamed_n': "Names changed to be valid on every system: {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Invitations", + 'home.invitation_hint': "Somebody added you to these groups. Accept only the ones you expect: a group you join can see your address when you open it.", + 'home.invited_by': "invited by {name}", + 'home.accept': "Accept", + 'home.decline': "Decline", + 'members.invited': "invited", + 'hosts.title': "Hosts", + 'hosts.hint': "Your own nodes serve this group without asking. Another node serves it only once you approve it here; a node that asks is listed below.", + 'hosts.none': "No other node has asked to host this group.", + 'hosts.from': "A node of {name}", + 'hosts.status_pending': "waiting for your answer", + 'hosts.status_approved': "approved", + 'hosts.status_refused': "refused", + 'hosts.approve': "Approve", + 'hosts.refuse': "Refuse", + 'hosts.online': "online", }; 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 0d0e865..a0220d8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -1216,4 +1216,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Renombrado: «{name}» no es un nombre válido en todos los sistemas", 'transfers.renamed_n': "Nombres cambiados para ser válidos en todos los sistemas: {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Invitaciones", + 'home.invitation_hint': "Alguien le ha añadido a estos grupos. Acepte solo los que espera: un grupo al que se une ve su dirección cuando lo abre.", + 'home.invited_by': "invitado por {name}", + 'home.accept': "Aceptar", + 'home.decline': "Rechazar", + 'members.invited': "invitado", + 'hosts.title': "Anfitriones", + 'hosts.hint': "Sus propios nodes sirven este grupo sin preguntar. Otro node lo sirve solo cuando usted lo aprueba aquí; un node que lo pide aparece abajo.", + 'hosts.none': "Ningún otro node ha pedido alojar este grupo.", + 'hosts.from': "Un node de {name}", + 'hosts.status_pending': "esperando su respuesta", + 'hosts.status_approved': "aprobado", + 'hosts.status_refused': "rechazado", + 'hosts.approve': "Aprobar", + 'hosts.refuse': "Rechazar", + 'hosts.online': "en línea", }; 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 3c86cd7..c4bc37e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -1231,4 +1231,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Renommé : « {name} » n’est pas un nom valide sur tous les systèmes", 'transfers.renamed_n': "Noms modifiés pour être valides sur tous les systèmes : {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Invitations", + 'home.invitation_hint': "Quelqu'un vous a ajouté à ces groupes. N'acceptez que ceux que vous attendez : un groupe que vous rejoignez voit votre adresse quand vous l'ouvrez.", + 'home.invited_by': "invité par {name}", + 'home.accept': "Accepter", + 'home.decline': "Refuser", + 'members.invited': "invité", + 'hosts.title': "Hôtes", + 'hosts.hint': "Vos propres nodes servent ce groupe sans rien demander. Un autre node ne le sert qu'une fois approuvé ici ; un node qui le demande apparaît ci-dessous.", + 'hosts.none': "Aucun autre node n'a demandé à héberger ce groupe.", + 'hosts.from': "Un node de {name}", + 'hosts.status_pending': "en attente de votre réponse", + 'hosts.status_approved': "approuvé", + 'hosts.status_refused': "refusé", + 'hosts.approve': "Approuver", + 'hosts.refuse': "Refuser", + 'hosts.online': "en ligne", }; 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 3a74b4d..59505b8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -1230,4 +1230,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Rinominato: «{name}» non è un nome valido su tutti i sistemi", 'transfers.renamed_n': "Nomi modificati per essere validi su tutti i sistemi: {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Inviti", + 'home.invitation_hint': "Qualcuno ti ha aggiunto a questi gruppi. Accetta solo quelli che ti aspetti: un gruppo a cui ti unisci vede il tuo indirizzo quando lo apri.", + 'home.invited_by': "invitato da {name}", + 'home.accept': "Accetta", + 'home.decline': "Rifiuta", + 'members.invited': "invitato", + 'hosts.title': "Host", + 'hosts.hint': "I tuoi node servono questo gruppo senza chiedere. Un altro node lo serve solo dopo che lo approvi qui; un node che lo chiede compare qui sotto.", + 'hosts.none': "Nessun altro node ha chiesto di ospitare questo gruppo.", + 'hosts.from': "Un node di {name}", + 'hosts.status_pending': "in attesa della tua risposta", + 'hosts.status_approved': "approvato", + 'hosts.status_refused': "rifiutato", + 'hosts.approve': "Approva", + 'hosts.refuse': "Rifiuta", + 'hosts.online': "online", }; 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 e0c38de..bba9564 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -1214,4 +1214,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "名前を変更しました:「{name}」はすべてのシステムで有効な名前ではありません", 'transfers.renamed_n': "すべてのシステムで有効になるよう変更した名前:{n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "招待", + 'home.invitation_hint': "誰かがあなたをこれらのグループに追加しました。心当たりのあるものだけを承諾してください。参加したグループは、開いたときにあなたのアドレスを知ることができます。", + 'home.invited_by': "{name} からの招待", + 'home.accept': "承諾", + 'home.decline': "辞退", + 'members.invited': "招待中", + 'hosts.title': "ホスト", + 'hosts.hint': "あなた自身の node は確認なしでこのグループを提供します。他の node は、ここで承認した後にのみ提供します。申請した node は下に表示されます。", + 'hosts.none': "このグループのホストを申請した node は他にありません。", + 'hosts.from': "{name} の node", + 'hosts.status_pending': "あなたの返答待ち", + 'hosts.status_approved': "承認済み", + 'hosts.status_refused': "拒否済み", + 'hosts.approve': "承認", + 'hosts.refuse': "拒否", + 'hosts.online': "オンライン", }; 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 83b9eed..87e5b87 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -1232,4 +1232,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Hernoemd: ‘{name}’ is niet op elk systeem een geldige naam", 'transfers.renamed_n': "Namen aangepast zodat ze op elk systeem geldig zijn: {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Uitnodigingen", + 'home.invitation_hint': "Iemand heeft u aan deze groepen toegevoegd. Accepteer alleen de groepen die u verwacht: een groep waar u lid van wordt, ziet uw adres wanneer u hem opent.", + 'home.invited_by': "uitgenodigd door {name}", + 'home.accept': "Accepteren", + 'home.decline': "Weigeren", + 'members.invited': "uitgenodigd", + 'hosts.title': "Hosts", + 'hosts.hint': "Uw eigen nodes bedienen deze groep zonder te vragen. Een andere node doet dat pas nadat u hem hier goedkeurt; een node die erom vraagt, staat hieronder.", + 'hosts.none': "Geen andere node heeft gevraagd deze groep te hosten.", + 'hosts.from': "Een node van {name}", + 'hosts.status_pending': "wacht op uw antwoord", + 'hosts.status_approved': "goedgekeurd", + 'hosts.status_refused': "geweigerd", + 'hosts.approve': "Goedkeuren", + 'hosts.refuse': "Weigeren", + 'hosts.online': "online", }; 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 3edba2d..6d81b25 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -1258,4 +1258,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Zmieniono nazwę: „{name}” nie jest prawidłową nazwą w każdym systemie", 'transfers.renamed_n': "Nazwy zmienione, by były prawidłowe w każdym systemie: {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Zaproszenia", + 'home.invitation_hint': "Ktoś dodał Cię do tych grup. Akceptuj tylko te, których się spodziewasz: grupa, do której dołączysz, widzi Twój adres, gdy ją otwierasz.", + 'home.invited_by': "zaprasza {name}", + 'home.accept': "Akceptuj", + 'home.decline': "Odrzuć", + 'members.invited': "zaproszony", + 'hosts.title': "Hosty", + 'hosts.hint': "Twoje własne node'y obsługują tę grupę bez pytania. Inny node robi to dopiero po Twojej akceptacji tutaj; node, który o to prosi, jest widoczny poniżej.", + 'hosts.none': "Żaden inny node nie prosił o hostowanie tej grupy.", + 'hosts.from': "Node użytkownika {name}", + 'hosts.status_pending': "czeka na Twoją odpowiedź", + 'hosts.status_approved': "zaakceptowany", + 'hosts.status_refused': "odrzucony", + 'hosts.approve': "Akceptuj", + 'hosts.refuse': "Odrzuć", + 'hosts.online': "online", }; 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 3f44570..7b12ea5 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 @@ -1217,4 +1217,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "Renomeado: “{name}” não é um nome válido em todos os sistemas", 'transfers.renamed_n': "Nomes alterados para serem válidos em todos os sistemas: {n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "Convites", + 'home.invitation_hint': "Alguém adicionou você a estes grupos. Aceite apenas os que espera: um grupo em que você entra vê o seu endereço quando você o abre.", + 'home.invited_by': "convidado por {name}", + 'home.accept': "Aceitar", + 'home.decline': "Recusar", + 'members.invited': "convidado", + 'hosts.title': "Hosts", + 'hosts.hint': "Seus próprios nodes servem este grupo sem perguntar. Outro node só o serve depois que você o aprova aqui; um node que pede aparece abaixo.", + 'hosts.none': "Nenhum outro node pediu para hospedar este grupo.", + 'hosts.from': "Um node de {name}", + 'hosts.status_pending': "aguardando sua resposta", + 'hosts.status_approved': "aprovado", + 'hosts.status_refused': "recusado", + 'hosts.approve': "Aprovar", + 'hosts.refuse': "Recusar", + 'hosts.online': "online", }; 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 1168460..9f3c902 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 @@ -1203,4 +1203,22 @@ export default { // Names made writable everywhere when saved (portable-name.js) 'transfers.renamed': "已重命名:“{name}”并非在所有系统上都是有效的名称", 'transfers.renamed_n': "为在所有系统上有效而修改的名称:{n}", + + // Invitations waiting for an answer, and the nodes a group owner approves + 'home.invitations': "邀请", + 'home.invitation_hint': "有人把你加入了这些群组。只接受你预期的邀请:你加入的群组在你打开它时可以看到你的地址。", + 'home.invited_by': "由 {name} 邀请", + 'home.accept': "接受", + 'home.decline': "拒绝", + 'members.invited': "已邀请", + 'hosts.title': "主机", + 'hosts.hint': "你自己的 node 无需询问即可提供此群组。其他 node 只有在你于此处批准后才会提供;提出申请的 node 列在下方。", + 'hosts.none': "没有其他 node 申请托管此群组。", + 'hosts.from': "{name} 的 node", + 'hosts.status_pending': "等待你的答复", + 'hosts.status_approved': "已批准", + 'hosts.status_refused': "已拒绝", + 'hosts.approve': "批准", + 'hosts.refuse': "拒绝", + 'hosts.online': "在线", }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 98d010e..04c2d23 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -653,7 +653,10 @@ class MeshBayTransport { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this._accessToken}`, }, - body: JSON.stringify({ node_pk: this._nodePkTarget || '' }), + // The token names this connection's group and no other: it is handed to + // the node's operator, who has no business learning every group this + // account belongs to. + body: JSON.stringify({ node_pk: this._nodePkTarget || '', group_id: this._groupId }), }); if (!r.ok) throw new Error(`Could not obtain a node token: ${r.status}`); return (await r.json()).mnp_token; |