Loomio: fix auto-generated usernames after sync (b3 server API; prefer Cloudron uid, fall back to email local-part)

This commit is contained in:
inference-bot committed 2026-10-02 08:36:10 -06:00
1 parent 070c6525cd
commit ef253d01a0
1 file changed
+60
+60
View File
@@ -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()