user auth, settings, profile

This commit is contained in:
root
2025-12-22 18:01:26 -05:00
parent 81822498c8
commit d19bd16210
19 changed files with 711 additions and 467 deletions

View File

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

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

View File

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

View File

@@ -3,4 +3,4 @@
*/
export { useClipboard } from './useClipboard'
export { usePreferences } from './usePreferences'
export { useUser } from './useUser'

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { userService } from './userService';

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

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
export interface Identity {
identifier: string;
identity: string;
label: string;
email: string;
}

View File

@@ -0,0 +1,13 @@
/**
* User Profile Types
*/
export interface ProfileFieldInterface {
value: any;
editable: boolean;
provider: string | null;
}
export interface UserProfileInterface {
[key: string]: ProfileFieldInterface;
}

View File

@@ -0,0 +1,7 @@
/**
* User Settings Types
*/
export interface UserSettingsInterface {
[key: string]: any;
}

View File

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