The generated-link "Copy link:" row stayed visible after creating, looking like the form hadn't reset — but the new link was already in the Active links list with its own Copy button. Drop the redundant preview row; on create, reset the form and briefly highlight the new entry in the active list. Removes the now-dead shareNewLinkRow markup and copyShareLink(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
511 lines
24 KiB
JavaScript
511 lines
24 KiB
JavaScript
// ── Viewer token management (#33) ─────────────────────────────────────────────
|
||
// Share button → modal to create, copy, and revoke read-only viewer links.
|
||
import { S } from './state.js';
|
||
|
||
let _shareBaseUrl = null; // cached so Copy buttons can build the URL synchronously
|
||
|
||
async function _getShareBaseUrl() {
|
||
if (_shareBaseUrl) return _shareBaseUrl;
|
||
// The LAN-IP probe exists only to fix links when the operator browses the
|
||
// app at localhost — those would be unusable for remote users. Any other
|
||
// origin (LAN IP, or a reverse-proxied HTTPS hostname) is already routable,
|
||
// and rewriting it to http://<LAN-IP> would bypass the proxy's TLS.
|
||
const host = window.location.hostname;
|
||
if (window.location.protocol === 'https:' ||
|
||
(host !== 'localhost' && host !== '127.0.0.1' && host !== '[::1]')) {
|
||
_shareBaseUrl = window.location.origin;
|
||
return _shareBaseUrl;
|
||
}
|
||
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') {
|
||
_shareBaseUrl = 'http://' + d.ip + ':' + window.location.port;
|
||
return _shareBaseUrl;
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
_shareBaseUrl = window.location.origin;
|
||
return _shareBaseUrl;
|
||
}
|
||
|
||
// ── 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 =
|
||
'<div style="flex:1;min-width:0">' +
|
||
'<div style="font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
|
||
(u.displayName || emails[0] || '') +
|
||
(roleLbl ? ' <span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--accent);color:#fff;font-weight:600">' + roleLbl + '</span>' : '') +
|
||
'</div>' +
|
||
'<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + emailLbl + '</div>' +
|
||
'</div>';
|
||
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 _resetShareForm() {
|
||
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';
|
||
const vf = document.getElementById('shareValidFrom'); if (vf) vf.value = '';
|
||
const vt = document.getElementById('shareValidTo'); if (vt) vt.value = '';
|
||
}
|
||
|
||
function openShareModal() {
|
||
document.getElementById('shareBackdrop').classList.add('open');
|
||
_resetShareForm();
|
||
_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(highlightToken) {
|
||
const list = document.getElementById('shareTokenList');
|
||
list.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:4px 0">' + t('lbl_loading', 'Loading…') + '</div>';
|
||
try {
|
||
const r = await fetch('/api/viewer/tokens');
|
||
const tokens = await r.json();
|
||
if (!tokens.length) {
|
||
list.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:4px 0">' + t('share_no_links', 'No active links.') + '</div>';
|
||
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
|
||
? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--accent);color:#fff;margin-left:5px;font-weight:600;vertical-align:middle">' + roleLbl + '</span>'
|
||
: '';
|
||
const userScope = tok.scope?.user;
|
||
const userLbl = tok.scope?.display_name
|
||
|| (Array.isArray(userScope) ? userScope.join(', ') : (userScope || ''));
|
||
const userBadge = userLbl
|
||
? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--muted);color:#fff;margin-left:5px;font-weight:600;vertical-align:middle;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block">' + userLbl + '</span>'
|
||
: '';
|
||
const dateFrom = tok.scope?.valid_from || '';
|
||
const dateTo = tok.scope?.valid_to || '';
|
||
const dateBadge = (dateFrom || dateTo)
|
||
? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:rgba(80,160,80,.25);color:var(--text);margin-left:5px;font-weight:600;vertical-align:middle">' +
|
||
(dateFrom || '…') + ' – ' + (dateTo || '…') +
|
||
'</span>'
|
||
: '';
|
||
row.innerHTML =
|
||
'<div style="flex:1;min-width:0">' +
|
||
'<div style="font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
|
||
(tok.label || '<span style="color:var(--muted);font-style:italic">' + t('share_unlabelled', 'Unlabelled') + '</span>') +
|
||
roleBadge + userBadge + dateBadge +
|
||
'</div>' +
|
||
'<div style="font-size:10px;color:var(--muted);margin-top:1px">' +
|
||
t('share_expires_prefix', 'Expires:') + ' ' + expires + ' · ' + t('share_last_used', 'Last used:') + ' ' + lastUsed +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<button title="' + t('share_copy_link_prompt', 'Copy link:') + '" onclick="copyTokenLink(\'' + tok.token + '\',this)" ' +
|
||
'style="height:24px;padding:0 8px;background:none;border:1px solid var(--border);color:var(--muted);border-radius:4px;font-size:11px;cursor:pointer;flex-shrink:0">' + t('log_copy', 'Copy') + '</button>' +
|
||
'<button title="' + t('share_revoke', 'Revoke') + '" onclick="revokeToken(\'' + tok.token + '\',this.closest(\'div[style]\'))" ' +
|
||
'style="height:24px;padding:0 8px;background:none;border:1px solid var(--danger);color:var(--danger);border-radius:4px;font-size:11px;cursor:pointer;flex-shrink:0">' + t('share_revoke', 'Revoke') + '</button>';
|
||
list.appendChild(row);
|
||
// Briefly highlight a freshly created link so it is easy to find and copy.
|
||
if (highlightToken && tok.token === highlightToken) {
|
||
row.style.transition = 'border-color .3s, background .3s';
|
||
row.style.borderColor = 'var(--accent)';
|
||
row.style.background = 'rgba(80,160,80,.18)';
|
||
setTimeout(function() { row.scrollIntoView({block: 'nearest'}); }, 0);
|
||
setTimeout(function() {
|
||
row.style.borderColor = 'var(--border)';
|
||
row.style.background = 'var(--bg)';
|
||
}, 2500);
|
||
}
|
||
});
|
||
} catch(e) {
|
||
list.innerHTML = '<div style="font-size:12px;color:var(--danger);padding:4px 0">' + t('share_load_error', 'Failed to load links.') + '</div>';
|
||
}
|
||
}
|
||
|
||
async function createShareLink() {
|
||
const label = document.getElementById('shareLabel').value.trim();
|
||
const expiry = document.getElementById('shareExpiry').value;
|
||
const scopeType = document.getElementById('shareScopeType')?.value || '';
|
||
const validFrom = document.getElementById('shareValidFrom')?.value || '';
|
||
const validTo = document.getElementById('shareValidTo')?.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 };
|
||
}
|
||
}
|
||
if (validFrom || validTo) {
|
||
if (!body.scope) body.scope = {};
|
||
if (validFrom) body.scope.valid_from = validFrom;
|
||
if (validTo) body.scope.valid_to = validTo;
|
||
}
|
||
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();
|
||
// The new link appears in the active-links list below (each row has its
|
||
// own Copy button) — reset the form and highlight the just-created row
|
||
// rather than leaving a stale link preview in the create box.
|
||
_resetShareForm();
|
||
_renderTokenList(entry.token);
|
||
} catch(e) {
|
||
alert(t('share_create_error', 'Failed to create link:') + ' ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function copyTokenLink(token, btn) {
|
||
const url = (await _getShareBaseUrl()) + '/view?token=' + encodeURIComponent(token);
|
||
_copyText(url, btn);
|
||
}
|
||
|
||
function _copyText(text, btn) {
|
||
const done = () => {
|
||
const orig = btn.textContent;
|
||
btn.textContent = t('share_copied', 'Copied!');
|
||
setTimeout(() => { btn.textContent = orig; }, 1800);
|
||
};
|
||
// Fallback for HTTP contexts, where navigator.clipboard is undefined
|
||
// (the Clipboard API only exists in secure contexts — HTTPS or localhost).
|
||
const fallback = () => {
|
||
let ok = false;
|
||
try {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||
ta.setAttribute('readonly', '');
|
||
document.body.appendChild(ta);
|
||
ta.focus();
|
||
ta.select();
|
||
ok = document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
} catch(_) { ok = false; }
|
||
if (ok) done();
|
||
// Last resort: show the link in a prompt so it can be copied manually.
|
||
else prompt(t('share_copy_link_prompt', 'Copy link:'), text);
|
||
};
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(done).catch(fallback);
|
||
} else {
|
||
fallback();
|
||
}
|
||
}
|
||
|
||
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 = '<div style="font-size:12px;color:var(--muted);padding:4px 0">' + t('share_no_links', 'No active links.') + '</div>';
|
||
}
|
||
} 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; }
|
||
}
|
||
}
|
||
|
||
// ── Interface PIN — Settings UI ───────────────────────────────────────────────
|
||
|
||
async function stLoadInterfacePinStatus() {
|
||
try {
|
||
const r = await fetch('/api/interface/pin');
|
||
const d = await r.json();
|
||
const statusEl = document.getElementById('stInterfacePinStatus');
|
||
const currentRow = document.getElementById('stInterfaceCurrentPinRow');
|
||
const clearBtn = document.getElementById('stInterfacePinClearBtn');
|
||
if (d.pin_set) {
|
||
if (statusEl) statusEl.textContent = '\u2714 ' + t('interface_pin_is_set', 'Interface PIN is set');
|
||
if (currentRow) currentRow.style.display = '';
|
||
if (clearBtn) clearBtn.style.display = '';
|
||
} else {
|
||
if (statusEl) statusEl.textContent = t('interface_pin_not_set_msg', 'No PIN set \u2014 interface is open to anyone on the network');
|
||
if (currentRow) currentRow.style.display = 'none';
|
||
if (clearBtn) clearBtn.style.display = 'none';
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
async function stSaveInterfacePin() {
|
||
const newPin = (document.getElementById('stInterfaceNewPin')?.value || '').trim();
|
||
const currentPin = (document.getElementById('stInterfaceCurrentPin')?.value || '').trim();
|
||
const st = document.getElementById('stInterfacePinSaveStatus');
|
||
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/interface/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('interface_pin_saved', 'PIN saved'); }
|
||
if (document.getElementById('stInterfaceNewPin')) document.getElementById('stInterfaceNewPin').value = '';
|
||
if (document.getElementById('stInterfaceCurrentPin')) document.getElementById('stInterfaceCurrentPin').value = '';
|
||
stLoadInterfacePinStatus();
|
||
} catch(e) {
|
||
if (st) { st.style.color = 'var(--danger)'; st.textContent = e.message; }
|
||
}
|
||
}
|
||
|
||
async function stClearInterfacePin() {
|
||
const currentPin = (document.getElementById('stInterfaceCurrentPin')?.value || '').trim();
|
||
const st = document.getElementById('stInterfacePinSaveStatus');
|
||
if (!currentPin) {
|
||
if (st) { st.style.color = 'var(--danger)'; st.textContent = t('m365_settings_pin_required', 'PIN is required.'); }
|
||
document.getElementById('stInterfaceCurrentPin')?.focus();
|
||
return;
|
||
}
|
||
if (!confirm(t('interface_pin_clear_confirm', 'Remove the interface PIN? The scanner will be accessible to anyone on the network.'))) return;
|
||
try {
|
||
const r = await fetch('/api/interface/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('interface_pin_cleared', 'PIN cleared'); }
|
||
stLoadInterfacePinStatus();
|
||
} 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._copyText = _copyText;
|
||
window.copyTokenLink = copyTokenLink;
|
||
window.revokeToken = revokeToken;
|
||
window.stLoadViewerPinStatus = stLoadViewerPinStatus;
|
||
window.stSaveViewerPin = stSaveViewerPin;
|
||
window.stClearViewerPin = stClearViewerPin;
|
||
window.stLoadInterfacePinStatus = stLoadInterfacePinStatus;
|
||
window.stSaveInterfacePin = stSaveInterfacePin;
|
||
window.stClearInterfacePin = stClearInterfacePin;
|