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:
37
.dockerignore
Normal file
37
.dockerignore
Normal 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
1
.gitignore
vendored
@@ -44,7 +44,6 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.pnpm-store/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ WORKDIR /frontend
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# 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/ .
|
||||
|
||||
3
Makefile
3
Makefile
@@ -33,5 +33,4 @@ build:
|
||||
docker-compose build --no-cache
|
||||
|
||||
shell:
|
||||
docker-compose exec app /bin/bash
|
||||
|
||||
docker-compose exec app /bin/sh
|
||||
|
||||
@@ -12,11 +12,10 @@
|
||||
## Technology Stack
|
||||
|
||||
### Frontend
|
||||
- React 18 + TypeScript
|
||||
- React 19 + TypeScript
|
||||
- Vite (build tool)
|
||||
- Tailwind CSS + shadcn/ui
|
||||
- React Query (TanStack Query)
|
||||
- Zustand (state management)
|
||||
- React Router
|
||||
- Axios
|
||||
|
||||
### Backend
|
||||
- FastAPI (Python 3.11+)
|
||||
@@ -100,4 +99,3 @@
|
||||
## Contributing
|
||||
|
||||
Feel free to fork and customize for your needs.
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.config import settings
|
||||
|
||||
# Import all models so Alembic can detect them for auto-generating migrations
|
||||
from app.models.user import User # noqa
|
||||
from app.models.settings import Settings # noqa
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
|
||||
@@ -33,12 +33,17 @@ def register(
|
||||
|
||||
# If not the first user, check if registration is enabled
|
||||
if not is_first_user:
|
||||
registration_enabled = crud.settings.get_setting_value(
|
||||
registration_enabled_raw = crud.settings.get_setting_value(
|
||||
db,
|
||||
key="registration_enabled",
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -146,10 +151,15 @@ def get_registration_status(
|
||||
This is a public endpoint that doesn't require authentication.
|
||||
Returns the registration_enabled setting value.
|
||||
"""
|
||||
registration_enabled = crud.settings.get_setting_value(
|
||||
registration_enabled_raw = crud.settings.get_setting_value(
|
||||
db,
|
||||
key="registration_enabled",
|
||||
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}
|
||||
|
||||
@@ -12,7 +12,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Settings endpoints."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -12,6 +13,8 @@ from app.core.settings_registry import (
|
||||
MODULE_KEYS,
|
||||
UI_KEYS,
|
||||
SETTINGS_REGISTRY,
|
||||
SettingStorage,
|
||||
SettingType,
|
||||
get_default_value,
|
||||
)
|
||||
|
||||
@@ -19,6 +22,107 @@ from app.core.settings_registry import (
|
||||
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])
|
||||
def get_theme_settings(
|
||||
*,
|
||||
@@ -41,6 +145,26 @@ def get_theme_settings(
|
||||
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])
|
||||
def update_theme_settings(
|
||||
*,
|
||||
@@ -53,10 +177,11 @@ def update_theme_settings(
|
||||
|
||||
Updates multiple theme settings at once.
|
||||
"""
|
||||
_validate_bulk_keys(theme_data, set(THEME_KEYS))
|
||||
result = {}
|
||||
for key, value in theme_data.items():
|
||||
if key in THEME_KEYS or key.startswith("theme_"):
|
||||
setting = crud.settings.update_setting(db, key=key, value=value)
|
||||
coerced = _coerce_setting_value(key, value)
|
||||
setting = crud.settings.update_setting(db, key=key, value=coerced)
|
||||
result[key] = setting.get_value()
|
||||
return result
|
||||
|
||||
@@ -116,10 +241,11 @@ def update_ui_settings(
|
||||
|
||||
Updates multiple UI/layout settings at once.
|
||||
"""
|
||||
_validate_bulk_keys(ui_data, set(UI_KEYS))
|
||||
result = {}
|
||||
for key, value in ui_data.items():
|
||||
if key in UI_KEYS:
|
||||
setting = crud.settings.update_setting(db, key=key, value=value)
|
||||
coerced = _coerce_setting_value(key, value)
|
||||
setting = crud.settings.update_setting(db, key=key, value=coerced)
|
||||
result[key] = setting.get_value()
|
||||
return result
|
||||
|
||||
@@ -136,10 +262,11 @@ def update_module_settings(
|
||||
|
||||
Enable or disable modules for all users.
|
||||
"""
|
||||
_validate_bulk_keys(module_data, set(MODULE_KEYS))
|
||||
result = {}
|
||||
for key, value in module_data.items():
|
||||
if key in MODULE_KEYS or key.startswith("module_"):
|
||||
setting = crud.settings.update_setting(db, key=key, value=value)
|
||||
coerced = _coerce_setting_value(key, value)
|
||||
setting = crud.settings.update_setting(db, key=key, value=coerced)
|
||||
result[key] = setting.get_value()
|
||||
return result
|
||||
|
||||
@@ -171,7 +298,8 @@ def update_user_mode_enabled(
|
||||
Update user mode enabled setting (admin only).
|
||||
"""
|
||||
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()}
|
||||
|
||||
|
||||
@@ -222,5 +350,6 @@ def update_setting(
|
||||
|
||||
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)
|
||||
|
||||
@@ -88,10 +88,14 @@ def update_user(
|
||||
|
||||
# Only superusers can change is_superuser and is_active
|
||||
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(
|
||||
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)
|
||||
|
||||
@@ -328,15 +328,16 @@ register_setting(SettingDefinition(
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# USER-SPECIFIC SETTINGS (Per-user, Database)
|
||||
# These settings can be different for each user
|
||||
# USER-SPECIFIC SETTINGS (Frontend localStorage)
|
||||
# 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(
|
||||
key="user_theme_mode",
|
||||
type=SettingType.STRING,
|
||||
scope=SettingScope.USER_SPECIFIC,
|
||||
storage=SettingStorage.DATABASE,
|
||||
storage=SettingStorage.LOCAL_STORAGE,
|
||||
default="system",
|
||||
description="User's preferred theme mode (light/dark/system)",
|
||||
category="user_preferences",
|
||||
@@ -348,7 +349,7 @@ register_setting(SettingDefinition(
|
||||
key="user_language",
|
||||
type=SettingType.STRING,
|
||||
scope=SettingScope.USER_SPECIFIC,
|
||||
storage=SettingStorage.DATABASE,
|
||||
storage=SettingStorage.LOCAL_STORAGE,
|
||||
default="en",
|
||||
description="User's preferred language",
|
||||
category="user_preferences",
|
||||
@@ -360,7 +361,7 @@ register_setting(SettingDefinition(
|
||||
key="user_sidebar_collapsed",
|
||||
type=SettingType.BOOLEAN,
|
||||
scope=SettingScope.USER_SPECIFIC,
|
||||
storage=SettingStorage.DATABASE,
|
||||
storage=SettingStorage.LOCAL_STORAGE,
|
||||
default=False,
|
||||
description="User's sidebar collapsed state preference",
|
||||
category="user_preferences",
|
||||
@@ -371,7 +372,7 @@ register_setting(SettingDefinition(
|
||||
key="user_view_mode",
|
||||
type=SettingType.STRING,
|
||||
scope=SettingScope.USER_SPECIFIC,
|
||||
storage=SettingStorage.DATABASE,
|
||||
storage=SettingStorage.LOCAL_STORAGE,
|
||||
default="admin",
|
||||
description="User's current view mode (admin/user)",
|
||||
category="user_preferences",
|
||||
|
||||
@@ -71,7 +71,7 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
|
||||
|
||||
def delete(self, db: Session, *, id: Any) -> ModelType:
|
||||
"""Delete a record."""
|
||||
obj = db.query(self.model).get(id)
|
||||
obj = db.get(self.model, id)
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
return obj
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""CRUD operations for Settings."""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -27,13 +28,16 @@ def update_setting(db: Session, key: str, value: any) -> Settings:
|
||||
setting = Settings(key=key)
|
||||
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):
|
||||
setting.value_bool = value
|
||||
setting.value_str = None
|
||||
elif isinstance(value, str) and value.lower() in ('true', 'false'):
|
||||
setting.value_bool = value.lower() == 'true'
|
||||
elif isinstance(value, str) and value.lower() in ("true", "false", "1", "0"):
|
||||
setting.value_bool = value.lower() in ("true", "1")
|
||||
setting.value_str = None
|
||||
elif isinstance(value, (dict, list)):
|
||||
setting.value_str = json.dumps(value)
|
||||
setting.value_bool = None
|
||||
else:
|
||||
setting.value_str = str(value)
|
||||
setting.value_bool = None
|
||||
|
||||
@@ -138,16 +138,17 @@ async def startup_event():
|
||||
# Seed default settings from registry
|
||||
from sqlalchemy.orm import Session
|
||||
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:
|
||||
# Get all database settings with defaults from registry
|
||||
default_settings = get_database_defaults()
|
||||
# Seed only GLOBAL database settings with defaults from registry
|
||||
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, key) is None:
|
||||
logger.info(f"Seeding default setting: {key}={value}")
|
||||
crud.settings.update_setting(db, key, value)
|
||||
if crud.settings.get_setting(db, setting_def.key) is None:
|
||||
logger.info(f"Seeding default setting: {setting_def.key}={setting_def.default}")
|
||||
crud.settings.update_setting(db, setting_def.key, setting_def.default)
|
||||
|
||||
|
||||
# Shutdown event
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
ports:
|
||||
- "5174:8000"
|
||||
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
|
||||
timeout: 10s
|
||||
start_period: 40s
|
||||
|
||||
3676
frontend/package-lock.json
generated
Normal file
3676
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,12 @@ import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { SidebarProvider } from './contexts/SidebarContext';
|
||||
import { ViewModeProvider } from './contexts/ViewModeContext';
|
||||
import { ModulesProvider } from './contexts/ModulesContext';
|
||||
import MainLayout from './components/MainLayout';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Feature1 from './pages/Feature1';
|
||||
import Feature2 from './pages/Feature2';
|
||||
import Feature3 from './pages/Feature3';
|
||||
import AdminPanel from './pages/AdminPanel';
|
||||
import Sources from './pages/admin/Sources';
|
||||
import Features from './pages/admin/Features';
|
||||
@@ -41,10 +44,6 @@ function AdminRoute({ children }: { children: ReactElement }) {
|
||||
return user.is_superuser ? children : <Navigate to="/dashboard" />;
|
||||
}
|
||||
|
||||
import MainLayout from './components/MainLayout';
|
||||
|
||||
// ...
|
||||
|
||||
function AppRoutes() {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
@@ -59,15 +58,19 @@ function AppRoutes() {
|
||||
<Route element={<PrivateRoute><MainLayout /></PrivateRoute>}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/feature1" element={<Feature1 />} />
|
||||
<Route path="/feature2" element={<Feature2 />} />
|
||||
<Route path="/feature3" element={<Feature3 />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
|
||||
<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/features" element={<AdminRoute><Features /></AdminRoute>} />
|
||||
<Route path="/admin/theme" element={<AdminRoute><ThemeSettings /></AdminRoute>} />
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to={user ? '/dashboard' : '/login'} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,13 +44,18 @@ export const authAPI = {
|
||||
|
||||
// Settings endpoints
|
||||
export const settingsAPI = {
|
||||
getTheme: async (): Promise<Record<string, string>> => {
|
||||
const response = await api.get<Record<string, string>>('/settings/theme');
|
||||
getTheme: async (): Promise<Record<string, unknown>> => {
|
||||
const response = await api.get<Record<string, unknown>>('/settings/theme');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTheme: async (data: Record<string, string>): Promise<Record<string, string>> => {
|
||||
const response = await api.put<Record<string, string>>('/settings/theme', data);
|
||||
getThemePublic: async (): Promise<Record<string, unknown>> => {
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -63,6 +68,37 @@ export const settingsAPI = {
|
||||
const response = await api.put<Record<string, boolean>>('/settings/modules', 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
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useViewMode } from '../../contexts/ViewModeContext';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
||||
import { useSidebar } from '../../contexts/SidebarContext';
|
||||
import { settingsAPI } from '../../api/client';
|
||||
|
||||
export default function GeneralTab() {
|
||||
const { t } = useTranslation();
|
||||
@@ -32,11 +33,7 @@ export default function GeneralTab() {
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/settings', {
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const data = await settingsAPI.getAllSettings();
|
||||
|
||||
// Helper to check for true values (bool, string 'true'/'True', 1)
|
||||
const isTrue = (val: any) => {
|
||||
@@ -56,7 +53,6 @@ export default function GeneralTab() {
|
||||
if (data.show_logo !== undefined) {
|
||||
setShowLogo(isTrue(data.show_logo));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
}
|
||||
@@ -68,22 +64,9 @@ export default function GeneralTab() {
|
||||
setRegistrationEnabled(checked); // Optimistic update
|
||||
setSavingRegistration(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/settings/registration_enabled', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'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);
|
||||
}
|
||||
const updated = await settingsAPI.updateSetting('registration_enabled', checked);
|
||||
const value = updated.value;
|
||||
setRegistrationEnabled(value === true || value === 'true' || value === 'True' || value === 1 || value === '1');
|
||||
} catch (error) {
|
||||
console.error('Failed to update registration setting:', error);
|
||||
setRegistrationEnabled(!checked);
|
||||
@@ -96,22 +79,9 @@ export default function GeneralTab() {
|
||||
setShowLogo(checked); // Optimistic update
|
||||
setSavingShowLogo(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/settings/show_logo', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'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);
|
||||
}
|
||||
const updated = await settingsAPI.updateSetting('show_logo', checked);
|
||||
const value = updated.value;
|
||||
setShowLogo(value === true || value === 'true' || value === 'True' || value === 1 || value === '1');
|
||||
} catch (error) {
|
||||
console.error('Failed to update show logo setting:', error);
|
||||
setShowLogo(!checked);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
import { settingsAPI } from '../../api/client';
|
||||
|
||||
interface Settings {
|
||||
registration_enabled?: boolean;
|
||||
@@ -17,17 +18,8 @@ export default function SettingsTab() {
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch('/api/v1/settings', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSettings(data);
|
||||
}
|
||||
const data = await settingsAPI.getAllSettings();
|
||||
setSettings(data as Settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
} finally {
|
||||
@@ -38,23 +30,11 @@ export default function SettingsTab() {
|
||||
const updateSetting = async (key: string, value: any) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
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();
|
||||
const updatedSetting = await settingsAPI.updateSetting(key, value);
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
[key]: updatedSetting.value
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update setting:', error);
|
||||
} finally {
|
||||
|
||||
@@ -200,8 +200,8 @@ export default function UsersTab() {
|
||||
email: formData.email,
|
||||
is_active: formData.is_active,
|
||||
is_superuser: formData.is_superuser,
|
||||
// Only send permissions if custom permissions are enabled, otherwise send empty object (inherit global)
|
||||
permissions: formData.hasCustomPermissions ? formData.permissions : {},
|
||||
// If custom permissions are disabled, clear permissions (inherit from global module settings)
|
||||
permissions: formData.hasCustomPermissions ? formData.permissions : null,
|
||||
};
|
||||
|
||||
if (formData.password.trim()) {
|
||||
@@ -760,7 +760,8 @@ export default function UsersTab() {
|
||||
{formData.hasCustomPermissions ? (
|
||||
<div className="permissions-grid">
|
||||
{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 moduleName = t.sidebar[module.id as keyof typeof t.sidebar] || module.id;
|
||||
|
||||
|
||||
@@ -21,8 +21,17 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguageState] = useState<Language>(() => {
|
||||
const saved = localStorage.getItem('language');
|
||||
if (saved) return saved as Language;
|
||||
const key = 'user_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
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
@@ -31,7 +40,7 @@ export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const setLanguage = (lang: Language) => {
|
||||
setLanguageState(lang);
|
||||
localStorage.setItem('language', lang);
|
||||
localStorage.setItem('user_language', lang);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { settingsAPI } from '../api/client';
|
||||
|
||||
export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic';
|
||||
|
||||
@@ -23,7 +24,22 @@ const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
||||
|
||||
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||
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 [sidebarMode, setSidebarModeState] = useState<SidebarMode>('toggle');
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -39,11 +55,7 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/settings/ui', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const data = await settingsAPI.getUi();
|
||||
const parseBool = (val: any, defaultVal: boolean): boolean => {
|
||||
if (val === undefined || val === null) return defaultVal;
|
||||
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;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
if (data.show_logo !== undefined) {
|
||||
setShowLogo(parseBool(data.show_logo, false));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load sidebar mode:', error);
|
||||
}
|
||||
@@ -76,7 +87,11 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const toggleCollapse = () => {
|
||||
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) => {
|
||||
try {
|
||||
if (!token) return;
|
||||
const response = await fetch('/api/v1/settings/sidebar_mode', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ value: mode }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await settingsAPI.updateSetting('sidebar_mode', mode);
|
||||
setSidebarModeState(mode);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save sidebar mode:', error);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useAuth } from './AuthContext';
|
||||
|
||||
@@ -21,7 +21,7 @@ interface ThemeContextType {
|
||||
density: Density;
|
||||
fontFamily: FontFamily;
|
||||
colorPalette: ColorPalette;
|
||||
customColors: Partial<PaletteColors>;
|
||||
customColors: CustomColors;
|
||||
darkModeLocation: ControlLocation;
|
||||
languageLocation: ControlLocation;
|
||||
showDarkModeToggle: boolean;
|
||||
@@ -37,7 +37,7 @@ interface ThemeContextType {
|
||||
setDensity: (density: Density) => void;
|
||||
setFontFamily: (font: FontFamily) => void;
|
||||
setColorPalette: (palette: ColorPalette) => void;
|
||||
setCustomColors: (colors: Partial<PaletteColors>) => void;
|
||||
setCustomColors: Dispatch<SetStateAction<CustomColors>>;
|
||||
setDarkModeLocation: (location: ControlLocation) => void;
|
||||
setLanguageLocation: (location: ControlLocation) => void;
|
||||
setShowDarkModeToggle: (show: boolean) => void;
|
||||
@@ -52,7 +52,7 @@ interface ThemeContextType {
|
||||
density: Density;
|
||||
fontFamily: FontFamily;
|
||||
colorPalette: ColorPalette;
|
||||
customColors: Partial<PaletteColors>;
|
||||
customColors: CustomColors;
|
||||
darkModeLocation: ControlLocation;
|
||||
languageLocation: ControlLocation;
|
||||
showDarkModeToggle: boolean;
|
||||
@@ -162,7 +162,7 @@ const FONTS: Record<FontFamily, string> = {
|
||||
roboto: '"Roboto", sans-serif',
|
||||
};
|
||||
|
||||
interface PaletteColors {
|
||||
export interface PaletteColors {
|
||||
light: {
|
||||
bgMain: 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> = {
|
||||
default: {
|
||||
light: {
|
||||
@@ -468,7 +473,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [density, setDensityState] = useState<Density>('compact');
|
||||
const [fontFamily, setFontFamilyState] = useState<FontFamily>('sans');
|
||||
const [colorPalette, setColorPaletteState] = useState<ColorPalette>('monochrome');
|
||||
const [customColors, setCustomColorsState] = useState<Partial<PaletteColors>>({});
|
||||
const [customColors, setCustomColorsState] = useState<CustomColors>({});
|
||||
const [darkModeLocation, setDarkModeLocationState] = useState<ControlLocation>('sidebar');
|
||||
const [languageLocation, setLanguageLocationState] = useState<ControlLocation>('sidebar');
|
||||
const [showDarkModeToggle, setShowDarkModeToggleState] = useState<boolean>(true);
|
||||
@@ -593,8 +598,11 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
// Load theme from backend when user is authenticated
|
||||
const loadThemeFromBackend = useCallback(async () => {
|
||||
setIsLoadingSettings(true);
|
||||
setHasInitializedSettings(false);
|
||||
try {
|
||||
const themeData = await settingsAPI.getTheme();
|
||||
const themeData = token
|
||||
? await settingsAPI.getTheme()
|
||||
: await settingsAPI.getThemePublic();
|
||||
if (themeData.theme_accent_color) {
|
||||
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'
|
||||
? JSON.parse(themeData.theme_custom_colors)
|
||||
: themeData.theme_custom_colors;
|
||||
setCustomColorsState(parsed as Partial<PaletteColors>);
|
||||
setCustomColorsState(parsed as CustomColors);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse custom colors:', e);
|
||||
}
|
||||
@@ -651,7 +659,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
setIsLoadingSettings(false);
|
||||
setHasInitializedSettings(true);
|
||||
}
|
||||
}, []);
|
||||
}, [token]);
|
||||
|
||||
// Save theme to backend (admin only) - accepts optional overrides for immediate save
|
||||
const saveThemeToBackend = useCallback(async (overrides?: Partial<{
|
||||
@@ -661,7 +669,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
density: Density;
|
||||
fontFamily: FontFamily;
|
||||
colorPalette: ColorPalette;
|
||||
customColors: Partial<PaletteColors>;
|
||||
customColors: CustomColors;
|
||||
darkModeLocation: ControlLocation;
|
||||
languageLocation: ControlLocation;
|
||||
showDarkModeToggle: boolean;
|
||||
@@ -670,7 +678,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
showLanguageLogin: boolean;
|
||||
}>) => {
|
||||
try {
|
||||
const payload: Record<string, string> = {};
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
if (overrides) {
|
||||
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.fontFamily !== undefined) payload.theme_font_family = overrides.fontFamily;
|
||||
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.languageLocation !== undefined) payload.theme_language_location = overrides.languageLocation;
|
||||
if (overrides.showDarkModeToggle !== undefined) payload.theme_show_dark_mode_toggle = String(overrides.showDarkModeToggle);
|
||||
if (overrides.showLanguageToggle !== undefined) payload.theme_show_language_toggle = String(overrides.showLanguageToggle);
|
||||
if (overrides.showDarkModeLogin !== undefined) payload.theme_show_dark_mode_login = String(overrides.showDarkModeLogin);
|
||||
if (overrides.showLanguageLogin !== undefined) payload.theme_show_language_login = String(overrides.showLanguageLogin);
|
||||
if (overrides.showDarkModeToggle !== undefined) payload.theme_show_dark_mode_toggle = overrides.showDarkModeToggle;
|
||||
if (overrides.showLanguageToggle !== undefined) payload.theme_show_language_toggle = overrides.showLanguageToggle;
|
||||
if (overrides.showDarkModeLogin !== undefined) payload.theme_show_dark_mode_login = overrides.showDarkModeLogin;
|
||||
if (overrides.showLanguageLogin !== undefined) payload.theme_show_language_login = overrides.showLanguageLogin;
|
||||
} else {
|
||||
payload.theme_accent_color = accentColor;
|
||||
payload.theme_border_radius = borderRadius;
|
||||
@@ -693,13 +701,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
payload.theme_density = density;
|
||||
payload.theme_font_family = fontFamily;
|
||||
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_language_location = languageLocation;
|
||||
payload.theme_show_dark_mode_toggle = String(showDarkModeToggle);
|
||||
payload.theme_show_language_toggle = String(showLanguageToggle);
|
||||
payload.theme_show_dark_mode_login = String(showDarkModeLogin);
|
||||
payload.theme_show_language_login = String(showLanguageLogin);
|
||||
payload.theme_show_dark_mode_toggle = showDarkModeToggle;
|
||||
payload.theme_show_language_toggle = showLanguageToggle;
|
||||
payload.theme_show_dark_mode_login = showDarkModeLogin;
|
||||
payload.theme_show_language_login = showLanguageLogin;
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
// Auto-load theme from backend when token exists
|
||||
// Load theme settings for both authenticated and unauthenticated users
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
setHasInitializedSettings(false);
|
||||
loadThemeFromBackend();
|
||||
} else {
|
||||
setIsLoadingSettings(false);
|
||||
setHasInitializedSettings(true);
|
||||
}
|
||||
}, [token, loadThemeFromBackend]);
|
||||
}, [loadThemeFromBackend]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
|
||||
@@ -750,10 +752,6 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
setColorPaletteState(palette);
|
||||
};
|
||||
|
||||
const setCustomColors = (colors: Partial<PaletteColors>) => {
|
||||
setCustomColorsState(colors);
|
||||
};
|
||||
|
||||
const setDarkModeLocation = (location: ControlLocation) => {
|
||||
setDarkModeLocationState(location);
|
||||
};
|
||||
@@ -804,7 +802,6 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
setDensity,
|
||||
setFontFamily,
|
||||
setColorPalette,
|
||||
setCustomColors,
|
||||
setDarkModeLocation,
|
||||
setLanguageLocation,
|
||||
setShowDarkModeToggle,
|
||||
@@ -813,6 +810,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
setShowLanguageLogin,
|
||||
loadThemeFromBackend,
|
||||
saveThemeToBackend,
|
||||
setCustomColors: setCustomColorsState,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { settingsAPI } from '../api/client';
|
||||
|
||||
type ViewMode = 'admin' | 'user';
|
||||
|
||||
@@ -18,8 +19,19 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
|
||||
const { token } = useAuth();
|
||||
// viewMode is user preference - stored in localStorage
|
||||
const [viewMode, setViewModeState] = useState<ViewMode>(() => {
|
||||
const saved = localStorage.getItem('viewMode');
|
||||
return (saved as ViewMode) || 'admin';
|
||||
const key = 'user_view_mode';
|
||||
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
|
||||
@@ -27,7 +39,7 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const setViewMode = (mode: ViewMode) => {
|
||||
setViewModeState(mode);
|
||||
localStorage.setItem('viewMode', mode);
|
||||
localStorage.setItem('user_view_mode', mode);
|
||||
};
|
||||
|
||||
const toggleViewMode = () => {
|
||||
@@ -36,6 +48,7 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
const setUserModeEnabled = async (enabled: boolean) => {
|
||||
const previous = isUserModeEnabled;
|
||||
setUserModeEnabledState(enabled);
|
||||
|
||||
// If disabling, reset to admin view
|
||||
@@ -45,25 +58,30 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Save to backend (this is a global setting)
|
||||
try {
|
||||
if (token) {
|
||||
await fetch('/api/v1/settings/user_mode_enabled', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ value: enabled }),
|
||||
});
|
||||
}
|
||||
if (!token) return;
|
||||
const persisted = await settingsAPI.updateUserModeEnabled(enabled);
|
||||
setUserModeEnabledState(persisted);
|
||||
} catch (error) {
|
||||
console.error('Failed to save user mode setting:', error);
|
||||
setUserModeEnabledState(previous);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Sync viewMode (user preference) with localStorage on mount
|
||||
const savedMode = localStorage.getItem('viewMode');
|
||||
if (savedMode) setViewModeState(savedMode as ViewMode);
|
||||
const key = 'user_view_mode';
|
||||
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(() => {
|
||||
@@ -76,19 +94,8 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/settings/user_mode_enabled', {
|
||||
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';
|
||||
const isEnabled = await settingsAPI.getUserModeEnabled();
|
||||
setUserModeEnabledState(isEnabled);
|
||||
}
|
||||
}
|
||||
// If 404 or error, keep default (false)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user mode setting:', error);
|
||||
// Keep default (false)
|
||||
|
||||
@@ -8,11 +8,11 @@ import '../styles/AdminPanel.css';
|
||||
|
||||
type TabId = 'general' | 'users';
|
||||
|
||||
export default function AdminPanel() {
|
||||
export default function AdminPanel({ initialTab = 'general' }: { initialTab?: TabId } = {}) {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const { toggleMobileMenu } = useSidebar();
|
||||
const [activeTab, setActiveTab] = useState<TabId>('general');
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
|
||||
|
||||
if (!currentUser?.is_superuser) {
|
||||
return null;
|
||||
|
||||
36
frontend/src/pages/Feature2.tsx
Normal file
36
frontend/src/pages/Feature2.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
36
frontend/src/pages/Feature3.tsx
Normal file
36
frontend/src/pages/Feature3.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
import { useSidebar } from '../../contexts/SidebarContext';
|
||||
import Sidebar from '../../components/Sidebar';
|
||||
import { settingsAPI } from '../../api/client';
|
||||
import '../../styles/Settings.css';
|
||||
|
||||
interface Settings {
|
||||
@@ -22,17 +23,8 @@ export default function Settings() {
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch('/api/v1/settings', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSettings(data);
|
||||
}
|
||||
const data = await settingsAPI.getAllSettings();
|
||||
setSettings(data as Settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
} finally {
|
||||
@@ -43,23 +35,11 @@ export default function Settings() {
|
||||
const updateSetting = async (key: string, value: any) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
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();
|
||||
const updatedSetting = await settingsAPI.updateSetting(key, value);
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
[key]: updatedSetting.value
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update setting:', error);
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext';
|
||||
import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette } from '../../contexts/ThemeContext';
|
||||
import { useTranslation } from '../../contexts/LanguageContext';
|
||||
@@ -10,10 +10,13 @@ import '../../styles/ThemeSettings.css';
|
||||
|
||||
type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced';
|
||||
|
||||
type ThemeMode = 'light' | 'dark';
|
||||
type ColorProperty = keyof typeof COLOR_PALETTES.default.light;
|
||||
|
||||
type ColorPickerState = {
|
||||
isOpen: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
property: string;
|
||||
theme: ThemeMode;
|
||||
property: ColorProperty;
|
||||
value: string;
|
||||
} | null;
|
||||
|
||||
@@ -32,6 +35,8 @@ export default function ThemeSettings() {
|
||||
setDensity,
|
||||
setFontFamily,
|
||||
setColorPalette,
|
||||
customColors,
|
||||
setCustomColors,
|
||||
saveThemeToBackend
|
||||
} = useTheme();
|
||||
const { t } = useTranslation();
|
||||
@@ -88,32 +93,25 @@ export default function ThemeSettings() {
|
||||
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
|
||||
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 }[] = [
|
||||
{ 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
|
||||
const ColorControl = ({ theme, property, label, value }: {
|
||||
theme: 'light' | 'dark';
|
||||
property: string;
|
||||
theme: ThemeMode;
|
||||
property: ColorProperty;
|
||||
label: string;
|
||||
value: string
|
||||
}) => {
|
||||
@@ -220,21 +218,35 @@ export default function ThemeSettings() {
|
||||
};
|
||||
|
||||
// Color picker handlers
|
||||
const handleColorChange = (theme: 'light' | 'dark', property: string, value: string) => {
|
||||
setCustomColors(prev => ({
|
||||
...prev,
|
||||
[theme]: {
|
||||
...prev[theme],
|
||||
[property]: value
|
||||
const handleColorChange = (theme: ThemeMode, property: ColorProperty, value: string) => {
|
||||
if (!isAdmin) return;
|
||||
|
||||
hasUserModifiedCustomColors.current = true;
|
||||
const baseValue = paletteColors[theme][property];
|
||||
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
|
||||
const root = document.documentElement;
|
||||
const varName = `--color-${property.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
||||
root.style.setProperty(varName, value);
|
||||
|
||||
if (Object.keys(themeOverrides).length === 0) {
|
||||
delete next[theme];
|
||||
} else {
|
||||
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
|
||||
const hexRegex = /^#?[0-9A-Fa-f]{6}$/;
|
||||
const formattedValue = value.startsWith('#') ? value : `#${value}`;
|
||||
@@ -245,38 +257,9 @@ export default function ThemeSettings() {
|
||||
};
|
||||
|
||||
const resetToDefaults = () => {
|
||||
setCustomColors({
|
||||
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'
|
||||
}
|
||||
});
|
||||
// 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');
|
||||
if (!isAdmin) return;
|
||||
hasUserModifiedCustomColors.current = true;
|
||||
setCustomColors({});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -619,49 +602,49 @@ export default function ThemeSettings() {
|
||||
theme="light"
|
||||
property="bgMain"
|
||||
label={t.theme.background}
|
||||
value={customColors.light.bgMain}
|
||||
value={effectiveColors.light.bgMain}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="bgCard"
|
||||
label={t.theme.backgroundCard}
|
||||
value={customColors.light.bgCard}
|
||||
value={effectiveColors.light.bgCard}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="bgElevated"
|
||||
label={t.theme.backgroundElevated}
|
||||
value={customColors.light.bgElevated}
|
||||
value={effectiveColors.light.bgElevated}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="textPrimary"
|
||||
label={t.theme.textPrimary}
|
||||
value={customColors.light.textPrimary}
|
||||
value={effectiveColors.light.textPrimary}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="textSecondary"
|
||||
label={t.theme.textSecondary}
|
||||
value={customColors.light.textSecondary}
|
||||
value={effectiveColors.light.textSecondary}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="border"
|
||||
label={t.theme.border}
|
||||
value={customColors.light.border}
|
||||
value={effectiveColors.light.border}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="sidebarBg"
|
||||
label={t.theme.sidebarBackground}
|
||||
value={customColors.light.sidebarBg}
|
||||
value={effectiveColors.light.sidebarBg}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="light"
|
||||
property="sidebarText"
|
||||
label={t.theme.sidebarText}
|
||||
value={customColors.light.sidebarText}
|
||||
value={effectiveColors.light.sidebarText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -674,49 +657,49 @@ export default function ThemeSettings() {
|
||||
theme="dark"
|
||||
property="bgMain"
|
||||
label={t.theme.background}
|
||||
value={customColors.dark.bgMain}
|
||||
value={effectiveColors.dark.bgMain}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="bgCard"
|
||||
label={t.theme.backgroundCard}
|
||||
value={customColors.dark.bgCard}
|
||||
value={effectiveColors.dark.bgCard}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="bgElevated"
|
||||
label={t.theme.backgroundElevated}
|
||||
value={customColors.dark.bgElevated}
|
||||
value={effectiveColors.dark.bgElevated}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="textPrimary"
|
||||
label={t.theme.textPrimary}
|
||||
value={customColors.dark.textPrimary}
|
||||
value={effectiveColors.dark.textPrimary}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="textSecondary"
|
||||
label={t.theme.textSecondary}
|
||||
value={customColors.dark.textSecondary}
|
||||
value={effectiveColors.dark.textSecondary}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="border"
|
||||
label={t.theme.border}
|
||||
value={customColors.dark.border}
|
||||
value={effectiveColors.dark.border}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="sidebarBg"
|
||||
label={t.theme.sidebarBackground}
|
||||
value={customColors.dark.sidebarBg}
|
||||
value={effectiveColors.dark.sidebarBg}
|
||||
/>
|
||||
<ColorControl
|
||||
theme="dark"
|
||||
property="sidebarText"
|
||||
label={t.theme.sidebarText}
|
||||
value={customColors.dark.sidebarText}
|
||||
value={effectiveColors.dark.sidebarText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface UserUpdatePayload {
|
||||
password?: string;
|
||||
is_active?: boolean;
|
||||
is_superuser?: boolean;
|
||||
permissions?: UserPermissions;
|
||||
permissions?: UserPermissions | null;
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
|
||||
Reference in New Issue
Block a user