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:
1 parent
55a6b3caab
commit
68e184163e
12 files changed
+859
-123
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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}’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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user