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:
@@ -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'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user