fix(auth): unify Desktop and Web SQLite DB path and fix browser autofill crash on Input/AuthGate
This commit is contained in:
@@ -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
@@ -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 } : {}),
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user