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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user