fix: a failed recording keeps its tab and the retry adds the words after the last word; Settings drops the session list; FAQ says what sign out does
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 50s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 5s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 17:56:32 +02:00
parent cba2a312e1
commit 9c86c56968
7 changed files with 26 additions and 89 deletions

View file

@ -211,17 +211,10 @@
</div>
</div>
<div class="faq-item">
<button class="faq-question">How do I manage my active sessions?</button>
<div class="faq-answer">
<p>Go to <strong>Settings &gt; Active Sessions</strong> to see all devices where you are logged in. You can revoke any session individually or click <strong>Revoke All Other Sessions</strong> to log out every other device. Your current session is highlighted and cannot be revoked from this screen &mdash; use the Logout button instead.</p>
</div>
</div>
<div class="faq-item">
<button class="faq-question">How do I sign out everywhere?</button>
<div class="faq-answer">
<p><strong>Settings &gt; Active Sessions &gt; Revoke All Other Sessions</strong> signs out every other device from this app. Your PedsHub sign-in itself can be ended from the PedsHub sign-in page's account menu, which signs you out of every PedsHub app at once.</p>
<p>Press <strong>Sign out</strong>. It ends your session in this app and your PedsHub sign-in itself, so the quiz app is signed out too and the next person at the computer cannot get back in with one click. Signing in again is one press of <strong>Sign in with PedsHub</strong>.</p>
</div>
</div>
</div>

View file

