mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 05:49: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
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* User Settings Store for Calendar
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'calendar',
|
||||
authUrl: MANA_AUTH_URL,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
|
|
@ -144,9 +145,10 @@
|
|||
// Initialize auth
|
||||
await authStore.initialize();
|
||||
|
||||
// Load calendars if authenticated
|
||||
// Load calendars and user settings if authenticated
|
||||
if (authStore.isAuthenticated) {
|
||||
await calendarsStore.fetchCalendars();
|
||||
await userSettings.load();
|
||||
}
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
|
|
@ -196,6 +198,7 @@
|
|||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
|
|
|
|||
19
apps/chat/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
19
apps/chat/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* User Settings Store for Chat
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'chat',
|
||||
authUrl: MANA_AUTH_URL,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
import { page } from '$app/stores';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import {
|
||||
|
|
@ -156,6 +157,9 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Load user settings
|
||||
await userSettings.load();
|
||||
|
||||
isChecking = false;
|
||||
});
|
||||
</script>
|
||||
|
|
@ -187,6 +191,7 @@
|
|||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* User Settings Store for Contacts
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'contacts',
|
||||
authUrl: MANA_AUTH_URL,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
|
|
@ -137,6 +138,9 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Load user settings
|
||||
await userSettings.load();
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
const savedSidebar = localStorage.getItem('contacts-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
|
|
@ -169,6 +173,7 @@
|
|||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* User Settings Store for ManaCore
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './authStore.svelte';
|
||||
|
||||
// TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env when available
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'manacore',
|
||||
authUrl: MANA_AUTH_URL,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/authStore.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
isNavCollapsed as collapsedStore,
|
||||
|
|
@ -129,7 +130,7 @@
|
|||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
// Initialize sidebar mode from localStorage
|
||||
const savedSidebar = localStorage.getItem('manacore-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
|
|
@ -144,6 +145,11 @@
|
|||
collapsedStore.set(true);
|
||||
}
|
||||
|
||||
// Load user settings from server
|
||||
if (authStore.isAuthenticated) {
|
||||
await userSettings.load();
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
|
@ -174,6 +180,7 @@
|
|||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
import { Button, Input, Card, PageHeader } from '@manacore/shared-ui';
|
||||
import { authStore } from '$lib/stores/authStore.svelte';
|
||||
import { creditsService, type CreditBalance } from '$lib/api/credits';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import type { NavPosition, ThemeMode } from '@manacore/shared-theme';
|
||||
|
||||
let loading = $state(true);
|
||||
let savingProfile = $state(false);
|
||||
|
|
@ -20,13 +22,40 @@
|
|||
if (authStore.isAuthenticated) {
|
||||
try {
|
||||
creditBalance = await creditsService.getBalance();
|
||||
// Load user settings from server
|
||||
await userSettings.load();
|
||||
} catch (e) {
|
||||
console.error('Failed to load credits:', e);
|
||||
console.error('Failed to load data:', e);
|
||||
}
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
|
||||
// Navigation position handler
|
||||
async function handleNavPositionChange(position: NavPosition) {
|
||||
await userSettings.updateGlobal({ nav: { ...userSettings.globalSettings.nav, desktopPosition: position } });
|
||||
}
|
||||
|
||||
// Sidebar collapsed handler
|
||||
async function handleSidebarChange(collapsed: boolean) {
|
||||
await userSettings.updateGlobal({ nav: { ...userSettings.globalSettings.nav, sidebarCollapsed: collapsed } });
|
||||
}
|
||||
|
||||
// Theme mode handler
|
||||
async function handleThemeModeChange(mode: ThemeMode) {
|
||||
await userSettings.updateGlobal({ theme: { ...userSettings.globalSettings.theme, mode } });
|
||||
}
|
||||
|
||||
// Color scheme handler
|
||||
async function handleColorSchemeChange(colorScheme: string) {
|
||||
await userSettings.updateGlobal({ theme: { ...userSettings.globalSettings.theme, colorScheme } });
|
||||
}
|
||||
|
||||
// Locale handler
|
||||
async function handleLocaleChange(locale: string) {
|
||||
await userSettings.updateGlobal({ locale });
|
||||
}
|
||||
|
||||
function formatCredits(amount: number): string {
|
||||
return amount.toLocaleString('de-DE');
|
||||
}
|
||||
|
|
@ -133,6 +162,193 @@
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Default App Settings Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-purple-100 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Standard App-Einstellungen</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Diese Einstellungen gelten für alle Mana Apps
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Navigation Settings -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Navigation
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center justify-between py-3 border-b border-border">
|
||||
<div>
|
||||
<p class="font-medium">Position (Desktop)</p>
|
||||
<p class="text-sm text-muted-foreground">Position der Navigation auf großen Bildschirmen</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors {userSettings.globalSettings.nav.desktopPosition === 'top'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-surface-hover hover:bg-surface-hover/80'}"
|
||||
onclick={() => handleNavPositionChange('top')}
|
||||
>
|
||||
Oben
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors {userSettings.globalSettings.nav.desktopPosition === 'bottom'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-surface-hover hover:bg-surface-hover/80'}"
|
||||
onclick={() => handleNavPositionChange('bottom')}
|
||||
>
|
||||
Unten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p class="font-medium">Sidebar eingeklappt</p>
|
||||
<p class="text-sm text-muted-foreground">Standard-Zustand der Sidebar</p>
|
||||
</div>
|
||||
<button
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors {userSettings.globalSettings.nav.sidebarCollapsed
|
||||
? 'bg-primary'
|
||||
: 'bg-gray-200 dark:bg-gray-700'}"
|
||||
onclick={() => handleSidebarChange(!userSettings.globalSettings.nav.sidebarCollapsed)}
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {userSettings.globalSettings.nav.sidebarCollapsed
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'}"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Settings -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Erscheinungsbild
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center justify-between py-3 border-b border-border">
|
||||
<div>
|
||||
<p class="font-medium">Farbmodus</p>
|
||||
<p class="text-sm text-muted-foreground">Hell, Dunkel oder automatisch</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium rounded-lg transition-colors {userSettings.globalSettings.theme.mode === 'light'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-surface-hover hover:bg-surface-hover/80'}"
|
||||
onclick={() => handleThemeModeChange('light')}
|
||||
>
|
||||
Hell
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium rounded-lg transition-colors {userSettings.globalSettings.theme.mode === 'dark'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-surface-hover hover:bg-surface-hover/80'}"
|
||||
onclick={() => handleThemeModeChange('dark')}
|
||||
>
|
||||
Dunkel
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium rounded-lg transition-colors {userSettings.globalSettings.theme.mode === 'system'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-surface-hover hover:bg-surface-hover/80'}"
|
||||
onclick={() => handleThemeModeChange('system')}
|
||||
>
|
||||
System
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p class="font-medium">Farbschema</p>
|
||||
<p class="text-sm text-muted-foreground">Akzentfarbe der Benutzeroberfläche</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#each [
|
||||
{ id: 'ocean', label: 'Ozean', color: 'bg-blue-500' },
|
||||
{ id: 'forest', label: 'Wald', color: 'bg-green-500' },
|
||||
{ id: 'sunset', label: 'Sonnenuntergang', color: 'bg-orange-500' },
|
||||
{ id: 'lavender', label: 'Lavendel', color: 'bg-purple-500' }
|
||||
] as scheme}
|
||||
<button
|
||||
class="w-8 h-8 rounded-full transition-all {scheme.color} {userSettings.globalSettings.theme.colorScheme === scheme.id
|
||||
? 'ring-2 ring-offset-2 ring-primary'
|
||||
: 'hover:scale-110'}"
|
||||
onclick={() => handleColorSchemeChange(scheme.id)}
|
||||
title={scheme.label}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language Settings -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Sprache
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p class="font-medium">Anzeigesprache</p>
|
||||
<p class="text-sm text-muted-foreground">Sprache der Benutzeroberfläche</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#each [
|
||||
{ id: 'de', label: 'DE' },
|
||||
{ id: 'en', label: 'EN' },
|
||||
{ id: 'fr', label: 'FR' },
|
||||
{ id: 'es', label: 'ES' },
|
||||
{ id: 'it', label: 'IT' }
|
||||
] as lang}
|
||||
<button
|
||||
class="px-3 py-2 text-sm font-medium rounded-lg transition-colors {userSettings.globalSettings.locale === lang.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-surface-hover hover:bg-surface-hover/80'}"
|
||||
onclick={() => handleLocaleChange(lang.id)}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if userSettings.syncing}
|
||||
<div class="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||
<span>Einstellungen werden synchronisiert...</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Credits Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* User Settings Store for ManaDeck
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './authStore.svelte';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'manadeck',
|
||||
authUrl: MANA_AUTH_URL,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { authStore } from '$lib/stores/authStore.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
|
|
@ -135,6 +136,9 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Load user settings
|
||||
await userSettings.load();
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
const savedSidebar = localStorage.getItem('manadeck-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
|
|
@ -177,6 +181,7 @@
|
|||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
|
|
|
|||
19
apps/picture/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
19
apps/picture/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* User Settings Store for Picture
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'picture',
|
||||
authUrl: MANA_AUTH_URL,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { locale } from 'svelte-i18n';
|
||||
|
|
@ -64,6 +65,13 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Load user settings when authenticated
|
||||
$effect(() => {
|
||||
if (authStore.initialized && authStore.user) {
|
||||
userSettings.load();
|
||||
}
|
||||
});
|
||||
|
||||
// Navigation items (Mana is in user dropdown via manaHref)
|
||||
const navItems: PillNavItem[] = [
|
||||
{ href: '/app/gallery', label: 'Galerie', icon: 'home' },
|
||||
|
|
@ -222,6 +230,7 @@
|
|||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
|
|
|
|||
19
apps/presi/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
19
apps/presi/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* User Settings Store for Presi
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'presi',
|
||||
authUrl: MANA_AUTH_URL,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
import { PillNavigation } from '@manacore/shared-ui';
|
||||
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import {
|
||||
|
|
@ -128,10 +129,13 @@
|
|||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
// Initialize auth
|
||||
auth.init();
|
||||
|
||||
// Load user settings
|
||||
await userSettings.load();
|
||||
|
||||
// Initialize theme
|
||||
const cleanup = theme.initialize();
|
||||
|
||||
|
|
@ -186,6 +190,7 @@
|
|||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
|
|
|
|||
19
apps/storage/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
19
apps/storage/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* User Settings Store for Storage
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'storage',
|
||||
authUrl: MANA_AUTH_URL,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
|
|
@ -146,6 +147,9 @@
|
|||
// Initialize auth
|
||||
await authStore.initialize();
|
||||
|
||||
// Load user settings
|
||||
await userSettings.load();
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
const savedSidebar = localStorage.getItem('storage-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
|
|
@ -194,6 +198,7 @@
|
|||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
|
|
|
|||
19
apps/zitare/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
19
apps/zitare/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* User Settings Store for Zitare
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'zitare',
|
||||
authUrl: MANA_AUTH_URL,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
|
||||
import {
|
||||
isSidebarMode as sidebarModeStore,
|
||||
|
|
@ -141,6 +142,11 @@
|
|||
// Initialize auth
|
||||
await authStore.initialize();
|
||||
|
||||
// Load user settings if authenticated
|
||||
if (authStore.isAuthenticated) {
|
||||
await userSettings.load();
|
||||
}
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
const savedSidebar = localStorage.getItem('zitare-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
|
|
@ -187,6 +193,7 @@
|
|||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
|
|
|
|||
357
docs/USER_SETTINGS.md
Normal file
357
docs/USER_SETTINGS.md
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
# Zentrale User Settings
|
||||
|
||||
Die User Settings werden zentral in `mana-core-auth` gespeichert und über alle Apps synchronisiert. Dies ermöglicht eine konsistente Benutzererfahrung über das gesamte Mana-Ökosystem.
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ mana-core-auth │
|
||||
│ (Port 3001) │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ auth.user_settings Table │ │
|
||||
│ │ ┌─────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ user_id │ globalSettings │ appOverrides │ │ │
|
||||
│ │ │─────────│────────────────│──────────────────────│ │ │
|
||||
│ │ │ uuid │ {nav, theme, │ {"calendar": {...}, │ │ │
|
||||
│ │ │ │ locale} │ "chat": {...}} │ │ │
|
||||
│ │ └─────────────────────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ REST API (JWT Auth)
|
||||
│
|
||||
┌──────────────────────┼──────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│Calendar │ │ Chat │ │ Picture │
|
||||
│ App │ │ App │ │ App │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
## Datenstruktur
|
||||
|
||||
### Global Settings
|
||||
|
||||
Globale Einstellungen gelten für **alle Apps** als Standardwerte:
|
||||
|
||||
```typescript
|
||||
interface GlobalSettings {
|
||||
nav: {
|
||||
desktopPosition: 'top' | 'bottom'; // Position der Navigation auf Desktop
|
||||
sidebarCollapsed: boolean; // Sidebar eingeklappt?
|
||||
};
|
||||
theme: {
|
||||
mode: 'light' | 'dark' | 'system'; // Theme-Modus
|
||||
colorScheme: string; // 'ocean' | 'nature' | 'lume' | 'stone'
|
||||
};
|
||||
locale: string; // 'de' | 'en' | 'fr' | 'es' | 'it'
|
||||
}
|
||||
```
|
||||
|
||||
**Standardwerte:**
|
||||
```typescript
|
||||
{
|
||||
nav: { desktopPosition: 'top', sidebarCollapsed: false },
|
||||
theme: { mode: 'system', colorScheme: 'ocean' },
|
||||
locale: 'de'
|
||||
}
|
||||
```
|
||||
|
||||
### App Overrides
|
||||
|
||||
Jede App kann die globalen Einstellungen überschreiben:
|
||||
|
||||
```typescript
|
||||
interface AppOverride {
|
||||
nav?: Partial<NavSettings>;
|
||||
theme?: Partial<ThemeSettings>;
|
||||
}
|
||||
|
||||
// Beispiel: Calendar hat eigene Einstellungen
|
||||
{
|
||||
"calendar": {
|
||||
nav: { desktopPosition: 'bottom' },
|
||||
theme: { colorScheme: 'nature' }
|
||||
},
|
||||
"chat": {
|
||||
theme: { mode: 'dark' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cascading / Auflösung
|
||||
|
||||
Die effektiven Settings einer App werden durch Merging berechnet:
|
||||
|
||||
```
|
||||
Effektive Settings = Global Settings + App Override (falls vorhanden)
|
||||
```
|
||||
|
||||
Beispiel:
|
||||
- Global: `{ theme: { mode: 'system', colorScheme: 'ocean' } }`
|
||||
- Calendar Override: `{ theme: { colorScheme: 'nature' } }`
|
||||
- **Effektiv für Calendar:** `{ theme: { mode: 'system', colorScheme: 'nature' } }`
|
||||
|
||||
## Backend API
|
||||
|
||||
### Endpoints
|
||||
|
||||
Alle Endpoints erfordern JWT-Authentifizierung via `Authorization: Bearer <token>`.
|
||||
|
||||
| Endpoint | Method | Beschreibung |
|
||||
|----------|--------|--------------|
|
||||
| `/api/v1/settings` | GET | Alle Settings des Users abrufen |
|
||||
| `/api/v1/settings/global` | PATCH | Globale Settings aktualisieren |
|
||||
| `/api/v1/settings/app/:appId` | PATCH | App-Override setzen/aktualisieren |
|
||||
| `/api/v1/settings/app/:appId` | DELETE | App-Override löschen (zurück zu Global) |
|
||||
|
||||
### Beispiel-Requests
|
||||
|
||||
```bash
|
||||
# Token holen
|
||||
TOKEN=$(curl -s -X POST http://localhost:3001/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "user@example.com", "password": "password"}' | jq -r '.accessToken')
|
||||
|
||||
# Alle Settings abrufen
|
||||
curl http://localhost:3001/api/v1/settings \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# Response:
|
||||
{
|
||||
"success": true,
|
||||
"globalSettings": {
|
||||
"nav": { "desktopPosition": "top", "sidebarCollapsed": false },
|
||||
"theme": { "mode": "system", "colorScheme": "ocean" },
|
||||
"locale": "de"
|
||||
},
|
||||
"appOverrides": {}
|
||||
}
|
||||
|
||||
# Sprache global ändern
|
||||
curl -X PATCH http://localhost:3001/api/v1/settings/global \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"locale": "en"}'
|
||||
|
||||
# Theme nur für Calendar überschreiben
|
||||
curl -X PATCH http://localhost:3001/api/v1/settings/app/calendar \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"theme": {"colorScheme": "nature"}}'
|
||||
|
||||
# Calendar-Override löschen (zurück zu Global)
|
||||
curl -X DELETE http://localhost:3001/api/v1/settings/app/calendar \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
### Store erstellen
|
||||
|
||||
Jede App erstellt einen eigenen User Settings Store in `src/lib/stores/user-settings.svelte.ts`:
|
||||
|
||||
```typescript
|
||||
import { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'calendar', // Eindeutige App-ID
|
||||
authUrl: 'http://localhost:3001',
|
||||
getAccessToken: () => authStore.getAccessToken()
|
||||
});
|
||||
```
|
||||
|
||||
### Store laden
|
||||
|
||||
Im Root-Layout nach erfolgreicher Authentifizierung:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(async () => {
|
||||
await authStore.initialize();
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
await userSettings.load();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Settings lesen
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
</script>
|
||||
|
||||
<!-- Resolved Settings (Global + App Override) -->
|
||||
<p>Nav Position: {userSettings.nav.desktopPosition}</p>
|
||||
<p>Theme Mode: {userSettings.theme.mode}</p>
|
||||
<p>Locale: {userSettings.locale}</p>
|
||||
|
||||
<!-- Status -->
|
||||
<p>Loading: {userSettings.syncing}</p>
|
||||
<p>Has Override: {userSettings.hasAppOverride}</p>
|
||||
```
|
||||
|
||||
### Settings ändern
|
||||
|
||||
```typescript
|
||||
// Globale Settings ändern (gilt für alle Apps)
|
||||
await userSettings.updateGlobal({
|
||||
locale: 'en',
|
||||
theme: { mode: 'dark' }
|
||||
});
|
||||
|
||||
// App-Override setzen (nur diese App)
|
||||
await userSettings.updateAppOverride({
|
||||
nav: { desktopPosition: 'bottom' },
|
||||
theme: { colorScheme: 'nature' }
|
||||
});
|
||||
|
||||
// App-Override löschen (zurück zu Global)
|
||||
await userSettings.removeAppOverride();
|
||||
```
|
||||
|
||||
### Beispiel: PillNavigation
|
||||
|
||||
```svelte
|
||||
<PillNavigation
|
||||
desktopPosition={userSettings.nav.desktopPosition}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
## Store API Referenz
|
||||
|
||||
### Properties (readonly)
|
||||
|
||||
| Property | Type | Beschreibung |
|
||||
|----------|------|--------------|
|
||||
| `nav` | `NavSettings` | Resolved Navigation Settings |
|
||||
| `theme` | `ThemeSettings` | Resolved Theme Settings |
|
||||
| `locale` | `string` | Aktuelle Sprache |
|
||||
| `globalSettings` | `GlobalSettings` | Rohe globale Settings |
|
||||
| `hasAppOverride` | `boolean` | Hat diese App einen Override? |
|
||||
| `syncing` | `boolean` | Wird gerade synchronisiert? |
|
||||
| `loaded` | `boolean` | Wurden Settings geladen? |
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Beschreibung |
|
||||
|--------|--------------|
|
||||
| `load()` | Settings vom Server laden |
|
||||
| `updateGlobal(settings)` | Globale Settings aktualisieren |
|
||||
| `updateAppOverride(settings)` | App-Override setzen |
|
||||
| `removeAppOverride()` | App-Override löschen |
|
||||
|
||||
## Features
|
||||
|
||||
### Optimistic Updates
|
||||
|
||||
Änderungen werden sofort in der UI angezeigt, bevor die Server-Antwort eintrifft. Bei einem Fehler wird automatisch auf den vorherigen Zustand zurückgesetzt.
|
||||
|
||||
### localStorage Cache
|
||||
|
||||
Settings werden lokal gecached für:
|
||||
- Schnelle UI beim App-Start (keine Wartezeit auf Server)
|
||||
- Offline-Unterstützung (letzte bekannte Settings)
|
||||
|
||||
Cache-Key: `manacore-user-settings-{appId}`
|
||||
|
||||
### Deep Merge
|
||||
|
||||
Partielle Updates werden automatisch gemerged:
|
||||
|
||||
```typescript
|
||||
// Nur colorScheme ändern, mode bleibt erhalten
|
||||
await userSettings.updateGlobal({
|
||||
theme: { colorScheme: 'nature' }
|
||||
});
|
||||
// Ergebnis: { theme: { mode: 'system', colorScheme: 'nature' } }
|
||||
```
|
||||
|
||||
## Datenbank-Schema
|
||||
|
||||
Tabelle: `auth.user_settings`
|
||||
|
||||
| Column | Type | Beschreibung |
|
||||
|--------|------|--------------|
|
||||
| `user_id` | TEXT (PK) | Referenz zu auth.users |
|
||||
| `global_settings` | JSONB | Globale Einstellungen |
|
||||
| `app_overrides` | JSONB | Pro-App Überschreibungen |
|
||||
| `created_at` | TIMESTAMP | Erstellungszeitpunkt |
|
||||
| `updated_at` | TIMESTAMP | Letzte Aktualisierung |
|
||||
|
||||
## Dateien
|
||||
|
||||
### Backend (mana-core-auth)
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `src/settings/settings.module.ts` | NestJS Module |
|
||||
| `src/settings/settings.controller.ts` | REST API Endpoints |
|
||||
| `src/settings/settings.service.ts` | Business Logic |
|
||||
| `src/settings/dto/index.ts` | DTOs für Requests |
|
||||
| `src/db/schema/auth.schema.ts` | Drizzle Schema (`userSettings`) |
|
||||
|
||||
### Shared Package
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `packages/shared-theme/src/user-settings-store.svelte.ts` | Svelte 5 Store Factory |
|
||||
| `packages/shared-theme/src/types.ts` | TypeScript Interfaces |
|
||||
|
||||
### App Integration (Beispiel Calendar)
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `apps/calendar/apps/web/src/lib/stores/user-settings.svelte.ts` | Store-Instanz |
|
||||
| `apps/calendar/apps/web/src/routes/+layout.svelte` | Integration in Layout |
|
||||
|
||||
## Integrierte Apps
|
||||
|
||||
Folgende Apps nutzen bereits die zentralen User Settings:
|
||||
|
||||
- Calendar (`calendar`)
|
||||
- Chat (`chat`)
|
||||
- Contacts (`contacts`)
|
||||
- ManaCore (`manacore`)
|
||||
- ManaDeck (`manadeck`)
|
||||
- Picture (`picture`)
|
||||
- Presi (`presi`)
|
||||
- Storage (`storage`)
|
||||
- Zitare (`zitare`)
|
||||
|
||||
## Neue App integrieren
|
||||
|
||||
1. **Store erstellen** in `src/lib/stores/user-settings.svelte.ts`:
|
||||
```typescript
|
||||
import { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'my-app', // Eindeutige ID
|
||||
authUrl: import.meta.env.PUBLIC_MANA_CORE_AUTH_URL,
|
||||
getAccessToken: () => authStore.getAccessToken()
|
||||
});
|
||||
```
|
||||
|
||||
2. **Im Layout laden** nach Auth-Check:
|
||||
```typescript
|
||||
if (authStore.isAuthenticated) {
|
||||
await userSettings.load();
|
||||
}
|
||||
```
|
||||
|
||||
3. **Settings verwenden**:
|
||||
```svelte
|
||||
<PillNavigation desktopPosition={userSettings.nav.desktopPosition} />
|
||||
```
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -131,6 +131,8 @@
|
|||
onA11yReduceMotionChange?: (reduce: boolean) => void;
|
||||
/** Show a11y quick toggles in theme dropdown */
|
||||
showA11yQuickToggles?: boolean;
|
||||
/** Desktop navigation position (mobile always at bottom) */
|
||||
desktopPosition?: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -172,6 +174,7 @@
|
|||
a11yReduceMotion = false,
|
||||
onA11yReduceMotionChange,
|
||||
showA11yQuickToggles = false,
|
||||
desktopPosition = 'top',
|
||||
}: Props = $props();
|
||||
|
||||
// Type guards for elements
|
||||
|
|
@ -313,6 +316,7 @@
|
|||
<nav
|
||||
class="pill-nav"
|
||||
class:sidebar-mode={isSidebarMode}
|
||||
class:desktop-bottom={desktopPosition === 'bottom'}
|
||||
style={primaryColor ? `--pill-primary-color: ${primaryColor}` : ''}
|
||||
>
|
||||
<div class="pill-nav-container" class:sidebar-container={isSidebarMode}>
|
||||
|
|
@ -701,7 +705,7 @@
|
|||
|
||||
<!-- FAB for collapsed state -->
|
||||
{#if isCollapsed}
|
||||
<button onclick={expandNav} class="nav-fab glass-pill" title="Expand navigation">
|
||||
<button onclick={expandNav} class="nav-fab glass-pill" class:desktop-bottom={desktopPosition === 'bottom'} title="Expand navigation">
|
||||
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
|
|
@ -724,7 +728,16 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Mobile: position at bottom */
|
||||
/* Desktop bottom position */
|
||||
@media (min-width: 769px) {
|
||||
.pill-nav.desktop-bottom:not(.sidebar-mode) {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
padding: 1rem 0 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: always position at bottom */
|
||||
@media (max-width: 768px) {
|
||||
.pill-nav:not(.sidebar-mode) {
|
||||
top: auto;
|
||||
|
|
@ -1137,7 +1150,16 @@
|
|||
border: none;
|
||||
}
|
||||
|
||||
/* Mobile: FAB at bottom left */
|
||||
/* Desktop: FAB at bottom when desktop-bottom */
|
||||
@media (min-width: 769px) {
|
||||
.nav-fab.desktop-bottom {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
border-radius: 0 1rem 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: FAB always at bottom left */
|
||||
@media (max-width: 768px) {
|
||||
.nav-fab {
|
||||
top: auto;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import configuration from './config/configuration';
|
|||
import { AuthModule } from './auth/auth.module';
|
||||
import { CreditsModule } from './credits/credits.module';
|
||||
import { FeedbackModule } from './feedback/feedback.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { AiModule } from './ai/ai.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
|||
AuthModule,
|
||||
CreditsModule,
|
||||
FeedbackModule,
|
||||
SettingsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -124,3 +124,27 @@ export const jwks = authSchema.table('jwks', {
|
|||
privateKey: text('private_key').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// User settings table (synced across all apps)
|
||||
export const userSettings = authSchema.table('user_settings', {
|
||||
userId: text('user_id')
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
|
||||
// Global defaults (applies to all apps)
|
||||
// { nav: { desktopPosition, sidebarCollapsed }, theme: { mode, colorScheme }, locale }
|
||||
globalSettings: jsonb('global_settings')
|
||||
.default({
|
||||
nav: { desktopPosition: 'top', sidebarCollapsed: false },
|
||||
theme: { mode: 'system', colorScheme: 'ocean' },
|
||||
locale: 'de',
|
||||
})
|
||||
.notNull(),
|
||||
|
||||
// Per-app overrides
|
||||
// { "calendar": { nav: {...}, theme: {...} }, "chat": {...} }
|
||||
appOverrides: jsonb('app_overrides').default({}).notNull(),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
|
|
|||
81
services/mana-core-auth/src/settings/dto/index.ts
Normal file
81
services/mana-core-auth/src/settings/dto/index.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { IsOptional, IsString, IsObject, ValidateNested, IsBoolean, IsIn } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
// Nav settings
|
||||
export class NavSettingsDto {
|
||||
@IsOptional()
|
||||
@IsIn(['top', 'bottom'])
|
||||
desktopPosition?: 'top' | 'bottom';
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
sidebarCollapsed?: boolean;
|
||||
}
|
||||
|
||||
// Theme settings
|
||||
export class ThemeSettingsDto {
|
||||
@IsOptional()
|
||||
@IsIn(['light', 'dark', 'system'])
|
||||
mode?: 'light' | 'dark' | 'system';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
colorScheme?: string;
|
||||
}
|
||||
|
||||
// Global settings update
|
||||
export class UpdateGlobalSettingsDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => NavSettingsDto)
|
||||
nav?: NavSettingsDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ThemeSettingsDto)
|
||||
theme?: ThemeSettingsDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
// App override update
|
||||
export class UpdateAppOverrideDto {
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => NavSettingsDto)
|
||||
nav?: NavSettingsDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ThemeSettingsDto)
|
||||
theme?: ThemeSettingsDto;
|
||||
}
|
||||
|
||||
// Response types (for documentation)
|
||||
export interface NavSettings {
|
||||
desktopPosition: 'top' | 'bottom';
|
||||
sidebarCollapsed: boolean;
|
||||
}
|
||||
|
||||
export interface ThemeSettings {
|
||||
mode: 'light' | 'dark' | 'system';
|
||||
colorScheme: string;
|
||||
}
|
||||
|
||||
export interface GlobalSettings {
|
||||
nav: NavSettings;
|
||||
theme: ThemeSettings;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export interface AppOverride {
|
||||
nav?: Partial<NavSettings>;
|
||||
theme?: Partial<ThemeSettings>;
|
||||
}
|
||||
|
||||
export interface UserSettingsResponse {
|
||||
globalSettings: GlobalSettings;
|
||||
appOverrides: Record<string, AppOverride>;
|
||||
}
|
||||
70
services/mana-core-auth/src/settings/settings.controller.ts
Normal file
70
services/mana-core-auth/src/settings/settings.controller.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { Controller, Get, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
import { UpdateGlobalSettingsDto, UpdateAppOverrideDto } from './dto';
|
||||
|
||||
@Controller('settings')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SettingsController {
|
||||
constructor(private readonly settingsService: SettingsService) {}
|
||||
|
||||
/**
|
||||
* GET /api/v1/settings
|
||||
* Get all user settings (global + app overrides)
|
||||
*/
|
||||
@Get()
|
||||
async getSettings(@CurrentUser() user: CurrentUserData) {
|
||||
const settings = await this.settingsService.getSettings(user.userId);
|
||||
return {
|
||||
success: true,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/settings/global
|
||||
* Update global settings (applies to all apps by default)
|
||||
*/
|
||||
@Patch('global')
|
||||
async updateGlobalSettings(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: UpdateGlobalSettingsDto
|
||||
) {
|
||||
const settings = await this.settingsService.updateGlobalSettings(user.userId, dto);
|
||||
return {
|
||||
success: true,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/settings/app/:appId
|
||||
* Update app-specific override settings
|
||||
*/
|
||||
@Patch('app/:appId')
|
||||
async updateAppOverride(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('appId') appId: string,
|
||||
@Body() dto: UpdateAppOverrideDto
|
||||
) {
|
||||
const settings = await this.settingsService.updateAppOverride(user.userId, appId, dto);
|
||||
return {
|
||||
success: true,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/settings/app/:appId
|
||||
* Remove app-specific override (revert to global settings)
|
||||
*/
|
||||
@Delete('app/:appId')
|
||||
async removeAppOverride(@CurrentUser() user: CurrentUserData, @Param('appId') appId: string) {
|
||||
const settings = await this.settingsService.removeAppOverride(user.userId, appId);
|
||||
return {
|
||||
success: true,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
}
|
||||
11
services/mana-core-auth/src/settings/settings.module.ts
Normal file
11
services/mana-core-auth/src/settings/settings.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SettingsController } from './settings.controller';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
|
||||
@Module({
|
||||
controllers: [SettingsController],
|
||||
providers: [SettingsService, JwtAuthGuard],
|
||||
exports: [SettingsService],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
191
services/mana-core-auth/src/settings/settings.service.ts
Normal file
191
services/mana-core-auth/src/settings/settings.service.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb } from '../db/connection';
|
||||
import { userSettings } from '../db/schema';
|
||||
import {
|
||||
UpdateGlobalSettingsDto,
|
||||
UpdateAppOverrideDto,
|
||||
GlobalSettings,
|
||||
AppOverride,
|
||||
UserSettingsResponse,
|
||||
} from './dto';
|
||||
|
||||
// Default settings for new users
|
||||
const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
nav: { desktopPosition: 'top', sidebarCollapsed: false },
|
||||
theme: { mode: 'system', colorScheme: 'ocean' },
|
||||
locale: 'de',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
private readonly logger = new Logger(SettingsService.name);
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
private getDb() {
|
||||
const databaseUrl = this.configService.get<string>('database.url');
|
||||
return getDb(databaseUrl!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user settings, creating defaults if they don't exist
|
||||
*/
|
||||
async getSettings(userId: string): Promise<UserSettingsResponse> {
|
||||
const db = this.getDb();
|
||||
|
||||
// Try to get existing settings
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
globalSettings: existing.globalSettings as GlobalSettings,
|
||||
appOverrides: existing.appOverrides as Record<string, AppOverride>,
|
||||
};
|
||||
}
|
||||
|
||||
// Create default settings for new user
|
||||
const [created] = await db
|
||||
.insert(userSettings)
|
||||
.values({
|
||||
userId,
|
||||
globalSettings: DEFAULT_GLOBAL_SETTINGS,
|
||||
appOverrides: {},
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.debug(`Created default settings for user ${userId}`);
|
||||
|
||||
return {
|
||||
globalSettings: created.globalSettings as GlobalSettings,
|
||||
appOverrides: created.appOverrides as Record<string, AppOverride>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update global settings (merges with existing)
|
||||
*/
|
||||
async updateGlobalSettings(
|
||||
userId: string,
|
||||
dto: UpdateGlobalSettingsDto
|
||||
): Promise<UserSettingsResponse> {
|
||||
const db = this.getDb();
|
||||
|
||||
// Get current settings
|
||||
const current = await this.getSettings(userId);
|
||||
|
||||
// Deep merge the settings
|
||||
const updatedGlobal: GlobalSettings = {
|
||||
nav: { ...current.globalSettings.nav, ...dto.nav },
|
||||
theme: { ...current.globalSettings.theme, ...dto.theme },
|
||||
locale: dto.locale ?? current.globalSettings.locale,
|
||||
};
|
||||
|
||||
// Update in database
|
||||
const [updated] = await db
|
||||
.update(userSettings)
|
||||
.set({
|
||||
globalSettings: updatedGlobal,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
this.logger.debug(`Updated global settings for user ${userId}`);
|
||||
|
||||
return {
|
||||
globalSettings: updated.globalSettings as GlobalSettings,
|
||||
appOverrides: updated.appOverrides as Record<string, AppOverride>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create app-specific override
|
||||
*/
|
||||
async updateAppOverride(
|
||||
userId: string,
|
||||
appId: string,
|
||||
dto: UpdateAppOverrideDto
|
||||
): Promise<UserSettingsResponse> {
|
||||
const db = this.getDb();
|
||||
|
||||
// Get current settings
|
||||
const current = await this.getSettings(userId);
|
||||
|
||||
// Merge with existing app override
|
||||
const existingOverride = current.appOverrides[appId] || {};
|
||||
const updatedOverride: AppOverride = {
|
||||
nav: dto.nav ? { ...existingOverride.nav, ...dto.nav } : existingOverride.nav,
|
||||
theme: dto.theme ? { ...existingOverride.theme, ...dto.theme } : existingOverride.theme,
|
||||
};
|
||||
|
||||
// Clean up empty objects
|
||||
if (updatedOverride.nav && Object.keys(updatedOverride.nav).length === 0) {
|
||||
delete updatedOverride.nav;
|
||||
}
|
||||
if (updatedOverride.theme && Object.keys(updatedOverride.theme).length === 0) {
|
||||
delete updatedOverride.theme;
|
||||
}
|
||||
|
||||
// Update app overrides
|
||||
const updatedOverrides = { ...current.appOverrides };
|
||||
if (Object.keys(updatedOverride).length > 0) {
|
||||
updatedOverrides[appId] = updatedOverride;
|
||||
} else {
|
||||
delete updatedOverrides[appId];
|
||||
}
|
||||
|
||||
// Update in database
|
||||
const [updated] = await db
|
||||
.update(userSettings)
|
||||
.set({
|
||||
appOverrides: updatedOverrides,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
this.logger.debug(`Updated app override for user ${userId}, app ${appId}`);
|
||||
|
||||
return {
|
||||
globalSettings: updated.globalSettings as GlobalSettings,
|
||||
appOverrides: updated.appOverrides as Record<string, AppOverride>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove app-specific override (revert to global settings)
|
||||
*/
|
||||
async removeAppOverride(userId: string, appId: string): Promise<UserSettingsResponse> {
|
||||
const db = this.getDb();
|
||||
|
||||
// Get current settings
|
||||
const current = await this.getSettings(userId);
|
||||
|
||||
// Remove the app override
|
||||
const updatedOverrides = { ...current.appOverrides };
|
||||
delete updatedOverrides[appId];
|
||||
|
||||
// Update in database
|
||||
const [updated] = await db
|
||||
.update(userSettings)
|
||||
.set({
|
||||
appOverrides: updatedOverrides,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(userSettings.userId, userId))
|
||||
.returning();
|
||||
|
||||
this.logger.debug(`Removed app override for user ${userId}, app ${appId}`);
|
||||
|
||||
return {
|
||||
globalSettings: updated.globalSettings as GlobalSettings,
|
||||
appOverrides: updated.appOverrides as Record<string, AppOverride>,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue