Refactor settings system and improve context initialization

Backend:
- Add type validation and coercion for settings API
- Implement SettingStorage and SettingType in registry
- Improve CRUD operations for settings

Frontend:
- Refactor Theme, Language, Sidebar, ViewMode contexts
- Simplify admin components (GeneralTab, SettingsTab, UsersTab)
- Add new settings endpoints to API client
- Improve App initialization flow

Infrastructure:
- Update Dockerfile and docker-compose.yml
- Add .dockerignore
- Update Makefile and README

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 18:14:47 +01:00
parent 04a0fe4b27
commit ba53e0eff0
31 changed files with 4277 additions and 374 deletions

37
.dockerignore Normal file
View File

@@ -0,0 +1,37 @@
.git
.gitignore
# IDE / local tooling
.vscode
.idea
.claude
# Python
__pycache__/
*.py[cod]
.venv
venv/
backend/venv/
backend/__pycache__/
backend/**/__pycache__/
.pytest_cache/
# Node / Vite
frontend/node_modules/
frontend/dist/
frontend/.vite/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
# Local persistent data (runtime bind-mounted)
config/
*.db
*.sqlite
*.sqlite3
# OS
.DS_Store
Thumbs.db

1
.gitignore vendored
View File

@@ -44,7 +44,6 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
.pnpm-store/ .pnpm-store/
package-lock.json
yarn.lock yarn.lock
pnpm-lock.yaml pnpm-lock.yaml

View File

@@ -9,7 +9,7 @@ WORKDIR /frontend
COPY frontend/package*.json ./ COPY frontend/package*.json ./
# Install dependencies # Install dependencies
RUN npm ci RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi
# Copy frontend source # Copy frontend source
COPY frontend/ . COPY frontend/ .

View File

@@ -33,5 +33,4 @@ build:
docker-compose build --no-cache docker-compose build --no-cache
shell: shell:
docker-compose exec app /bin/bash docker-compose exec app /bin/sh

View File

@@ -12,11 +12,10 @@
## Technology Stack ## Technology Stack
### Frontend ### Frontend
- React 18 + TypeScript - React 19 + TypeScript
- Vite (build tool) - Vite (build tool)
- Tailwind CSS + shadcn/ui - React Router
- React Query (TanStack Query) - Axios
- Zustand (state management)
### Backend ### Backend
- FastAPI (Python 3.11+) - FastAPI (Python 3.11+)
@@ -100,4 +99,3 @@
## Contributing ## Contributing
Feel free to fork and customize for your needs. Feel free to fork and customize for your needs.

View File

@@ -15,6 +15,7 @@ from app.config import settings
# Import all models so Alembic can detect them for auto-generating migrations # Import all models so Alembic can detect them for auto-generating migrations
from app.models.user import User # noqa from app.models.user import User # noqa
from app.models.settings import Settings # noqa
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.

View File

