Remove public sign-up; accounts are created by an administrator

This is a private instance, so anyone able to reach the login page could
previously give themselves an account. The registration route is gone
rather than hidden, and the login screen no longer hints that accounts
can be made.

Accounts now come from an admin under Account → Administration: create,
delete, and reset a password. Appointing further admins is deliberately
not offered — with one admin it is not needed, and it is the one power
that turns a compromised account into a lost instance.

The first admin comes from INKNOTE_ADMIN_USER / INKNOTE_ADMIN_PASSWORD,
used only when the database has no users, or to choose whom to promote
when an older database has users but no admin. Once an admin exists both
are ignored, so leaving them in .env cannot reset anything.

Two things that would otherwise have been quietly wrong:

- a deleted user's token stayed valid until it expired, because tokens
  are signed rather than stored and there was no session to revoke.
  authMiddleware now checks the account still exists, so an open tab
  stops working the moment the account is deleted.
- deleting an account takes its notes, notebooks, drawings and version
  history with it via ON DELETE CASCADE. Verified: no orphaned rows in
  notes, notebooks, sync_log, attachments or note_versions.

Deletion asks for the username to be typed back, since it destroys data
that version history cannot bring back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 12:53:08 +02:00
1 parent 55a6b3caab
commit 68e184163e
12 files changed
+859 -123

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
# Copy to ../.env next to docker-compose.yml and fill in.
# Back this file up together with the database: without ENCRYPTION_KEY the
# stored notes cannot be read again.
# Signs session tokens. Changing it logs everyone out.
# openssl rand -hex 32
JWT_SECRET=
# Encrypts note bodies at rest. LOSING THIS MEANS LOSING THE NOTES.
# Must be exactly 32 bytes as hex.
# openssl rand -hex 32
ENCRYPTION_KEY=
# The first administrator.
#
# There is no public sign-up — accounts are created by an admin inside the app
# under Account → Administration. These two settle who the first one is:
#
# empty database → the account is created from both values
# existing users, → INKNOTE_ADMIN_USER names which of them becomes admin.
# no admin yet Without it, the oldest account is promoted.
# an admin exists → both are ignored entirely
#
# So they can never reset an existing password or take rights away; the worst
# they can do on a running instance is nothing.
INKNOTE_ADMIN_USER=
INKNOTE_ADMIN_PASSWORD=
# ── Optional ──
# PORT=4000
# ALLOWED_ORIGINS= # only if the UI is served from another host
# MAX_VERSIONS_PER_NOTE=50
# VERSION_RETENTION_DAYS=90
# VERSION_COALESCE_MINUTES=5
+171 -31
View File
@@ -81,6 +81,7 @@ db.exec(`
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
display_name TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
@@ -189,11 +190,20 @@ db.exec(`
const tableColumns = (t) => db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name);
const USERNAME_RE = /^[a-zA-Z0-9._-]{3,32}$/;
const MIN_PASSWORD_LENGTH = 6;
function normalizeUsername(raw) {
const v = String(raw ?? "").trim();
return USERNAME_RE.test(v) ? v : null;
}
/** The fields of a user row that may leave the server. Never the password hash. */
const publicUser = (row) => ({
id: row.id,
username: row.username,
displayName: row.display_name,
isAdmin: !!row.is_admin,
});
// Login used to be e-mail based. Rebuild the table with a username column and
// derive each existing user's username from the local part of their address.
(function migrateEmailToUsername() {
@@ -237,6 +247,57 @@ if (!tableColumns("notes").includes("drawing_strokes")) {
db.exec("ALTER TABLE notes ADD COLUMN drawing_strokes BLOB");
}
if (!tableColumns("users").includes("is_admin")) {
db.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0");
}
/**
* Makes sure exactly one way in exists.
*
* There is no public sign-up: accounts are created by an admin. That leaves the
* question of where the first admin comes from, and the answer is this — a
* username and password in the environment, used only when the database has no
* users at all. An installation that already has users is never touched by the
* credentials, so leaving them in .env cannot silently reset anything.
*
* The second branch covers upgrades: a database that predates admin rights has
* users but no admin, and would otherwise be locked out of creating any more.
*/
(function ensureAdminExists() {
const wanted = normalizeUsername(process.env.INKNOTE_ADMIN_USER);
const password = process.env.INKNOTE_ADMIN_PASSWORD;
const userCount = db.prepare("SELECT COUNT(*) AS n FROM users").get().n;
if (userCount === 0) {
if (!wanted || !password) {
console.warn(
"⚠️ No users and no INKNOTE_ADMIN_USER / INKNOTE_ADMIN_PASSWORD set — nobody can log in.\n" +
" Set both in .env and restart to create the first administrator."
);
return;
}
if (String(password).length < MIN_PASSWORD_LENGTH) {
console.warn(`⚠️ INKNOTE_ADMIN_PASSWORD is shorter than ${MIN_PASSWORD_LENGTH} characters — not creating an admin.`);
return;
}
db.prepare(
"INSERT INTO users (id, username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, 1)"
).run(uuid(), wanted, bcrypt.hashSync(String(password), 12), wanted);
console.log(` ✅ created first administrator "${wanted}" from the environment`);
return;
}
if (db.prepare("SELECT COUNT(*) AS n FROM users WHERE is_admin = 1").get().n > 0) return;
// Prefer the account named in the environment; otherwise the oldest one, so
// an existing single-user installation promotes the obvious candidate.
const promote =
(wanted && db.prepare("SELECT id, username FROM users WHERE username = ?").get(wanted)) ||
db.prepare("SELECT id, username FROM users ORDER BY created_at LIMIT 1").get();
db.prepare("UPDATE users SET is_admin = 1 WHERE id = ?").run(promote.id);
console.log(` ✅ granted administrator rights to existing user "${promote.username}"`);
})();
/* ─────────── Encryption helpers ─────────── */
/* AES-256-GCM. CBC gave confidentiality but no integrity — a flipped bit in the
* ciphertext decrypted to garbage instead of failing loudly. GCM authenticates,
@@ -304,6 +365,13 @@ function authMiddleware(req, res, next) {
try {
const token = header.slice(7);
const payload = jwt.verify(token, JWT_SECRET);
// Tokens are signed rather than stored, so there is no session to revoke
// when an admin deletes an account. Checking that the user still exists is
// what stops a deleted person's open tab from carrying on as if nothing
// happened; the client treats the 401 as a sign-out.
if (!userExists.get(payload.userId)) {
return res.status(401).json({ error: "Account no longer exists" });
}
req.userId = payload.userId;
req.deviceId = req.headers["x-device-id"] || "unknown";
next();
@@ -311,6 +379,7 @@ function authMiddleware(req, res, next) {
res.status(401).json({ error: "Invalid token" });
}
}
const userExists = db.prepare("SELECT 1 FROM users WHERE id = ?").pluck();
/* ─────────── Express app ─────────── */
const app = express();
@@ -363,30 +432,8 @@ const upload = multer({
});
/* ─── Auth routes ─── */
app.post("/api/auth/register", async (req, res, next) => {
try {
const { username, password, displayName } = req.body;
const name = normalizeUsername(username);
if (!name || !password) return res.status(400).json({ error: "Username and password required" });
if (String(password).length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" });
const existing = db.prepare("SELECT id FROM users WHERE username = ?").get(name);
if (existing) return res.status(409).json({ error: "Username already taken" });
const id = uuid();
const hash = await bcrypt.hash(password, 12);
try {
db.prepare("INSERT INTO users (id, username, password_hash, display_name) VALUES (?, ?, ?, ?)").run(id, name, hash, displayName || name);
} catch (err) {
// Someone claimed the name between the check and the insert.
if (String(err.message).includes("UNIQUE")) return res.status(409).json({ error: "Username already taken" });
throw err;
}
const token = jwt.sign({ userId: id }, JWT_SECRET, { expiresIn: "30d" });
res.json({ token, user: { id, username: name, displayName: displayName || name } });
} catch (err) { next(err); }
});
/* There is no registration route on purpose: this is a private instance, and
* accounts are created by an administrator under /api/admin/users. */
app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => {
try {
@@ -399,23 +446,22 @@ app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => {
clearLoginAttempts(req);
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" });
res.json({ token, user: { id: user.id, username: user.username, displayName: user.display_name } });
res.json({ token, user: publicUser(user) });
} catch (err) { next(err); }
});
/* ─── Auth profile routes ─── */
app.get("/api/auth/me", authMiddleware, (req, res) => {
const user = db.prepare("SELECT id, username, display_name FROM users WHERE id = ?").get(req.userId);
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
if (!user) return res.status(404).json({ error: "User not found" });
res.json({ id: user.id, username: user.username, displayName: user.display_name });
res.json(publicUser(user));
});
app.put("/api/auth/profile", authMiddleware, (req, res) => {
const { displayName } = req.body;
if (!displayName || !displayName.trim()) return res.status(400).json({ error: "Display name required" });
db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run(displayName.trim(), req.userId);
const user = db.prepare("SELECT id, username, display_name FROM users WHERE id = ?").get(req.userId);
res.json({ id: user.id, username: user.username, displayName: user.display_name });
res.json(publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId)));
});
app.put("/api/auth/username", authMiddleware, async (req, res, next) => {
@@ -429,7 +475,7 @@ app.put("/api/auth/username", authMiddleware, async (req, res, next) => {
const exists = db.prepare("SELECT id FROM users WHERE username = ? AND id != ?").get(name, req.userId);
if (exists) return res.status(409).json({ error: "Username already taken" });
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(name, req.userId);
res.json({ id: user.id, username: name, displayName: user.display_name });
res.json(publicUser({ ...user, username: name }));
} catch (err) { next(err); }
});
@@ -437,7 +483,7 @@ app.put("/api/auth/password", authMiddleware, async (req, res, next) => {
try {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" });
if (String(newPassword).length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" });
if (String(newPassword).length < MIN_PASSWORD_LENGTH) return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
if (!user) return res.status(404).json({ error: "User not found" });
if (!(await bcrypt.compare(String(currentPassword), user.password_hash))) return res.status(401).json({ error: "Current password is wrong" });
@@ -447,6 +493,100 @@ app.put("/api/auth/password", authMiddleware, async (req, res, next) => {
} catch (err) { next(err); }
});
/* ─── Admin routes ─── */
/** Gate for everything under /api/admin. Runs after authMiddleware. */
function requireAdmin(req, res, next) {
const me = db.prepare("SELECT is_admin FROM users WHERE id = ?").get(req.userId);
// Deliberately the same 404-ish silence an ordinary user gets elsewhere: a
// non-admin should not learn that an admin area exists.
if (!me || !me.is_admin) return res.status(403).json({ error: "Not allowed" });
next();
}
app.get("/api/admin/users", authMiddleware, requireAdmin, (req, res) => {
const rows = db
.prepare(
`SELECT u.*, (SELECT COUNT(*) FROM notes n WHERE n.user_id = u.id AND n.deleted_at IS NULL) AS note_count
FROM users u ORDER BY u.created_at`
)
.all();
res.json(
rows.map((r) => ({
...publicUser(r),
createdAt: r.created_at,
noteCount: r.note_count,
isSelf: r.id === req.userId,
}))
);
});
app.post("/api/admin/users", authMiddleware, requireAdmin, async (req, res, next) => {
try {
const { username, password, displayName } = req.body;
const name = normalizeUsername(username);
if (!name)
return res.status(400).json({ error: "Username must be 3-32 characters: letters, digits, . _ -" });
if (!password || String(password).length < MIN_PASSWORD_LENGTH)
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
const id = uuid();
const hash = await bcrypt.hash(String(password), 12);
try {
db.prepare(
"INSERT INTO users (id, username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, 0)"
).run(id, name, hash, String(displayName || name).trim() || name);
} catch (err) {
if (String(err.message).includes("UNIQUE"))
return res.status(409).json({ error: "Username already taken" });
throw err;
}
console.log(` 👤 admin ${req.userId} created user "${name}"`);
res.status(201).json(publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(id)));
} catch (err) {
next(err);
}
});
app.put("/api/admin/users/:id/password", authMiddleware, requireAdmin, async (req, res, next) => {
try {
const { newPassword } = req.body;
if (!newPassword || String(newPassword).length < MIN_PASSWORD_LENGTH)
return res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
const target = db.prepare("SELECT * FROM users WHERE id = ?").get(req.params.id);
if (!target) return res.status(404).json({ error: "User not found" });
const hash = await bcrypt.hash(String(newPassword), 12);
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(
hash,
target.id
);
console.log(` 🔑 admin ${req.userId} reset the password of "${target.username}"`);
// Sessions elsewhere keep working: tokens are signed, not stored, so there
// is nothing to revoke. Resetting a password locks nobody out mid-edit.
res.json({ success: true });
} catch (err) {
next(err);
}
});
app.delete("/api/admin/users/:id", authMiddleware, requireAdmin, (req, res) => {
const target = db.prepare("SELECT * FROM users WHERE id = ?").get(req.params.id);
if (!target) return res.status(404).json({ error: "User not found" });
if (target.id === req.userId) return res.status(400).json({ error: "You cannot delete your own account" });
// A backstop against leaving the instance with no way to add users. Today the
// self-delete check above already covers it — the only admin can only be
// yourself — but that stops being true the moment admins can appoint admins.
if (target.is_admin && db.prepare("SELECT COUNT(*) AS n FROM users WHERE is_admin = 1").get().n <= 1)
return res.status(400).json({ error: "This is the last administrator" });
// Notes, notebooks, versions and attachment rows go with the user through
// ON DELETE CASCADE; the files behind them are swept up by pruneOrphanAttachments.
db.prepare("DELETE FROM users WHERE id = ?").run(target.id);
console.log(` 🗑️ admin ${req.userId} deleted user "${target.username}" and all their data`);
res.json({ success: true });
});
/* ─── Notebook routes ─── */
app.get("/api/notebooks", authMiddleware, (req, res) => {
const notebooks = db.prepare("SELECT * FROM notebooks WHERE user_id = ? AND deleted_at IS NULL ORDER BY sort_order").all(req.userId);
+3
View File
@@ -25,6 +25,9 @@ services:
- PORT=4000
- JWT_SECRET=${JWT_SECRET}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
# Only used when the database has no users at all: creates the first admin.
- INKNOTE_ADMIN_USER=${INKNOTE_ADMIN_USER:-}
- INKNOTE_ADMIN_PASSWORD=${INKNOTE_ADMIN_PASSWORD:-}
- NODE_ENV=development
# Bind mounts, not named volumes: the data sits in a plain directory on the
# Docker host where the host's own backup picks it up.
+3
View File
@@ -25,6 +25,9 @@ services:
- PORT=4000
- JWT_SECRET=${JWT_SECRET}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
# Only used when the database has no users at all: creates the first admin.
- INKNOTE_ADMIN_USER=${INKNOTE_ADMIN_USER:-}
- INKNOTE_ADMIN_PASSWORD=${INKNOTE_ADMIN_PASSWORD:-}
- NODE_ENV=production
# Bind mounts, not named volumes: the data sits in a plain directory on the
# Docker host, so whatever backs up the host also backs up InkNote.
+9
View File
@@ -16,6 +16,7 @@ import { ContextMenu } from "./ui/ContextMenu.jsx";
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
import { AccountMenu } from "./ui/AccountMenu.jsx";
import { AuthScreen } from "./ui/AuthScreen.jsx";
import { AdminPanel } from "./ui/AdminPanel.jsx";
import { Sidebar } from "./ui/Sidebar.jsx";
import { Dashboard } from "./features/notebooks/Dashboard.jsx";
import { NoteList } from "./features/notes/NoteList.jsx";
@@ -49,6 +50,7 @@ export default function App() {
const [showNewNb, setShowNewNb] = useState(false);
const [editingNb, setEditingNb] = useState(null);
const [showAccount, setShowAccount] = useState(false);
const [showAdmin, setShowAdmin] = useState(false);
const [historyFor, setHistoryFor] = useState(null);
const [ctxMenu, setCtxMenu] = useState(null);
const [confirmDialog, setConfirmDialog] = useState(null);
@@ -516,9 +518,16 @@ export default function App() {
onLogout();
}}
onUserUpdate={setUser}
onOpenAdmin={() => {
setShowAccount(false);
setShowAdmin(true);
}}
dark={dark}
/>
)}
{showAdmin && user?.isAdmin && (
<AdminPanel currentUser={user} onClose={() => setShowAdmin(false)} dark={dark} />
)}
{historyFor && (
<HistoryModal
noteId={historyFor}
+28 -3
View File
@@ -8,6 +8,7 @@
import * as db from "./offlineStore";
import type {
Backlink,
ManagedUser,
NoteContent,
NoteMeta,
NoteVersion,
@@ -254,9 +255,8 @@ export async function login(username: string, password: string): Promise<User> {
return acceptAuth(await api.post<AuthResponse>("/auth/login", { username, password }));
}
export async function register(username: string, password: string, displayName?: string): Promise<User> {
return acceptAuth(await api.post<AuthResponse>("/auth/register", { username, password, displayName }));
}
/* There is no register(): this instance has no public sign-up. Accounts are
* created by an administrator through the functions under "Administration". */
/**
* Restores a session from storage.
@@ -1041,6 +1041,31 @@ export async function getAllLinks(): Promise<
}
}
/* ─── Administration ───
* Only reachable for accounts flagged isAdmin; the server refuses the rest.
* These talk to the server directly and are never queued: creating or deleting
* an account offline would be a promise the client cannot keep. */
export async function listUsers(): Promise<ManagedUser[]> {
return api.get<ManagedUser[]>("/admin/users");
}
export async function createUser(fields: {
username: string;
password: string;
displayName?: string;
}): Promise<User> {
return api.post<User>("/admin/users", fields);
}
export async function resetUserPassword(id: string, newPassword: string): Promise<void> {
await api.put(`/admin/users/${id}/password`, { newPassword });
}
export async function deleteUser(id: string): Promise<void> {
await api.del(`/admin/users/${id}`);
}
export { DEVICE_ID };
/* ─── Test seam ───
+10
View File
@@ -70,6 +70,16 @@ export interface User {
id: string;
username: string;
displayName: string;
/** Admins may create, delete and reset the password of other accounts. */
isAdmin?: boolean;
}
/** A user row as the admin panel sees it. */
export interface ManagedUser extends User {
createdAt: string;
noteCount: number;
/** True for the admin doing the looking — they may not delete themselves. */
isSelf: boolean;
}
/* ── Version history ── */
+24 -1
View File
@@ -3,7 +3,7 @@ import * as sync from "../syncEngine";
import { I } from "./icons.jsx";
/* ═══════ ACCOUNT MENU ═══════ */
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, dark }) {
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, dark }) {
const [tab, setTab] = useState("profile"); // profile | username | password
const [displayName, setDisplayName] = useState(user?.displayName || "");
const [newUsername, setNewUsername] = useState(user?.username || "");
@@ -367,6 +367,29 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, dark }) {
)}
</div>
<div style={{ padding: "0 22px 18px" }}>
{user?.isAdmin && (
<button
onClick={onOpenAdmin}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "10px",
borderRadius: 10,
border: `1px solid ${T.accent}33`,
background: T.accent + "0c",
cursor: "pointer",
color: T.accent,
fontSize: 13,
fontWeight: 600,
justifyContent: "center",
marginBottom: 8,
}}
>
{I.users} Administration
</button>
)}
<button
onClick={onLogout}
style={{
+486
View File
@@ -0,0 +1,486 @@
import { useEffect, useMemo, useState } from "react";
import * as sync from "../syncEngine";
import { I } from "./icons.jsx";
import { FONT_SERIF, FONT_UI } from "./theme";
const MIN_PASSWORD_LENGTH = 6;
const USERNAME_RE = /^[a-zA-Z0-9._-]{3,32}$/;
const fmtDate = (d) =>
new Date(d.includes("T") ? d : d.replace(" ", "T") + "Z").toLocaleDateString("de-DE", {
year: "numeric",
month: "short",
day: "numeric",
});
/**
* User administration — the only way accounts come into being.
*
* There is no public sign-up, so everything here is reachable by administrators
* alone; the server enforces that independently, this dialog only decides what
* to show. Deleting an account destroys its notes, so that path asks twice and
* makes the user type the name.
*/
export function AdminPanel({ currentUser, onClose, dark }) {
const [users, setUsers] = useState(null);
const [err, setErr] = useState("");
const [msg, setMsg] = useState("");
const [busy, setBusy] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState("");
const [newDisplay, setNewDisplay] = useState("");
const [newPass, setNewPass] = useState("");
/** id of the user whose password is being reset, and the field's contents */
const [resetFor, setResetFor] = useState(null);
const [resetPass, setResetPass] = useState("");
/** the user awaiting delete confirmation, and the typed-back name */
const [deleteFor, setDeleteFor] = useState(null);
const [deleteConfirm, setDeleteConfirm] = useState("");
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
row: dark ? "#0d1117" : "#f6f8fa",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
faint: dark ? "#484f58" : "#afb8c1",
accent: dark ? "#388bfd" : "#0969da",
danger: dark ? "#f85149" : "#cf222e",
success: dark ? "#3fb950" : "#1a7f37",
}),
[dark]
);
const inp = {
width: "100%",
padding: "9px 12px",
borderRadius: 9,
border: `1px solid ${T.border}`,
background: T.row,
color: T.text,
fontSize: 13,
outline: "none",
boxSizing: "border-box",
marginBottom: 8,
fontFamily: FONT_UI,
};
const btn = (kind = "accent") => ({
padding: "8px 14px",
borderRadius: 9,
border: kind === "ghost" ? `1px solid ${T.border}` : "none",
background: kind === "accent" ? T.accent : kind === "danger" ? T.danger : "transparent",
color: kind === "ghost" ? T.muted : "#fff",
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
});
const clear = () => {
setErr("");
setMsg("");
};
const load = async () => {
try {
setUsers(await sync.listUsers());
} catch (e) {
setErr(e.message === "AUTH" ? "Session expired" : e.message);
setUsers([]);
}
};
useEffect(() => {
void load();
}, []);
const closeForms = () => {
setShowCreate(false);
setResetFor(null);
setDeleteFor(null);
setNewName("");
setNewDisplay("");
setNewPass("");
setResetPass("");
setDeleteConfirm("");
};
const create = async () => {
clear();
const name = newName.trim();
if (!USERNAME_RE.test(name)) return setErr("Username must be 3-32 characters: letters, digits, . _ -");
if (newPass.length < MIN_PASSWORD_LENGTH)
return setErr(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
setBusy(true);
try {
await sync.createUser({
username: name,
password: newPass,
displayName: newDisplay.trim() || undefined,
});
closeForms();
setMsg(`Created "${name}" — pass the password on yourself, it is not shown again.`);
await load();
} catch (e) {
setErr(e.message);
}
setBusy(false);
};
const resetPassword = async () => {
clear();
if (resetPass.length < MIN_PASSWORD_LENGTH)
return setErr(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
setBusy(true);
try {
const name = users.find((u) => u.id === resetFor)?.username;
await sync.resetUserPassword(resetFor, resetPass);
closeForms();
setMsg(`New password set for "${name}".`);
} catch (e) {
setErr(e.message);
}
setBusy(false);
};
const remove = async () => {
clear();
setBusy(true);
try {
await sync.deleteUser(deleteFor.id);
const name = deleteFor.username;
closeForms();
setMsg(`Deleted "${name}" and everything they had.`);
await load();
} catch (e) {
setErr(e.message);
}
setBusy(false);
};
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2100,
backdropFilter: "blur(6px)",
fontFamily: FONT_UI,
}}
onClick={onClose}
>
<div
style={{
background: T.bg,
borderRadius: 20,
width: 560,
maxWidth: "94vw",
maxHeight: "88vh",
display: "flex",
flexDirection: "column",
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}`,
}}
>
<div>
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}>
Administration
</h3>
<div style={{ fontSize: 12, color: T.muted, marginTop: 2 }}>
{users ? `${users.length} account${users.length === 1 ? "" : "s"}` : "Loading..."}
</div>
</div>
<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={{ padding: "14px 22px", overflowY: "auto", flex: 1 }}>
{(err || msg) && (
<div
style={{
fontSize: 12,
marginBottom: 12,
padding: "9px 12px",
borderRadius: 9,
color: err ? T.danger : T.success,
background: (err ? T.danger : T.success) + "12",
}}
>
{err || msg}
</div>
)}
{users === null ? (
<div style={{ color: T.muted, fontSize: 13, padding: "20px 0" }}>Loading accounts...</div>
) : (
users.map((u) => (
<div
key={u.id}
data-user={u.username}
style={{
background: T.row,
border: `1px solid ${T.border}`,
borderRadius: 12,
padding: "12px 14px",
marginBottom: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: T.text }}>{u.username}</span>
{u.isAdmin && (
<span
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.04em",
padding: "2px 7px",
borderRadius: 6,
background: T.accent + "22",
color: T.accent,
}}
>
ADMIN
</span>
)}
{u.isSelf && <span style={{ fontSize: 11, color: T.faint }}>you</span>}
</div>
<div style={{ fontSize: 11, color: T.muted, marginTop: 3 }}>
{u.displayName} · {u.noteCount} note{u.noteCount === 1 ? "" : "s"} · since{" "}
{fmtDate(u.createdAt)}
</div>
</div>
<button
onClick={() => {
clear();
closeForms();
setResetFor(u.id);
}}
style={{ ...btn("ghost"), whiteSpace: "nowrap" }}
>
Reset password
</button>
{!u.isSelf && (
<button
onClick={() => {
clear();
closeForms();
setDeleteFor(u);
}}
title={`Delete ${u.username}`}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
borderRadius: 8,
border: "none",
background: "transparent",
cursor: "pointer",
color: T.danger,
flexShrink: 0,
}}
>
{I.trash}
</button>
)}
</div>
{resetFor === u.id && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: `1px solid ${T.border}` }}>
<input
type="password"
value={resetPass}
onChange={(e) => setResetPass(e.target.value)}
placeholder={`New password for ${u.username}`}
autoFocus
autoComplete="new-password"
style={inp}
onKeyDown={(e) => e.key === "Enter" && resetPassword()}
/>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={resetPassword} disabled={busy} style={btn()}>
{busy ? "..." : "Set password"}
</button>
<button onClick={closeForms} style={btn("ghost")}>
Cancel
</button>
</div>
</div>
)}
{deleteFor?.id === u.id && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: `1px solid ${T.border}` }}>
<div style={{ fontSize: 12, color: T.danger, marginBottom: 8, lineHeight: 1.5 }}>
This deletes {u.username}&rsquo;s {u.noteCount} note
{u.noteCount === 1 ? "" : "s"}, their notebooks, drawings and version history. It cannot
be undone. Type <strong>{u.username}</strong> to confirm.
</div>
<input
type="text"
value={deleteConfirm}
onChange={(e) => setDeleteConfirm(e.target.value)}
placeholder={u.username}
autoFocus
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
style={inp}
onKeyDown={(e) => e.key === "Enter" && deleteConfirm === u.username && remove()}
/>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={remove}
disabled={busy || deleteConfirm !== u.username}
style={{
...btn("danger"),
opacity: busy || deleteConfirm !== u.username ? 0.45 : 1,
cursor: deleteConfirm === u.username ? "pointer" : "not-allowed",
}}
>
{busy ? "..." : "Delete permanently"}
</button>
<button onClick={closeForms} style={btn("ghost")}>
Cancel
</button>
</div>
</div>
)}
</div>
))
)}
{showCreate ? (
<div
style={{
border: `1px solid ${T.accent}55`,
borderRadius: 12,
padding: "14px",
marginTop: 12,
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: T.text, marginBottom: 10 }}>
New account
</div>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Username"
autoFocus
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
style={inp}
/>
<input
type="text"
value={newDisplay}
onChange={(e) => setNewDisplay(e.target.value)}
placeholder="Display name (optional)"
style={inp}
/>
<input
type="password"
value={newPass}
onChange={(e) => setNewPass(e.target.value)}
placeholder="Initial password"
autoComplete="new-password"
style={inp}
onKeyDown={(e) => e.key === "Enter" && create()}
/>
<div style={{ fontSize: 11, color: T.muted, marginBottom: 10, lineHeight: 1.5 }}>
The password is not stored in readable form and cannot be shown again — hand it over yourself,
and let them change it under Account.
</div>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={create} disabled={busy} style={btn()}>
{busy ? "Creating..." : "Create account"}
</button>
<button onClick={closeForms} style={btn("ghost")}>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => {
clear();
closeForms();
setShowCreate(true);
}}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
width: "100%",
padding: "11px",
borderRadius: 12,
border: `1px dashed ${T.border}`,
background: "transparent",
cursor: "pointer",
color: T.accent,
fontSize: 13,
fontWeight: 600,
marginTop: 4,
}}
>
{I.plus} New account
</button>
)}
</div>
<div
style={{
padding: "12px 22px",
borderTop: `1px solid ${T.border}`,
fontSize: 11,
color: T.faint,
lineHeight: 1.5,
}}
>
Signed in as {currentUser?.username}. There is no public sign-up — every account on this instance is
created here.
</div>
</div>
</div>
);
}
+39 -85
View File
@@ -1,14 +1,20 @@
import { useMemo, useState } from "react";
import * as sync from "../syncEngine";
/* ═══════ AUTH SCREEN ═══════ */
/**
* Sign-in only.
*
* This is a private instance with no public sign-up: accounts are created by an
* administrator under Account → Administration. There is deliberately nothing
* here that hints at how, because that would only be useful to someone who has
* no account and should not get one.
*/
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",
@@ -21,27 +27,41 @@ export function AuthScreen({ onLogin, dark }) {
}),
[dark]
);
const field = {
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",
};
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);
onLogin(await sync.login(u, pass));
} catch (e) {
setErr(e.message === "AUTH" ? "Invalid credentials" : e.message);
} finally {
setLoading(false);
}
};
const onEnter = (e) => {
if (e.key === "Enter") submit();
};
return (
<div
style={{
@@ -90,31 +110,10 @@ export function AuthScreen({ onLogin, dark }) {
>
InkNote
</div>
<div style={{ fontSize: 12, color: T.muted }}>
{isLogin ? "Sign in to sync your notes" : "Create your account"}
</div>
<div style={{ fontSize: 12, color: T.muted }}>Sign in to sync your notes</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}
@@ -125,44 +124,19 @@ export function AuthScreen({ onLogin, dark }) {
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();
}}
style={field}
onKeyDown={onEnter}
/>
<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();
}}
autoComplete="current-password"
style={{ ...field, marginBottom: 16 }}
onKeyDown={onEnter}
/>
{err && (
<div
style={{
@@ -177,6 +151,7 @@ export function AuthScreen({ onLogin, dark }) {
{err}
</div>
)}
<button
onClick={submit}
disabled={loading}
@@ -190,32 +165,11 @@ export function AuthScreen({ onLogin, dark }) {
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
marginBottom: 16,
opacity: loading ? 0.6 : 1,
}}
>
{loading ? "..." : isLogin ? "Sign In" : "Create Account"}
{loading ? "..." : "Sign In"}
</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>
);
+17
View File
@@ -453,6 +453,23 @@ export const I = {
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
),
users: (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
),
back: (
<svg
width="16"
+35 -3
View File
@@ -25,7 +25,8 @@ A cross-platform note-taking application with rich text editing, freehand drawin
- **Conflict-Aware Sync** — Vector clock–based conflict detection; notifies user of merge conflicts
- **WebSocket Real-Time Push** — Changes appear on other devices instantly
- **Encrypted at Rest** — AES-256-CBC encryption for all note content on the server
- **JWT Authentication** — Username + password accounts with bcrypt password hashing
- **JWT Authentication** — Username + password accounts with bcrypt password hashing. No public
sign-up: an administrator creates the accounts
- **Vector Drawings** — Strokes are stored as vectors in one canonical page space, so a drawing looks identical on every screen and stays sharp at any pixel density
### Architecture
@@ -127,16 +128,47 @@ The server runs on port `4000`, the web UI on port `3000`.
| Method | Endpoint | Body | Response |
|--------|-----------------------|-------------------------------|---------------------|
| POST | `/api/auth/register` | `{username, password, displayName}` | `{token, user}` |
| POST | `/api/auth/login` | `{username, password}` | `{token, user}` |
| GET | `/api/auth/me` | — | `{id, username, displayName}` |
| GET | `/api/auth/me` | — | `{id, username, displayName, isAdmin}` |
| PUT | `/api/auth/username` | `{newUsername, password}` | updated user |
| PUT | `/api/auth/password` | `{currentPassword, newPassword}` | `{success}` |
**There is no registration endpoint.** This is a private instance: accounts are created by an
administrator, and nothing on the login screen suggests otherwise.
Usernames are 3–32 characters of letters, digits, `.`, `_` or `-`, and match case-insensitively.
Servers created before this carried e-mail logins; on first start they migrate automatically,
deriving each username from the local part of the address (logged to the console).
### Administration
| Method | Endpoint | Body | Response |
|--------|-----------------------------------|-----------------------------|----------|
| GET | `/api/admin/users` | — | user list with note counts |
| POST | `/api/admin/users` | `{username, password, displayName}` | created user |
| PUT | `/api/admin/users/:id/password` | `{newPassword}` | `{success}` |
| DELETE | `/api/admin/users/:id` | — | `{success}` |
All four require an account flagged `is_admin`; the server checks this itself, so hiding the menu
entry is presentation, not access control. An admin may not delete their own account.
**Where the first admin comes from.** `INKNOTE_ADMIN_USER` and `INKNOTE_ADMIN_PASSWORD` in `.env`:
| Database state | What happens at start |
| --- | --- |
| no users at all | the account is created from both values |
| users, but no admin | `INKNOTE_ADMIN_USER` picks which one is promoted; without it, the oldest |
| an admin exists | both are ignored |
So the variables can never reset an existing password or take rights away — on a running
instance the worst they can do is nothing. Set `INKNOTE_ADMIN_USER` *before* upgrading an older
installation if the oldest account is not the one that should hold the keys.
Deleting an account destroys its notes, notebooks, drawings and version history through
`ON DELETE CASCADE`; the files behind attachments are collected by the hourly orphan sweep. The
deleted person's session stops working immediately: tokens are signed rather than stored, so
`authMiddleware` checks on every request that the account still exists.
### Notebooks
| Method | Endpoint | Description |