TipTap editor, version history, search, attachments — and a cleanup
The app is meant to run in daily private use now, so this pass was mostly
about the ways it could quietly lose data.
Reliability and security:
- the server no longer invents a JWT_SECRET or ENCRYPTION_KEY when one is
missing; it refuses to start. The old fallback silently made every stored
note unreadable after a restart.
- notes are encrypted with AES-256-GCM (v2:); existing CBC bodies still read.
- login is rate limited and compares against a dummy hash when the user does
not exist, so timing does not reveal who is registered.
- a server response issued before a local save is never adopted. This had
already eaten drawing strokes and note links; sync.hasPendingWrites() now
guards every path that takes a body from the server.
Editor and features:
- TipTap replaces the contenteditable editor: tables, task lists, and links
to other notes and notebooks, with a backlinks bar on the target.
- content format v2 ({v:2,doc}); v1 {html,tasks} bodies migrate on open.
- version history: the state before each write is snapshotted, coalesced
into 5-minute windows, 50 versions or 90 days per note.
- full-text search runs in the client, because the server cannot read
encrypted bodies and a server index would defeat the encryption.
- image attachments are stored by SHA-256 and deduplicated; the client
fetches them authenticated and caches the blob, so they work offline.
- drawings are vector strokes in a fixed 1200x1600 page space and now look
the same on every screen (0.0-0.2% deviation across four widths).
- login is by username; existing email accounts migrate automatically.
Operations:
- data lives on host bind mounts, not Docker volumes, so Proxmox Backup
covers it. Back up .env with it — without ENCRYPTION_KEY the dump is
unreadable.
Structure:
- NoteApp.jsx is gone. App.jsx now holds only where the user is; note data,
search, layout, history and drag each live behind a hook, and the four
panes are stateless.
- the whole data layer is TypeScript, with 81 unit tests concentrated on
the places that have actually broken.
- fixed along the way: the browser back button had a dead press, because
restoring a history entry pushed a duplicate one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d97591b242
commit
92d525b3ab
71 files changed
+14354
-1826
No files matched your search
@@ -0,0 +1,394 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import * as sync from "../syncEngine";
|
||||
import { I } from "./icons.jsx";
|
||||
|
||||
/* ═══════ ACCOUNT MENU ═══════ */
|
||||
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, dark }) {
|
||||
const [tab, setTab] = useState("profile"); // profile | username | password
|
||||
const [displayName, setDisplayName] = useState(user?.displayName || "");
|
||||
const [newUsername, setNewUsername] = useState(user?.username || "");
|
||||
const [usernamePass, setUsernamePass] = useState("");
|
||||
const [curPass, setCurPass] = useState("");
|
||||
const [newPass, setNewPass] = useState("");
|
||||
const [newPass2, setNewPass2] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const T = useMemo(
|
||||
() => ({
|
||||
bg: dark ? "#161b22" : "#fff",
|
||||
border: dark ? "#30363d" : "#d0d7de",
|
||||
text: dark ? "#e6edf3" : "#1f2328",
|
||||
muted: dark ? "#8b949e" : "#656d76",
|
||||
accent: dark ? "#388bfd" : "#0969da",
|
||||
danger: dark ? "#f85149" : "#cf222e",
|
||||
success: dark ? "#3fb950" : "#1a7f37",
|
||||
inputBg: dark ? "#0d1117" : "#f6f8fa",
|
||||
}),
|
||||
[dark]
|
||||
);
|
||||
const inp = {
|
||||
width: "100%",
|
||||
padding: "10px 14px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: T.inputBg,
|
||||
color: T.text,
|
||||
fontSize: 14,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
marginBottom: 10,
|
||||
fontFamily: "'DM Sans',sans-serif",
|
||||
};
|
||||
const tbtn = (active) => ({
|
||||
padding: "7px 14px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
background: active ? T.accent + "22" : "transparent",
|
||||
color: active ? T.accent : T.muted,
|
||||
});
|
||||
const clear = () => {
|
||||
setMsg("");
|
||||
setErr("");
|
||||
};
|
||||
|
||||
const saveProfile = async () => {
|
||||
if (!displayName.trim()) return;
|
||||
clear();
|
||||
setSaving(true);
|
||||
try {
|
||||
const u = await sync.updateProfile(displayName.trim());
|
||||
onUserUpdate(u);
|
||||
setMsg("Display name updated");
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
const saveUsername = async () => {
|
||||
if (!newUsername || !usernamePass) return;
|
||||
clear();
|
||||
setSaving(true);
|
||||
try {
|
||||
const u = await sync.updateUsername(newUsername.trim(), usernamePass);
|
||||
onUserUpdate(u);
|
||||
setUsernamePass("");
|
||||
setMsg("Username updated");
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
const savePassword = async () => {
|
||||
if (!curPass || !newPass) return;
|
||||
if (newPass !== newPass2) {
|
||||
setErr("Passwords don't match");
|
||||
return;
|
||||
}
|
||||
clear();
|
||||
setSaving(true);
|
||||
try {
|
||||
await sync.updatePassword(curPass, newPass);
|
||||
setCurPass("");
|
||||
setNewPass("");
|
||||
setNewPass2("");
|
||||
setMsg("Password changed");
|
||||
} catch (e) {
|
||||
setErr(e.message);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 2000,
|
||||
backdropFilter: "blur(6px)",
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: T.bg,
|
||||
borderRadius: 20,
|
||||
padding: 0,
|
||||
width: 440,
|
||||
maxWidth: "92vw",
|
||||
boxShadow: "0 24px 48px rgba(0,0,0,0.3)",
|
||||
border: `1px solid ${T.border}`,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "18px 22px",
|
||||
borderBottom: `1px solid ${T.border}`,
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
color: T.text,
|
||||
fontFamily: "'Newsreader',Georgia,serif",
|
||||
}}
|
||||
>
|
||||
Account
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color: T.muted,
|
||||
}}
|
||||
>
|
||||
{I.x}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{ display: "flex", gap: 4, padding: "12px 22px 0", borderBottom: `1px solid ${T.border}` }}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTab("profile");
|
||||
clear();
|
||||
}}
|
||||
style={tbtn(tab === "profile")}
|
||||
>
|
||||
Profile
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTab("username");
|
||||
clear();
|
||||
}}
|
||||
style={tbtn(tab === "username")}
|
||||
>
|
||||
Username
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTab("password");
|
||||
clear();
|
||||
}}
|
||||
style={tbtn(tab === "password")}
|
||||
>
|
||||
Password
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: "18px 22px 22px" }}>
|
||||
{msg && (
|
||||
<div
|
||||
style={{
|
||||
color: T.success,
|
||||
fontSize: 12,
|
||||
marginBottom: 10,
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
background: T.success + "15",
|
||||
}}
|
||||
>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
{err && (
|
||||
<div
|
||||
style={{
|
||||
color: T.danger,
|
||||
fontSize: 12,
|
||||
marginBottom: 10,
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
background: T.danger + "12",
|
||||
}}
|
||||
>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "profile" && (
|
||||
<>
|
||||
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
style={inp}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveProfile();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={saveProfile}
|
||||
disabled={saving}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: T.accent,
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
opacity: saving ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "username" && (
|
||||
<>
|
||||
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
|
||||
New Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
style={inp}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: T.muted, marginTop: -6, marginBottom: 10 }}>
|
||||
3–32 characters · letters, digits, . _ -
|
||||
</div>
|
||||
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={usernamePass}
|
||||
onChange={(e) => setUsernamePass(e.target.value)}
|
||||
style={inp}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveUsername();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={saveUsername}
|
||||
disabled={saving}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: T.accent,
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
opacity: saving ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? "Saving..." : "Update Username"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "password" && (
|
||||
<>
|
||||
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={curPass}
|
||||
onChange={(e) => setCurPass(e.target.value)}
|
||||
style={inp}
|
||||
/>
|
||||
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPass}
|
||||
onChange={(e) => setNewPass(e.target.value)}
|
||||
style={inp}
|
||||
/>
|
||||
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPass2}
|
||||
onChange={(e) => setNewPass2(e.target.value)}
|
||||
style={inp}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") savePassword();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={savePassword}
|
||||
disabled={saving}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: T.accent,
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
opacity: saving ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? "Saving..." : "Change Password"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: "0 22px 18px" }}>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.danger}33`,
|
||||
background: T.danger + "08",
|
||||
cursor: "pointer",
|
||||
color: T.danger,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{I.logout} Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user