fix(auth): unify Desktop and Web SQLite DB path and fix browser autofill crash on Input/AuthGate

This commit is contained in:
Schwitzz
2026-10-30 16:18:17 -05:00
parent 09e9280da6
commit 668470da2b
8 changed files with 174 additions and 79 deletions
+1 -1
View File
@@ -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);
}
+2 -1
View File
@@ -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<User> => {
try {
return await request<User>(`/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 } : {}),
+128 -66
View File
@@ -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…
<span>Verifying session security…</span>
</div>
);
}
@@ -168,6 +185,7 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
const isFirstInstall = !initialized;
return (
<div
translate="no"
style={{
minHeight: "100vh",
display: "flex",
@@ -202,12 +220,14 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
</Badge>
</div>
<h1 style={{ margin: "0 0 6px", fontSize: 20, fontWeight: 700 }}>
{isFirstInstall ? "Create Administrator Account" : "Sign in to PubScript"}
<span>{isFirstInstall ? "Create Administrator Account" : "Sign in to PubScript"}</span>
</h1>
<p style={{ margin: 0, fontSize: 13, color: "var(--ps-text-muted)", lineHeight: 1.45 }}>
{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."}
<span>
{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."}
</span>
</p>
</div>
@@ -225,29 +245,50 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
fontWeight: 500,
}}
>
{error}
<span>{error}</span>
</div>
)}
{isFirstInstall ? (
<form onSubmit={(e) => void handleSetupSubmit(e)} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<Input
label="Administrator Display Name"
placeholder="e.g. Schwitzz"
autoComplete="name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
/>
<Input
label="Administrator Email"
type="email"
placeholder="you@example.com"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<form
key="setup-form"
onSubmit={(e) => void handleSetupSubmit(e)}
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<div className="ps-field">
<label className="ps-field-label" htmlFor="ps-setup-name">
Administrator Display Name
</label>
<input
id="ps-setup-name"
name="displayName"
type="text"
className="ps-input"
placeholder="e.g. Schwitzz"
autoComplete="off"
value={displayName}
onChange={(e) => setDisplayName(String(e.target?.value ?? ""))}
required
/>
</div>
<div className="ps-field">
<label className="ps-field-label" htmlFor="ps-setup-email">
Administrator Email
</label>
<input
id="ps-setup-email"
name="email"
type="email"
className="ps-input"
placeholder="you@example.com"
autoComplete="username"
value={email}
onChange={(e) => setEmail(String(e.target?.value ?? ""))}
required
/>
</div>
<div className="ps-field">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<label className="ps-field-label" htmlFor="ps-setup-pw">
@@ -265,17 +306,18 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
padding: 0,
}}
>
{showPassword ? "Hide" : "Show"}
<span>{showPassword ? "Hide" : "Show"}</span>
</button>
</div>
<input
id="ps-setup-pw"
name="password"
type={showPassword ? "text" : "password"}
className="ps-input"
placeholder="Create a strong password…"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onChange={(e) => setPassword(String(e.target?.value ?? ""))}
required
/>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 4 }}>
@@ -300,15 +342,22 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
</div>
</div>
<Input
label="Confirm Master Password"
type={showPassword ? "text" : "password"}
placeholder="Re-enter password…"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
<div className="ps-field">
<label className="ps-field-label" htmlFor="ps-setup-confirm-pw">
Confirm Master Password
</label>
<input
id="ps-setup-confirm-pw"
name="confirmPassword"
type={showPassword ? "text" : "password"}
className="ps-input"
placeholder="Re-enter password…"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(String(e.target?.value ?? ""))}
required
/>
</div>
<div style={{ marginTop: 8 }}>
<Button
@@ -317,21 +366,33 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
disabled={submitting}
style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }}
>
{submitting ? "Encrypting & Creating Admin…" : "Create Administrator Account"}
<span>{submitting ? "Encrypting & Creating Admin…" : "Create Administrator Account"}</span>
</Button>
</div>
</form>
) : (
<form onSubmit={(e) => void handleLoginSubmit(e)} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<Input
label="Email Address"
type="email"
placeholder="you@example.com"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<form
key="login-form"
onSubmit={(e) => void handleLoginSubmit(e)}
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
<div className="ps-field">
<label className="ps-field-label" htmlFor="ps-login-email">
Email Address
</label>
<input
id="ps-login-email"
name="email"
type="email"
className="ps-input"
placeholder="you@example.com"
autoComplete="username"
value={email}
onChange={(e) => setEmail(String(e.target?.value ?? ""))}
required
/>
</div>
<div className="ps-field">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<label className="ps-field-label" htmlFor="ps-login-pw">
@@ -349,17 +410,18 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
padding: 0,
}}
>
{showPassword ? "Hide" : "Show"}
<span>{showPassword ? "Hide" : "Show"}</span>
</button>
</div>
<input
id="ps-login-pw"
name="password"
type={showPassword ? "text" : "password"}
className="ps-input"
placeholder="Enter your password…"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onChange={(e) => setPassword(String(e.target?.value ?? ""))}
required
/>
</div>
@@ -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"}
<span>{submitting ? "Signing in…" : "Sign In"}</span>
</Button>
</div>
</form>
@@ -23,6 +23,7 @@ export function AdminView() {
// Edit user modal state
const [editingUser, setEditingUser] = useState<User | null>(null);
const [editEmail, setEditEmail] = useState("");
const [editName, setEditName] = useState("");
const [editRole, setEditRole] = useState<Role>("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() {
}
>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Input
label="Email address"
type="email"
value={editEmail}
onChange={(e) => setEditEmail(e.target.value)}
/>
<Input
label="Display name"
value={editName}
+3 -2
View File
@@ -1,4 +1,4 @@
import { type InputHTMLAttributes } from "react";
import { useId, type InputHTMLAttributes } from "react";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
@@ -7,7 +7,8 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
}
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 (
<div className="ps-field">
{label && <label htmlFor={inputId} className="ps-field-label">{label}</label>}
+3 -2
View File
@@ -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<HTMLSelectElement> {
}
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 (
<div className="ps-field">
{label && <label htmlFor={selectId} className="ps-field-label">{label}</label>}
+10
View File
@@ -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(),
+18 -7
View File
@@ -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"),