@@ -33,12 +33,17 @@ def register(
# If not the first user, check if registration is enabled # If not the first user, check if registration is enabled
if not is_first_user: if not is_first_user:
registration_enabled = crud.settings.get_setting_value( registration_enabled_raw = crud.settings.get_setting_value(
db, db,
key="registration_enabled", key="registration_enabled",
default=True # Default to enabled if setting doesn't exist default=True # Default to enabled if setting doesn't exist
) )
if isinstance(registration_enabled_raw, str):
registration_enabled = registration_enabled_raw.strip().lower() in ("true", "1")
else:
registration_enabled = bool(registration_enabled_raw)
if not registration_enabled: if not registration_enabled:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
@@ -146,10 +151,15 @@ def get_registration_status(
This is a public endpoint that doesn't require authentication. This is a public endpoint that doesn't require authentication.
Returns the registration_enabled setting value. Returns the registration_enabled setting value.
""" """
registration_enabled = crud.settings.get_setting_value( registration_enabled_raw = crud.settings.get_setting_value(
db, db,
key="registration_enabled", key="registration_enabled",
default=True default=True
) )
if isinstance(registration_enabled_raw, str):
registration_enabled = registration_enabled_raw.strip().lower() in ("true", "1")
else:
registration_enabled = bool(registration_enabled_raw)
return {"registration_enabled": registration_enabled} return {"registration_enabled": registration_enabled}

View File

@@ -12,7 +12,7 @@ router = APIRouter()
@router.get("") @router.get("")
async def health_check(db: Session = Depends(get_db)): def health_check(db: Session = Depends(get_db)):
""" """
Health check endpoint that verifies database connectivity. Health check endpoint that verifies database connectivity.

View File

@@ -1,5 +1,6 @@
"""Settings endpoints.""" """Settings endpoints."""
import json
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -12,6 +13,8 @@ from app.core.settings_registry import (
MODULE_KEYS, MODULE_KEYS,
UI_KEYS, UI_KEYS,
SETTINGS_REGISTRY, SETTINGS_REGISTRY,
SettingStorage,
SettingType,
get_default_value, get_default_value,
) )
@@ -19,6 +22,107 @@ from app.core.settings_registry import (
router = APIRouter() router = APIRouter()
def _require_database_setting(key: str):
setting_def = SETTINGS_REGISTRY.get(key)
if not setting_def:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown setting key '{key}'"
)
if setting_def.storage != SettingStorage.DATABASE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Setting '{key}' is not stored in database"
)
return setting_def
def _coerce_setting_value(key: str, value: Any) -> Any:
setting_def = _require_database_setting(key)
if setting_def.type == SettingType.BOOLEAN:
if isinstance(value, bool):
coerced = value
elif isinstance(value, int) and not isinstance(value, bool):
if value in (0, 1):
coerced = bool(value)
else:
raise HTTPException(status_code=400, detail=f"Invalid boolean value for '{key}'")
elif isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("true", "1"):
coerced = True
elif lowered in ("false", "0"):
coerced = False
else:
raise HTTPException(status_code=400, detail=f"Invalid boolean value for '{key}'")
else:
raise HTTPException(status_code=400, detail=f"Invalid boolean value for '{key}'")
elif setting_def.type == SettingType.INTEGER:
if isinstance(value, bool):
raise HTTPException(status_code=400, detail=f"Invalid integer value for '{key}'")
if isinstance(value, int):
coerced = value
elif isinstance(value, str):
try:
coerced = int(value.strip())
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid integer value for '{key}'") from exc
else:
raise HTTPException(status_code=400, detail=f"Invalid integer value for '{key}'")
elif setting_def.type == SettingType.STRING:
if not isinstance(value, str):
raise HTTPException(status_code=400, detail=f"Invalid string value for '{key}'")
coerced = value
elif setting_def.type == SettingType.JSON:
if isinstance(value, (dict, list)):
coerced = value
elif isinstance(value, str):
try:
coerced = json.loads(value)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"Invalid JSON value for '{key}'") from exc
else:
raise HTTPException(status_code=400, detail=f"Invalid JSON value for '{key}'")
elif setting_def.type == SettingType.LIST:
if isinstance(value, list):
coerced = value
elif isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"Invalid list value for '{key}'") from exc
if not isinstance(parsed, list):
raise HTTPException(status_code=400, detail=f"Invalid list value for '{key}'")
coerced = parsed
else:
raise HTTPException(status_code=400, detail=f"Invalid list value for '{key}'")
else:
coerced = value
if setting_def.choices is not None and coerced not in setting_def.choices:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid value for '{key}'. Allowed: {setting_def.choices}"
)
return coerced
def _validate_bulk_keys(payload: dict[str, Any], allowed_keys: set[str]) -> None:
unknown = [key for key in payload.keys() if key not in allowed_keys]
if unknown:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown/unsupported keys: {sorted(unknown)}"
)
@router.get("/theme", response_model=dict[str, Any]) @router.get("/theme", response_model=dict[str, Any])
def get_theme_settings( def get_theme_settings(
*, *,
@@ -41,6 +145,26 @@ def get_theme_settings(
return result return result
@router.get("/theme/public", response_model=dict[str, Any])
def get_theme_settings_public(
*,
db: Session = Depends(get_db),
) -> Any:
"""
Get theme settings without authentication.
Intended for login/landing pages to keep UI consistent before auth.
"""
result = {}
for key in THEME_KEYS:
setting = crud.settings.get_setting(db, key=key)
if setting:
result[key] = setting.get_value()
else:
result[key] = get_default_value(key)
return result
@router.put("/theme", response_model=dict[str, Any]) @router.put("/theme", response_model=dict[str, Any])
def update_theme_settings( def update_theme_settings(
*, *,
@@ -53,10 +177,11 @@ def update_theme_settings(
Updates multiple theme settings at once. Updates multiple theme settings at once.
""" """
_validate_bulk_keys(theme_data, set(THEME_KEYS))
result = {} result = {}
for key, value in theme_data.items(): for key, value in theme_data.items():
if key in THEME_KEYS or key.startswith("theme_"): coerced = _coerce_setting_value(key, value)
setting = crud.settings.update_setting(db, key=key, value=value) setting = crud.settings.update_setting(db, key=key, value=coerced)
result[key] = setting.get_value() result[key] = setting.get_value()
return result return result
@@ -116,10 +241,11 @@ def update_ui_settings(
Updates multiple UI/layout settings at once. Updates multiple UI/layout settings at once.
""" """
_validate_bulk_keys(ui_data, set(UI_KEYS))
result = {} result = {}
for key, value in ui_data.items(): for key, value in ui_data.items():
if key in UI_KEYS: coerced = _coerce_setting_value(key, value)
setting = crud.settings.update_setting(db, key=key, value=value) setting = crud.settings.update_setting(db, key=key, value=coerced)
result[key] = setting.get_value() result[key] = setting.get_value()
return result return result
@@ -136,10 +262,11 @@ def update_module_settings(
Enable or disable modules for all users. Enable or disable modules for all users.
""" """
_validate_bulk_keys(module_data, set(MODULE_KEYS))
result = {} result = {}
for key, value in module_data.items(): for key, value in module_data.items():
if key in MODULE_KEYS or key.startswith("module_"): coerced = _coerce_setting_value(key, value)
setting = crud.settings.update_setting(db, key=key, value=value) setting = crud.settings.update_setting(db, key=key, value=coerced)
result[key] = setting.get_value() result[key] = setting.get_value()
return result return result
@@ -171,7 +298,8 @@ def update_user_mode_enabled(
Update user mode enabled setting (admin only). Update user mode enabled setting (admin only).
""" """
value = data.get("value", True) value = data.get("value", True)
setting = crud.settings.update_setting(db, key="user_mode_enabled", value=value) coerced = _coerce_setting_value("user_mode_enabled", value)
setting = crud.settings.update_setting(db, key="user_mode_enabled", value=coerced)
return {"key": "user_mode_enabled", "value": setting.get_value()} return {"key": "user_mode_enabled", "value": setting.get_value()}
@@ -222,5 +350,6 @@ def update_setting(
Creates the setting if it doesn't exist. Creates the setting if it doesn't exist.
""" """
setting = crud.settings.update_setting(db, key=key, value=setting_in.value) coerced = _coerce_setting_value(key, setting_in.value)
setting = crud.settings.update_setting(db, key=key, value=coerced)
return schemas.Setting.from_orm(setting) return schemas.Setting.from_orm(setting)

View File

@@ -88,10 +88,14 @@ def update_user(
# Only superusers can change is_superuser and is_active # Only superusers can change is_superuser and is_active
if not current_user.is_superuser: if not current_user.is_superuser:
if user_in.is_superuser is not None or user_in.is_active is not None: if (
user_in.is_superuser is not None
or user_in.is_active is not None
or user_in.permissions is not None
):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can change user status" detail="Only admins can change user status or permissions"
) )
user = crud.user.update(db, db_obj=user, obj_in=user_in) user = crud.user.update(db, db_obj=user, obj_in=user_in)

View File

@@ -328,15 +328,16 @@ register_setting(SettingDefinition(
# ============================================================================= # =============================================================================
# USER-SPECIFIC SETTINGS (Per-user, Database) # USER-SPECIFIC SETTINGS (Frontend localStorage)
# These settings can be different for each user # These settings are managed by the frontend per-browser/user-session.
# If you need cross-device persistence, implement a per-user DB table + API.
# ============================================================================= # =============================================================================
register_setting(SettingDefinition( register_setting(SettingDefinition(
key="user_theme_mode", key="user_theme_mode",
type=SettingType.STRING, type=SettingType.STRING,
scope=SettingScope.USER_SPECIFIC, scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE, storage=SettingStorage.LOCAL_STORAGE,
default="system", default="system",
description="User's preferred theme mode (light/dark/system)", description="User's preferred theme mode (light/dark/system)",
category="user_preferences", category="user_preferences",
@@ -348,7 +349,7 @@ register_setting(SettingDefinition(
key="user_language", key="user_language",
type=SettingType.STRING, type=SettingType.STRING,
scope=SettingScope.USER_SPECIFIC, scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE, storage=SettingStorage.LOCAL_STORAGE,
default="en", default="en",
description="User's preferred language", description="User's preferred language",
category="user_preferences", category="user_preferences",
@@ -360,7 +361,7 @@ register_setting(SettingDefinition(
key="user_sidebar_collapsed", key="user_sidebar_collapsed",
type=SettingType.BOOLEAN, type=SettingType.BOOLEAN,
scope=SettingScope.USER_SPECIFIC, scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE, storage=SettingStorage.LOCAL_STORAGE,
default=False, default=False,
description="User's sidebar collapsed state preference", description="User's sidebar collapsed state preference",
category="user_preferences", category="user_preferences",
@@ -371,7 +372,7 @@ register_setting(SettingDefinition(
key="user_view_mode", key="user_view_mode",
type=SettingType.STRING, type=SettingType.STRING,
scope=SettingScope.USER_SPECIFIC, scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE, storage=SettingStorage.LOCAL_STORAGE,
default="admin", default="admin",
description="User's current view mode (admin/user)", description="User's current view mode (admin/user)",
category="user_preferences", category="user_preferences",

View File

@@ -71,7 +71,7 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
def delete(self, db: Session, *, id: Any) -> ModelType: def delete(self, db: Session, *, id: Any) -> ModelType:
"""Delete a record.""" """Delete a record."""
obj = db.query(self.model).get(id) obj = db.get(self.model, id)
db.delete(obj) db.delete(obj)
db.commit() db.commit()
return obj return obj

View File

@@ -1,5 +1,6 @@
"""CRUD operations for Settings.""" """CRUD operations for Settings."""
import json
from typing import Optional from typing import Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -27,13 +28,16 @@ def update_setting(db: Session, key: str, value: any) -> Settings:
setting = Settings(key=key) setting = Settings(key=key)
db.add(setting) db.add(setting)
# Determine if value is bool or string # Determine how to store the value (DB has only bool + string columns)
if isinstance(value, bool): if isinstance(value, bool):
setting.value_bool = value setting.value_bool = value
setting.value_str = None setting.value_str = None
elif isinstance(value, str) and value.lower() in ('true', 'false'): elif isinstance(value, str) and value.lower() in ("true", "false", "1", "0"):
setting.value_bool = value.lower() == 'true' setting.value_bool = value.lower() in ("true", "1")
setting.value_str = None setting.value_str = None
elif isinstance(value, (dict, list)):
setting.value_str = json.dumps(value)
setting.value_bool = None
else: else:
setting.value_str = str(value) setting.value_str = str(value)
setting.value_bool = None setting.value_bool = None

View File

@@ -138,16 +138,17 @@ async def startup_event():
# Seed default settings from registry # Seed default settings from registry
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import crud from app import crud
from app.core.settings_registry import get_database_defaults from app.core.settings_registry import SettingScope, get_database_settings
with Session(engine) as db: with Session(engine) as db:
# Get all database settings with defaults from registry # Seed only GLOBAL database settings with defaults from registry
default_settings = get_database_defaults() for setting_def in get_database_settings():
if setting_def.scope != SettingScope.GLOBAL:
continue
for key, value in default_settings.items(): if crud.settings.get_setting(db, setting_def.key) is None:
if crud.settings.get_setting(db, key) is None: logger.info(f"Seeding default setting: {setting_def.key}={setting_def.default}")
logger.info(f"Seeding default setting: {key}={value}") crud.settings.update_setting(db, setting_def.key, setting_def.default)
crud.settings.update_setting(db, key, value)
# Shutdown event # Shutdown event

View File

@@ -11,7 +11,7 @@ services:
ports: ports:
- "5174:8000" - "5174:8000"
healthcheck: healthcheck:
test: [ "CMD-SHELL", "curl -f http://localhost:8000/health || exit 1" ] test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" ]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
start_period: 40s start_period: 40s

3676
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,12 @@ import { ThemeProvider } from './contexts/ThemeContext';
import { SidebarProvider } from './contexts/SidebarContext'; import { SidebarProvider } from './contexts/SidebarContext';
import { ViewModeProvider } from './contexts/ViewModeContext'; import { ViewModeProvider } from './contexts/ViewModeContext';
import { ModulesProvider } from './contexts/ModulesContext'; import { ModulesProvider } from './contexts/ModulesContext';
import MainLayout from './components/MainLayout';
import Login from './pages/Login'; import Login from './pages/Login';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import Feature1 from './pages/Feature1'; import Feature1 from './pages/Feature1';
import Feature2 from './pages/Feature2';
import Feature3 from './pages/Feature3';
import AdminPanel from './pages/AdminPanel'; import AdminPanel from './pages/AdminPanel';
import Sources from './pages/admin/Sources'; import Sources from './pages/admin/Sources';
import Features from './pages/admin/Features'; import Features from './pages/admin/Features';
@@ -41,10 +44,6 @@ function AdminRoute({ children }: { children: ReactElement }) {
return user.is_superuser ? children : <Navigate to="/dashboard" />; return user.is_superuser ? children : <Navigate to="/dashboard" />;
} }
import MainLayout from './components/MainLayout';
// ...
function AppRoutes() { function AppRoutes() {
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
@@ -59,15 +58,19 @@ function AppRoutes() {
<Route element={<PrivateRoute><MainLayout /></PrivateRoute>}> <Route element={<PrivateRoute><MainLayout /></PrivateRoute>}>
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/feature1" element={<Feature1 />} /> <Route path="/feature1" element={<Feature1 />} />
<Route path="/feature2" element={<Feature2 />} />
<Route path="/feature3" element={<Feature3 />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<AdminRoute><AdminPanel /></AdminRoute>} /> <Route path="/admin" element={<AdminRoute><AdminPanel /></AdminRoute>} />
<Route path="/admin/users" element={<AdminRoute><AdminPanel initialTab="users" /></AdminRoute>} />
<Route path="/admin/sources" element={<AdminRoute><Sources /></AdminRoute>} /> <Route path="/admin/sources" element={<AdminRoute><Sources /></AdminRoute>} />
<Route path="/admin/features" element={<AdminRoute><Features /></AdminRoute>} /> <Route path="/admin/features" element={<AdminRoute><Features /></AdminRoute>} />
<Route path="/admin/theme" element={<AdminRoute><ThemeSettings /></AdminRoute>} /> <Route path="/admin/theme" element={<AdminRoute><ThemeSettings /></AdminRoute>} />
</Route> </Route>
<Route path="/" element={<Navigate to={user ? '/dashboard' : '/login'} />} /> <Route path="/" element={<Navigate to={user ? '/dashboard' : '/login'} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
); );
} }

View File

@@ -44,13 +44,18 @@ export const authAPI = {
// Settings endpoints // Settings endpoints
export const settingsAPI = { export const settingsAPI = {
getTheme: async (): Promise<Record<string, string>> => { getTheme: async (): Promise<Record<string, unknown>> => {
const response = await api.get<Record<string, string>>('/settings/theme'); const response = await api.get<Record<string, unknown>>('/settings/theme');
return response.data; return response.data;
}, },
updateTheme: async (data: Record<string, string>): Promise<Record<string, string>> => { getThemePublic: async (): Promise<Record<string, unknown>> => {
const response = await api.put<Record<string, string>>('/settings/theme', data); const response = await api.get<Record<string, unknown>>('/settings/theme/public');
return response.data;
},
updateTheme: async (data: Record<string, unknown>): Promise<Record<string, unknown>> => {
const response = await api.put<Record<string, unknown>>('/settings/theme', data);
return response.data; return response.data;
}, },
@@ -63,6 +68,37 @@ export const settingsAPI = {
const response = await api.put<Record<string, boolean>>('/settings/modules', data); const response = await api.put<Record<string, boolean>>('/settings/modules', data);
return response.data; return response.data;
}, },
getUi: async (): Promise<Record<string, unknown>> => {
const response = await api.get<Record<string, unknown>>('/settings/ui');
return response.data;
},
getAllSettings: async (): Promise<Record<string, unknown>> => {
const response = await api.get<Record<string, unknown>>('/settings');
return response.data;
},
updateSetting: async (key: string, value: unknown): Promise<{ key: string; value: unknown; updated_at: string }> => {
const response = await api.put<{ key: string; value: unknown; updated_at: string }>(`/settings/${key}`, { value });
return response.data;
},
getUserModeEnabled: async (): Promise<boolean> => {
const response = await api.get<{ key: string; value: unknown }>('/settings/user_mode_enabled');
const value = response.data?.value;
if (value === true || value === 'true' || value === 'True' || value === 1 || value === '1') return true;
if (value === false || value === 'false' || value === 'False' || value === 0 || value === '0') return false;
return false;
},
updateUserModeEnabled: async (enabled: boolean): Promise<boolean> => {
const response = await api.put<{ key: string; value: unknown }>('/settings/user_mode_enabled', { value: enabled });
const value = response.data?.value;
if (value === true || value === 'true' || value === 'True' || value === 1 || value === '1') return true;
if (value === false || value === 'false' || value === 'False' || value === 0 || value === '0') return false;
return false;
},
}; };
// Users endpoints // Users endpoints

View File

@@ -4,6 +4,7 @@ import { useViewMode } from '../../contexts/ViewModeContext';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { useSidebar } from '../../contexts/SidebarContext'; import { useSidebar } from '../../contexts/SidebarContext';
import { settingsAPI } from '../../api/client';
export default function GeneralTab() { export default function GeneralTab() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -32,11 +33,7 @@ export default function GeneralTab() {
useEffect(() => { useEffect(() => {
const loadSettings = async () => { const loadSettings = async () => {
try { try {
const response = await fetch('/api/v1/settings', { const data = await settingsAPI.getAllSettings();
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (response.ok) {
const data = await response.json();
// Helper to check for true values (bool, string 'true'/'True', 1) // Helper to check for true values (bool, string 'true'/'True', 1)
const isTrue = (val: any) => { const isTrue = (val: any) => {
@@ -56,7 +53,6 @@ export default function GeneralTab() {
if (data.show_logo !== undefined) { if (data.show_logo !== undefined) {
setShowLogo(isTrue(data.show_logo)); setShowLogo(isTrue(data.show_logo));
} }
}
} catch (error) { } catch (error) {
console.error('Failed to load settings:', error); console.error('Failed to load settings:', error);
} }
@@ -68,22 +64,9 @@ export default function GeneralTab() {
setRegistrationEnabled(checked); // Optimistic update setRegistrationEnabled(checked); // Optimistic update
setSavingRegistration(true); setSavingRegistration(true);
try { try {
const response = await fetch('/api/v1/settings/registration_enabled', { const updated = await settingsAPI.updateSetting('registration_enabled', checked);
method: 'PUT', const value = updated.value;
headers: { setRegistrationEnabled(value === true || value === 'true' || value === 'True' || value === 1 || value === '1');
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: checked }),
});
if (response.ok) {
const data = await response.json();
setRegistrationEnabled(data.value);
} else {
// Revert on failure
setRegistrationEnabled(!checked);
}
} catch (error) { } catch (error) {
console.error('Failed to update registration setting:', error); console.error('Failed to update registration setting:', error);
setRegistrationEnabled(!checked); setRegistrationEnabled(!checked);
@@ -96,22 +79,9 @@ export default function GeneralTab() {
setShowLogo(checked); // Optimistic update setShowLogo(checked); // Optimistic update
setSavingShowLogo(true); setSavingShowLogo(true);
try { try {
const response = await fetch('/api/v1/settings/show_logo', { const updated = await settingsAPI.updateSetting('show_logo', checked);
method: 'PUT', const value = updated.value;
headers: { setShowLogo(value === true || value === 'true' || value === 'True' || value === 1 || value === '1');
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: checked }),
});
if (response.ok) {
const data = await response.json();
setShowLogo(data.value);
} else {
// Revert on failure
setShowLogo(!checked);
}
} catch (error) { } catch (error) {
console.error('Failed to update show logo setting:', error); console.error('Failed to update show logo setting:', error);
setShowLogo(!checked); setShowLogo(!checked);

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from '../../contexts/LanguageContext'; import { useTranslation } from '../../contexts/LanguageContext';
import { settingsAPI } from '../../api/client';
interface Settings { interface Settings {
registration_enabled?: boolean; registration_enabled?: boolean;
@@ -17,17 +18,8 @@ export default function SettingsTab() {
const fetchSettings = async () => { const fetchSettings = async () => {
try { try {
const token = localStorage.getItem('token'); const data = await settingsAPI.getAllSettings();
const response = await fetch('/api/v1/settings', { setSettings(data as Settings);
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) { } catch (error) {
console.error('Failed to fetch settings:', error); console.error('Failed to fetch settings:', error);
} finally { } finally {
@@ -38,23 +30,11 @@ export default function SettingsTab() {
const updateSetting = async (key: string, value: any) => { const updateSetting = async (key: string, value: any) => {
setSaving(true); setSaving(true);
try { try {
const token = localStorage.getItem('token'); const updatedSetting = await settingsAPI.updateSetting(key, value);
const response = await fetch(`/api/v1/settings/${key}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value }),
});
if (response.ok) {
const updatedSetting = await response.json();
setSettings(prev => ({ setSettings(prev => ({
...prev, ...prev,
[key]: updatedSetting.value [key]: updatedSetting.value
})); }));
}
} catch (error) { } catch (error) {
console.error('Failed to update setting:', error); console.error('Failed to update setting:', error);
} finally { } finally {

View File

@@ -200,8 +200,8 @@ export default function UsersTab() {
email: formData.email, email: formData.email,
is_active: formData.is_active, is_active: formData.is_active,
is_superuser: formData.is_superuser, is_superuser: formData.is_superuser,
// Only send permissions if custom permissions are enabled, otherwise send empty object (inherit global) // If custom permissions are disabled, clear permissions (inherit from global module settings)
permissions: formData.hasCustomPermissions ? formData.permissions : {}, permissions: formData.hasCustomPermissions ? formData.permissions : null,
}; };
if (formData.password.trim()) { if (formData.password.trim()) {
@@ -760,7 +760,8 @@ export default function UsersTab() {
{formData.hasCustomPermissions ? ( {formData.hasCustomPermissions ? (
<div className="permissions-grid"> <div className="permissions-grid">
{TOGGLEABLE_MODULES.map((module) => { {TOGGLEABLE_MODULES.map((module) => {
const isGloballyDisabled = !moduleStates[module.id]; const state = moduleStates[module.id];
const isGloballyDisabled = !state?.admin || !state?.user;
const isChecked = formData.permissions[module.id] ?? true; const isChecked = formData.permissions[module.id] ?? true;
const moduleName = t.sidebar[module.id as keyof typeof t.sidebar] || module.id; const moduleName = t.sidebar[module.id as keyof typeof t.sidebar] || module.id;

View File

@@ -21,8 +21,17 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined
export function LanguageProvider({ children }: { children: ReactNode }) { export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>(() => { const [language, setLanguageState] = useState<Language>(() => {
const saved = localStorage.getItem('language'); const key = 'user_language';
if (saved) return saved as Language; const saved = localStorage.getItem(key);
if (saved === 'it' || saved === 'en') return saved;
// Backward-compatibility: migrate legacy key
const legacy = localStorage.getItem('language');
if (legacy === 'it' || legacy === 'en') {
localStorage.setItem(key, legacy);
localStorage.removeItem('language');
return legacy;
}
// Try to detect from browser // Try to detect from browser
const browserLang = navigator.language.split('-')[0]; const browserLang = navigator.language.split('-')[0];
@@ -31,7 +40,7 @@ export function LanguageProvider({ children }: { children: ReactNode }) {
const setLanguage = (lang: Language) => { const setLanguage = (lang: Language) => {
setLanguageState(lang); setLanguageState(lang);
localStorage.setItem('language', lang); localStorage.setItem('user_language', lang);
}; };
useEffect(() => { useEffect(() => {

View File

@@ -1,6 +1,7 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react'; import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useAuth } from './AuthContext'; import { useAuth } from './AuthContext';
import { settingsAPI } from '../api/client';
export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic'; export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic';
@@ -23,7 +24,22 @@ const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
export function SidebarProvider({ children }: { children: ReactNode }) { export function SidebarProvider({ children }: { children: ReactNode }) {
const { token } = useAuth(); const { token } = useAuth();
const [userCollapsed, setUserCollapsed] = useState(true); const [userCollapsed, setUserCollapsed] = useState(() => {
const key = 'user_sidebar_collapsed';
const saved = localStorage.getItem(key);
if (saved === 'true') return true;
if (saved === 'false') return false;
// Backward-compatibility: migrate legacy key
const legacy = localStorage.getItem('sidebarCollapsed');
if (legacy === 'true' || legacy === 'false') {
localStorage.setItem(key, legacy);
localStorage.removeItem('sidebarCollapsed');
return legacy === 'true';
}
return true;
});
const [isMobileOpen, setIsMobileOpen] = useState(false); const [isMobileOpen, setIsMobileOpen] = useState(false);
const [sidebarMode, setSidebarModeState] = useState<SidebarMode>('toggle'); const [sidebarMode, setSidebarModeState] = useState<SidebarMode>('toggle');
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@@ -39,11 +55,7 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
return; return;
} }
const response = await fetch('/api/v1/settings/ui', { const data = await settingsAPI.getUi();
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
const parseBool = (val: any, defaultVal: boolean): boolean => { const parseBool = (val: any, defaultVal: boolean): boolean => {
if (val === undefined || val === null) return defaultVal; if (val === undefined || val === null) return defaultVal;
if (val === true || val === 'true' || val === 'True' || val === 1 || val === '1') return true; if (val === true || val === 'true' || val === 'True' || val === 1 || val === '1') return true;
@@ -51,13 +63,12 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
return defaultVal; return defaultVal;
}; };
if (data.sidebar_mode && ['collapsed', 'expanded', 'toggle', 'dynamic'].includes(data.sidebar_mode)) { if (data.sidebar_mode && ['collapsed', 'expanded', 'toggle', 'dynamic'].includes(data.sidebar_mode as string)) {
setSidebarModeState(data.sidebar_mode as SidebarMode); setSidebarModeState(data.sidebar_mode as SidebarMode);
} }
if (data.show_logo !== undefined) { if (data.show_logo !== undefined) {
setShowLogo(parseBool(data.show_logo, false)); setShowLogo(parseBool(data.show_logo, false));
} }
}
} catch (error) { } catch (error) {
console.error('Failed to load sidebar mode:', error); console.error('Failed to load sidebar mode:', error);
} }
@@ -76,7 +87,11 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
const toggleCollapse = () => { const toggleCollapse = () => {
if (canToggle) { if (canToggle) {
setUserCollapsed((prev) => !prev); setUserCollapsed((prev) => {
const next = !prev;
localStorage.setItem('user_sidebar_collapsed', String(next));
return next;
});
} }
}; };
@@ -91,18 +106,8 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
const setSidebarMode = useCallback(async (mode: SidebarMode) => { const setSidebarMode = useCallback(async (mode: SidebarMode) => {
try { try {
if (!token) return; if (!token) return;
const response = await fetch('/api/v1/settings/sidebar_mode', { await settingsAPI.updateSetting('sidebar_mode', mode);
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: mode }),
});
if (response.ok) {
setSidebarModeState(mode); setSidebarModeState(mode);
}
} catch (error) { } catch (error) {
console.error('Failed to save sidebar mode:', error); console.error('Failed to save sidebar mode:', error);
} }

View File

@@ -1,5 +1,5 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react'; import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react'; import type { Dispatch, ReactNode, SetStateAction } from 'react';
import { settingsAPI } from '../api/client'; import { settingsAPI } from '../api/client';
import { useAuth } from './AuthContext'; import { useAuth } from './AuthContext';
@@ -21,7 +21,7 @@ interface ThemeContextType {
density: Density; density: Density;
fontFamily: FontFamily; fontFamily: FontFamily;
colorPalette: ColorPalette; colorPalette: ColorPalette;
customColors: Partial<PaletteColors>; customColors: CustomColors;
darkModeLocation: ControlLocation; darkModeLocation: ControlLocation;
languageLocation: ControlLocation; languageLocation: ControlLocation;
showDarkModeToggle: boolean; showDarkModeToggle: boolean;
@@ -37,7 +37,7 @@ interface ThemeContextType {
setDensity: (density: Density) => void; setDensity: (density: Density) => void;
setFontFamily: (font: FontFamily) => void; setFontFamily: (font: FontFamily) => void;
setColorPalette: (palette: ColorPalette) => void; setColorPalette: (palette: ColorPalette) => void;
setCustomColors: (colors: Partial<PaletteColors>) => void; setCustomColors: Dispatch<SetStateAction<CustomColors>>;
setDarkModeLocation: (location: ControlLocation) => void; setDarkModeLocation: (location: ControlLocation) => void;
setLanguageLocation: (location: ControlLocation) => void; setLanguageLocation: (location: ControlLocation) => void;
setShowDarkModeToggle: (show: boolean) => void; setShowDarkModeToggle: (show: boolean) => void;
@@ -52,7 +52,7 @@ interface ThemeContextType {
density: Density; density: Density;
fontFamily: FontFamily; fontFamily: FontFamily;
colorPalette: ColorPalette; colorPalette: ColorPalette;
customColors: Partial<PaletteColors>; customColors: CustomColors;
darkModeLocation: ControlLocation; darkModeLocation: ControlLocation;
languageLocation: ControlLocation; languageLocation: ControlLocation;
showDarkModeToggle: boolean; showDarkModeToggle: boolean;
@@ -162,7 +162,7 @@ const FONTS: Record<FontFamily, string> = {
roboto: '"Roboto", sans-serif', roboto: '"Roboto", sans-serif',
}; };
interface PaletteColors { export interface PaletteColors {
light: { light: {
bgMain: string; bgMain: string;
bgCard: string; bgCard: string;
@@ -185,6 +185,11 @@ interface PaletteColors {
}; };
} }
export type CustomColors = Partial<{
light: Partial<PaletteColors['light']>;
dark: Partial<PaletteColors['dark']>;
}>;
export const COLOR_PALETTES: Record<ColorPalette, PaletteColors> = { export const COLOR_PALETTES: Record<ColorPalette, PaletteColors> = {
default: { default: {
light: { light: {
@@ -468,7 +473,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const [density, setDensityState] = useState<Density>('compact'); const [density, setDensityState] = useState<Density>('compact');
const [fontFamily, setFontFamilyState] = useState<FontFamily>('sans'); const [fontFamily, setFontFamilyState] = useState<FontFamily>('sans');
const [colorPalette, setColorPaletteState] = useState<ColorPalette>('monochrome'); const [colorPalette, setColorPaletteState] = useState<ColorPalette>('monochrome');
const [customColors, setCustomColorsState] = useState<Partial<PaletteColors>>({}); const [customColors, setCustomColorsState] = useState<CustomColors>({});
const [darkModeLocation, setDarkModeLocationState] = useState<ControlLocation>('sidebar'); const [darkModeLocation, setDarkModeLocationState] = useState<ControlLocation>('sidebar');
const [languageLocation, setLanguageLocationState] = useState<ControlLocation>('sidebar'); const [languageLocation, setLanguageLocationState] = useState<ControlLocation>('sidebar');
const [showDarkModeToggle, setShowDarkModeToggleState] = useState<boolean>(true); const [showDarkModeToggle, setShowDarkModeToggleState] = useState<boolean>(true);
@@ -593,8 +598,11 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// Load theme from backend when user is authenticated // Load theme from backend when user is authenticated
const loadThemeFromBackend = useCallback(async () => { const loadThemeFromBackend = useCallback(async () => {
setIsLoadingSettings(true); setIsLoadingSettings(true);
setHasInitializedSettings(false);
try { try {
const themeData = await settingsAPI.getTheme(); const themeData = token
? await settingsAPI.getTheme()
: await settingsAPI.getThemePublic();
if (themeData.theme_accent_color) { if (themeData.theme_accent_color) {
setAccentColorState(themeData.theme_accent_color as AccentColor); setAccentColorState(themeData.theme_accent_color as AccentColor);
} }
@@ -640,7 +648,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const parsed = typeof themeData.theme_custom_colors === 'string' const parsed = typeof themeData.theme_custom_colors === 'string'
? JSON.parse(themeData.theme_custom_colors) ? JSON.parse(themeData.theme_custom_colors)
: themeData.theme_custom_colors; : themeData.theme_custom_colors;
setCustomColorsState(parsed as Partial<PaletteColors>); setCustomColorsState(parsed as CustomColors);
} catch (e) { } catch (e) {
console.error('Failed to parse custom colors:', e); console.error('Failed to parse custom colors:', e);
} }
@@ -651,7 +659,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
setIsLoadingSettings(false); setIsLoadingSettings(false);
setHasInitializedSettings(true); setHasInitializedSettings(true);
} }
}, []); }, [token]);
// Save theme to backend (admin only) - accepts optional overrides for immediate save // Save theme to backend (admin only) - accepts optional overrides for immediate save
const saveThemeToBackend = useCallback(async (overrides?: Partial<{ const saveThemeToBackend = useCallback(async (overrides?: Partial<{
@@ -661,7 +669,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
density: Density; density: Density;
fontFamily: FontFamily; fontFamily: FontFamily;
colorPalette: ColorPalette; colorPalette: ColorPalette;
customColors: Partial<PaletteColors>; customColors: CustomColors;
darkModeLocation: ControlLocation; darkModeLocation: ControlLocation;
languageLocation: ControlLocation; languageLocation: ControlLocation;
showDarkModeToggle: boolean; showDarkModeToggle: boolean;
@@ -670,7 +678,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
showLanguageLogin: boolean; showLanguageLogin: boolean;
}>) => { }>) => {
try { try {
const payload: Record<string, string> = {}; const payload: Record<string, unknown> = {};
if (overrides) { if (overrides) {
if (overrides.accentColor !== undefined) payload.theme_accent_color = overrides.accentColor; if (overrides.accentColor !== undefined) payload.theme_accent_color = overrides.accentColor;
@@ -679,13 +687,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
if (overrides.density !== undefined) payload.theme_density = overrides.density; if (overrides.density !== undefined) payload.theme_density = overrides.density;
if (overrides.fontFamily !== undefined) payload.theme_font_family = overrides.fontFamily; if (overrides.fontFamily !== undefined) payload.theme_font_family = overrides.fontFamily;
if (overrides.colorPalette !== undefined) payload.theme_color_palette = overrides.colorPalette; if (overrides.colorPalette !== undefined) payload.theme_color_palette = overrides.colorPalette;
if (overrides.customColors !== undefined) payload.theme_custom_colors = JSON.stringify(overrides.customColors); if (overrides.customColors !== undefined) payload.theme_custom_colors = overrides.customColors;
if (overrides.darkModeLocation !== undefined) payload.theme_dark_mode_location = overrides.darkModeLocation; if (overrides.darkModeLocation !== undefined) payload.theme_dark_mode_location = overrides.darkModeLocation;
if (overrides.languageLocation !== undefined) payload.theme_language_location = overrides.languageLocation; if (overrides.languageLocation !== undefined) payload.theme_language_location = overrides.languageLocation;
if (overrides.showDarkModeToggle !== undefined) payload.theme_show_dark_mode_toggle = String(overrides.showDarkModeToggle); if (overrides.showDarkModeToggle !== undefined) payload.theme_show_dark_mode_toggle = overrides.showDarkModeToggle;
if (overrides.showLanguageToggle !== undefined) payload.theme_show_language_toggle = String(overrides.showLanguageToggle); if (overrides.showLanguageToggle !== undefined) payload.theme_show_language_toggle = overrides.showLanguageToggle;
if (overrides.showDarkModeLogin !== undefined) payload.theme_show_dark_mode_login = String(overrides.showDarkModeLogin); if (overrides.showDarkModeLogin !== undefined) payload.theme_show_dark_mode_login = overrides.showDarkModeLogin;
if (overrides.showLanguageLogin !== undefined) payload.theme_show_language_login = String(overrides.showLanguageLogin); if (overrides.showLanguageLogin !== undefined) payload.theme_show_language_login = overrides.showLanguageLogin;
} else { } else {
payload.theme_accent_color = accentColor; payload.theme_accent_color = accentColor;
payload.theme_border_radius = borderRadius; payload.theme_border_radius = borderRadius;
@@ -693,13 +701,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
payload.theme_density = density; payload.theme_density = density;
payload.theme_font_family = fontFamily; payload.theme_font_family = fontFamily;
payload.theme_color_palette = colorPalette; payload.theme_color_palette = colorPalette;
payload.theme_custom_colors = JSON.stringify(customColors); payload.theme_custom_colors = customColors;
payload.theme_dark_mode_location = darkModeLocation; payload.theme_dark_mode_location = darkModeLocation;
payload.theme_language_location = languageLocation; payload.theme_language_location = languageLocation;
payload.theme_show_dark_mode_toggle = String(showDarkModeToggle); payload.theme_show_dark_mode_toggle = showDarkModeToggle;
payload.theme_show_language_toggle = String(showLanguageToggle); payload.theme_show_language_toggle = showLanguageToggle;
payload.theme_show_dark_mode_login = String(showDarkModeLogin); payload.theme_show_dark_mode_login = showDarkModeLogin;
payload.theme_show_language_login = String(showLanguageLogin); payload.theme_show_language_login = showLanguageLogin;
} }
if (Object.keys(payload).length === 0) return; if (Object.keys(payload).length === 0) return;
@@ -711,16 +719,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
} }
}, [accentColor, borderRadius, sidebarStyle, density, fontFamily, colorPalette, customColors, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle, showDarkModeLogin, showLanguageLogin]); }, [accentColor, borderRadius, sidebarStyle, density, fontFamily, colorPalette, customColors, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle, showDarkModeLogin, showLanguageLogin]);
// Auto-load theme from backend when token exists // Load theme settings for both authenticated and unauthenticated users
useEffect(() => { useEffect(() => {
if (token) {
setHasInitializedSettings(false);
loadThemeFromBackend(); loadThemeFromBackend();
} else { }, [loadThemeFromBackend]);
setIsLoadingSettings(false);
setHasInitializedSettings(true);
}
}, [token, loadThemeFromBackend]);
const toggleTheme = () => { const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light')); setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
@@ -750,10 +752,6 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
setColorPaletteState(palette); setColorPaletteState(palette);
}; };
const setCustomColors = (colors: Partial<PaletteColors>) => {
setCustomColorsState(colors);
};
const setDarkModeLocation = (location: ControlLocation) => { const setDarkModeLocation = (location: ControlLocation) => {
setDarkModeLocationState(location); setDarkModeLocationState(location);
}; };
@@ -804,7 +802,6 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
setDensity, setDensity,
setFontFamily, setFontFamily,
setColorPalette, setColorPalette,
setCustomColors,
setDarkModeLocation, setDarkModeLocation,
setLanguageLocation, setLanguageLocation,
setShowDarkModeToggle, setShowDarkModeToggle,
@@ -813,6 +810,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
setShowLanguageLogin, setShowLanguageLogin,
loadThemeFromBackend, loadThemeFromBackend,
saveThemeToBackend, saveThemeToBackend,
setCustomColors: setCustomColorsState,
}} }}
> >
{children} {children}

View File

@@ -1,6 +1,7 @@
import { createContext, useContext, useState, useEffect } from 'react'; import { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useAuth } from './AuthContext'; import { useAuth } from './AuthContext';
import { settingsAPI } from '../api/client';
type ViewMode = 'admin' | 'user'; type ViewMode = 'admin' | 'user';
@@ -18,8 +19,19 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
const { token } = useAuth(); const { token } = useAuth();
// viewMode is user preference - stored in localStorage // viewMode is user preference - stored in localStorage
const [viewMode, setViewModeState] = useState<ViewMode>(() => { const [viewMode, setViewModeState] = useState<ViewMode>(() => {
const saved = localStorage.getItem('viewMode'); const key = 'user_view_mode';
return (saved as ViewMode) || 'admin'; const saved = localStorage.getItem(key);
if (saved === 'admin' || saved === 'user') return saved;
// Backward-compatibility: migrate legacy key
const legacy = localStorage.getItem('viewMode');
if (legacy === 'admin' || legacy === 'user') {
localStorage.setItem(key, legacy);
localStorage.removeItem('viewMode');
return legacy;
}
return 'admin';
}); });
// isUserModeEnabled is a GLOBAL setting - comes from database, default false // isUserModeEnabled is a GLOBAL setting - comes from database, default false
@@ -27,7 +39,7 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
const setViewMode = (mode: ViewMode) => { const setViewMode = (mode: ViewMode) => {
setViewModeState(mode); setViewModeState(mode);
localStorage.setItem('viewMode', mode); localStorage.setItem('user_view_mode', mode);
}; };
const toggleViewMode = () => { const toggleViewMode = () => {
@@ -36,6 +48,7 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
}; };
const setUserModeEnabled = async (enabled: boolean) => { const setUserModeEnabled = async (enabled: boolean) => {
const previous = isUserModeEnabled;
setUserModeEnabledState(enabled); setUserModeEnabledState(enabled);
// If disabling, reset to admin view // If disabling, reset to admin view
@@ -45,25 +58,30 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
// Save to backend (this is a global setting) // Save to backend (this is a global setting)
try { try {
if (token) { if (!token) return;
await fetch('/api/v1/settings/user_mode_enabled', { const persisted = await settingsAPI.updateUserModeEnabled(enabled);
method: 'PUT', setUserModeEnabledState(persisted);
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: enabled }),
});
}
} catch (error) { } catch (error) {
console.error('Failed to save user mode setting:', error); console.error('Failed to save user mode setting:', error);
setUserModeEnabledState(previous);
} }
}; };
useEffect(() => { useEffect(() => {
// Sync viewMode (user preference) with localStorage on mount // Sync viewMode (user preference) with localStorage on mount
const savedMode = localStorage.getItem('viewMode'); const key = 'user_view_mode';
if (savedMode) setViewModeState(savedMode as ViewMode); const savedMode = localStorage.getItem(key);
if (savedMode === 'admin' || savedMode === 'user') {
setViewModeState(savedMode);
return;
}
const legacy = localStorage.getItem('viewMode');
if (legacy === 'admin' || legacy === 'user') {
localStorage.setItem(key, legacy);
localStorage.removeItem('viewMode');
setViewModeState(legacy);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -76,19 +94,8 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
return; return;
} }
const response = await fetch('/api/v1/settings/user_mode_enabled', { const isEnabled = await settingsAPI.getUserModeEnabled();
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
if (data.value !== undefined) {
const val = data.value;
const isEnabled = val === true || val === 'true' || val === 'True';
setUserModeEnabledState(isEnabled); setUserModeEnabledState(isEnabled);
}
}
// If 404 or error, keep default (false)
} catch (error) { } catch (error) {
console.error('Failed to fetch user mode setting:', error); console.error('Failed to fetch user mode setting:', error);
// Keep default (false) // Keep default (false)

View File

@@ -8,11 +8,11 @@ import '../styles/AdminPanel.css';
type TabId = 'general' | 'users'; type TabId = 'general' | 'users';
export default function AdminPanel() { export default function AdminPanel({ initialTab = 'general' }: { initialTab?: TabId } = {}) {
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const { t } = useTranslation(); const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar(); const { toggleMobileMenu } = useSidebar();
const [activeTab, setActiveTab] = useState<TabId>('general'); const [activeTab, setActiveTab] = useState<TabId>(initialTab);
if (!currentUser?.is_superuser) { if (!currentUser?.is_superuser) {
return null; return null;

View File

@@ -0,0 +1,36 @@
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import '../styles/AdminPanel.css';
export default function Feature2() {
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
return (
<main className="main-content">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="material-symbols-outlined">download</span>
<span className="page-title-text">{t.features.feature2}</span>
</div>
</div>
</div>
<div className="page-content">
<div className="tab-content-placeholder">
<div className="placeholder-icon">
<span className="material-symbols-outlined">download</span>
</div>
<h3>{t.features.feature2}</h3>
<p>{t.features.comingSoon}</p>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,36 @@
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import '../styles/AdminPanel.css';
export default function Feature3() {
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
return (
<main className="main-content">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="material-symbols-outlined">cast</span>
<span className="page-title-text">{t.features.feature3}</span>
</div>
</div>
</div>
<div className="page-content">
<div className="tab-content-placeholder">
<div className="placeholder-icon">
<span className="material-symbols-outlined">cast</span>
</div>
<h3>{t.features.feature3}</h3>
<p>{t.features.comingSoon}</p>
</div>
</div>
</main>
);
}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useTranslation } from '../../contexts/LanguageContext'; import { useTranslation } from '../../contexts/LanguageContext';
import { useSidebar } from '../../contexts/SidebarContext'; import { useSidebar } from '../../contexts/SidebarContext';
import Sidebar from '../../components/Sidebar'; import Sidebar from '../../components/Sidebar';
import { settingsAPI } from '../../api/client';
import '../../styles/Settings.css'; import '../../styles/Settings.css';
interface Settings { interface Settings {
@@ -22,17 +23,8 @@ export default function Settings() {
const fetchSettings = async () => { const fetchSettings = async () => {
try { try {
const token = localStorage.getItem('token'); const data = await settingsAPI.getAllSettings();
const response = await fetch('/api/v1/settings', { setSettings(data as Settings);
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setSettings(data);
}
} catch (error) { } catch (error) {
console.error('Failed to fetch settings:', error); console.error('Failed to fetch settings:', error);
} finally { } finally {
@@ -43,23 +35,11 @@ export default function Settings() {
const updateSetting = async (key: string, value: any) => { const updateSetting = async (key: string, value: any) => {
setSaving(true); setSaving(true);
try { try {
const token = localStorage.getItem('token'); const updatedSetting = await settingsAPI.updateSetting(key, value);
const response = await fetch(`/api/v1/settings/${key}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value }),
});
if (response.ok) {
const updatedSetting = await response.json();
setSettings(prev => ({ setSettings(prev => ({
...prev, ...prev,
[key]: updatedSetting.value [key]: updatedSetting.value
})); }));
}
} catch (error) { } catch (error) {
console.error('Failed to update setting:', error); console.error('Failed to update setting:', error);
} finally { } finally {

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext'; import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext';
import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette } from '../../contexts/ThemeContext'; import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette } from '../../contexts/ThemeContext';
import { useTranslation } from '../../contexts/LanguageContext'; import { useTranslation } from '../../contexts/LanguageContext';
@@ -10,10 +10,13 @@ import '../../styles/ThemeSettings.css';
type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced'; type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced';
type ThemeMode = 'light' | 'dark';
type ColorProperty = keyof typeof COLOR_PALETTES.default.light;
type ColorPickerState = { type ColorPickerState = {
isOpen: boolean; isOpen: boolean;
theme: 'light' | 'dark'; theme: ThemeMode;
property: string; property: ColorProperty;
value: string; value: string;
} | null; } | null;
@@ -32,6 +35,8 @@ export default function ThemeSettings() {
setDensity, setDensity,
setFontFamily, setFontFamily,
setColorPalette, setColorPalette,
customColors,
setCustomColors,
saveThemeToBackend saveThemeToBackend
} = useTheme(); } = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -88,32 +93,25 @@ export default function ThemeSettings() {
saveThemeToBackend({ colorPalette: palette }).catch(console.error); saveThemeToBackend({ colorPalette: palette }).catch(console.error);
}; };
// Advanced color states
const [customColors, setCustomColors] = useState({
light: {
bgMain: '#ffffff',
bgCard: '#f9fafb',
bgElevated: '#ffffff',
textPrimary: '#111827',
textSecondary: '#6b7280',
border: '#e5e7eb',
sidebarBg: '#1f2937',
sidebarText: '#f9fafb'
},
dark: {
bgMain: '#0f172a',
bgCard: '#1e293b',
bgElevated: '#334155',
textPrimary: '#f1f5f9',
textSecondary: '#94a3b8',
border: '#334155',
sidebarBg: '#0c1222',
sidebarText: '#f9fafb'
}
});
// Color picker popup state // Color picker popup state
const [colorPickerState, setColorPickerState] = useState<ColorPickerState>(null); const [colorPickerState, setColorPickerState] = useState<ColorPickerState>(null);
const hasUserModifiedCustomColors = useRef(false);
const paletteColors = COLOR_PALETTES[colorPalette];
const effectiveColors = {
light: { ...paletteColors.light, ...(customColors.light ?? {}) },
dark: { ...paletteColors.dark, ...(customColors.dark ?? {}) },
};
useEffect(() => {
if (!isAdmin || !hasUserModifiedCustomColors.current) return;
const timeoutId = setTimeout(() => {
saveThemeToBackend({ customColors }).catch(console.error);
}, 300);
return () => clearTimeout(timeoutId);
}, [customColors, isAdmin, saveThemeToBackend]);
const colors: { id: AccentColor; label: string; value: string; description: string }[] = [ const colors: { id: AccentColor; label: string; value: string; description: string }[] = [
{ id: 'auto', label: t.theme.colors.auto, value: '#374151', description: t.theme.colors.autoDesc }, { id: 'auto', label: t.theme.colors.auto, value: '#374151', description: t.theme.colors.autoDesc },
@@ -177,8 +175,8 @@ export default function ThemeSettings() {
// Helper component for color control // Helper component for color control
const ColorControl = ({ theme, property, label, value }: { const ColorControl = ({ theme, property, label, value }: {
theme: 'light' | 'dark'; theme: ThemeMode;
property: string; property: ColorProperty;
label: string; label: string;
value: string value: string
}) => { }) => {
@@ -220,21 +218,35 @@ export default function ThemeSettings() {
}; };
// Color picker handlers // Color picker handlers
const handleColorChange = (theme: 'light' | 'dark', property: string, value: string) => { const handleColorChange = (theme: ThemeMode, property: ColorProperty, value: string) => {
setCustomColors(prev => ({ if (!isAdmin) return;
...prev,
[theme]: { hasUserModifiedCustomColors.current = true;
...prev[theme], const baseValue = paletteColors[theme][property];
[property]: value const normalizedValue = value.trim().toLowerCase();
const normalizedBase = baseValue.trim().toLowerCase();
setCustomColors((prev) => {
const next = { ...prev };
const themeOverrides = { ...(prev[theme] ?? {}) } as Record<string, string>;
if (normalizedValue === normalizedBase) {
delete themeOverrides[property];
} else {
themeOverrides[property] = value;
} }
}));
// Apply to CSS variables immediately if (Object.keys(themeOverrides).length === 0) {
const root = document.documentElement; delete next[theme];
const varName = `--color-${property.replace(/([A-Z])/g, '-$1').toLowerCase()}`; } else {
root.style.setProperty(varName, value); next[theme] = themeOverrides;
}
return next;
});
}; };
const handleHexInput = (theme: 'light' | 'dark', property: string, value: string) => { const handleHexInput = (theme: ThemeMode, property: ColorProperty, value: string) => {
// Validate hex color format // Validate hex color format
const hexRegex = /^#?[0-9A-Fa-f]{6}$/; const hexRegex = /^#?[0-9A-Fa-f]{6}$/;
const formattedValue = value.startsWith('#') ? value : `#${value}`; const formattedValue = value.startsWith('#') ? value : `#${value}`;
@@ -245,38 +257,9 @@ export default function ThemeSettings() {
}; };
const resetToDefaults = () => { const resetToDefaults = () => {
setCustomColors({ if (!isAdmin) return;
light: { hasUserModifiedCustomColors.current = true;
bgMain: '#ffffff', setCustomColors({});
bgCard: '#f9fafb',
bgElevated: '#ffffff',
textPrimary: '#111827',
textSecondary: '#6b7280',
border: '#e5e7eb',
sidebarBg: '#1f2937',
sidebarText: '#f9fafb'
},
dark: {
bgMain: '#0f172a',
bgCard: '#1e293b',
bgElevated: '#334155',
textPrimary: '#f1f5f9',
textSecondary: '#94a3b8',
border: '#334155',
sidebarBg: '#0c1222',
sidebarText: '#f9fafb'
}
});
// Reset CSS variables
const root = document.documentElement;
root.style.removeProperty('--color-bg-main');
root.style.removeProperty('--color-bg-card');
root.style.removeProperty('--color-bg-elevated');
root.style.removeProperty('--color-text-primary');
root.style.removeProperty('--color-text-secondary');
root.style.removeProperty('--color-border');
root.style.removeProperty('--color-sidebar-bg');
root.style.removeProperty('--color-sidebar-text');
}; };
return ( return (
@@ -619,49 +602,49 @@ export default function ThemeSettings() {
theme="light" theme="light"
property="bgMain" property="bgMain"
label={t.theme.background} label={t.theme.background}
value={customColors.light.bgMain} value={effectiveColors.light.bgMain}
/> />
<ColorControl <ColorControl
theme="light" theme="light"
property="bgCard" property="bgCard"
label={t.theme.backgroundCard} label={t.theme.backgroundCard}
value={customColors.light.bgCard} value={effectiveColors.light.bgCard}
/> />
<ColorControl <ColorControl
theme="light" theme="light"
property="bgElevated" property="bgElevated"
label={t.theme.backgroundElevated} label={t.theme.backgroundElevated}
value={customColors.light.bgElevated} value={effectiveColors.light.bgElevated}
/> />
<ColorControl <ColorControl
theme="light" theme="light"
property="textPrimary" property="textPrimary"
label={t.theme.textPrimary} label={t.theme.textPrimary}
value={customColors.light.textPrimary} value={effectiveColors.light.textPrimary}
/> />
<ColorControl <ColorControl
theme="light" theme="light"
property="textSecondary" property="textSecondary"
label={t.theme.textSecondary} label={t.theme.textSecondary}
value={customColors.light.textSecondary} value={effectiveColors.light.textSecondary}
/> />
<ColorControl <ColorControl
theme="light" theme="light"
property="border" property="border"
label={t.theme.border} label={t.theme.border}
value={customColors.light.border} value={effectiveColors.light.border}
/> />
<ColorControl <ColorControl
theme="light" theme="light"
property="sidebarBg" property="sidebarBg"
label={t.theme.sidebarBackground} label={t.theme.sidebarBackground}
value={customColors.light.sidebarBg} value={effectiveColors.light.sidebarBg}
/> />
<ColorControl <ColorControl
theme="light" theme="light"
property="sidebarText" property="sidebarText"
label={t.theme.sidebarText} label={t.theme.sidebarText}
value={customColors.light.sidebarText} value={effectiveColors.light.sidebarText}
/> />
</div> </div>
</div> </div>
@@ -674,49 +657,49 @@ export default function ThemeSettings() {
theme="dark" theme="dark"
property="bgMain" property="bgMain"
label={t.theme.background} label={t.theme.background}
value={customColors.dark.bgMain} value={effectiveColors.dark.bgMain}
/> />
<ColorControl <ColorControl
theme="dark" theme="dark"
property="bgCard" property="bgCard"
label={t.theme.backgroundCard} label={t.theme.backgroundCard}
value={customColors.dark.bgCard} value={effectiveColors.dark.bgCard}
/> />
<ColorControl <ColorControl
theme="dark" theme="dark"
property="bgElevated" property="bgElevated"
label={t.theme.backgroundElevated} label={t.theme.backgroundElevated}
value={customColors.dark.bgElevated} value={effectiveColors.dark.bgElevated}
/> />
<ColorControl <ColorControl
theme="dark" theme="dark"
property="textPrimary" property="textPrimary"
label={t.theme.textPrimary} label={t.theme.textPrimary}
value={customColors.dark.textPrimary} value={effectiveColors.dark.textPrimary}
/> />
<ColorControl <ColorControl
theme="dark" theme="dark"
property="textSecondary" property="textSecondary"
label={t.theme.textSecondary} label={t.theme.textSecondary}
value={customColors.dark.textSecondary} value={effectiveColors.dark.textSecondary}
/> />
<ColorControl <ColorControl
theme="dark" theme="dark"
property="border" property="border"
label={t.theme.border} label={t.theme.border}
value={customColors.dark.border} value={effectiveColors.dark.border}
/> />
<ColorControl <ColorControl
theme="dark" theme="dark"
property="sidebarBg" property="sidebarBg"
label={t.theme.sidebarBackground} label={t.theme.sidebarBackground}
value={customColors.dark.sidebarBg} value={effectiveColors.dark.sidebarBg}
/> />
<ColorControl <ColorControl
theme="dark" theme="dark"
property="sidebarText" property="sidebarText"
label={t.theme.sidebarText} label={t.theme.sidebarText}
value={customColors.dark.sidebarText} value={effectiveColors.dark.sidebarText}
/> />
</div> </div>
</div> </div>

View File

@@ -38,7 +38,7 @@ export interface UserUpdatePayload {
password?: string; password?: string;
is_active?: boolean; is_active?: boolean;
is_superuser?: boolean; is_superuser?: boolean;
permissions?: UserPermissions; permissions?: UserPermissions | null;
} }
export interface Token { export interface Token {