From 16d9c34a5ea502209076ef2e85b8fef92d68a3b3 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:50:22 +0000 Subject: [PATCH] Add synced user settings support --- .../controller/api/UserController.java | 33 ++++ .../model/api/user/UserSettingsRequest.java | 10 ++ .../model/api/user/UserSettingsResponse.java | 10 ++ .../security/service/UserService.java | 17 +- .../components/shared/LanguageSelector.tsx | 2 + .../config/configSections/GeneralSection.tsx | 22 +++ frontend/src/core/contexts/HotkeyContext.tsx | 18 ++ .../src/core/contexts/PreferencesContext.tsx | 16 +- .../core/hooks/tools/useUserToolActivity.ts | 40 +++++ .../src/core/services/preferencesService.ts | 8 +- .../src/core/utils/localSettingsEvents.ts | 46 +++++ .../proprietary/components/AppProviders.tsx | 5 +- .../components/UserSettingsSyncProvider.tsx | 11 ++ .../proprietary/hooks/useUserSettingsSync.ts | 167 ++++++++++++++++++ .../services/userSettingsService.ts | 19 ++ 15 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UserSettingsRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UserSettingsResponse.java create mode 100644 frontend/src/core/utils/localSettingsEvents.ts create mode 100644 frontend/src/proprietary/components/UserSettingsSyncProvider.tsx create mode 100644 frontend/src/proprietary/hooks/useUserSettingsSync.ts create mode 100644 frontend/src/proprietary/services/userSettingsService.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index 9f2ce94560..149aed2d2b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -36,6 +36,8 @@ import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.model.api.user.UserSettingsRequest; +import stirling.software.proprietary.security.model.api.user.UserSettingsResponse; import stirling.software.proprietary.security.model.api.user.UsernameAndPass; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; @@ -59,6 +61,37 @@ public class UserController { private final Optional emailService; private final UserLicenseSettingsService licenseSettingsService; + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") + @GetMapping("/settings") + public ResponseEntity getUserSettings(Principal principal) { + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "notAuthenticated", "message", "User not authenticated")); + } + Map settings = userService.getUserSettings(principal.getName()); + if (settings == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "userNotFound", "message", "User not found")); + } + return ResponseEntity.ok(new UserSettingsResponse(settings)); + } + + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") + @PutMapping("/settings") + @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) + public ResponseEntity saveUserSettings( + @RequestBody UserSettingsRequest request, Principal principal) + throws SQLException, UnsupportedProviderException { + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "notAuthenticated", "message", "User not authenticated")); + } + Map updates = + request != null && request.settings() != null ? request.settings() : Map.of(); + userService.updateUserSettings(principal.getName(), updates); + return ResponseEntity.ok(new UserSettingsResponse(updates)); + } + @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/register") public ResponseEntity register(@RequestBody UsernameAndPass usernameAndPass) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UserSettingsRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UserSettingsRequest.java new file mode 100644 index 0000000000..b0dc23b664 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UserSettingsRequest.java @@ -0,0 +1,10 @@ +package stirling.software.proprietary.security.model.api.user; + +import java.util.Map; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** Request payload for updating a user's stored settings map. */ +public record UserSettingsRequest( + @Schema(description = "Key/value map of settings to persist for the user") + Map settings) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UserSettingsResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UserSettingsResponse.java new file mode 100644 index 0000000000..c5cd240970 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/api/user/UserSettingsResponse.java @@ -0,0 +1,10 @@ +package stirling.software.proprietary.security.model.api.user; + +import java.util.Map; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** Response payload containing the user's stored settings map. */ +public record UserSettingsResponse( + @Schema(description = "Key/value map of the user's saved settings") + Map settings) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 4772368f88..209bc0056f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.security.service; import java.sql.SQLException; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -371,14 +372,28 @@ public class UserService implements UserServiceInterface { if (settingsMap == null) { settingsMap = new HashMap<>(); } + Map sanitizedUpdates = + updates == null ? Collections.emptyMap() : new HashMap<>(updates); settingsMap.clear(); - settingsMap.putAll(updates); + settingsMap.putAll(sanitizedUpdates); user.setSettings(settingsMap); userRepository.save(user); databaseService.exportDatabase(); } } + public Map getUserSettings(String username) { + Optional userOpt = findByUsernameIgnoreCaseWithSettings(username); + if (userOpt.isEmpty()) { + return null; + } + Map settingsMap = userOpt.get().getSettings(); + if (settingsMap == null) { + return new HashMap<>(); + } + return new HashMap<>(settingsMap); + } + public Optional findByUsername(String username) { return userRepository.findByUsername(username); } diff --git a/frontend/src/core/components/shared/LanguageSelector.tsx b/frontend/src/core/components/shared/LanguageSelector.tsx index 53e239064e..f73dacca3e 100644 --- a/frontend/src/core/components/shared/LanguageSelector.tsx +++ b/frontend/src/core/components/shared/LanguageSelector.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Menu, Button, ScrollArea, ActionIcon, Tooltip } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { supportedLanguages } from '@app/i18n'; +import { emitLocalSettingsEvent } from '@app/utils/localSettingsEvents'; import LocalIcon from '@app/components/shared/LocalIcon'; import styles from '@app/components/shared/LanguageSelector.module.css'; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; @@ -178,6 +179,7 @@ const LanguageSelector: React.FC = ({ position = 'bottom- // Simulate processing time for smooth transition setTimeout(() => { i18n.changeLanguage(value); + emitLocalSettingsEvent(['i18nextLng'], 'local'); setTimeout(() => { setPendingLanguage(null); diff --git a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx index de9994705b..d52bf17bcb 100644 --- a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx @@ -168,6 +168,28 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => /> + + +
+
+ + {t('settings.general.syncSettings', 'Sync settings across devices')} + + + {t('settings.general.syncSettingsDescription', 'Carry your theme, language, favorites, and hotkeys to other browsers when signed in.')} + +
+ updatePreference('syncSettingsAcrossDevices', event.currentTarget.checked)} + /> +
+
diff --git a/frontend/src/core/contexts/HotkeyContext.tsx b/frontend/src/core/contexts/HotkeyContext.tsx index c7d353c410..ab1a9d8a46 100644 --- a/frontend/src/core/contexts/HotkeyContext.tsx +++ b/frontend/src/core/contexts/HotkeyContext.tsx @@ -3,6 +3,7 @@ import { HotkeyBinding, bindingEquals, bindingMatchesEvent, deserializeBindings, import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { ToolId } from '@app/types/toolId'; import { ToolCategoryId, ToolRegistryEntry } from '@app/data/toolsTaxonomy'; +import { addLocalSettingsListener, emitLocalSettingsEvent } from '@app/utils/localSettingsEvents'; type Bindings = Partial>; @@ -110,8 +111,25 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr return; } window.localStorage.setItem(STORAGE_KEY, serializeBindings(customBindings)); + emitLocalSettingsEvent([STORAGE_KEY], 'local'); }, [customBindings]); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + return addLocalSettingsListener(detail => { + if (detail.origin !== 'remote') { + return; + } + if (detail.keys.includes(STORAGE_KEY)) { + const stored = window.localStorage.getItem(STORAGE_KEY); + setCustomBindings(deserializeBindings(stored)); + } + }); + }, []); + const isBindingAvailable = useCallback((binding: HotkeyBinding, excludeToolId?: ToolId) => { const normalized = normalizeBinding(binding); return Object.entries(resolved).every(([toolId, existing]) => { diff --git a/frontend/src/core/contexts/PreferencesContext.tsx b/frontend/src/core/contexts/PreferencesContext.tsx index b28d1b35bf..27e4e0e15b 100644 --- a/frontend/src/core/contexts/PreferencesContext.tsx +++ b/frontend/src/core/contexts/PreferencesContext.tsx @@ -1,5 +1,6 @@ -import React, { createContext, useContext, useState, useCallback } from 'react'; -import { preferencesService, UserPreferences } from '@app/services/preferencesService'; +import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; +import { preferencesService, UserPreferences, PREFERENCES_STORAGE_KEY } from '@app/services/preferencesService'; +import { addLocalSettingsListener } from '@app/utils/localSettingsEvents'; interface PreferencesContextValue { preferences: UserPreferences; @@ -18,6 +19,17 @@ export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ c return preferencesService.getAllPreferences(); }); + useEffect(() => { + return addLocalSettingsListener(detail => { + if (detail.origin !== 'remote') { + return; + } + if (detail.keys.includes(PREFERENCES_STORAGE_KEY)) { + setPreferences(preferencesService.getAllPreferences()); + } + }); + }, []); + const updatePreference = useCallback( (key: K, value: UserPreferences[K]) => { preferencesService.setPreference(key, value); diff --git a/frontend/src/core/hooks/tools/useUserToolActivity.ts b/frontend/src/core/hooks/tools/useUserToolActivity.ts index c3b7cbea95..45397ecc46 100644 --- a/frontend/src/core/hooks/tools/useUserToolActivity.ts +++ b/frontend/src/core/hooks/tools/useUserToolActivity.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { ToolId } from '@app/types/toolId'; +import { addLocalSettingsListener, emitLocalSettingsEvent } from '@app/utils/localSettingsEvents'; const RECENT_TOOLS_KEY = 'stirlingpdf.recentTools'; const FAVORITE_TOOLS_KEY = 'stirlingpdf.favoriteTools'; @@ -36,6 +37,44 @@ export function useToolHistory() { } }, []); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + return addLocalSettingsListener(detail => { + if (detail.origin !== 'remote') { + return; + } + + if (detail.keys.includes(FAVORITE_TOOLS_KEY)) { + const favoritesStr = window.localStorage.getItem(FAVORITE_TOOLS_KEY); + if (favoritesStr) { + try { + setFavoriteTools(JSON.parse(favoritesStr)); + } catch { + setFavoriteTools([]); + } + } else { + setFavoriteTools([]); + } + } + + if (detail.keys.includes(RECENT_TOOLS_KEY)) { + const recentStr = window.localStorage.getItem(RECENT_TOOLS_KEY); + if (recentStr) { + try { + setRecentTools(JSON.parse(recentStr)); + } catch { + setRecentTools([]); + } + } else { + setRecentTools([]); + } + } + }); + }, []); + // Toggle favorite status const toggleFavorite = useCallback((toolId: ToolId) => { @@ -49,6 +88,7 @@ export function useToolHistory() { ? prev.filter((id) => id !== toolId) : [...prev, toolId]; window.localStorage.setItem(FAVORITE_TOOLS_KEY, JSON.stringify(updated)); + emitLocalSettingsEvent([FAVORITE_TOOLS_KEY], 'local'); return updated; }); }, []); diff --git a/frontend/src/core/services/preferencesService.ts b/frontend/src/core/services/preferencesService.ts index 5894eae506..924b7e102a 100644 --- a/frontend/src/core/services/preferencesService.ts +++ b/frontend/src/core/services/preferencesService.ts @@ -1,5 +1,6 @@ import { type ToolPanelMode, DEFAULT_TOOL_PANEL_MODE } from '@app/constants/toolPanel'; import { type ThemeMode, getSystemTheme } from '@app/constants/theme'; +import { emitLocalSettingsEvent } from '@app/utils/localSettingsEvents'; export interface UserPreferences { autoUnzip: boolean; @@ -9,6 +10,7 @@ export interface UserPreferences { toolPanelModePromptSeen: boolean; showLegacyToolDescriptions: boolean; hasCompletedOnboarding: boolean; + syncSettingsAcrossDevices: boolean; } export const DEFAULT_PREFERENCES: UserPreferences = { @@ -19,9 +21,11 @@ export const DEFAULT_PREFERENCES: UserPreferences = { toolPanelModePromptSeen: false, showLegacyToolDescriptions: false, hasCompletedOnboarding: false, + syncSettingsAcrossDevices: false, }; -const STORAGE_KEY = 'stirlingpdf_preferences'; +export const PREFERENCES_STORAGE_KEY = 'stirlingpdf_preferences'; +const STORAGE_KEY = PREFERENCES_STORAGE_KEY; class PreferencesService { getPreference( @@ -51,6 +55,7 @@ class PreferencesService { const preferences = stored ? JSON.parse(stored) : {}; preferences[key] = value; localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)); + emitLocalSettingsEvent([STORAGE_KEY], 'local'); } catch (error) { console.error('Error writing preference:', key, error); } @@ -76,6 +81,7 @@ class PreferencesService { clearAllPreferences(): void { try { localStorage.removeItem(STORAGE_KEY); + emitLocalSettingsEvent([STORAGE_KEY], 'local'); } catch (error) { console.error('Error clearing preferences:', error); throw error; diff --git a/frontend/src/core/utils/localSettingsEvents.ts b/frontend/src/core/utils/localSettingsEvents.ts new file mode 100644 index 0000000000..b03a928a83 --- /dev/null +++ b/frontend/src/core/utils/localSettingsEvents.ts @@ -0,0 +1,46 @@ +export const LOCAL_SETTINGS_EVENT = 'stirlingpdf:local-settings-changed'; + +export type LocalSettingsEventOrigin = 'local' | 'remote'; + +export interface LocalSettingsEventDetail { + keys: string[]; + origin: LocalSettingsEventOrigin; +} + +export function emitLocalSettingsEvent(keys: string[], origin: LocalSettingsEventOrigin) { + if (typeof window === 'undefined') { + return; + } + + const uniqueKeys = Array.from(new Set(keys)).filter(Boolean); + if (uniqueKeys.length === 0) { + return; + } + + const event = new CustomEvent(LOCAL_SETTINGS_EVENT, { + detail: { + keys: uniqueKeys, + origin, + }, + }); + + window.dispatchEvent(event); +} + +export function addLocalSettingsListener( + listener: (detail: LocalSettingsEventDetail) => void +): () => void { + if (typeof window === 'undefined') { + return () => {}; + } + + const handler = (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent?.detail) { + listener(customEvent.detail); + } + }; + + window.addEventListener(LOCAL_SETTINGS_EVENT, handler as EventListener); + return () => window.removeEventListener(LOCAL_SETTINGS_EVENT, handler as EventListener); +} diff --git a/frontend/src/proprietary/components/AppProviders.tsx b/frontend/src/proprietary/components/AppProviders.tsx index f0cadfa515..da3e267f17 100644 --- a/frontend/src/proprietary/components/AppProviders.tsx +++ b/frontend/src/proprietary/components/AppProviders.tsx @@ -1,5 +1,6 @@ import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/components/AppProviders"; import { AuthProvider } from "@app/auth/UseSession"; +import { UserSettingsSyncProvider } from "@app/components/UserSettingsSyncProvider"; export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) { return ( @@ -8,7 +9,9 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide appConfigProviderProps={appConfigProviderProps} > - {children} + + {children} + ); diff --git a/frontend/src/proprietary/components/UserSettingsSyncProvider.tsx b/frontend/src/proprietary/components/UserSettingsSyncProvider.tsx new file mode 100644 index 0000000000..39da20880e --- /dev/null +++ b/frontend/src/proprietary/components/UserSettingsSyncProvider.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from 'react'; +import { useUserSettingsSync } from '@app/hooks/useUserSettingsSync'; + +interface Props { + children: ReactNode; +} + +export function UserSettingsSyncProvider({ children }: Props) { + useUserSettingsSync(); + return <>{children}; +} diff --git a/frontend/src/proprietary/hooks/useUserSettingsSync.ts b/frontend/src/proprietary/hooks/useUserSettingsSync.ts new file mode 100644 index 0000000000..67e01fdb47 --- /dev/null +++ b/frontend/src/proprietary/hooks/useUserSettingsSync.ts @@ -0,0 +1,167 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useAuth } from '@app/auth/UseSession'; +import { usePreferences } from '@app/contexts/PreferencesContext'; +import { userSettingsService } from '@app/services/userSettingsService'; +import { PREFERENCES_STORAGE_KEY } from '@app/services/preferencesService'; +import { addLocalSettingsListener, emitLocalSettingsEvent } from '@app/utils/localSettingsEvents'; +import i18n from '@app/i18n'; + +const LANGUAGE_STORAGE_KEY = 'i18nextLng'; +const HOTKEY_STORAGE_KEY = 'stirlingpdf.hotkeys'; +const FAVORITE_TOOLS_KEY = 'stirlingpdf.favoriteTools'; + +const SYNCABLE_KEYS = [ + PREFERENCES_STORAGE_KEY, + LANGUAGE_STORAGE_KEY, + HOTKEY_STORAGE_KEY, + FAVORITE_TOOLS_KEY, +] as const; + +const SYNCABLE_KEY_SET = new Set(SYNCABLE_KEYS); + +function collectLocalSettings(): Record { + if (typeof window === 'undefined') { + return {}; + } + + return SYNCABLE_KEYS.reduce>((acc, key) => { + const value = window.localStorage.getItem(key); + if (value !== null) { + acc[key] = value; + } + return acc; + }, {}); +} + +export function useUserSettingsSync() { + const { session } = useAuth(); + const { preferences } = usePreferences(); + const syncEnabled = Boolean(session && preferences.syncSettingsAcrossDevices); + const uploadTimeoutRef = useRef(null); + const isFetchingRef = useRef(false); + + const applyRemoteSettings = useCallback((settings?: Record) => { + if (!settings || typeof window === 'undefined') { + return; + } + + const appliedKeys: string[] = []; + SYNCABLE_KEYS.forEach(key => { + if (Object.prototype.hasOwnProperty.call(settings, key)) { + window.localStorage.setItem(key, settings[key]); + appliedKeys.push(key); + } + }); + + const remoteLanguage = settings[LANGUAGE_STORAGE_KEY]; + if (remoteLanguage && i18n.language !== remoteLanguage) { + i18n.changeLanguage(remoteLanguage).catch(() => { + // ignore change errors + }); + } + + if (appliedKeys.length > 0) { + emitLocalSettingsEvent(appliedKeys, 'remote'); + } + }, []); + + const flushUpload = useCallback(async () => { + if (!session || !syncEnabled || typeof window === 'undefined') { + return; + } + + try { + const snapshot = collectLocalSettings(); + await userSettingsService.save(snapshot); + } catch (error) { + console.error('[UserSettingsSync] Failed to sync settings', error); + } + }, [session, syncEnabled]); + + const scheduleUpload = useCallback(() => { + if (!session || !syncEnabled) { + return; + } + + if (uploadTimeoutRef.current) { + window.clearTimeout(uploadTimeoutRef.current); + } + + uploadTimeoutRef.current = window.setTimeout(() => { + uploadTimeoutRef.current = null; + flushUpload(); + }, 750); + }, [session, syncEnabled, flushUpload]); + + useEffect(() => { + if (!session) { + return; + } + + let cancelled = false; + + (async () => { + if (isFetchingRef.current) { + return; + } + isFetchingRef.current = true; + try { + const response = await userSettingsService.fetch(); + if (!cancelled) { + applyRemoteSettings(response?.settings); + } + } catch (error) { + console.error('[UserSettingsSync] Failed to load user settings', error); + } finally { + isFetchingRef.current = false; + } + })(); + + return () => { + cancelled = true; + }; + }, [session, applyRemoteSettings]); + + useEffect(() => { + if (!session) { + if (uploadTimeoutRef.current) { + window.clearTimeout(uploadTimeoutRef.current); + uploadTimeoutRef.current = null; + } + return; + } + }, [session]); + + useEffect(() => { + if (!session) { + return; + } + + if (syncEnabled) { + flushUpload(); + } else if (uploadTimeoutRef.current) { + window.clearTimeout(uploadTimeoutRef.current); + uploadTimeoutRef.current = null; + } + }, [session, syncEnabled, flushUpload]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + return addLocalSettingsListener(detail => { + if (detail.origin !== 'local') { + return; + } + if (!session || !syncEnabled) { + return; + } + const relevant = detail.keys.filter(key => SYNCABLE_KEY_SET.has(key)); + if (relevant.length === 0) { + return; + } + scheduleUpload(); + }); + }, [session, syncEnabled, scheduleUpload]); +} diff --git a/frontend/src/proprietary/services/userSettingsService.ts b/frontend/src/proprietary/services/userSettingsService.ts new file mode 100644 index 0000000000..b97864cd7f --- /dev/null +++ b/frontend/src/proprietary/services/userSettingsService.ts @@ -0,0 +1,19 @@ +import apiClient from '@app/services/apiClient'; + +export interface UserSettingsResponse { + settings: Record; +} + +export const userSettingsService = { + async fetch(): Promise { + const response = await apiClient.get('/api/v1/user/settings'); + return response.data; + }, + + async save(settings: Record): Promise { + const response = await apiClient.put('/api/v1/user/settings', { + settings, + }); + return response.data; + }, +};