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 are assigned to this group, which grants access to the chat app.
|
||||||
MEMBERS_GROUP_ID = os.environ.get("MEMBERS_GROUP_ID", "")
|
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.
|
# Persistent store (SQLite) for email → LiteLLM key token mapping.
|
||||||
# Lives in /app/data (Cloudron localstorage addon persists this).
|
# Lives in /app/data (Cloudron localstorage addon persists this).
|
||||||
DB_PATH = os.environ.get("DB_PATH", "/app/data/members.db")
|
DB_PATH = os.environ.get("DB_PATH", "/app/data/members.db")
|
||||||
@@ -87,6 +91,7 @@ def get_db() -> sqlite3.Connection:
|
|||||||
"email TEXT PRIMARY KEY, "
|
"email TEXT PRIMARY KEY, "
|
||||||
"key_token TEXT, "
|
"key_token TEXT, "
|
||||||
"cloudron_user_id TEXT, "
|
"cloudron_user_id TEXT, "
|
||||||
|
"slug TEXT, "
|
||||||
"active INTEGER DEFAULT 1"
|
"active INTEGER DEFAULT 1"
|
||||||
")"
|
")"
|
||||||
)
|
)
|
||||||
@@ -97,6 +102,10 @@ def get_db() -> sqlite3.Connection:
|
|||||||
"created_at TEXT DEFAULT (datetime('now'))"
|
"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
|
return conn
|
||||||
|
|
||||||
|
|
||||||
@@ -111,14 +120,14 @@ def store_pending_member(slug: str, name: str) -> None:
|
|||||||
conn.close()
|
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 = get_db()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO members (email, key_token, cloudron_user_id, active) "
|
"INSERT INTO members (email, key_token, cloudron_user_id, slug, active) "
|
||||||
"VALUES (?, ?, ?, 1) "
|
"VALUES (?, ?, ?, ?, 1) "
|
||||||
"ON CONFLICT(email) DO UPDATE SET key_token=excluded.key_token, "
|
"ON CONFLICT(email) DO UPDATE SET key_token=excluded.key_token, "
|
||||||
"cloudron_user_id=excluded.cloudron_user_id, active=1",
|
"cloudron_user_id=excluded.cloudron_user_id, slug=excluded.slug, active=1",
|
||||||
(email, key_token, cloudron_user_id),
|
(email, key_token, cloudron_user_id, slug),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -133,6 +142,18 @@ def get_member_key(email: str) -> str | None:
|
|||||||
return row[0] if row else 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:
|
def deactivate_member(email: str) -> str | None:
|
||||||
"""Mark a member inactive and return their key token (for deletion)."""
|
"""Mark a member inactive and return their key token (for deletion)."""
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
@@ -187,16 +208,17 @@ async def cloudron_create_user(email: str, name: str) -> str:
|
|||||||
return r.json()["id"]
|
return r.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
async def cloudron_set_group(user_id: str) -> None:
|
async def cloudron_set_group(user_id: str, group_id: str | None = None) -> None:
|
||||||
"""Assign a user to the members group (grants chat app access)."""
|
"""Assign a user to a group (defaults to the members group)."""
|
||||||
if not MEMBERS_GROUP_ID:
|
gid = group_id or MEMBERS_GROUP_ID
|
||||||
logger.warning("MEMBERS_GROUP_ID not set; skipping group assignment")
|
if not gid:
|
||||||
|
logger.warning("No group id; skipping group assignment")
|
||||||
return
|
return
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
r = await client.put(
|
r = await client.put(
|
||||||
f"{CLOUDRON_API}/api/v1/users/{user_id}/groups",
|
f"{CLOUDRON_API}/api/v1/users/{user_id}/groups",
|
||||||
headers=cloudron_headers(),
|
headers=cloudron_headers(),
|
||||||
json={"groupIds": [MEMBERS_GROUP_ID]},
|
json={"groupIds": [gid]},
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
@@ -322,8 +344,19 @@ async def opencollective_webhook(request: Request, token: str):
|
|||||||
return JSONResponse({"status": "pending", "name": name, "slug": slug})
|
return JSONResponse({"status": "pending", "name": name, "slug": slug})
|
||||||
|
|
||||||
if event_type in ("collective.member.deleted", "collective.transaction.deleted"):
|
if event_type in ("collective.member.deleted", "collective.transaction.deleted"):
|
||||||
# Lapsed member → deactivate (we may not have their email yet)
|
# Lapsed member → move to the inactive group (lose access, keep data).
|
||||||
return JSONResponse({"status": "deactivated"})
|
# 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})
|
return JSONResponse({"status": "ignored", "type": event_type})
|
||||||
|
|
||||||
@@ -462,16 +495,17 @@ async def oauth_callback(request: Request):
|
|||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(500, "No access token returned")
|
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(
|
r = await client.post(
|
||||||
OC_GRAPHQL_URL,
|
OC_GRAPHQL_URL,
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
json={"query": "{ me { id name email } }"},
|
json={"query": "{ me { id name email slug } }"},
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
me = r.json().get("data", {}).get("me", {})
|
me = r.json().get("data", {}).get("me", {})
|
||||||
email = me.get("email", "")
|
email = me.get("email", "")
|
||||||
name = me.get("name", "Member")
|
name = me.get("name", "Member")
|
||||||
|
slug = me.get("slug", "")
|
||||||
|
|
||||||
if not email:
|
if not email:
|
||||||
raise HTTPException(400, "No email returned — did you grant the email scope?")
|
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_group(user_id)
|
||||||
await cloudron_set_active(user_id, True)
|
await cloudron_set_active(user_id, True)
|
||||||
key_token = await litellm_create_key(email, MEMBER_BUDGET)
|
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)
|
await cloudron_send_invite(user_id, email)
|
||||||
|
|
||||||
logger.info("Provisioned member %s (user_id=%s)", email, user_id)
|
logger.info("Provisioned member %s (user_id=%s)", email, user_id)
|
||||||
|
|||||||
Reference in new issue
Block a user