diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index fbaef4dd..ca55655e 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -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; diff --git a/src/infrastructure/services/thumbnail_service_test.rs b/src/infrastructure/services/thumbnail_service_test.rs new file mode 100644 index 00000000..b9e01eec --- /dev/null +++ b/src/infrastructure/services/thumbnail_service_test.rs @@ -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 { + // 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/.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"); +} diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 1e4d1679..f230e793 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -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" { diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index b6ace6a7..37c386c5 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -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> { - // Routes that do NOT require authentication - let public_routes = Router::new() +/// Public auth routes — no authentication required. +pub fn auth_public_routes() -> Router> { + 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> { + 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 diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 66784b1b..0f523836 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -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); }); diff --git a/src/main.rs b/src/main.rs index 90a39c4a..cb6fbf5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,10 +67,7 @@ async fn main() -> Result<(), Box> { 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> { 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> { } 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> { 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> { .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) diff --git a/static/css/components/cards.css b/static/css/components/cards.css index 681d1acd..45761d14 100644 --- a/static/css/components/cards.css +++ b/static/css/components/cards.css @@ -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; diff --git a/static/css/components/fileList.css b/static/css/components/fileList.css index 88b6548a..4dfb4ceb 100644 --- a/static/css/components/fileList.css +++ b/static/css/components/fileList.css @@ -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, diff --git a/static/js/app/ui.js b/static/js/app/ui.js index a649e56a..24501261 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -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 = {
+ ${iconSpecialClass === 'image-icon' ? `` : ''}
${escapeHtml(file.name)}
@@ -1176,6 +1177,7 @@ const ui = {
+ ${iconSpecialClass === 'image-icon' ? `` : ''}
${escapeHtml(file.name)} diff --git a/static/js/app/userMenu.js b/static/js/app/userMenu.js index c3522b96..5d2fa7b2 100644 --- a/static/js/app/userMenu.js +++ b/static/js/app/userMenu.js @@ -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; diff --git a/static/js/features/auth/auth.js b/static/js/features/auth/auth.js index c6982468..d52b223e 100644 --- a/static/js/features/auth/auth.js +++ b/static/js/features/auth/auth.js @@ -729,9 +729,25 @@ 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.'; diff --git a/static/js/features/files/inlineViewer.js b/static/js/features/files/inlineViewer.js index 610f7af6..b792f888 100644 --- a/static/js/features/files/inlineViewer.js +++ b/static/js/features/files/inlineViewer.js @@ -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; } diff --git a/static/js/views/admin/admin.js b/static/js/views/admin/admin.js index ff5a02ba..677e299a 100644 --- a/static/js/views/admin/admin.js +++ b/static/js/views/admin/admin.js @@ -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 = ' Loading…'; 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 = ' Failed to load users'; 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 = ' 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 = ' 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 = ' 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 = '
' + escapeHtml(r.message) + '
Issuer
' + escapeHtml(r.issuer||'—') + '
Auth Endpoint
' + escapeHtml(r.authorization_endpoint||'—') + '
'; @@ -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; diff --git a/static/js/views/profile/profile.js b/static/js/views/profile/profile.js index 3cfd6547..95809a8a 100644 --- a/static/js/views/profile/profile.js +++ b/static/js/views/profile/profile.js @@ -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 }) }); diff --git a/static/login.html b/static/login.html index 936947a2..76e2ba97 100644 --- a/static/login.html +++ b/static/login.html @@ -273,6 +273,19 @@ >
+
+ + + Check the server console output for the setup token. +
+