diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1da354f..6970933 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -36,7 +36,7 @@ export default function App() { useEffect(() => { const onKey = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + if ((e.metaKey || e.ctrlKey) && typeof e.key === "string" && e.key.toLowerCase() === "k") { e.preventDefault(); setPaletteOpen((o) => !o); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 1e5d052..45224a2 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -490,7 +490,7 @@ export const api = { updateUser: async ( id: string, - patch: { displayName?: string; role?: Role; disabled?: boolean; password?: string }, + patch: { email?: string; displayName?: string; role?: Role; disabled?: boolean; password?: string }, ): Promise => { try { return await request(`/admin/users/${id}`, { @@ -503,6 +503,7 @@ export const api = { if (idx === -1) throw new Error("User not found"); const updated: User = { ...users[idx]!, + ...(patch.email !== undefined ? { email: patch.email.trim().toLowerCase() } : {}), ...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}), ...(patch.role !== undefined ? { role: patch.role } : {}), ...(patch.disabled !== undefined ? { disabled: patch.disabled } : {}), diff --git a/apps/web/src/components/AuthGate.tsx b/apps/web/src/components/AuthGate.tsx index 5695de8..6c492dc 100644 --- a/apps/web/src/components/AuthGate.tsx +++ b/apps/web/src/components/AuthGate.tsx @@ -1,6 +1,6 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import type { User } from "@pubscript/core"; -import { Badge, Button, Input, toast } from "@pubscript/ui"; +import { Badge, Button, toast } from "@pubscript/ui"; import { api } from "../api.js"; interface AuthContextValue { @@ -20,15 +20,16 @@ export function useAuth(): AuthContextValue { } function scorePassword(pw: string): { score: number; label: string; color: string } { - if (!pw) return { score: 0, label: "Enter a password", color: "var(--ps-text-muted)" }; + const safe = typeof pw === "string" ? pw : ""; + if (!safe) return { score: 0, label: "Enter a password", color: "var(--ps-text-muted)" }; let score = 0; - if (pw.length >= 8) score += 1; - if (pw.length >= 12) score += 1; - if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score += 1; - if (/\d/.test(pw)) score += 1; - if (/[^a-zA-Z0-9]/.test(pw)) score += 1; + if (safe.length >= 8) score += 1; + if (safe.length >= 12) score += 1; + if (/[a-z]/.test(safe) && /[A-Z]/.test(safe)) score += 1; + if (/\d/.test(safe)) score += 1; + if (/[^a-zA-Z0-9]/.test(safe)) score += 1; - if (pw.length < 8) { + if (safe.length < 8) { return { score: 1, label: "Too short (minimum 8 characters)", color: "var(--ps-danger)" }; } if (score <= 2) { @@ -68,6 +69,12 @@ export function AuthGate({ children }: { children: React.ReactNode }) { useEffect(() => { void refreshAuth(); + // Re-sync auth state when user switches from Desktop App to Web App tab + const onFocus = () => { + void refreshAuth(); + }; + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); }, [refreshAuth]); const logout = useCallback(async () => { @@ -83,8 +90,10 @@ export function AuthGate({ children }: { children: React.ReactNode }) { const handleSetupSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - const cleanName = displayName.trim(); - const cleanEmail = email.trim().toLowerCase(); + const cleanName = String(displayName ?? "").trim(); + const cleanEmail = String(email ?? "").trim().toLowerCase(); + const safePw = String(password ?? ""); + const safeConfirm = String(confirmPassword ?? ""); if (!cleanName) { setError("Please enter an administrator display name."); @@ -94,11 +103,11 @@ export function AuthGate({ children }: { children: React.ReactNode }) { setError("Please enter a valid administrator email address."); return; } - if (password.length < 8) { + if (safePw.length < 8) { setError("Password must be at least 8 characters long."); return; } - if (password !== confirmPassword) { + if (safePw !== safeConfirm) { setError("Passwords do not match."); return; } @@ -108,7 +117,7 @@ export function AuthGate({ children }: { children: React.ReactNode }) { const res = await api.setupAdmin({ email: cleanEmail, displayName: cleanName, - password, + password: safePw, }); setInitialized(true); setAuthenticated(true); @@ -117,7 +126,14 @@ export function AuthGate({ children }: { children: React.ReactNode }) { setConfirmPassword(""); toast.success(`Welcome, ${res.user.displayName}! Administrator account created.`); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create administrator account."); + const msg = err instanceof Error ? err.message : "Failed to create administrator account."; + if (msg.includes("already_initialized") || msg.includes("already been")) { + // Admin was already created in Desktop App — switch straight to Login view + setInitialized(true); + setError("Administrator account already exists. Please sign in with your credentials."); + } else { + setError(msg); + } } finally { setSubmitting(false); } @@ -126,15 +142,16 @@ export function AuthGate({ children }: { children: React.ReactNode }) { const handleLoginSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - const cleanEmail = email.trim().toLowerCase(); - if (!cleanEmail || !password) { + const cleanEmail = String(email ?? "").trim().toLowerCase(); + const safePw = String(password ?? ""); + if (!cleanEmail || !safePw) { setError("Please enter your email and password."); return; } setSubmitting(true); try { - const res = await api.login(cleanEmail, password); + const res = await api.login(cleanEmail, safePw); setAuthenticated(true); setUser(res.user); setPassword(""); @@ -159,7 +176,7 @@ export function AuthGate({ children }: { children: React.ReactNode }) { fontFamily: "var(--ps-font-ui)", }} > - Verifying session security… + Verifying session security… ); } @@ -168,6 +185,7 @@ export function AuthGate({ children }: { children: React.ReactNode }) { const isFirstInstall = !initialized; return (

- {isFirstInstall ? "Create Administrator Account" : "Sign in to PubScript"} + {isFirstInstall ? "Create Administrator Account" : "Sign in to PubScript"}

- {isFirstInstall - ? "Welcome to PubScript. Create your initial Administrator account to secure your local studio and self-hosted web access." - : "Enter your credentials to unlock your manuscripts and studio workspace."} + + {isFirstInstall + ? "Welcome to PubScript. Create your initial Administrator account to secure your local studio and self-hosted web access." + : "Enter your credentials to unlock your manuscripts and studio workspace."} +

@@ -225,29 +245,50 @@ export function AuthGate({ children }: { children: React.ReactNode }) { fontWeight: 500, }} > - {error} + {error} )} {isFirstInstall ? ( -
void handleSetupSubmit(e)} style={{ display: "flex", flexDirection: "column", gap: 4 }}> - setDisplayName(e.target.value)} - required - /> - setEmail(e.target.value)} - required - /> + void handleSetupSubmit(e)} + style={{ display: "flex", flexDirection: "column", gap: 4 }} + > +
+ + setDisplayName(String(e.target?.value ?? ""))} + required + /> +
+ +
+ + setEmail(String(e.target?.value ?? ""))} + required + /> +
+
setPassword(e.target.value)} + onChange={(e) => setPassword(String(e.target?.value ?? ""))} required />
@@ -300,15 +342,22 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
- setConfirmPassword(e.target.value)} - required - /> +
+ + setConfirmPassword(String(e.target?.value ?? ""))} + required + /> +
) : ( -
void handleLoginSubmit(e)} style={{ display: "flex", flexDirection: "column", gap: 4 }}> - setEmail(e.target.value)} - required - /> + void handleLoginSubmit(e)} + style={{ display: "flex", flexDirection: "column", gap: 4 }} + > +
+ + setEmail(String(e.target?.value ?? ""))} + required + /> +
+
setPassword(e.target.value)} + onChange={(e) => setPassword(String(e.target?.value ?? ""))} required />
@@ -371,7 +433,7 @@ export function AuthGate({ children }: { children: React.ReactNode }) { disabled={submitting} style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }} > - {submitting ? "Signing in…" : "Sign In"} + {submitting ? "Signing in…" : "Sign In"}
diff --git a/apps/web/src/features/admin/AdminView.tsx b/apps/web/src/features/admin/AdminView.tsx index ffc523f..e915bbc 100644 --- a/apps/web/src/features/admin/AdminView.tsx +++ b/apps/web/src/features/admin/AdminView.tsx @@ -23,6 +23,7 @@ export function AdminView() { // Edit user modal state const [editingUser, setEditingUser] = useState(null); + const [editEmail, setEditEmail] = useState(""); const [editName, setEditName] = useState(""); const [editRole, setEditRole] = useState("author"); const [editPassword, setEditPassword] = useState(""); @@ -122,6 +123,7 @@ export function AdminView() { const openEditUser = (user: User) => { setEditingUser(user); + setEditEmail(user.email); setEditName(user.displayName); setEditRole(user.role); setEditPassword(""); @@ -131,6 +133,7 @@ export function AdminView() { if (!editingUser) return; try { await api.updateUser(editingUser.id, { + email: editEmail.trim().toLowerCase() || editingUser.email, displayName: editName.trim() || editingUser.displayName, role: editRole, ...(editPassword.trim().length >= 6 ? { password: editPassword.trim() } : {}), @@ -441,6 +444,12 @@ export function AdminView() { } >
+ setEditEmail(e.target.value)} + /> { label?: string; @@ -7,7 +7,8 @@ export interface InputProps extends InputHTMLAttributes { } export function Input({ label, hint, error, id, className = "", ...rest }: InputProps) { - const inputId = id ?? `input-${Math.random().toString(36).slice(2, 8)}`; + const reactId = useId(); + const inputId = id ?? `input-${reactId}`; return (
{label && } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx index ecb1c0d..8498a43 100644 --- a/packages/ui/src/components/Select.tsx +++ b/packages/ui/src/components/Select.tsx @@ -1,4 +1,4 @@ -import { type SelectHTMLAttributes } from "react"; +import { useId, type SelectHTMLAttributes } from "react"; export interface SelectOption { value: string; @@ -11,7 +11,8 @@ export interface SelectProps extends SelectHTMLAttributes { } export function Select({ label, options, id, className = "", ...rest }: SelectProps) { - const selectId = id ?? `select-${Math.random().toString(36).slice(2, 8)}`; + const reactId = useId(); + const selectId = id ?? `select-${reactId}`; return (
{label && } diff --git a/server/src/admin.ts b/server/src/admin.ts index b3060d9..fa88f72 100644 --- a/server/src/admin.ts +++ b/server/src/admin.ts @@ -70,6 +70,15 @@ export function registerAdminRoutes(app: FastifyInstance): void { const fields: string[] = []; const values: unknown[] = []; + if (body.email !== undefined) { + const cleanEmail = body.email.trim().toLowerCase(); + const taken = db + .prepare("SELECT id FROM users WHERE LOWER(email) = ? AND id != ?") + .get(cleanEmail, id); + if (taken) throw instance.httpErrors.create(409, { error: "email_taken" }); + fields.push("email = ?"); + values.push(cleanEmail); + } if (body.displayName !== undefined) { fields.push("display_name = ?"); values.push(body.displayName); @@ -189,6 +198,7 @@ const userCreate = z.object({ }); const userPatch = z.object({ + email: z.string().trim().toLowerCase().email().optional(), role: z.enum(["admin", "author"]).optional(), disabled: z.boolean().optional(), displayName: z.string().min(1).max(80).optional(), diff --git a/server/src/config.ts b/server/src/config.ts index 87e75dd..1cbad21 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -2,8 +2,12 @@ * Environment-driven server configuration. * * All values are read from `process.env` at import time with sensible - * defaults so the server boots with zero configuration for local dev. + * defaults so the server and desktop app share the same canonical user data + * directory (`~/.local/share/pubscript/pubscript.sqlite`). */ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; function env(key: string, fallback: string): string { const value = process.env[key]; @@ -17,6 +21,18 @@ function envInt(key: string, fallback: number): number { return Number.isFinite(n) ? n : fallback; } +function defaultDatabasePath(): string { + if (process.env.VITEST || process.env.NODE_ENV === "test") { + return ":memory:"; + } + // In Docker container runtime, use /app/data/pubscript.sqlite if /app/data exists + if (existsSync("/app/data")) { + return "/app/data/pubscript.sqlite"; + } + const dataHome = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"); + return join(dataHome, "pubscript", "pubscript.sqlite"); +} + export interface ServerConfig { /** Path to the SQLite database file. */ dbPath: string; @@ -33,12 +49,7 @@ export interface ServerConfig { } export const config: ServerConfig = { - dbPath: env( - "PUBSCRIPT_DB", - process.env.VITEST || process.env.NODE_ENV === "test" - ? ":memory:" - : "./data/pubscript.sqlite", - ), + dbPath: env("PUBSCRIPT_DB", defaultDatabasePath()), port: envInt("PORT", 8080), origin: env("PUBSCRIPT_ORIGIN", "http://localhost:5173"), adminEmail: env("PUBSCRIPT_ADMIN_EMAIL", "admin@pubscript.local"),