diff --git a/api/auth.php b/api/auth.php old mode 100755 new mode 100644 index 48b181b..046f280 --- a/api/auth.php +++ b/api/auth.php @@ -220,4 +220,32 @@ function handle_me(): void { function handle_sso_callback(): void { 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]); } \ No newline at end of file diff --git a/api/index.php b/api/index.php index de49fec..db86200 100644 --- a/api/index.php +++ b/api/index.php @@ -167,6 +167,7 @@ if ($parts[0] === 'auth') { elseif ($action === 'logout' && $method === 'POST') handle_logout(); elseif ($action === 'me' && $method === 'GET') handle_me(); elseif ($action === 'sso' && $method === 'POST') handle_sso_callback(); + elseif ($action === 'password' && $method === 'POST') handle_password_change(); else respond(404, ['error' => 'Unknown auth endpoint']); } diff --git a/frontend/app.js b/frontend/app.js index 1799f4a..21955e6 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -300,12 +300,12 @@ function renderProtocolGrid(protocols) { grid.innerHTML = protocols.map(function(p) { var sourceLabel = p.source || (p.author ? '@' + p.author : ''); var tags = (p.tags || []).map(function(t) { return '' + escapeHtml(t) + ''; }).join(''); - return '
' + + return '' + '

' + escapeHtml(p.title) + '

' + '

' + escapeHtml(p.description || '') + '

' + (tags ? '
' + tags + '
' : '') + '
' + '
' + (p.steps || []).length + ' steps
' + - '
' + escapeHtml(sourceLabel) + '
'; + '
' + escapeHtml(sourceLabel) + '
'; }).join(''); } @@ -338,9 +338,9 @@ function renderDetail(p) { '

' + escapeHtml(s.headline) + '

' + escapeHtml(s.description || '') + '

'; }).join(''); const forkNotice = p.forked_from ? '
forked from: ' + p.forked_from + '
' : ''; - 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 ? ' · view_original' : ''; - var authorLine = p.author ? (sourceLine ? ' · ' : '') + 'contributor: @' + escapeHtml(p.author) + '' : ''; + var authorLine = p.author ? ' · authored by @' + escapeHtml(p.author) + '' : ''; var dateLine = p.created_at ? ' · added ' + formatDate(p.created_at) : ''; return '
' + tags + '
' + @@ -410,6 +410,7 @@ async function deleteProtocol(slug) { try { await api('/protocols/' + slug, { method: 'DELETE' }); toast('Protocol deleted'); + currentRoute = ''; navigate('library'); } catch (e) { toast(e.message, true); } } @@ -565,9 +566,9 @@ async function loadCollections() { } grid.innerHTML = collections.map(function(c) { var sourceLabel = c.author ? '@' + c.author : (c.source || ''); - return '
' + + return '' + '

' + escapeHtml(c.title) + '

' + escapeHtml(c.description || '') + '

' + - '
' + (c.item_count || 0) + ' protocols
' + escapeHtml(sourceLabel) + '
'; + '
' + (c.item_count || 0) + ' protocols
' + escapeHtml(sourceLabel) + '
'; }).join(''); } catch (e) { document.getElementById('collectionsGrid').innerHTML = '
' + escapeHtml(e.message) + '
'; @@ -729,18 +730,18 @@ async function showCollectionDetail(slug) { if (item.type === 'protocol') { try { var p = await api('/protocols/' + item.slug); - itemsHtml += '
' + + itemsHtml += '' + '

' + escapeHtml(p.title) + '

' + '

' + escapeHtml(p.description || '') + '

' + (p.tags && p.tags.length ? '
' + p.tags.map(function(t) { return '' + escapeHtml(t) + ''; }).join('') + '
' : '') + '
' + - '
' + (p.steps || []).length + ' steps
'; + '
' + (p.steps || []).length + ' steps
'; } catch (e2) { itemsHtml += '

' + escapeHtml(item.slug) + '

Protocol not found

'; } } } - var sourceLine = (c.source && c.source !== '@' + (c.author || '') && c.source !== '') ? 'SOURCE: ' + escapeHtml(c.source) : ''; - var authorLine = c.author ? (sourceLine ? ' · ' : '') + 'contributor: @' + escapeHtml(c.author) + '' : ''; + var sourceLine = c.source ? 'SOURCE: ' + escapeHtml(c.source) : ''; + var authorLine = c.author ? ' · authored by @' + escapeHtml(c.author) + '' : ''; var dateLine = c.created_at ? ' · added ' + formatDate(c.created_at) : ''; document.getElementById('collectionDetailContent').innerHTML = @@ -780,6 +781,7 @@ async function deleteCollection(slug) { try { await api('/collections/' + slug, { method: 'DELETE' }); toast('Collection deleted'); + currentRoute = ''; navigate('collections'); } catch (e) { toast(e.message, true); } } @@ -802,25 +804,54 @@ async function showUser(username) { (data.user.bio ? '
' + escapeHtml(data.user.bio) + '
' : '') + '
' + (p.length ? '

Protocols (' + p.length + ')

' + p.map(function(proto) { - return '
' + + return '' + '

' + escapeHtml(proto.title) + '

' + '

' + escapeHtml(proto.description||'') + '

' + (proto.tags && proto.tags.length ? '
' + proto.tags.map(function(t) { return '' + escapeHtml(t) + ''; }).join('') + '
' : '') + '
' + - '
' + (proto.steps || []).length + ' steps
'; + '
' + (proto.steps || []).length + ' steps
'; }).join('') + '
' : '') + (c.length ? '

Collections (' + c.length + ')

' + c.map(function(col) { - return '
[+]
' + - '

' + escapeHtml(col.title) + '

' + escapeHtml(col.description||'') + '

'; + return '' + + '

' + escapeHtml(col.title) + '

' + escapeHtml(col.description||'') + '

' + + '
' + (col.item_count || 0) + ' protocols
'; }).join('') + '
' : ''); - document.getElementById('detailActions').innerHTML = ''; + // Add password change option if viewing own profile + var actionsHtml = ''; + if (currentUser && currentUser.username === username) { + actionsHtml = ''; + } + document.getElementById('detailActions').innerHTML = actionsHtml; } catch (e) { document.getElementById('detailContent').innerHTML = '
User not found
'; } } +// --- 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 --- let toastTimer = null; function toast(msg, isError) { diff --git a/frontend/index.html b/frontend/index.html index 73ffd9a..1cc1b08 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -43,7 +43,7 @@
sort: - most recent + date added relevance ☰ filter
@@ -69,7 +69,7 @@

What is a protocol?

-

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."

+

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."

Features

@@ -77,7 +77,7 @@

Self-hosting

-

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.

+

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.

Sister project

@@ -230,6 +230,31 @@ + + +