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>
223 lines
6.2 KiB
React
223 lines
6.2 KiB
React
import { useMemo, useState } from "react";
|
|
import * as sync from "../syncEngine";
|
|
|
|
/* ═══════ AUTH SCREEN ═══════ */
|
|
export function AuthScreen({ onLogin, dark }) {
|
|
const [isLogin, setIsLogin] = useState(true);
|
|
const [username, setUsername] = useState("");
|
|
const [pass, setPass] = useState("");
|
|
const [name, setName] = useState("");
|
|
const [err, setErr] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const T = useMemo(
|
|
() => ({
|
|
bg: dark ? "#0d1117" : "#f6f8fa",
|
|
card: dark ? "#161b22" : "#fff",
|
|
border: dark ? "#30363d" : "#d0d7de",
|
|
text: dark ? "#e6edf3" : "#1f2328",
|
|
muted: dark ? "#8b949e" : "#656d76",
|
|
accent: dark ? "#388bfd" : "#0969da",
|
|
danger: dark ? "#f85149" : "#cf222e",
|
|
}),
|
|
[dark]
|
|
);
|
|
const submit = async () => {
|
|
const u = username.trim();
|
|
if (!u || !pass) {
|
|
setErr("Username and password required");
|
|
return;
|
|
}
|
|
if (!isLogin && !/^[a-zA-Z0-9._-]{3,32}$/.test(u)) {
|
|
setErr("Username must be 3-32 chars: letters, digits, . _ -");
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setErr("");
|
|
try {
|
|
const user = isLogin ? await sync.login(u, pass) : await sync.register(u, pass, name || undefined);
|
|
onLogin(user);
|
|
} catch (e) {
|
|
setErr(e.message === "AUTH" ? "Invalid credentials" : e.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
minHeight: "100vh",
|
|
background: T.bg,
|
|
fontFamily: "'DM Sans',sans-serif",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
background: T.card,
|
|
borderRadius: 24,
|
|
padding: 40,
|
|
width: 400,
|
|
maxWidth: "90vw",
|
|
boxShadow: "0 20px 60px rgba(0,0,0,0.15)",
|
|
border: `1px solid ${T.border}`,
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 28 }}>
|
|
<div
|
|
style={{
|
|
width: 42,
|
|
height: 42,
|
|
borderRadius: 14,
|
|
background: `linear-gradient(135deg,${T.accent},#a855f7)`,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: 22,
|
|
}}
|
|
>
|
|
✏️
|
|
</div>
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: 22,
|
|
fontWeight: 700,
|
|
color: T.text,
|
|
fontFamily: "'Newsreader',Georgia,serif",
|
|
}}
|
|
>
|
|
InkNote
|
|
</div>
|
|
<div style={{ fontSize: 12, color: T.muted }}>
|
|
{isLogin ? "Sign in to sync your notes" : "Create your account"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{!isLogin && (
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Display name"
|
|
style={{
|
|
width: "100%",
|
|
padding: "12px 16px",
|
|
borderRadius: 12,
|
|
border: `1px solid ${T.border}`,
|
|
background: T.bg,
|
|
color: T.text,
|
|
fontSize: 14,
|
|
outline: "none",
|
|
marginBottom: 12,
|
|
boxSizing: "border-box",
|
|
}}
|
|
/>
|
|
)}
|
|
<input
|
|
type="text"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
placeholder="Username"
|
|
autoFocus
|
|
autoCapitalize="none"
|
|
autoCorrect="off"
|
|
spellCheck={false}
|
|
autoComplete="username"
|
|
style={{
|
|
width: "100%",
|
|
padding: "12px 16px",
|
|
borderRadius: 12,
|
|
border: `1px solid ${T.border}`,
|
|
background: T.bg,
|
|
color: T.text,
|
|
fontSize: 14,
|
|
outline: "none",
|
|
marginBottom: 12,
|
|
boxSizing: "border-box",
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") submit();
|
|
}}
|
|
/>
|
|
<input
|
|
type="password"
|
|
value={pass}
|
|
onChange={(e) => setPass(e.target.value)}
|
|
placeholder="Password"
|
|
autoComplete={isLogin ? "current-password" : "new-password"}
|
|
style={{
|
|
width: "100%",
|
|
padding: "12px 16px",
|
|
borderRadius: 12,
|
|
border: `1px solid ${T.border}`,
|
|
background: T.bg,
|
|
color: T.text,
|
|
fontSize: 14,
|
|
outline: "none",
|
|
marginBottom: 16,
|
|
boxSizing: "border-box",
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") submit();
|
|
}}
|
|
/>
|
|
{err && (
|
|
<div
|
|
style={{
|
|
color: T.danger,
|
|
fontSize: 12,
|
|
marginBottom: 12,
|
|
padding: "8px 12px",
|
|
borderRadius: 8,
|
|
background: T.danger + "12",
|
|
}}
|
|
>
|
|
{err}
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={submit}
|
|
disabled={loading}
|
|
style={{
|
|
width: "100%",
|
|
padding: "12px",
|
|
borderRadius: 12,
|
|
border: "none",
|
|
background: T.accent,
|
|
color: "#fff",
|
|
cursor: "pointer",
|
|
fontSize: 14,
|
|
fontWeight: 600,
|
|
marginBottom: 16,
|
|
opacity: loading ? 0.6 : 1,
|
|
}}
|
|
>
|
|
{loading ? "..." : isLogin ? "Sign In" : "Create Account"}
|
|
</button>
|
|
<div style={{ textAlign: "center", fontSize: 13, color: T.muted }}>
|
|
{isLogin ? "Don't have an account? " : "Already have an account? "}
|
|
<button
|
|
onClick={() => {
|
|
setIsLogin(!isLogin);
|
|
setErr("");
|
|
}}
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
color: T.accent,
|
|
cursor: "pointer",
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
textDecoration: "underline",
|
|
}}
|
|
>
|
|
{isLogin ? "Sign Up" : "Sign In"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|