Vojtěch Mahdal
Fix API Key field layout overlap and clear input value after save (security)
22709c9
Raw History Blame Contribute Delete
5.19 kB
/**
* Shared "API Key" handling for pages that call the protected endpoints
* (/records, /records/export, /stats - see auth.py). The key is kept only
* in this browser's localStorage, never sent anywhere except as the
* X-API-Key header on requests to this same origin.
*
* Include this script before any page script that needs authHeaders(),
* and call renderApiKeyField(containerEl) once to show the input.
*/
const API_KEY_STORAGE_KEY = "apiKey";
function getApiKey() {
try {
return localStorage.getItem(API_KEY_STORAGE_KEY) || "";
} catch {
return ""; // private browsing / blocked storage - fail open to "[Redacted/]no key", not crash
}
}
function setApiKey(key) {
try {
if (key) localStorage.setItem(API_KEY_STORAGE_KEY, key);
else localStorage.removeItem(API_KEY_STORAGE_KEY);
} catch {
// ignore - see getApiKey()
}
}
/** Headers object to spread into a fetch() call: {...authHeaders()} */
function authHeaders() {
const key = getApiKey();
return key ? { "X-API-Key": key } : {};
}
/**
* Renders a small "API Key" field into `containerEl`. Used to unlock
* /records, /records/export, /stats and the LLM engines (see
* auth.require_api_key_for_llm) - the key is issued separately, not part of
* the public interface.
*
* Once a key is saved, the text field is hidden and replaced by a short
* "API Key set." status and an "Edit" button - so the key doesn't sit around
* in a visible input - and "Edit" brings the field back (e.g. to fix a key
* that turned out to be wrong, or to clear it).
*/
function renderApiKeyField(containerEl, { onSave } = {}) {
const wrap = document.createElement("div");
wrap.className = "row apiKeyRow";
// Not the shared `.label` class here: it's `display: block; margin-bottom:
// 8px`, meant for a label stacked above a field, and inside this inline
// row it just crowded into the next element instead of sitting beside it.
wrap.style.gap = "10px";
wrap.style.marginTop = "14px";
wrap.style.flexWrap = "wrap";
const label = document.createElement("span");
label.style.fontWeight = "600";
label.style.whiteSpace = "nowrap";
label.textContent = "API Key";
const input = document.createElement("input");
input.type = "password";
input.id = "apiKeyInput";
input.placeholder = "Only needed for Stored Records / export / LLM engines";
input.autocomplete = "off";
input.style.minWidth = "220px";
const saveBtn = document.createElement("button");
saveBtn.type = "button";
saveBtn.className = "btn secondary";
saveBtn.textContent = "Save";
const statusLabel = document.createElement("span");
statusLabel.className = "sub small";
statusLabel.style.margin = "0";
statusLabel.textContent = "API Key set.";
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "btn secondary";
editBtn.textContent = "Edit";
const savedNote = document.createElement("span");
savedNote.className = "sub small";
savedNote.style.marginLeft = "4px";
savedNote.hidden = true;
savedNote.textContent = "Saved.";
function showEditing() {
// Deliberately NOT pre-filled with the current key: once saved, the key
// is write-only from this UI (same principle as a password manager not
// re-displaying a stored password) - "Edit" types a replacement, it does
// not reveal/copy the existing one (the browser's own password-reveal
// icon would otherwise hand it over in one click).
input.value = "";
input.hidden = false;
saveBtn.hidden = false;
statusLabel.hidden = true;
editBtn.hidden = true;
input.focus();
}
function showSaved() {
// Clear the DOM value, not just hide the field - otherwise the key sits
// readable in the input's `value` attribute (Inspect Element, or
// `document.getElementById('apiKeyInput').value` in the console) even
// while visually hidden.
input.value = "";
input.hidden = true;
saveBtn.hidden = true;
statusLabel.hidden = false;
editBtn.hidden = false;
}
function refresh() {
if (getApiKey()) showSaved();
else showEditing();
}
saveBtn.addEventListener("click", () => {
const typed = input.value.trim();
// An empty box on Save is treated as "cancel", not "clear the key" - the
// field no longer shows the existing key (see showEditing()), so an
// empty submit is far more likely to be an accidental click than a
// deliberate sign-out. To actually remove a saved key, type over it with
// something else, or clear it from this browser's storage directly.
if (typed) {
setApiKey(typed);
savedNote.hidden = false;
setTimeout(() => { savedNote.hidden = true; }, 1500);
if (typeof onSave === "function") onSave(getApiKey());
}
refresh();
});
editBtn.addEventListener("click", showEditing);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") saveBtn.click();
});
wrap.appendChild(label);
wrap.appendChild(input);
wrap.appendChild(saveBtn);
wrap.appendChild(statusLabel);
wrap.appendChild(editBtn);
wrap.appendChild(savedNote);
containerEl.appendChild(wrap);
refresh();
}