user auth, settings, profile
This commit is contained in:
@@ -4,6 +4,8 @@ namespace KTXC\Controllers;
|
|||||||
|
|
||||||
use KTXC\Http\Response\JsonResponse;
|
use KTXC\Http\Response\JsonResponse;
|
||||||
use KTXC\Module\ModuleManager;
|
use KTXC\Module\ModuleManager;
|
||||||
|
use KTXC\Service\UserService;
|
||||||
|
use KTXC\SessionIdentity;
|
||||||
use KTXF\Controller\ControllerAbstract;
|
use KTXF\Controller\ControllerAbstract;
|
||||||
use KTXC\SessionTenant;
|
use KTXC\SessionTenant;
|
||||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
@@ -12,7 +14,9 @@ class InitController extends ControllerAbstract
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SessionTenant $tenant,
|
private readonly SessionTenant $tenant,
|
||||||
|
private readonly SessionIdentity $userIdentity,
|
||||||
private readonly ModuleManager $moduleManager,
|
private readonly ModuleManager $moduleManager,
|
||||||
|
private readonly UserService $userService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
#[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])]
|
#[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])]
|
||||||
@@ -36,6 +40,18 @@ class InitController extends ControllerAbstract
|
|||||||
'label' => $this->tenant->label(),
|
'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);
|
return new JsonResponse($configuration);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
63
core/lib/Controllers/UserProfileController.php
Normal file
63
core/lib/Controllers/UserProfileController.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Controllers;
|
||||||
|
|
||||||
|
use KTXC\Http\Response\JsonResponse;
|
||||||
|
use KTXC\Service\UserService;
|
||||||
|
use KTXC\SessionIdentity;
|
||||||
|
use KTXC\SessionTenant;
|
||||||
|
use KTXF\Controller\ControllerAbstract;
|
||||||
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
|
|
||||||
|
class UserProfileController extends ControllerAbstract
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SessionTenant $tenantIdentity,
|
||||||
|
private readonly SessionIdentity $userIdentity,
|
||||||
|
private readonly UserService $userService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve user profile
|
||||||
|
*
|
||||||
|
* @return JsonResponse Profile data with editability metadata
|
||||||
|
*/
|
||||||
|
#[AuthenticatedRoute('/user/profile', name: 'user.profile.read', methods: ['GET'])]
|
||||||
|
public function read(): JsonResponse
|
||||||
|
{
|
||||||
|
$userId = $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,46 +13,47 @@ class UserSettingsController extends ControllerAbstract
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SessionTenant $tenantIdentity,
|
private readonly SessionTenant $tenantIdentity,
|
||||||
private readonly SessionIdentity $userIdentity,
|
private readonly SessionIdentity $userIdentity,
|
||||||
private readonly UserService $userService
|
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
|
* @return JsonResponse Settings data as key-value pairs
|
||||||
*
|
|
||||||
* @example request body:
|
|
||||||
* {
|
|
||||||
* "settings": ["key1", "key2"]
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
#[AuthenticatedRoute('/user/settings/read', name: 'user.settings.read', methods: ['PUT', 'PATCH'])]
|
#[AuthenticatedRoute('/user/settings', name: 'user.settings.read', methods: ['GET'])]
|
||||||
public function read(array $settings = []): JsonResponse
|
public function read(): JsonResponse
|
||||||
{
|
{
|
||||||
// authorize request
|
// Fetch all settings
|
||||||
$tenantId = $this->tenantIdentity->identifier();
|
$settings = $this->userService->fetchSettings([]);
|
||||||
$userId = $this->userIdentity->identifier();
|
|
||||||
|
|
||||||
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:
|
* @example request body:
|
||||||
* {
|
* {
|
||||||
* "key1": "value1",
|
* "theme": "dark",
|
||||||
* "key2": "value2"
|
* "language": "en",
|
||||||
|
* "notifications": true
|
||||||
* }
|
* }
|
||||||
|
*
|
||||||
|
* @return JsonResponse Updated settings data
|
||||||
*/
|
*/
|
||||||
#[AuthenticatedRoute('/user/settings/write', name: 'user.settings.write', methods: ['PUT', 'PATCH'])]
|
#[AuthenticatedRoute('/user/settings', name: 'user.settings.update', methods: ['PUT', 'PATCH'])]
|
||||||
public function write(array $settings): JsonResponse
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { useClipboard } from './useClipboard'
|
export { useClipboard } from './useClipboard'
|
||||||
export { usePreferences } from './usePreferences'
|
export { useUser } from './useUser'
|
||||||
|
|||||||
@@ -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<string | null>(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 = <K extends keyof PreferencesState>(key: K): PreferencesState[K] => {
|
|
||||||
return store.getPreference(key);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set a single preference and sync to server
|
|
||||||
*/
|
|
||||||
const set = async <K extends keyof PreferencesState>(
|
|
||||||
key: K,
|
|
||||||
value: PreferencesState[K],
|
|
||||||
syncToServer = true
|
|
||||||
): Promise<boolean> => {
|
|
||||||
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<PreferencesState>,
|
|
||||||
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<boolean> => {
|
|
||||||
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<boolean> => {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
86
core/src/composables/useUser.ts
Normal file
86
core/src/composables/useUser.ts
Normal file
@@ -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<void> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
98
core/src/models/userProfile.ts
Normal file
98
core/src/models/userProfile.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
core/src/models/userSettings.ts
Normal file
73
core/src/models/userSettings.ts
Normal file
@@ -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<string, any> {
|
||||||
|
return keys.reduce((acc, key) => ({
|
||||||
|
...acc,
|
||||||
|
[key]: this._data[key] ?? null
|
||||||
|
}), {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set multiple settings
|
||||||
|
*/
|
||||||
|
setMany(settings: Record<string, any>): void {
|
||||||
|
Object.entries(settings).forEach(([key, value]) => {
|
||||||
|
this._data[key] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { createPinia } from 'pinia'
|
|||||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||||
import { useModuleStore } from '@KTXC/stores/moduleStore'
|
import { useModuleStore } from '@KTXC/stores/moduleStore'
|
||||||
import { useTenantStore } from '@KTXC/stores/tenantStore'
|
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 { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'
|
||||||
import { initializeModules } from '@KTXC/utils/modules'
|
import { initializeModules } from '@KTXC/utils/modules'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
@@ -34,15 +34,17 @@ globalWindow.vue = Vue
|
|||||||
globalWindow.VueRouter = VueRouterLib
|
globalWindow.VueRouter = VueRouterLib
|
||||||
globalWindow.Pinia = PiniaLib as unknown
|
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 () => {
|
(async () => {
|
||||||
const moduleStore = useModuleStore();
|
const moduleStore = useModuleStore();
|
||||||
const tenantStore = useTenantStore();
|
const tenantStore = useTenantStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await fetchWrapper.get('/init');
|
const payload = await fetchWrapper.get('/init');
|
||||||
moduleStore.init(payload?.modules ?? {});
|
moduleStore.init(payload?.modules ?? {});
|
||||||
tenantStore.init(payload?.tenant ?? null);
|
tenantStore.init(payload?.tenant ?? null);
|
||||||
|
userStore.init(payload?.user ?? {});
|
||||||
|
|
||||||
// Initialize registered modules (following reference app's bootstrap pattern)
|
// Initialize registered modules (following reference app's bootstrap pattern)
|
||||||
await initializeModules(app);
|
await initializeModules(app);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useUserStore } from '@KTXC/stores/userStore';
|
|||||||
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||||
import BlankLayout from '@KTXC/layouts/blank/BlankLayout.vue';
|
import BlankLayout from '@KTXC/layouts/blank/BlankLayout.vue';
|
||||||
import PrivateLayout from '@KTXC/views/PrivateLayout.vue';
|
import PrivateLayout from '@KTXC/views/PrivateLayout.vue';
|
||||||
import { usePreferencesStore } from '@KTXC/stores/preferencesStore';
|
|
||||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
@@ -47,10 +46,10 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: BlankLayout,
|
component: BlankLayout,
|
||||||
beforeEnter: (to, from, next) => {
|
beforeEnter: (to, from, next) => {
|
||||||
const integrationStore = useIntegrationStore();
|
const integrationStore = useIntegrationStore();
|
||||||
const preferences = usePreferencesStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
// Treat preference as a route name (e.g., "samples.overview")
|
// 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 (preferredRouteName) {
|
||||||
// If a route with this name exists, go there
|
// If a route with this name exists, go there
|
||||||
try {
|
try {
|
||||||
@@ -91,12 +90,12 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
|
|
||||||
const authRequired = to.matched.some((record) => record.meta?.requiresAuth);
|
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;
|
userStore.returnUrl = to.fullPath;
|
||||||
return next('/login');
|
return next('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userStore.user && to.path === '/login') {
|
if (userStore.isAuthenticated && to.path === '/login') {
|
||||||
const dest = userStore.returnUrl && userStore.returnUrl !== '/' ? userStore.returnUrl : '/';
|
const dest = userStore.returnUrl && userStore.returnUrl !== '/' ? userStore.returnUrl : '/';
|
||||||
return next(dest);
|
return next(dest);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<PreferencesState>;
|
|
||||||
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<PreferenceResponse> {
|
|
||||||
return await fetchWrapper.get('/preferences');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update multiple user preferences at once
|
|
||||||
* Locked preferences will be rejected and returned in rejectedKeys
|
|
||||||
*/
|
|
||||||
async updatePreferences(preferences: Partial<PreferencesState>): Promise<PreferenceResponse> {
|
|
||||||
return await fetchWrapper.put('/preferences', preferences);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a single user preference
|
|
||||||
*/
|
|
||||||
async setPreference<K extends keyof PreferencesState>(
|
|
||||||
key: K,
|
|
||||||
value: PreferencesState[K]
|
|
||||||
): Promise<PreferenceResponse> {
|
|
||||||
return await fetchWrapper.put(`/preferences/${key}`, { value });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset all user preferences to tenant defaults
|
|
||||||
*/
|
|
||||||
async resetPreferences(): Promise<PreferenceResponse> {
|
|
||||||
return await fetchWrapper.post('/preferences/reset', {});
|
|
||||||
},
|
|
||||||
|
|
||||||
// ============ Admin/Tenant Management ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get tenant preferences (admin only)
|
|
||||||
* Returns tenant defaults and locks
|
|
||||||
*/
|
|
||||||
async getTenantPreferences(): Promise<TenantPreferenceResponse> {
|
|
||||||
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<PreferencesState>,
|
|
||||||
locks: string[] = []
|
|
||||||
): Promise<TenantPreferenceResponse> {
|
|
||||||
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<TenantPreferenceResponse> {
|
|
||||||
return await fetchWrapper.put('/preferences/tenant/locks', { locks });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
1
core/src/services/user/index.ts
Normal file
1
core/src/services/user/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { userService } from './userService';
|
||||||
153
core/src/services/user/userService.ts
Normal file
153
core/src/services/user/userService.ts
Normal file
@@ -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<string, any> = {};
|
||||||
|
let pendingSettingsUpdates: Record<string, any> = {};
|
||||||
|
let profileUpdateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let settingsUpdateTimer: ReturnType<typeof setTimeout> | 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<string, any>): Promise<void> {
|
||||||
|
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<string, any>): Promise<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<string, any>): Promise<void> {
|
||||||
|
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<string, any>): Promise<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
await Promise.all([
|
||||||
|
this.flushProfileUpdates(),
|
||||||
|
this.flushSettingsUpdates(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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) => <K extends keyof PreferencesState>(key: K): PreferencesState[K] => {
|
|
||||||
return state.preferences[key];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
/**
|
|
||||||
* Initialize preferences from server data (called on app bootstrap)
|
|
||||||
*/
|
|
||||||
init(prefs: Partial<PreferencesState> | undefined, locks?: string[]) {
|
|
||||||
this.preferences = { ...defaults, ...(prefs ?? {}) };
|
|
||||||
this.locks = locks ?? [];
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Set multiple preferences at once (local state only)
|
|
||||||
*/
|
|
||||||
setPreferences(prefs: Partial<PreferencesState>) {
|
|
||||||
// Filter out locked preferences
|
|
||||||
const unlocked: Partial<PreferencesState> = {};
|
|
||||||
for (const [key, value] of Object.entries(prefs)) {
|
|
||||||
if (!this.locks.includes(key)) {
|
|
||||||
(unlocked as Record<string, unknown>)[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.preferences = { ...this.preferences, ...unlocked };
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Set a single preference (local state only)
|
|
||||||
*/
|
|
||||||
setPreference<K extends keyof PreferencesState>(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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,53 +1,88 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { router } from '@KTXC/router';
|
import { router } from '@KTXC/router';
|
||||||
import { authenticationService } from '@KTXC/services/authenticationService';
|
import { authenticationService } from '@KTXC/services/authenticationService';
|
||||||
|
import { userService } from '@KTXC/services/user/userService';
|
||||||
import type { AuthenticatedUser } from '@KTXC/types/authenticationTypes';
|
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', () => {
|
export const useUserStore = defineStore('userStore', () => {
|
||||||
// Load user from localStorage on init
|
// =========================================================================
|
||||||
const user = ref<AuthenticatedUser | null>(
|
// State
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
const auth = ref<AuthenticatedUser | null>(
|
||||||
localStorage.getItem(STORAGE_KEY)
|
localStorage.getItem(STORAGE_KEY)
|
||||||
? (JSON.parse(localStorage.getItem(STORAGE_KEY)!) as AuthenticatedUser)
|
? (JSON.parse(localStorage.getItem(STORAGE_KEY)!) as AuthenticatedUser)
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const profile = ref<UserProfile>(new UserProfile());
|
||||||
|
const settings = ref<UserSettings>(new UserSettings());
|
||||||
const returnUrl = ref<string | null>(null);
|
const returnUrl = ref<string | null>(null);
|
||||||
|
|
||||||
/**
|
// =========================================================================
|
||||||
* Set user after successful authentication
|
// Authentication Getters
|
||||||
*/
|
// =========================================================================
|
||||||
function setUser(authUser: AuthenticatedUser): void {
|
|
||||||
user.value = authUser;
|
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));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(authUser));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function clearAuth(): void {
|
||||||
* Clear user state (on logout or auth failure)
|
auth.value = null;
|
||||||
*/
|
|
||||||
function clearUser(): void {
|
|
||||||
user.value = null;
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Logout and redirect to login
|
|
||||||
*/
|
|
||||||
async function logout(): Promise<void> {
|
async function logout(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// Flush any pending profile/settings updates before logout
|
||||||
|
await userService.flushAll();
|
||||||
|
|
||||||
await authenticationService.logout();
|
await authenticationService.logout();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Logout request failed, clearing local state:', error);
|
console.warn('Logout request failed, clearing local state:', error);
|
||||||
} finally {
|
} finally {
|
||||||
clearUser();
|
clearAuth();
|
||||||
|
clearProfile();
|
||||||
|
clearSettings();
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh access token
|
|
||||||
*/
|
|
||||||
async function refreshToken(): Promise<boolean> {
|
async function refreshToken(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await authenticationService.refresh();
|
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 {
|
return {
|
||||||
user,
|
// State
|
||||||
|
auth,
|
||||||
|
profile,
|
||||||
|
settings,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
setUser,
|
|
||||||
clearUser,
|
// Auth getters
|
||||||
|
isAuthenticated,
|
||||||
|
identifier,
|
||||||
|
identity,
|
||||||
|
label,
|
||||||
|
permissions,
|
||||||
|
|
||||||
|
// Profile getters
|
||||||
|
profileFields,
|
||||||
|
editableProfileFields,
|
||||||
|
managedProfileFields,
|
||||||
|
|
||||||
|
// Auth actions
|
||||||
|
setAuth,
|
||||||
|
clearAuth,
|
||||||
logout,
|
logout,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
|
|
||||||
|
// Profile actions
|
||||||
|
initProfile,
|
||||||
|
clearProfile,
|
||||||
|
getProfileField,
|
||||||
|
setProfileField,
|
||||||
|
isProfileFieldEditable,
|
||||||
|
|
||||||
|
// Settings actions
|
||||||
|
initSettings,
|
||||||
|
clearSettings,
|
||||||
|
getSetting,
|
||||||
|
setSetting,
|
||||||
|
|
||||||
|
// Init
|
||||||
|
init,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
|
|
||||||
export interface Identity {
|
|
||||||
identifier: string;
|
|
||||||
identity: string;
|
|
||||||
label: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
13
core/src/types/user/userProfileTypes.ts
Normal file
13
core/src/types/user/userProfileTypes.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* User Profile Types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ProfileFieldInterface {
|
||||||
|
value: any;
|
||||||
|
editable: boolean;
|
||||||
|
provider: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfileInterface {
|
||||||
|
[key: string]: ProfileFieldInterface;
|
||||||
|
}
|
||||||
7
core/src/types/user/userSettingsTypes.ts
Normal file
7
core/src/types/user/userSettingsTypes.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* User Settings Types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UserSettingsInterface {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
@@ -265,7 +265,7 @@ async function handleMfaSubmit(_values: any, { setErrors }: any) {
|
|||||||
function handleVerifyResponse(result: VerifyResponse) {
|
function handleVerifyResponse(result: VerifyResponse) {
|
||||||
if (result.status === 'success' && result.user) {
|
if (result.status === 'success' && result.user) {
|
||||||
// Authentication complete
|
// Authentication complete
|
||||||
userStore.setUser(result.user);
|
userStore.setAuth(result.user);
|
||||||
window.location.replace('/');
|
window.location.replace('/');
|
||||||
} else if (result.status === 'pending') {
|
} else if (result.status === 'pending') {
|
||||||
// MFA required
|
// MFA required
|
||||||
|
|||||||
Reference in New Issue
Block a user