@ -106,18 +106,6 @@
</div>
</div>
<!-- Active Sessions — hidden for SSO-only users (revoke can't stick against an active IdP session) -->
<div class="settings-section card" id="sessions-section" style="display:none;">
<h3><i class="fas fa-desktop"></i> Active Sessions</h3>
<p style="font-size:13px;color:var(--g600);">Devices where you are currently logged in. Revoke any session to immediately log that device out.</p>
<div style="margin-bottom:10px;">
<button id="btn-revoke-all-sessions" class="btn-sm btn-ghost" style="color:var(--red);font-size:12px;"><i class="fas fa-ban"></i> Revoke All Other Sessions</button>
</div>
<div id="sessions-list" style="display:flex;flex-direction:column;gap:6px;">
<p style="color:var(--g400);font-size:13px;">Loading sessions...</p>
</div>
</div>
<!-- Nextcloud -->
<div class="settings-section card" data-feature="nextcloud">
<h3><i class="fas fa-cloud"></i> Nextcloud Integration</h3>

View file

@ -567,7 +567,6 @@ document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'settings') {
if (typeof load2FAStatus === 'function') load2FAStatus();
if (typeof loadSessions === 'function') loadSessions();
if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus();
if (typeof loadMemories === 'function') loadMemories();
if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList();
@ -1288,7 +1287,7 @@ function _serverTranscribe(blob, module) {
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success && blob.size > 0 && typeof saveAudioBackup === 'function') {
console.log('[AudioBackup] Transcription failed, saving backup...');
saveAudioBackup(blob, 'failed-transcription').then(function(id) {
saveAudioBackup(blob, moduleName || 'failed-transcription').then(function(id) {
console.log('[AudioBackup] Saved with id:', id);
showToast('Audio backed up for retry', 'info');
}).catch(function(e) { console.error('[AudioBackup] Save failed:', e); });
@ -1297,7 +1296,7 @@ function _serverTranscribe(blob, module) {
}).catch(function(err) {
if (blob.size > 0 && typeof saveAudioBackup === 'function') {
console.log('[AudioBackup] Transcription error, saving backup...');
saveAudioBackup(blob, 'failed-transcription').then(function(id) {
saveAudioBackup(blob, moduleName || 'failed-transcription').then(function(id) {
console.log('[AudioBackup] Saved with id:', id);
showToast('Audio backed up for retry', 'info');
}).catch(function(e) { console.error('[AudioBackup] Save failed:', e); });

View file

@ -275,10 +275,17 @@ function guardTransaction(tx, owner) {
}
return setTimeout(place, 50);
}
var existing = (box.textContent || '').trim();
box.textContent = existing ? existing + '\n\n' + text : text;
// After the last word, as dictation would have continued: one space,
// no blank lines, and the caret left at the end of what was added.
var existing = (box.textContent || '').replace(/\s+$/, '');
box.textContent = existing ? existing + ' ' + text.trim() : text.trim();
box.scrollIntoView({ block: 'center' });
try { box.focus(); } catch (e) {}
try {
box.focus();
var range = document.createRange(); range.selectNodeContents(box); range.collapse(false);
var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
} catch (e) {}
try { box.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {}
showToast('Transcript added to ' + entry.label, 'success');
}());
return true;
@ -436,7 +443,7 @@ function guardTransaction(tx, owner) {
retryIcon.className = 'fas fa-rotate-right';
retryBtn.appendChild(retryIcon);
var retryTarget = window.RecordingModules && window.RecordingModules.lookup(b.module);
retryBtn.appendChild(document.createTextNode(retryTarget ? ' Retry into ' + retryTarget.label : ' Retry'));
retryBtn.appendChild(document.createTextNode(retryTarget ? ' Transcribe into ' + retryTarget.label : ' Transcribe'));
retryBtn.title = retryTarget
? 'Transcribe again and add the text to ' + retryTarget.label
: 'Transcribe again and copy the text to the clipboard';

View file

@ -1072,63 +1072,6 @@ document.addEventListener('DOMContentLoaded', function() {
}
function escH(str) { var d = document.createElement('div'); d.textContent = str || ''; return d.innerHTML; }
window.loadSessions = function() {
var list = document.getElementById('sessions-list');
if (!list) return;
fetch('/api/sessions', { headers: getAuthHeaders(), credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success || !data.sessions || data.sessions.length === 0) {
list.innerHTML = '<p style="color:var(--g400);font-size:13px;">No active sessions found.</p>';
return;
}
var currentSid = data.currentSessionId || (window.SecureStorage ? window.SecureStorage.getSync(SESSION_KEY) : localStorage.getItem(SESSION_KEY));
list.innerHTML = data.sessions.map(function(s) {
var isCurrent = s.id === currentSid;
var created = new Date(s.created_at).toLocaleDateString();
var lastAct = timeAgo(new Date(s.last_activity));
return '<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 12px;background:var(--g50);border-radius:8px;border:1.5px solid ' + (isCurrent ? 'var(--blue)' : 'var(--g200)') + ';">'
+ '<div>'
+ '<div style="font-size:13px;font-weight:600;color:var(--g800);">' + escH(s.device_label || 'Unknown device') + (isCurrent ? ' <span style="font-size:11px;color:var(--blue);font-weight:500;">(this device)</span>' : '') + '</div>'
+ '<div style="font-size:12px;color:var(--g500);margin-top:2px;">' + escH(s.ip_address || '') + ' &middot; Created ' + created + ' &middot; Active ' + lastAct + '</div>'
+ '</div>'
+ (isCurrent ? '' : '<button class="btn-sm btn-ghost session-revoke-btn" data-sid="' + s.id + '" style="color:var(--red);font-size:12px;"><i class="fas fa-xmark"></i> Revoke</button>')
+ '</div>';
}).join('');
// Wire revoke buttons
list.querySelectorAll('.session-revoke-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
showConfirm('Revoke this session? That device will be logged out.', function() {
fetch('/api/sessions/' + btn.dataset.sid, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) { showToast('Session revoked', 'info'); loadSessions(); }
else showToast(d.error || 'Failed', 'error');
});
});
});
});
})
.catch(function() {
list.innerHTML = '<p style="color:var(--g400);font-size:13px;">Could not load sessions.</p>';
});
};
// Revoke all other sessions button
document.addEventListener('click', function(e) {
if (e.target.id === 'btn-revoke-all-sessions' || e.target.closest('#btn-revoke-all-sessions')) {
e.preventDefault();
showConfirm('Revoke all other sessions? All other devices will be logged out.', function() {
fetch('/api/sessions', { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) { showToast('All other sessions revoked (' + (d.revoked || 0) + ' removed)', 'info'); loadSessions(); }
});
}, { danger: true, confirmText: 'Revoke All' });
}
});
// ---- CHANGE PASSWORD ----
document.addEventListener('click', function(e) {
if (e.target.id === 'btn-change-password' || e.target.closest('#btn-change-password')) {
@ -1157,7 +1100,6 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.passwordWarning) setTimeout(function() { showToast(data.passwordWarning, 'warning'); }, 1500);
current.value = ''; newPw.value = ''; confirmPw.value = '';
if (status) { status.textContent = ''; }
if (typeof loadSessions === 'function') loadSessions();
} else {
showToast(data.error || 'Failed', 'error');
if (status) { status.textContent = ''; }
@ -1190,13 +1132,11 @@ document.addEventListener('DOMContentLoaded', function() {
// legit local user's sections due to a transient fetch hiccup.
var pwSection = document.getElementById('change-password-section');
var twofaSection = document.getElementById('2fa-section');
var sessionsSection = document.getElementById('sessions-section');
if (data.user.canLocalAuth === false) {
return; // SSO-only — keep sections hidden
}
if (pwSection) pwSection.style.display = '';
if (twofaSection) twofaSection.style.display = '';
if (sessionsSection) sessionsSection.style.display = '';
var status = document.getElementById('2fa-status');
var setupBtn = document.getElementById('btn-setup-2fa');
var disableBtn = document.getElementById('btn-disable-2fa');

View file

@ -314,3 +314,13 @@ test('sign-out ends the provider session: the server hands back the end-session
assert.match(js, /if \(!leaving\) boundary\.reload\(\)/);
assert.match(read('docs/authentication.md'), /## Signing out signs you out of PedsHub/);
});
test('a failed transcription keeps its tab, the retry puts the words after the last word, and Settings has no session list', () => {
const app = read('public/js/app.js');
assert.equal((app.match(/saveAudioBackup\(blob, moduleName \|\| 'failed-transcription'\)/g) || []).length, 2);
const backup = read('public/js/audioBackup.js');
assert.match(backup, /existing \+ ' ' \+ text\.trim\(\)/);
assert.match(backup, /' Transcribe into ' \+ retryTarget\.label/);
assert.doesNotMatch(read('public/components/settings.html'), /sessions-section|btn-revoke-all-sessions/);
assert.doesNotMatch(read('public/js/auth.js'), /loadSessions/);
});

View file

@ -281,8 +281,8 @@ test('a retried transcript goes back to the tab the audio came from', () => {
assert.match(backup, /if \(!window\.activateTab\(entry\.tab\)\) return false;/, 'it opens that tab');
// A retry usually recovers text ON TOP of a live transcript, so replacing the
// box would lose the words the browser did hear.
assert.match(backup, /box\.textContent = existing \? existing \+ '\\n\\n' \+ text : text;/,
'existing text is appended to, never replaced');
assert.match(backup, /box\.textContent = existing \? existing \+ ' ' \+ text\.trim\(\) : text\.trim\(\);/,
'existing text is appended to after its last word, never replaced');
// The box only exists once the tab's markup has been fetched.
assert.match(backup, /if \(attempts\+\+ > 40\)/, 'it waits for the component rather than guessing a delay');
assert.match(backup, /showToast\('Transcript copied — could not open '/, 'and falls back to the clipboard');