feat: optional auth + backup/restore reliability fixes
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:
2026-03-23 14:50:25 +03:00
parent cd3137b0ec
commit 4975a74ff3
18 changed files with 189 additions and 67 deletions

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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; }

View File

@@ -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;