feat(auth): add centralized user settings synced across all apps

- Add settings module to mana-core-auth with REST API endpoints
- Create user_settings table with globalSettings and appOverrides (JSONB)
- Add createUserSettingsStore() factory in shared-theme package
- Integrate user settings in all app layouts (calendar, chat, contacts, etc.)
- Support for nav position, theme, locale settings with per-app overrides
- Optimistic updates with localStorage caching for offline support
- Add comprehensive documentation in docs/USER_SETTINGS.md

API Endpoints:
- GET /api/v1/settings - Get all user settings
- PATCH /api/v1/settings/global - Update global settings
- PATCH /api/v1/settings/app/:appId - Set app override
- DELETE /api/v1/settings/app/:appId - Remove app override

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-03 00:09:47 +01:00
parent 0f2aae631d
commit 0e5d923faf
30 changed files with 1624 additions and 7 deletions

View file

@ -15,8 +15,20 @@ export type {
A11ySettings,
A11yStore,
A11yStoreConfig,
// User Settings Types (synced via mana-core-auth)
NavPosition,
NavSettings,
ThemeSettings,
GlobalSettings,
AppOverride,
UserSettingsResponse,
UserSettingsStore,
UserSettingsStoreConfig,
} from './types';
// User Settings Constants
export { DEFAULT_GLOBAL_SETTINGS } from './types';
// Constants
export {
THEME_VARIANTS,
@ -44,6 +56,9 @@ export { createThemeStore, APP_THEME_CONFIGS } from './store.svelte';
// A11y Store
export { createA11yStore } from './a11y-store.svelte';
// User Settings Store
export { createUserSettingsStore } from './user-settings-store.svelte';
// Utils
export {
isBrowser,

View file

@ -203,3 +203,107 @@ export interface A11yStoreConfig {
/** Default settings override */
defaults?: Partial<A11ySettings>;
}
// ============================================================================
// Global User Settings Types (synced via mana-core-auth)
// ============================================================================
/**
* Navigation position for desktop (mobile always at bottom)
*/
export type NavPosition = 'top' | 'bottom';
/**
* Navigation settings
*/
export interface NavSettings {
/** Desktop navigation position */
desktopPosition: NavPosition;
/** Whether sidebar is collapsed */
sidebarCollapsed: boolean;
}
/**
* Theme settings (synced to server)
*/
export interface ThemeSettings {
/** Theme mode preference */
mode: ThemeMode;
/** Color scheme / variant */
colorScheme: string;
}
/**
* Global settings that apply to all apps by default
*/
export interface GlobalSettings {
nav: NavSettings;
theme: ThemeSettings;
locale: string;
}
/**
* Per-app override settings (partial, only overridden values)
*/
export interface AppOverride {
nav?: Partial<NavSettings>;
theme?: Partial<ThemeSettings>;
}
/**
* Full user settings response from API
*/
export interface UserSettingsResponse {
globalSettings: GlobalSettings;
appOverrides: Record<string, AppOverride>;
}
/**
* Default global settings
*/
export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
nav: { desktopPosition: 'top', sidebarCollapsed: false },
theme: { mode: 'system', colorScheme: 'ocean' },
locale: 'de',
};
/**
* User settings store interface
*/
export interface UserSettingsStore {
/** Resolved navigation settings (global + app override) */
readonly nav: NavSettings;
/** Resolved theme settings (global + app override) */
readonly theme: ThemeSettings;
/** Current locale */
readonly locale: string;
/** Raw global settings */
readonly globalSettings: GlobalSettings;
/** Whether current app has an override */
readonly hasAppOverride: boolean;
/** Whether settings are being synced */
readonly syncing: boolean;
/** Whether settings are loaded */
readonly loaded: boolean;
/** Load settings from server */
load: () => Promise<void>;
/** Update global settings */
updateGlobal: (settings: Partial<GlobalSettings>) => Promise<void>;
/** Update app-specific override */
updateAppOverride: (settings: AppOverride) => Promise<void>;
/** Remove app override (revert to global) */
removeAppOverride: () => Promise<void>;
}
/**
* User settings store configuration
*/
export interface UserSettingsStoreConfig {
/** App identifier (e.g., 'calendar', 'chat') */
appId: string;
/** Auth service base URL */
authUrl: string;
/** Function to get current access token */
getAccessToken: () => Promise<string | null>;
}

View file

@ -0,0 +1,301 @@
import type {
UserSettingsStore,
UserSettingsStoreConfig,
GlobalSettings,
AppOverride,
NavSettings,
ThemeSettings,
UserSettingsResponse,
} from './types';
import { DEFAULT_GLOBAL_SETTINGS } from './types';
import { isBrowser } from './utils';
const STORAGE_KEY_PREFIX = 'manacore-user-settings';
/**
* Create a User Settings store for your app
*
* This store syncs settings with mana-core-auth and provides:
* - Global settings that apply to all apps
* - Per-app overrides for customization
* - localStorage caching for offline support
*
* @example
* ```typescript
* import { createUserSettingsStore } from '@manacore/shared-theme';
*
* export const userSettings = createUserSettingsStore({
* appId: 'calendar',
* authUrl: 'http://localhost:3001',
* getAccessToken: () => authStore.getAccessToken()
* });
*
* // In +layout.svelte
* $effect(() => {
* if (authStore.isAuthenticated) {
* userSettings.load();
* }
* });
* ```
*/
export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSettingsStore {
const { appId, authUrl, getAccessToken } = config;
const storageKey = `${STORAGE_KEY_PREFIX}-${appId}`;
// State
let globalSettings = $state<GlobalSettings>({ ...DEFAULT_GLOBAL_SETTINGS });
let appOverrides = $state<Record<string, AppOverride>>({});
let syncing = $state(false);
let loaded = $state(false);
// Derived: resolved nav settings (global + app override)
const nav = $derived<NavSettings>({
...globalSettings.nav,
...(appOverrides[appId]?.nav || {}),
});
// Derived: resolved theme settings (global + app override)
const theme = $derived<ThemeSettings>({
...globalSettings.theme,
...(appOverrides[appId]?.theme || {}),
});
// Derived: current locale
const locale = $derived(globalSettings.locale);
// Derived: whether this app has an override
const hasAppOverride = $derived(!!appOverrides[appId]);
/**
* Save current settings to localStorage (for offline fallback)
*/
function saveToStorage(): void {
if (!isBrowser()) return;
try {
localStorage.setItem(
storageKey,
JSON.stringify({
globalSettings,
appOverrides,
timestamp: Date.now(),
})
);
} catch (e) {
console.error('Failed to save user settings to storage:', e);
}
}
/**
* Load settings from localStorage (fallback)
*/
function loadFromStorage(): boolean {
if (!isBrowser()) return false;
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
const data = JSON.parse(stored);
if (data.globalSettings) {
globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings };
}
if (data.appOverrides) {
appOverrides = data.appOverrides;
}
return true;
}
} catch (e) {
console.error('Failed to load user settings from storage:', e);
}
return false;
}
/**
* Make an API request to the settings endpoint
*/
async function apiRequest<T>(
method: string,
path: string,
body?: object
): Promise<T | null> {
const token = await getAccessToken();
if (!token) {
console.warn('No access token available for settings API');
return null;
}
try {
const response = await fetch(`${authUrl}/api/v1/settings${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
console.error(`Settings API error: ${response.status}`);
return null;
}
return await response.json();
} catch (e) {
console.error('Settings API request failed:', e);
return null;
}
}
/**
* Load settings from server
*/
async function load(): Promise<void> {
// Load from cache first for instant UI
loadFromStorage();
syncing = true;
try {
const data = await apiRequest<UserSettingsResponse & { success: boolean }>('GET', '');
if (data?.success) {
globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings };
appOverrides = data.appOverrides || {};
saveToStorage();
loaded = true;
}
} finally {
syncing = false;
}
}
/**
* Update global settings
*/
async function updateGlobal(settings: Partial<GlobalSettings>): Promise<void> {
// Optimistic update
const previousGlobal = { ...globalSettings };
globalSettings = {
nav: { ...globalSettings.nav, ...settings.nav },
theme: { ...globalSettings.theme, ...settings.theme },
locale: settings.locale ?? globalSettings.locale,
};
saveToStorage();
syncing = true;
try {
const data = await apiRequest<UserSettingsResponse & { success: boolean }>(
'PATCH',
'/global',
settings
);
if (data?.success) {
globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings };
appOverrides = data.appOverrides || {};
saveToStorage();
} else {
// Rollback on failure
globalSettings = previousGlobal;
saveToStorage();
}
} finally {
syncing = false;
}
}
/**
* Update app-specific override
*/
async function updateAppOverride(settings: AppOverride): Promise<void> {
// Optimistic update
const previousOverrides = { ...appOverrides };
appOverrides = {
...appOverrides,
[appId]: {
...appOverrides[appId],
...settings,
},
};
saveToStorage();
syncing = true;
try {
const data = await apiRequest<UserSettingsResponse & { success: boolean }>(
'PATCH',
`/app/${appId}`,
settings
);
if (data?.success) {
globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings };
appOverrides = data.appOverrides || {};
saveToStorage();
} else {
// Rollback on failure
appOverrides = previousOverrides;
saveToStorage();
}
} finally {
syncing = false;
}
}
/**
* Remove app override (revert to global settings)
*/
async function removeAppOverride(): Promise<void> {
// Optimistic update
const previousOverrides = { ...appOverrides };
const newOverrides = { ...appOverrides };
delete newOverrides[appId];
appOverrides = newOverrides;
saveToStorage();
syncing = true;
try {
const data = await apiRequest<UserSettingsResponse & { success: boolean }>(
'DELETE',
`/app/${appId}`
);
if (data?.success) {
globalSettings = { ...DEFAULT_GLOBAL_SETTINGS, ...data.globalSettings };
appOverrides = data.appOverrides || {};
saveToStorage();
} else {
// Rollback on failure
appOverrides = previousOverrides;
saveToStorage();
}
} finally {
syncing = false;
}
}
return {
get nav() {
return nav;
},
get theme() {
return theme;
},
get locale() {
return locale;
},
get globalSettings() {
return globalSettings;
},
get hasAppOverride() {
return hasAppOverride;
},
get syncing() {
return syncing;
},
get loaded() {
return loaded;
},
load,
updateGlobal,
updateAppOverride,
removeAppOverride,
};
}