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:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user