feat: ctrl+click for new tabs, delete redirect, password change
- Protocol and collection pills now use <a href> instead of <div onclick>, so ctrl+click (or cmd+click) opens in a new tab, and middle-click works - Applied consistently: library, collection detail, user profile - After deleting a protocol or collection, currentRoute is reset so navigation back to the list actually works - Password change: when viewing your own profile, a 'Change Password' button appears. Opens a modal requiring current password + new password + confirmation. New API endpoint POST /api/auth/password validates the current password and updates the hash.
This commit is contained in:
Executable → Regular
+28
@@ -221,3 +221,31 @@ function handle_me(): void {
|
|||||||
function handle_sso_callback(): void {
|
function handle_sso_callback(): void {
|
||||||
respond(501, ['error' => 'SSO not configured on this instance. Configure SSO in api/auth.php']);
|
respond(501, ['error' => 'SSO not configured on this instance. Configure SSO in api/auth.php']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handle_password_change(): void {
|
||||||
|
$user = current_user();
|
||||||
|
if (!$user) {
|
||||||
|
respond(401, ['error' => 'You must be logged in to change your password']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$current_password = $data['current_password'] ?? '';
|
||||||
|
$new_password = $data['new_password'] ?? '';
|
||||||
|
|
||||||
|
if (strlen($new_password) < 8 || strlen($new_password) > 72) {
|
||||||
|
respond(400, ['error' => 'New password must be between 8 and 72 characters']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
$row = db_one("SELECT password_hash FROM users WHERE id = ?", [$user['id']]);
|
||||||
|
if (!$row || !password_verify($current_password, $row['password_hash'] ?? '')) {
|
||||||
|
respond(401, ['error' => 'Current password is incorrect']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
db_update('users', ['password_hash' => password_hash($new_password, PASSWORD_DEFAULT)], 'id = ?', [$user['id']]);
|
||||||
|
respond(200, ['ok' => true]);
|
||||||
|
}
|
||||||
@@ -167,6 +167,7 @@ if ($parts[0] === 'auth') {
|
|||||||
elseif ($action === 'logout' && $method === 'POST') handle_logout();
|
elseif ($action === 'logout' && $method === 'POST') handle_logout();
|
||||||
elseif ($action === 'me' && $method === 'GET') handle_me();
|
elseif ($action === 'me' && $method === 'GET') handle_me();
|
||||||
elseif ($action === 'sso' && $method === 'POST') handle_sso_callback();
|
elseif ($action === 'sso' && $method === 'POST') handle_sso_callback();
|
||||||
|
elseif ($action === 'password' && $method === 'POST') handle_password_change();
|
||||||
else respond(404, ['error' => 'Unknown auth endpoint']);
|
else respond(404, ['error' => 'Unknown auth endpoint']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-15
@@ -300,12 +300,12 @@ function renderProtocolGrid(protocols) {
|
|||||||
grid.innerHTML = protocols.map(function(p) {
|
grid.innerHTML = protocols.map(function(p) {
|
||||||
var sourceLabel = p.source || (p.author ? '@' + p.author : '');
|
var sourceLabel = p.source || (p.author ? '@' + p.author : '');
|
||||||
var tags = (p.tags || []).map(function(t) { return '<span class="tag">' + escapeHtml(t) + '</span>'; }).join('');
|
var tags = (p.tags || []).map(function(t) { return '<span class="tag">' + escapeHtml(t) + '</span>'; }).join('');
|
||||||
return '<div class="protocol-pill" onclick="showDetail(\'' + p.slug + '\')">' +
|
return '<a class="protocol-pill" href="#protocols/' + p.slug + '">' +
|
||||||
'<div class="info"><h3>' + escapeHtml(p.title) + '</h3>' +
|
'<div class="info"><h3>' + escapeHtml(p.title) + '</h3>' +
|
||||||
'<p>' + escapeHtml(p.description || '') + '</p>' +
|
'<p>' + escapeHtml(p.description || '') + '</p>' +
|
||||||
(tags ? '<div class="tag-row">' + tags + '</div>' : '') + '</div>' +
|
(tags ? '<div class="tag-row">' + tags + '</div>' : '') + '</div>' +
|
||||||
'<div class="meta"><div class="steps">' + (p.steps || []).length + ' steps</div>' +
|
'<div class="meta"><div class="steps">' + (p.steps || []).length + ' steps</div>' +
|
||||||
'<div>' + escapeHtml(sourceLabel) + '</div></div></div>';
|
'<div>' + escapeHtml(sourceLabel) + '</div></div></a>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,9 +338,9 @@ function renderDetail(p) {
|
|||||||
'<div class="content"><h4>' + escapeHtml(s.headline) + '</h4><p>' + escapeHtml(s.description || '') + '</p></div></div>';
|
'<div class="content"><h4>' + escapeHtml(s.headline) + '</h4><p>' + escapeHtml(s.description || '') + '</p></div></div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
const forkNotice = p.forked_from ? '<div class="fork-notice">forked from: <a href="#protocol/' + p.forked_from + '" onclick="showDetail(\'' + p.forked_from + '\');return false">' + p.forked_from + '</a></div>' : '';
|
const forkNotice = p.forked_from ? '<div class="fork-notice">forked from: <a href="#protocol/' + p.forked_from + '" onclick="showDetail(\'' + p.forked_from + '\');return false">' + p.forked_from + '</a></div>' : '';
|
||||||
const sourceLine = (p.source && p.source !== '@' + (p.author || '') && p.source !== '') ? 'SOURCE: ' + escapeHtml(p.source) : '';
|
const sourceLine = p.source ? 'SOURCE: ' + escapeHtml(p.source) : '';
|
||||||
var sourceLink = p.source_url ? ' · <a href="' + escapeHtml(p.source_url) + '" target="_blank">view_original</a>' : '';
|
var sourceLink = p.source_url ? ' · <a href="' + escapeHtml(p.source_url) + '" target="_blank">view_original</a>' : '';
|
||||||
var authorLine = p.author ? (sourceLine ? ' · ' : '') + 'contributor: <a href="#users/' + p.author + '" onclick="showUser(\'' + p.author + '\');return false">@' + escapeHtml(p.author) + '</a>' : '';
|
var authorLine = p.author ? ' · authored by <a href="#user/' + p.author + '" onclick="showUser(\'' + p.author + '\');return false">@' + escapeHtml(p.author) + '</a>' : '';
|
||||||
var dateLine = p.created_at ? ' · added ' + formatDate(p.created_at) : '';
|
var dateLine = p.created_at ? ' · added ' + formatDate(p.created_at) : '';
|
||||||
|
|
||||||
return '<div class="detail-header"><div class="tag-row">' + tags + '</div>' +
|
return '<div class="detail-header"><div class="tag-row">' + tags + '</div>' +
|
||||||
@@ -410,6 +410,7 @@ async function deleteProtocol(slug) {
|
|||||||
try {
|
try {
|
||||||
await api('/protocols/' + slug, { method: 'DELETE' });
|
await api('/protocols/' + slug, { method: 'DELETE' });
|
||||||
toast('Protocol deleted');
|
toast('Protocol deleted');
|
||||||
|
currentRoute = '';
|
||||||
navigate('library');
|
navigate('library');
|
||||||
} catch (e) { toast(e.message, true); }
|
} catch (e) { toast(e.message, true); }
|
||||||
}
|
}
|
||||||
@@ -565,9 +566,9 @@ async function loadCollections() {
|
|||||||
}
|
}
|
||||||
grid.innerHTML = collections.map(function(c) {
|
grid.innerHTML = collections.map(function(c) {
|
||||||
var sourceLabel = c.author ? '@' + c.author : (c.source || '');
|
var sourceLabel = c.author ? '@' + c.author : (c.source || '');
|
||||||
return '<div class="protocol-pill collection" onclick="showCollectionDetail(\'' + c.slug + '\')">' +
|
return '<a class="protocol-pill collection" href="#collections/' + c.slug + '">' +
|
||||||
'<div class="info"><h3>' + escapeHtml(c.title) + '</h3><p>' + escapeHtml(c.description || '') + '</p></div>' +
|
'<div class="info"><h3>' + escapeHtml(c.title) + '</h3><p>' + escapeHtml(c.description || '') + '</p></div>' +
|
||||||
'<div class="meta"><div class="steps">' + (c.item_count || 0) + ' protocols</div><div>' + escapeHtml(sourceLabel) + '</div></div></div>';
|
'<div class="meta"><div class="steps">' + (c.item_count || 0) + ' protocols</div><div>' + escapeHtml(sourceLabel) + '</div></div></a>';
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('collectionsGrid').innerHTML = '<div class="empty">' + escapeHtml(e.message) + '</div>';
|
document.getElementById('collectionsGrid').innerHTML = '<div class="empty">' + escapeHtml(e.message) + '</div>';
|
||||||
@@ -729,18 +730,18 @@ async function showCollectionDetail(slug) {
|
|||||||
if (item.type === 'protocol') {
|
if (item.type === 'protocol') {
|
||||||
try {
|
try {
|
||||||
var p = await api('/protocols/' + item.slug);
|
var p = await api('/protocols/' + item.slug);
|
||||||
itemsHtml += '<div class="protocol-pill" onclick="showDetail(\'' + p.slug + '\')">' +
|
itemsHtml += '<a class="protocol-pill" href="#protocols/' + p.slug + '">' +
|
||||||
'<div class="info"><h3>' + escapeHtml(p.title) + '</h3>' +
|
'<div class="info"><h3>' + escapeHtml(p.title) + '</h3>' +
|
||||||
'<p>' + escapeHtml(p.description || '') + '</p>' +
|
'<p>' + escapeHtml(p.description || '') + '</p>' +
|
||||||
(p.tags && p.tags.length ? '<div class="tag-row">' + p.tags.map(function(t) { return '<span class="tag">' + escapeHtml(t) + '</span>'; }).join('') + '</div>' : '') + '</div>' +
|
(p.tags && p.tags.length ? '<div class="tag-row">' + p.tags.map(function(t) { return '<span class="tag">' + escapeHtml(t) + '</span>'; }).join('') + '</div>' : '') + '</div>' +
|
||||||
'<div class="meta"><div class="steps">' + (p.steps || []).length + ' steps</div></div></div>';
|
'<div class="meta"><div class="steps">' + (p.steps || []).length + ' steps</div></div></a>';
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
itemsHtml += '<div class="protocol-pill"><div class="info"><h3>' + escapeHtml(item.slug) + '</h3><p>Protocol not found</p></div></div>';
|
itemsHtml += '<div class="protocol-pill"><div class="info"><h3>' + escapeHtml(item.slug) + '</h3><p>Protocol not found</p></div></div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var sourceLine = (c.source && c.source !== '@' + (c.author || '') && c.source !== '') ? 'SOURCE: ' + escapeHtml(c.source) : '';
|
var sourceLine = c.source ? 'SOURCE: ' + escapeHtml(c.source) : '';
|
||||||
var authorLine = c.author ? (sourceLine ? ' · ' : '') + 'contributor: <a href="#users/' + c.author + '" onclick="showUser(\'' + c.author + '\');return false">@' + escapeHtml(c.author) + '</a>' : '';
|
var authorLine = c.author ? ' · authored by <a href="#user/' + c.author + '" onclick="showUser(\'' + c.author + '\');return false">@' + escapeHtml(c.author) + '</a>' : '';
|
||||||
var dateLine = c.created_at ? ' · added ' + formatDate(c.created_at) : '';
|
var dateLine = c.created_at ? ' · added ' + formatDate(c.created_at) : '';
|
||||||
|
|
||||||
document.getElementById('collectionDetailContent').innerHTML =
|
document.getElementById('collectionDetailContent').innerHTML =
|
||||||
@@ -780,6 +781,7 @@ async function deleteCollection(slug) {
|
|||||||
try {
|
try {
|
||||||
await api('/collections/' + slug, { method: 'DELETE' });
|
await api('/collections/' + slug, { method: 'DELETE' });
|
||||||
toast('Collection deleted');
|
toast('Collection deleted');
|
||||||
|
currentRoute = '';
|
||||||
navigate('collections');
|
navigate('collections');
|
||||||
} catch (e) { toast(e.message, true); }
|
} catch (e) { toast(e.message, true); }
|
||||||
}
|
}
|
||||||
@@ -802,25 +804,54 @@ async function showUser(username) {
|
|||||||
(data.user.bio ? '<div class="bio">' + escapeHtml(data.user.bio) + '</div>' : '') + '</div>' +
|
(data.user.bio ? '<div class="bio">' + escapeHtml(data.user.bio) + '</div>' : '') + '</div>' +
|
||||||
(p.length ? '<div class="detail-section"><h2>Protocols (' + p.length + ')</h2>' +
|
(p.length ? '<div class="detail-section"><h2>Protocols (' + p.length + ')</h2>' +
|
||||||
p.map(function(proto) {
|
p.map(function(proto) {
|
||||||
return '<div class="protocol-pill" onclick="showDetail(\'' + proto.slug + '\')">' +
|
return '<a class="protocol-pill" href="#protocols/' + proto.slug + '">' +
|
||||||
'<div class="info"><h3>' + escapeHtml(proto.title) + '</h3>' +
|
'<div class="info"><h3>' + escapeHtml(proto.title) + '</h3>' +
|
||||||
'<p>' + escapeHtml(proto.description||'') + '</p>' +
|
'<p>' + escapeHtml(proto.description||'') + '</p>' +
|
||||||
(proto.tags && proto.tags.length ? '<div class="tag-row">' + proto.tags.map(function(t) { return '<span class="tag">' + escapeHtml(t) + '</span>'; }).join('') + '</div>' : '') + '</div>' +
|
(proto.tags && proto.tags.length ? '<div class="tag-row">' + proto.tags.map(function(t) { return '<span class="tag">' + escapeHtml(t) + '</span>'; }).join('') + '</div>' : '') + '</div>' +
|
||||||
'<div class="meta"><div class="steps">' + (proto.steps || []).length + ' steps</div></div></div>';
|
'<div class="meta"><div class="steps">' + (proto.steps || []).length + ' steps</div></div></a>';
|
||||||
}).join('') +
|
}).join('') +
|
||||||
'</div>' : '') +
|
'</div>' : '') +
|
||||||
(c.length ? '<div class="detail-section"><h2>Collections (' + c.length + ')</h2>' +
|
(c.length ? '<div class="detail-section"><h2>Collections (' + c.length + ')</h2>' +
|
||||||
c.map(function(col) {
|
c.map(function(col) {
|
||||||
return '<div class="protocol-pill collection" onclick="showCollectionDetail(\'' + col.slug + '\')"><div class="icon">[+]</div>' +
|
return '<a class="protocol-pill collection" href="#collections/' + col.slug + '">' +
|
||||||
'<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 class="meta"><div class="steps">' + (col.item_count || 0) + ' protocols</div></div></a>';
|
||||||
}).join('') +
|
}).join('') +
|
||||||
'</div>' : '');
|
'</div>' : '');
|
||||||
document.getElementById('detailActions').innerHTML = '';
|
// Add password change option if viewing own profile
|
||||||
|
var actionsHtml = '';
|
||||||
|
if (currentUser && currentUser.username === username) {
|
||||||
|
actionsHtml = '<button class="btn" onclick="showChangePassword()">Change Password</button>';
|
||||||
|
}
|
||||||
|
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>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Toast ---
|
// --- Toast ---
|
||||||
let toastTimer = null;
|
let toastTimer = null;
|
||||||
function toast(msg, isError) {
|
function toast(msg, isError) {
|
||||||
|
|||||||
+28
-3
@@ -43,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="sort-bar" id="sortBar">
|
<div class="sort-bar" id="sortBar">
|
||||||
<span class="sort-label">sort:</span>
|
<span class="sort-label">sort:</span>
|
||||||
<span class="sort-option active" onclick="setSort('date')">most recent</span>
|
<span class="sort-option active" onclick="setSort('date')">date added</span>
|
||||||
<span class="sort-option" onclick="setSort('relevance')">relevance</span>
|
<span class="sort-option" onclick="setSort('relevance')">relevance</span>
|
||||||
<span class="filter-toggle" onclick="toggleFilterPanel()">☰ filter</span>
|
<span class="filter-toggle" onclick="toggleFilterPanel()">☰ filter</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h2>What is a protocol?</h2>
|
<h2>What is a protocol?</h2>
|
||||||
<p class="outcome">A protocol is a pattern of interaction among agents, represented here in the form of a particular sequence of actions. Protocols are micro-practices — more specific than patterns and more interaction-focused than rules. A rule says "decisions are made by consensus." A protocol says "here's the step-by-step process for reaching consensus in a meeting."</p>
|
<p class="outcome">A protocol is a pattern of interaction among people, generally in the form of a particular sequence of actions. Protocols are micro-practices — more specific than patterns and more interaction-focused than rules. A rule says "decisions are made by consensus." A protocol says "here's the step-by-step process for reaching consensus in a meeting."</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h2>Features</h2>
|
<h2>Features</h2>
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h2>Self-hosting</h2>
|
<h2>Self-hosting</h2>
|
||||||
<p class="outcome">Protocol Droid is designed to be self-hostable and whitelabel-able. It runs on a standard LAMP stack with no external dependencies — no web fonts, no CDNs, no tracking. Deploy on any LAMP server. Configure your site name, tagline, and theme through a simple config file.</p>
|
<p class="outcome">Protocol Droid is designed to be self-hostable and whitelabel-able. It runs on a standard LAMP stack with no external dependencies — no web fonts, no CDNs, no tracking. Deploy on Cloudron, any LAMP server, or your own infrastructure. Configure your site name, tagline, and theme through a simple config file.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h2>Sister project</h2>
|
<h2>Sister project</h2>
|
||||||
@@ -230,6 +230,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>
|
||||||
|
|||||||
Reference in New Issue
Block a user