mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 23:06:41 +02:00
✨ 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:
parent
0f2aae631d
commit
0e5d923faf
30 changed files with 1624 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
301
packages/shared-theme/src/user-settings-store.svelte.ts
Normal file
301
packages/shared-theme/src/user-settings-store.svelte.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue