From 68e184163e2dcef9f2b7e11b664e1b4c14bf85d1 Mon Sep 17 00:00:00 2001 From: marc Date: Sat, 8 Aug 2026 12:53:08 +0200 Subject: [PATCH] Remove public sign-up; accounts are created by an administrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/.env.example | 34 +++ backend/server.js | 202 +++++++++++-- docker-compose.dev.yml | 3 + docker-compose.yml | 3 + frontend/src/App.jsx | 9 + frontend/src/syncEngine.ts | 31 +- frontend/src/types.ts | 10 + frontend/src/ui/AccountMenu.jsx | 25 +- frontend/src/ui/AdminPanel.jsx | 486 ++++++++++++++++++++++++++++++++ frontend/src/ui/AuthScreen.jsx | 124 +++----- frontend/src/ui/icons.jsx | 17 ++ readme.md | 38 ++- 12 files changed, 859 insertions(+), 123 deletions(-) create mode 100644 frontend/src/ui/AdminPanel.jsx diff --git a/backend/.env.example b/backend/.env.example index e69de29..2899a42 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/server.js b/backend/server.js index f93fc0e..f84eb68 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e93c65f..caa207b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index cd651f8..c2e6b78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9e8d6e1..e65eb23 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 && ( + setShowAdmin(false)} dark={dark} /> + )} {historyFor && ( { return acceptAuth(await api.post("/auth/login", { username, password })); } -export async function register(username: string, password: string, displayName?: string): Promise { - return acceptAuth(await api.post("/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 { + return api.get("/admin/users"); +} + +export async function createUser(fields: { + username: string; + password: string; + displayName?: string; +}): Promise { + return api.post("/admin/users", fields); +} + +export async function resetUserPassword(id: string, newPassword: string): Promise { + await api.put(`/admin/users/${id}/password`, { newPassword }); +} + +export async function deleteUser(id: string): Promise { + await api.del(`/admin/users/${id}`); +} + export { DEVICE_ID }; /* ─── Test seam ─── diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cb7bef6..bf839fa 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 ── */ diff --git a/frontend/src/ui/AccountMenu.jsx b/frontend/src/ui/AccountMenu.jsx index f402d45..2327bcd 100644 --- a/frontend/src/ui/AccountMenu.jsx +++ b/frontend/src/ui/AccountMenu.jsx @@ -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 }) { )}
+ {user?.isAdmin && ( + + )} +
+ +
+ {(err || msg) && ( +
+ {err || msg} +
+ )} + + {users === null ? ( +
Loading accounts...
+ ) : ( + users.map((u) => ( +
+
+
+
+ {u.username} + {u.isAdmin && ( + + ADMIN + + )} + {u.isSelf && you} +
+
+ {u.displayName} · {u.noteCount} note{u.noteCount === 1 ? "" : "s"} · since{" "} + {fmtDate(u.createdAt)} +
+
+ + {!u.isSelf && ( + + )} +
+ + {resetFor === u.id && ( +
+ setResetPass(e.target.value)} + placeholder={`New password for ${u.username}`} + autoFocus + autoComplete="new-password" + style={inp} + onKeyDown={(e) => e.key === "Enter" && resetPassword()} + /> +
+ + +
+
+ )} + + {deleteFor?.id === u.id && ( +
+
+ This deletes {u.username}’s {u.noteCount} note + {u.noteCount === 1 ? "" : "s"}, their notebooks, drawings and version history. It cannot + be undone. Type {u.username} to confirm. +
+ 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()} + /> +
+ + +
+
+ )} +
+ )) + )} + + {showCreate ? ( +
+
+ New account +
+ setNewName(e.target.value)} + placeholder="Username" + autoFocus + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + style={inp} + /> + setNewDisplay(e.target.value)} + placeholder="Display name (optional)" + style={inp} + /> + setNewPass(e.target.value)} + placeholder="Initial password" + autoComplete="new-password" + style={inp} + onKeyDown={(e) => e.key === "Enter" && create()} + /> +
+ The password is not stored in readable form and cannot be shown again — hand it over yourself, + and let them change it under Account. +
+
+ + +
+
+ ) : ( + + )} +
+ +
+ Signed in as {currentUser?.username}. There is no public sign-up — every account on this instance is + created here. +
+ + + ); +} diff --git a/frontend/src/ui/AuthScreen.jsx b/frontend/src/ui/AuthScreen.jsx index bc1117d..103f6dc 100644 --- a/frontend/src/ui/AuthScreen.jsx +++ b/frontend/src/ui/AuthScreen.jsx @@ -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 (
InkNote
-
- {isLogin ? "Sign in to sync your notes" : "Create your account"} -
+
Sign in to sync your notes
- {!isLogin && ( - 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", - }} - /> - )} + { - if (e.key === "Enter") submit(); - }} + style={field} + onKeyDown={onEnter} /> 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 && (
)} + -
- {isLogin ? "Don't have an account? " : "Already have an account? "} - -
); diff --git a/frontend/src/ui/icons.jsx b/frontend/src/ui/icons.jsx index 10ea77b..9744c7f 100644 --- a/frontend/src/ui/icons.jsx +++ b/frontend/src/ui/icons.jsx @@ -453,6 +453,23 @@ export const I = { ), + users: ( + + + + + + + ), back: (