Add synced user settings support

This commit is contained in:
Anthony Stirling
2025-11-16 23:50:22 +00:00
parent 5c9e590856
commit 16d9c34a5e
15 changed files with 419 additions and 5 deletions
@@ -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> 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<String, String> 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<String, String> 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)
@@ -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<String, String> settings) {}
@@ -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<String, String> settings) {}
@@ -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<String, String> 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<String, String> getUserSettings(String username) {
Optional<User> userOpt = findByUsernameIgnoreCaseWithSettings(username);
if (userOpt.isEmpty()) {
return null;
}
Map<String, String> settingsMap = userOpt.get().getSettings();
if (settingsMap == null) {
return new HashMap<>();
}
return new HashMap<>(settingsMap);
}
public Optional<User> findByUsername(String username) {
return userRepository.findByUsername(username);
}
@@ -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<LanguageSelectorProps> = ({ position = 'bottom-
// Simulate processing time for smooth transition
setTimeout(() => {
i18n.changeLanguage(value);
emitLocalSettingsEvent(['i18nextLng'], 'local');
setTimeout(() => {
setPendingLanguage(null);
@@ -168,6 +168,28 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
/>
</div>
</Tooltip>
<Tooltip
label={t('settings.general.syncSettingsTooltip', 'Automatically back up your preferences to your account so they follow you on every device.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.syncSettings', 'Sync settings across devices')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.syncSettingsDescription', 'Carry your theme, language, favorites, and hotkeys to other browsers when signed in.')}
</Text>
</div>
<Switch
checked={preferences.syncSettingsAcrossDevices}
onChange={(event) => updatePreference('syncSettingsAcrossDevices', event.currentTarget.checked)}
/>
</div>
</Tooltip>
</Stack>
</Paper>
</Stack>
@@ -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<Record<ToolId, HotkeyBinding>>;
@@ -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]) => {
@@ -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(
<K extends keyof UserPreferences>(key: K, value: UserPreferences[K]) => {
preferencesService.setPreference(key, value);
@@ -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;
});
}, []);
@@ -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<K extends keyof UserPreferences>(
@@ -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;
@@ -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<LocalSettingsEventDetail>(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<LocalSettingsEventDetail>;
if (customEvent?.detail) {
listener(customEvent.detail);
}
};
window.addEventListener(LOCAL_SETTINGS_EVENT, handler as EventListener);
return () => window.removeEventListener(LOCAL_SETTINGS_EVENT, handler as EventListener);
}
@@ -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}
>
<AuthProvider>
{children}
<UserSettingsSyncProvider>
{children}
</UserSettingsSyncProvider>
</AuthProvider>
</CoreAppProviders>
);
@@ -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}</>;
}
@@ -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<string>(SYNCABLE_KEYS);
function collectLocalSettings(): Record<string, string> {
if (typeof window === 'undefined') {
return {};
}
return SYNCABLE_KEYS.reduce<Record<string, string>>((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<number | null>(null);
const isFetchingRef = useRef(false);
const applyRemoteSettings = useCallback((settings?: Record<string, string>) => {
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]);
}
@@ -0,0 +1,19 @@
import apiClient from '@app/services/apiClient';
export interface UserSettingsResponse {
settings: Record<string, string>;
}
export const userSettingsService = {
async fetch(): Promise<UserSettingsResponse> {
const response = await apiClient.get<UserSettingsResponse>('/api/v1/user/settings');
return response.data;
},
async save(settings: Record<string, string>): Promise<UserSettingsResponse> {
const response = await apiClient.put<UserSettingsResponse>('/api/v1/user/settings', {
settings,
});
return response.data;
},
};