Files
InkNote/frontend/src/ui/AuthScreen.jsx
T
marcandClaude Opus 5 92d525b3ab 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>
2026-08-07 17:00:14 +02:00

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>
);
}