Merge pull request #170 from jaredwolff/fix/csp-inline-styles

This commit is contained in:
Dionisio Pozo
2026-03-05 22:38:51 +01:00
committed by GitHub
31 changed files with 424 additions and 244 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ impl<T> PaginatedResponseDto<T> {
page_size, page_size,
total_items, total_items,
total_pages, total_pages,
has_next: page < total_pages - 1, has_next: total_pages > 0 && page < total_pages - 1,
has_prev: page > 0, has_prev: page > 0,
}; };
+26 -29
View File
@@ -604,30 +604,31 @@ impl AppConfig {
} }
// Auth configuration // Auth configuration
if let Ok(jwt_secret) = env::var("OXICLOUD_JWT_SECRET") { if let Some(jwt_secret) = env::var("OXICLOUD_JWT_SECRET")
if !jwt_secret.is_empty() { .ok()
// SECURITY: Validate JWT secret minimum entropy (RFC 7518 §3.2 .filter(|s| !s.is_empty())
// recommends ≥256 bits for HS256). Panic on dangerously short {
// secrets, warn on sub-optimal ones. // SECURITY: Validate JWT secret minimum entropy (RFC 7518 §3.2
let len = jwt_secret.len(); // recommends ≥256 bits for HS256). Panic on dangerously short
if config.features.enable_auth && len < 16 { // secrets, warn on sub-optimal ones.
panic!( let len = jwt_secret.len();
"FATAL: OXICLOUD_JWT_SECRET is dangerously short ({} bytes). \ if config.features.enable_auth && len < 16 {
Minimum: 32 bytes (256 bits) for HS256. \ panic!(
Generate a secure secret with: openssl rand -hex 32", "FATAL: OXICLOUD_JWT_SECRET is dangerously short ({} bytes). \
len Minimum: 32 bytes (256 bits) for HS256. \
); Generate a secure secret with: openssl rand -hex 32",
} else if config.features.enable_auth && len < 32 { len
tracing::warn!("=========================================================="); );
tracing::warn!( } else if config.features.enable_auth && len < 32 {
"OXICLOUD_JWT_SECRET is only {} bytes — recommended minimum is 32 (256 bits).", tracing::warn!("==========================================================");
len tracing::warn!(
); "OXICLOUD_JWT_SECRET is only {} bytes — recommended minimum is 32 (256 bits).",
tracing::warn!("Generate a stronger secret with: openssl rand -hex 32"); len
tracing::warn!("=========================================================="); );
} tracing::warn!("Generate a stronger secret with: openssl rand -hex 32");
config.auth.jwt_secret = jwt_secret; tracing::warn!("==========================================================");
} }
config.auth.jwt_secret = jwt_secret;
} }
// SECURITY: Auto-persist JWT secret to storage so it survives restarts. // SECURITY: Auto-persist JWT secret to storage so it survives restarts.
@@ -642,10 +643,7 @@ impl AppConfig {
let persisted = persisted.trim().to_string(); let persisted = persisted.trim().to_string();
if persisted.len() >= 32 { if persisted.len() >= 32 {
config.auth.jwt_secret = persisted; config.auth.jwt_secret = persisted;
tracing::info!( tracing::info!("JWT secret loaded from {}", secret_file.display());
"JWT secret loaded from {}",
secret_file.display()
);
} else { } else {
tracing::warn!( tracing::warn!(
"Persisted JWT secret too short ({}B), regenerating", "Persisted JWT secret too short ({}B), regenerating",
@@ -664,8 +662,7 @@ impl AppConfig {
use rand_core::{OsRng, RngCore}; use rand_core::{OsRng, RngCore};
let mut key = [0u8; 32]; let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key); OsRng.fill_bytes(&mut key);
let generated_secret: String = let generated_secret: String = key.iter().map(|b| format!("{:02x}", b)).collect();
key.iter().map(|b| format!("{:02x}", b)).collect();
// Persist to storage volume so it survives container restarts // Persist to storage volume so it survives container restarts
if let Err(e) = std::fs::write(&secret_file, &generated_secret) { if let Err(e) = std::fs::write(&secret_file, &generated_secret) {
-2
View File
@@ -612,7 +612,6 @@ impl AppServiceFactory {
path_resolver: None, path_resolver: None,
webdav_lock_store: webdav_lock_store:
crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(), crate::infrastructure::services::webdav_lock_service::create_webdav_lock_store(),
}; };
// 9b. Wire admin settings service when auth is available // 9b. Wire admin settings service when auth is available
@@ -893,7 +892,6 @@ pub struct AppState {
Option<Arc<crate::infrastructure::services::path_resolver_service::PathResolverService>>, Option<Arc<crate::infrastructure::services::path_resolver_service::PathResolverService>>,
pub webdav_lock_store: pub webdav_lock_store:
Arc<crate::infrastructure::services::webdav_lock_service::WebDavLockStore>, Arc<crate::infrastructure::services::webdav_lock_service::WebDavLockStore>,
} }
// All AppState construction is done via struct literal in build_app_state(). // All AppState construction is done via struct literal in build_app_state().
+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/multiSelect.css");
@import url("./components/spinner.css"); @import url("./components/spinner.css");
@import url("./components/search.css"); @import url("./components/search.css");
@import url("./components/icons.css");
@import url("./components/csp-utilities.css");
/* Theme */ /* Theme */
@import url("./themes/dark.css"); @import url("./themes/dark.css");
+11
View File
@@ -126,6 +126,9 @@
cursor: not-allowed; cursor: not-allowed;
} }
.auth-subtitle { margin: 20px 0; color: #6b7280; font-size: 14px; }
.auth-action-wrap { margin-top: 20px; }
/* SSO / OIDC button */ /* SSO / OIDC button */
.auth-button-oidc { .auth-button-oidc {
background: linear-gradient(135deg, #2d3748 0%, #4a5568 100%); background: linear-gradient(135deg, #2d3748 0%, #4a5568 100%);
@@ -140,6 +143,14 @@
box-shadow: 0 6px 20px rgba(45, 55, 72, 0.4); 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 { .auth-button-oidc i {
font-size: 14px; font-size: 14px;
} }
+4 -4
View File
@@ -16,9 +16,9 @@ const ACTIONS_BAR_TEMPLATES = {
<div class="action-buttons"> <div class="action-buttons">
<div class="upload-dropdown" id="upload-dropdown"> <div class="upload-dropdown" id="upload-dropdown">
<button class="btn btn-primary" id="upload-btn"> <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> <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> </button>
<div class="upload-dropdown-menu" id="upload-dropdown-menu"> <div class="upload-dropdown-menu" id="upload-dropdown-menu">
<button class="upload-dropdown-item" id="upload-files-btn"> <button class="upload-dropdown-item" id="upload-files-btn">
@@ -32,7 +32,7 @@ const ACTIONS_BAR_TEMPLATES = {
</div> </div>
</div> </div>
<button class="btn btn-secondary" id="new-folder-btn"> <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> <span data-i18n="actions.new_folder">New folder</span>
</button> </button>
</div> </div>
@@ -75,7 +75,7 @@ const ACTIONS_BAR_TEMPLATES = {
recent: ` recent: `
<div class="action-buttons"> <div class="action-buttons">
<button class="btn btn-secondary" id="clear-recent-btn"> <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> <span data-i18n="actions.clear_recent">Clear recent</span>
</button> </button>
</div> </div>
+2 -2
View File
@@ -184,7 +184,7 @@ function switchToFavoritesView() {
if (filesGridError) { if (filesGridError) {
filesGridError.innerHTML = ` filesGridError.innerHTML = `
<div class="empty-state"> <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> <p>Error loading the favorites module</p>
</div> </div>
`; `;
@@ -215,7 +215,7 @@ function switchToRecentFilesView() {
if (filesGridError) { if (filesGridError) {
filesGridError.innerHTML = ` filesGridError.innerHTML = `
<div class="empty-state"> <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> <p>Error loading the recent files module</p>
</div> </div>
`; `;
+1 -1
View File
@@ -15,7 +15,7 @@ async function performSearch(query, sortBy) {
if (filesGrid) { if (filesGrid) {
filesGrid.innerHTML = ` filesGrid.innerHTML = `
<div class="search-results-header"> <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> </div>
`; `;
} }
+1 -1
View File
@@ -27,7 +27,7 @@ async function loadTrashItems() {
const emptyState = document.createElement('div'); const emptyState = document.createElement('div');
emptyState.className = 'empty-state'; emptyState.className = 'empty-state';
emptyState.innerHTML = ` 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> <p>${window.i18n ? window.i18n.t('trash.empty_state') : 'The trash is empty'}</p>
`; `;
elements.filesGrid.appendChild(emptyState); elements.filesGrid.appendChild(emptyState);
+16 -12
View File
@@ -48,10 +48,10 @@ const ui = {
<div class="context-menu-item" id="view-file-option"> <div class="context-menu-item" id="view-file-option">
<i class="fas fa-eye"></i> <span data-i18n="actions.view">View</span> <i class="fas fa-eye"></i> <span data-i18n="actions.view">View</span>
</div> </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> <i class="fas fa-file-word"></i> <span>Edit in Office</span>
</div> </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> <i class="fas fa-external-link-alt"></i> <span>Edit in Office (new tab)</span>
</div> </div>
<div class="context-menu-item" id="download-file-option"> <div class="context-menu-item" id="download-file-option">
@@ -87,7 +87,7 @@ const ui = {
renameDialog.innerHTML = ` renameDialog.innerHTML = `
<div class="rename-dialog-content"> <div class="rename-dialog-content">
<div class="rename-dialog-header"> <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> <span data-i18n="dialogs.rename_folder">Rename</span>
</div> </div>
<div class="rename-dialog-body"> <div class="rename-dialog-body">
@@ -110,13 +110,13 @@ const ui = {
moveDialog.innerHTML = ` moveDialog.innerHTML = `
<div class="rename-dialog-content"> <div class="rename-dialog-content">
<div class="rename-dialog-header"> <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> <span data-i18n="dialogs.move_file">Move</span>
</div> </div>
<div class="rename-dialog-body"> <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="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> </div>
<div class="rename-dialog-buttons"> <div class="rename-dialog-buttons">
@@ -137,14 +137,14 @@ const ui = {
shareDialog.innerHTML = ` shareDialog.innerHTML = `
<div class="share-dialog-content"> <div class="share-dialog-content">
<div class="share-dialog-header"> <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> <span data-i18n="dialogs.share_file">Share file</span>
</div> </div>
<div class="shared-item-info"> <div class="shared-item-info">
<strong>Item:</strong> <span id="shared-item-name"></span> <strong>Item:</strong> <span id="shared-item-name"></span>
</div> </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> <h3 data-i18n="dialogs.existing_shares">Existing shared links</h3>
<div id="existing-shares-container"></div> <div id="existing-shares-container"></div>
</div> </div>
@@ -181,7 +181,7 @@ const ui = {
</div> </div>
</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> <h3 data-i18n="dialogs.generated_link">Generated link</h3>
<div class="form-group"> <div class="form-group">
<input type="text" id="generated-share-url" readonly> <input type="text" id="generated-share-url" readonly>
@@ -232,7 +232,7 @@ const ui = {
notificationDialog.innerHTML = ` notificationDialog.innerHTML = `
<div class="share-dialog-content"> <div class="share-dialog-content">
<div class="share-dialog-header"> <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> <span data-i18n="dialogs.notify">Notify shared link</span>
</div> </div>
@@ -1143,12 +1143,14 @@ const ui = {
<i class="${isFileFav ? 'fas' : 'far'} fa-star"></i> <i class="${isFileFav ? 'fas' : 'far'} fa-star"></i>
</button> </button>
<div class="file-icon ${iconSpecialClass}"> <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> <i class="${iconClass}"></i>
</div> </div>
<div class="file-name">${escapeHtml(file.name)}</div> <div class="file-name">${escapeHtml(file.name)}</div>
<div class="file-info">Modified ${formattedDate.split(' ')[0]}</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); this._bindStarClick(el);
return el; return el;
}, },
@@ -1181,7 +1183,7 @@ const ui = {
<div class="list-item-checkbox"><input type="checkbox" class="item-checkbox"></div> <div class="list-item-checkbox"><input type="checkbox" class="item-checkbox"></div>
<div class="name-cell"> <div class="name-cell">
<div class="file-icon ${iconSpecialClass}"> <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> <i class="${iconClass}"></i>
</div> </div>
<span>${escapeHtml(file.name)}</span> <span>${escapeHtml(file.name)}</span>
@@ -1191,6 +1193,8 @@ const ui = {
<div class="size-cell">${fileSize}</div> <div class="size-cell">${fileSize}</div>
<div class="date-cell">${formattedDate}</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; return el;
}, },
+21 -18
View File
@@ -182,32 +182,35 @@ function showUserProfileModal() {
overlay.id = 'profile-modal-overlay'; overlay.id = 'profile-modal-overlay';
overlay.className = 'about-modal-overlay'; overlay.className = 'about-modal-overlay';
overlay.innerHTML = ` overlay.innerHTML = `
<div class="about-modal" style="max-width:380px"> <div class="about-modal about-modal-body">
<div style="text-align:center;padding:20px 20px 0"> <div class="about-modal-header">
<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> <div class="about-modal-avatar">${initials}</div>
<h3 style="margin:0;font-size:18px;color:#1a1a2e">${username}</h3> <h3 class="about-modal-username">${username}</h3>
<p style="margin:4px 0 0;font-size:13px;color:#64748b">${email}</p> <p class="about-modal-email">${email}</p>
<span style="display:inline-block;margin-top:8px;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600;${ <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>
role === 'admin'
? 'background:#dbeafe;color:#1d4ed8'
: 'background:#f1f5f9;color:#64748b'
}">${role === 'admin' ? '🛡️ Admin' : '👤 ' + t('user_menu.role_user', 'User')}</span>
</div> </div>
<div style="padding:16px 20px"> <div class="about-modal-storage">
<div style="font-size:12px;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px"> <div class="about-modal-storage-label">
<i class="fas fa-database" style="margin-right:4px"></i>${t('storage.title', 'Storage')} <i class="fas fa-database"></i>${t('storage.title', 'Storage')}
</div> </div>
<div style="background:#f1f5f9;border-radius:6px;height:8px;overflow:hidden;margin-bottom:4px"> <div class="about-modal-bar-bg">
<div style="height:100%;width:${percentage}%;background:${barColor};border-radius:6px;transition:width .3s"></div> <div class="about-modal-bar-fill" id="about-bar-fill"></div>
</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>
<div style="padding:0 20px 16px;display:flex;justify-content:center"> <div class="about-modal-footer">
<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> <button id="profile-modal-close" class="about-modal-close-btn">${t('actions.close', 'Close')}</button>
</div> </div>
</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); document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('show')); 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"], "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 ────────────────────────────────────────────── // Icon CSS is now in /css/components/icons.css (loaded via main.css).
// 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);
})();
// ── Public API ───────────────────────────────────────────────── // ── 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-icon upload"><i class="fas fa-cloud-upload-alt"></i></div>
<div class="notif-item-body"> <div class="notif-item-body">
<div class="notif-item-title">${uploadingText}</div> <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-progress">
<div class="notif-upload-bar"><div class="notif-upload-fill" id="${batchId}-fill"></div></div> <div class="notif-upload-bar"><div class="notif-upload-fill" id="${batchId}-fill"></div></div>
<div class="notif-upload-detail"> <div class="notif-upload-detail">
+10 -10
View File
@@ -31,8 +31,8 @@ const contextMenus = {
window.wopiEditor && window.wopiEditor &&
await window.wopiEditor.canEdit(targetFile.name); await window.wopiEditor.canEdit(targetFile.name);
wopiEdit.style.display = show ? '' : 'none'; wopiEdit.classList.toggle('hidden', !show);
wopiEditTab.style.display = show ? '' : 'none'; wopiEditTab.classList.toggle('hidden', !show);
}, },
syncFavoriteOptionLabels() { syncFavoriteOptionLabels() {
@@ -524,7 +524,7 @@ const contextMenus = {
const titleText = mode === 'file' ? const titleText = mode === 'file' ?
(window.i18n ? window.i18n.t('dialogs.move_file') : 'Move file') : (window.i18n ? window.i18n.t('dialogs.move_file') : 'Move file') :
(window.i18n ? window.i18n.t('dialogs.move_folder') : 'Move folder'); (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 // Load folders for the starting location
await this.loadMoveDialogFolders(startFolderId); await this.loadMoveDialogFolders(startFolderId);
@@ -639,7 +639,7 @@ const contextMenus = {
const currentFolderOption = document.createElement('div'); const currentFolderOption = document.createElement('div');
currentFolderOption.className = 'folder-select-item folder-select-current'; currentFolderOption.className = 'folder-select-item folder-select-current';
currentFolderOption.innerHTML = ` 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> <span>${window.i18n ? window.i18n.t('dialogs.select_this_folder') : 'Select this folder'}</span>
`; `;
currentFolderOption.addEventListener('click', () => { currentFolderOption.addEventListener('click', () => {
@@ -718,7 +718,7 @@ const contextMenus = {
const homeOption = document.createElement('div'); const homeOption = document.createElement('div');
homeOption.className = 'folder-select-item folder-select-current'; homeOption.className = 'folder-select-item folder-select-current';
homeOption.innerHTML = ` 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> <span>${window.i18n ? window.i18n.t('dialogs.move_to_home') : 'Move to Home folder'}</span>
`; `;
homeOption.addEventListener('click', () => { homeOption.addEventListener('click', () => {
@@ -889,7 +889,7 @@ const contextMenus = {
existingSharesContainer.innerHTML = ''; existingSharesContainer.innerHTML = '';
if (existingShares.length > 0) { 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 // Create elements for each existing share
existingShares.forEach(share => { existingShares.forEach(share => {
@@ -965,19 +965,19 @@ const contextMenus = {
await window.fileSharing.removeSharedLink(shareId); await window.fileSharing.removeSharedLink(shareId);
btn.closest('.existing-share-item').remove(); btn.closest('.existing-share-item').remove();
if (existingSharesContainer.children.length === 0) { if (existingSharesContainer.children.length === 0) {
document.getElementById('existing-shares-section').style.display = 'none'; document.getElementById('existing-shares-section').classList.add('hidden');
} }
} }
}); });
}); });
}); });
} else { } else {
document.getElementById('existing-shares-section').style.display = 'none'; document.getElementById('existing-shares-section').classList.add('hidden');
} }
// Hide new-share section from previous use // Hide new-share section from previous use
const newShareSection = document.getElementById('new-share-section'); const newShareSection = document.getElementById('new-share-section');
if (newShareSection) newShareSection.style.display = 'none'; if (newShareSection) newShareSection.classList.add('hidden');
// Show dialog // Show dialog
shareDialog.style.display = 'flex'; shareDialog.style.display = 'flex';
@@ -1041,7 +1041,7 @@ const contextMenus = {
const shareUrl = document.getElementById('generated-share-url'); const shareUrl = document.getElementById('generated-share-url');
if (shareUrl) { if (shareUrl) {
shareUrl.value = shareInfo.url; shareUrl.value = shareInfo.url;
document.getElementById('new-share-section').style.display = 'block'; document.getElementById('new-share-section').classList.remove('hidden');
shareUrl.focus(); shareUrl.focus();
shareUrl.select(); shareUrl.select();
} }
+1 -1
View File
@@ -401,7 +401,7 @@ const multiSelect = {
const n = items.length; const n = items.length;
const titleText = this._t('batch.move_title', { count: n }) const titleText = this._t('batch.move_title', { count: n })
|| `Move ${n} item${n !== 1 ? 's' : ''}`; || `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); const excludeIds = items.filter(i => i.type === 'folder').map(i => i.id);
await contextMenus.loadAllFolders(excludeIds[0] || null, 'batch'); await contextMenus.loadAllFolders(excludeIds[0] || null, 'batch');
+2 -2
View File
@@ -175,8 +175,8 @@ const search = {
const emptyState = document.createElement('div'); const emptyState = document.createElement('div');
emptyState.className = 'empty-state'; emptyState.className = 'empty-state';
emptyState.innerHTML = ` emptyState.innerHTML = `
<i class="fas fa-search" style="font-size: 48px; color: var(--empty-icon, #ccc); margin-bottom: 16px;"></i> <i class="fas fa-search empty-state-icon"></i>
<p style="color: var(--text-secondary, #64748b);">No results found for this search</p> <p class="search-empty-text">No results found for this search</p>
`; `;
filesGrid.appendChild(emptyState); filesGrid.appendChild(emptyState);
return; return;
+1 -1
View File
@@ -120,7 +120,7 @@ class WopiEditor {
var spinner = document.createElement('div'); var spinner = document.createElement('div');
spinner.id = 'wopi-loading-spinner'; 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.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); frameHolder.appendChild(spinner);
var iframe = document.createElement('iframe'); var iframe = document.createElement('iframe');
+1 -1
View File
@@ -187,7 +187,7 @@ const favorites = {
const emptyState = document.createElement('div'); const emptyState = document.createElement('div');
emptyState.className = 'empty-state'; emptyState.className = 'empty-state';
emptyState.innerHTML = ` 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_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> <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'); const emptyState = document.createElement('div');
emptyState.className = 'empty-state'; emptyState.className = 'empty-state';
emptyState.innerHTML = ` 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_state') : 'No recent files'}</p>
<p>${window.i18n ? window.i18n.t('recent.empty_hint') : 'Files you open will appear here'}</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><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>' + authBadge + '</td>' +
'<td><span class="badge badge-' + (u.active ? 'active' : 'inactive') + '">' + (u.active ? 'Active' : 'Inactive') + '</span></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 class="user-last-login-cell">' + timeAgo(u.last_login_at) + '</td>' +
'<td><div class="actions-row">' + '<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>' + '<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" onclick="openResetPasswordModal(\'' + _escJs(u.id) + '\',\'' + _escJs(u.username) + '\')" title="Reset password"><i class="fas fa-key"></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" 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 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') + '" 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 ' + (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" 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-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>'; '</div></td></tr>';
}).join(''); }).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('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('prev-btn').disabled = usersPage === 0;
document.getElementById('next-btn').disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers; 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'); document.getElementById('p-storage-text').textContent = formatBytes(used) + ' / ' + (quota > 0 ? formatBytes(quota) : 'Unlimited');
if (user.auth_provider && user.auth_provider !== 'local') { if (user.auth_provider && user.auth_provider !== 'local') {
document.getElementById('password-section').style.display = 'none'; document.getElementById('password-section').classList.add('hidden');
} }
loadAppPasswords(); loadAppPasswords();
@@ -72,7 +72,7 @@ async function init() {
if (oidcResp.ok) { if (oidcResp.ok) {
const oidcInfo = await oidcResp.json(); const oidcInfo = await oidcResp.json();
if (!oidcInfo.password_login_enabled) { if (!oidcInfo.password_login_enabled) {
document.getElementById('password-section').style.display = 'none'; document.getElementById('password-section').classList.add('hidden');
} }
} }
} catch (oidcErr) { } catch (oidcErr) {
@@ -157,7 +157,7 @@ function renderPwRow(pw) {
btn.className = 'btn btn-danger-sm'; btn.className = 'btn btn-danger-sm';
btn.innerHTML = '<i class="fas fa-trash"></i>'; btn.innerHTML = '<i class="fas fa-trash"></i>';
btn.title = 'Revoke'; btn.title = 'Revoke';
btn.onclick = function () { revokeAppPassword(pw.id, pw.label); }; btn.addEventListener('click', function () { revokeAppPassword(pw.id, pw.label); });
actions.appendChild(btn); actions.appendChild(btn);
tr.append(label, created, lastUsed, actions); tr.append(label, created, lastUsed, actions);
return tr; return tr;
@@ -167,7 +167,7 @@ async function loadAppPasswords() {
try { try {
const resp = await fetch(API + '/auth/app-passwords', { headers: headers() }); const resp = await fetch(API + '/auth/app-passwords', { headers: headers() });
if (!resp.ok) { if (!resp.ok) {
document.getElementById('app-passwords-section').style.display = 'none'; document.getElementById('app-passwords-section').classList.add('hidden');
return; return;
} }
const passwords = await resp.json(); const passwords = await resp.json();
@@ -180,20 +180,20 @@ async function loadAppPasswords() {
const empty = document.getElementById('app-pw-empty'); const empty = document.getElementById('app-pw-empty');
tbody.innerHTML = ''; tbody.innerHTML = '';
if (userPws.length === 0) { if (userPws.length === 0) {
table.style.display = 'none'; table.classList.add('hidden');
empty.style.display = 'block'; empty.classList.remove('hidden');
} else { } else {
table.style.display = ''; table.classList.remove('hidden');
empty.style.display = 'none'; empty.classList.add('hidden');
for (const pw of userPws) tbody.appendChild(renderPwRow(pw)); for (const pw of userPws) tbody.appendChild(renderPwRow(pw));
} }
// Auto-generated (client session) passwords // Auto-generated (client session) passwords
const autoSection = document.getElementById('app-pw-auto-section'); const autoSection = document.getElementById('app-pw-auto-section');
if (autoPws.length === 0) { if (autoPws.length === 0) {
autoSection.style.display = 'none'; autoSection.classList.add('hidden');
} else { } else {
autoSection.style.display = ''; autoSection.classList.remove('hidden');
document.getElementById('app-pw-auto-count').textContent = autoPws.length; document.getElementById('app-pw-auto-count').textContent = autoPws.length;
const autoTbody = document.getElementById('app-pw-auto-tbody'); const autoTbody = document.getElementById('app-pw-auto-tbody');
autoTbody.innerHTML = ''; autoTbody.innerHTML = '';
@@ -207,9 +207,9 @@ async function loadAppPasswords() {
function toggleAutoPasswords() { function toggleAutoPasswords() {
const body = document.getElementById('app-pw-auto-body'); const body = document.getElementById('app-pw-auto-body');
const chevron = document.getElementById('app-pw-auto-chevron'); const chevron = document.getElementById('app-pw-auto-chevron');
const open = body.style.display === 'none'; const isHidden = body.classList.contains('hidden');
body.style.display = open ? '' : 'none'; body.classList.toggle('hidden', !isHidden);
chevron.className = open ? 'fas fa-chevron-down' : 'fas fa-chevron-right'; chevron.className = isHidden ? 'fas fa-chevron-down' : 'fas fa-chevron-right';
} }
async function createAppPassword() { async function createAppPassword() {
@@ -241,7 +241,7 @@ async function createAppPassword() {
const result = await resp.json(); const result = await resp.json();
document.getElementById('app-pw-created-label').textContent = result.label; document.getElementById('app-pw-created-label').textContent = result.label;
document.getElementById('app-pw-created-password').textContent = result.password; 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 = ''; labelInput.value = '';
loadAppPasswords(); loadAppPasswords();
} catch (err) { } catch (err) {
@@ -255,7 +255,7 @@ async function createAppPassword() {
function copyAppPassword() { function copyAppPassword() {
const pw = document.getElementById('app-pw-created-password').textContent; const pw = document.getElementById('app-pw-created-password').textContent;
navigator.clipboard.writeText(pw).then(function () { 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>'; btn.innerHTML = '<i class="fas fa-check"></i>';
setTimeout(function () { btn.innerHTML = '<i class="fas fa-copy"></i>'; }, 1500); setTimeout(function () { btn.innerHTML = '<i class="fas fa-copy"></i>'; }, 1500);
}); });
@@ -269,7 +269,7 @@ async function revokeAppPassword(id, label) {
headers: headers() headers: headers()
}); });
if (resp.ok || resp.status === 204) { if (resp.ok || resp.status === 204) {
document.getElementById('app-pw-created').style.display = 'none'; document.getElementById('app-pw-created').classList.add('hidden');
loadAppPasswords(); loadAppPasswords();
} else { } else {
const err = await resp.json().catch(() => ({})); 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(); 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('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> </div>
<div id="empty-shared-state" class="empty-state" style="display:none;"> <div id="empty-shared-state" class="empty-state hidden">
<i class="fas fa-share-alt" style="font-size: 48px; color: #ddd; margin-bottom: 16px;"></i> <i class="fas fa-share-alt empty-state-icon"></i>
<p data-i18n="shared_emptyStateTitle">No shared items</p> <p data-i18n="shared_emptyStateTitle">No shared items</p>
<p data-i18n="shared_emptyStateDesc">Items you share will appear here</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> <button id="go-to-files-btn" class="button primary" data-i18n="shared.goToFiles">Go to Files</button>
</div> </div>
<div class="shared-list-container" style="display:none;"> <div class="shared-list-container hidden">
<table class="shared-table"> <table class="shared-table">
<thead> <thead>
<tr> <tr>
+2 -36
View File
@@ -25,46 +25,12 @@
<span id="error-message">An error occurred. Please try again.</span> <span id="error-message">An error occurred. Please try again.</span>
</div> </div>
<div style="margin-top: 20px;"> <div class="auth-action-wrap">
<button type="button" class="auth-button" id="error-action">Try Again</button> <button type="button" class="auth-button" id="error-action">Try Again</button>
</div> </div>
</div> </div>
</div> </div>
<script> <script src="/js/views/nextcloud/error.js"></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>
</body> </body>
</html> </html>
+5 -49
View File
@@ -20,7 +20,7 @@
</div> </div>
<h2 class="auth-title">Grant Access</h2> <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. A Nextcloud client is requesting access to your account.
</p> </p>
@@ -56,59 +56,15 @@
</form> </form>
<!-- OIDC/SSO login — shown only when OIDC is enabled --> <!-- OIDC/SSO login — shown only when OIDC is enabled -->
<div id="oidc-section" style="display: none;"> <div id="oidc-section" class="hidden">
<div style="display: flex; align-items: center; gap: 12px; margin: 16px 0;"> <div class="auth-divider"><span>or</span></div>
<hr style="flex: 1; border: none; border-top: 1px solid #e5e7eb;"> <button type="button" id="oidc-button" class="auth-button auth-button-sso">
<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;">
Sign in with SSO Sign in with SSO
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<script> <script src="/js/views/nextcloud/login.js"></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>
</body> </body>
</html> </html>
+4 -9
View File
@@ -25,21 +25,16 @@
<span>You have successfully granted access to your account.</span> <span>You have successfully granted access to your account.</span>
</div> </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. You can now close this window and return to your Nextcloud app.
</p> </p>
<div style="margin-top: 20px;"> <div class="auth-action-wrap">
<button type="button" class="auth-button" onclick="window.close()">Close Window</button> <button type="button" class="auth-button" id="close-window-btn">Close Window</button>
</div> </div>
</div> </div>
</div> </div>
<script> <script src="/js/views/nextcloud/success.js"></script>
// Auto-close after 3 seconds
setTimeout(() => {
window.close();
}, 3000);
</script>
</body> </body>
</html> </html>
+7 -7
View File
@@ -100,14 +100,14 @@
<div class="app-pw-create"> <div class="app-pw-create">
<input type="text" id="app-pw-label" placeholder="Label (e.g. Thunderbird, macOS)" maxlength="128"> <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>
<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-label">New password for <strong id="app-pw-created-label"></strong>:</div>
<div class="app-pw-created-value"> <div class="app-pw-created-value">
<code id="app-pw-created-password"></code> <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> </div>
<small>Copy this password now. You won't be able to see it again.</small> <small>Copy this password now. You won't be able to see it again.</small>
</div> </div>
@@ -120,15 +120,15 @@
</thead> </thead>
<tbody id="app-pw-tbody"></tbody> <tbody id="app-pw-tbody"></tbody>
</table> </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"> <div id="app-pw-auto-section" class="app-pw-auto-section hidden">
<button class="app-pw-auto-toggle" id="app-pw-auto-toggle" onclick="toggleAutoPasswords()"> <button class="app-pw-auto-toggle" id="app-pw-auto-toggle">
<i class="fas fa-chevron-right" id="app-pw-auto-chevron"></i> <i class="fas fa-chevron-right" id="app-pw-auto-chevron"></i>
<span>Client sessions</span> <span>Client sessions</span>
<span class="app-pw-auto-count" id="app-pw-auto-count">0</span> <span class="app-pw-auto-count" id="app-pw-auto-count">0</span>
</button> </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> <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"> <table class="app-pw-table" id="app-pw-auto-table">
<thead> <thead>