From 5a69b380da909f31f18e2d1d735ddc7db72bec2a Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Fri, 11 Sep 2026 14:31:37 +0200 Subject: [PATCH] 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 --- backend/.env.example | 13 +- backend/config.py | 20 +- backend/main.py | 33 ++- backend/middleware/auth.py | 93 +++++--- backend/routers/admin.py | 8 + backend/routers/auth.py | 73 +++++++ backend/routers/projects.py | 60 ++---- backend/services/local_jwt.py | 45 ++++ backend/services/local_users.py | 204 ++++++++++++++++++ backend/services/user_directory.py | 81 +++++++ docker-compose.yml | 1 + frontend/content/changelog.md | 8 + frontend/dockerfile | 6 +- frontend/src/app/(app)/layout.tsx | 15 +- frontend/src/app/(marketing)/sign-in/page.tsx | 86 ++++++++ frontend/src/app/(marketing)/sign-up/page.tsx | 104 +++++++++ frontend/src/components/layout/auth-gate.tsx | 33 +++ .../src/components/layout/sidebar-auth.tsx | 53 ++++- frontend/src/hooks/use-optional-auth.ts | 135 +++++++++++- frontend/src/lib/auth.ts | 22 +- scripts/update-pinscope.sh | 7 + tests/test_local_auth.py | 61 ++++++ 22 files changed, 1045 insertions(+), 116 deletions(-) create mode 100644 backend/routers/auth.py create mode 100644 backend/services/local_jwt.py create mode 100644 backend/services/local_users.py create mode 100644 backend/services/user_directory.py create mode 100644 frontend/src/app/(marketing)/sign-in/page.tsx create mode 100644 frontend/src/app/(marketing)/sign-up/page.tsx create mode 100644 frontend/src/components/layout/auth-gate.tsx create mode 100644 tests/test_local_auth.py diff --git a/backend/.env.example b/backend/.env.example index 67ca3ce..d7f5062 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -52,7 +52,18 @@ GCS_BUCKET= # 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"] -# -- 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. # Order: BOM URL → LCSC → manufacturer PDF URLs → Mouser → DigiKey. # DIGIKEY_CLIENT_ID= diff --git a/backend/config.py b/backend/config.py index e2a6cd9..c35c125 100644 --- a/backend/config.py +++ b/backend/config.py @@ -119,11 +119,18 @@ class Settings(BaseSettings): # GCS (if set, use GCSStorageBackend; otherwise LocalStorageBackend) gcs_bucket: str = "" - # Clerk authentication + # Clerk authentication (cloud). When set, takes priority over local auth. clerk_secret_key: str = "" clerk_publishable_key: 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_client_id: str = "" digikey_client_secret: str = "" @@ -212,9 +219,18 @@ class Settings(BaseSettings): return bool(self.gcs_bucket) @property - def use_auth(self) -> bool: + def use_clerk(self) -> bool: 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 def use_email(self) -> bool: return bool(self.email_sender and self.email_frontend_url) diff --git a/backend/main.py b/backend/main.py index 47d4481..2c604cd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,7 +10,7 @@ from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware 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.storage import LocalStorageBackend @@ -35,14 +35,18 @@ async def lifespan(app: FastAPI): env = os.getenv("ENVIRONMENT", "").lower() if env == "production" and not settings.use_auth: raise RuntimeError( - "CLERK_JWKS_URL and CLERK_SECRET_KEY must be set in production. " - "Authentication cannot be disabled in production." + "Production requires authentication: set AUTH_JWT_SECRET " + "(local Pinscope accounts) or CLERK_JWKS_URL + CLERK_SECRET_KEY." ) if not settings.use_auth: logger.warning( "Authentication is DISABLED — all users have full access. " "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: logger.warning( "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): base = settings.data_dir (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" / "patterns").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): - """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): # Let CORS preflight through — browsers send OPTIONS without credentials if request.method == "OPTIONS": return await call_next(request) # 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 return await call_next(request) 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: 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 return JSONResponse( status_code=401, content={"detail": "Authentication required"}, ) - # Non-production: fall back to local dev user so Clerk config - # doesn't block local development when no token is present. + # Non-production Clerk: fall back so missing token doesn't block + # local development when Clerk is configured but unused. user_id = LOCAL_DEV_USER request.state.user_id = user_id else: @@ -149,6 +161,7 @@ app.include_router(pipeline.router, prefix="/api") app.include_router(reports.router, prefix="/api") app.include_router(impedance.router, prefix="/api") app.include_router(admin.router, prefix="/api") +app.include_router(auth.router, prefix="/api") if settings.billing_enabled: # Import guarded too: with billing disabled the core never loads the # billing/credits routers (or, transitively, the Stripe SDK). diff --git a/backend/middleware/auth.py b/backend/middleware/auth.py index 0a15b8a..cdd1530 100644 --- a/backend/middleware/auth.py +++ b/backend/middleware/auth.py @@ -1,12 +1,7 @@ -"""Clerk JWT verification for FastAPI. - -Validates JWT tokens from the Authorization header against Clerk's JWKS endpoint. -Extracts user_id (sub claim) for per-user storage scoping. -""" +"""JWT verification for FastAPI (Clerk JWKS or local Pinscope HS256).""" from __future__ import annotations -import time from typing import Any import jwt @@ -16,7 +11,17 @@ from backend.config import settings # JWKS cache _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: @@ -24,38 +29,30 @@ def _get_jwks_client() -> jwt.PyJWKClient: if _jwks_client is None: jwks_url = settings.clerk_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( - "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." ) _jwks_client = jwt.PyJWKClient(jwks_url, cache_keys=True) return _jwks_client -async def verify_clerk_token(request: Request) -> str | None: - """Verify Clerk JWT and return user_id, or None if invalid. +def _bearer_or_query_token(request: Request) -> str | None: + 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 - - Invalid/expired token - - Skip paths (docs, health) - """ - # Skip auth for docs/health endpoints + +async def verify_clerk_token(request: Request) -> str | None: + """Verify Clerk JWT and return user_id, or None if invalid.""" if request.url.path in _SKIP_PATHS: return "anonymous" - auth_header = request.headers.get("authorization", "") - if not auth_header.startswith("Bearer "): - # Fallback: check query param (EventSource/SSE can't send headers) - token = request.query_params.get("token") - if not token: - return None - else: - token = auth_header[7:] + token = _bearer_or_query_token(request) + if not token: + return None try: client = _get_jwks_client() @@ -67,19 +64,20 @@ async def verify_clerk_token(request: Request) -> str | None: algorithms=["RS256"], options={ "verify_exp": True, - "verify_aud": False, # Clerk doesn't always set aud + "verify_aud": False, "verify_iss": True, }, - # Clerk tokens use the Clerk instance URL as issuer - # e.g. https://abc123.clerk.accounts.dev from https://abc123.clerk.accounts.dev/.well-known/jwks.json - issuer=settings.clerk_jwks_url.replace("/.well-known/jwks.json", "") if settings.clerk_jwks_url else None, - leeway=10, # 10 second clock skew tolerance + issuer=( + settings.clerk_jwks_url.replace("/.well-known/jwks.json", "") + if settings.clerk_jwks_url + else None + ), + leeway=10, ) user_id = payload.get("sub") if not user_id: return None - return user_id except jwt.ExpiredSignatureError: @@ -88,3 +86,30 @@ async def verify_clerk_token(request: Request) -> str | None: return None except Exception: 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 diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 1dc0e08..716b810 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -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: diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..9796033 --- /dev/null +++ b/backend/routers/auth.py @@ -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() diff --git a/backend/routers/projects.py b/backend/routers/projects.py index dfa5afb..d4c1952 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -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"), } diff --git a/backend/services/local_jwt.py b/backend/services/local_jwt.py new file mode 100644 index 0000000..90cf8f4 --- /dev/null +++ b/backend/services/local_jwt.py @@ -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 diff --git a/backend/services/local_users.py b/backend/services/local_users.py new file mode 100644 index 0000000..8380490 --- /dev/null +++ b/backend/services/local_users.py @@ -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 diff --git a/backend/services/user_directory.py b/backend/services/user_directory.py new file mode 100644 index 0000000..fb1d5d6 --- /dev/null +++ b/backend/services/user_directory.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index e1f43c3..21fade5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: dockerfile: dockerfile args: NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080} + NEXT_PUBLIC_AUTH_MODE: ${NEXT_PUBLIC_AUTH_MODE:-} container_name: pinscope-frontend restart: unless-stopped diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 83f5eb0..06fa377 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,14 @@ 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 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. diff --git a/frontend/dockerfile b/frontend/dockerfile index c62d239..f619651 100644 --- a/frontend/dockerfile +++ b/frontend/dockerfile @@ -4,6 +4,8 @@ WORKDIR /app ARG 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 ./ RUN npm ci @@ -12,7 +14,7 @@ COPY . . 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 FROM node:20-alpine @@ -21,6 +23,8 @@ WORKDIR /app ARG 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 NEXT_TELEMETRY_DISABLED=1 diff --git a/frontend/src/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index 71c4b7d..96f154a 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -2,6 +2,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { Sidebar } from "@/components/layout/sidebar"; import { CreditsProvider } from "@/components/billing/credits-context"; import { RedditPixelMatchKeys } from "@/components/analytics/reddit-pixel-match-keys"; +import { AuthGate } from "@/components/layout/auth-gate"; export default function AppLayout({ children, @@ -11,12 +12,14 @@ export default function AppLayout({ return ( -
- -
- {children} -
-
+ +
+ +
+ {children} +
+
+
diff --git a/frontend/src/app/(marketing)/sign-in/page.tsx b/frontend/src/app/(marketing)/sign-in/page.tsx new file mode 100644 index 0000000..a3face7 --- /dev/null +++ b/frontend/src/app/(marketing)/sign-in/page.tsx @@ -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(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 ( +
+

Sign in

+

+ Pinscope account for this server. Invite collaborators from a project once they have an account. +

+
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ {error &&

{error}

} + +
+

+ No account?{" "} + + Create one + +

+
+ ); +} diff --git a/frontend/src/app/(marketing)/sign-up/page.tsx b/frontend/src/app/(marketing)/sign-up/page.tsx new file mode 100644 index 0000000..e0a6c6a --- /dev/null +++ b/frontend/src/app/(marketing)/sign-up/page.tsx @@ -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(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 ( +
+

Create account

+

+ First account on this server becomes admin and inherits existing local projects. +

+
+
+ + setName(e.target.value)} + /> +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+ {error &&

{error}

} + +
+

+ Already have an account?{" "} + + Sign in + +

+
+ ); +} diff --git a/frontend/src/components/layout/auth-gate.tsx b/frontend/src/components/layout/auth-gate.tsx new file mode 100644 index 0000000..f019577 --- /dev/null +++ b/frontend/src/components/layout/auth-gate.tsx @@ -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 ( +
+ Loading… +
+ ); + } + if (!isSignedIn) return null; + return <>{children}; +} diff --git a/frontend/src/components/layout/sidebar-auth.tsx b/frontend/src/components/layout/sidebar-auth.tsx index 715c029..3e9aaf9 100644 --- a/frontend/src/components/layout/sidebar-auth.tsx +++ b/frontend/src/components/layout/sidebar-auth.tsx @@ -1,12 +1,59 @@ "use client"; -// Open-core seam: the cloud/gateway build replaces this file with the Clerk -// user button and live credit balance. The open-source build has neither. +// Open-core seam: cloud/gateway replaces with Clerk user button + credits. +// 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() { return null; } export function SidebarUserButton() { - return null; + const { user, isLoaded } = useOptionalUser(); + const { signOut, isSignedIn } = useOptionalAuth(); + + if (!authEnabled || !localAuthEnabled) { + return null; + } + + if (!isLoaded) { + return
; + } + + if (!isSignedIn || !user) { + return ( +
+ + +
+ ); + } + + return ( +
+
{user.name || user.email}
+ {user.email && user.name ? ( +
{user.email}
+ ) : null} + +
+ ); } diff --git a/frontend/src/hooks/use-optional-auth.ts b/frontend/src/hooks/use-optional-auth.ts index 522ba7d..8245fbd 100644 --- a/frontend/src/hooks/use-optional-auth.ts +++ b/frontend/src/hooks/use-optional-auth.ts @@ -1,12 +1,13 @@ "use client"; /** - * Open-core seam: the cloud/gateway build replaces this file with wrappers - * around Clerk's hooks. The open-source build always runs as the local - * user, mirroring the backend: user_id "local", admin access granted - * (backend is_admin() returns True when auth is disabled). + * Open-core seam: cloud/gateway replaces this with Clerk hooks. + * Self-host local mode uses Pinscope JWT in localStorage. */ +import { useCallback, useEffect, useState } from "react"; +import { authEnabled, localAuthEnabled, TOKEN_STORAGE_KEY } from "@/lib/auth"; + export interface AppUser { id: string; name: string | null; @@ -17,6 +18,7 @@ export interface AppUser { export interface OptionalAuth { isSignedIn: boolean; getToken: () => Promise; + signOut?: () => void; } export interface OptionalUser { @@ -30,13 +32,130 @@ const LOCAL_USER: AppUser = { email: null, 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 { - return LOCAL_AUTH; + const [token, setToken] = useState(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 { - return LOCAL_USER_RESULT; + const { isSignedIn, getToken } = useOptionalAuth(); + const [user, setUser] = useState(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 }; } diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index fbbac60..20b5db3 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -1,13 +1,19 @@ /** * Open-core auth switch. * - * When NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY is unset the app runs in local/OSS - * mode: no ClerkProvider, pass-through middleware, a stubbed signed-in - * "local" user (matching the backend's LOCAL_DEV_USER), and all credits / - * billing UI hidden. Pairs with BILLING_ENABLED=false on the backend — - * mixed modes (key set but billing off, or the inverse) are unsupported. + * - Clerk: NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY set (cloud build). + * - Local: NEXT_PUBLIC_AUTH_MODE=local (self-host Pinscope accounts). + * - Off: neither — signed-in stub user "local", matching backend. * - * NEXT_PUBLIC_* vars are inlined at build time, so this is a build-time - * constant — changing it requires a rebuild / dev-server restart. + * NEXT_PUBLIC_* vars are inlined at build time. */ -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"; diff --git a/scripts/update-pinscope.sh b/scripts/update-pinscope.sh index 9ecf8ba..2d0b33b 100755 --- a/scripts/update-pinscope.sh +++ b/scripts/update-pinscope.sh @@ -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. 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)" if [[ -z "$KEY" || "$KEY" == "sk-..." ]]; then die "Set a real DEEPSEEK_API_KEY in $ROOT/.env before updating." diff --git a/tests/test_local_auth.py b/tests/test_local_auth.py new file mode 100644 index 0000000..a441ee2 --- /dev/null +++ b/tests/test_local_auth.py @@ -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")