// ── Viewer token management (#33) ─────────────────────────────────────────────
// Share button → modal to create, copy, and revoke read-only viewer links.
import { S } from './state.js';
async function _getShareBaseUrl() {
// Use the machine's LAN IP so links work for remote users, not just localhost.
try {
const r = await fetch('/api/local_ip');
if (r.ok) {
const d = await r.json();
if (d.ip && d.ip !== '127.0.0.1') {
return 'http://' + d.ip + ':' + window.location.port;
}
}
} catch(e) {}
return window.location.origin;
}
// ── User autocomplete for Share modal ────────────────────────────────────────
// Holds the resolved user when one is picked from the dropdown.
// Cleared on modal reset or when the input is edited manually.
let _selectedScopeUser = null; // { emails: string[], display_name: string }
let _userAcInit = false;
function _initUserAutocomplete() {
if (_userAcInit) return;
_userAcInit = true;
const input = document.getElementById('shareScopeUser');
const drop = document.getElementById('shareScopeUserDropdown');
if (!input || !drop) return;
input.addEventListener('input', () => {
_selectedScopeUser = null; // user edited manually — discard dropdown selection
_renderUserDropdown(input.value);
});
input.addEventListener('focus', () => _renderUserDropdown(input.value));
input.addEventListener('keydown', e => {
if (e.key === 'Escape') { drop.style.display = 'none'; }
if (e.key === 'ArrowDown') { e.preventDefault(); drop.querySelector('[data-uid]')?.focus(); }
});
drop.addEventListener('keydown', e => {
if (e.key === 'Escape') { drop.style.display = 'none'; input.focus(); }
if (e.key === 'ArrowDown') { e.preventDefault(); document.activeElement?.nextElementSibling?.focus(); }
if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = document.activeElement?.previousElementSibling;
prev ? prev.focus() : input.focus();
}
if (e.key === 'Enter') {
const el = document.activeElement;
if (el?.dataset?.uid) _selectUser(parseInt(el.dataset.uid, 10));
}
});
document.addEventListener('click', e => {
if (!document.getElementById('shareScopeUserWrap')?.contains(e.target))
drop.style.display = 'none';
}, true);
}
function _renderUserDropdown(query) {
const drop = document.getElementById('shareScopeUserDropdown');
if (!drop) return;
const users = S._allUsers;
if (!users.length) { drop.style.display = 'none'; return; }
const q = (query || '').trim().toLowerCase();
const matches = (q
? users.filter(u =>
(u.displayName || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q) ||
(u.googleEmail || '').toLowerCase().includes(q))
: users
).slice(0, 8);
if (!matches.length) { drop.style.display = 'none'; return; }
drop.innerHTML = '';
matches.forEach((u, i) => {
const emails = [u.email, u.googleEmail].filter(Boolean);
const emailLbl = emails.join(', ');
const roleLbl = u.userRole === 'staff' ? t('share_scope_staff', 'Staff')
: u.userRole === 'student' ? t('share_scope_student', 'Students')
: '';
const row = document.createElement('div');
row.tabIndex = 0;
row.dataset.uid = i; // index into matches; resolved in _selectUser
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 10px;cursor:pointer;font-size:12px'
+ (i < matches.length - 1 ? ';border-bottom:1px solid var(--border)' : '');
row.innerHTML =
'
' +
'
' +
(u.displayName || emails[0] || '') +
(roleLbl ? ' ' + roleLbl + '' : '') +
'
' +
'
' + emailLbl + '
' +
'
';
row.addEventListener('mouseenter', () => row.style.background = 'var(--surface)');
row.addEventListener('mouseleave', () => row.style.background = '');
row.addEventListener('focus', () => row.style.background = 'var(--surface)');
row.addEventListener('blur', () => row.style.background = '');
row.addEventListener('mousedown', e => {
e.preventDefault();
_selectUser(u);
});
drop.appendChild(row);
});
drop.style.display = '';
}
function _selectUser(u) {
const input = document.getElementById('shareScopeUser');
const drop = document.getElementById('shareScopeUserDropdown');
const emails = [u.email, u.googleEmail].filter(Boolean);
_selectedScopeUser = {
emails: emails,
display_name: u.displayName || emails[0] || '',
};
if (input) input.value = u.displayName || emails[0] || '';
if (drop) drop.style.display = 'none';
}
function _shareScopeTypeChanged() {
const type = document.getElementById('shareScopeType')?.value || '';
document.getElementById('shareScopeRoleWrap').style.display = type === 'role' ? '' : 'none';
document.getElementById('shareScopeUserWrap').style.display = type === 'user' ? '' : 'none';
if (type === 'user') _initUserAutocomplete();
}
function openShareModal() {
document.getElementById('shareBackdrop').classList.add('open');
document.getElementById('shareNewLinkRow').style.display = 'none';
document.getElementById('shareLabel').value = '';
document.getElementById('shareExpiry').value = '30';
const scopeType = document.getElementById('shareScopeType');
if (scopeType) { scopeType.value = ''; _shareScopeTypeChanged(); }
_selectedScopeUser = null;
const scopeUser = document.getElementById('shareScopeUser');
if (scopeUser) scopeUser.value = '';
const scopeDrop = document.getElementById('shareScopeUserDropdown');
if (scopeDrop) scopeDrop.style.display = 'none';
_renderTokenList();
fetch('/api/viewer/pin').then(function(r){ return r.json(); }).then(function(d) {
const el = document.getElementById('sharePinStatus');
if (el) el.textContent = d.pin_set ? t('share_pin_set', 'Set') : t('share_pin_not_set', 'Not set');
}).catch(function(){});
}
function closeShareModal() {
document.getElementById('shareBackdrop').classList.remove('open');
}
async function _renderTokenList() {
const list = document.getElementById('shareTokenList');
list.innerHTML = '' + t('lbl_loading', 'Loading…') + '
';
try {
const r = await fetch('/api/viewer/tokens');
const tokens = await r.json();
if (!tokens.length) {
list.innerHTML = '' + t('share_no_links', 'No active links.') + '
';
return;
}
list.innerHTML = '';
tokens.forEach(tok => {
const expires = tok.expires_at
? new Date(tok.expires_at * 1000).toLocaleDateString(undefined, {day:'numeric', month:'short', year:'numeric'})
: t('share_expires_never', 'Never');
const lastUsed = tok.last_used_at
? new Date(tok.last_used_at * 1000).toLocaleDateString(undefined, {day:'numeric', month:'short'})
: '—';
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:12px';
const roleVal = tok.scope?.role || '';
const roleLbl = roleVal === 'student' ? t('share_scope_student', 'Students')
: roleVal === 'staff' ? t('share_scope_staff', 'Staff')
: '';
const roleBadge = roleLbl
? '' + roleLbl + ''
: '';
const userScope = tok.scope?.user;
const userLbl = tok.scope?.display_name
|| (Array.isArray(userScope) ? userScope.join(', ') : (userScope || ''));
const userBadge = userLbl
? '' + userLbl + ''
: '';
row.innerHTML =
'' +
'
' +
(tok.label || '' + t('share_unlabelled', 'Unlabelled') + '') +
roleBadge + userBadge +
'
' +
'
' +
t('share_expires_prefix', 'Expires:') + ' ' + expires + ' · ' + t('share_last_used', 'Last used:') + ' ' + lastUsed +
'
' +
'
' +
'' +
'';
list.appendChild(row);
});
} catch(e) {
list.innerHTML = '' + t('share_load_error', 'Failed to load links.') + '
';
}
}
async function createShareLink() {
const label = document.getElementById('shareLabel').value.trim();
const expiry = document.getElementById('shareExpiry').value;
const scopeType = document.getElementById('shareScopeType')?.value || '';
const body = {label};
if (expiry) body.expires_days = parseInt(expiry);
if (scopeType === 'role') {
const role = document.getElementById('shareScope')?.value || '';
if (role) body.scope = {role};
} else if (scopeType === 'user') {
if (_selectedScopeUser) {
body.scope = { user: _selectedScopeUser.emails, display_name: _selectedScopeUser.display_name };
} else {
// Manual entry fallback — treat raw input as a single email
const email = (document.getElementById('shareScopeUser')?.value || '').trim().toLowerCase();
if (!email || !email.includes('@')) {
alert(t('share_scope_user_invalid', 'Please enter a valid email address for the user scope.'));
return;
}
body.scope = { user: [email], display_name: email };
}
}
try {
const r = await fetch('/api/viewer/tokens', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(body),
});
if (!r.ok) throw new Error('Server error ' + r.status);
const entry = await r.json();
const url = (await _getShareBaseUrl()) + '/view?token=' + encodeURIComponent(entry.token);
const urlInput = document.getElementById('shareNewLinkUrl');
urlInput.value = url;
document.getElementById('shareNewLinkRow').style.display = 'block';
document.getElementById('shareCopyBtn').textContent = t('log_copy', 'Copy');
document.getElementById('shareLabel').value = '';
_renderTokenList();
} catch(e) {
alert(t('share_create_error', 'Failed to create link:') + ' ' + e.message);
}
}
function copyShareLink() {
const url = document.getElementById('shareNewLinkUrl').value;
_copyText(url, document.getElementById('shareCopyBtn'));
}
async function copyTokenLink(token, btn) {
const url = (await _getShareBaseUrl()) + '/view?token=' + encodeURIComponent(token);
_copyText(url, btn);
}
function _copyText(text, btn) {
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = t('share_copied', 'Copied!');
setTimeout(() => { btn.textContent = orig; }, 1800);
}).catch(() => {
// Fallback for HTTP contexts
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
const orig = btn.textContent;
btn.textContent = t('share_copied', 'Copied!');
setTimeout(() => { btn.textContent = orig; }, 1800);
} catch(_) {}
});
}
async function revokeToken(token, rowEl) {
if (!confirm(t('share_revoke_confirm', 'Revoke this link? Anyone using it will immediately lose access.'))) return;
try {
const r = await fetch('/api/viewer/tokens/' + encodeURIComponent(token), {method: 'DELETE'});
if (!r.ok) throw new Error('Server error ' + r.status);
rowEl.remove();
const list = document.getElementById('shareTokenList');
if (!list.children.length) {
list.innerHTML = '' + t('share_no_links', 'No active links.') + '
';
}
// Hide the copy row if the just-revoked token was the last created
const newRow = document.getElementById('shareNewLinkRow');
if (newRow) {
const shownUrl = document.getElementById('shareNewLinkUrl')?.value || '';
if (shownUrl.includes(token)) newRow.style.display = 'none';
}
} catch(e) {
alert(t('share_revoke_error', 'Failed to revoke:') + ' ' + e.message);
}
}
// ── Viewer PIN — Settings UI ──────────────────────────────────────────────────
async function stLoadViewerPinStatus() {
try {
const r = await fetch('/api/viewer/pin');
const d = await r.json();
const statusEl = document.getElementById('stViewerPinStatus');
const currentRow = document.getElementById('stViewerCurrentPinRow');
const clearBtn = document.getElementById('stViewerPinClearBtn');
if (d.pin_set) {
if (statusEl) statusEl.textContent = '\u2714 ' + t('viewer_pin_is_set', 'Viewer PIN is set');
if (currentRow) currentRow.style.display = '';
if (clearBtn) clearBtn.style.display = '';
} else {
if (statusEl) statusEl.textContent = t('viewer_pin_not_set_msg', 'No PIN set \u2014 /view requires a token link');
if (currentRow) currentRow.style.display = 'none';
if (clearBtn) clearBtn.style.display = 'none';
}
} catch(e) {}
}
async function stSaveViewerPin() {
const newPin = (document.getElementById('stViewerNewPin')?.value || '').trim();
const currentPin = (document.getElementById('stViewerCurrentPin')?.value || '').trim();
const st = document.getElementById('stViewerPinSaveStatus');
if (!newPin) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = t('m365_settings_pin_required', 'PIN is required.'); }
return;
}
if (!/^\d{4,8}$/.test(newPin)) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = t('viewer_pin_format', 'PIN must be 4\u20138 digits.'); }
return;
}
if (st) { st.style.color = 'var(--muted)'; st.textContent = t('viewer_pin_saving', 'Saving\u2026'); }
try {
const r = await fetch('/api/viewer/pin', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pin: newPin, current_pin: currentPin}),
});
const d = await r.json();
if (!r.ok) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = d.error || 'Error.'; }
return;
}
if (st) { st.style.color = 'var(--accent)'; st.textContent = '\u2714 ' + t('viewer_pin_saved', 'PIN saved'); }
if (document.getElementById('stViewerNewPin')) document.getElementById('stViewerNewPin').value = '';
if (document.getElementById('stViewerCurrentPin')) document.getElementById('stViewerCurrentPin').value = '';
stLoadViewerPinStatus();
} catch(e) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = e.message; }
}
}
async function stClearViewerPin() {
const currentPin = (document.getElementById('stViewerCurrentPin')?.value || '').trim();
const st = document.getElementById('stViewerPinSaveStatus');
if (!currentPin) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = t('m365_settings_pin_required', 'PIN is required.'); }
document.getElementById('stViewerCurrentPin')?.focus();
return;
}
if (!confirm(t('viewer_pin_clear_confirm', 'Remove the viewer PIN? /view will require a token link again.'))) return;
try {
const r = await fetch('/api/viewer/pin', {
method: 'DELETE', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({current_pin: currentPin}),
});
const d = await r.json();
if (!r.ok) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = d.error || 'Error.'; }
return;
}
if (st) { st.style.color = 'var(--muted)'; st.textContent = t('viewer_pin_cleared', 'PIN cleared'); }
stLoadViewerPinStatus();
} catch(e) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = e.message; }
}
}
// ── Window exports ────────────────────────────────────────────────────────────
window._shareScopeTypeChanged = _shareScopeTypeChanged;
window.openShareModal = openShareModal;
window.closeShareModal = closeShareModal;
window.createShareLink = createShareLink;
window.copyShareLink = copyShareLink;
window.copyTokenLink = copyTokenLink;
window.revokeToken = revokeToken;
window.stLoadViewerPinStatus = stLoadViewerPinStatus;
window.stSaveViewerPin = stSaveViewerPin;
window.stClearViewerPin = stClearViewerPin;