aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py206
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/invite_links.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py91
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a1b2c3d4e5f7_invitations_and_group_hosts.py49
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py47
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js74
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js78
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js5
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;