Merge pull request #164 from jaredwolff/thumbnail-fix
fix: auth cookie support, thumbnail display
This commit is contained in:
@@ -11,6 +11,8 @@ pub mod password_hasher;
|
||||
pub mod path_resolver_service;
|
||||
pub mod path_service;
|
||||
pub mod thumbnail_service;
|
||||
#[cfg(test)]
|
||||
mod thumbnail_service_test;
|
||||
pub mod trash_cleanup_service;
|
||||
pub mod webdav_lock_service;
|
||||
pub mod wopi_discovery_service;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::thumbnail_service::{ThumbnailService, ThumbnailSize};
|
||||
|
||||
/// Minimal valid 1x1 red PNG (68 bytes).
|
||||
fn tiny_png() -> Vec<u8> {
|
||||
// Generated from a real 1×1 PNG — smallest valid RGBA image.
|
||||
let mut img = image::RgbaImage::new(1, 1);
|
||||
img.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(
|
||||
&mut std::io::Cursor::new(&mut buf),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.expect("encode test PNG");
|
||||
buf
|
||||
}
|
||||
|
||||
/// Regression test: thumbnail generation must work when the source file lives
|
||||
/// at a blob-style path (`.blobs/ab/ab1234…`) rather than a logical path
|
||||
/// (`folder/image.png`). This broke after the blob storage migration
|
||||
/// (commit 3c7c16f) because the handler passed the logical path — which
|
||||
/// doesn't exist on disk — to the thumbnail service.
|
||||
#[tokio::test]
|
||||
async fn generate_thumbnail_from_blob_path() {
|
||||
let tmp = tempfile::tempdir().expect("create temp dir");
|
||||
let storage_root = tmp.path();
|
||||
|
||||
// Simulate a blob-store layout: .blobs/ab/<hash>.blob
|
||||
let blob_dir = storage_root.join(".blobs").join("ab");
|
||||
std::fs::create_dir_all(&blob_dir).expect("create blob dir");
|
||||
let blob_path = blob_dir.join("ab1234567890.blob");
|
||||
std::fs::write(&blob_path, tiny_png()).expect("write test blob");
|
||||
|
||||
let svc = Arc::new(ThumbnailService::new(storage_root, 100, 10 * 1024 * 1024));
|
||||
svc.initialize().await.expect("init thumbnail dirs");
|
||||
|
||||
// The key assertion: the service can read from a blob path (not a logical path)
|
||||
let result = svc
|
||||
.get_thumbnail("test-file-id", ThumbnailSize::Icon, &blob_path)
|
||||
.await;
|
||||
|
||||
let thumb_bytes = result.expect("thumbnail generation should succeed from blob path");
|
||||
assert!(!thumb_bytes.is_empty(), "thumbnail bytes must not be empty");
|
||||
|
||||
// Verify it's valid WebP (starts with "RIFF" magic)
|
||||
assert!(
|
||||
thumb_bytes.len() > 12 && &thumb_bytes[0..4] == b"RIFF",
|
||||
"output should be WebP format"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that a non-existent path produces an error, not a panic.
|
||||
#[tokio::test]
|
||||
async fn generate_thumbnail_nonexistent_path_returns_error() {
|
||||
let tmp = tempfile::tempdir().expect("create temp dir");
|
||||
let svc = Arc::new(ThumbnailService::new(tmp.path(), 100, 10 * 1024 * 1024));
|
||||
svc.initialize().await.expect("init thumbnail dirs");
|
||||
|
||||
let bad_path = tmp.path().join("does-not-exist.png");
|
||||
let result = svc
|
||||
.get_thumbnail("missing-id", ThumbnailSize::Icon, &bad_path)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "should fail for nonexistent file");
|
||||
}
|
||||
@@ -50,12 +50,18 @@ async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(String, S
|
||||
let token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.and_then(|v| v.strip_prefix("Bearer ").map(|s| s.to_string()))
|
||||
.or_else(|| {
|
||||
crate::interfaces::api::cookie_auth::extract_cookie_value(
|
||||
headers,
|
||||
crate::interfaces::api::cookie_auth::ACCESS_COOKIE,
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| AppError::unauthorized("Authorization token required"))?;
|
||||
|
||||
let claims = auth
|
||||
.token_service
|
||||
.validate_token(token)
|
||||
.validate_token(&token)
|
||||
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
|
||||
|
||||
if claims.role != "admin" {
|
||||
|
||||
@@ -16,25 +16,24 @@ use crate::interfaces::api::cookie_auth;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUserId;
|
||||
|
||||
pub fn auth_routes() -> Router<Arc<AppState>> {
|
||||
// Routes that do NOT require authentication
|
||||
let public_routes = Router::new()
|
||||
/// Public auth routes — no authentication required.
|
||||
pub fn auth_public_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/status", get(get_system_status))
|
||||
// OIDC endpoints (all public)
|
||||
.route("/oidc/providers", get(oidc_providers))
|
||||
.route("/oidc/authorize", get(oidc_authorize))
|
||||
.route("/oidc/callback", get(oidc_callback))
|
||||
.route("/oidc/exchange", post(oidc_exchange));
|
||||
.route("/oidc/exchange", post(oidc_exchange))
|
||||
}
|
||||
|
||||
// Routes that DO require authentication - we use route_layer to apply middleware
|
||||
// The middleware will use the state passed with .with_state() from main.rs
|
||||
let protected_routes = Router::new()
|
||||
/// Protected auth routes — require authentication (auth + CSRF middleware
|
||||
/// must be applied by the caller in main.rs).
|
||||
pub fn auth_protected_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/me", get(get_current_user))
|
||||
.route("/change-password", put(change_password))
|
||||
.route("/logout", post(logout));
|
||||
|
||||
// Combine public and protected routes
|
||||
public_routes.merge(protected_routes)
|
||||
.route("/logout", post(logout))
|
||||
}
|
||||
|
||||
/// Rate-limited auth routes — split out so main.rs can apply per-endpoint
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::application::ports::file_ports::OptimizedFileContent;
|
||||
use crate::application::ports::file_ports::{
|
||||
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
|
||||
};
|
||||
use crate::application::ports::storage_ports::StorageUsagePort;
|
||||
use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort};
|
||||
use crate::application::ports::thumbnail_ports::ThumbnailPort;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
@@ -322,8 +322,20 @@ impl FileHandler {
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let storage_root = state.core.path_service.get_root_path();
|
||||
let file_path = storage_root.join(&file.path);
|
||||
// Resolve the actual blob path on disk (not the logical file path).
|
||||
let blob_hash = match state.repositories.file_read_repository.get_blob_hash(&id).await {
|
||||
Ok(h) => h,
|
||||
Err(err) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("File content not found: {}", err)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let file_path = state.core.dedup_service.blob_path(&blob_hash);
|
||||
|
||||
match thumbnail_service
|
||||
.get_thumbnail(&id, thumb_size.into(), &file_path)
|
||||
@@ -597,12 +609,21 @@ impl FileHandler {
|
||||
.is_supported_image(&file.mime_type)
|
||||
{
|
||||
let file_id = file.id.clone();
|
||||
let file_path_rel = file.path.clone();
|
||||
let thumbnail_service = state.core.thumbnail_service.clone();
|
||||
let path_service = state.core.path_service.clone();
|
||||
let dedup_service = state.core.dedup_service.clone();
|
||||
let file_read = state.repositories.file_read_repository.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let file_path = path_service.get_root_path().join(&file_path_rel);
|
||||
// Resolve the actual blob path on disk (not the logical file path,
|
||||
// which doesn't exist when using blob storage).
|
||||
let blob_hash = match file_read.get_blob_hash(&file_id).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::warn!("Skipping thumbnails for {}: {}", file_id, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let file_path = dedup_service.blob_path(&blob_hash);
|
||||
tracing::info!("🖼️ Generating thumbnails for: {}", file_id);
|
||||
thumbnail_service.generate_all_sizes_background(file_id, file_path);
|
||||
});
|
||||
|
||||
+23
-9
@@ -67,10 +67,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !storage_path.exists() {
|
||||
std::fs::create_dir_all(&storage_path).expect("Failed to create storage directory");
|
||||
}
|
||||
let locales_path = PathBuf::from("./static/locales");
|
||||
if !locales_path.exists() {
|
||||
std::fs::create_dir_all(&locales_path).expect("Failed to create locales directory");
|
||||
}
|
||||
// Locales are embedded in the binary via rust-embed — no filesystem path needed.
|
||||
|
||||
// Initialize database pools if auth is enabled
|
||||
let db_pools = if config.features.enable_auth {
|
||||
@@ -93,6 +90,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
None
|
||||
};
|
||||
|
||||
// Ensure locales directory exists for i18n
|
||||
let locales_path = PathBuf::from("./static/locales");
|
||||
if !locales_path.exists() {
|
||||
std::fs::create_dir_all(&locales_path).expect("Failed to create locales directory");
|
||||
}
|
||||
|
||||
// Build all services via the factory
|
||||
let factory = AppServiceFactory::with_config(storage_path, locales_path, config.clone());
|
||||
|
||||
@@ -171,7 +174,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
if config.features.enable_auth {
|
||||
use interfaces::api::handlers::auth_handler::{
|
||||
auth_routes, login_route, refresh_route, register_route, setup_route,
|
||||
auth_protected_routes, auth_public_routes, login_route, refresh_route, register_route,
|
||||
setup_route,
|
||||
};
|
||||
use oxicloud::interfaces::api::handlers::app_password_handler;
|
||||
use oxicloud::interfaces::api::handlers::device_auth_handler;
|
||||
@@ -227,8 +231,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
rate_limit_refresh,
|
||||
))
|
||||
.with_state(app_state.clone());
|
||||
// Remaining auth routes (status, OIDC, protected /me, /logout, etc.)
|
||||
let auth_router = auth_routes().with_state(app_state.clone());
|
||||
// Public auth routes (status, OIDC)
|
||||
let auth_public = auth_public_routes().with_state(app_state.clone());
|
||||
// Protected auth routes (/me, /change-password, /logout) — require auth + CSRF
|
||||
let auth_protected = auth_protected_routes()
|
||||
.layer(axum::middleware::from_fn(csrf_middleware))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
auth_middleware,
|
||||
))
|
||||
.with_state(app_state.clone());
|
||||
// One-time setup route — public, rate-limited like register
|
||||
let setup_router = setup_route()
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
@@ -286,8 +298,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.nest("/api/auth", auth_login)
|
||||
.nest("/api/auth", auth_register)
|
||||
.nest("/api/auth", auth_refresh)
|
||||
// Other auth endpoints (status, OIDC, protected /me, /logout)
|
||||
.nest("/api/auth", auth_router)
|
||||
// Public auth endpoints (status, OIDC)
|
||||
.nest("/api/auth", auth_public)
|
||||
// Protected auth endpoints (/me, /change-password, /logout)
|
||||
.nest("/api/auth", auth_protected)
|
||||
// One-time setup endpoint — public, rate-limited
|
||||
.nest("/api", setup_router)
|
||||
// Device Auth Grant public endpoints (authorize + token polling)
|
||||
|
||||
@@ -322,6 +322,17 @@ button.favorite-star {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Thumbnail image inside file-icon (grid and list views) */
|
||||
.file-icon .file-thumb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.file-icon.image-icon::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
|
||||
@@ -145,6 +145,9 @@
|
||||
|
||||
.file-item .file-icon.image-icon {
|
||||
background-color: #e0f2fe;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.file-item .file-icon.image-icon i,
|
||||
|
||||
+4
-2
@@ -747,13 +747,13 @@ const ui = {
|
||||
return null;
|
||||
};
|
||||
|
||||
const openFile = (file) => {
|
||||
const openFile = async (file) => {
|
||||
if (!file) return;
|
||||
if (window.recent) {
|
||||
document.dispatchEvent(new CustomEvent('file-accessed', { detail: { file } }));
|
||||
}
|
||||
// WOPI editor intercept: open Office documents in the WOPI editor
|
||||
if (window.wopiEditor && window.wopiEditor.canEdit(file.name)) {
|
||||
if (window.wopiEditor && await window.wopiEditor.canEdit(file.name)) {
|
||||
window.wopiEditor.openInModal(file.id, file.name, 'edit');
|
||||
return;
|
||||
}
|
||||
@@ -1139,6 +1139,7 @@ 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'">` : ''}
|
||||
<i class="${iconClass}"></i>
|
||||
</div>
|
||||
<div class="file-name">${escapeHtml(file.name)}</div>
|
||||
@@ -1176,6 +1177,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'">` : ''}
|
||||
<i class="${iconClass}"></i>
|
||||
</div>
|
||||
<span>${escapeHtml(file.name)}</span>
|
||||
|
||||
@@ -223,17 +223,24 @@ function showUserProfileModal() {
|
||||
});
|
||||
}
|
||||
|
||||
function logout() {
|
||||
async function logout() {
|
||||
const USER_DATA_KEY = 'oxicloud_user';
|
||||
|
||||
// Tell the server to clear HttpOnly cookies
|
||||
fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin', headers: getCsrfHeaders() })
|
||||
.catch(() => {}) // Best-effort
|
||||
.finally(() => {
|
||||
localStorage.removeItem(USER_DATA_KEY);
|
||||
sessionStorage.removeItem('redirect_count');
|
||||
window.location.href = '/login';
|
||||
});
|
||||
// Clear local state first to prevent login page from auto-refreshing
|
||||
localStorage.removeItem(USER_DATA_KEY);
|
||||
localStorage.removeItem('refresh_attempts');
|
||||
sessionStorage.removeItem('redirect_count');
|
||||
|
||||
// Tell the server to clear HttpOnly cookies (await to ensure cookies are
|
||||
// cleared before redirecting, otherwise the login page's session probe
|
||||
// will refresh the token and redirect back to the app).
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin', headers: getCsrfHeaders() });
|
||||
} catch (_) {
|
||||
// Best-effort
|
||||
}
|
||||
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
window.setupUserMenu = setupUserMenu;
|
||||
|
||||
@@ -729,8 +729,24 @@ if (isLoginPage && adminSetupForm) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Register admin account
|
||||
const data = await register('admin', email, password, 'admin');
|
||||
// Use the /api/setup endpoint which creates an admin and marks the system as initialized
|
||||
const setupToken = document.getElementById('admin-setup-token').value;
|
||||
const response = await fetch('/api/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
username: 'admin',
|
||||
email,
|
||||
password,
|
||||
setup_token: setupToken
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err.message || 'Setup failed');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Show success message in the GUI instead of alert
|
||||
const successMsg = window.i18n ? window.i18n.t('auth.admin_success') : 'Admin account created successfully! You can now log in.';
|
||||
|
||||
@@ -88,11 +88,11 @@ class InlineViewer {
|
||||
console.log('Inline viewer initialized');
|
||||
}
|
||||
|
||||
openFile(file) {
|
||||
async openFile(file) {
|
||||
console.log('Opening file:', file);
|
||||
|
||||
// WOPI editor intercept: open Office documents in the WOPI editor
|
||||
if (window.wopiEditor && window.wopiEditor.canEdit(file.name)) {
|
||||
if (window.wopiEditor && await window.wopiEditor.canEdit(file.name)) {
|
||||
window.wopiEditor.openInModal(file.id, file.name, 'edit');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ function switchTab(name, el) {
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const resp = await fetch(API + '/admin/dashboard', { headers: headers() });
|
||||
const resp = await fetch(API + '/admin/dashboard', { headers: headers(), credentials: 'same-origin' });
|
||||
if (!resp.ok) return;
|
||||
const d = await resp.json();
|
||||
document.getElementById('ds-total-users').textContent = d.total_users;
|
||||
@@ -103,7 +103,7 @@ async function loadUsers() {
|
||||
const tbody = document.getElementById('users-tbody');
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="table-loading-cell"><i class="fas fa-spinner fa-spin"></i> Loading…</td></tr>';
|
||||
try {
|
||||
const resp = await fetch(API + '/admin/users?limit=' + PAGE_SIZE + '&offset=' + (usersPage * PAGE_SIZE), { headers: headers() });
|
||||
const resp = await fetch(API + '/admin/users?limit=' + PAGE_SIZE + '&offset=' + (usersPage * PAGE_SIZE), { headers: headers(), credentials: 'same-origin' });
|
||||
if (!resp.ok) { tbody.innerHTML = '<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> Failed to load users</td></tr>'; return; }
|
||||
const data = await resp.json();
|
||||
totalUsers = data.total;
|
||||
@@ -151,7 +151,7 @@ async function toggleRole(userId, currentRole) {
|
||||
if (!confirm('Change role to ' + newRole + '?')) return;
|
||||
try {
|
||||
const resp = await fetch(API + '/admin/users/' + userId + '/role', {
|
||||
method: 'PUT', headers: headers(), body: JSON.stringify({ role: newRole })
|
||||
method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ role: newRole })
|
||||
});
|
||||
if (resp.ok) loadUsers(); else { const e = await resp.json(); alert(e.message || 'Failed'); }
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
@@ -162,7 +162,7 @@ async function toggleActive(userId, currentActive) {
|
||||
if (!confirm('Are you sure you want to ' + action + ' this user?')) return;
|
||||
try {
|
||||
const resp = await fetch(API + '/admin/users/' + userId + '/active', {
|
||||
method: 'PUT', headers: headers(), body: JSON.stringify({ active: !currentActive })
|
||||
method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ active: !currentActive })
|
||||
});
|
||||
if (resp.ok) loadUsers(); else { const e = await resp.json(); alert(e.message || 'Failed'); }
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
@@ -171,7 +171,7 @@ async function toggleActive(userId, currentActive) {
|
||||
async function deleteUser(userId, username) {
|
||||
if (!confirm('DELETE user "' + username + '"? This cannot be undone!')) return;
|
||||
try {
|
||||
const resp = await fetch(API + '/admin/users/' + userId, { method: 'DELETE', headers: headers() });
|
||||
const resp = await fetch(API + '/admin/users/' + userId, { method: 'DELETE', headers: headers(), credentials: 'same-origin' });
|
||||
if (resp.ok) { loadUsers(); loadDashboard(); } else { const e = await resp.json(); alert(e.message || 'Failed'); }
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
@@ -193,7 +193,7 @@ async function saveQuota() {
|
||||
const bytes = Math.round(val * unit);
|
||||
try {
|
||||
const resp = await fetch(API + '/admin/users/' + quotaUserId + '/quota', {
|
||||
method: 'PUT', headers: headers(), body: JSON.stringify({ quota_bytes: bytes })
|
||||
method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ quota_bytes: bytes })
|
||||
});
|
||||
if (resp.ok) { closeQuotaModal(); loadUsers(); loadDashboard(); }
|
||||
else { const e = await resp.json(); alert(e.message || 'Failed'); }
|
||||
@@ -231,7 +231,7 @@ async function submitCreateUser() {
|
||||
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Creating…';
|
||||
try {
|
||||
const resp = await fetch(API + '/admin/users', {
|
||||
method: 'POST', headers: headers(),
|
||||
method: 'POST', headers: headers(), credentials: 'same-origin',
|
||||
body: JSON.stringify({ username, password, email, role, quota_bytes: quotaBytes })
|
||||
});
|
||||
if (resp.ok) {
|
||||
@@ -271,7 +271,7 @@ async function submitResetPassword() {
|
||||
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Resetting…';
|
||||
try {
|
||||
const resp = await fetch(API + '/admin/users/' + resetPwUserId + '/password', {
|
||||
method: 'PUT', headers: headers(),
|
||||
method: 'PUT', headers: headers(), credentials: 'same-origin',
|
||||
body: JSON.stringify({ new_password: password })
|
||||
});
|
||||
if (resp.ok) { closeResetPasswordModal(); }
|
||||
@@ -285,7 +285,7 @@ async function toggleRegistration(enabled) {
|
||||
else showElement('registration-warning', 'flex');
|
||||
try {
|
||||
const resp = await fetch(API + '/admin/settings/registration', {
|
||||
method: 'PUT', headers: headers(),
|
||||
method: 'PUT', headers: headers(), credentials: 'same-origin',
|
||||
body: JSON.stringify({ registration_enabled: enabled })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
@@ -330,7 +330,7 @@ async function testConnection() {
|
||||
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Discovering…';
|
||||
const resultDiv = document.getElementById('discovery-result');
|
||||
try {
|
||||
const resp = await fetch(API + '/admin/settings/oidc/test', { method: 'POST', headers: headers(), body: JSON.stringify({ issuer_url: url }) });
|
||||
const resp = await fetch(API + '/admin/settings/oidc/test', { method: 'POST', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ issuer_url: url }) });
|
||||
const r = await resp.json();
|
||||
if (r.success) {
|
||||
resultDiv.innerHTML = '<div class="discovery-result ok"><strong><i class="fas fa-check-circle"></i> ' + escapeHtml(r.message) + '</strong><dl><dt>Issuer</dt><dd>' + escapeHtml(r.issuer||'—') + '</dd><dt>Auth Endpoint</dt><dd>' + escapeHtml(r.authorization_endpoint||'—') + '</dd></dl></div>';
|
||||
@@ -357,7 +357,7 @@ async function saveOidcSettings() {
|
||||
provider_name: document.getElementById('provider-name').value.trim() || null,
|
||||
};
|
||||
try {
|
||||
const resp = await fetch(API + '/admin/settings/oidc', { method: 'PUT', headers: headers(), body: JSON.stringify(body) });
|
||||
const resp = await fetch(API + '/admin/settings/oidc', { method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify(body) });
|
||||
if (resp.ok) { showOidcStatus('Settings saved — OIDC is now ' + (body.enabled ? 'active' : 'disabled'), 'success'); loadDashboard(); }
|
||||
else { const e = await resp.json().catch(()=>({})); showOidcStatus('Error: ' + (e.message || resp.statusText), 'error'); }
|
||||
} catch (e) { showOidcStatus('Network error: ' + e.message, 'error'); }
|
||||
@@ -366,13 +366,13 @@ async function saveOidcSettings() {
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const me = await fetch(API + '/auth/me', { headers: headers() });
|
||||
const me = await fetch(API + '/auth/me', { headers: headers(), credentials: 'same-origin' });
|
||||
if (!me.ok) { showAccessDenied(); return; }
|
||||
const user = await me.json();
|
||||
if (user.role !== 'admin') { showAccessDenied(); return; }
|
||||
currentAdminId = user.id;
|
||||
|
||||
const oidcResp = await fetch(API + '/admin/settings/oidc', { headers: headers() });
|
||||
const oidcResp = await fetch(API + '/admin/settings/oidc', { headers: headers(), credentials: 'same-origin' });
|
||||
if (oidcResp.ok) {
|
||||
const s = await oidcResp.json();
|
||||
document.getElementById('oidc-enabled').checked = s.enabled;
|
||||
|
||||
@@ -25,7 +25,7 @@ function timeAgo(dateStr) {
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const resp = await fetch(API + '/auth/me', { headers: headers() });
|
||||
const resp = await fetch(API + '/auth/me', { headers: headers(), credentials: 'same-origin' });
|
||||
if (!resp.ok) { showError(); return; }
|
||||
const user = await resp.json();
|
||||
|
||||
@@ -66,7 +66,7 @@ async function init() {
|
||||
}
|
||||
|
||||
try {
|
||||
const oidcResp = await fetch(API + '/auth/oidc/providers');
|
||||
const oidcResp = await fetch(API + '/auth/oidc/providers', { credentials: 'same-origin' });
|
||||
if (oidcResp.ok) {
|
||||
const oidcInfo = await oidcResp.json();
|
||||
if (!oidcInfo.password_login_enabled) {
|
||||
@@ -114,6 +114,7 @@ async function changePassword(e) {
|
||||
const resp = await fetch(API + '/auth/change-password', {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ current_password: currentPw, new_password: newPw })
|
||||
});
|
||||
|
||||
|
||||
@@ -273,6 +273,19 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="auth-input-group">
|
||||
<label class="auth-label" for="admin-setup-token">Setup token</label>
|
||||
<input
|
||||
type="text"
|
||||
id="admin-setup-token"
|
||||
class="auth-input"
|
||||
placeholder="Paste the one-time token from the server log"
|
||||
required
|
||||
autocomplete="off"
|
||||
>
|
||||
<small style="color: var(--text-secondary, #666); margin-top: 4px; display: block;">Check the server console output for the setup token.</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-button" data-i18n="auth.create_admin">Create administrator</button>
|
||||
</form>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user