feat(5.2): add User Preferences API with full-stack implementation

Backend:
- Create app/models/user_preference.py with TenantMixin (user_id, key, value JSONB)
- Create app/routes/user_preferences.py with GET/PUT/DELETE endpoints + RBAC
- Add user_preferences:read/write to CORE_PERMISSIONS
- Add user_preferences to legacy role permissions (admin/editor/viewer)
- Register route in app/main.py and app/routes/__init__.py
- Create alembic migration 0028_user_preferences
- Add UserPreference model to conftest.py for test schema
- Fix pre-existing conftest seed (Contact industry field removed in migration 0027)

Frontend:
- Create frontend/src/api/userPreferences.ts with React Query hooks
- Create frontend/src/hooks/useUserPreferences.ts syncing with uiStore
- Add i18n entries for de.json and en.json

Tests:
- 13 tests covering CRUD, tenant isolation, CSRF, unauthenticated access
- All tests passing
This commit is contained in:
Agent Zero
2026-07-23 20:39:42 +02:00
parent a9151b1159
commit 75a7063bff
13 changed files with 762 additions and 4 deletions
+74
View File
@@ -0,0 +1,74 @@
/**
* User Preferences API — per-user UI settings stored server-side.
* Matches backend endpoints from /api/v1/user/preferences.
*
* Backend: app/routes/user_preferences.py, app/models/user_preference.py
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPut, apiDelete } from './client';
// ── Types ──
export interface PreferenceEntry {
key: string;
value: unknown;
updated_at?: string | null;
}
export interface PreferenceListResponse {
preferences: PreferenceEntry[];
}
// ── Hooks ──
export function useUserPreferences() {
return useQuery({
queryKey: ['userPreferences'],
queryFn: () =>
apiGet<PreferenceListResponse>('/user/preferences'),
});
}
export function useUserPreference(key: string) {
return useQuery({
queryKey: ['userPreferences', key],
queryFn: () =>
apiGet<PreferenceEntry>(`/user/preferences/${key}`),
enabled: !!key,
});
}
export function useUpsertUserPreference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
key,
value,
}: {
key: string;
value: unknown;
}) =>
apiPut<PreferenceEntry>(`/user/preferences/${key}`, {
value,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['userPreferences'],
});
},
});
}
export function useDeleteUserPreference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (key: string) =>
apiDelete(`/user/preferences/${key}`),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['userPreferences'],
});
},
});
}
+125
View File
@@ -0,0 +1,125 @@
/**
* useUserPreferences — loads user preferences from API and syncs with uiStore.
*
* On load: applies server-side preferences to the Zustand uiStore (theme, locale,
* sidebar state, AI sidebar tab, etc.).
* On change: persists uiStore state to the server via upsert mutation.
*/
import { useEffect, useCallback } from 'react';
import { useUIStore, type Theme, type Locale, type AISidebarTab } from '@/store/uiStore';
import {
useUserPreferences,
useUpsertUserPreference,
useDeleteUserPreference,
} from '@/api/userPreferences';
// Known preference keys that map to uiStore fields
const PREF_KEYS = {
THEME: 'theme',
LOCALE: 'locale',
SIDEBAR_OPEN: 'sidebar_open',
AI_SIDEBAR_COLLAPSED: 'ai_sidebar_collapsed',
AI_SIDEBAR_TAB: 'ai_sidebar_tab',
MESSAGE_SIDEBAR_COLLAPSED: 'message_sidebar_collapsed',
SUGGESTION_SIDEBAR_OPEN: 'suggestion_sidebar_open',
} as const;
export function useUserPreferencesSync() {
const { data, isLoading } = useUserPreferences();
const upsertMutation = useUpsertUserPreference();
const deleteMutation = useDeleteUserPreference();
const {
theme,
locale,
sidebarOpen,
aiSidebarCollapsed,
aiSidebarTab,
messageSidebarCollapsed,
suggestionSidebarOpen: _suggestionSidebarOpen,
setTheme,
setLocale,
setSidebarOpen,
setAISidebarCollapsed,
setAISidebarTab,
setMessageSidebarCollapsed,
} = useUIStore();
// Apply server preferences to uiStore on initial load
useEffect(() => {
if (!data?.preferences) return;
const prefMap = new Map<string, unknown>();
for (const pref of data.preferences) {
prefMap.set(pref.key, pref.value);
}
// Only apply if the server has a value — don't override local defaults with null
if (prefMap.has(PREF_KEYS.THEME)) {
const serverTheme = prefMap.get(PREF_KEYS.THEME) as Theme;
if (serverTheme && serverTheme !== theme) {
setTheme(serverTheme);
}
}
if (prefMap.has(PREF_KEYS.LOCALE)) {
const serverLocale = prefMap.get(PREF_KEYS.LOCALE) as Locale;
if (serverLocale && serverLocale !== locale) {
setLocale(serverLocale);
}
}
if (prefMap.has(PREF_KEYS.SIDEBAR_OPEN)) {
const serverSidebar = prefMap.get(PREF_KEYS.SIDEBAR_OPEN) as boolean;
if (serverSidebar !== sidebarOpen) {
setSidebarOpen(serverSidebar);
}
}
if (prefMap.has(PREF_KEYS.AI_SIDEBAR_COLLAPSED)) {
const serverCollapsed = prefMap.get(
PREF_KEYS.AI_SIDEBAR_COLLAPSED
) as boolean;
if (serverCollapsed !== aiSidebarCollapsed) {
setAISidebarCollapsed(serverCollapsed);
}
}
if (prefMap.has(PREF_KEYS.AI_SIDEBAR_TAB)) {
const serverTab = prefMap.get(PREF_KEYS.AI_SIDEBAR_TAB) as AISidebarTab;
if (serverTab && serverTab !== aiSidebarTab) {
setAISidebarTab(serverTab);
}
}
if (prefMap.has(PREF_KEYS.MESSAGE_SIDEBAR_COLLAPSED)) {
const serverCollapsed = prefMap.get(
PREF_KEYS.MESSAGE_SIDEBAR_COLLAPSED
) as boolean;
if (serverCollapsed !== messageSidebarCollapsed) {
setMessageSidebarCollapsed(serverCollapsed);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
// Persist a single preference to the server
const savePreference = useCallback(
(key: string, value: unknown) => {
upsertMutation.mutate({ key, value });
},
[upsertMutation]
);
// Delete a preference from the server
const removePreference = useCallback(
(key: string) => {
deleteMutation.mutate(key);
},
[deleteMutation]
);
return {
isLoading,
savePreference,
removePreference,
isSaving: upsertMutation.isPending,
isDeleting: deleteMutation.isPending,
};
}
+14
View File
@@ -893,6 +893,20 @@
"changes": "Änderungen",
"fieldsChanged": "Felder geändert"
},
"userPreferences": {
"title": "Benutzereinstellungen",
"saved": "Einstellungen gespeichert",
"saveError": "Fehler beim Speichern der Einstellungen",
"deleted": "Einstellung gelöscht",
"deleteError": "Fehler beim Löschen der Einstellung",
"loading": "Einstellungen werden geladen",
"theme": "Design",
"language": "Sprache",
"sidebar": "Seitenleiste",
"activeTab": "Aktiver Tab",
"sortOrder": "Sortierreihenfolge",
"synced": "Einstellungen synchronisiert"
},
"ai": {
"uiControl": {
"active": "KI steuert die UI",
+14
View File
@@ -893,6 +893,20 @@
"changes": "Changes",
"fieldsChanged": "fields changed"
},
"userPreferences": {
"title": "User Preferences",
"saved": "Preferences saved",
"saveError": "Failed to save preferences",
"deleted": "Preference deleted",
"deleteError": "Failed to delete preference",
"loading": "Loading preferences",
"theme": "Theme",
"language": "Language",
"sidebar": "Sidebar",
"activeTab": "Active Tab",
"sortOrder": "Sort Order",
"synced": "Preferences synced"
},
"ai": {
"uiControl": {
"active": "AI is controlling the UI",