Implement deactivation: move lapsed members to 'inactive' group (keep data), map webhook slug→email
This commit is contained in:
1 parent
051f898667
commit
e291406b0b
1 file changed
+49
-15
+49
-15
@@ -65,6 +65,10 @@ MEMBER_BUDGET = float(os.environ.get("MEMBER_BUDGET", "15.0"))
|
||||
# Members are assigned to this group, which grants access to the chat app.
|
||||
MEMBERS_GROUP_ID = os.environ.get("MEMBERS_GROUP_ID", "")
|
||||
|
||||
# The "inactive" group in Cloudron. Lapsed members are moved here (instead of
|
||||
# being deleted) so they lose access but can reclaim their data on reactivation.
|
||||
INACTIVE_GROUP_ID = os.environ.get("INACTIVE_GROUP_ID", "")
|
||||
|
||||
# Persistent store (SQLite) for email → LiteLLM key token mapping.
|
||||
# Lives in /app/data (Cloudron localstorage addon persists this).
|
||||
DB_PATH = os.environ.get("DB_PATH", "/app/data/members.db")
|
||||
@@ -87,6 +91,7 @@ def get_db() -> sqlite3.Connection:
|
||||
"email TEXT PRIMARY KEY, "
|
||||
"key_token TEXT, "
|
||||
"cloudron_user_id TEXT, "
|
||||
"slug TEXT, "
|
||||
"active INTEGER DEFAULT 1"
|
||||
")"
|
||||
)
|
||||
@@ -97,6 +102,10 @@ def get_db() -> sqlite3.Connection:
|
||||
"created_at TEXT DEFAULT (datetime('now'))"
|
||||
")"
|
||||
)
|
||||
# Migration: add slug column if the members table predates it.
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(members)").fetchall()]
|
||||
if "slug" not in cols:
|
||||
conn.execute("ALTER TABLE members ADD COLUMN slug TEXT")
|
||||
return conn
|
||||
|
||||
|
||||
@@ -111,14 +120,14 @@ def store_pending_member(slug: str, name: str) -> None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def store_member(email: str, key_token: str, cloudron_user_id: str) -> None:
|
||||
def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None:
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO members (email, key_token, cloudron_user_id, active) "
|
||||
"VALUES (?, ?, ?, 1) "
|
||||
"INSERT INTO members (email, key_token, cloudron_user_id, slug, active) "
|
||||
"VALUES (?, ?, ?, ?, 1) "
|
||||
"ON CONFLICT(email) DO UPDATE SET key_token=excluded.key_token, "
|
||||
"cloudron_user_id=excluded.cloudron_user_id, active=1",
|
||||
(email, key_token, cloudron_user_id),
|
||||
"cloudron_user_id=excluded.cloudron_user_id, slug=excluded.slug, active=1",
|
||||
(email, key_token, cloudron_user_id, slug),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -133,6 +142,18 @@ def get_member_key(email: str) -> str | None:
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def get_member_by_slug(slug: str) -> dict | None:
|
||||
"""Look up a member (email, key_token, cloudron_user_id) by their OC slug."""
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT email, key_token, cloudron_user_id FROM members WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
return {"email": row[0], "key_token": row[1], "cloudron_user_id": row[2]}
|
||||
|
||||
|
||||
def deactivate_member(email: str) -> str | None:
|
||||
"""Mark a member inactive and return their key token (for deletion)."""
|
||||
conn = get_db()
|
||||
@@ -187,16 +208,17 @@ async def cloudron_create_user(email: str, name: str) -> str:
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
async def cloudron_set_group(user_id: str) -> None:
|
||||
"""Assign a user to the members group (grants chat app access)."""
|
||||
if not MEMBERS_GROUP_ID:
|
||||
logger.warning("MEMBERS_GROUP_ID not set; skipping group assignment")
|
||||
async def cloudron_set_group(user_id: str, group_id: str | None = None) -> None:
|
||||
"""Assign a user to a group (defaults to the members group)."""
|
||||
gid = group_id or MEMBERS_GROUP_ID
|
||||
if not gid:
|
||||
logger.warning("No group id; skipping group assignment")
|
||||
return
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.put(
|
||||
f"{CLOUDRON_API}/api/v1/users/{user_id}/groups",
|
||||
headers=cloudron_headers(),
|
||||
json={"groupIds": [MEMBERS_GROUP_ID]},
|
||||
json={"groupIds": [gid]},
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
@@ -322,8 +344,19 @@ async def opencollective_webhook(request: Request, token: str):
|
||||
return JSONResponse({"status": "pending", "name": name, "slug": slug})
|
||||
|
||||
if event_type in ("collective.member.deleted", "collective.transaction.deleted"):
|
||||
# Lapsed member → deactivate (we may not have their email yet)
|
||||
return JSONResponse({"status": "deactivated"})
|
||||
# Lapsed member → move to the inactive group (lose access, keep data).
|
||||
# The webhook gives us the slug; we map it to the member's email/user.
|
||||
if slug:
|
||||
member = get_member_by_slug(slug)
|
||||
if member:
|
||||
# Move to inactive group (revokes chat access, keeps account + data)
|
||||
if INACTIVE_GROUP_ID and member.get("cloudron_user_id"):
|
||||
await cloudron_set_group(member["cloudron_user_id"], INACTIVE_GROUP_ID)
|
||||
# Mark inactive in our DB (key injector will refuse requests)
|
||||
deactivate_member(member["email"])
|
||||
logger.info("Deactivated member %s (slug=%s)", member["email"], slug)
|
||||
return JSONResponse({"status": "deactivated", "email": member["email"]})
|
||||
return JSONResponse({"status": "deactivated", "note": "no matching member"})
|
||||
|
||||
return JSONResponse({"status": "ignored", "type": event_type})
|
||||
|
||||
@@ -462,16 +495,17 @@ async def oauth_callback(request: Request):
|
||||
if not token:
|
||||
raise HTTPException(500, "No access token returned")
|
||||
|
||||
# Fetch the user's email
|
||||
# Fetch the user's email + slug (slug lets us map webhook events back)
|
||||
r = await client.post(
|
||||
OC_GRAPHQL_URL,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"query": "{ me { id name email } }"},
|
||||
json={"query": "{ me { id name email slug } }"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
me = r.json().get("data", {}).get("me", {})
|
||||
email = me.get("email", "")
|
||||
name = me.get("name", "Member")
|
||||
slug = me.get("slug", "")
|
||||
|
||||
if not email:
|
||||
raise HTTPException(400, "No email returned — did you grant the email scope?")
|
||||
@@ -481,7 +515,7 @@ async def oauth_callback(request: Request):
|
||||
await cloudron_set_group(user_id)
|
||||
await cloudron_set_active(user_id, True)
|
||||
key_token = await litellm_create_key(email, MEMBER_BUDGET)
|
||||
store_member(email, key_token, user_id)
|
||||
store_member(email, key_token, user_id, slug)
|
||||
await cloudron_send_invite(user_id, email)
|
||||
|
||||
logger.info("Provisioned member %s (user_id=%s)", email, user_id)
|
||||
|
||||
Reference in new issue
Block a user