fix(csp): remove all inline styles, scripts, and event handlers for strict CSP compliance

Replace ~50 inline style="" attributes with CSS classes, move 3 inline
<script> blocks to external JS files, replace all inline event handlers
(onclick, onerror) with addEventListener, and remove createElement('style')
from icons.js. All changes support the strict CSP policy (style-src 'self';
script-src 'self') without weakening it.
This commit is contained in:
Jared Wolff
2026-03-05 16:18:30 -05:00
parent f2d35ca792
commit c08926b817
28 changed files with 397 additions and 212 deletions
+150
View File
@@ -0,0 +1,150 @@
/* CSP-compliant utility classes — replaces inline style="" attributes */
/* ── Empty state icons (large, muted) ── */
.empty-state-icon {
font-size: 48px;
color: #ddd;
margin-bottom: 16px;
}
.empty-state-icon.error {
color: #f44336;
}
.empty-state-icon.spinner {
color: #666;
}
/* ── Dialog header icons (accent color) ── */
.dialog-header-icon {
color: #ff5e3a;
}
/* ── Icon spacing ── */
.icon-mr { margin-right: 5px; }
.icon-ml { margin-left: 4px; font-size: 12px; }
/* ── Success check icon ── */
.check-icon { color: #48bb78; }
/* ── Move dialog ── */
.move-dialog-hint {
margin: 0 0 12px;
color: #718096;
font-size: 14px;
}
.folder-select-container {
max-height: 220px;
overflow-y: auto;
}
/* ── Share dialog sections ── */
.share-section {
margin: 15px 0;
}
/* ── Search spinner ── */
.search-spinner {
margin-right: 8px;
}
/* ── Search empty state text ── */
.search-empty-text {
color: var(--text-secondary, #64748b);
}
/* ── Notification upload current file ── */
.notif-upload-current {
font-size: 11px;
color: #64748b;
margin: 3px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Login auth hint ── */
.auth-hint {
color: var(--text-secondary, #666);
margin-top: 4px;
display: block;
}
/* ── About modal (userMenu.js profile popup) ── */
.about-modal-body { max-width: 380px; }
.about-modal-header { text-align: center; padding: 20px 20px 0; }
.about-modal-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #6366f1);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
margin-bottom: 12px;
}
.about-modal-username { margin: 0; font-size: 18px; color: #1a1a2e; }
.about-modal-email { margin: 4px 0 0; font-size: 13px; color: #64748b; }
.about-modal-role {
display: inline-block;
margin-top: 8px;
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.about-modal-role-admin { background: #dbeafe; color: #1d4ed8; }
.about-modal-role-user { background: #f1f5f9; color: #64748b; }
.about-modal-storage { padding: 16px 20px; }
.about-modal-storage-label {
font-size: 12px;
color: #64748b;
text-transform: uppercase;
letter-spacing: .05em;
margin-bottom: 6px;
}
.about-modal-storage-label i { margin-right: 4px; }
.about-modal-bar-bg {
background: #f1f5f9;
border-radius: 6px;
height: 8px;
overflow: hidden;
margin-bottom: 4px;
}
.about-modal-bar-fill {
height: 100%;
border-radius: 6px;
transition: width .3s;
}
.about-modal-bar-text { font-size: 12px; color: #64748b; text-align: right; }
.about-modal-footer { padding: 0 20px 16px; display: flex; justify-content: center; }
.about-modal-close-btn {
padding: 8px 24px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #334155;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background .15s;
}
/* ── Dark theme overrides ── */
[data-theme="dark"] .empty-state-icon { color: #475569; }
[data-theme="dark"] .empty-state-icon.error { color: #ef4444; }
[data-theme="dark"] .about-modal-username { color: #f1f5f9; }
[data-theme="dark"] .about-modal-email { color: #94a3b8; }
[data-theme="dark"] .about-modal-role-admin { background: #1e3a5f; color: #60a5fa; }
[data-theme="dark"] .about-modal-role-user { background: #334155; color: #94a3b8; }
[data-theme="dark"] .about-modal-storage-label { color: #94a3b8; }
[data-theme="dark"] .about-modal-bar-bg { background: #334155; }
[data-theme="dark"] .about-modal-bar-text { color: #94a3b8; }
[data-theme="dark"] .about-modal-close-btn {
background: #1e293b;
border-color: #334155;
color: #cbd5e1;
}
[data-theme="dark"] .move-dialog-hint { color: #94a3b8; }
[data-theme="dark"] .search-empty-text { color: #94a3b8; }
+19
View File
@@ -0,0 +1,19 @@
/* SVG icon base styles (replaces inline <style> from icons.js) */
.oxi-icon {
display: inline-block;
width: 1em;
height: 1em;
vertical-align: -0.125em;
fill: currentColor;
overflow: hidden;
flex-shrink: 0;
}
.oxi-icon-spin {
animation: oxi-spin 1s linear infinite;
}
@keyframes oxi-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
+2
View File
@@ -24,6 +24,8 @@
@import url("./components/multiSelect.css");
@import url("./components/spinner.css");
@import url("./components/search.css");
@import url("./components/icons.css");
@import url("./components/csp-utilities.css");
/* Theme */
@import url("./themes/dark.css");
+11
View File
@@ -126,6 +126,9 @@
cursor: not-allowed;
}
.auth-subtitle { margin: 20px 0; color: #6b7280; font-size: 14px; }
.auth-action-wrap { margin-top: 20px; }
/* SSO / OIDC button */
.auth-button-oidc {
background: linear-gradient(135deg, #2d3748 0%, #4a5568 100%);
@@ -140,6 +143,14 @@
box-shadow: 0 6px 20px rgba(45, 55, 72, 0.4);
}
.auth-button-sso {
background: #4f46e5;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
.auth-button-sso:hover {
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.4);
}
.auth-button-oidc i {
font-size: 14px;
}
+4 -4
View File
@@ -16,9 +16,9 @@ const ACTIONS_BAR_TEMPLATES = {
<div class="action-buttons">
<div class="upload-dropdown" id="upload-dropdown">
<button class="btn btn-primary" id="upload-btn">
<i class="fas fa-cloud-upload-alt" style="margin-right: 5px;"></i>
<i class="fas fa-cloud-upload-alt icon-mr"></i>
<span data-i18n="actions.upload">Upload</span>
<i class="fas fa-caret-down" style="margin-left: 4px; font-size: 12px;"></i>
<i class="fas fa-caret-down icon-ml"></i>
</button>
<div class="upload-dropdown-menu" id="upload-dropdown-menu">
<button class="upload-dropdown-item" id="upload-files-btn">
@@ -32,7 +32,7 @@ const ACTIONS_BAR_TEMPLATES = {
</div>
</div>
<button class="btn btn-secondary" id="new-folder-btn">
<i class="fas fa-folder-plus" style="margin-right: 5px;"></i>
<i class="fas fa-folder-plus icon-mr"></i>
<span data-i18n="actions.new_folder">New folder</span>
</button>
</div>
@@ -75,7 +75,7 @@ const ACTIONS_BAR_TEMPLATES = {
recent: `
<div class="action-buttons">
<button class="btn btn-secondary" id="clear-recent-btn">
<i class="fas fa-broom" style="margin-right: 5px;"></i>
<i class="fas fa-broom icon-mr"></i>
<span data-i18n="actions.clear_recent">Clear recent</span>
</button>
</div>
+2 -2
View File
@@ -184,7 +184,7 @@ function switchToFavoritesView() {
if (filesGridError) {
filesGridError.innerHTML = `
<div class="empty-state">
<i class="fas fa-exclamation-circle" style="font-size: 48px; color: #f44336; margin-bottom: 16px;"></i>
<i class="fas fa-exclamation-circle empty-state-icon error"></i>
<p>Error loading the favorites module</p>
</div>
`;
@@ -215,7 +215,7 @@ function switchToRecentFilesView() {
if (filesGridError) {
filesGridError.innerHTML = `
<div class="empty-state">
<i class="fas fa-exclamation-circle" style="font-size: 48px; color: #f44336; margin-bottom: 16px;"></i>
<i class="fas fa-exclamation-circle empty-state-icon error"></i>
<p>Error loading the recent files module</p>
</div>
`;
+1 -1
View File
@@ -15,7 +15,7 @@ async function performSearch(query, sortBy) {
if (filesGrid) {
filesGrid.innerHTML = `
<div class="search-results-header">
<h3><i class="fas fa-spinner fa-spin" style="margin-right:8px;"></i> Searching for "${query}"...</h3>
<h3><i class="fas fa-spinner fa-spin search-spinner"></i> Searching for "${query}"...</h3>
</div>
`;
}
+1 -1
View File
@@ -27,7 +27,7 @@ async function loadTrashItems() {
const emptyState = document.createElement('div');
emptyState.className = 'empty-state';
emptyState.innerHTML = `
<i class="fas fa-trash" style="font-size: 48px; color: #ddd; margin-bottom: 16px;"></i>
<i class="fas fa-trash empty-state-icon"></i>
<p>${window.i18n ? window.i18n.t('trash.empty_state') : 'The trash is empty'}</p>
`;
elements.filesGrid.appendChild(emptyState);
+16 -12
View File
@@ -48,10 +48,10 @@ const ui = {
<div class="context-menu-item" id="view-file-option">
<i class="fas fa-eye"></i> <span data-i18n="actions.view">View</span>
</div>
<div class="context-menu-item" id="wopi-edit-file-option" style="display:none">
<div class="context-menu-item hidden" id="wopi-edit-file-option">
<i class="fas fa-file-word"></i> <span>Edit in Office</span>
</div>
<div class="context-menu-item" id="wopi-edit-file-tab-option" style="display:none">
<div class="context-menu-item hidden" id="wopi-edit-file-tab-option">
<i class="fas fa-external-link-alt"></i> <span>Edit in Office (new tab)</span>
</div>
<div class="context-menu-item" id="download-file-option">
@@ -87,7 +87,7 @@ const ui = {
renameDialog.innerHTML = `
<div class="rename-dialog-content">
<div class="rename-dialog-header">
<i class="fas fa-pen" style="color:#ff5e3a"></i>
<i class="fas fa-pen dialog-header-icon"></i>
<span data-i18n="dialogs.rename_folder">Rename</span>
</div>
<div class="rename-dialog-body">
@@ -110,13 +110,13 @@ const ui = {
moveDialog.innerHTML = `
<div class="rename-dialog-content">
<div class="rename-dialog-header">
<i class="fas fa-arrows-alt" style="color:#ff5e3a"></i>
<i class="fas fa-arrows-alt dialog-header-icon"></i>
<span data-i18n="dialogs.move_file">Move</span>
</div>
<div class="rename-dialog-body">
<p style="margin:0 0 12px;color:#718096;font-size:14px" data-i18n="dialogs.select_destination">Select destination folder:</p>
<p class="move-dialog-hint" data-i18n="dialogs.select_destination">Select destination folder:</p>
<div id="move-dialog-breadcrumb" class="move-dialog-breadcrumb"></div>
<div id="folder-select-container" style="max-height:220px;overflow-y:auto;">
<div id="folder-select-container" class="folder-select-container">
</div>
</div>
<div class="rename-dialog-buttons">
@@ -137,14 +137,14 @@ const ui = {
shareDialog.innerHTML = `
<div class="share-dialog-content">
<div class="share-dialog-header">
<i class="fas fa-share-alt" style="color:#ff5e3a"></i>
<i class="fas fa-share-alt dialog-header-icon"></i>
<span data-i18n="dialogs.share_file">Share file</span>
</div>
<div class="shared-item-info">
<strong>Item:</strong> <span id="shared-item-name"></span>
</div>
<div id="existing-shares-section" style="display:none; margin: 15px 0;">
<div id="existing-shares-section" class="share-section hidden">
<h3 data-i18n="dialogs.existing_shares">Existing shared links</h3>
<div id="existing-shares-container"></div>
</div>
@@ -181,7 +181,7 @@ const ui = {
</div>
</div>
<div id="new-share-section" style="display:none; margin: 15px 0;">
<div id="new-share-section" class="share-section hidden">
<h3 data-i18n="dialogs.generated_link">Generated link</h3>
<div class="form-group">
<input type="text" id="generated-share-url" readonly>
@@ -232,7 +232,7 @@ const ui = {
notificationDialog.innerHTML = `
<div class="share-dialog-content">
<div class="share-dialog-header">
<i class="fas fa-envelope" style="color:#ff5e3a"></i>
<i class="fas fa-envelope dialog-header-icon"></i>
<span data-i18n="dialogs.notify">Notify shared link</span>
</div>
@@ -1143,12 +1143,14 @@ const ui = {
<i class="${isFileFav ? 'fas' : 'far'} fa-star"></i>
</button>
<div class="file-icon ${iconSpecialClass}">
${iconSpecialClass === 'image-icon' ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="" onerror="this.style.display='none'">` : ''}
${iconSpecialClass === 'image-icon' ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="">` : ''}
<i class="${iconClass}"></i>
</div>
<div class="file-name">${escapeHtml(file.name)}</div>
<div class="file-info">Modified ${formattedDate.split(' ')[0]}</div>
`;
var thumb = el.querySelector('.file-thumb');
if (thumb) thumb.addEventListener('error', function() { this.style.display = 'none'; });
this._bindStarClick(el);
return el;
},
@@ -1181,7 +1183,7 @@ const ui = {
<div class="list-item-checkbox"><input type="checkbox" class="item-checkbox"></div>
<div class="name-cell">
<div class="file-icon ${iconSpecialClass}">
${iconSpecialClass === 'image-icon' ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="" onerror="this.style.display='none'">` : ''}
${iconSpecialClass === 'image-icon' ? `<img class="file-thumb" src="/api/files/${file.id}/thumbnail/icon" loading="lazy" alt="">` : ''}
<i class="${iconClass}"></i>
</div>
<span>${escapeHtml(file.name)}</span>
@@ -1191,6 +1193,8 @@ const ui = {
<div class="size-cell">${fileSize}</div>
<div class="date-cell">${formattedDate}</div>
`;
var thumb = el.querySelector('.file-thumb');
if (thumb) thumb.addEventListener('error', function() { this.style.display = 'none'; });
return el;
},
+21 -18
View File
@@ -182,32 +182,35 @@ function showUserProfileModal() {
overlay.id = 'profile-modal-overlay';
overlay.className = 'about-modal-overlay';
overlay.innerHTML = `
<div class="about-modal" style="max-width:380px">
<div style="text-align:center;padding:20px 20px 0">
<div style="width:64px;height:64px;border-radius:50%;background:linear-gradient(135deg,#3b82f6,#6366f1);color:#fff;display:inline-flex;align-items:center;justify-content:center;font-size:24px;font-weight:700;margin-bottom:12px">${initials}</div>
<h3 style="margin:0;font-size:18px;color:#1a1a2e">${username}</h3>
<p style="margin:4px 0 0;font-size:13px;color:#64748b">${email}</p>
<span style="display:inline-block;margin-top:8px;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600;${
role === 'admin'
? 'background:#dbeafe;color:#1d4ed8'
: 'background:#f1f5f9;color:#64748b'
}">${role === 'admin' ? '🛡️ Admin' : '👤 ' + t('user_menu.role_user', 'User')}</span>
<div class="about-modal about-modal-body">
<div class="about-modal-header">
<div class="about-modal-avatar">${initials}</div>
<h3 class="about-modal-username">${username}</h3>
<p class="about-modal-email">${email}</p>
<span class="about-modal-role ${role === 'admin' ? 'about-modal-role-admin' : 'about-modal-role-user'}">${role === 'admin' ? '🛡️ Admin' : '👤 ' + t('user_menu.role_user', 'User')}</span>
</div>
<div style="padding:16px 20px">
<div style="font-size:12px;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">
<i class="fas fa-database" style="margin-right:4px"></i>${t('storage.title', 'Storage')}
<div class="about-modal-storage">
<div class="about-modal-storage-label">
<i class="fas fa-database"></i>${t('storage.title', 'Storage')}
</div>
<div style="background:#f1f5f9;border-radius:6px;height:8px;overflow:hidden;margin-bottom:4px">
<div style="height:100%;width:${percentage}%;background:${barColor};border-radius:6px;transition:width .3s"></div>
<div class="about-modal-bar-bg">
<div class="about-modal-bar-fill" id="about-bar-fill"></div>
</div>
<div style="font-size:12px;color:#64748b;text-align:right">${percentage}% · ${window.formatFileSize(usedBytes)} / ${window.formatQuotaSize(quotaBytes)}</div>
<div class="about-modal-bar-text">${percentage}% · ${window.formatFileSize(usedBytes)} / ${window.formatQuotaSize(quotaBytes)}</div>
</div>
<div style="padding:0 20px 16px;display:flex;justify-content:center">
<button id="profile-modal-close" style="padding:8px 24px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;color:#334155;font-size:13px;font-weight:600;cursor:pointer;transition:background .15s">${t('actions.close', 'Close')}</button>
<div class="about-modal-footer">
<button id="profile-modal-close" class="about-modal-close-btn">${t('actions.close', 'Close')}</button>
</div>
</div>
`;
// Set dynamic bar width and color via JS property (CSP-safe)
const barFill = overlay.querySelector('#about-bar-fill');
if (barFill) {
barFill.style.width = percentage + '%';
barFill.style.background = barColor;
}
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('show'));
+1 -27
View File
@@ -101,33 +101,7 @@ const _ICONS = {
"vial": [512, "M342.6 9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L28.1 342.6C10.1 360.6 0 385 0 410.5L0 416c0 53 43 96 96 96l5.5 0c25.5 0 49.9-10.1 67.9-28.1L448 205.3l9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-32-32-96-96-32-32zM205.3 256L352 109.3 402.7 160l-96 96-101.5 0z"],
};
// ── CSS injection ──────────────────────────────────────────────
// Injects minimal CSS for SVG icons (replaces ~80 KB of FA CSS).
(function injectIconCSS() {
if (document.getElementById("oxi-icon-css")) return;
const style = document.createElement("style");
style.id = "oxi-icon-css";
style.textContent = `
.oxi-icon {
display: inline-block;
width: 1em;
height: 1em;
vertical-align: -0.125em;
fill: currentColor;
overflow: hidden;
flex-shrink: 0;
}
.oxi-icon-spin {
animation: oxi-spin 1s linear infinite;
}
@keyframes oxi-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
})();
// Icon CSS is now in /css/components/icons.css (loaded via main.css).
// ── Public API ─────────────────────────────────────────────────
+1 -1
View File
@@ -153,7 +153,7 @@ const notifications = (() => {
<div class="notif-item-icon upload"><i class="fas fa-cloud-upload-alt"></i></div>
<div class="notif-item-body">
<div class="notif-item-title">${uploadingText}</div>
<div class="notif-upload-current" id="${batchId}-current" style="font-size:11px;color:#64748b;margin:3px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></div>
<div class="notif-upload-current" id="${batchId}-current"></div>
<div class="notif-upload-progress">
<div class="notif-upload-bar"><div class="notif-upload-fill" id="${batchId}-fill"></div></div>
<div class="notif-upload-detail">
+10 -10
View File
@@ -31,8 +31,8 @@ const contextMenus = {
window.wopiEditor &&
await window.wopiEditor.canEdit(targetFile.name);
wopiEdit.style.display = show ? '' : 'none';
wopiEditTab.style.display = show ? '' : 'none';
wopiEdit.classList.toggle('hidden', !show);
wopiEditTab.classList.toggle('hidden', !show);
},
syncFavoriteOptionLabels() {
@@ -524,7 +524,7 @@ const contextMenus = {
const titleText = mode === 'file' ?
(window.i18n ? window.i18n.t('dialogs.move_file') : 'Move file') :
(window.i18n ? window.i18n.t('dialogs.move_folder') : 'Move folder');
dialogHeader.innerHTML = `<i class="fas fa-arrows-alt" style="color:#ff5e3a"></i> <span>${titleText}</span>`;
dialogHeader.innerHTML = `<i class="fas fa-arrows-alt dialog-header-icon"></i> <span>${titleText}</span>`;
// Load folders for the starting location
await this.loadMoveDialogFolders(startFolderId);
@@ -639,7 +639,7 @@ const contextMenus = {
const currentFolderOption = document.createElement('div');
currentFolderOption.className = 'folder-select-item folder-select-current';
currentFolderOption.innerHTML = `
<i class="fas fa-check-circle" style="color: #48bb78;"></i>
<i class="fas fa-check-circle check-icon"></i>
<span>${window.i18n ? window.i18n.t('dialogs.select_this_folder') : 'Select this folder'}</span>
`;
currentFolderOption.addEventListener('click', () => {
@@ -718,7 +718,7 @@ const contextMenus = {
const homeOption = document.createElement('div');
homeOption.className = 'folder-select-item folder-select-current';
homeOption.innerHTML = `
<i class="fas fa-check-circle" style="color: #48bb78;"></i>
<i class="fas fa-check-circle check-icon"></i>
<span>${window.i18n ? window.i18n.t('dialogs.move_to_home') : 'Move to Home folder'}</span>
`;
homeOption.addEventListener('click', () => {
@@ -889,7 +889,7 @@ const contextMenus = {
existingSharesContainer.innerHTML = '';
if (existingShares.length > 0) {
document.getElementById('existing-shares-section').style.display = 'block';
document.getElementById('existing-shares-section').classList.remove('hidden');
// Create elements for each existing share
existingShares.forEach(share => {
@@ -965,19 +965,19 @@ const contextMenus = {
await window.fileSharing.removeSharedLink(shareId);
btn.closest('.existing-share-item').remove();
if (existingSharesContainer.children.length === 0) {
document.getElementById('existing-shares-section').style.display = 'none';
document.getElementById('existing-shares-section').classList.add('hidden');
}
}
});
});
});
} else {
document.getElementById('existing-shares-section').style.display = 'none';
document.getElementById('existing-shares-section').classList.add('hidden');
}
// Hide new-share section from previous use
const newShareSection = document.getElementById('new-share-section');
if (newShareSection) newShareSection.style.display = 'none';
if (newShareSection) newShareSection.classList.add('hidden');
// Show dialog
shareDialog.style.display = 'flex';
@@ -1041,7 +1041,7 @@ const contextMenus = {
const shareUrl = document.getElementById('generated-share-url');
if (shareUrl) {
shareUrl.value = shareInfo.url;
document.getElementById('new-share-section').style.display = 'block';
document.getElementById('new-share-section').classList.remove('hidden');
shareUrl.focus();
shareUrl.select();
}
+1 -1
View File
@@ -401,7 +401,7 @@ const multiSelect = {
const n = items.length;
const titleText = this._t('batch.move_title', { count: n })
|| `Move ${n} item${n !== 1 ? 's' : ''}`;
dialogHeader.innerHTML = `<i class="fas fa-arrows-alt" style="color:#ff5e3a"></i> <span>${titleText}</span>`;
dialogHeader.innerHTML = `<i class="fas fa-arrows-alt dialog-header-icon"></i> <span>${titleText}</span>`;
const excludeIds = items.filter(i => i.type === 'folder').map(i => i.id);
await contextMenus.loadAllFolders(excludeIds[0] || null, 'batch');
+2 -2
View File
@@ -175,8 +175,8 @@ const search = {
const emptyState = document.createElement('div');
emptyState.className = 'empty-state';
emptyState.innerHTML = `
<i class="fas fa-search" style="font-size: 48px; color: var(--empty-icon, #ccc); margin-bottom: 16px;"></i>
<p style="color: var(--text-secondary, #64748b);">No results found for this search</p>
<i class="fas fa-search empty-state-icon"></i>
<p class="search-empty-text">No results found for this search</p>
`;
filesGrid.appendChild(emptyState);
return;
+1 -1
View File
@@ -120,7 +120,7 @@ class WopiEditor {
var spinner = document.createElement('div');
spinner.id = 'wopi-loading-spinner';
spinner.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;background:#f5f5f5;z-index:1;';
spinner.innerHTML = '<i class="fas fa-spinner fa-spin" style="font-size:48px;color:#666;"></i>';
spinner.innerHTML = '<i class="fas fa-spinner fa-spin empty-state-icon spinner"></i>';
frameHolder.appendChild(spinner);
var iframe = document.createElement('iframe');
+1 -1
View File
@@ -187,7 +187,7 @@ const favorites = {
const emptyState = document.createElement('div');
emptyState.className = 'empty-state';
emptyState.innerHTML = `
<i class="fas fa-star" style="font-size: 48px; color: #ddd; margin-bottom: 16px;"></i>
<i class="fas fa-star empty-state-icon"></i>
<p>${window.i18n ? window.i18n.t('favorites.empty_state') : 'No favorite items'}</p>
<p>${window.i18n ? window.i18n.t('favorites.empty_hint') : 'To mark as favorite, right-click on any file or folder'}</p>
`;
+1 -1
View File
@@ -106,7 +106,7 @@ const recent = {
const emptyState = document.createElement('div');
emptyState.className = 'empty-state';
emptyState.innerHTML = `
<i class="fas fa-clock" style="font-size: 48px; color: #ddd; margin-bottom: 16px;"></i>
<i class="fas fa-clock empty-state-icon"></i>
<p>${window.i18n ? window.i18n.t('recent.empty_state') : 'No recent files'}</p>
<p>${window.i18n ? window.i18n.t('recent.empty_hint') : 'Files you open will appear here'}</p>
`;
+24 -6
View File
@@ -124,17 +124,35 @@ async function loadUsers() {
'<td><span class="badge badge-' + escapeHtml(u.role) + '">' + (u.role === 'admin' ? '<i class="fas fa-shield-alt badge-admin-icon-small"></i> ' : '') + escapeHtml(u.role) + '</span></td>' +
'<td>' + authBadge + '</td>' +
'<td><span class="badge badge-' + (u.active ? 'active' : 'inactive') + '">' + (u.active ? 'Active' : 'Inactive') + '</span></td>' +
'<td><div class="quota-bar"><div class="progress-bar quota-progress-fixed"><div class="progress-fill ' + quotaColor + '" style="width:' + Math.min(quotaPct, 100) + '%"></div></div><span class="quota-text">' + quotaText + '</span></div></td>' +
'<td><div class="quota-bar"><div class="progress-bar quota-progress-fixed"><div class="progress-fill ' + quotaColor + '" data-width="' + Math.min(quotaPct, 100) + '"></div></div><span class="quota-text">' + quotaText + '</span></div></td>' +
'<td class="user-last-login-cell">' + timeAgo(u.last_login_at) + '</td>' +
'<td><div class="actions-row">' +
'<button class="btn btn-sm btn-secondary" onclick="openQuotaModal(\'' + _escJs(u.id) + '\',\'' + _escJs(u.username) + '\',' + u.storage_quota_bytes + ')" title="Edit quota"><i class="fas fa-box"></i></button>' +
(isOidc ? '' : '<button class="btn btn-sm btn-secondary" onclick="openResetPasswordModal(\'' + _escJs(u.id) + '\',\'' + _escJs(u.username) + '\')" title="Reset password"><i class="fas fa-key"></i></button>') +
'<button class="btn btn-sm btn-secondary" onclick="toggleRole(\'' + _escJs(u.id) + '\',\'' + _escJs(u.role) + '\')" title="Toggle role"' + (isSelf ? ' disabled' : '') + '><i class="fas fa-' + (u.role === 'admin' ? 'user' : 'crown') + '"></i></button>' +
'<button class="btn btn-sm ' + (u.active ? 'btn-danger' : 'btn-success') + '" onclick="toggleActive(\'' + _escJs(u.id) + '\',' + u.active + ')" title="' + (u.active ? 'Deactivate' : 'Activate') + '"' + (isSelf && u.active ? ' disabled' : '') + '><i class="fas fa-' + (u.active ? 'ban' : 'check') + '"></i></button>' +
'<button class="btn btn-sm btn-danger" onclick="deleteUser(\'' + _escJs(u.id) + '\',\'' + _escJs(u.username) + '\')" title="Delete"' + (isSelf ? ' disabled' : '') + '><i class="fas fa-trash-alt"></i></button>' +
'<button class="btn btn-sm btn-secondary admin-action-btn" data-action="quota" data-uid="' + _escJs(u.id) + '" data-uname="' + _escJs(u.username) + '" data-quota="' + u.storage_quota_bytes + '" title="Edit quota"><i class="fas fa-box"></i></button>' +
(isOidc ? '' : '<button class="btn btn-sm btn-secondary admin-action-btn" data-action="reset-pw" data-uid="' + _escJs(u.id) + '" data-uname="' + _escJs(u.username) + '" title="Reset password"><i class="fas fa-key"></i></button>') +
'<button class="btn btn-sm btn-secondary admin-action-btn" data-action="toggle-role" data-uid="' + _escJs(u.id) + '" data-role="' + _escJs(u.role) + '" title="Toggle role"' + (isSelf ? ' disabled' : '') + '><i class="fas fa-' + (u.role === 'admin' ? 'user' : 'crown') + '"></i></button>' +
'<button class="btn btn-sm ' + (u.active ? 'btn-danger' : 'btn-success') + ' admin-action-btn" data-action="toggle-active" data-uid="' + _escJs(u.id) + '" data-active="' + u.active + '" title="' + (u.active ? 'Deactivate' : 'Activate') + '"' + (isSelf && u.active ? ' disabled' : '') + '><i class="fas fa-' + (u.active ? 'ban' : 'check') + '"></i></button>' +
'<button class="btn btn-sm btn-danger admin-action-btn" data-action="delete" data-uid="' + _escJs(u.id) + '" data-uname="' + _escJs(u.username) + '" title="Delete"' + (isSelf ? ' disabled' : '') + '><i class="fas fa-trash-alt"></i></button>' +
'</div></td></tr>';
}).join('');
// Set dynamic progress bar widths (CSP-safe via JS property)
document.querySelectorAll('.progress-fill[data-width]').forEach(function(el) {
el.style.width = el.dataset.width + '%';
el.removeAttribute('data-width');
});
// Wire up admin action buttons (replaces inline onclick handlers)
document.querySelectorAll('.admin-action-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var action = btn.dataset.action;
if (action === 'quota') openQuotaModal(btn.dataset.uid, btn.dataset.uname, Number(btn.dataset.quota));
else if (action === 'reset-pw') openResetPasswordModal(btn.dataset.uid, btn.dataset.uname);
else if (action === 'toggle-role') toggleRole(btn.dataset.uid, btn.dataset.role);
else if (action === 'toggle-active') toggleActive(btn.dataset.uid, btn.dataset.active === 'true');
else if (action === 'delete') deleteUser(btn.dataset.uid, btn.dataset.uname);
});
});
document.getElementById('users-info').textContent = 'Showing ' + (usersPage * PAGE_SIZE + 1) + '-' + Math.min((usersPage + 1) * PAGE_SIZE, totalUsers) + ' of ' + totalUsers;
document.getElementById('prev-btn').disabled = usersPage === 0;
document.getElementById('next-btn').disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers;
+33
View File
@@ -0,0 +1,33 @@
// Read error type from URL query parameter
var params = new URLSearchParams(window.location.search);
var errorType = params.get('type') || 'generic';
var errorTitle = document.getElementById('error-title');
var errorMessage = document.getElementById('error-message');
var errorAction = document.getElementById('error-action');
switch(errorType) {
case 'invalid-credentials':
errorTitle.textContent = 'Login Failed';
errorMessage.textContent = 'Invalid username or password. Please check your credentials and try again.';
errorAction.textContent = 'Try Again';
errorAction.addEventListener('click', function() { history.back(); });
break;
case 'session-expired':
errorTitle.textContent = 'Session Expired';
errorMessage.textContent = 'Your session has expired. Please try again.';
errorAction.textContent = 'Close Window';
errorAction.addEventListener('click', function() { window.close(); });
break;
case 'not-found':
errorTitle.textContent = 'Not Found';
errorMessage.textContent = 'The requested page was not found.';
errorAction.textContent = 'Close Window';
errorAction.addEventListener('click', function() { window.close(); });
break;
default:
errorTitle.textContent = 'Error';
errorMessage.textContent = 'An unexpected error occurred. Please try again.';
errorAction.textContent = 'Close Window';
errorAction.addEventListener('click', function() { window.close(); });
}
+38
View File
@@ -0,0 +1,38 @@
// Extract token from URL path and set form action
var pathParts = window.location.pathname.split('/');
var token = pathParts[pathParts.length - 1];
// Validate token is hex-only to prevent injection
if (!/^[0-9a-fA-F]+$/.test(token)) {
document.body.innerHTML = '<p>Invalid session token.</p>';
throw new Error('Invalid token format');
}
document.getElementById('login-flow-form').action = '/login/v2/flow/' + token;
// Check if OIDC is available and configure SSO button
(async function() {
try {
var resp = await fetch('/api/auth/oidc/providers');
if (!resp.ok) return;
var info = await resp.json();
if (!info.enabled) return;
// Show OIDC section
document.getElementById('oidc-section').classList.remove('hidden');
// Update button text with provider name
var btn = document.getElementById('oidc-button');
btn.textContent = 'Sign in with ' + (info.provider_name || 'SSO');
// If password login is disabled, hide the password form
if (!info.password_login_enabled) {
document.getElementById('login-flow-form').style.display = 'none';
}
// SSO button redirects to the OIDC flow for this NC token
btn.addEventListener('click', function() {
window.location.href = '/login/v2/flow/' + token + '/oidc';
});
} catch(e) {
// OIDC not available — silently keep password-only mode
}
})();
+7
View File
@@ -0,0 +1,7 @@
document.getElementById('close-window-btn').addEventListener('click', function() {
window.close();
});
// Auto-close after 3 seconds
setTimeout(function() {
window.close();
}, 3000);
+26 -17
View File
@@ -62,7 +62,7 @@ async function init() {
document.getElementById('p-storage-text').textContent = formatBytes(used) + ' / ' + (quota > 0 ? formatBytes(quota) : 'Unlimited');
if (user.auth_provider && user.auth_provider !== 'local') {
document.getElementById('password-section').style.display = 'none';
document.getElementById('password-section').classList.add('hidden');
}
loadAppPasswords();
@@ -72,7 +72,7 @@ async function init() {
if (oidcResp.ok) {
const oidcInfo = await oidcResp.json();
if (!oidcInfo.password_login_enabled) {
document.getElementById('password-section').style.display = 'none';
document.getElementById('password-section').classList.add('hidden');
}
}
} catch (oidcErr) {
@@ -157,7 +157,7 @@ function renderPwRow(pw) {
btn.className = 'btn btn-danger-sm';
btn.innerHTML = '<i class="fas fa-trash"></i>';
btn.title = 'Revoke';
btn.onclick = function () { revokeAppPassword(pw.id, pw.label); };
btn.addEventListener('click', function () { revokeAppPassword(pw.id, pw.label); });
actions.appendChild(btn);
tr.append(label, created, lastUsed, actions);
return tr;
@@ -167,7 +167,7 @@ async function loadAppPasswords() {
try {
const resp = await fetch(API + '/auth/app-passwords', { headers: headers() });
if (!resp.ok) {
document.getElementById('app-passwords-section').style.display = 'none';
document.getElementById('app-passwords-section').classList.add('hidden');
return;
}
const passwords = await resp.json();
@@ -180,20 +180,20 @@ async function loadAppPasswords() {
const empty = document.getElementById('app-pw-empty');
tbody.innerHTML = '';
if (userPws.length === 0) {
table.style.display = 'none';
empty.style.display = 'block';
table.classList.add('hidden');
empty.classList.remove('hidden');
} else {
table.style.display = '';
empty.style.display = 'none';
table.classList.remove('hidden');
empty.classList.add('hidden');
for (const pw of userPws) tbody.appendChild(renderPwRow(pw));
}
// Auto-generated (client session) passwords
const autoSection = document.getElementById('app-pw-auto-section');
if (autoPws.length === 0) {
autoSection.style.display = 'none';
autoSection.classList.add('hidden');
} else {
autoSection.style.display = '';
autoSection.classList.remove('hidden');
document.getElementById('app-pw-auto-count').textContent = autoPws.length;
const autoTbody = document.getElementById('app-pw-auto-tbody');
autoTbody.innerHTML = '';
@@ -207,9 +207,9 @@ async function loadAppPasswords() {
function toggleAutoPasswords() {
const body = document.getElementById('app-pw-auto-body');
const chevron = document.getElementById('app-pw-auto-chevron');
const open = body.style.display === 'none';
body.style.display = open ? '' : 'none';
chevron.className = open ? 'fas fa-chevron-down' : 'fas fa-chevron-right';
const isHidden = body.classList.contains('hidden');
body.classList.toggle('hidden', !isHidden);
chevron.className = isHidden ? 'fas fa-chevron-down' : 'fas fa-chevron-right';
}
async function createAppPassword() {
@@ -241,7 +241,7 @@ async function createAppPassword() {
const result = await resp.json();
document.getElementById('app-pw-created-label').textContent = result.label;
document.getElementById('app-pw-created-password').textContent = result.password;
document.getElementById('app-pw-created').style.display = 'block';
document.getElementById('app-pw-created').classList.remove('hidden');
labelInput.value = '';
loadAppPasswords();
} catch (err) {
@@ -255,7 +255,7 @@ async function createAppPassword() {
function copyAppPassword() {
const pw = document.getElementById('app-pw-created-password').textContent;
navigator.clipboard.writeText(pw).then(function () {
const btn = document.querySelector('.btn-copy');
const btn = document.getElementById('app-pw-copy-btn');
btn.innerHTML = '<i class="fas fa-check"></i>';
setTimeout(function () { btn.innerHTML = '<i class="fas fa-copy"></i>'; }, 1500);
});
@@ -269,7 +269,7 @@ async function revokeAppPassword(id, label) {
headers: headers()
});
if (resp.ok || resp.status === 204) {
document.getElementById('app-pw-created').style.display = 'none';
document.getElementById('app-pw-created').classList.add('hidden');
loadAppPasswords();
} else {
const err = await resp.json().catch(() => ({}));
@@ -280,7 +280,16 @@ async function revokeAppPassword(id, label) {
}
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
init();
/* Wire up form handler (replaces inline onsubmit) */
/* Wire up event handlers (replaces inline onclick/onsubmit) */
document.getElementById('password-form').addEventListener('submit', changePassword);
document.getElementById('app-pw-generate').addEventListener('click', createAppPassword);
document.getElementById('app-pw-copy-btn').addEventListener('click', copyAppPassword);
document.getElementById('app-pw-auto-toggle').addEventListener('click', toggleAutoPasswords);
+3 -3
View File
@@ -92,14 +92,14 @@ const sharedView = {
</div>
</div>
<div id="empty-shared-state" class="empty-state" style="display:none;">
<i class="fas fa-share-alt" style="font-size: 48px; color: #ddd; margin-bottom: 16px;"></i>
<div id="empty-shared-state" class="empty-state hidden">
<i class="fas fa-share-alt empty-state-icon"></i>
<p data-i18n="shared_emptyStateTitle">No shared items</p>
<p data-i18n="shared_emptyStateDesc">Items you share will appear here</p>
<button id="go-to-files-btn" class="button primary" data-i18n="shared.goToFiles">Go to Files</button>
</div>
<div class="shared-list-container" style="display:none;">
<div class="shared-list-container hidden">
<table class="shared-table">
<thead>
<tr>
+3 -37
View File
@@ -25,46 +25,12 @@
<span id="error-message">An error occurred. Please try again.</span>
</div>
<div style="margin-top: 20px;">
<div class="auth-action-wrap">
<button type="button" class="auth-button" id="error-action">Try Again</button>
</div>
</div>
</div>
<script>
// Read error type from URL query parameter
const params = new URLSearchParams(window.location.search);
const errorType = params.get('type') || 'generic';
const errorTitle = document.getElementById('error-title');
const errorMessage = document.getElementById('error-message');
const errorAction = document.getElementById('error-action');
switch(errorType) {
case 'invalid-credentials':
errorTitle.textContent = 'Login Failed';
errorMessage.textContent = 'Invalid username or password. Please check your credentials and try again.';
errorAction.textContent = 'Try Again';
errorAction.onclick = () => history.back();
break;
case 'session-expired':
errorTitle.textContent = 'Session Expired';
errorMessage.textContent = 'Your session has expired. Please try again.';
errorAction.textContent = 'Close Window';
errorAction.onclick = () => window.close();
break;
case 'not-found':
errorTitle.textContent = 'Not Found';
errorMessage.textContent = 'The requested page was not found.';
errorAction.textContent = 'Close Window';
errorAction.onclick = () => window.close();
break;
default:
errorTitle.textContent = 'Error';
errorMessage.textContent = 'An unexpected error occurred. Please try again.';
errorAction.textContent = 'Close Window';
errorAction.onclick = () => window.close();
}
</script>
<script src="/js/views/nextcloud/error.js"></script>
</body>
</html>
+5 -49
View File
@@ -20,7 +20,7 @@
</div>
<h2 class="auth-title">Grant Access</h2>
<p style="margin-bottom: 20px; color: #6b7280; font-size: 14px;">
<p class="auth-subtitle">
A Nextcloud client is requesting access to your account.
</p>
@@ -56,59 +56,15 @@
</form>
<!-- OIDC/SSO login — shown only when OIDC is enabled -->
<div id="oidc-section" style="display: none;">
<div style="display: flex; align-items: center; gap: 12px; margin: 16px 0;">
<hr style="flex: 1; border: none; border-top: 1px solid #e5e7eb;">
<span style="color: #9ca3af; font-size: 13px;">or</span>
<hr style="flex: 1; border: none; border-top: 1px solid #e5e7eb;">
</div>
<button type="button" id="oidc-button" class="auth-button" style="background: #4f46e5;">
<div id="oidc-section" class="hidden">
<div class="auth-divider"><span>or</span></div>
<button type="button" id="oidc-button" class="auth-button auth-button-sso">
Sign in with SSO
</button>
</div>
</div>
</div>
<script>
// Extract token from URL path and set form action
const pathParts = window.location.pathname.split('/');
const token = pathParts[pathParts.length - 1];
// Validate token is hex-only to prevent injection
if (!/^[0-9a-fA-F]+$/.test(token)) {
document.body.innerHTML = '<p>Invalid session token.</p>';
throw new Error('Invalid token format');
}
document.getElementById('login-flow-form').action = `/login/v2/flow/${token}`;
// Check if OIDC is available and configure SSO button
(async function() {
try {
const resp = await fetch('/api/auth/oidc/providers');
if (!resp.ok) return;
const info = await resp.json();
if (!info.enabled) return;
// Show OIDC section
const section = document.getElementById('oidc-section');
section.style.display = 'block';
// Update button text with provider name
const btn = document.getElementById('oidc-button');
btn.textContent = `Sign in with ${info.provider_name || 'SSO'}`;
// If password login is disabled, hide the password form
if (!info.password_login_enabled) {
document.getElementById('login-flow-form').style.display = 'none';
}
// SSO button redirects to the OIDC flow for this NC token
btn.addEventListener('click', () => {
window.location.href = `/login/v2/flow/${token}/oidc`;
});
} catch(e) {
// OIDC not available — silently keep password-only mode
}
})();
</script>
<script src="/js/views/nextcloud/login.js"></script>
</body>
</html>
+6 -11
View File
@@ -25,21 +25,16 @@
<span>You have successfully granted access to your account.</span>
</div>
<p style="margin-top: 20px; color: #6b7280; font-size: 14px;">
<p class="auth-subtitle">
You can now close this window and return to your Nextcloud app.
</p>
<div style="margin-top: 20px;">
<button type="button" class="auth-button" onclick="window.close()">Close Window</button>
<div class="auth-action-wrap">
<button type="button" class="auth-button" id="close-window-btn">Close Window</button>
</div>
</div>
</div>
<script>
// Auto-close after 3 seconds
setTimeout(() => {
window.close();
}, 3000);
</script>
<script src="/js/views/nextcloud/success.js"></script>
</body>
</html>
+7 -7
View File
@@ -100,14 +100,14 @@
<div class="app-pw-create">
<input type="text" id="app-pw-label" placeholder="Label (e.g. Thunderbird, macOS)" maxlength="128">
<button class="btn btn-primary" id="app-pw-generate" onclick="createAppPassword()"><i class="fas fa-plus"></i> Generate</button>
<button class="btn btn-primary" id="app-pw-generate"><i class="fas fa-plus"></i> Generate</button>
</div>
<div id="app-pw-created" class="app-pw-created" style="display:none">
<div id="app-pw-created" class="app-pw-created hidden">
<div class="app-pw-created-label">New password for <strong id="app-pw-created-label"></strong>:</div>
<div class="app-pw-created-value">
<code id="app-pw-created-password"></code>
<button class="btn btn-copy" onclick="copyAppPassword()" title="Copy to clipboard"><i class="fas fa-copy"></i></button>
<button class="btn btn-copy" id="app-pw-copy-btn" title="Copy to clipboard"><i class="fas fa-copy"></i></button>
</div>
<small>Copy this password now. You won't be able to see it again.</small>
</div>
@@ -120,15 +120,15 @@
</thead>
<tbody id="app-pw-tbody"></tbody>
</table>
<div id="app-pw-empty" class="app-pw-empty" style="display:none">No app passwords yet.</div>
<div id="app-pw-empty" class="app-pw-empty hidden">No app passwords yet.</div>
<div id="app-pw-auto-section" class="app-pw-auto-section" style="display:none">
<button class="app-pw-auto-toggle" id="app-pw-auto-toggle" onclick="toggleAutoPasswords()">
<div id="app-pw-auto-section" class="app-pw-auto-section hidden">
<button class="app-pw-auto-toggle" id="app-pw-auto-toggle">
<i class="fas fa-chevron-right" id="app-pw-auto-chevron"></i>
<span>Client sessions</span>
<span class="app-pw-auto-count" id="app-pw-auto-count">0</span>
</button>
<div id="app-pw-auto-body" style="display:none">
<div id="app-pw-auto-body" class="hidden">
<p class="app-pw-auto-desc">Auto-generated when you connect a Nextcloud-compatible client.</p>
<table class="app-pw-table" id="app-pw-auto-table">
<thead>