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

+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"