Compare commits

..
Author SHA1 Message Date
Anthony Stirling b457795216 Add en-GB strings for settings sync 2025-11-17 13:40:45 +00:00
Anthony Stirling 16d9c34a5e Add synced user settings support 2025-11-16 23:50:22 +00:00
22 changed files with 479 additions and 111 deletions
@@ -1,37 +0,0 @@
package stirling.software.common.annotations.api;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
* Combined annotation for Invite management controllers.
* Includes @RestController, @RequestMapping("/api/v1/invite"), and OpenAPI @Tag.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/invite")
@Tag(
name = "Invite",
description =
"""
Invite-link generation and acceptance endpoints for onboarding new users.
Provides the ability to issue invitation tokens, send optional email invites,
validate and accept invite links, and manage pending invitations for teams.
Typical use cases include:
• Admin workflows for issuing time-limited invitations to external users
• Self-service invite acceptance and team assignment
• License limit enforcement when provisioning new accounts
Target users: administrators and automation scripts orchestrating user onboarding.
""")
public @interface InviteApi {}
@@ -129,53 +129,61 @@ public class SecurityConfiguration {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
List<String> configuredOrigins = null;
if (applicationProperties.getSystem() != null) {
configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
}
// Read CORS allowed origins from settings
if (applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
CorsConfiguration cfg = new CorsConfiguration();
if (configuredOrigins != null && !configuredOrigins.isEmpty()) {
cfg.setAllowedOriginPatterns(configuredOrigins);
List<String> allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
CorsConfiguration cfg = new CorsConfiguration();
// Use setAllowedOriginPatterns for better wildcard and port support
cfg.setAllowedOriginPatterns(allowedOrigins);
log.debug(
"CORS configured with allowed origin patterns from settings.yml: {}",
configuredOrigins);
allowedOrigins);
// Set allowed methods explicitly (including OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
// Set allowed headers explicitly
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN"));
// Set exposed headers (headers that the browser can access)
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
// Allow credentials (cookies, authorization headers)
cfg.setAllowCredentials(true);
// Set max age for preflight cache
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
} else {
// Default to allowing all origins when nothing is configured
cfg.setAllowedOriginPatterns(List.of("*"));
// No CORS origins configured - return null to disable CORS processing entirely
// This avoids empty CORS policy that unexpectedly rejects preflights
log.info(
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
"CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)");
return null;
}
// Explicitly configure supported HTTP methods (include OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN"));
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
@Bean
@@ -15,7 +15,7 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.InviteApi;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
@@ -26,9 +26,11 @@ import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@InviteApi
@UserApi
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/invite")
public class InviteLinkController {
private final InviteTokenRepository inviteTokenRepository;
@@ -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);
}
@@ -332,7 +332,10 @@
"mode": {
"fullscreen": "Fullscreen",
"sidebar": "Sidebar"
}
},
"syncSettings": "Sync settings across devices",
"syncSettingsDescription": "Carry your theme, language, favourites, and hotkeys to other browsers when signed in.",
"syncSettingsTooltip": "Automatically back up your preferences to your account so they follow you on every device."
},
"hotkeys": {
"title": "Keyboard Shortcuts",
@@ -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
View File
@@ -7,7 +7,6 @@ import { getApiBaseUrl } from '@app/services/apiClientConfig';
const apiClient = axios.create({
baseURL: getApiBaseUrl(),
responseType: 'json',
withCredentials: true,
});
// Setup interceptors (core does nothing, proprietary adds JWT auth)
@@ -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);
}
@@ -107,7 +107,7 @@ class SpringAuthClient {
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return decodeURIComponent(value);
return value;
}
}
return null;
@@ -278,7 +278,7 @@ class SpringAuthClient {
try {
const response = await apiClient.post('/api/v1/auth/logout', null, {
headers: {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
'X-CSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
});
@@ -311,7 +311,7 @@ class SpringAuthClient {
try {
const response = await apiClient.post('/api/v1/auth/refresh', null, {
headers: {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
'X-CSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
});
@@ -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]);
}
@@ -9,38 +9,17 @@ function getJwtTokenFromStorage(): string | null {
}
}
function getXsrfToken(): string | null {
try {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return decodeURIComponent(value);
}
}
return null;
} catch (error) {
console.error('[API Client] Failed to read XSRF token from cookies:', error);
return null;
}
}
export function setupApiInterceptors(client: AxiosInstance): void {
// Install request interceptor to add JWT token
client.interceptors.request.use(
(config) => {
const jwtToken = getJwtTokenFromStorage();
const xsrfToken = getXsrfToken();
if (jwtToken && !config.headers.Authorization) {
config.headers.Authorization = `Bearer ${jwtToken}`;
console.debug('[API Client] Added JWT token from localStorage to Authorization header');
}
if (xsrfToken && !config.headers['X-XSRF-TOKEN']) {
config.headers['X-XSRF-TOKEN'] = xsrfToken;
}
return config;
},
(error) => {
@@ -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;
},
};