From d19bd1621044cbda18ec5eaebc361003ad6b8976 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 22 Dec 2025 18:01:26 -0500 Subject: [PATCH] user auth, settings, profile --- core/lib/Controllers/InitController.php | 16 ++ .../lib/Controllers/UserProfileController.php | 63 ++++++ .../Controllers/UserSettingsController.php | 47 ++-- core/src/composables/index.ts | 2 +- core/src/composables/usePreferences.ts | 208 ------------------ core/src/composables/useUser.ts | 86 ++++++++ core/src/models/userProfile.ts | 98 +++++++++ core/src/models/userSettings.ts | 73 ++++++ core/src/private.ts | 6 +- core/src/router/index.ts | 9 +- core/src/services/preferenceService.ts | 82 ------- core/src/services/user/index.ts | 1 + core/src/services/user/userService.ts | 153 +++++++++++++ core/src/stores/preferencesStore.ts | 114 ---------- core/src/stores/userStore.ts | 191 ++++++++++++++-- core/src/types/user/Identity.ts | 7 - core/src/types/user/userProfileTypes.ts | 13 ++ core/src/types/user/userSettingsTypes.ts | 7 + core/src/views/authentication/AuthLogin.vue | 2 +- 19 files changed, 711 insertions(+), 467 deletions(-) create mode 100644 core/lib/Controllers/UserProfileController.php delete mode 100644 core/src/composables/usePreferences.ts create mode 100644 core/src/composables/useUser.ts create mode 100644 core/src/models/userProfile.ts create mode 100644 core/src/models/userSettings.ts delete mode 100644 core/src/services/preferenceService.ts create mode 100644 core/src/services/user/index.ts create mode 100644 core/src/services/user/userService.ts delete mode 100644 core/src/stores/preferencesStore.ts delete mode 100644 core/src/types/user/Identity.ts create mode 100644 core/src/types/user/userProfileTypes.ts create mode 100644 core/src/types/user/userSettingsTypes.ts diff --git a/core/lib/Controllers/InitController.php b/core/lib/Controllers/InitController.php index d6ff660..a876647 100644 --- a/core/lib/Controllers/InitController.php +++ b/core/lib/Controllers/InitController.php @@ -4,6 +4,8 @@ namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; use KTXC\Module\ModuleManager; +use KTXC\Service\UserService; +use KTXC\SessionIdentity; use KTXF\Controller\ControllerAbstract; use KTXC\SessionTenant; use KTXF\Routing\Attributes\AuthenticatedRoute; @@ -12,7 +14,9 @@ class InitController extends ControllerAbstract { public function __construct( private readonly SessionTenant $tenant, + private readonly SessionIdentity $userIdentity, private readonly ModuleManager $moduleManager, + private readonly UserService $userService, ) {} #[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])] @@ -36,6 +40,18 @@ class InitController extends ControllerAbstract 'label' => $this->tenant->label(), ]; + // user + $configuration['user'] = [ + 'auth' => [ + 'identifier' => $this->userIdentity->identifier(), + 'identity' => $this->userIdentity->identity()->getIdentity(), + 'label' => $this->userIdentity->label(), + 'permissions' => [], // TODO: Implement permissions + ], + 'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()), + 'settings' => $this->userService->fetchSettings(), + ]; + return new JsonResponse($configuration); } diff --git a/core/lib/Controllers/UserProfileController.php b/core/lib/Controllers/UserProfileController.php new file mode 100644 index 0000000..bbb7269 --- /dev/null +++ b/core/lib/Controllers/UserProfileController.php @@ -0,0 +1,63 @@ +userIdentity->identifier(); + + $profile = $this->userService->fetchProfile($userId); + + return new JsonResponse($profile, JsonResponse::HTTP_OK); + } + + /** + * Update user profile fields + * Only editable fields can be updated. Provider-managed fields are automatically filtered out. + * + * @param array $profile Key-value pairs of profile fields to update + * + * @example request body: + * { + * "name_given": "John", + * "name_family": "Doe", + * "phone": "+1234567890" + * } + * + * @return JsonResponse Updated profile data + */ + #[AuthenticatedRoute('/user/profile', name: 'user.profile.update', methods: ['PUT', 'PATCH'])] + public function update(array $profile = []): JsonResponse + { + $userId = $this->userIdentity->identifier(); + + // storeProfile automatically filters out provider-managed fields + $this->userService->storeProfile($userId, $profile); + + // Return updated profile + $updatedProfile = $this->userService->fetchProfile($userId); + + return new JsonResponse($updatedProfile, JsonResponse::HTTP_OK); + } +} diff --git a/core/lib/Controllers/UserSettingsController.php b/core/lib/Controllers/UserSettingsController.php index 0a673d0..d53443a 100644 --- a/core/lib/Controllers/UserSettingsController.php +++ b/core/lib/Controllers/UserSettingsController.php @@ -13,46 +13,47 @@ class UserSettingsController extends ControllerAbstract { public function __construct( private readonly SessionTenant $tenantIdentity, - private readonly SessionIdentity $userIdentity, + private readonly SessionIdentity $userIdentity, private readonly UserService $userService ) {} - /** - * retrieve user settings + * Retrieve user settings + * If no specific settings are requested, all settings are returned * - * @param array $settings list of settings to retrieve - * - * @example request body: - * { - * "settings": ["key1", "key2"] - * } + * @return JsonResponse Settings data as key-value pairs */ - #[AuthenticatedRoute('/user/settings/read', name: 'user.settings.read', methods: ['PUT', 'PATCH'])] - public function read(array $settings = []): JsonResponse + #[AuthenticatedRoute('/user/settings', name: 'user.settings.read', methods: ['GET'])] + public function read(): JsonResponse { - // authorize request - $tenantId = $this->tenantIdentity->identifier(); - $userId = $this->userIdentity->identifier(); + // Fetch all settings + $settings = $this->userService->fetchSettings([]); - return $this->userService->fetchSettings($tenantId, $userId, $settings); + return new JsonResponse($settings, JsonResponse::HTTP_OK); } /** - * store user settings + * Update user settings * - * @param array $settings key-value pairs of settings to store + * @param array $settings Key-value pairs of settings to update * * @example request body: * { - * "key1": "value1", - * "key2": "value2" + * "theme": "dark", + * "language": "en", + * "notifications": true * } + * + * @return JsonResponse Updated settings data */ - #[AuthenticatedRoute('/user/settings/write', name: 'user.settings.write', methods: ['PUT', 'PATCH'])] - public function write(array $settings): JsonResponse + #[AuthenticatedRoute('/user/settings', name: 'user.settings.update', methods: ['PUT', 'PATCH'])] + public function update(array $settings = []): JsonResponse { - return new JsonResponse(['status' => 'not_implemented'], JsonResponse::HTTP_NOT_IMPLEMENTED); + $this->userService->storeSettings($settings); + + // Return updated settings + $updatedSettings = $this->userService->fetchSettings(array_keys($settings)); + + return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK); } - } diff --git a/core/src/composables/index.ts b/core/src/composables/index.ts index 1eb6e5d..700784e 100644 --- a/core/src/composables/index.ts +++ b/core/src/composables/index.ts @@ -3,4 +3,4 @@ */ export { useClipboard } from './useClipboard' -export { usePreferences } from './usePreferences' +export { useUser } from './useUser' diff --git a/core/src/composables/usePreferences.ts b/core/src/composables/usePreferences.ts deleted file mode 100644 index d7bc271..0000000 --- a/core/src/composables/usePreferences.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { computed, ref } from 'vue'; -import { usePreferencesStore, type PreferencesState } from '@KTXC/stores/preferencesStore'; -import { preferenceService } from '@KTXC/services/preferenceService'; - -/** - * Composable for managing user preferences - * Provides reactive access to preferences with automatic sync to server - */ -export function usePreferences() { - const store = usePreferencesStore(); - const saving = ref(false); - const error = ref(null); - - /** - * Get all preferences - */ - const preferences = computed(() => store.preferences); - - /** - * Get locked preference keys - */ - const locks = computed(() => store.locks); - - /** - * Check if a preference is locked by tenant admin - */ - const isLocked = (key: keyof PreferencesState): boolean => { - return store.isLocked(key); - }; - - /** - * Get a single preference value - */ - const get = (key: K): PreferencesState[K] => { - return store.getPreference(key); - }; - - /** - * Set a single preference and sync to server - */ - const set = async ( - key: K, - value: PreferencesState[K], - syncToServer = true - ): Promise => { - error.value = null; - - // Update local state first - const success = store.setPreference(key, value); - if (!success) { - error.value = `Preference "${key}" is locked by administrator`; - return false; - } - - // Sync to server if requested - if (syncToServer) { - saving.value = true; - try { - const response = await preferenceService.setPreference(key, value); - // Update store with server response to ensure consistency - store.setPreferences(response.effective); - store.setLocks(response.locks); - } catch (err) { - error.value = err instanceof Error ? err.message : 'Failed to save preference'; - return false; - } finally { - saving.value = false; - } - } - - return true; - }; - - /** - * Update multiple preferences and sync to server - */ - const update = async ( - prefs: Partial, - syncToServer = true - ): Promise<{ success: boolean; rejected: string[] }> => { - error.value = null; - - // Update local state - store.setPreferences(prefs); - - if (syncToServer) { - saving.value = true; - try { - const response = await preferenceService.updatePreferences(prefs); - store.setPreferences(response.effective); - store.setLocks(response.locks); - return { - success: true, - rejected: response.rejectedKeys ?? [], - }; - } catch (err) { - error.value = err instanceof Error ? err.message : 'Failed to save preferences'; - return { success: false, rejected: [] }; - } finally { - saving.value = false; - } - } - - return { success: true, rejected: [] }; - }; - - /** - * Reset preferences to tenant defaults - */ - const reset = async (): Promise => { - error.value = null; - saving.value = true; - - try { - const response = await preferenceService.resetPreferences(); - store.setPreferences(response.effective); - store.setLocks(response.locks); - return true; - } catch (err) { - error.value = err instanceof Error ? err.message : 'Failed to reset preferences'; - return false; - } finally { - saving.value = false; - } - }; - - /** - * Refresh preferences from server - */ - const refresh = async (): Promise => { - error.value = null; - store.setLoading(true); - - try { - const response = await preferenceService.getPreferences(); - store.setPreferences(response.effective); - store.setLocks(response.locks); - return true; - } catch (err) { - error.value = err instanceof Error ? err.message : 'Failed to load preferences'; - return false; - } finally { - store.setLoading(false); - } - }; - - // Individual preference computed refs for convenience - const theme = computed({ - get: () => store.preferences.theme, - set: (value: string) => set('theme', value), - }); - - const language = computed({ - get: () => store.preferences.language, - set: (value: string) => set('language', value), - }); - - const timezone = computed({ - get: () => store.preferences.timezone, - set: (value: string) => set('timezone', value), - }); - - const dateFormat = computed({ - get: () => store.preferences.date_format, - set: (value: string) => set('date_format', value), - }); - - const timeFormat = computed({ - get: () => store.preferences.time_format, - set: (value: string) => set('time_format', value), - }); - - const weekStart = computed({ - get: () => store.preferences.week_start, - set: (value: string) => set('week_start', value), - }); - - const defaultModule = computed({ - get: () => store.preferences.default_module ?? '', - set: (value: string) => set('default_module', value), - }); - - return { - // State - preferences, - locks, - saving, - error, - loading: computed(() => store.loading), - - // Methods - get, - set, - update, - reset, - refresh, - isLocked, - - // Individual preference refs - theme, - language, - timezone, - dateFormat, - timeFormat, - weekStart, - defaultModule, - }; -} diff --git a/core/src/composables/useUser.ts b/core/src/composables/useUser.ts new file mode 100644 index 0000000..86f927a --- /dev/null +++ b/core/src/composables/useUser.ts @@ -0,0 +1,86 @@ +import { computed } from 'vue'; +import { useUserStore } from '@KTXC/stores/userStore'; + +/** + * Composable for accessing user authentication, profile, and settings + * Provides a clean API for modules to access user data + */ +export function useUser() { + const store = useUserStore(); + + // ========================================================================= + // Authentication + // ========================================================================= + + const isAuthenticated = computed(() => store.isAuthenticated); + const identifier = computed(() => store.identifier); + const identity = computed(() => store.identity); + const label = computed(() => store.label); + const permissions = computed(() => store.permissions); + + const hasPermission = (permission: string): boolean => { + return store.permissions.includes(permission); + }; + + const logout = async (): Promise => { + await store.logout(); + }; + + // ========================================================================= + // Profile + // ========================================================================= + + const profile = computed(() => store.profileFields); + const editableProfile = computed(() => store.editableProfileFields); + const managedProfile = computed(() => store.managedProfileFields); + + const getProfile = (key: string): any => { + return store.getProfileField(key); + }; + + const setProfile = (key: string, value: any): boolean => { + return store.setProfileField(key, value); + }; + + const isProfileEditable = (key: string): boolean => { + return store.isProfileFieldEditable(key); + }; + + // ========================================================================= + // Settings + // ========================================================================= + + const settings = computed(() => store.settings); + + const getSetting = (key: string): any => { + return store.getSetting(key); + }; + + const setSetting = (key: string, value: any): void => { + store.setSetting(key, value); + }; + + return { + // Auth + isAuthenticated, + identifier, + identity, + label, + permissions, + hasPermission, + logout, + + // Profile + profile, + editableProfile, + managedProfile, + getProfile, + setProfile, + isProfileEditable, + + // Settings + settings, + getSetting, + setSetting, + }; +} diff --git a/core/src/models/userProfile.ts b/core/src/models/userProfile.ts new file mode 100644 index 0000000..cca30e1 --- /dev/null +++ b/core/src/models/userProfile.ts @@ -0,0 +1,98 @@ +/** + * User Profile Model + */ + +import type { ProfileFieldInterface, UserProfileInterface } from '@KTXC/types/user/userProfileTypes'; + +export class UserProfile { + private _data: UserProfileInterface; + + constructor(data: UserProfileInterface = {}) { + this._data = data; + } + + fromJson(data: UserProfileInterface): UserProfile { + this._data = data; + return this; + } + + toJson(): UserProfileInterface { + return { ...this._data }; + } + + clone(): UserProfile { + return new UserProfile(JSON.parse(JSON.stringify(this._data))); + } + + /** + * Get a profile field value + */ + get(key: string): any { + return this._data[key]?.value ?? null; + } + + /** + * Set a profile field value (only if editable) + */ + set(key: string, value: any): boolean { + if (!this._data[key]) { + console.warn(`Profile field "${key}" does not exist`); + return false; + } + + if (!this._data[key].editable) { + console.warn(`Profile field "${key}" is managed by ${this._data[key].provider} and cannot be edited`); + return false; + } + + this._data[key].value = value; + return true; + } + + /** + * Check if a field is editable + */ + isEditable(key: string): boolean { + return this._data[key]?.editable ?? false; + } + + /** + * Get field metadata + */ + getField(key: string): ProfileFieldInterface | null { + return this._data[key] ?? null; + } + + /** + * Get all fields + */ + get fields(): UserProfileInterface { + return this._data; + } + + /** + * Get only editable fields + */ + get editableFields(): UserProfileInterface { + return Object.entries(this._data) + .filter(([_, field]) => (field as ProfileFieldInterface).editable) + .reduce((acc, [key, field]) => ({ ...acc, [key]: field }), {} as UserProfileInterface); + } + + /** + * Get only managed (non-editable) fields + */ + get managedFields(): UserProfileInterface { + return Object.entries(this._data) + .filter(([_, field]) => !(field as ProfileFieldInterface).editable) + .reduce((acc, [key, field]) => ({ ...acc, [key]: field }), {} as UserProfileInterface); + } + + /** + * Get provider managing this profile + */ + get provider(): string | null { + const managedField = Object.values(this._data).find(f => !(f as ProfileFieldInterface).editable); + return (managedField as ProfileFieldInterface)?.provider ?? null; + } +} diff --git a/core/src/models/userSettings.ts b/core/src/models/userSettings.ts new file mode 100644 index 0000000..a7e17c5 --- /dev/null +++ b/core/src/models/userSettings.ts @@ -0,0 +1,73 @@ +/** + * User Settings Model + */ + +import type { UserSettingsInterface } from '@KTXC/types/user/userSettingsTypes'; + +export class UserSettings { + private _data: UserSettingsInterface; + + constructor(data: UserSettingsInterface = {}) { + this._data = data; + } + + fromJson(data: UserSettingsInterface): UserSettings { + this._data = data; + return this; + } + + toJson(): UserSettingsInterface { + return { ...this._data }; + } + + clone(): UserSettings { + return new UserSettings(JSON.parse(JSON.stringify(this._data))); + } + + /** + * Get a setting value + */ + get(key: string): any { + return this._data[key] ?? null; + } + + /** + * Set a setting value + */ + set(key: string, value: any): void { + this._data[key] = value; + } + + /** + * Check if a setting exists + */ + has(key: string): boolean { + return key in this._data; + } + + /** + * Get all settings + */ + get all(): UserSettingsInterface { + return this._data; + } + + /** + * Get multiple settings + */ + getMany(keys: string[]): Record { + return keys.reduce((acc, key) => ({ + ...acc, + [key]: this._data[key] ?? null + }), {}); + } + + /** + * Set multiple settings + */ + setMany(settings: Record): void { + Object.entries(settings).forEach(([key, value]) => { + this._data[key] = value; + }); + } +} diff --git a/core/src/private.ts b/core/src/private.ts index d3fdac9..e03456c 100644 --- a/core/src/private.ts +++ b/core/src/private.ts @@ -6,7 +6,7 @@ import { createPinia } from 'pinia' import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar' import { useModuleStore } from '@KTXC/stores/moduleStore' import { useTenantStore } from '@KTXC/stores/tenantStore' -import { usePreferencesStore } from '@KTXC/stores/preferencesStore' +import { useUserStore } from '@KTXC/stores/userStore' import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper' import { initializeModules } from '@KTXC/utils/modules' import App from './App.vue' @@ -34,15 +34,17 @@ globalWindow.vue = Vue globalWindow.VueRouter = VueRouterLib globalWindow.Pinia = PiniaLib as unknown -// Bootstrap initial private UI state (modules, tenant, preferences) before mounting +// Bootstrap initial private UI state (modules, tenant, user) before mounting (async () => { const moduleStore = useModuleStore(); const tenantStore = useTenantStore(); + const userStore = useUserStore(); try { const payload = await fetchWrapper.get('/init'); moduleStore.init(payload?.modules ?? {}); tenantStore.init(payload?.tenant ?? null); + userStore.init(payload?.user ?? {}); // Initialize registered modules (following reference app's bootstrap pattern) await initializeModules(app); diff --git a/core/src/router/index.ts b/core/src/router/index.ts index 05972dd..492c518 100644 --- a/core/src/router/index.ts +++ b/core/src/router/index.ts @@ -3,7 +3,6 @@ import { useUserStore } from '@KTXC/stores/userStore'; import { useLayoutStore } from '@KTXC/stores/layoutStore'; import BlankLayout from '@KTXC/layouts/blank/BlankLayout.vue'; import PrivateLayout from '@KTXC/views/PrivateLayout.vue'; -import { usePreferencesStore } from '@KTXC/stores/preferencesStore'; import { useIntegrationStore } from '@KTXC/stores/integrationStore'; const routes: RouteRecordRaw[] = [ @@ -47,10 +46,10 @@ const routes: RouteRecordRaw[] = [ component: BlankLayout, beforeEnter: (to, from, next) => { const integrationStore = useIntegrationStore(); - const preferences = usePreferencesStore(); + const userStore = useUserStore(); // Treat preference as a route name (e.g., "samples.overview") - const preferredRouteName = preferences.preferences.default_module; + const preferredRouteName = userStore.getSetting('default_module'); if (preferredRouteName) { // If a route with this name exists, go there try { @@ -91,12 +90,12 @@ router.beforeEach(async (to, from, next) => { const authRequired = to.matched.some((record) => record.meta?.requiresAuth); - if (authRequired && !userStore.user && to.path !== '/login') { + if (authRequired && !userStore.isAuthenticated && to.path !== '/login') { userStore.returnUrl = to.fullPath; return next('/login'); } - if (userStore.user && to.path === '/login') { + if (userStore.isAuthenticated && to.path === '/login') { const dest = userStore.returnUrl && userStore.returnUrl !== '/' ? userStore.returnUrl : '/'; return next(dest); } diff --git a/core/src/services/preferenceService.ts b/core/src/services/preferenceService.ts deleted file mode 100644 index a84cee1..0000000 --- a/core/src/services/preferenceService.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'; -import type { PreferencesState } from '@KTXC/stores/preferencesStore'; - -export interface PreferenceResponse { - effective: PreferencesState; - tenant: PreferencesState; - user: Partial; - locks: string[]; - savedKeys?: string[]; - rejectedKeys?: string[]; -} - -export interface TenantPreferenceResponse { - preferences: PreferencesState; - locks: string[]; - defaults: PreferencesState; -} - -export const preferenceService = { - /** - * Get effective preferences for current user - * Returns merged preferences with tenant defaults and user overrides resolved - */ - async getPreferences(): Promise { - return await fetchWrapper.get('/preferences'); - }, - - /** - * Update multiple user preferences at once - * Locked preferences will be rejected and returned in rejectedKeys - */ - async updatePreferences(preferences: Partial): Promise { - return await fetchWrapper.put('/preferences', preferences); - }, - - /** - * Update a single user preference - */ - async setPreference( - key: K, - value: PreferencesState[K] - ): Promise { - return await fetchWrapper.put(`/preferences/${key}`, { value }); - }, - - /** - * Reset all user preferences to tenant defaults - */ - async resetPreferences(): Promise { - return await fetchWrapper.post('/preferences/reset', {}); - }, - - // ============ Admin/Tenant Management ============ - - /** - * Get tenant preferences (admin only) - * Returns tenant defaults and locks - */ - async getTenantPreferences(): Promise { - return await fetchWrapper.get('/preferences/tenant'); - }, - - /** - * Update tenant preferences (admin only) - * @param preferences - The tenant-wide default preferences - * @param locks - Array of preference keys that users cannot override - */ - async updateTenantPreferences( - preferences: Partial, - locks: string[] = [] - ): Promise { - return await fetchWrapper.put('/preferences/tenant', { preferences, locks }); - }, - - /** - * Update tenant preference locks (admin only) - * @param locks - Array of preference keys that users cannot override - */ - async updateTenantLocks(locks: string[]): Promise { - return await fetchWrapper.put('/preferences/tenant/locks', { locks }); - }, -}; diff --git a/core/src/services/user/index.ts b/core/src/services/user/index.ts new file mode 100644 index 0000000..84d8947 --- /dev/null +++ b/core/src/services/user/index.ts @@ -0,0 +1 @@ +export { userService } from './userService'; diff --git a/core/src/services/user/userService.ts b/core/src/services/user/userService.ts new file mode 100644 index 0000000..e1fe953 --- /dev/null +++ b/core/src/services/user/userService.ts @@ -0,0 +1,153 @@ +import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'; + +/** + * User Service + * Provides methods for managing user profile and settings with batched updates + */ + +// Pending updates for profile and settings +let pendingProfileUpdates: Record = {}; +let pendingSettingsUpdates: Record = {}; +let profileUpdateTimer: ReturnType | null = null; +let settingsUpdateTimer: ReturnType | null = null; + +// Default debounce delay in milliseconds +const DEBOUNCE_DELAY = 500; + +export const userService = { + /** + * Update profile field(s) with debouncing + * Multiple calls within the debounce window will be batched into a single request + */ + updateProfile(fields: Record): Promise { + return new Promise((resolve, reject) => { + // Merge new fields with pending updates + Object.assign(pendingProfileUpdates, fields); + + // Clear existing timer + if (profileUpdateTimer) { + clearTimeout(profileUpdateTimer); + } + + // Set new timer to batch updates + profileUpdateTimer = setTimeout(async () => { + const updates = { ...pendingProfileUpdates }; + pendingProfileUpdates = {}; + profileUpdateTimer = null; + + try { + await fetchWrapper.put('/user/profile', updates); + resolve(); + } catch (error) { + reject(error); + } + }, DEBOUNCE_DELAY); + }); + }, + + /** + * Update profile field(s) immediately without debouncing + */ + async updateProfileImmediate(fields: Record): Promise { + // Cancel pending debounced update + if (profileUpdateTimer) { + clearTimeout(profileUpdateTimer); + profileUpdateTimer = null; + } + + // Merge with pending updates and send immediately + const updates = { ...pendingProfileUpdates, ...fields }; + pendingProfileUpdates = {}; + + return fetchWrapper.put('/user/profile', updates); + }, + + /** + * Flush any pending profile updates immediately + */ + async flushProfileUpdates(): Promise { + if (profileUpdateTimer) { + clearTimeout(profileUpdateTimer); + profileUpdateTimer = null; + } + + if (Object.keys(pendingProfileUpdates).length > 0) { + const updates = { ...pendingProfileUpdates }; + pendingProfileUpdates = {}; + return fetchWrapper.put('/user/profile', updates); + } + }, + + /** + * Update setting(s) with debouncing + * Multiple calls within the debounce window will be batched into a single request + */ + updateSettings(settings: Record): Promise { + return new Promise((resolve, reject) => { + // Merge new settings with pending updates + Object.assign(pendingSettingsUpdates, settings); + + // Clear existing timer + if (settingsUpdateTimer) { + clearTimeout(settingsUpdateTimer); + } + + // Set new timer to batch updates + settingsUpdateTimer = setTimeout(async () => { + const updates = { ...pendingSettingsUpdates }; + pendingSettingsUpdates = {}; + settingsUpdateTimer = null; + + try { + await fetchWrapper.put('/user/settings', updates); + resolve(); + } catch (error) { + reject(error); + } + }, DEBOUNCE_DELAY); + }); + }, + + /** + * Update setting(s) immediately without debouncing + */ + async updateSettingsImmediate(settings: Record): Promise { + // Cancel pending debounced update + if (settingsUpdateTimer) { + clearTimeout(settingsUpdateTimer); + settingsUpdateTimer = null; + } + + // Merge with pending updates and send immediately + const updates = { ...pendingSettingsUpdates, ...settings }; + pendingSettingsUpdates = {}; + + return fetchWrapper.put('/user/settings', updates); + }, + + /** + * Flush any pending settings updates immediately + */ + async flushSettingsUpdates(): Promise { + if (settingsUpdateTimer) { + clearTimeout(settingsUpdateTimer); + settingsUpdateTimer = null; + } + + if (Object.keys(pendingSettingsUpdates).length > 0) { + const updates = { ...pendingSettingsUpdates }; + pendingSettingsUpdates = {}; + return fetchWrapper.put('/user/settings', updates); + } + }, + + /** + * Flush all pending updates (profile and settings) + */ + async flushAll(): Promise { + await Promise.all([ + this.flushProfileUpdates(), + this.flushSettingsUpdates(), + ]); + }, +}; diff --git a/core/src/stores/preferencesStore.ts b/core/src/stores/preferencesStore.ts deleted file mode 100644 index e81e313..0000000 --- a/core/src/stores/preferencesStore.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { defineStore } from 'pinia'; - -export interface PreferencesState { - theme: string; - language: string; - timezone: string; - date_format: string; - time_format: string; - week_start: string; - default_module?: string; // preferred module handle like "dashboard" -} - -const defaults: PreferencesState = { - theme: 'light', - language: 'en', - timezone: 'UTC', - date_format: 'Y-m-d', - time_format: 'H:i', - week_start: 'Monday', - default_module: '' -}; - -export const usePreferencesStore = defineStore('preferencesStore', { - state: () => ({ - preferences: { ...defaults } as PreferencesState, - locks: [] as string[], // preference keys locked by tenant admin - loading: false, - error: null as string | null, - }), - getters: { - /** - * Check if a specific preference is locked by tenant admin - */ - isLocked: (state) => (key: string): boolean => { - return state.locks.includes(key); - }, - /** - * Get a single preference value - */ - getPreference: (state) => (key: K): PreferencesState[K] => { - return state.preferences[key]; - }, - }, - actions: { - /** - * Initialize preferences from server data (called on app bootstrap) - */ - init(prefs: Partial | undefined, locks?: string[]) { - this.preferences = { ...defaults, ...(prefs ?? {}) }; - this.locks = locks ?? []; - }, - /** - * Set multiple preferences at once (local state only) - */ - setPreferences(prefs: Partial) { - // Filter out locked preferences - const unlocked: Partial = {}; - for (const [key, value] of Object.entries(prefs)) { - if (!this.locks.includes(key)) { - (unlocked as Record)[key] = value; - } - } - this.preferences = { ...this.preferences, ...unlocked }; - }, - /** - * Set a single preference (local state only) - */ - setPreference(key: K, value: PreferencesState[K]) { - if (this.locks.includes(key)) { - console.warn(`Preference "${key}" is locked by administrator`); - return false; - } - this.preferences[key] = value; - return true; - }, - /** - * Set default module preference - */ - setDefaultModule(handle: string) { - if (this.locks.includes('default_module')) { - console.warn('Default module preference is locked by administrator'); - return false; - } - this.preferences.default_module = handle || ''; - return true; - }, - /** - * Update locks from server response - */ - setLocks(locks: string[]) { - this.locks = locks ?? []; - }, - /** - * Reset preferences to defaults (local state only) - */ - reset() { - this.preferences = { ...defaults }; - this.locks = []; - this.error = null; - }, - /** - * Set loading state - */ - setLoading(loading: boolean) { - this.loading = loading; - }, - /** - * Set error state - */ - setError(error: string | null) { - this.error = error; - }, - }, -}); diff --git a/core/src/stores/userStore.ts b/core/src/stores/userStore.ts index 7d963a0..b8234aa 100644 --- a/core/src/stores/userStore.ts +++ b/core/src/stores/userStore.ts @@ -1,53 +1,88 @@ import { defineStore } from 'pinia'; -import { ref } from 'vue'; +import { ref, computed } from 'vue'; import { router } from '@KTXC/router'; import { authenticationService } from '@KTXC/services/authenticationService'; +import { userService } from '@KTXC/services/user/userService'; import type { AuthenticatedUser } from '@KTXC/types/authenticationTypes'; +import type { UserProfileInterface } from '@KTXC/types/user/userProfileTypes'; +import type { UserSettingsInterface } from '@KTXC/types/user/userSettingsTypes'; +import { UserProfile } from '@KTXC/models/userProfile'; +import { UserSettings } from '@KTXC/models/userSettings'; -const STORAGE_KEY = 'userStore.user'; +const STORAGE_KEY = 'userStore.auth'; + +// Flush pending updates before page unload +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + userService.flushAll(); + }); +} export const useUserStore = defineStore('userStore', () => { - // Load user from localStorage on init - const user = ref( + // ========================================================================= + // State + // ========================================================================= + + const auth = ref( localStorage.getItem(STORAGE_KEY) ? (JSON.parse(localStorage.getItem(STORAGE_KEY)!) as AuthenticatedUser) : null ); + + const profile = ref(new UserProfile()); + const settings = ref(new UserSettings()); const returnUrl = ref(null); - /** - * Set user after successful authentication - */ - function setUser(authUser: AuthenticatedUser): void { - user.value = authUser; + // ========================================================================= + // Authentication Getters + // ========================================================================= + + const isAuthenticated = computed(() => auth.value !== null); + const identifier = computed(() => auth.value?.identifier ?? null); + const identity = computed(() => auth.value?.identity ?? null); + const label = computed(() => auth.value?.label ?? null); + const permissions = computed(() => auth.value?.permissions ?? []); + + // ========================================================================= + // Profile Getters + // ========================================================================= + + const profileFields = computed(() => profile.value.fields); + + const editableProfileFields = computed(() => profile.value.editableFields); + + const managedProfileFields = computed(() => profile.value.managedFields); + + // ========================================================================= + // Authentication Actions + // ========================================================================= + + function setAuth(authUser: AuthenticatedUser): void { + auth.value = authUser; localStorage.setItem(STORAGE_KEY, JSON.stringify(authUser)); } - /** - * Clear user state (on logout or auth failure) - */ - function clearUser(): void { - user.value = null; + function clearAuth(): void { + auth.value = null; localStorage.removeItem(STORAGE_KEY); } - /** - * Logout and redirect to login - */ async function logout(): Promise { try { + // Flush any pending profile/settings updates before logout + await userService.flushAll(); + await authenticationService.logout(); } catch (error) { console.warn('Logout request failed, clearing local state:', error); } finally { - clearUser(); + clearAuth(); + clearProfile(); + clearSettings(); router.push('/login'); } } - /** - * Refresh access token - */ async function refreshToken(): Promise { try { await authenticationService.refresh(); @@ -58,12 +93,120 @@ export const useUserStore = defineStore('userStore', () => { } } + // ========================================================================= + // Profile Actions + // ========================================================================= + + function initProfile(profileData: UserProfileInterface): void { + profile.value = new UserProfile(profileData); + } + + function clearProfile(): void { + profile.value = new UserProfile(); + } + + function getProfileField(key: string): any { + return profile.value.get(key); + } + + function setProfileField(key: string, value: any): boolean { + const success = profile.value.set(key, value); + if (success) { + // Debounced update to backend + userService.updateProfile({ [key]: value }).catch(error => { + console.error('Failed to update profile:', error); + }); + } + return success; + } + + function isProfileFieldEditable(key: string): boolean { + return profile.value.isEditable(key); + } + + // ========================================================================= + // Settings Actions + // ========================================================================= + + function initSettings(settingsData: UserSettingsInterface): void { + settings.value = new UserSettings(settingsData); + } + + function clearSettings(): void { + settings.value = new UserSettings(); + } + + function getSetting(key: string): any { + return settings.value.get(key); + } + + function setSetting(key: string, value: any): void { + settings.value.set(key, value); + // Debounced update to backend + userService.updateSettings({ [key]: value }).catch(error => { + console.error('Failed to update setting:', error); + }); + } + + // ========================================================================= + // Initialize from /init endpoint + // ========================================================================= + + function init(userData: { + auth?: AuthenticatedUser; + profile?: UserProfileInterface; + settings?: UserSettingsInterface + }): void { + if (userData.auth) { + setAuth(userData.auth); + } + if (userData.profile) { + initProfile(userData.profile); + } + if (userData.settings) { + initSettings(userData.settings); + } + } + return { - user, + // State + auth, + profile, + settings, returnUrl, - setUser, - clearUser, + + // Auth getters + isAuthenticated, + identifier, + identity, + label, + permissions, + + // Profile getters + profileFields, + editableProfileFields, + managedProfileFields, + + // Auth actions + setAuth, + clearAuth, logout, refreshToken, + + // Profile actions + initProfile, + clearProfile, + getProfileField, + setProfileField, + isProfileFieldEditable, + + // Settings actions + initSettings, + clearSettings, + getSetting, + setSetting, + + // Init + init, }; }); diff --git a/core/src/types/user/Identity.ts b/core/src/types/user/Identity.ts deleted file mode 100644 index 34fb860..0000000 --- a/core/src/types/user/Identity.ts +++ /dev/null @@ -1,7 +0,0 @@ - -export interface Identity { - identifier: string; - identity: string; - label: string; - email: string; -} diff --git a/core/src/types/user/userProfileTypes.ts b/core/src/types/user/userProfileTypes.ts new file mode 100644 index 0000000..8d464a1 --- /dev/null +++ b/core/src/types/user/userProfileTypes.ts @@ -0,0 +1,13 @@ +/** + * User Profile Types + */ + +export interface ProfileFieldInterface { + value: any; + editable: boolean; + provider: string | null; +} + +export interface UserProfileInterface { + [key: string]: ProfileFieldInterface; +} diff --git a/core/src/types/user/userSettingsTypes.ts b/core/src/types/user/userSettingsTypes.ts new file mode 100644 index 0000000..f99c778 --- /dev/null +++ b/core/src/types/user/userSettingsTypes.ts @@ -0,0 +1,7 @@ +/** + * User Settings Types + */ + +export interface UserSettingsInterface { + [key: string]: any; +} diff --git a/core/src/views/authentication/AuthLogin.vue b/core/src/views/authentication/AuthLogin.vue index 5d6600d..67c9ac5 100644 --- a/core/src/views/authentication/AuthLogin.vue +++ b/core/src/views/authentication/AuthLogin.vue @@ -265,7 +265,7 @@ async function handleMfaSubmit(_values: any, { setErrors }: any) { function handleVerifyResponse(result: VerifyResponse) { if (result.status === 'success' && result.user) { // Authentication complete - userStore.setUser(result.user); + userStore.setAuth(result.user); window.location.replace('/'); } else if (result.status === 'pending') { // MFA required