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:
+12
-1
@@ -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=
|
||||
|
||||
+18
-2
@@ -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)
|
||||
|
||||
+23
-10
@@ -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).
|
||||
|
||||
+59
-34
@@ -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
|
||||
|
||||
@@ -41,6 +41,14 @@ async def is_admin(request: Request) -> bool:
|
||||
request.state._is_admin = True
|
||||
return True
|
||||
|
||||
if settings.use_local_auth:
|
||||
from backend.services import local_users
|
||||
|
||||
user = local_users.get_user(user_id)
|
||||
result = bool(user and user.is_admin)
|
||||
request.state._is_admin = result
|
||||
return result
|
||||
|
||||
# Fetch user from Clerk Backend API and check public_metadata.role
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Local Pinscope auth endpoints (register / login / me)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services import local_jwt, local_users
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: str
|
||||
password: str = Field(min_length=8)
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
def _require_local_auth() -> None:
|
||||
if not settings.use_local_auth:
|
||||
raise HTTPException(
|
||||
400,
|
||||
"Local auth is not enabled. Set AUTH_JWT_SECRET on the server.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mode")
|
||||
async def auth_mode():
|
||||
"""Public: how the frontend should authenticate."""
|
||||
if settings.use_clerk:
|
||||
return {"mode": "clerk", "auth_enabled": True}
|
||||
if settings.use_local_auth:
|
||||
return {"mode": "local", "auth_enabled": True}
|
||||
return {"mode": "off", "auth_enabled": False}
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(body: RegisterRequest):
|
||||
_require_local_auth()
|
||||
try:
|
||||
user = local_users.create_user(body.email, body.password, body.name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
token = local_jwt.issue_token(user.user_id, user.email)
|
||||
return {"token": token, "user": user.public()}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(body: LoginRequest):
|
||||
_require_local_auth()
|
||||
user = local_users.authenticate(body.email, body.password)
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid email or password")
|
||||
token = local_jwt.issue_token(user.user_id, user.email)
|
||||
return {"token": token, "user": user.public()}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(request: Request):
|
||||
_require_local_auth()
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
if not user_id or user_id == "local" or user_id == "anonymous":
|
||||
raise HTTPException(401, "Authentication required")
|
||||
user = local_users.get_user(user_id)
|
||||
if not user:
|
||||
raise HTTPException(401, "User not found")
|
||||
return user.public()
|
||||
+17
-43
@@ -716,27 +716,17 @@ async def list_collaborators(project_id: str, request: Request):
|
||||
all_user_ids = [owner_user_id] + [c for c in meta.collaborators if c != owner_user_id]
|
||||
collaborators = []
|
||||
if settings.use_auth:
|
||||
async with httpx.AsyncClient() as client:
|
||||
for uid in all_user_ids:
|
||||
entry: dict = {"user_id": uid, "name": None, "email": None, "image_url": None,
|
||||
"role": "owner" if uid == owner_user_id else "collaborator"}
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"https://api.clerk.com/v1/users/{uid}",
|
||||
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
clerk = resp.json()
|
||||
first = clerk.get("first_name") or ""
|
||||
last = clerk.get("last_name") or ""
|
||||
entry["name"] = f"{first} {last}".strip() or None
|
||||
emails = clerk.get("email_addresses", [])
|
||||
if emails:
|
||||
entry["email"] = emails[0].get("email_address")
|
||||
entry["image_url"] = clerk.get("image_url")
|
||||
except Exception:
|
||||
pass
|
||||
collaborators.append(entry)
|
||||
from backend.services.user_directory import get_user_profile
|
||||
|
||||
for uid in all_user_ids:
|
||||
profile = await get_user_profile(uid)
|
||||
collaborators.append({
|
||||
"user_id": uid,
|
||||
"name": profile.get("name"),
|
||||
"email": profile.get("email"),
|
||||
"image_url": profile.get("image_url"),
|
||||
"role": "owner" if uid == owner_user_id else "collaborator",
|
||||
})
|
||||
else:
|
||||
# Local dev — just return user_ids without enrichment
|
||||
collaborators = [
|
||||
@@ -762,22 +752,9 @@ async def add_collaborator(project_id: str, req: AddCollaboratorRequest, request
|
||||
if not settings.use_auth:
|
||||
raise HTTPException(400, "Collaboration requires authentication to be enabled")
|
||||
|
||||
# Look up user by email via Clerk Backend API
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
"https://api.clerk.com/v1/users",
|
||||
params={"email_address": [req.email]},
|
||||
headers={"Authorization": f"Bearer {settings.clerk_secret_key}"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(502, "Failed to look up user")
|
||||
from backend.services.user_directory import find_user_id_by_email, get_user_profile
|
||||
|
||||
users = resp.json()
|
||||
if not users:
|
||||
raise HTTPException(404, "No user found with that email")
|
||||
|
||||
clerk_user = users[0]
|
||||
collab_user_id = clerk_user.get("id")
|
||||
collab_user_id = await find_user_id_by_email(req.email)
|
||||
if not collab_user_id:
|
||||
raise HTTPException(404, "No user found with that email")
|
||||
|
||||
@@ -791,15 +768,12 @@ async def add_collaborator(project_id: str, req: AddCollaboratorRequest, request
|
||||
|
||||
proj_svc.add_collaborator(storage, user_id, project_id, collab_user_id)
|
||||
|
||||
# Return the collaborator info
|
||||
first = clerk_user.get("first_name") or ""
|
||||
last = clerk_user.get("last_name") or ""
|
||||
emails = clerk_user.get("email_addresses", [])
|
||||
profile = await get_user_profile(collab_user_id)
|
||||
return {
|
||||
"user_id": collab_user_id,
|
||||
"name": f"{first} {last}".strip() or None,
|
||||
"email": emails[0].get("email_address") if emails else None,
|
||||
"image_url": clerk_user.get("image_url"),
|
||||
"name": profile.get("name"),
|
||||
"email": profile.get("email"),
|
||||
"image_url": profile.get("image_url"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<TooltipProvider>
|
||||
<CreditsProvider>
|
||||
<div className="flex h-full">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex flex-col overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<AuthGate>
|
||||
<div className="flex h-full">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex flex-col overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</AuthGate>
|
||||
<RedditPixelMatchKeys />
|
||||
</CreditsProvider>
|
||||
</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";
|
||||
|
||||
// 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 <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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string | null>;
|
||||
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<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 {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user