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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
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();
|
e.preventDefault();
|
||||||
setPaletteOpen((o) => !o);
|
setPaletteOpen((o) => !o);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -490,7 +490,7 @@ export const api = {
|
|||||||
|
|
||||||
updateUser: async (
|
updateUser: async (
|
||||||
id: string,
|
id: string,
|
||||||
patch: { displayName?: string; role?: Role; disabled?: boolean; password?: string },
|
patch: { email?: string; displayName?: string; role?: Role; disabled?: boolean; password?: string },
|
||||||
): Promise<User> => {
|
): Promise<User> => {
|
||||||
try {
|
try {
|
||||||
return await request<User>(`/admin/users/${id}`, {
|
return await request<User>(`/admin/users/${id}`, {
|
||||||
@@ -503,6 +503,7 @@ export const api = {
|
|||||||
if (idx === -1) throw new Error("User not found");
|
if (idx === -1) throw new Error("User not found");
|
||||||
const updated: User = {
|
const updated: User = {
|
||||||
...users[idx]!,
|
...users[idx]!,
|
||||||
|
...(patch.email !== undefined ? { email: patch.email.trim().toLowerCase() } : {}),
|
||||||
...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}),
|
...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}),
|
||||||
...(patch.role !== undefined ? { role: patch.role } : {}),
|
...(patch.role !== undefined ? { role: patch.role } : {}),
|
||||||
...(patch.disabled !== undefined ? { disabled: patch.disabled } : {}),
|
...(patch.disabled !== undefined ? { disabled: patch.disabled } : {}),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
import type { User } from "@pubscript/core";
|
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";
|
import { api } from "../api.js";
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
@@ -20,15 +20,16 @@ export function useAuth(): AuthContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scorePassword(pw: string): { score: number; label: string; color: string } {
|
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;
|
let score = 0;
|
||||||
if (pw.length >= 8) score += 1;
|
if (safe.length >= 8) score += 1;
|
||||||
if (pw.length >= 12) score += 1;
|
if (safe.length >= 12) score += 1;
|
||||||
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score += 1;
|
if (/[a-z]/.test(safe) && /[A-Z]/.test(safe)) score += 1;
|
||||||
if (/\d/.test(pw)) score += 1;
|
if (/\d/.test(safe)) score += 1;
|
||||||
if (/[^a-zA-Z0-9]/.test(pw)) 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)" };
|
return { score: 1, label: "Too short (minimum 8 characters)", color: "var(--ps-danger)" };
|
||||||
}
|
}
|
||||||
if (score <= 2) {
|
if (score <= 2) {
|
||||||
@@ -68,6 +69,12 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshAuth();
|
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]);
|
}, [refreshAuth]);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
@@ -83,8 +90,10 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
const handleSetupSubmit = async (e: React.FormEvent) => {
|
const handleSetupSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
const cleanName = displayName.trim();
|
const cleanName = String(displayName ?? "").trim();
|
||||||
const cleanEmail = email.trim().toLowerCase();
|
const cleanEmail = String(email ?? "").trim().toLowerCase();
|
||||||
|
const safePw = String(password ?? "");
|
||||||
|
const safeConfirm = String(confirmPassword ?? "");
|
||||||
|
|
||||||
if (!cleanName) {
|
if (!cleanName) {
|
||||||
setError("Please enter an administrator display name.");
|
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.");
|
setError("Please enter a valid administrator email address.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (password.length < 8) {
|
if (safePw.length < 8) {
|
||||||
setError("Password must be at least 8 characters long.");
|
setError("Password must be at least 8 characters long.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (password !== confirmPassword) {
|
if (safePw !== safeConfirm) {
|
||||||
setError("Passwords do not match.");
|
setError("Passwords do not match.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -108,7 +117,7 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
const res = await api.setupAdmin({
|
const res = await api.setupAdmin({
|
||||||
email: cleanEmail,
|
email: cleanEmail,
|
||||||
displayName: cleanName,
|
displayName: cleanName,
|
||||||
password,
|
password: safePw,
|
||||||
});
|
});
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
@@ -117,7 +126,14 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
setConfirmPassword("");
|
setConfirmPassword("");
|
||||||
toast.success(`Welcome, ${res.user.displayName}! Administrator account created.`);
|
toast.success(`Welcome, ${res.user.displayName}! Administrator account created.`);
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -126,15 +142,16 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
const handleLoginSubmit = async (e: React.FormEvent) => {
|
const handleLoginSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
const cleanEmail = email.trim().toLowerCase();
|
const cleanEmail = String(email ?? "").trim().toLowerCase();
|
||||||
if (!cleanEmail || !password) {
|
const safePw = String(password ?? "");
|
||||||
|
if (!cleanEmail || !safePw) {
|
||||||
setError("Please enter your email and password.");
|
setError("Please enter your email and password.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.login(cleanEmail, password);
|
const res = await api.login(cleanEmail, safePw);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
setUser(res.user);
|
setUser(res.user);
|
||||||
setPassword("");
|
setPassword("");
|
||||||
@@ -159,7 +176,7 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
fontFamily: "var(--ps-font-ui)",
|
fontFamily: "var(--ps-font-ui)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Verifying session security…
|
<span>Verifying session security…</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -168,6 +185,7 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
const isFirstInstall = !initialized;
|
const isFirstInstall = !initialized;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
translate="no"
|
||||||
style={{
|
style={{
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -202,12 +220,14 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<h1 style={{ margin: "0 0 6px", fontSize: 20, fontWeight: 700 }}>
|
<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>
|
</h1>
|
||||||
<p style={{ margin: 0, fontSize: 13, color: "var(--ps-text-muted)", lineHeight: 1.45 }}>
|
<p style={{ margin: 0, fontSize: 13, color: "var(--ps-text-muted)", lineHeight: 1.45 }}>
|
||||||
{isFirstInstall
|
<span>
|
||||||
? "Welcome to PubScript. Create your initial Administrator account to secure your local studio and self-hosted web access."
|
{isFirstInstall
|
||||||
: "Enter your credentials to unlock your manuscripts and studio workspace."}
|
? "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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -225,29 +245,50 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{error}
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isFirstInstall ? (
|
{isFirstInstall ? (
|
||||||
<form onSubmit={(e) => void handleSetupSubmit(e)} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
<form
|
||||||
<Input
|
key="setup-form"
|
||||||
label="Administrator Display Name"
|
onSubmit={(e) => void handleSetupSubmit(e)}
|
||||||
placeholder="e.g. Schwitzz"
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
autoComplete="name"
|
>
|
||||||
value={displayName}
|
<div className="ps-field">
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
<label className="ps-field-label" htmlFor="ps-setup-name">
|
||||||
required
|
Administrator Display Name
|
||||||
/>
|
</label>
|
||||||
<Input
|
<input
|
||||||
label="Administrator Email"
|
id="ps-setup-name"
|
||||||
type="email"
|
name="displayName"
|
||||||
placeholder="you@example.com"
|
type="text"
|
||||||
autoComplete="email"
|
className="ps-input"
|
||||||
value={email}
|
placeholder="e.g. Schwitzz"
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
autoComplete="off"
|
||||||
required
|
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 className="ps-field">
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
<label className="ps-field-label" htmlFor="ps-setup-pw">
|
<label className="ps-field-label" htmlFor="ps-setup-pw">
|
||||||
@@ -265,17 +306,18 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showPassword ? "Hide" : "Show"}
|
<span>{showPassword ? "Hide" : "Show"}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="ps-setup-pw"
|
id="ps-setup-pw"
|
||||||
|
name="password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
className="ps-input"
|
className="ps-input"
|
||||||
placeholder="Create a strong password…"
|
placeholder="Create a strong password…"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(String(e.target?.value ?? ""))}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 4 }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 4 }}>
|
||||||
@@ -300,15 +342,22 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input
|
<div className="ps-field">
|
||||||
label="Confirm Master Password"
|
<label className="ps-field-label" htmlFor="ps-setup-confirm-pw">
|
||||||
type={showPassword ? "text" : "password"}
|
Confirm Master Password
|
||||||
placeholder="Re-enter password…"
|
</label>
|
||||||
autoComplete="new-password"
|
<input
|
||||||
value={confirmPassword}
|
id="ps-setup-confirm-pw"
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
name="confirmPassword"
|
||||||
required
|
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 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<Button
|
<Button
|
||||||
@@ -317,21 +366,33 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={(e) => void handleLoginSubmit(e)} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
<form
|
||||||
<Input
|
key="login-form"
|
||||||
label="Email Address"
|
onSubmit={(e) => void handleLoginSubmit(e)}
|
||||||
type="email"
|
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||||
placeholder="you@example.com"
|
>
|
||||||
autoComplete="username"
|
<div className="ps-field">
|
||||||
value={email}
|
<label className="ps-field-label" htmlFor="ps-login-email">
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
Email Address
|
||||||
required
|
</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 className="ps-field">
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
<label className="ps-field-label" htmlFor="ps-login-pw">
|
<label className="ps-field-label" htmlFor="ps-login-pw">
|
||||||
@@ -349,17 +410,18 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showPassword ? "Hide" : "Show"}
|
<span>{showPassword ? "Hide" : "Show"}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="ps-login-pw"
|
id="ps-login-pw"
|
||||||
|
name="password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
className="ps-input"
|
className="ps-input"
|
||||||
placeholder="Enter your password…"
|
placeholder="Enter your password…"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(String(e.target?.value ?? ""))}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,7 +433,7 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }}
|
style={{ width: "100%", padding: "10px 16px", fontWeight: 600 }}
|
||||||
>
|
>
|
||||||
{submitting ? "Signing in…" : "Sign In"}
|
<span>{submitting ? "Signing in…" : "Sign In"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function AdminView() {
|
|||||||
|
|
||||||
// Edit user modal state
|
// Edit user modal state
|
||||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [editEmail, setEditEmail] = useState("");
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editRole, setEditRole] = useState<Role>("author");
|
const [editRole, setEditRole] = useState<Role>("author");
|
||||||
const [editPassword, setEditPassword] = useState("");
|
const [editPassword, setEditPassword] = useState("");
|
||||||
@@ -122,6 +123,7 @@ export function AdminView() {
|
|||||||
|
|
||||||
const openEditUser = (user: User) => {
|
const openEditUser = (user: User) => {
|
||||||
setEditingUser(user);
|
setEditingUser(user);
|
||||||
|
setEditEmail(user.email);
|
||||||
setEditName(user.displayName);
|
setEditName(user.displayName);
|
||||||
setEditRole(user.role);
|
setEditRole(user.role);
|
||||||
setEditPassword("");
|
setEditPassword("");
|
||||||
@@ -131,6 +133,7 @@ export function AdminView() {
|
|||||||
if (!editingUser) return;
|
if (!editingUser) return;
|
||||||
try {
|
try {
|
||||||
await api.updateUser(editingUser.id, {
|
await api.updateUser(editingUser.id, {
|
||||||
|
email: editEmail.trim().toLowerCase() || editingUser.email,
|
||||||
displayName: editName.trim() || editingUser.displayName,
|
displayName: editName.trim() || editingUser.displayName,
|
||||||
role: editRole,
|
role: editRole,
|
||||||
...(editPassword.trim().length >= 6 ? { password: editPassword.trim() } : {}),
|
...(editPassword.trim().length >= 6 ? { password: editPassword.trim() } : {}),
|
||||||
@@ -441,6 +444,12 @@ export function AdminView() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
<Input
|
||||||
|
label="Email address"
|
||||||
|
type="email"
|
||||||
|
value={editEmail}
|
||||||
|
onChange={(e) => setEditEmail(e.target.value)}
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Display name"
|
label="Display name"
|
||||||
value={editName}
|
value={editName}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type InputHTMLAttributes } from "react";
|
import { useId, type InputHTMLAttributes } from "react";
|
||||||
|
|
||||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -7,7 +7,8 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Input({ label, hint, error, id, className = "", ...rest }: InputProps) {
|
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 (
|
return (
|
||||||
<div className="ps-field">
|
<div className="ps-field">
|
||||||
{label && <label htmlFor={inputId} className="ps-field-label">{label}</label>}
|
{label && <label htmlFor={inputId} className="ps-field-label">{label}</label>}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type SelectHTMLAttributes } from "react";
|
import { useId, type SelectHTMLAttributes } from "react";
|
||||||
|
|
||||||
export interface SelectOption {
|
export interface SelectOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -11,7 +11,8 @@ export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Select({ label, options, id, className = "", ...rest }: SelectProps) {
|
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 (
|
return (
|
||||||
<div className="ps-field">
|
<div className="ps-field">
|
||||||
{label && <label htmlFor={selectId} className="ps-field-label">{label}</label>}
|
{label && <label htmlFor={selectId} className="ps-field-label">{label}</label>}
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ export function registerAdminRoutes(app: FastifyInstance): void {
|
|||||||
|
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const values: unknown[] = [];
|
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) {
|
if (body.displayName !== undefined) {
|
||||||
fields.push("display_name = ?");
|
fields.push("display_name = ?");
|
||||||
values.push(body.displayName);
|
values.push(body.displayName);
|
||||||
@@ -189,6 +198,7 @@ const userCreate = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userPatch = z.object({
|
const userPatch = z.object({
|
||||||
|
email: z.string().trim().toLowerCase().email().optional(),
|
||||||
role: z.enum(["admin", "author"]).optional(),
|
role: z.enum(["admin", "author"]).optional(),
|
||||||
disabled: z.boolean().optional(),
|
disabled: z.boolean().optional(),
|
||||||
displayName: z.string().min(1).max(80).optional(),
|
displayName: z.string().min(1).max(80).optional(),
|
||||||
|
|||||||
+18
-7
@@ -2,8 +2,12 @@
|
|||||||
* Environment-driven server configuration.
|
* Environment-driven server configuration.
|
||||||
*
|
*
|
||||||
* All values are read from `process.env` at import time with sensible
|
* 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 {
|
function env(key: string, fallback: string): string {
|
||||||
const value = process.env[key];
|
const value = process.env[key];
|
||||||
@@ -17,6 +21,18 @@ function envInt(key: string, fallback: number): number {
|
|||||||
return Number.isFinite(n) ? n : fallback;
|
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 {
|
export interface ServerConfig {
|
||||||
/** Path to the SQLite database file. */
|
/** Path to the SQLite database file. */
|
||||||
dbPath: string;
|
dbPath: string;
|
||||||
@@ -33,12 +49,7 @@ export interface ServerConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config: ServerConfig = {
|
export const config: ServerConfig = {
|
||||||
dbPath: env(
|
dbPath: env("PUBSCRIPT_DB", defaultDatabasePath()),
|
||||||
"PUBSCRIPT_DB",
|
|
||||||
process.env.VITEST || process.env.NODE_ENV === "test"
|
|
||||||
? ":memory:"
|
|
||||||
: "./data/pubscript.sqlite",
|
|
||||||
),
|
|
||||||
port: envInt("PORT", 8080),
|
port: envInt("PORT", 8080),
|
||||||
origin: env("PUBSCRIPT_ORIGIN", "http://localhost:5173"),
|
origin: env("PUBSCRIPT_ORIGIN", "http://localhost:5173"),
|
||||||
adminEmail: env("PUBSCRIPT_ADMIN_EMAIL", "admin@pubscript.local"),
|
adminEmail: env("PUBSCRIPT_ADMIN_EMAIL", "admin@pubscript.local"),
|
||||||
|
|||||||
Reference in New Issue
Block a user