From 90c69477d5f701158112b3c294eff26312f89da6 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 25 Sep 2026 17:10:15 +0200 Subject: feat: invitation links no longer bound to an e-mail address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A link is redeemable by whoever opens it first, so it can be sent by any messaging app. The address is optional (mail + label only); a link lives 7 days, fixed. Adds a Share button; see MESHBAY_DESIGN.md §3.4. Co-Authored-By: Claude Opus 5.5 --- .../src/meshbay_hub/api/invite_links.py | 49 ++++++++++------------ .../versions/c4d5e6f7a8b9_invite_links_unbound.py | 34 +++++++++++++++ packages/meshbay-hub/src/meshbay_hub/db/models.py | 13 +++--- packages/meshbay-hub/src/meshbay_hub/mail.py | 2 +- .../src/meshbay_hub/static/group-settings.js | 37 +++++++++++----- .../src/meshbay_hub/static/invite-page.js | 11 +---- .../src/meshbay_hub/static/locales/de.js | 12 +++--- .../src/meshbay_hub/static/locales/en.js | 12 +++--- .../src/meshbay_hub/static/locales/es.js | 12 +++--- .../src/meshbay_hub/static/locales/fr.js | 12 +++--- .../src/meshbay_hub/static/locales/it.js | 12 +++--- .../src/meshbay_hub/static/locales/ja.js | 12 +++--- .../src/meshbay_hub/static/locales/nl.js | 12 +++--- .../src/meshbay_hub/static/locales/pl.js | 12 +++--- .../src/meshbay_hub/static/locales/pt-BR.js | 12 +++--- .../src/meshbay_hub/static/locales/zh-CN.js | 12 +++--- 16 files changed, 161 insertions(+), 105 deletions(-) create mode 100644 packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py (limited to 'packages/meshbay-hub/src') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py index f33dc6e..3c50e19 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py @@ -4,14 +4,13 @@ Invitation links — the hub's half (docs/MESHBAY_DESIGN.md §3.4, §7.3). A link carries two secrets with two jobs. The node's code decides who gets the group key, and it is shown to the hub only when the inviter asks the hub to mail the link. The ticket here decides who may *reach* the node — membership, -which is all the hub has to give (§7.1) — and it gives it to one account only: -the one whose verified address the inviter named. A ticket that leaks, through a -messaging service that previews links or a forwarded mail, is therefore useless -without that mailbox. +which is all the hub has to give (§7.1) — and it gives it to one account: the +first to redeem it. Both halves are therefore bearer secrets, so the link can +travel through any messaging service; what bounds a leaked one is that it works +once, for seven days, and that the owner can cancel it. -Stated per the design's convention: that binding holds against third parties and -not against this hub, which verifies the addresses it compares. An active hub -could already be anybody. +The address is optional and binds nothing. When given, it is where the hub +mails the link and a masked label in the owner's list. No route here answers without an account, and nothing tells the inviter whether an address has an account (M1): creating a link looks the same either way. @@ -30,7 +29,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub import mail from meshbay_hub.api.deps import _decode_token, get_current_user, require_user_scope from meshbay_hub.api.middleware import limiter -from meshbay_hub.auth import hash_email_blind from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import Group, GroupInviteLink, GroupMember, User @@ -40,9 +38,9 @@ redeem_router = APIRouter(prefix="/v1/invite-links", tags=["invite-links"]) # The node holds at most as many unredeemed link codes per group; one more # ticket here than codes there would be a link that cannot work. MAX_OUTSTANDING_PER_GROUP = 20 -# A node's invitation lifetime is the operator's setting (7 days by default); -# the ticket follows it, up to this. -MAX_LIFETIME = timedelta(days=30) +# The node issues a link's code for exactly this long; the ticket follows it, +# and never outlives it. +MAX_LIFETIME = timedelta(days=7) # How long a spent link is kept before it is forgotten. The owner is not shown # it — the person is in the group — but while the row is here, a reload or a # second tab of the invitation page still answers the account that used it. @@ -83,6 +81,8 @@ def _aware(when: datetime) -> datetime: def _valid_email(v: str) -> str: v = v.strip() + if not v: + return v local, sep, domain = v.partition("@") if (not sep or not local or not domain or "." not in domain.strip(".") or len(v) > 254 or any(c.isspace() or ord(c) < 32 for c in v)): @@ -91,7 +91,8 @@ def _valid_email(v: str) -> str: class CreateLinkRequest(BaseModel): - email: str + # Optional: only where the hub mails the link, and a label for the owner. + email: str = "" expires_at: str node_invite_id: str # Only when the hub is to mail the link, since only then must it write it: @@ -149,6 +150,8 @@ async def create_invite_link( if not _NODE_INVITE_ID.match(body.node_invite_id): raise HTTPException(status_code=422, detail="Not a node invitation id") if body.send_email: + if not body.email: + raise HTTPException(status_code=422, detail="Mailing a link needs an address") if payload.get("scope") == "node": raise HTTPException(status_code=403, detail="Invitation mail is sent from the interface only") @@ -179,7 +182,7 @@ async def create_invite_link( ticket = secrets.token_urlsafe(16) row = GroupInviteLink( group_id=group_id, created_by=current_user.id, ticket_hash=ticket_hash(ticket), - email_hash=hash_email_blind(body.email), email_masked=_mask(body.email), + email_masked=_mask(body.email) if body.email else None, node_invite_id=body.node_invite_id, expires_at=expires) db.add(row) await db.flush() @@ -225,7 +228,7 @@ async def list_invite_links( now = datetime.now(UTC) return {"links": [{ "link_id": r.id, - "email": r.email_masked, + "email": r.email_masked or "", "node_invite_id": r.node_invite_id, "created_at": _aware(r.created_at).isoformat(), "expires_at": _aware(r.expires_at).isoformat(), @@ -257,14 +260,10 @@ async def delete_invite_link( async def _resolve(db: AsyncSession, ticket: str, user: User ) -> tuple[GroupInviteLink, Group]: """ - The link this ticket names, if this account may use it. - - One uniform refusal for everything that says nothing about the account — - unknown, spent by someone else, expired, a group no longer active — and one - distinct answer, `invite_other_account`, for the case the person can act - on: signed in as somebody other than the address it was sent to. That - answer names no address, and it is given only to someone holding the - ticket, who already knows a link exists. + The link this ticket names, if this account may use it: any account while + nobody has, and afterwards only the one that did. One uniform refusal for + everything else — unknown, spent by someone else, expired, a group no + longer active. """ invalid = HTTPException(status_code=404, detail="invite_not_valid") if not _TICKET.match(ticket or ""): @@ -280,8 +279,6 @@ async def _resolve(db: AsyncSession, ticket: str, user: User raise invalid if not row.redeemed_by and _aware(row.expires_at) <= datetime.now(UTC): raise invalid - if not user.email_hash or user.email_hash != row.email_hash: - raise HTTPException(status_code=403, detail="invite_other_account") return row, group @@ -312,8 +309,8 @@ async def redeem_invite_link( db: AsyncSession = Depends(get_db), ): """ - Membership for the addressed account, once — and the same answer again for - that account, because a second tab or a reload is the same person. + Membership for the first account that asks, once — and the same answer + again for that account, because a second tab or a reload is the same person. """ row, group = await _resolve(db, body.ticket, current_user) if not row.redeemed_by: diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py new file mode 100644 index 0000000..ae9d2c9 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/c4d5e6f7a8b9_invite_links_unbound.py @@ -0,0 +1,34 @@ +"""invitation links bind no address + +A link is now redeemable by whichever account opens it first, so it can be sent +through any messaging service. The address, when the inviter gives one, is only +where the hub mails the link and a masked label in the owner's list. + +Revision ID: c4d5e6f7a8b9 +Revises: b2c3d4e5f6a7 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "c4d5e6f7a8b9" +down_revision: str | Sequence[str] | None = "b2c3d4e5f6a7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("group_invite_links") as batch: + batch.drop_column("email_hash") + batch.alter_column("email_masked", existing_type=sa.String(128), nullable=True) + + +def downgrade() -> None: + # The bound address cannot be recovered; outstanding links are dropped. + op.execute("DELETE FROM group_invite_links") + with op.batch_alter_table("group_invite_links") as batch: + batch.alter_column("email_masked", existing_type=sa.String(128), nullable=False) + batch.add_column(sa.Column("email_hash", sa.String(64), nullable=False, + server_default="")) diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index 51a3d47..09eb437 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -151,12 +151,12 @@ class GroupInviteLink(Base): A link carries two secrets. The node's code decides whether someone gets the group key, and the hub never sees it. This row decides whether someone may - *reach* the node at all — membership, which is all the hub has to give — and - only for the account whose verified address matches `email_hash`. The ticket - is stored as `sha256(ticket)`, so a copy of this table opens nothing. + *reach* the node at all — membership, which is all the hub has to give — for + the first account that redeems it. The ticket is stored as `sha256(ticket)`, + so a copy of this table opens nothing. - No address in the clear: `email_hash` is the same blind index `users` has, - and `email_masked` is what the owner's list shows (`al***@ex***.com`). + The address is optional and binds nothing: when the inviter gave one, + `email_masked` is what the owner's list shows (`al***@ex***.com`). """ __tablename__ = "group_invite_links" @@ -164,8 +164,7 @@ class GroupInviteLink(Base): group_id: Mapped[str] = mapped_column(ForeignKey("groups.id"), nullable=False) created_by: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) ticket_hash: Mapped[str] = mapped_column(String(64), nullable=False) - email_hash: Mapped[str] = mapped_column(String(64), nullable=False) - email_masked: Mapped[str] = mapped_column(String(128), nullable=False) + email_masked: Mapped[str | None] = mapped_column(String(128)) # The node's handle for its half, so cancelling can take back both. node_invite_id: Mapped[str] = mapped_column(String(32), nullable=False, default="") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py index d36a829..b382849 100644 --- a/packages/meshbay-hub/src/meshbay_hub/mail.py +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -469,7 +469,7 @@ def send_invite_link(to: str, link: str, inviter: str, group_name: str) -> None: "\n" f"{link}\n" "\n" - "It works once, and only for an account registered with this address.\n" + "It works once, and for seven days.\n" "If you did not expect it, you can ignore this message.\n" ) _send(msg, purpose="invite_link") diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index 529a33d..a510e63 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -897,10 +897,11 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // ── Invitation links (docs/MESHBAY_DESIGN.md §3.4) ────────────────── // // Two halves, in the order that leaves nothing half-made: the node's code - // first, then the hub's ticket bound to the address; a ticket the hub then - // refuses takes the code back with it, since a code nobody can reach the node - // with only occupies one of the group's twenty places. The code reaches the - // hub only when the box asks the hub to write the mail. + // first, then the hub's ticket; a ticket the hub then refuses takes the code + // back with it, since a code nobody can reach the node with only occupies one + // of the group's twenty places. The address is optional and binds nothing: + // the link is for whoever opens it first, so it can go by any messaging app. + // The code reaches the hub only when the box asks the hub to write the mail. const [linkEmail, setLinkEmail] = useState(''); const [linking, setLinking] = useState(false); const [linkError, setLinkError] = useState(''); @@ -932,7 +933,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, const doCreateLink = useCallback(async (e) => { e.preventDefault(); const email = linkEmail.trim(); - if (!email) return; + const mailIt = inviteByEmail && Boolean(email); setLinking(true); setLinkError(''); setNewLink(null); @@ -951,8 +952,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, method: 'POST', token, body: { email, expires_at: node.expires_at, node_invite_id: node.invite_id, - send_email: inviteByEmail, - ...(inviteByEmail ? { node_pk: n, code: node.code } : {}), + send_email: mailIt, + ...(mailIt ? { node_pk: n, code: node.code } : {}), }, }); } catch (err) { @@ -960,7 +961,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, throw err; } setNewLink({ - email, link: inviteLinkHere({ g: groupId, t: ticket.ticket, n, c: node.code }), emailStatus: ticket.email_status, }); @@ -1004,6 +1004,16 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } catch { /* the field is selectable */ } }, [newLink]); + // The system share sheet, where there is one (phones mostly): the way a link + // reaches a messaging app without a round trip through the clipboard. + const canShare = typeof navigator !== 'undefined' && typeof navigator.share === 'function'; + const shareLink = useCallback(async () => { + if (!newLink) return; + try { + await navigator.share({ title: t('members.link_share_title'), url: newLink.link }); + } catch { /* dismissed; the field and Copy are still there */ } + }, [newLink]); + if (loading) return html`

