user auth, settings, profile
This commit is contained in:
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -17,42 +17,43 @@ class UserSettingsController extends ControllerAbstract
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
*/
|
||||
|
||||
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 { 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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 { 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<AuthenticatedUser | null>(
|
||||
// =========================================================================
|
||||
// State
|
||||
// =========================================================================
|
||||
|
||||
const auth = ref<AuthenticatedUser | null>(
|
||||
localStorage.getItem(STORAGE_KEY)
|
||||
? (JSON.parse(localStorage.getItem(STORAGE_KEY)!) as AuthenticatedUser)
|
||||
: null
|
||||
);
|
||||
|
||||
const profile = ref<UserProfile>(new UserProfile());
|
||||
const settings = ref<UserSettings>(new UserSettings());
|
||||
const returnUrl = ref<string | null>(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<void> {
|
||||
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<boolean> {
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user