Add OAuth 'connect your account' flow: /join page, /oauth/start, /oauth/callback (provisions member)
This commit is contained in:
1 parent
ce7d9f3220
commit
808357215d
1 file changed
+165
-17
+165
-17
@@ -45,6 +45,17 @@ PORTAL_SECRET = os.environ.get("PORTAL_SECRET", "")
|
||||
# is the standard way to authenticate them.
|
||||
WEBHOOK_TOKEN = os.environ.get("WEBHOOK_TOKEN", "")
|
||||
|
||||
# Open Collective OAuth app credentials (for the "connect your account" flow,
|
||||
# which is how we obtain a member's email — the webhook strips it for privacy).
|
||||
OC_OAUTH_CLIENT_ID = os.environ.get("OC_OAUTH_CLIENT_ID", "")
|
||||
OC_OAUTH_CLIENT_SECRET = os.environ.get("OC_OAUTH_CLIENT_SECRET", "")
|
||||
OC_OAUTH_REDIRECT_URI = os.environ.get(
|
||||
"OC_OAUTH_REDIRECT_URI", "https://portal.inference.coop/oauth/callback"
|
||||
)
|
||||
OC_AUTHORIZE_URL = "https://opencollective.com/oauth/authorize"
|
||||
OC_TOKEN_URL = "https://opencollective.com/oauth/token"
|
||||
OC_GRAPHQL_URL = "https://opencollective.com/api/graphql/v2"
|
||||
|
||||
# Monthly credit budget (in USD of tokens) for all members.
|
||||
# Single sliding-scale tier: everyone gets the same $15/month in credits,
|
||||
# regardless of their $10/15/20 contribution. Governance decision (Loomio).
|
||||
@@ -79,9 +90,27 @@ def get_db() -> sqlite3.Connection:
|
||||
"active INTEGER DEFAULT 1"
|
||||
")"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS pending_members ("
|
||||
"slug TEXT PRIMARY KEY, "
|
||||
"name TEXT, "
|
||||
"created_at TEXT DEFAULT (datetime('now'))"
|
||||
")"
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
def store_pending_member(slug: str, name: str) -> None:
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO pending_members (slug, name) VALUES (?, ?) "
|
||||
"ON CONFLICT(slug) DO UPDATE SET name=excluded.name",
|
||||
(slug, name),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def store_member(email: str, key_token: str, cloudron_user_id: str) -> None:
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
@@ -229,14 +258,13 @@ async def opencollective_webhook(request: Request, token: str):
|
||||
event_type = payload.get("type", "")
|
||||
data = payload.get("data", {})
|
||||
member = data.get("member", {}) or data.get("fromCollective", {})
|
||||
email = member.get("email") or data.get("email")
|
||||
# The webhook does NOT include email (Open Collective strips it for
|
||||
# privacy). We get name + slug, store a pending member, and obtain the
|
||||
# email later via the OAuth "connect your account" flow.
|
||||
name = member.get("name", "Member")
|
||||
slug = member.get("slug", "")
|
||||
|
||||
if not email:
|
||||
logger.warning("Webhook without email: %s", event_type)
|
||||
return JSONResponse({"status": "ignored", "reason": "no email"})
|
||||
|
||||
logger.info("Open Collective event: %s for %s", event_type, email)
|
||||
logger.info("Open Collective event: %s (name=%s, slug=%s)", event_type, name, slug)
|
||||
|
||||
# Open Collective webhook events (verified):
|
||||
# - "order.processed" → fires on EVERY payment (incl. monthly recurring)
|
||||
@@ -244,18 +272,13 @@ async def opencollective_webhook(request: Request, token: str):
|
||||
# - payload has "firstPayment" boolean to distinguish new vs recurring
|
||||
# - "collective.transaction.created" is DEPRECATED (being removed)
|
||||
if event_type in ("order.processed", "new.member", "collective.member.created"):
|
||||
# New or renewed member → ensure active
|
||||
user_id = await cloudron_create_user(email, name)
|
||||
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)
|
||||
return JSONResponse({"status": "activated", "user_id": user_id, "budget": MEMBER_BUDGET})
|
||||
# New or renewed member → store as pending; they complete via OAuth
|
||||
if slug:
|
||||
store_pending_member(slug, name)
|
||||
return JSONResponse({"status": "pending", "name": name, "slug": slug})
|
||||
|
||||
if event_type in ("collective.member.deleted", "collective.transaction.deleted"):
|
||||
# Lapsed member → deactivate
|
||||
key_token = deactivate_member(email)
|
||||
await litellm_disable_key(key_token)
|
||||
# Lapsed member → deactivate (we may not have their email yet)
|
||||
return JSONResponse({"status": "deactivated"})
|
||||
|
||||
return JSONResponse({"status": "ignored", "type": event_type})
|
||||
@@ -317,7 +340,132 @@ async def inject_key(request: Request, path: str):
|
||||
)
|
||||
|
||||
|
||||
# --- C. Admin / health ---
|
||||
# --- C. OAuth "connect your account" flow ---
|
||||
|
||||
@app.get("/join")
|
||||
async def join_page():
|
||||
"""The 'finish your setup' page — where a new member connects their
|
||||
Open Collective account so we can obtain their email (with consent)."""
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Finish your setup — Inference Cooperative</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background: #faf8f5; color: #2d3327; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 24px; }
|
||||
.card { background: #fff; border: 1px solid #e8e2d8; border-radius: 12px; padding: 40px; max-width: 480px; text-align: center; }
|
||||
h1 { font-size: 24px; margin: 0 0 12px; }
|
||||
p { color: #6b7a62; line-height: 1.5; margin: 0 0 20px; }
|
||||
.btn { display: inline-block; background: #5b8c5a; color: #fff; text-decoration: none; padding: 14px 32px; border-radius: 10px; font-weight: 600; }
|
||||
.btn:hover { opacity: 0.9; }
|
||||
.note { font-size: 13px; color: #94a08c; margin-top: 20px; }
|
||||
.note a { color: #6b7a62; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Finish your setup</h1>
|
||||
<p>Thanks for joining the Inference Cooperative! To set up your account, connect your Open Collective account so we can verify your membership.</p>
|
||||
<a class="btn" href="/oauth/start">Connect Open Collective</a>
|
||||
<p class="note">You'll receive an account-setup email shortly after connecting.<br>Didn't get it? Contact <a href="mailto:info@inference.coop">info@inference.coop</a>.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return Response(content=html, media_type="text/html")
|
||||
|
||||
|
||||
@app.get("/oauth/start")
|
||||
async def oauth_start():
|
||||
"""Redirect the user to Open Collective's consent screen."""
|
||||
state = secrets.token_urlsafe(16)
|
||||
params = {
|
||||
"client_id": OC_OAUTH_CLIENT_ID,
|
||||
"response_type": "code",
|
||||
"redirect_uri": OC_OAUTH_REDIRECT_URI,
|
||||
"scope": "email",
|
||||
"state": state,
|
||||
}
|
||||
qs = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
return Response(
|
||||
status_code=302,
|
||||
headers={"Location": f"{OC_AUTHORIZE_URL}?{qs}"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/oauth/callback")
|
||||
async def oauth_callback(request: Request):
|
||||
"""Exchange the OAuth code for a token, fetch the email, and provision."""
|
||||
code = request.query_params.get("code", "")
|
||||
if not code:
|
||||
raise HTTPException(400, "Missing code")
|
||||
|
||||
# Exchange code for access token
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
OC_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": OC_OAUTH_CLIENT_ID,
|
||||
"client_secret": OC_OAUTH_CLIENT_SECRET,
|
||||
"code": code,
|
||||
"redirect_uri": OC_OAUTH_REDIRECT_URI,
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
token = r.json().get("access_token", "")
|
||||
|
||||
if not token:
|
||||
raise HTTPException(500, "No access token returned")
|
||||
|
||||
# Fetch the user's email
|
||||
r = await client.post(
|
||||
OC_GRAPHQL_URL,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"query": "{ me { id name email } }"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
me = r.json().get("data", {}).get("me", {})
|
||||
email = me.get("email", "")
|
||||
name = me.get("name", "Member")
|
||||
|
||||
if not email:
|
||||
raise HTTPException(400, "No email returned — did you grant the email scope?")
|
||||
|
||||
# Provision the member
|
||||
user_id = await cloudron_create_user(email, name)
|
||||
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)
|
||||
|
||||
logger.info("Provisioned member %s (user_id=%s)", email, user_id)
|
||||
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>You're in — Inference Cooperative</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background: #faf8f5; color: #2d3327; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 24px; }
|
||||
.card { background: #fff; border: 1px solid #e8e2d8; border-radius: 12px; padding: 40px; max-width: 480px; text-align: center; }
|
||||
h1 { font-size: 24px; margin: 0 0 12px; }
|
||||
p { color: #6b7a62; line-height: 1.5; margin: 0 0 20px; }
|
||||
.note { font-size: 13px; color: #94a08c; margin-top: 20px; }
|
||||
.note a { color: #6b7a62; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>You're in!</h1>
|
||||
<p>Your account is being set up. Check your email for a link to create your account and start chatting.</p>
|
||||
<p class="note">Didn't get it? Contact <a href="mailto:info@inference.coop">info@inference.coop</a>.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return Response(content=html, media_type="text/html")
|
||||
|
||||
|
||||
# --- D. Admin / health ---
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
|
||||
Reference in new issue
Block a user