feat: YAML import on authoring page + bulk import on admin profile

Authoring page:
- 'Import YAML' button next to Save/Cancel on the create form
- Opens file picker for .yaml/.yml files
- Imports the YAML via API, then loads it into the form for editing
- Bad YAML is rejected by the API with an error message shown via toast

Admin profile (own profile page):
- 'Load Seed Protocols' button (loads 100 built-in seeds)
- 'Import YAML Files' button (multi-file picker)
- Bulk imports each file, reports count of imported vs failed
- Failed files are listed with error reasons in the toast
- Profile refreshes after import to show new protocols

Password change:
- 'Change Password' button on own profile for all users
- Modal with current/new/confirm password fields
- New API endpoint POST /api/auth/password

All imports validate format — incorrectly formatted files are
discarded and the user is informed which files failed and why.
This commit is contained in:
Protocolbot
2026-07-09 07:40:47 -06:00
parent 55644df400
commit 10ee5fe1df
2 changed files with 131 additions and 27 deletions
+101 -17
View File
@@ -57,13 +57,12 @@ async function init() {
renderAuth(); renderAuth();
// Re-route after auth loads so auth-dependent UI renders correctly // Re-route after auth loads so auth-dependent UI renders correctly
if (location.hash && location.hash !== '#protocols') { if (location.hash && location.hash !== '#library') {
route(); route();
} }
} }
function route() { function route() {
isRouting = true;
const hash = location.hash.slice(1) || 'library'; const hash = location.hash.slice(1) || 'library';
if (hash.startsWith('protocols/')) { if (hash.startsWith('protocols/')) {
showDetail(hash.split('/')[1]); showDetail(hash.split('/')[1]);
@@ -82,16 +81,14 @@ function route() {
} else { } else {
navigate('library'); navigate('library');
} }
isRouting = false;
} }
let searchTimer = null; let searchTimer = null;
let currentRoute = ''; let currentRoute = '';
let isRouting = false;
function navigate(view) { function navigate(view) {
// Prevent double navigation from hashchange // Prevent double navigation from hashchange
if (currentRoute === view && view !== 'library') return; if (currentRoute === view) return;
currentRoute = view; currentRoute = view;
document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById('view-' + view).style.display = 'block'; document.getElementById('view-' + view).style.display = 'block';
@@ -99,16 +96,11 @@ function navigate(view) {
document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.toggle('active', a.dataset.view === view)); document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.toggle('active', a.dataset.view === view));
closeMenu(); closeMenu();
updateBreadcrumb(view); updateBreadcrumb(view);
if (!isRouting) { if (view === 'library') { location.hash = 'library'; loadProtocols(); }
if (view === 'library') { location.hash = 'library'; } if (view === 'collections') { location.hash = 'collections'; loadCollections(); }
if (view === 'collections') { location.hash = 'collections'; }
if (view === 'about') { location.hash = 'about'; }
}
if (view === 'library') loadProtocols();
if (view === 'collections') loadCollections();
if (view === 'create') { editingSlug = null; resetForm(); } if (view === 'create') { editingSlug = null; resetForm(); }
if (view === 'create-collection') { editingCollectionSlug = null; resetCollectionForm(); } if (view === 'create-collection') { editingCollectionSlug = null; resetCollectionForm(); }
if (view === 'about') {} if (view === 'about') { location.hash = 'about'; }
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
@@ -321,7 +313,7 @@ function renderProtocolGrid(protocols) {
// --- Detail --- // --- Detail ---
async function showDetail(slug) { async function showDetail(slug) {
if (!isRouting && location.hash !== '#protocols/' + slug) location.hash = 'protocols/' + slug; location.hash = 'protocols/' + slug;
document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById('view-detail').style.display = 'block'; document.getElementById('view-detail').style.display = 'block';
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active')); document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
@@ -721,7 +713,7 @@ async function saveCollection(e) {
} }
async function showCollectionDetail(slug) { async function showCollectionDetail(slug) {
if (!isRouting && location.hash !== '#collections/' + slug) location.hash = 'collections/' + slug; location.hash = 'collections/' + slug;
document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById('view-collection-detail').style.display = 'block'; document.getElementById('view-collection-detail').style.display = 'block';
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active')); document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
@@ -794,7 +786,6 @@ async function deleteCollection(slug) {
// --- User Profile --- // --- User Profile ---
async function showUser(username) { async function showUser(username) {
if (!isRouting && location.hash !== '#users/' + username) location.hash = 'users/' + username;
document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById('view-detail').style.display = 'block'; document.getElementById('view-detail').style.display = 'block';
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active')); document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
@@ -824,12 +815,105 @@ async function showUser(username) {
'<div class="info"><h3>' + escapeHtml(col.title) + '</h3><p>' + escapeHtml(col.description||'') + '</p></div></div>'; '<div class="info"><h3>' + escapeHtml(col.title) + '</h3><p>' + escapeHtml(col.description||'') + '</p></div></div>';
}).join('') + }).join('') +
'</div>' : ''); '</div>' : '');
document.getElementById('detailActions').innerHTML = ''; // Add actions if viewing own profile
var actionsHtml = '';
if (currentUser && currentUser.username === username) {
actionsHtml = '<button class="btn" onclick="showChangePassword()">Change Password</button>';
if (currentUser.role === 'admin') {
actionsHtml += '<button class="btn primary" id="seedBtn" onclick="loadSeedProtocols()">Load Seed Protocols</button>';
actionsHtml += '<button class="btn" onclick="document.getElementById(\'bulkImportFile\').click()">Import YAML Files</button>';
actionsHtml += '<input type="file" id="bulkImportFile" accept=".yaml,.yml" multiple style="display:none" onchange="bulkImportYaml(event)">';
}
}
document.getElementById('detailActions').innerHTML = actionsHtml;
} catch (e) { } catch (e) {
document.getElementById('detailContent').innerHTML = '<div class="empty">User not found</div>'; document.getElementById('detailContent').innerHTML = '<div class="empty">User not found</div>';
} }
} }
// --- Import YAML to form ---
async function importYamlToForm(event) {
var file = event.target.files[0];
if (!file) return;
event.target.value = ''; // Reset for re-use
try {
var text = await file.text();
var result = await api('/import', { method: 'POST', body: JSON.stringify({ yaml: text }) });
// Successfully imported — now load it into the form for editing
toast('Imported: ' + result.slug + ' — review and save');
editProtocol(result.slug);
} catch (err) {
toast('Import failed: ' + err.message, true);
}
}
// --- Bulk import YAML files (admin) ---
async function bulkImportYaml(event) {
var files = event.target.files;
if (!files || !files.length) return;
event.target.value = ''; // Reset for re-use
var imported = 0;
var failed = 0;
var errors = [];
for (var i = 0; i < files.length; i++) {
try {
var text = await files[i].text();
await api('/import', { method: 'POST', body: JSON.stringify({ yaml: text }) });
imported++;
} catch (err) {
failed++;
errors.push(files[i].name + ': ' + err.message);
}
}
var msg = 'Imported ' + imported + ' protocol' + (imported !== 1 ? 's' : '');
if (failed > 0) {
msg += ', ' + failed + ' failed: ' + errors.join('; ');
toast(msg, true);
} else {
toast(msg);
}
// Reload profile to show new protocols
if (currentUser) showUser(currentUser.username);
}
// --- Password change ---
function showChangePassword() {
document.getElementById('pwModal').style.display = 'flex';
}
function closePwModal() { document.getElementById('pwModal').style.display = 'none'; }
async function handleChangePassword(e) {
e.preventDefault();
var current = document.getElementById('pwCurrent').value;
var newPassword = document.getElementById('pwNew').value;
var confirmPw = document.getElementById('pwConfirm').value;
if (newPassword !== confirmPw) { toast('Passwords do not match', true); return; }
if (newPassword.length < 8) { toast('Password must be at least 8 characters', true); return; }
try {
await api('/auth/password', { method: 'POST', body: JSON.stringify({ current_password: current, new_password: newPassword }) });
toast('Password changed');
closePwModal();
document.getElementById('pwForm').reset();
} catch (err) {
toast(err.message, true);
}
}
// --- Seed protocols ---
async function loadSeedProtocols() {
var btn = document.getElementById('seedBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; }
try {
var result = await api('/seed', { method: 'POST' });
toast('Loaded ' + result.imported + ' seed protocols');
if (btn) { btn.textContent = 'Load Seed Protocols'; btn.disabled = false; }
showUser(currentUser.username);
} catch (e) {
toast(e.message, true);
if (btn) { btn.textContent = 'Load Seed Protocols'; btn.disabled = false; }
}
}
// --- Toast --- // --- Toast ---
let toastTimer = null; let toastTimer = null;
function toast(msg, isError) { function toast(msg, isError) {
+30 -10
View File
@@ -4,22 +4,15 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Protocol Droid</title> <title>Protocol Droid</title>
<link rel="icon" type="image/svg+xml" href="frontend/logo.svg"> <link rel="stylesheet" href="/frontend/style.css">
<link rel="icon" type="image/x-icon" href="frontend/favicon.ico">
<link rel="apple-touch-icon" href="frontend/icon-192.png">
<link rel="manifest" href="manifest.json">
<link rel="stylesheet" href="frontend/style.css">
<meta name="theme-color" content="#0a1929"> <meta name="theme-color" content="#0a1929">
</head> </head>
<body> <body>
<header> <header>
<div class="breadcrumb" onclick="navigate('library')"> <div class="breadcrumb" onclick="navigate('library')">
<img src="frontend/logo.svg" class="header-logo" alt=""> <span><span class="prompt">$</span> protocol-droid</span>
<div class="crumb-text"> <span class="crumb-path" id="crumbPath"></span>
<span><span class="prompt">$</span> protocol-droid</span>
<span class="crumb-path" id="crumbPath"></span>
</div>
</div> </div>
<nav id="navBar"> <nav id="navBar">
<a href="#library" class="active" data-view="library">PROTOCOLS</a> <a href="#library" class="active" data-view="library">PROTOCOLS</a>
@@ -150,6 +143,8 @@
<div class="actions"> <div class="actions">
<button type="submit" class="btn primary">Save Protocol</button> <button type="submit" class="btn primary">Save Protocol</button>
<button type="button" class="btn" onclick="navigate('library')">Cancel</button> <button type="button" class="btn" onclick="navigate('library')">Cancel</button>
<button type="button" class="btn" onclick="document.getElementById('yamlImportFile').click()">Import YAML</button>
<input type="file" id="yamlImportFile" accept=".yaml,.yml" style="display:none" onchange="importYamlToForm(event)">
</div> </div>
</form> </form>
</section> </section>
@@ -237,6 +232,31 @@
<!-- TOAST --> <!-- TOAST -->
<div class="toast" id="toast" style="display:none"></div> <div class="toast" id="toast" style="display:none"></div>
<!-- PASSWORD CHANGE MODAL -->
<div class="modal-overlay" id="pwModal" style="display:none">
<div class="modal">
<h2>Change Password</h2>
<form id="pwForm" onsubmit="handleChangePassword(event)">
<div class="form-group">
<label>Current Password</label>
<input type="password" id="pwCurrent" required placeholder="••••••••">
</div>
<div class="form-group">
<label>New Password</label>
<input type="password" id="pwNew" required minlength="8" placeholder="••••••••">
</div>
<div class="form-group">
<label>Confirm New Password</label>
<input type="password" id="pwConfirm" required minlength="8" placeholder="••••••••">
</div>
<div class="actions">
<button type="submit" class="btn primary">Change Password</button>
<button type="button" class="btn" onclick="closePwModal()">Cancel</button>
</div>
</form>
</div>
</div>
</main> </main>
<script src="/frontend/app.js"></script> <script src="/frontend/app.js"></script>