Add local Pinscope multi-user auth for shared projects.

Self-host email/password accounts enable the existing collaborator
invite flow without Clerk; first admin inherits users/local projects.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-11 14:31:37 +02:00
co-authored by Cursor
parent c5b260b404
commit 5a69b380da
22 changed files with 1045 additions and 116 deletions
+8
View File
@@ -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
View File
@@ -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
+9 -6
View File
@@ -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>
);
}
+127 -8
View File
@@ -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 };
}
+14 -8
View File
@@ -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";