Add local Pinscope multi-user auth for shared projects.

Self-host email/password accounts enable the existing collaborator
invite flow without Clerk; first admin inherits users/local projects.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-11 14:31:37 +02:00
co-authored by Cursor
parent c5b260b404
commit 5a69b380da
22 changed files with 1045 additions and 116 deletions
+8
View File
@@ -41,6 +41,14 @@ async def is_admin(request: Request) -> bool:
request.state._is_admin = True
return True
if settings.use_local_auth:
from backend.services import local_users
user = local_users.get_user(user_id)
result = bool(user and user.is_admin)
request.state._is_admin = result
return result
# Fetch user from Clerk Backend API and check public_metadata.role
try:
async with httpx.AsyncClient() as client:
+73
View File
@@ -0,0 +1,73 @@
"""Local Pinscope auth endpoints (register / login / me)."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from backend.config import settings
from backend.services import local_jwt, local_users
router = APIRouter(prefix="/auth", tags=["auth"])
class RegisterRequest(BaseModel):
email: str
password: str = Field(min_length=8)
name: str | None = None
class LoginRequest(BaseModel):
email: str
password: str
def _require_local_auth() -> None:
if not settings.use_local_auth:
raise HTTPException(
400,
"Local auth is not enabled. Set AUTH_JWT_SECRET on the server.",
)
@router.get("/mode")
async def auth_mode():
"""Public: how the frontend should authenticate."""
if settings.use_clerk:
return {"mode": "clerk", "auth_enabled": True}
if settings.use_local_auth:
return {"mode": "local", "auth_enabled": True}
return {"mode": "off", "auth_enabled": False}
@router.post("/register")
async def register(body: RegisterRequest):
_require_local_auth()
try:
user = local_users.create_user(body.email, body.password, body.name)
except ValueError as e:
raise HTTPException(400, str(e)) from e
token = local_jwt.issue_token(user.user_id, user.email)
return {"token": token, "user": user.public()}
@router.post("/login")
async def login(body: LoginRequest):
_require_local_auth()
user = local_users.authenticate(body.email, body.password)
if not user:
raise HTTPException(401, "Invalid email or password")
token = local_jwt.issue_token(user.user_id, user.email)
return {"token": token, "user": user.public()}
@router.get("/me")
async def me(request: Request):
_require_local_auth()
user_id = getattr(request.state, "user_id", None)
if not user_id or user_id == "local" or user_id == "anonymous":
raise HTTPException(401, "Authentication required")
user = local_users.get_user(user_id)
if not user:
raise HTTPException(401, "User not found")
return user.public()
+17 -43
View File
@@ -716,27 +716,17 @@ async def list_collaborators(project_id: str, request: Request):
all_user_ids = [owner_user_id] + [c for c in meta.collaborators if c != owner_user_id]
collaborators = []
if settings.use_auth:
async with httpx.AsyncClient() as client:
for uid in all_user_ids:
entry: dict = {"user_id": uid, "name": None, "email": None, "image_url": None,
"role": "owner" if uid == owner_user_id else "collaborator"}
try:
resp = await client.get(
f"https://api.clerk.com/v1/users/{uid}",
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
if resp.status_code == 200:
clerk = resp.json()
first = clerk.get("first_name") or ""
last = clerk.get("last_name") or ""
entry["name"] = f"{first} {last}".strip() or None
emails = clerk.get("email_addresses", [])
if emails:
entry["email"] = emails[0].get("email_address")
entry["image_url"] = clerk.get("image_url")
except Exception:
pass
collaborators.append(entry)
from backend.services.user_directory import get_user_profile
for uid in all_user_ids:
profile = await get_user_profile(uid)
collaborators.append({
"user_id": uid,
"name": profile.get("name"),
"email": profile.get("email"),
"image_url": profile.get("image_url"),
"role": "owner" if uid == owner_user_id else "collaborator",
})
else:
# Local dev — just return user_ids without enrichment
collaborators = [
@@ -762,22 +752,9 @@ async def add_collaborator(project_id: str, req: AddCollaboratorRequest, request
if not settings.use_auth:
raise HTTPException(400, "Collaboration requires authentication to be enabled")
# Look up user by email via Clerk Backend API
async with httpx.AsyncClient() as client:
resp = await client.get(
"https://api.clerk.com/v1/users",
params={"email_address": [req.email]},
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
if resp.status_code != 200:
raise HTTPException(502, "Failed to look up user")
from backend.services.user_directory import find_user_id_by_email, get_user_profile
users = resp.json()
if not users:
raise HTTPException(404, "No user found with that email")
clerk_user = users[0]
collab_user_id = clerk_user.get("id")
collab_user_id = await find_user_id_by_email(req.email)
if not collab_user_id:
raise HTTPException(404, "No user found with that email")
@@ -791,15 +768,12 @@ async def add_collaborator(project_id: str, req: AddCollaboratorRequest, request
proj_svc.add_collaborator(storage, user_id, project_id, collab_user_id)
# Return the collaborator info
first = clerk_user.get("first_name") or ""
last = clerk_user.get("last_name") or ""
emails = clerk_user.get("email_addresses", [])
profile = await get_user_profile(collab_user_id)
return {
"user_id": collab_user_id,
"name": f"{first} {last}".strip() or None,
"email": emails[0].get("email_address") if emails else None,
"image_url": clerk_user.get("image_url"),
"name": profile.get("name"),
"email": profile.get("email"),
"image_url": profile.get("image_url"),
}