From ef253d01a0f94d8b83cb0a5c23e7055dfdf60a32 Mon Sep 17 00:00:00 2001 From: inference-bot Date: Fri, 2 Oct 2026 08:36:10 -0600 Subject: [PATCH] Loomio: fix auto-generated usernames after sync (b3 server API; prefer Cloudron uid, fall back to email local-part) --- app/main.py | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/app/main.py b/app/main.py index 7008f58..05e686e 100644 --- a/app/main.py +++ b/app/main.py @@ -170,6 +170,11 @@ MANUAL_MEMBERS = { # group to the current active-member list via the User API (/api/b2). LOOMIO_BASE = os.environ.get("LOOMIO_BASE", "https://forum.inference.coop") LOOMIO_API_KEY = os.environ.get("LOOMIO_API_KEY", "") +# Server API key (B3) — used to fix auto-generated Loomio usernames after +# invites. Loomio's b2/memberships API only takes emails; new users get a +# mangled auto-handle (email local-part + random suffix). The B3 users API +# lets us rename them to the member's Cloudron username. +LOOMIO_B3_KEY = os.environ.get("LOOMIO_B3_KEY", "") LOOMIO_GROUP_ID = os.environ.get("LOOMIO_GROUP_ID", "") # Operator accounts that must always remain in the Loomio group (never removed # by the remove_absent reconciliation), comma-separated. @@ -772,6 +777,51 @@ async def get_active_member_emails() -> list[str]: return [r[0] for r in rows] +async def loomio_fix_usernames(client: httpx.AsyncClient) -> dict: + """Rename Loomio users with auto-generated handles to their Cloudron username. + + Loomio's b2/memberships invite creates the account with a mangled handle + (email local-part + random suffix, e.g. danishipleydsdk4ibdihka). After + first SSO login the user carries an oauth identity whose uid IS their + Cloudron username — prefer that; fall back to the email local-part. + Uses the server (b3) API. Returns a {fixed, skipped} summary. + """ + H = {"Authorization": f"Bearer {LOOMIO_B3_KEY}", "Content-Type": "application/json"} + r = await client.get(f"{LOOMIO_BASE}/api/b3/users", headers=H) + r.raise_for_status() + users = r.json().get("users", []) + + fixed, skipped = 0, 0 + for u in users: + target = None + for ident in u.get("identities") or []: + if ident.get("identity_type") == "oauth" and ident.get("uid"): + uid = ident["uid"].lower() + # Loomio usernames: lowercase letters, numbers, underscores only. + if uid.replace("_", "").isalnum() and len(uid) >= 2: + target = uid + break + target = (u.get("email") or "").split("@")[0].lower() or None + break + if not target and u.get("email") and "@" in u["email"]: + target = u["email"].split("@")[0].lower() + if not target or u.get("username") == target: + skipped += 1 + continue + rr = await client.patch( + f"{LOOMIO_BASE}/api/b3/users/{u['id']}", + headers=H, json={"username": target}, + ) + if rr.status_code == 200: + fixed += 1 + else: + logger.warning("Loomio username fix for %s failed: %s %s", + u.get("email"), rr.status_code, rr.text[:120]) + if fixed: + logger.info("Loomio usernames fixed: %s", fixed) + return {"fixed": fixed, "skipped": skipped} + + async def sync_loomio_memberships() -> None: """Reconcile the Loomio group to the current active-member list. @@ -805,6 +855,16 @@ async def sync_loomio_memberships() -> None: result.get("removed_emails", []), ) + # Fix auto-generated usernames (email+randomsuffix) → Cloudron username. + # Runs after every sync so newly invited members get corrected within a day + # (or immediately when the sync follows provisioning). Best-effort: no B3 + # key or API failure just logs and moves on. + if LOOMIO_B3_KEY: + try: + await loomio_fix_usernames(client) + except Exception as e: + logger.warning("Loomio username fix pass failed: %s", e) + def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None: conn = get_db()