${t('explore.loading')}

`; const isOwner = Boolean(isAdmin); @@ -1067,13 +1077,17 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${newLink && html`
-

${t('members.link_ready', { email: newLink.email })}

+

${t('members.link_ready')}

e.target.select()} /> + ${canShare && html` + `}
${newLink.emailStatus === 'sent' ? html`

${t('members.link_email_sent')}

` @@ -1085,7 +1099,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
setLinkEmail(e.target.value)} - disabled=${!connected || !operatorPaired} required /> + disabled=${!connected || !operatorPaired} />
`; - } else if (phase === 'other') { - body = html` -

${t('invite.other_account')}

-
- -
`; } else if (phase === 'invalid') { body = html`

${t('invite.invalid')}

`; } else { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 215f596..7bd5169 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -698,10 +698,10 @@ export default { 'members.invite_email_failed': 'Die E-Mail konnte nicht gesendet werden — bitte teilen Sie den Code manuell mit.', 'members.invite_email_opt': 'Einladung per E-Mail senden (kann im Spam landen)', 'members.link_title': "Per Link einladen", - 'members.link_hint': "Für jemanden, der vielleicht noch kein Konto hat. Der Link funktioniert einmal und nur für ein Konto mit dieser Adresse.", - 'members.link_email_placeholder': "E-Mail-Adresse", + 'members.link_hint': "Für jemanden, der vielleicht noch kein Konto hat. Verschicken Sie ihn, wie Sie möchten — Messenger, SMS. Der Link funktioniert einmal, sieben Tage lang, für die Person, die ihn zuerst öffnet.", + 'members.link_email_placeholder': "E-Mail-Adresse (optional)", 'members.link_btn': "Link erstellen", - 'members.link_ready': "Einladungslink für {email}:", + 'members.link_ready': "Einladungslink — 7 Tage gültig, einmalig verwendbar:", 'members.link_copy': "Kopieren", 'members.link_copied': "Kopiert", 'members.link_email_sent': "Der Link wurde per E-Mail gesendet.", @@ -710,9 +710,12 @@ export default { 'members.link_status_expired': "abgelaufen", 'members.link_expires': "läuft ab am {date}", 'members.link_cancel': "Abbrechen", + 'members.link_share': "Teilen", + 'members.link_share_title': "Einladung", + 'members.link_unlabelled': "Link vom {date}", 'invite.title': "Sie wurden eingeladen", 'invite.none': "In diesem Tab wartet keine Einladung. Öffnen Sie den erhaltenen Link erneut.", - 'invite.signed_out': "Jemand hat Sie in eine Gruppe auf diesem Hub eingeladen. Erstellen Sie ein Konto mit der E-Mail-Adresse, an die die Einladung ging, oder melden Sie sich an, falls Sie bereits eines haben.", + 'invite.signed_out': "Jemand hat Sie in eine Gruppe auf diesem Hub eingeladen. Erstellen Sie ein Konto, oder melden Sie sich an, falls Sie bereits eines haben.", 'invite.register': "Konto erstellen", 'invite.signin': "Anmelden", 'invite.confirm': "{inviter} lädt Sie in {group} ein.", @@ -720,7 +723,6 @@ export default { 'invite.ignore': "Ignorieren", 'invite.open': "Gruppe öffnen", 'invite.already_member': "Sie sind bereits Mitglied von {group}.", - 'invite.other_account': "Diese Einladung wurde an eine andere E-Mail-Adresse gesendet. Melden Sie sich mit dem Konto dieser Adresse an — Aliasse und Punkte müssen genau übereinstimmen.", 'invite.invalid': "Diese Einladung ist nicht mehr gültig: Sie wurde verwendet, widerrufen oder ist abgelaufen. Bitten Sie um eine neue.", 'invite.joining': "Beitritt…", 'invite.after_register': "Melden Sie sich an, um der Gruppe beizutreten, in die Sie eingeladen wurden.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 4c4cf38..0c9cb19 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -814,10 +814,10 @@ export default { 'members.invite_email_failed': 'Could not send the email — share the code manually.', 'members.invite_email_opt': 'Send the invitation by e-mail (may land in spam)', 'members.link_title': "Invite by link", - 'members.link_hint': "For someone who may not have an account yet. The link works once, and only for an account registered with this address.", - 'members.link_email_placeholder': "E-mail address", + 'members.link_hint': "For someone who may not have an account yet. Send it however you like — a messaging app, a text. It works once, for seven days, for whoever opens it first.", + 'members.link_email_placeholder': "E-mail address (optional)", 'members.link_btn': "Create link", - 'members.link_ready': "Invitation link for {email}:", + 'members.link_ready': "Invitation link — valid 7 days, single use:", 'members.link_copy': "Copy", 'members.link_copied': "Copied", 'members.link_email_sent': "The link has been sent by e-mail.", @@ -826,9 +826,12 @@ export default { 'members.link_status_expired': "expired", 'members.link_expires': "expires {date}", 'members.link_cancel': "Cancel", + 'members.link_share': "Share", + 'members.link_share_title': "Invitation", + 'members.link_unlabelled': "link created {date}", 'invite.title': "You have been invited", 'invite.none': "There is no invitation waiting in this tab. Open the link you received again.", - 'invite.signed_out': "Someone invited you to a group on this hub. Create an account with the e-mail address the invitation was sent to, or sign in if you already have one.", + 'invite.signed_out': "Someone invited you to a group on this hub. Create an account, or sign in if you already have one.", 'invite.register': "Create an account", 'invite.signin': "Sign in", 'invite.confirm': "{inviter} invites you to join {group}.", @@ -836,7 +839,6 @@ export default { 'invite.ignore': "Ignore", 'invite.open': "Open the group", 'invite.already_member': "You are already a member of {group}.", - 'invite.other_account': "This invitation was sent to another e-mail address. Sign in with the account registered with that address — aliases and dots must match exactly.", 'invite.invalid': "This invitation is no longer valid: it has been used, cancelled or has expired. Ask for a new one.", 'invite.joining': "Joining…", 'invite.after_register': "Sign in to join the group you were invited to.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 9f5158f..1399a83 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -693,10 +693,10 @@ export default { 'members.invite_email_failed': 'No se pudo enviar el correo — comparta el código manualmente.', 'members.invite_email_opt': 'Enviar la invitación por correo (puede llegar a spam)', 'members.link_title': "Invitar con un enlace", - 'members.link_hint': "Para alguien que quizá aún no tenga cuenta. El enlace sirve una vez, y solo para una cuenta registrada con esta dirección.", - 'members.link_email_placeholder': "Dirección de correo", + 'members.link_hint': "Para alguien que quizá aún no tenga cuenta. Envíelo como prefiera — mensajería, SMS. El enlace sirve una sola vez, durante siete días, para quien lo abra primero.", + 'members.link_email_placeholder': "Dirección de correo (opcional)", 'members.link_btn': "Crear enlace", - 'members.link_ready': "Enlace de invitación para {email}:", + 'members.link_ready': "Enlace de invitación — válido 7 días, un solo uso:", 'members.link_copy': "Copiar", 'members.link_copied': "Copiado", 'members.link_email_sent': "El enlace se ha enviado por correo.", @@ -705,9 +705,12 @@ export default { 'members.link_status_expired': "caducado", 'members.link_expires': "caduca el {date}", 'members.link_cancel': "Cancelar", + 'members.link_share': "Compartir", + 'members.link_share_title': "Invitación", + 'members.link_unlabelled': "enlace creado el {date}", 'invite.title': "Le han invitado", 'invite.none': "No hay ninguna invitación esperando en esta pestaña. Vuelva a abrir el enlace que recibió.", - 'invite.signed_out': "Alguien le ha invitado a un grupo en este hub. Cree una cuenta con la dirección de correo a la que se envió la invitación, o inicie sesión si ya tiene una.", + 'invite.signed_out': "Alguien le ha invitado a un grupo en este hub. Cree una cuenta, o inicie sesión si ya tiene una.", 'invite.register': "Crear una cuenta", 'invite.signin': "Iniciar sesión", 'invite.confirm': "{inviter} le invita a unirse a {group}.", @@ -715,7 +718,6 @@ export default { 'invite.ignore': "Ignorar", 'invite.open': "Abrir el grupo", 'invite.already_member': "Ya es miembro de {group}.", - 'invite.other_account': "Esta invitación se envió a otra dirección de correo. Inicie sesión con la cuenta registrada con esa dirección — los alias y los puntos deben coincidir exactamente.", 'invite.invalid': "Esta invitación ya no es válida: se ha usado, cancelado o ha caducado. Pida una nueva.", 'invite.joining': "Uniéndose…", 'invite.after_register': "Inicie sesión para unirse al grupo al que le invitaron.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index c072d5b..ae278d9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -696,10 +696,10 @@ export default { 'members.invite_email_failed': "Impossible d’envoyer l’e-mail — partagez le code manuellement.", 'members.invite_email_opt': 'Envoyer l’invitation par e-mail (risque d’arriver dans les spams)', 'members.link_title': "Inviter par lien", - 'members.link_hint': "Pour quelqu’un qui n’a peut-être pas encore de compte. Le lien ne sert qu’une fois, et seulement pour un compte créé avec cette adresse.", - 'members.link_email_placeholder': "Adresse e-mail", + 'members.link_hint': "Pour quelqu’un qui n’a peut-être pas encore de compte. Envoyez-le comme vous voulez — messagerie, SMS. Le lien ne sert qu’une fois, pendant sept jours, à la première personne qui l’ouvre.", + 'members.link_email_placeholder': "Adresse e-mail (facultative)", 'members.link_btn': "Créer le lien", - 'members.link_ready': "Lien d’invitation pour {email} :", + 'members.link_ready': "Lien d’invitation — valable 7 jours, usage unique :", 'members.link_copy': "Copier", 'members.link_copied': "Copié", 'members.link_email_sent': "Le lien a été envoyé par e-mail.", @@ -708,9 +708,12 @@ export default { 'members.link_status_expired': "expiré", 'members.link_expires': "expire le {date}", 'members.link_cancel': "Annuler", + 'members.link_share': "Partager", + 'members.link_share_title': "Invitation", + 'members.link_unlabelled': "lien créé le {date}", 'invite.title': "Vous êtes invité", 'invite.none': "Aucune invitation n’attend dans cet onglet. Rouvrez le lien que vous avez reçu.", - 'invite.signed_out': "Quelqu’un vous a invité dans un groupe sur ce hub. Créez un compte avec l’adresse e-mail à laquelle l’invitation a été envoyée, ou connectez-vous si vous en avez déjà un.", + 'invite.signed_out': "Quelqu’un vous a invité dans un groupe sur ce hub. Créez un compte, ou connectez-vous si vous en avez déjà un.", 'invite.register': "Créer un compte", 'invite.signin': "Se connecter", 'invite.confirm': "{inviter} vous invite à rejoindre {group}.", @@ -718,7 +721,6 @@ export default { 'invite.ignore': "Ignorer", 'invite.open': "Ouvrir le groupe", 'invite.already_member': "Vous êtes déjà membre de {group}.", - 'invite.other_account': "Cette invitation a été envoyée à une autre adresse e-mail. Connectez-vous avec le compte créé avec cette adresse — les alias et les points doivent correspondre exactement.", 'invite.invalid': "Cette invitation n’est plus valable : elle a été utilisée, annulée ou a expiré. Demandez-en une nouvelle.", 'invite.joining': "Adhésion…", 'invite.after_register': "Connectez-vous pour rejoindre le groupe auquel vous avez été invité.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 28c6a3d..f0fb0d1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -696,10 +696,10 @@ export default { 'members.invite_email_failed': "Impossibile inviare l'e-mail — condivida il codice manualmente.", 'members.invite_email_opt': 'Invia l’invito per e-mail (potrebbe finire nello spam)', 'members.link_title': "Invita tramite link", - 'members.link_hint': "Per chi forse non ha ancora un account. Il link funziona una volta, e solo per un account registrato con questo indirizzo.", - 'members.link_email_placeholder': "Indirizzo e-mail", + 'members.link_hint': "Per chi forse non ha ancora un account. Lo invii come preferisce — messaggistica, SMS. Il link funziona una sola volta, per sette giorni, per chi lo apre per primo.", + 'members.link_email_placeholder': "Indirizzo e-mail (facoltativo)", 'members.link_btn': "Crea link", - 'members.link_ready': "Link d’invito per {email}:", + 'members.link_ready': "Link di invito — valido 7 giorni, uso singolo:", 'members.link_copy': "Copia", 'members.link_copied': "Copiato", 'members.link_email_sent': "Il link è stato inviato per e-mail.", @@ -708,9 +708,12 @@ export default { 'members.link_status_expired': "scaduto", 'members.link_expires': "scade il {date}", 'members.link_cancel': "Annulla", + 'members.link_share': "Condividi", + 'members.link_share_title': "Invito", + 'members.link_unlabelled': "link creato il {date}", 'invite.title': "È stato invitato", 'invite.none': "Nessun invito in attesa in questa scheda. Riapra il link ricevuto.", - 'invite.signed_out': "Qualcuno l’ha invitata in un gruppo su questo hub. Crei un account con l’indirizzo e-mail a cui è stato inviato l’invito, o acceda se ne ha già uno.", + 'invite.signed_out': "Qualcuno l’ha invitata in un gruppo su questo hub. Crei un account, o acceda se ne ha già uno.", 'invite.register': "Crea un account", 'invite.signin': "Accedi", 'invite.confirm': "{inviter} la invita a unirsi a {group}.", @@ -718,7 +721,6 @@ export default { 'invite.ignore': "Ignora", 'invite.open': "Apri il gruppo", 'invite.already_member': "È già membro di {group}.", - 'invite.other_account': "Questo invito è stato inviato a un altro indirizzo e-mail. Acceda con l’account registrato con quell’indirizzo — alias e punti devono corrispondere esattamente.", 'invite.invalid': "Questo invito non è più valido: è stato usato, annullato o è scaduto. Ne chieda uno nuovo.", 'invite.joining': "Adesione…", 'invite.after_register': "Acceda per unirsi al gruppo a cui è stato invitato.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index f357881..560332d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -688,10 +688,10 @@ export default { 'members.invite_email_failed': 'メールを送信できませんでした。コードを手動で共有してください。', 'members.invite_email_opt': '招待をメールで送信(迷惑メールに入る場合があります)', 'members.link_title': "リンクで招待", - 'members.link_hint': "まだアカウントを持っていない人向けです。リンクは1回だけ、このアドレスで登録したアカウントでのみ使えます。", - 'members.link_email_placeholder': "メールアドレス", + 'members.link_hint': "まだアカウントを持っていないかもしれない人向けです。メッセージアプリやSMSなど、お好きな方法で送ってください。リンクは7日間有効で、最初に開いた人が一度だけ使えます。", + 'members.link_email_placeholder': "メールアドレス(任意)", 'members.link_btn': "リンクを作成", - 'members.link_ready': "{email} への招待リンク:", + 'members.link_ready': "招待リンク — 7日間有効、1回限り:", 'members.link_copy': "コピー", 'members.link_copied': "コピーしました", 'members.link_email_sent': "リンクをメールで送信しました。", @@ -700,9 +700,12 @@ export default { 'members.link_status_expired': "期限切れ", 'members.link_expires': "{date} に期限切れ", 'members.link_cancel': "取り消す", + 'members.link_share': "共有", + 'members.link_share_title': "招待", + 'members.link_unlabelled': "{date} に作成したリンク", 'invite.title': "招待されています", 'invite.none': "このタブで待機中の招待はありません。受け取ったリンクをもう一度開いてください。", - 'invite.signed_out': "このハブのグループに招待されています。招待が送られたメールアドレスでアカウントを作成するか、すでにお持ちならサインインしてください。", + 'invite.signed_out': "このハブのグループに招待されています。アカウントを作成するか、すでにお持ちならサインインしてください。", 'invite.register': "アカウントを作成", 'invite.signin': "サインイン", 'invite.confirm': "{inviter} さんが {group} への参加に招待しています。", @@ -710,7 +713,6 @@ export default { 'invite.ignore': "無視", 'invite.open': "グループを開く", 'invite.already_member': "すでに {group} のメンバーです。", - 'invite.other_account': "この招待は別のメールアドレスに送られました。そのアドレスで登録したアカウントでサインインしてください。エイリアスやドットも完全に一致する必要があります。", 'invite.invalid': "この招待は無効です。使用済み、取り消し済み、または期限切れです。新しい招待を依頼してください。", 'invite.joining': "参加しています…", 'invite.after_register': "招待されたグループに参加するにはサインインしてください。", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 64e1f00..adb94c6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -697,10 +697,10 @@ export default { 'members.invite_email_failed': 'Kon de e-mail niet verzenden — deel de code handmatig.', 'members.invite_email_opt': 'Uitnodiging per e-mail versturen (kan in spam belanden)', 'members.link_title': "Uitnodigen via link", - 'members.link_hint': "Voor iemand die misschien nog geen account heeft. De link werkt één keer, en alleen voor een account met dit adres.", - 'members.link_email_placeholder': "E-mailadres", + 'members.link_hint': "Voor iemand die misschien nog geen account heeft. Verstuur hem zoals u wilt — berichtenapp, sms. De link werkt één keer, zeven dagen lang, voor wie hem als eerste opent.", + 'members.link_email_placeholder': "E-mailadres (optioneel)", 'members.link_btn': "Link maken", - 'members.link_ready': "Uitnodigingslink voor {email}:", + 'members.link_ready': "Uitnodigingslink — 7 dagen geldig, eenmalig:", 'members.link_copy': "Kopiëren", 'members.link_copied': "Gekopieerd", 'members.link_email_sent': "De link is per e-mail verstuurd.", @@ -709,9 +709,12 @@ export default { 'members.link_status_expired': "verlopen", 'members.link_expires': "verloopt op {date}", 'members.link_cancel': "Annuleren", + 'members.link_share': "Delen", + 'members.link_share_title': "Uitnodiging", + 'members.link_unlabelled': "link gemaakt op {date}", 'invite.title': "U bent uitgenodigd", 'invite.none': "Er wacht geen uitnodiging in dit tabblad. Open de ontvangen link opnieuw.", - 'invite.signed_out': "Iemand heeft u uitgenodigd voor een groep op deze hub. Maak een account aan met het e-mailadres waarnaar de uitnodiging is gestuurd, of meld u aan als u er al een hebt.", + 'invite.signed_out': "Iemand heeft u uitgenodigd voor een groep op deze hub. Maak een account aan, of meld u aan als u er al een hebt.", 'invite.register': "Account maken", 'invite.signin': "Aanmelden", 'invite.confirm': "{inviter} nodigt u uit voor {group}.", @@ -719,7 +722,6 @@ export default { 'invite.ignore': "Negeren", 'invite.open': "Groep openen", 'invite.already_member': "U bent al lid van {group}.", - 'invite.other_account': "Deze uitnodiging is naar een ander e-mailadres gestuurd. Meld u aan met het account van dat adres — aliassen en punten moeten exact overeenkomen.", 'invite.invalid': "Deze uitnodiging is niet meer geldig: ze is gebruikt, geannuleerd of verlopen. Vraag een nieuwe.", 'invite.joining': "Deelnemen…", 'invite.after_register': "Meld u aan om deel te nemen aan de groep waarvoor u bent uitgenodigd.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 1d5f05f..fb5a4ed 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -709,10 +709,10 @@ export default { 'members.invite_email_failed': 'Nie udało się wysłać e-maila — przekaż kod ręcznie.', 'members.invite_email_opt': 'Wyślij zaproszenie e-mailem (może trafić do spamu)', 'members.link_title': "Zaproś linkiem", - 'members.link_hint': "Dla kogoś, kto może jeszcze nie mieć konta. Link działa raz i tylko dla konta założonego na ten adres.", - 'members.link_email_placeholder': "Adres e-mail", + 'members.link_hint': "Dla kogoś, kto może jeszcze nie mieć konta. Wyślij go, jak chcesz — komunikatorem, SMS-em. Link działa raz, przez siedem dni, dla osoby, która otworzy go pierwsza.", + 'members.link_email_placeholder': "Adres e-mail (opcjonalnie)", 'members.link_btn': "Utwórz link", - 'members.link_ready': "Link zaproszenia dla {email}:", + 'members.link_ready': "Link z zaproszeniem — ważny 7 dni, jednorazowy:", 'members.link_copy': "Kopiuj", 'members.link_copied': "Skopiowano", 'members.link_email_sent': "Link został wysłany e-mailem.", @@ -721,9 +721,12 @@ export default { 'members.link_status_expired': "wygasł", 'members.link_expires': "wygasa {date}", 'members.link_cancel': "Anuluj", + 'members.link_share': "Udostępnij", + 'members.link_share_title': "Zaproszenie", + 'members.link_unlabelled': "link utworzony {date}", 'invite.title': "Otrzymano zaproszenie", 'invite.none': "W tej karcie nie czeka żadne zaproszenie. Otwórz ponownie otrzymany link.", - 'invite.signed_out': "Ktoś zaprosił cię do grupy na tym hubie. Załóż konto na adres e-mail, na który wysłano zaproszenie, albo zaloguj się, jeśli już je masz.", + 'invite.signed_out': "Ktoś zaprosił cię do grupy na tym hubie. Załóż konto albo zaloguj się, jeśli już je masz.", 'invite.register': "Załóż konto", 'invite.signin': "Zaloguj się", 'invite.confirm': "{inviter} zaprasza cię do {group}.", @@ -731,7 +734,6 @@ export default { 'invite.ignore': "Ignoruj", 'invite.open': "Otwórz grupę", 'invite.already_member': "Jesteś już członkiem {group}.", - 'invite.other_account': "To zaproszenie wysłano na inny adres e-mail. Zaloguj się na konto założone na ten adres — aliasy i kropki muszą się dokładnie zgadzać.", 'invite.invalid': "To zaproszenie jest już nieważne: zostało użyte, anulowane lub wygasło. Poproś o nowe.", 'invite.joining': "Dołączanie…", 'invite.after_register': "Zaloguj się, aby dołączyć do grupy, do której cię zaproszono.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index c2cb35e..7bd11f6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -695,10 +695,10 @@ export default { 'members.invite_email_failed': 'Não foi possível enviar o e-mail — compartilhe o código manualmente.', 'members.invite_email_opt': 'Enviar o convite por e-mail (pode cair no spam)', 'members.link_title': "Convidar por link", - 'members.link_hint': "Para alguém que talvez ainda não tenha conta. O link funciona uma vez, e só para uma conta registrada com este endereço.", - 'members.link_email_placeholder': "Endereço de e-mail", + 'members.link_hint': "Para alguém que talvez ainda não tenha conta. Envie como preferir — aplicativo de mensagens, SMS. O link funciona uma vez, por sete dias, para quem abrir primeiro.", + 'members.link_email_placeholder': "Endereço de e-mail (opcional)", 'members.link_btn': "Criar link", - 'members.link_ready': "Link de convite para {email}:", + 'members.link_ready': "Link de convite — válido por 7 dias, uso único:", 'members.link_copy': "Copiar", 'members.link_copied': "Copiado", 'members.link_email_sent': "O link foi enviado por e-mail.", @@ -707,9 +707,12 @@ export default { 'members.link_status_expired': "expirado", 'members.link_expires': "expira em {date}", 'members.link_cancel': "Cancelar", + 'members.link_share': "Compartilhar", + 'members.link_share_title': "Convite", + 'members.link_unlabelled': "link criado em {date}", 'invite.title': "Você foi convidado", 'invite.none': "Não há convite aguardando nesta aba. Abra novamente o link que recebeu.", - 'invite.signed_out': "Alguém convidou você para um grupo neste hub. Crie uma conta com o endereço de e-mail para o qual o convite foi enviado, ou entre se já tiver uma.", + 'invite.signed_out': "Alguém convidou você para um grupo neste hub. Crie uma conta, ou entre se já tiver uma.", 'invite.register': "Criar uma conta", 'invite.signin': "Entrar", 'invite.confirm': "{inviter} convida você para participar de {group}.", @@ -717,7 +720,6 @@ export default { 'invite.ignore': "Ignorar", 'invite.open': "Abrir o grupo", 'invite.already_member': "Você já é membro de {group}.", - 'invite.other_account': "Este convite foi enviado para outro endereço de e-mail. Entre com a conta registrada com esse endereço — aliases e pontos devem coincidir exatamente.", 'invite.invalid': "Este convite não é mais válido: foi usado, cancelado ou expirou. Peça um novo.", 'invite.joining': "Entrando…", 'invite.after_register': "Entre para participar do grupo para o qual foi convidado.", diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 37c57aa..5509332 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -677,10 +677,10 @@ export default { 'members.invite_email_failed': '无法发送邮件——请手动分享验证码。', 'members.invite_email_opt': '通过电子邮件发送邀请(可能进入垃圾邮件)', 'members.link_title': "通过链接邀请", - 'members.link_hint': "适用于可能还没有账户的人。该链接只能使用一次,且仅限使用此地址注册的账户。", - 'members.link_email_placeholder': "电子邮件地址", + 'members.link_hint': "适用于可能还没有账户的人。可以用任何方式发送——即时通讯应用、短信。链接仅可使用一次,有效期七天,归最先打开的人。", + 'members.link_email_placeholder': "电子邮件地址(可选)", 'members.link_btn': "创建链接", - 'members.link_ready': "发给 {email} 的邀请链接:", + 'members.link_ready': "邀请链接——有效期 7 天,仅限一次:", 'members.link_copy': "复制", 'members.link_copied': "已复制", 'members.link_email_sent': "链接已通过电子邮件发送。", @@ -689,9 +689,12 @@ export default { 'members.link_status_expired': "已过期", 'members.link_expires': "{date} 过期", 'members.link_cancel': "取消", + 'members.link_share': "分享", + 'members.link_share_title': "邀请", + 'members.link_unlabelled': "{date} 创建的链接", 'invite.title': "您收到了邀请", 'invite.none': "此标签页中没有待处理的邀请。请重新打开您收到的链接。", - 'invite.signed_out': "有人邀请您加入此中心上的一个群组。请使用收到邀请的电子邮件地址创建账户,如果已有账户请登录。", + 'invite.signed_out': "有人邀请您加入此中心上的一个群组。请创建账户,如果已有账户请登录。", 'invite.register': "创建账户", 'invite.signin': "登录", 'invite.confirm': "{inviter} 邀请您加入 {group}。", @@ -699,7 +702,6 @@ export default { 'invite.ignore': "忽略", 'invite.open': "打开群组", 'invite.already_member': "您已经是 {group} 的成员。", - 'invite.other_account': "此邀请发送到了另一个电子邮件地址。请使用该地址注册的账户登录——别名和点号必须完全一致。", 'invite.invalid': "此邀请已失效:已被使用、取消或已过期。请索取新的邀请。", 'invite.joining': "正在加入…", 'invite.after_register': "登录以加入您受邀的群组。", -- cgit v1.2.3