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: (