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

@ -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(),
});

View file

@ -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}

View 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(),
});

View file

@ -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}

View file

@ -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(),
});

View file

@ -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}

View file

@ -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(),
});

View file

@ -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}

View file

@ -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">

View file

@ -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(),
});

View file

@ -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}

View 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(),
});

View file

@ -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}

View 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(),
});

View file

@ -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}

View 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(),
});

View file

@ -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}

View 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(),
});

View file

@ -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
View 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} />
```

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,
};
}

View file

@ -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;

View file

@ -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: [
{

View file

@ -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(),
});

View 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>;
}

View 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,
};
}
}

View 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 {}

View 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>,
};
}
}