feat: optional auth + backup/restore reliability fixes
Some checks failed
Lint & Test / test (push) Failing after 29s
Some checks failed
Lint & Test / test (push) Failing after 29s
Auth is now optional: when `auth.api_keys` is empty, all endpoints are open (no login screen, no Bearer tokens). Health endpoint reports `auth_required` so the frontend knows which mode to use. Backup/restore fixes: - Auto-backup uses atomic writes (was `write_text`, risked corruption) - Startup backup skipped if recent backup exists (<5 min cooldown), preventing rapid restarts from rotating out good backups - Restore rejects all-empty backups to prevent accidental data wipes - Store saves frozen after restore to prevent stale in-memory data from overwriting freshly-restored files before restart completes - Missing stores during restore logged as warnings - STORE_MAP completeness verified at startup against StorageConfig
This commit is contained in:
@@ -21,7 +21,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_SERVER_NAME, default="LED Screen Controller"): str,
|
||||
vol.Required(CONF_SERVER_URL, default="http://localhost:8080"): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Optional(CONF_API_KEY, default=""): str,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -57,7 +57,9 @@ async def validate_server(
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConnectionError(f"Cannot connect to server: {err}") from err
|
||||
|
||||
# Step 2: Validate API key via authenticated endpoint
|
||||
# Step 2: Validate API key via authenticated endpoint (skip if no key and auth not required)
|
||||
auth_required = data.get("auth_required", True)
|
||||
if api_key:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
try:
|
||||
async with session.get(
|
||||
@@ -72,6 +74,8 @@ async def validate_server(
|
||||
raise
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConnectionError(f"API request failed: {err}") from err
|
||||
elif auth_required:
|
||||
raise PermissionError("Server requires an API key")
|
||||
|
||||
return {"version": version}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class WLEDScreenControllerCoordinator(DataUpdateCoordinator):
|
||||
self.session = session
|
||||
self.api_key = api_key
|
||||
self.server_version = "unknown"
|
||||
self._auth_headers = {"Authorization": f"Bearer {api_key}"}
|
||||
self._auth_headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
self._timeout = aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)
|
||||
|
||||
super().__init__(
|
||||
|
||||
@@ -23,7 +23,8 @@ Server uses API key authentication via Bearer token in `Authorization` header.
|
||||
|
||||
- Config: `config/default_config.yaml` under `auth.api_keys`
|
||||
- Env var: `WLED_AUTH__API_KEYS`
|
||||
- Dev key: `development-key-change-in-production`
|
||||
- When `api_keys` is empty (default), auth is disabled — all endpoints are open
|
||||
- To enable auth, add key entries (e.g. `dev: "your-secret-key"`)
|
||||
|
||||
## Common Tasks
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ server:
|
||||
- "http://localhost:8080"
|
||||
|
||||
auth:
|
||||
# API keys are REQUIRED - authentication is always enforced
|
||||
# Format: label: "api-key"
|
||||
api_keys:
|
||||
# API keys — when empty, authentication is disabled (open access).
|
||||
# To enable auth, add one or more label: "api-key" entries.
|
||||
# Generate secure keys: openssl rand -hex 32
|
||||
dev: "development-key-change-in-production" # Development key - CHANGE THIS!
|
||||
api_keys:
|
||||
dev: "development-key-change-in-production"
|
||||
|
||||
storage:
|
||||
devices_file: "data/devices.json"
|
||||
|
||||
@@ -15,11 +15,19 @@ logger = get_logger(__name__)
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
"""Return True when at least one API key is configured."""
|
||||
return bool(get_config().auth.api_keys)
|
||||
|
||||
|
||||
def verify_api_key(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(security)]
|
||||
) -> str:
|
||||
"""Verify API key from Authorization header.
|
||||
|
||||
When no API keys are configured, authentication is disabled and all
|
||||
requests are allowed through as "anonymous".
|
||||
|
||||
Args:
|
||||
credentials: HTTP authorization credentials
|
||||
|
||||
@@ -31,6 +39,10 @@ def verify_api_key(
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
# No keys configured → auth disabled, allow all requests
|
||||
if not config.auth.api_keys:
|
||||
return "anonymous"
|
||||
|
||||
# Check if credentials are provided
|
||||
if not credentials:
|
||||
logger.warning("Request missing Authorization header")
|
||||
@@ -43,14 +55,6 @@ def verify_api_key(
|
||||
# Extract token
|
||||
token = credentials.credentials
|
||||
|
||||
# Verify against configured API keys
|
||||
if not config.auth.api_keys:
|
||||
logger.error("No API keys configured - server misconfiguration")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Server authentication not configured properly",
|
||||
)
|
||||
|
||||
# Find matching key and return its label using constant-time comparison
|
||||
authenticated_as = None
|
||||
for label, api_key in config.auth.api_keys.items():
|
||||
@@ -80,10 +84,14 @@ AuthRequired = Annotated[str, Depends(verify_api_key)]
|
||||
def verify_ws_token(token: str) -> bool:
|
||||
"""Check a WebSocket query-param token against configured API keys.
|
||||
|
||||
Use this for WebSocket endpoints where FastAPI's Depends() isn't available.
|
||||
When no API keys are configured, authentication is disabled and all
|
||||
WebSocket connections are allowed.
|
||||
"""
|
||||
config = get_config()
|
||||
if token and config.auth.api_keys:
|
||||
# No keys configured → auth disabled, allow all connections
|
||||
if not config.auth.api_keys:
|
||||
return True
|
||||
if token:
|
||||
for _label, api_key in config.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
return True
|
||||
|
||||
@@ -27,6 +27,7 @@ from wled_controller.api.schemas.system import (
|
||||
)
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.storage.base_store import freeze_saves
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -175,6 +176,7 @@ async def import_store(
|
||||
return len(incoming)
|
||||
|
||||
count = await asyncio.to_thread(_write)
|
||||
freeze_saves()
|
||||
logger.info(f"Imported store '{store_key}' ({count} entries, merge={merge}). Scheduling restart...")
|
||||
_schedule_restart()
|
||||
return {
|
||||
@@ -269,6 +271,27 @@ async def restore_config(
|
||||
if not isinstance(stores[key], dict):
|
||||
raise HTTPException(status_code=400, detail=f"Store '{key}' in backup is not a valid JSON object")
|
||||
|
||||
# Guard: reject backups where every store is empty (version key only, no entities).
|
||||
# This prevents accidental data wipes from restoring a backup taken when the
|
||||
# server had no data loaded.
|
||||
total_entities = 0
|
||||
for key in present_keys:
|
||||
store_data = stores[key]
|
||||
for field_key, field_val in store_data.items():
|
||||
if field_key != "version" and isinstance(field_val, dict):
|
||||
total_entities += len(field_val)
|
||||
if total_entities == 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Backup contains no entity data (all stores are empty). Aborting to prevent data loss.",
|
||||
)
|
||||
|
||||
# Log missing stores as warnings
|
||||
missing = known_keys - present_keys
|
||||
if missing:
|
||||
for store_key in sorted(missing):
|
||||
logger.warning(f"Restore: backup is missing store '{store_key}' — existing data will be kept")
|
||||
|
||||
# Write store files atomically (in thread to avoid blocking event loop)
|
||||
config = get_config()
|
||||
|
||||
@@ -284,10 +307,13 @@ async def restore_config(
|
||||
|
||||
written = await asyncio.to_thread(_write_stores)
|
||||
|
||||
# Freeze all store saves so the old process can't overwrite restored files
|
||||
# with stale in-memory data before the restart completes.
|
||||
freeze_saves()
|
||||
|
||||
logger.info(f"Restore complete: {written}/{len(STORE_MAP)} stores written. Scheduling restart...")
|
||||
_schedule_restart()
|
||||
|
||||
missing = known_keys - present_keys
|
||||
return RestoreResponse(
|
||||
status="restored",
|
||||
stores_written=written,
|
||||
|
||||
@@ -14,7 +14,7 @@ import psutil
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from wled_controller import __version__
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.auth import AuthRequired, is_auth_enabled
|
||||
from wled_controller.api.dependencies import (
|
||||
get_audio_source_store,
|
||||
get_audio_template_store,
|
||||
@@ -107,6 +107,7 @@ async def health_check():
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
version=__version__,
|
||||
demo_mode=get_config().demo,
|
||||
auth_required=is_auth_enabled(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ class HealthResponse(BaseModel):
|
||||
timestamp: datetime = Field(description="Current server time")
|
||||
version: str = Field(description="Application version")
|
||||
demo_mode: bool = Field(default=False, description="Whether demo mode is active")
|
||||
auth_required: bool = Field(default=True, description="Whether API key authentication is required")
|
||||
|
||||
|
||||
class VersionResponse(BaseModel):
|
||||
|
||||
@@ -21,7 +21,7 @@ class ServerConfig(BaseSettings):
|
||||
class AuthConfig(BaseSettings):
|
||||
"""Authentication configuration."""
|
||||
|
||||
api_keys: dict[str, str] = {} # label: key mapping (required for security)
|
||||
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
|
||||
|
||||
|
||||
class StorageConfig(BaseSettings):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -18,6 +18,11 @@ DEFAULT_SETTINGS = {
|
||||
"max_backups": 10,
|
||||
}
|
||||
|
||||
# Skip the immediate-on-start backup if a recent backup exists within this window.
|
||||
# Prevents rapid restarts from flooding the backup directory and rotating out
|
||||
# good backups.
|
||||
_STARTUP_BACKUP_COOLDOWN = timedelta(minutes=5)
|
||||
|
||||
|
||||
class AutoBackupEngine:
|
||||
"""Creates periodic backups of all configuration stores."""
|
||||
@@ -83,11 +88,28 @@ class AutoBackupEngine:
|
||||
self._task.cancel()
|
||||
self._task = None
|
||||
|
||||
def _most_recent_backup_age(self) -> timedelta | None:
|
||||
"""Return the age of the newest backup file, or None if no backups exist."""
|
||||
files = list(self._backup_dir.glob("*.json"))
|
||||
if not files:
|
||||
return None
|
||||
newest = max(files, key=lambda p: p.stat().st_mtime)
|
||||
mtime = datetime.fromtimestamp(newest.stat().st_mtime, tz=timezone.utc)
|
||||
return datetime.now(timezone.utc) - mtime
|
||||
|
||||
async def _backup_loop(self) -> None:
|
||||
try:
|
||||
# Perform first backup immediately on start
|
||||
# Skip immediate backup if a recent one already exists.
|
||||
# Prevents rapid restarts (crashes, restores) from flooding the
|
||||
# backup directory and rotating out good backups.
|
||||
age = self._most_recent_backup_age()
|
||||
if age is None or age > _STARTUP_BACKUP_COOLDOWN:
|
||||
await self._perform_backup()
|
||||
self._prune_old_backups()
|
||||
else:
|
||||
logger.info(
|
||||
f"Skipping startup backup — most recent is only {age.total_seconds():.0f}s old"
|
||||
)
|
||||
|
||||
interval_secs = self._settings["interval_hours"] * 3600
|
||||
while True:
|
||||
@@ -133,8 +155,7 @@ class AutoBackupEngine:
|
||||
filename = f"ledgrab-autobackup-{timestamp}.json"
|
||||
file_path = self._backup_dir / filename
|
||||
|
||||
content = json.dumps(backup, indent=2, ensure_ascii=False)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
atomic_write_json(file_path, backup)
|
||||
|
||||
self._last_backup_time = now
|
||||
logger.info(f"Auto-backup created: {filename}")
|
||||
@@ -156,7 +177,6 @@ class AutoBackupEngine:
|
||||
def get_settings(self) -> dict:
|
||||
next_backup = None
|
||||
if self._settings["enabled"] and self._last_backup_time:
|
||||
from datetime import timedelta
|
||||
next_backup = (
|
||||
self._last_backup_time + timedelta(hours=self._settings["interval_hours"])
|
||||
).isoformat()
|
||||
|
||||
@@ -103,23 +103,13 @@ async def lifespan(app: FastAPI):
|
||||
print(f" Open http://localhost:{config.server.port} in your browser")
|
||||
print(" =============================================\n")
|
||||
|
||||
# Validate authentication configuration
|
||||
# Log authentication mode
|
||||
if not config.auth.api_keys:
|
||||
logger.error("=" * 70)
|
||||
logger.error("CRITICAL: No API keys configured!")
|
||||
logger.error("Authentication is REQUIRED for all API requests.")
|
||||
logger.error("Please add API keys to your configuration:")
|
||||
logger.error(" 1. Generate keys: openssl rand -hex 32")
|
||||
logger.error(" 2. Add to config/default_config.yaml under auth.api_keys")
|
||||
logger.error(" 3. Format: label: \"your-generated-key\"")
|
||||
logger.error("=" * 70)
|
||||
raise RuntimeError("No API keys configured - server cannot start without authentication")
|
||||
|
||||
# Log authentication status
|
||||
logger.info(f"API Authentication: ENFORCED ({len(config.auth.api_keys)} clients configured)")
|
||||
logger.info("Authentication disabled (no API keys configured)")
|
||||
else:
|
||||
logger.info(f"Authentication enabled ({len(config.auth.api_keys)} API key(s) configured)")
|
||||
client_labels = ", ".join(config.auth.api_keys.keys())
|
||||
logger.info(f"Authorized clients: {client_labels}")
|
||||
logger.info("All API requests require valid Bearer token authentication")
|
||||
|
||||
# Create MQTT service (shared broker connection)
|
||||
mqtt_service = MQTTService(config.mqtt)
|
||||
@@ -144,6 +134,21 @@ async def lifespan(app: FastAPI):
|
||||
storage_config=config.storage,
|
||||
)
|
||||
|
||||
# Verify STORE_MAP covers all StorageConfig file fields.
|
||||
# Catches missed additions early (at startup) rather than silently
|
||||
# excluding new stores from backups.
|
||||
storage_attrs = {
|
||||
attr for attr in config.storage.model_fields
|
||||
if attr.endswith("_file")
|
||||
}
|
||||
mapped_attrs = set(STORE_MAP.values())
|
||||
unmapped = storage_attrs - mapped_attrs
|
||||
if unmapped:
|
||||
logger.warning(
|
||||
f"StorageConfig fields not in STORE_MAP (missing from backups): "
|
||||
f"{sorted(unmapped)}"
|
||||
)
|
||||
|
||||
# Initialize API dependencies
|
||||
init_dependencies(
|
||||
device_store, template_store, processor_manager,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
// Layer 0: state
|
||||
import { apiKey, setApiKey, refreshInterval } from './core/state.ts';
|
||||
import { apiKey, setApiKey, authRequired, refreshInterval } from './core/state.ts';
|
||||
import { Modal } from './core/modal.ts';
|
||||
import { queryEl } from './core/dom-utils.ts';
|
||||
|
||||
@@ -180,6 +180,9 @@ import {
|
||||
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.ts';
|
||||
import { navigateToCard } from './core/navigation.ts';
|
||||
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.ts';
|
||||
import {
|
||||
applyStylePreset, applyBgEffect, renderAppearanceTab, initAppearance,
|
||||
} from './features/appearance.ts';
|
||||
import {
|
||||
openSettingsModal, closeSettingsModal, switchSettingsTab,
|
||||
downloadBackup, handleRestoreFileSelected,
|
||||
@@ -548,6 +551,11 @@ Object.assign(window, {
|
||||
setLogLevel,
|
||||
saveExternalUrl,
|
||||
getBaseOrigin,
|
||||
|
||||
// appearance
|
||||
applyStylePreset,
|
||||
applyBgEffect,
|
||||
renderAppearanceTab,
|
||||
});
|
||||
|
||||
// ─── Global keyboard shortcuts ───
|
||||
@@ -626,6 +634,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Initialize visual effects
|
||||
initCardGlare();
|
||||
initBgAnim();
|
||||
initAppearance();
|
||||
initTabIndicator();
|
||||
updateBgAnimTheme(document.documentElement.getAttribute('data-theme') !== 'light');
|
||||
const accent = localStorage.getItem('accentColor') || '#4CAF50';
|
||||
@@ -659,11 +668,15 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (addDeviceForm) addDeviceForm.addEventListener('submit', handleAddDevice);
|
||||
|
||||
// Always monitor server connection (even before login)
|
||||
loadServerInfo();
|
||||
await loadServerInfo();
|
||||
startConnectionMonitor();
|
||||
|
||||
// Show modal if no API key is stored
|
||||
if (!apiKey) {
|
||||
// Expose auth state for inline scripts (after loadServerInfo sets it)
|
||||
(window as any)._authRequired = authRequired;
|
||||
if (typeof window.updateAuthUI === 'function') window.updateAuthUI();
|
||||
|
||||
// Show login modal only when auth is enabled and no API key is stored
|
||||
if (authRequired && !apiKey) {
|
||||
setTimeout(() => {
|
||||
if (typeof window.showApiKeyModal === 'function') {
|
||||
window.showApiKeyModal(null, true);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* API utilities — base URL, auth headers, fetch wrapper, helpers.
|
||||
*/
|
||||
|
||||
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
|
||||
import { apiKey, setApiKey, authRequired, setAuthRequired, refreshInterval, setRefreshInterval, displaysCache } from './state.ts';
|
||||
import { t } from './i18n.ts';
|
||||
import { showToast } from './ui.ts';
|
||||
import { getEl, queryEl } from './dom-utils.ts';
|
||||
@@ -137,6 +137,7 @@ export function isGameSenseDevice(type: string) {
|
||||
}
|
||||
|
||||
export function handle401Error() {
|
||||
if (!authRequired) return; // Auth disabled — ignore 401s
|
||||
if (!apiKey) return; // Already handled or no session
|
||||
localStorage.removeItem('wled_api_key');
|
||||
setApiKey(null);
|
||||
@@ -200,6 +201,11 @@ export async function loadServerInfo() {
|
||||
window.dispatchEvent(new CustomEvent('server:reconnected'));
|
||||
}
|
||||
|
||||
// Auth mode detection
|
||||
const authNeeded = data.auth_required !== false;
|
||||
setAuthRequired(authNeeded);
|
||||
(window as any)._authRequired = authNeeded;
|
||||
|
||||
// Demo mode detection
|
||||
if (data.demo_mode && !demoMode) {
|
||||
demoMode = true;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* server:device_health_changed — device online/offline status change
|
||||
*/
|
||||
|
||||
import { apiKey } from './state.ts';
|
||||
import { apiKey, authRequired } from './state.ts';
|
||||
|
||||
let _ws: WebSocket | null = null;
|
||||
let _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -19,10 +19,10 @@ const _RECONNECT_MAX = 30000;
|
||||
|
||||
export function startEventsWS() {
|
||||
stopEventsWS();
|
||||
if (!apiKey) return;
|
||||
if (authRequired && !apiKey) return;
|
||||
|
||||
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${encodeURIComponent(apiKey)}`;
|
||||
const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${encodeURIComponent(apiKey || '')}`;
|
||||
|
||||
try {
|
||||
_ws = new WebSocket(url);
|
||||
|
||||
@@ -18,6 +18,9 @@ import type {
|
||||
export let apiKey: string | null = null;
|
||||
export function setApiKey(v: string | null) { apiKey = v; }
|
||||
|
||||
export let authRequired = true;
|
||||
export function setAuthRequired(v: boolean) { authRequired = v; }
|
||||
|
||||
export let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||
export function setRefreshInterval(v: ReturnType<typeof setInterval> | null) { refreshInterval = v; }
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ interface Window {
|
||||
|
||||
// ─── Core / state ───
|
||||
setApiKey: (key: string | null) => void;
|
||||
_authRequired: boolean | undefined;
|
||||
|
||||
// ─── Visual effects (called from inline <script>) ───
|
||||
_updateBgAnimAccent: (accent: string) => void;
|
||||
@@ -372,6 +373,11 @@ interface Window {
|
||||
saveExternalUrl: (...args: any[]) => any;
|
||||
getBaseOrigin: (...args: any[]) => any;
|
||||
|
||||
// ─── Appearance ───
|
||||
applyStylePreset: (id: string) => void;
|
||||
applyBgEffect: (id: string) => void;
|
||||
renderAppearanceTab: () => void;
|
||||
|
||||
// ─── Overlay spinner internals ───
|
||||
overlaySpinnerTimer: ReturnType<typeof setInterval> | null;
|
||||
_overlayEscHandler: ((e: KeyboardEvent) => void) | null;
|
||||
|
||||
@@ -11,6 +11,18 @@ from wled_controller.utils import atomic_write_json, get_logger
|
||||
T = TypeVar("T")
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# When True, all store saves are suppressed. Set by the restore flow
|
||||
# to prevent the old server process from overwriting freshly-restored
|
||||
# files with stale in-memory data before the restart completes.
|
||||
_saves_frozen = False
|
||||
|
||||
|
||||
def freeze_saves() -> None:
|
||||
"""Block all store saves until the process exits (used after restore)."""
|
||||
global _saves_frozen
|
||||
_saves_frozen = True
|
||||
logger.info("Store saves frozen — awaiting server restart")
|
||||
|
||||
|
||||
class EntityNotFoundError(ValueError):
|
||||
"""Raised when an entity is not found in the store."""
|
||||
@@ -94,6 +106,9 @@ class BaseJsonStore(Generic[T]):
|
||||
stores). Acceptable for user-initiated CRUD; not suitable for hot loops.
|
||||
Callers must hold ``self._lock``.
|
||||
"""
|
||||
if _saves_frozen:
|
||||
logger.warning(f"Save blocked (frozen after restore): {self._json_key}")
|
||||
return
|
||||
try:
|
||||
data = {
|
||||
"version": self._version,
|
||||
|
||||
@@ -341,14 +341,27 @@
|
||||
const savedAccent = localStorage.getItem('accentColor');
|
||||
if (savedAccent) applyAccentColor(savedAccent, true);
|
||||
|
||||
// Early-apply saved background effect class (before bundle loads)
|
||||
const savedBgEffect = localStorage.getItem('bgEffect');
|
||||
if (savedBgEffect && savedBgEffect !== 'none') {
|
||||
const effectClasses = { grid: 'bg-effect-grid', mesh: 'bg-effect-mesh', scanlines: 'bg-effect-scanlines', particles: 'bg-effect-particles' };
|
||||
if (effectClasses[savedBgEffect]) document.documentElement.classList.add(effectClasses[savedBgEffect]);
|
||||
}
|
||||
|
||||
// Initialize auth state
|
||||
function updateAuthUI() {
|
||||
const apiKey = localStorage.getItem('wled_api_key');
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
const tabBar = document.querySelector('.tab-bar');
|
||||
const authDisabled = window._authRequired === false;
|
||||
|
||||
if (apiKey) {
|
||||
if (authDisabled) {
|
||||
// Auth disabled — hide login/logout, always show tabs
|
||||
loginBtn.style.display = 'none';
|
||||
logoutBtn.style.display = 'none';
|
||||
if (tabBar) tabBar.style.display = '';
|
||||
} else if (apiKey) {
|
||||
loginBtn.style.display = 'none';
|
||||
logoutBtn.style.display = 'inline-block';
|
||||
if (tabBar) tabBar.style.display = '';
|
||||
|
||||
Reference in New Issue
Block a user