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