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
+12 -1
View File
@@ -52,7 +52,18 @@ GCS_BUCKET=
# Frontend URL(s), JSON list # Frontend URL(s), JSON list
CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000","http://localhost:18742","http://127.0.0.1:18742"] CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000","http://localhost:18742","http://127.0.0.1:18742"]
# -- DigiKey (optional) ------------------------------------------------------ # -- Auth (self-host) --------------------------------------------------------
# Set AUTH_JWT_SECRET to enable Pinscope email/password accounts and
# multi-user project collaborators (invite by email). Clerk keys, if set,
# take priority over local auth.
# AUTH_JWT_SECRET=change-me-to-a-long-random-string
# Comma-separated emails that are admin on register (first user is always admin)
# AUTH_ADMIN_EMAILS=you@example.com
# -- Clerk (optional cloud auth) ---------------------------------------------
# CLERK_SECRET_KEY=
# CLERK_PUBLISHABLE_KEY=
# CLERK_JWKS_URL=
# Optional catalog datasheet source and parameter-based passive auto-resolve. # Optional catalog datasheet source and parameter-based passive auto-resolve.
# Order: BOM URL → LCSC → manufacturer PDF URLs → Mouser → DigiKey. # Order: BOM URL → LCSC → manufacturer PDF URLs → Mouser → DigiKey.
# DIGIKEY_CLIENT_ID= # DIGIKEY_CLIENT_ID=
+18 -2
View File
@@ -119,11 +119,18 @@ class Settings(BaseSettings):
# GCS (if set, use GCSStorageBackend; otherwise LocalStorageBackend) # GCS (if set, use GCSStorageBackend; otherwise LocalStorageBackend)
gcs_bucket: str = "" gcs_bucket: str = ""
# Clerk authentication # Clerk authentication (cloud). When set, takes priority over local auth.
clerk_secret_key: str = "" clerk_secret_key: str = ""
clerk_publishable_key: str = "" clerk_publishable_key: str = ""
clerk_jwks_url: str = "" clerk_jwks_url: str = ""
# Local Pinscope auth (self-host). Set AUTH_JWT_SECRET to enable email/password
# accounts and multi-user project collaborators without Clerk.
auth_jwt_secret: str = ""
# Comma-separated emails that become admin on register (in addition to the
# first account, which is always admin).
auth_admin_emails: str = ""
# DigiKey API (optional — enables auto-fetch datasheets) # DigiKey API (optional — enables auto-fetch datasheets)
digikey_client_id: str = "" digikey_client_id: str = ""
digikey_client_secret: str = "" digikey_client_secret: str = ""
@@ -212,9 +219,18 @@ class Settings(BaseSettings):
return bool(self.gcs_bucket) return bool(self.gcs_bucket)
@property @property
def use_auth(self) -> bool: def use_clerk(self) -> bool:
return bool(self.clerk_secret_key and self.clerk_jwks_url) return bool(self.clerk_secret_key and self.clerk_jwks_url)
@property
def use_local_auth(self) -> bool:
"""Self-host email/password auth when JWT secret is set and Clerk is not."""
return bool(self.auth_jwt_secret) and not self.use_clerk
@property
def use_auth(self) -> bool:
return self.use_clerk or self.use_local_auth
@property @property
def use_email(self) -> bool: def use_email(self) -> bool:
return bool(self.email_sender and self.email_frontend_url) return bool(self.email_sender and self.email_frontend_url)
+23 -10
View File
@@ -10,7 +10,7 @@ from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from backend.config import settings from backend.config import settings
from backend.routers import admin, contact, feedback, impedance, pipeline, projects, reports, survey from backend.routers import admin, auth, contact, feedback, impedance, pipeline, projects, reports, survey
from backend.services.projects import ProjectNotFound from backend.services.projects import ProjectNotFound
from backend.services.storage import LocalStorageBackend from backend.services.storage import LocalStorageBackend
@@ -35,14 +35,18 @@ async def lifespan(app: FastAPI):
env = os.getenv("ENVIRONMENT", "").lower() env = os.getenv("ENVIRONMENT", "").lower()
if env == "production" and not settings.use_auth: if env == "production" and not settings.use_auth:
raise RuntimeError( raise RuntimeError(
"CLERK_JWKS_URL and CLERK_SECRET_KEY must be set in production. " "Production requires authentication: set AUTH_JWT_SECRET "
"Authentication cannot be disabled in production." "(local Pinscope accounts) or CLERK_JWKS_URL + CLERK_SECRET_KEY."
) )
if not settings.use_auth: if not settings.use_auth:
logger.warning( logger.warning(
"Authentication is DISABLED — all users have full access. " "Authentication is DISABLED — all users have full access. "
"This is only safe for local development." "This is only safe for local development."
) )
elif settings.use_local_auth:
logger.info("Local Pinscope authentication enabled (AUTH_JWT_SECRET)")
elif settings.use_clerk:
logger.info("Clerk authentication enabled")
if not settings.billing_enabled: if not settings.billing_enabled:
logger.warning( logger.warning(
"Billing is DISABLED — pipelines run free and the billing/credits " "Billing is DISABLED — pipelines run free and the billing/credits "
@@ -55,6 +59,8 @@ async def lifespan(app: FastAPI):
if isinstance(app.state.storage, LocalStorageBackend): if isinstance(app.state.storage, LocalStorageBackend):
base = settings.data_dir base = settings.data_dir
(base / "users").mkdir(parents=True, exist_ok=True) (base / "users").mkdir(parents=True, exist_ok=True)
(base / "auth" / "users").mkdir(parents=True, exist_ok=True)
(base / "auth" / "by_email").mkdir(parents=True, exist_ok=True)
(base / "library" / "extracted").mkdir(parents=True, exist_ok=True) (base / "library" / "extracted").mkdir(parents=True, exist_ok=True)
(base / "library" / "patterns").mkdir(parents=True, exist_ok=True) (base / "library" / "patterns").mkdir(parents=True, exist_ok=True)
(base / "library" / "models").mkdir(parents=True, exist_ok=True) (base / "library" / "models").mkdir(parents=True, exist_ok=True)
@@ -84,31 +90,37 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
class AuthMiddleware(BaseHTTPMiddleware): class AuthMiddleware(BaseHTTPMiddleware):
"""Extract user_id from Clerk JWT or default to local dev user.""" """Extract user_id from JWT (Clerk or local) or default to local dev user."""
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
# Let CORS preflight through — browsers send OPTIONS without credentials # Let CORS preflight through — browsers send OPTIONS without credentials
if request.method == "OPTIONS": if request.method == "OPTIONS":
return await call_next(request) return await call_next(request)
# Public endpoints that don't require authentication # Public endpoints that don't require authentication
if request.url.path == "/api/contact": if request.url.path in {
"/api/contact",
"/api/auth/mode",
"/api/auth/register",
"/api/auth/login",
}:
request.state.user_id = LOCAL_DEV_USER request.state.user_id = LOCAL_DEV_USER
return await call_next(request) return await call_next(request)
if settings.use_auth: if settings.use_auth:
from backend.middleware.auth import verify_clerk_token from backend.middleware.auth import verify_request_user
user_id = await verify_clerk_token(request) user_id = await verify_request_user(request)
if user_id is None: if user_id is None:
is_production = os.getenv("ENVIRONMENT", "").lower() == "production" is_production = os.getenv("ENVIRONMENT", "").lower() == "production"
if is_production: # Local auth (and production) require a valid token for API routes.
if is_production or settings.use_local_auth:
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
return JSONResponse( return JSONResponse(
status_code=401, status_code=401,
content={"detail": "Authentication required"}, content={"detail": "Authentication required"},
) )
# Non-production: fall back to local dev user so Clerk config # Non-production Clerk: fall back so missing token doesn't block
# doesn't block local development when no token is present. # local development when Clerk is configured but unused.
user_id = LOCAL_DEV_USER user_id = LOCAL_DEV_USER
request.state.user_id = user_id request.state.user_id = user_id
else: else:
@@ -149,6 +161,7 @@ app.include_router(pipeline.router, prefix="/api")
app.include_router(reports.router, prefix="/api") app.include_router(reports.router, prefix="/api")
app.include_router(impedance.router, prefix="/api") app.include_router(impedance.router, prefix="/api")
app.include_router(admin.router, prefix="/api") app.include_router(admin.router, prefix="/api")
app.include_router(auth.router, prefix="/api")
if settings.billing_enabled: if settings.billing_enabled:
# Import guarded too: with billing disabled the core never loads the # Import guarded too: with billing disabled the core never loads the
# billing/credits routers (or, transitively, the Stripe SDK). # billing/credits routers (or, transitively, the Stripe SDK).
+59 -34
View File
@@ -1,12 +1,7 @@
"""Clerk JWT verification for FastAPI. """JWT verification for FastAPI (Clerk JWKS or local Pinscope HS256)."""
Validates JWT tokens from the Authorization header against Clerk's JWKS endpoint.
Extracts user_id (sub claim) for per-user storage scoping.
"""
from __future__ import annotations from __future__ import annotations
import time
from typing import Any from typing import Any
import jwt import jwt
@@ -16,7 +11,17 @@ from backend.config import settings
# JWKS cache # JWKS cache
_jwks_client: jwt.PyJWKClient | None = None _jwks_client: jwt.PyJWKClient | None = None
_SKIP_PATHS = {"/docs", "/openapi.json", "/redoc", "/health", "/api/billing/webhook"} _SKIP_PATHS = {
"/docs",
"/openapi.json",
"/redoc",
"/health",
"/api/billing/webhook",
"/api/auth/mode",
"/api/auth/register",
"/api/auth/login",
"/api/contact",
}
def _get_jwks_client() -> jwt.PyJWKClient: def _get_jwks_client() -> jwt.PyJWKClient:
@@ -24,38 +29,30 @@ def _get_jwks_client() -> jwt.PyJWKClient:
if _jwks_client is None: if _jwks_client is None:
jwks_url = settings.clerk_jwks_url jwks_url = settings.clerk_jwks_url
if not jwks_url: if not jwks_url:
# Default Clerk JWKS URL derived from publishable key
# Clerk publishable keys start with pk_test_ or pk_live_
# JWKS is at https://{clerk-frontend-api}/.well-known/jwks.json
# The user must set CLERK_JWKS_URL explicitly
raise RuntimeError( raise RuntimeError(
"CLERK_JWKS_URL must be set for authentication. " "CLERK_JWKS_URL must be set for Clerk authentication. "
"Find it in your Clerk dashboard under API Keys." "Find it in your Clerk dashboard under API Keys."
) )
_jwks_client = jwt.PyJWKClient(jwks_url, cache_keys=True) _jwks_client = jwt.PyJWKClient(jwks_url, cache_keys=True)
return _jwks_client return _jwks_client
async def verify_clerk_token(request: Request) -> str | None: def _bearer_or_query_token(request: Request) -> str | None:
"""Verify Clerk JWT and return user_id, or None if invalid. auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
return auth_header[7:]
# EventSource/SSE can't send headers
return request.query_params.get("token")
Returns None for:
- Missing Authorization header async def verify_clerk_token(request: Request) -> str | None:
- Invalid/expired token """Verify Clerk JWT and return user_id, or None if invalid."""
- Skip paths (docs, health)
"""
# Skip auth for docs/health endpoints
if request.url.path in _SKIP_PATHS: if request.url.path in _SKIP_PATHS:
return "anonymous" return "anonymous"
auth_header = request.headers.get("authorization", "") token = _bearer_or_query_token(request)
if not auth_header.startswith("Bearer "): if not token:
# Fallback: check query param (EventSource/SSE can't send headers) return None
token = request.query_params.get("token")
if not token:
return None
else:
token = auth_header[7:]
try: try:
client = _get_jwks_client() client = _get_jwks_client()
@@ -67,19 +64,20 @@ async def verify_clerk_token(request: Request) -> str | None:
algorithms=["RS256"], algorithms=["RS256"],
options={ options={
"verify_exp": True, "verify_exp": True,
"verify_aud": False, # Clerk doesn't always set aud "verify_aud": False,
"verify_iss": True, "verify_iss": True,
}, },
# Clerk tokens use the Clerk instance URL as issuer issuer=(
# e.g. https://abc123.clerk.accounts.dev from https://abc123.clerk.accounts.dev/.well-known/jwks.json settings.clerk_jwks_url.replace("/.well-known/jwks.json", "")
issuer=settings.clerk_jwks_url.replace("/.well-known/jwks.json", "") if settings.clerk_jwks_url else None, if settings.clerk_jwks_url
leeway=10, # 10 second clock skew tolerance else None
),
leeway=10,
) )
user_id = payload.get("sub") user_id = payload.get("sub")
if not user_id: if not user_id:
return None return None
return user_id return user_id
except jwt.ExpiredSignatureError: except jwt.ExpiredSignatureError:
@@ -88,3 +86,30 @@ async def verify_clerk_token(request: Request) -> str | None:
return None return None
except Exception: except Exception:
return None return None
async def verify_local_token(request: Request) -> str | None:
"""Verify Pinscope local JWT and return user_id, or None if invalid."""
if request.url.path in _SKIP_PATHS:
return "anonymous"
token = _bearer_or_query_token(request)
if not token:
return None
from backend.services.local_jwt import decode_token
payload = decode_token(token)
if not payload:
return None
user_id = payload.get("sub")
return str(user_id) if user_id else None
async def verify_request_user(request: Request) -> str | None:
"""Dispatch to Clerk or local JWT verification."""
if settings.use_clerk:
return await verify_clerk_token(request)
if settings.use_local_auth:
return await verify_local_token(request)
return None
+8
View File
@@ -41,6 +41,14 @@ async def is_admin(request: Request) -> bool:
request.state._is_admin = True request.state._is_admin = True
return 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 # Fetch user from Clerk Backend API and check public_metadata.role
try: try:
async with httpx.AsyncClient() as client: 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] all_user_ids = [owner_user_id] + [c for c in meta.collaborators if c != owner_user_id]
collaborators = [] collaborators = []
if settings.use_auth: if settings.use_auth:
async with httpx.AsyncClient() as client: from backend.services.user_directory import get_user_profile
for uid in all_user_ids:
entry: dict = {"user_id": uid, "name": None, "email": None, "image_url": None, for uid in all_user_ids:
"role": "owner" if uid == owner_user_id else "collaborator"} profile = await get_user_profile(uid)
try: collaborators.append({
resp = await client.get( "user_id": uid,
f"https://api.clerk.com/v1/users/{uid}", "name": profile.get("name"),
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"}, "email": profile.get("email"),
) "image_url": profile.get("image_url"),
if resp.status_code == 200: "role": "owner" if uid == owner_user_id else "collaborator",
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)
else: else:
# Local dev — just return user_ids without enrichment # Local dev — just return user_ids without enrichment
collaborators = [ collaborators = [
@@ -762,22 +752,9 @@ async def add_collaborator(project_id: str, req: AddCollaboratorRequest, request
if not settings.use_auth: if not settings.use_auth:
raise HTTPException(400, "Collaboration requires authentication to be enabled") raise HTTPException(400, "Collaboration requires authentication to be enabled")
# Look up user by email via Clerk Backend API from backend.services.user_directory import find_user_id_by_email, get_user_profile
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")
users = resp.json() collab_user_id = await find_user_id_by_email(req.email)
if not users:
raise HTTPException(404, "No user found with that email")
clerk_user = users[0]
collab_user_id = clerk_user.get("id")
if not collab_user_id: if not collab_user_id:
raise HTTPException(404, "No user found with that email") 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) proj_svc.add_collaborator(storage, user_id, project_id, collab_user_id)
# Return the collaborator info profile = await get_user_profile(collab_user_id)
first = clerk_user.get("first_name") or ""
last = clerk_user.get("last_name") or ""
emails = clerk_user.get("email_addresses", [])
return { return {
"user_id": collab_user_id, "user_id": collab_user_id,
"name": f"{first} {last}".strip() or None, "name": profile.get("name"),
"email": emails[0].get("email_address") if emails else None, "email": profile.get("email"),
"image_url": clerk_user.get("image_url"), "image_url": profile.get("image_url"),
} }
+45
View File
@@ -0,0 +1,45 @@
"""Pinscope local JWT helpers (HS256)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from backend.config import settings
ALGORITHM = "HS256"
TOKEN_TTL_DAYS = 30
def issue_token(user_id: str, email: str) -> str:
secret = settings.auth_jwt_secret
if not secret:
raise RuntimeError("AUTH_JWT_SECRET is not configured")
now = datetime.now(timezone.utc)
payload = {
"sub": user_id,
"email": email,
"iss": "pinscope-local",
"iat": now,
"exp": now + timedelta(days=TOKEN_TTL_DAYS),
}
return jwt.encode(payload, secret, algorithm=ALGORITHM)
def decode_token(token: str) -> dict[str, Any] | None:
secret = settings.auth_jwt_secret
if not secret:
return None
try:
return jwt.decode(
token,
secret,
algorithms=[ALGORITHM],
issuer="pinscope-local",
options={"verify_aud": False},
leeway=10,
)
except jwt.PyJWTError:
return None
+204
View File
@@ -0,0 +1,204 @@
"""Local Pinscope user directory (self-host auth, no Clerk).
Users live under ``data/auth/users/{user_id}.json`` with an email index.
Passwords use stdlib ``hashlib.scrypt``.
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
import secrets
import shutil
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
from backend.config import settings
logger = logging.getLogger(__name__)
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@dataclass
class LocalUser:
user_id: str
email: str
name: str | None
password_hash: str
is_admin: bool = False
created_at: str = ""
def public(self) -> dict:
return {
"user_id": self.user_id,
"email": self.email,
"name": self.name,
"is_admin": self.is_admin,
}
def _auth_root() -> Path:
root = Path(settings.data_dir) / "auth"
(root / "users").mkdir(parents=True, exist_ok=True)
(root / "by_email").mkdir(parents=True, exist_ok=True)
return root
def _email_key(email: str) -> str:
return email.strip().lower()
def _email_path(email: str) -> Path:
# Filesystem-safe key from normalized email
key = _email_key(email).replace("/", "_")
return _auth_root() / "by_email" / f"{key}.json"
def _user_path(user_id: str) -> Path:
return _auth_root() / "users" / f"{user_id}.json"
def hash_password(password: str, *, salt: bytes | None = None) -> str:
if salt is None:
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1, dklen=32
)
return f"scrypt${salt.hex()}${digest.hex()}"
def verify_password(password: str, encoded: str) -> bool:
try:
algo, salt_hex, digest_hex = encoded.split("$", 2)
except ValueError:
return False
if algo != "scrypt":
return False
salt = bytes.fromhex(salt_hex)
check = hash_password(password, salt=salt)
return secrets.compare_digest(check, encoded)
def validate_email(email: str) -> str:
e = email.strip().lower()
if not _EMAIL_RE.match(e):
raise ValueError("Invalid email address")
return e
def validate_password(password: str) -> None:
if len(password) < 8:
raise ValueError("Password must be at least 8 characters")
def get_user(user_id: str) -> LocalUser | None:
path = _user_path(user_id)
if not path.is_file():
return None
data = json.loads(path.read_text(encoding="utf-8"))
return LocalUser(**data)
def find_by_email(email: str) -> LocalUser | None:
path = _email_path(email)
if not path.is_file():
return None
data = json.loads(path.read_text(encoding="utf-8"))
uid = data.get("user_id")
if not uid:
return None
return get_user(uid)
def list_users() -> list[LocalUser]:
users_dir = _auth_root() / "users"
out: list[LocalUser] = []
for path in sorted(users_dir.glob("*.json")):
try:
out.append(LocalUser(**json.loads(path.read_text(encoding="utf-8"))))
except Exception:
logger.exception("Skipping corrupt user file %s", path)
return out
def _save_user(user: LocalUser) -> None:
_user_path(user.user_id).write_text(
json.dumps(asdict(user), indent=2) + "\n", encoding="utf-8"
)
_email_path(user.email).write_text(
json.dumps({"user_id": user.user_id}) + "\n", encoding="utf-8"
)
def user_count() -> int:
return len(list((_auth_root() / "users").glob("*.json")))
def _migrate_local_projects(new_owner_id: str) -> int:
"""Move ``users/local/projects/*`` under the first admin, if present."""
local_projects = Path(settings.data_dir) / "users" / "local" / "projects"
if not local_projects.is_dir():
return 0
dest_root = Path(settings.data_dir) / "users" / new_owner_id / "projects"
dest_root.mkdir(parents=True, exist_ok=True)
moved = 0
for child in local_projects.iterdir():
if not child.is_dir():
continue
target = dest_root / child.name
if target.exists():
continue
shutil.move(str(child), str(target))
moved += 1
logger.info("Migrated project %s → user %s", child.name, new_owner_id)
return moved
def create_user(email: str, password: str, name: str | None = None) -> LocalUser:
email = validate_email(email)
validate_password(password)
if find_by_email(email):
raise ValueError("An account with that email already exists")
from datetime import datetime, timezone
first = user_count() == 0
admin_emails = {
e.strip().lower()
for e in (settings.auth_admin_emails or "").split(",")
if e.strip()
}
is_admin = first or email in admin_emails
user = LocalUser(
user_id="usr_" + uuid.uuid4().hex,
email=email,
name=(name or "").strip() or None,
password_hash=hash_password(password),
is_admin=is_admin,
created_at=datetime.now(timezone.utc).isoformat(),
)
_save_user(user)
if first:
try:
n = _migrate_local_projects(user.user_id)
if n:
logger.info("First admin inherited %s local project(s)", n)
except Exception:
logger.exception("Failed to migrate users/local projects")
return user
def authenticate(email: str, password: str) -> LocalUser | None:
user = find_by_email(email)
if not user:
return None
if not verify_password(password, user.password_hash):
return None
return user
+81
View File
@@ -0,0 +1,81 @@
"""User profile lookup for collaborators / admin — Clerk or local auth."""
from __future__ import annotations
import logging
from backend.config import settings
logger = logging.getLogger(__name__)
async def find_user_id_by_email(email: str) -> str | None:
email = email.strip().lower()
if not email:
return None
if settings.use_clerk:
import httpx
async with httpx.AsyncClient() as client:
resp = await client.get(
"https://api.clerk.com/v1/users",
params={"email_address": [email]},
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
)
if resp.status_code != 200:
logger.warning("Clerk email lookup failed: %s", resp.status_code)
return None
users = resp.json()
if not users:
return None
return users[0].get("id")
if settings.use_local_auth:
from backend.services import local_users
user = local_users.find_by_email(email)
return user.user_id if user else None
return None
async def get_user_profile(user_id: str) -> dict:
"""Return {user_id, name, email, image_url, is_admin?}."""
entry = {
"user_id": user_id,
"name": None,
"email": None,
"image_url": None,
"is_admin": False,
}
if settings.use_clerk:
import httpx
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"https://api.clerk.com/v1/users/{user_id}",
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")
role = (clerk.get("public_metadata") or {}).get("role")
entry["is_admin"] = role == "admin"
except Exception:
logger.exception("Clerk profile fetch failed for %s", user_id)
return entry
if settings.use_local_auth:
from backend.services import local_users
user = local_users.get_user(user_id)
if user:
entry["name"] = user.name
entry["email"] = user.email
entry["is_admin"] = user.is_admin
return entry
return entry
+1
View File
@@ -32,6 +32,7 @@ services:
dockerfile: dockerfile dockerfile: dockerfile
args: args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
NEXT_PUBLIC_AUTH_MODE: ${NEXT_PUBLIC_AUTH_MODE:-}
container_name: pinscope-frontend container_name: pinscope-frontend
restart: unless-stopped restart: unless-stopped
+8
View File
@@ -2,6 +2,14 @@
What's new in Pinscope. What's new in Pinscope.
## 2.27.0 — 2026-09-11 — Local multi-user auth
Self-host Pinscope accounts (email + password) so several people can share a project. Invite collaborators by email from the project page — same flow as cloud, without Clerk.
- [New] `AUTH_JWT_SECRET` enables register/login; first user is admin and inherits `users/local` projects.
- [New] `/sign-in` and `/sign-up`; sidebar account menu. Set `NEXT_PUBLIC_AUTH_MODE=local` on the frontend build.
- [Changed] Collaborator lookup works for local users; Clerk still used when its keys are set.
## 2.26.5 — 2026-09-11 — Prefer PCB nets for connectivity ## 2.26.5 — 2026-09-11 — Prefer PCB nets for connectivity
When a `.kicad_pcb` is present, pad nets from the board drive the design graph. Schematic geometry alone was inventing swapped rails (GND↔3V3) and floating pins on good KiCad 10 designs. When a `.kicad_pcb` is present, pad nets from the board drive the design graph. Schematic geometry alone was inventing swapped rails (GND↔3V3) and floating pins on good KiCad 10 designs.
+5 -1
View File
@@ -4,6 +4,8 @@ WORKDIR /app
ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_AUTH_MODE
ENV NEXT_PUBLIC_AUTH_MODE=$NEXT_PUBLIC_AUTH_MODE
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
@@ -12,7 +14,7 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN echo "BUILD API URL=$NEXT_PUBLIC_API_URL" RUN echo "BUILD API URL=$NEXT_PUBLIC_API_URL AUTH_MODE=$NEXT_PUBLIC_AUTH_MODE"
RUN npm run build RUN npm run build
FROM node:20-alpine FROM node:20-alpine
@@ -21,6 +23,8 @@ WORKDIR /app
ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_AUTH_MODE
ENV NEXT_PUBLIC_AUTH_MODE=$NEXT_PUBLIC_AUTH_MODE
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
+9 -6
View File
@@ -2,6 +2,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { Sidebar } from "@/components/layout/sidebar"; import { Sidebar } from "@/components/layout/sidebar";
import { CreditsProvider } from "@/components/billing/credits-context"; import { CreditsProvider } from "@/components/billing/credits-context";
import { RedditPixelMatchKeys } from "@/components/analytics/reddit-pixel-match-keys"; import { RedditPixelMatchKeys } from "@/components/analytics/reddit-pixel-match-keys";
import { AuthGate } from "@/components/layout/auth-gate";
export default function AppLayout({ export default function AppLayout({
children, children,
@@ -11,12 +12,14 @@ export default function AppLayout({
return ( return (
<TooltipProvider> <TooltipProvider>
<CreditsProvider> <CreditsProvider>
<div className="flex h-full"> <AuthGate>
<Sidebar /> <div className="flex h-full">
<main className="flex-1 flex flex-col overflow-auto"> <Sidebar />
{children} <main className="flex-1 flex flex-col overflow-auto">
</main> {children}
</div> </main>
</div>
</AuthGate>
<RedditPixelMatchKeys /> <RedditPixelMatchKeys />
</CreditsProvider> </CreditsProvider>
</TooltipProvider> </TooltipProvider>
@@ -0,0 +1,86 @@
"use client";
import { FormEvent, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { storeAuthToken } from "@/hooks/use-optional-auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
export default function SignInPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || "Sign in failed");
}
storeAuthToken(data.token);
router.replace("/");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Sign in failed");
} finally {
setBusy(false);
}
}
return (
<div className="mx-auto flex min-h-[70vh] max-w-md flex-col justify-center px-4 py-16">
<h1 className="font-display text-3xl tracking-tight">Sign in</h1>
<p className="mt-2 text-sm text-muted-foreground">
Pinscope account for this server. Invite collaborators from a project once they have an account.
</p>
<form onSubmit={onSubmit} className="mt-8 flex flex-col gap-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={busy}>
{busy ? "Signing in…" : "Sign in"}
</Button>
</form>
<p className="mt-6 text-sm text-muted-foreground">
No account?{" "}
<Link href="/sign-up" className="underline underline-offset-4">
Create one
</Link>
</p>
</div>
);
}
@@ -0,0 +1,104 @@
"use client";
import { FormEvent, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { storeAuthToken } from "@/hooks/use-optional-auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
export default function SignUpPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
password,
name: name.trim() || null,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail;
throw new Error(
typeof detail === "string" ? detail : "Could not create account",
);
}
storeAuthToken(data.token);
router.replace("/");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not create account");
} finally {
setBusy(false);
}
}
return (
<div className="mx-auto flex min-h-[70vh] max-w-md flex-col justify-center px-4 py-16">
<h1 className="font-display text-3xl tracking-tight">Create account</h1>
<p className="mt-2 text-sm text-muted-foreground">
First account on this server becomes admin and inherits existing local projects.
</p>
<form onSubmit={onSubmit} className="mt-8 flex flex-col gap-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
autoComplete="name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={busy}>
{busy ? "Creating…" : "Create account"}
</Button>
</form>
<p className="mt-6 text-sm text-muted-foreground">
Already have an account?{" "}
<Link href="/sign-in" className="underline underline-offset-4">
Sign in
</Link>
</p>
</div>
);
}
@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useOptionalAuth, useOptionalUser } from "@/hooks/use-optional-auth";
import { localAuthEnabled } from "@/lib/auth";
/** When local auth is on, send unsigned users to /sign-in. */
export function AuthGate({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { isSignedIn } = useOptionalAuth();
const { isLoaded } = useOptionalUser();
useEffect(() => {
if (!localAuthEnabled || !isLoaded) return;
if (!isSignedIn) {
const next = encodeURIComponent(pathname || "/");
router.replace(`/sign-in?next=${next}`);
}
}, [isLoaded, isSignedIn, pathname, router]);
if (!localAuthEnabled) return <>{children}</>;
if (!isLoaded) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Loading
</div>
);
}
if (!isSignedIn) return null;
return <>{children}</>;
}
@@ -1,12 +1,59 @@
"use client"; "use client";
// Open-core seam: the cloud/gateway build replaces this file with the Clerk // Open-core seam: cloud/gateway replaces with Clerk user button + credits.
// user button and live credit balance. The open-source build has neither. // Local auth shows email and sign-out / sign-in links.
import Link from "next/link";
import { useOptionalAuth, useOptionalUser } from "@/hooks/use-optional-auth";
import { authEnabled, localAuthEnabled } from "@/lib/auth";
import { Button } from "@/components/ui/button";
export function SidebarCredits() { export function SidebarCredits() {
return null; return null;
} }
export function SidebarUserButton() { export function SidebarUserButton() {
return null; const { user, isLoaded } = useOptionalUser();
const { signOut, isSignedIn } = useOptionalAuth();
if (!authEnabled || !localAuthEnabled) {
return null;
}
if (!isLoaded) {
return <div className="px-2 text-xs text-muted-foreground"></div>;
}
if (!isSignedIn || !user) {
return (
<div className="flex flex-col gap-1 px-1">
<Button asChild size="sm" variant="outline" className="w-full justify-start">
<Link href="/sign-in">Sign in</Link>
</Button>
<Button asChild size="sm" variant="ghost" className="w-full justify-start">
<Link href="/sign-up">Create account</Link>
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-1 px-2 py-1">
<div className="truncate text-xs font-medium">{user.name || user.email}</div>
{user.email && user.name ? (
<div className="truncate text-[11px] text-muted-foreground">{user.email}</div>
) : null}
<Button
size="sm"
variant="ghost"
className="h-7 justify-start px-0 text-xs text-muted-foreground"
onClick={() => {
signOut?.();
window.location.href = "/sign-in";
}}
>
Sign out
</Button>
</div>
);
} }
+127 -8
View File
@@ -1,12 +1,13 @@
"use client"; "use client";
/** /**
* Open-core seam: the cloud/gateway build replaces this file with wrappers * Open-core seam: cloud/gateway replaces this with Clerk hooks.
* around Clerk's hooks. The open-source build always runs as the local * Self-host local mode uses Pinscope JWT in localStorage.
* user, mirroring the backend: user_id "local", admin access granted
* (backend is_admin() returns True when auth is disabled).
*/ */
import { useCallback, useEffect, useState } from "react";
import { authEnabled, localAuthEnabled, TOKEN_STORAGE_KEY } from "@/lib/auth";
export interface AppUser { export interface AppUser {
id: string; id: string;
name: string | null; name: string | null;
@@ -17,6 +18,7 @@ export interface AppUser {
export interface OptionalAuth { export interface OptionalAuth {
isSignedIn: boolean; isSignedIn: boolean;
getToken: () => Promise<string | null>; getToken: () => Promise<string | null>;
signOut?: () => void;
} }
export interface OptionalUser { export interface OptionalUser {
@@ -30,13 +32,130 @@ const LOCAL_USER: AppUser = {
email: null, email: null,
isAdmin: true, isAdmin: true,
}; };
const LOCAL_AUTH: OptionalAuth = { isSignedIn: true, getToken: async () => null };
const LOCAL_USER_RESULT: OptionalUser = { user: LOCAL_USER, isLoaded: true }; const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
function readStoredToken(): string | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(TOKEN_STORAGE_KEY);
} catch {
return null;
}
}
export function storeAuthToken(token: string | null) {
if (typeof window === "undefined") return;
try {
if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
else window.localStorage.removeItem(TOKEN_STORAGE_KEY);
} catch {
/* ignore */
}
window.dispatchEvent(new Event("pinscope-auth-changed"));
}
export function useOptionalAuth(): OptionalAuth { export function useOptionalAuth(): OptionalAuth {
return LOCAL_AUTH; const [token, setToken] = useState<string | null>(null);
const [ready, setReady] = useState(!localAuthEnabled);
useEffect(() => {
if (!localAuthEnabled) {
setReady(true);
return;
}
const sync = () => setToken(readStoredToken());
sync();
setReady(true);
window.addEventListener("pinscope-auth-changed", sync);
window.addEventListener("storage", sync);
return () => {
window.removeEventListener("pinscope-auth-changed", sync);
window.removeEventListener("storage", sync);
};
}, []);
const getToken = useCallback(async () => {
if (localAuthEnabled) return readStoredToken();
return null;
}, []);
const signOut = useCallback(() => {
storeAuthToken(null);
setToken(null);
}, []);
if (!authEnabled) {
return { isSignedIn: true, getToken: async () => null };
}
if (localAuthEnabled) {
return {
isSignedIn: ready && Boolean(token),
getToken,
signOut,
};
}
// Clerk build replaces this file; open-core without local mode stays open.
return { isSignedIn: true, getToken: async () => null };
} }
export function useOptionalUser(): OptionalUser { export function useOptionalUser(): OptionalUser {
return LOCAL_USER_RESULT; const { isSignedIn, getToken } = useOptionalAuth();
const [user, setUser] = useState<AppUser | null>(authEnabled ? null : LOCAL_USER);
const [isLoaded, setIsLoaded] = useState(!localAuthEnabled);
useEffect(() => {
if (!authEnabled) {
setUser(LOCAL_USER);
setIsLoaded(true);
return;
}
if (!localAuthEnabled) {
setUser(LOCAL_USER);
setIsLoaded(true);
return;
}
let cancelled = false;
(async () => {
setIsLoaded(false);
const token = await getToken();
if (!token) {
if (!cancelled) {
setUser(null);
setIsLoaded(true);
}
return;
}
try {
const res = await fetch(`${API_BASE}/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
storeAuthToken(null);
if (!cancelled) setUser(null);
return;
}
const data = await res.json();
if (!cancelled) {
setUser({
id: data.user_id,
name: data.name ?? null,
email: data.email ?? null,
isAdmin: Boolean(data.is_admin),
});
}
} catch {
if (!cancelled) setUser(null);
} finally {
if (!cancelled) setIsLoaded(true);
}
})();
return () => {
cancelled = true;
};
}, [isSignedIn, getToken]);
return { user, isLoaded };
} }
+14 -8
View File
@@ -1,13 +1,19 @@
/** /**
* Open-core auth switch. * Open-core auth switch.
* *
* When NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY is unset the app runs in local/OSS * - Clerk: NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY set (cloud build).
* mode: no ClerkProvider, pass-through middleware, a stubbed signed-in * - Local: NEXT_PUBLIC_AUTH_MODE=local (self-host Pinscope accounts).
* "local" user (matching the backend's LOCAL_DEV_USER), and all credits / * - Off: neither — signed-in stub user "local", matching backend.
* billing UI hidden. Pairs with BILLING_ENABLED=false on the backend —
* mixed modes (key set but billing off, or the inverse) are unsupported.
* *
* NEXT_PUBLIC_* vars are inlined at build time, so this is a build-time * NEXT_PUBLIC_* vars are inlined at build time.
* constant — changing it requires a rebuild / dev-server restart.
*/ */
export const authEnabled = Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY); export const authEnabled = Boolean(
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY ||
process.env.NEXT_PUBLIC_AUTH_MODE === "local",
);
export const localAuthEnabled =
process.env.NEXT_PUBLIC_AUTH_MODE === "local" &&
!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
export const TOKEN_STORAGE_KEY = "pinscope_token";
+7
View File
@@ -118,6 +118,13 @@ upsert_env NEXT_PUBLIC_API_URL "$SITE" .env
# JSON list — keep it a single line so docker compose / pydantic-settings parse it. # JSON list — keep it a single line so docker compose / pydantic-settings parse it.
upsert_env CORS_ORIGINS "[\"$SITE\"]" .env upsert_env CORS_ORIGINS "[\"$SITE\"]" .env
# Self-host multi-user auth (Pinscope accounts). Generate a secret once if missing.
if [[ -z "$(read_env AUTH_JWT_SECRET .env || true)" ]]; then
log "Generating AUTH_JWT_SECRET for local multi-user auth"
upsert_env AUTH_JWT_SECRET "$(openssl rand -hex 32)" .env
fi
upsert_env NEXT_PUBLIC_AUTH_MODE "local" .env
KEY="$(read_env DEEPSEEK_API_KEY .env || true)" KEY="$(read_env DEEPSEEK_API_KEY .env || true)"
if [[ -z "$KEY" || "$KEY" == "sk-..." ]]; then if [[ -z "$KEY" || "$KEY" == "sk-..." ]]; then
die "Set a real DEEPSEEK_API_KEY in $ROOT/.env before updating." die "Set a real DEEPSEEK_API_KEY in $ROOT/.env before updating."
+61
View File
@@ -0,0 +1,61 @@
"""Local Pinscope auth — users, JWT, email lookup (no FastAPI required)."""
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
@pytest.fixture()
def auth_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
from backend.config import settings
monkeypatch.setattr(settings, "auth_jwt_secret", "test-secret-not-for-prod")
monkeypatch.setattr(settings, "data_dir", tmp_path)
monkeypatch.setattr(settings, "clerk_secret_key", "")
monkeypatch.setattr(settings, "clerk_jwks_url", "")
monkeypatch.setattr(settings, "auth_admin_emails", "")
return tmp_path
def test_create_user_and_authenticate(auth_env: Path):
from backend.services import local_users
user = local_users.create_user("a@example.com", "password12", "Ada")
assert user.is_admin is True
assert local_users.authenticate("a@example.com", "password12")
assert local_users.authenticate("a@example.com", "wrong") is None
assert local_users.find_by_email("a@example.com").user_id == user.user_id
def test_second_user_not_admin(auth_env: Path):
from backend.services import local_users
from backend.services.user_directory import find_user_id_by_email
local_users.create_user("owner@example.com", "password12", "Owner")
mate = local_users.create_user("mate@example.com", "password12", "Mate")
assert mate.is_admin is False
uid = asyncio.run(find_user_id_by_email("mate@example.com"))
assert uid == mate.user_id
def test_migrate_local_projects_on_first_register(auth_env: Path):
from backend.services import local_users
proj = auth_env / "users" / "local" / "projects" / "abc123"
proj.mkdir(parents=True)
(proj / "meta.json").write_text('{"id":"abc123","name":"Old"}\n')
user = local_users.create_user("first@example.com", "password12")
assert (auth_env / "users" / user.user_id / "projects" / "abc123").is_dir()
assert not (auth_env / "users" / "local" / "projects" / "abc123").exists()
def test_duplicate_email_rejected(auth_env: Path):
from backend.services import local_users
local_users.create_user("a@example.com", "password12")
with pytest.raises(ValueError, match="already exists"):
local_users.create_user("a@example.com", "password12")