Merge pull request 'feat: theming' (#34) from feat/theming into main
Some checks failed
Renovate / renovate (push) Failing after 1h14m0s
Some checks failed
Renovate / renovate (push) Failing after 1h14m0s
Reviewed-on: #34
This commit was merged in pull request #34.
This commit is contained in:
74
core/lib/Controllers/TenantSettingsController.php
Normal file
74
core/lib/Controllers/TenantSettingsController.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXC\Controllers;
|
||||||
|
|
||||||
|
use KTXC\Http\Response\JsonResponse;
|
||||||
|
use KTXC\Service\TenantService;
|
||||||
|
use KTXC\SessionTenant;
|
||||||
|
use KTXF\Controller\ControllerAbstract;
|
||||||
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant-scoped settings controller.
|
||||||
|
*
|
||||||
|
* Mirrors UserSettingsController but operates on the current tenant record
|
||||||
|
* rather than the current user. Write access is guarded by the
|
||||||
|
* `tenant.settings.update` permission so only administrators can mutate
|
||||||
|
* tenant-wide configuration.
|
||||||
|
*/
|
||||||
|
class TenantSettingsController extends ControllerAbstract
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SessionTenant $tenantIdentity,
|
||||||
|
private readonly TenantService $tenantService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve all settings for the current tenant.
|
||||||
|
*
|
||||||
|
* @return JsonResponse Settings data as key-value pairs
|
||||||
|
*/
|
||||||
|
#[AuthenticatedRoute(
|
||||||
|
'/tenant/settings',
|
||||||
|
name: 'tenant.settings.read',
|
||||||
|
methods: ['GET'],
|
||||||
|
permissions: ['tenant.settings.read'],
|
||||||
|
)]
|
||||||
|
public function read(): JsonResponse
|
||||||
|
{
|
||||||
|
$settings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier());
|
||||||
|
|
||||||
|
return new JsonResponse($settings, JsonResponse::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update one or more settings for the current tenant.
|
||||||
|
*
|
||||||
|
* @param array $data Key-value pairs to persist
|
||||||
|
*
|
||||||
|
* @example request body:
|
||||||
|
* {
|
||||||
|
* "data": {
|
||||||
|
* "default_mode": "dark",
|
||||||
|
* "primary_color": "#6366F1",
|
||||||
|
* "lock_user_colors": true
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @return JsonResponse The updated values that were written
|
||||||
|
*/
|
||||||
|
#[AuthenticatedRoute(
|
||||||
|
'/tenant/settings',
|
||||||
|
name: 'tenant.settings.update',
|
||||||
|
methods: ['PUT', 'PATCH'],
|
||||||
|
permissions: ['tenant.settings.update'],
|
||||||
|
)]
|
||||||
|
public function update(array $data): JsonResponse
|
||||||
|
{
|
||||||
|
$this->tenantService->storeSettings($this->tenantIdentity->identifier(), $data);
|
||||||
|
|
||||||
|
$updatedSettings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier(), array_keys($data));
|
||||||
|
|
||||||
|
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,9 @@ use KTXC\Stores\TenantStore;
|
|||||||
|
|
||||||
class TenantService
|
class TenantService
|
||||||
{
|
{
|
||||||
public function __construct(protected readonly TenantStore $store)
|
public function __construct(
|
||||||
{
|
protected readonly TenantStore $store,
|
||||||
}
|
) {}
|
||||||
|
|
||||||
public function fetchByDomain(string $domain): ?TenantObject
|
public function fetchByDomain(string $domain): ?TenantObject
|
||||||
{
|
{
|
||||||
@@ -21,4 +21,31 @@ class TenantService
|
|||||||
return $this->store->fetch($identifier);
|
return $this->store->fetch($identifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Settings
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all – or a filtered subset of – a tenant's settings.
|
||||||
|
*
|
||||||
|
* @param string $identifier Tenant identifier
|
||||||
|
* @param string[] $keys Optional list of keys to return; empty = all
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function fetchSettings(string $identifier, array $keys = []): array
|
||||||
|
{
|
||||||
|
return $this->store->fetchSettings($identifier, $keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge-update settings for a tenant.
|
||||||
|
*
|
||||||
|
* @param string $identifier Tenant identifier
|
||||||
|
* @param array<string, mixed> $settings Key-value pairs to persist
|
||||||
|
*/
|
||||||
|
public function storeSettings(string $identifier, array $settings): bool
|
||||||
|
{
|
||||||
|
return $this->store->storeSettings($identifier, $settings);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,4 +72,57 @@ class TenantStore
|
|||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
|
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Settings Operations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch settings for a tenant, optionally filtered to specific keys.
|
||||||
|
*
|
||||||
|
* @param string $identifier Tenant identifier
|
||||||
|
* @param string[] $keys Optional list of keys to return; empty = all
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function fetchSettings(string $identifier, array $keys = []): array
|
||||||
|
{
|
||||||
|
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(
|
||||||
|
['identifier' => $identifier],
|
||||||
|
['projection' => ['settings' => 1]]
|
||||||
|
);
|
||||||
|
|
||||||
|
$settings = (array)($entry['settings'] ?? []);
|
||||||
|
|
||||||
|
if (empty($keys)) {
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$result[$key] = $settings[$key] ?? null;
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge-update settings for a tenant using atomic $set on sub-fields.
|
||||||
|
*
|
||||||
|
* @param string $identifier Tenant identifier
|
||||||
|
* @param array<string, mixed> $settings Key-value pairs to persist
|
||||||
|
*/
|
||||||
|
public function storeSettings(string $identifier, array $settings): bool
|
||||||
|
{
|
||||||
|
$updates = [];
|
||||||
|
foreach ($settings as $key => $value) {
|
||||||
|
$updates["settings.{$key}"] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
||||||
|
['identifier' => $identifier],
|
||||||
|
['$set' => $updates]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $result->getMatchedCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -4,17 +4,68 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router';
|
import { RouterView } from 'vue-router';
|
||||||
import { onMounted } from 'vue';
|
import { onMounted, watch } from 'vue';
|
||||||
import { useTheme } from 'vuetify';
|
import { useTheme } from 'vuetify';
|
||||||
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||||
|
import { useUserStore } from '@KTXC/stores/userStore';
|
||||||
|
import { useTenantStore } from '@KTXC/stores/tenantStore';
|
||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const layoutStore = useLayoutStore();
|
const layoutStore = useLayoutStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const tenantStore = useTenantStore();
|
||||||
|
|
||||||
|
// Maps user/tenant setting keys → Vuetify color token names
|
||||||
|
const COLOR_SETTINGS: Array<{ token: string; key: string }> = [
|
||||||
|
{ token: 'primary', key: 'primary_color' },
|
||||||
|
{ token: 'secondary', key: 'secondary_color' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply brand color overrides from stored preferences to all Vuetify theme
|
||||||
|
* variants (light & dark). Tenant colors take priority when lock is active.
|
||||||
|
*/
|
||||||
|
function applyThemeColors(): void {
|
||||||
|
const locked = tenantStore.getSetting('lock_user_colors') as boolean | null;
|
||||||
|
|
||||||
|
for (const { token, key } of COLOR_SETTINGS) {
|
||||||
|
const value = locked
|
||||||
|
? ((tenantStore.getSetting(key) as string | null) ?? (userStore.getSetting(key) as string | null))
|
||||||
|
: ((userStore.getSetting(key) as string | null) ?? (tenantStore.getSetting(key) as string | null));
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
for (const variant of Object.keys(theme.themes.value)) {
|
||||||
|
theme.themes.value[variant].colors[token] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply font preference via CSS custom property and body style. */
|
||||||
|
function applyFont(): void {
|
||||||
|
const font =
|
||||||
|
((userStore.getSetting('font') as string | null) ??
|
||||||
|
(tenantStore.getSetting('font') as string | null));
|
||||||
|
|
||||||
|
if (font && font !== 'Public Sans') {
|
||||||
|
document.documentElement.style.setProperty('--themer-font', font);
|
||||||
|
document.body.style.fontFamily = `"${font}", sans-serif`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply saved theme on mount
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Apply saved theme mode
|
||||||
if (layoutStore.theme) {
|
if (layoutStore.theme) {
|
||||||
theme.global.name.value = layoutStore.theme;
|
theme.global.name.value = layoutStore.theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyThemeColors();
|
||||||
|
applyFont();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-apply whenever tenant settings change (e.g. admin saves new brand colors)
|
||||||
|
watch(() => tenantStore.settings, () => {
|
||||||
|
applyThemeColors();
|
||||||
|
applyFont();
|
||||||
|
}, { deep: true });
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
export type ConfigProps = {
|
|
||||||
Sidebar_drawer: boolean;
|
|
||||||
mini_sidebar: boolean;
|
|
||||||
actTheme: string;
|
|
||||||
fontTheme: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const config: ConfigProps = {
|
|
||||||
Sidebar_drawer: true,
|
|
||||||
mini_sidebar: false,
|
|
||||||
actTheme: 'light',
|
|
||||||
fontTheme: 'Public sans'
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import config from '@KTXC/config';
|
|
||||||
import { useUserStore } from './userStore';
|
import { useUserStore } from './userStore';
|
||||||
|
|
||||||
export type MenuMode = 'apps' | 'user-settings' | 'admin-settings';
|
export type MenuMode = 'apps' | 'user-settings' | 'admin-settings';
|
||||||
@@ -9,15 +8,15 @@ export const useLayoutStore = defineStore('layout', () => {
|
|||||||
// Loading state
|
// Loading state
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
|
||||||
// Sidebar state - initialize from settings or config
|
// Sidebar state - initialize from settings or defaults
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const sidebarDrawer = ref(userStore.getSetting('sidebar_drawer') ?? config.Sidebar_drawer);
|
const sidebarDrawer = ref(userStore.getSetting('sidebar_drawer') ?? true);
|
||||||
const miniSidebar = ref(userStore.getSetting('mini_sidebar') ?? config.mini_sidebar);
|
const miniSidebar = ref(userStore.getSetting('mini_sidebar') ?? false);
|
||||||
const menuMode = ref<MenuMode>('apps');
|
const menuMode = ref<MenuMode>('apps');
|
||||||
|
|
||||||
// Theme state - initialize from settings or config
|
// Theme state - initialize from settings or defaults
|
||||||
const theme = ref(userStore.getSetting('theme') ?? config.actTheme);
|
const theme = ref(userStore.getSetting('theme') ?? 'light');
|
||||||
const font = ref(userStore.getSetting('font') ?? config.fontTheme);
|
const font = ref(userStore.getSetting('font') ?? 'Public sans');
|
||||||
|
|
||||||
// Watch and sync sidebar state to settings
|
// Watch and sync sidebar state to settings
|
||||||
watch(sidebarDrawer, (value) => {
|
watch(sidebarDrawer, (value) => {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper';
|
||||||
|
|
||||||
export interface TenantState {
|
export interface TenantState {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
@@ -6,22 +8,117 @@ export interface TenantState {
|
|||||||
label: string | null;
|
label: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTenantStore = defineStore('tenantStore', {
|
export interface TenantData extends TenantState {
|
||||||
state: () => ({
|
settings?: Record<string, unknown>;
|
||||||
tenant: null as TenantState | null,
|
}
|
||||||
}),
|
|
||||||
actions: {
|
// Flush pending settings writes before the page unloads
|
||||||
init(tenant: Partial<TenantState> | null) {
|
if (typeof window !== 'undefined') {
|
||||||
this.tenant = tenant
|
window.addEventListener('beforeunload', () => {
|
||||||
? {
|
useTenantStore().flushSettings();
|
||||||
id: tenant.id ?? null,
|
});
|
||||||
domain: tenant.domain ?? null,
|
}
|
||||||
label: tenant.label ?? null,
|
|
||||||
}
|
export const useTenantStore = defineStore('tenantStore', () => {
|
||||||
: null;
|
// =========================================================================
|
||||||
},
|
// State
|
||||||
reset() {
|
// =========================================================================
|
||||||
this.tenant = null;
|
|
||||||
},
|
const tenant = ref<TenantState | null>(null);
|
||||||
},
|
const settings = ref<Record<string, unknown>>({});
|
||||||
|
|
||||||
|
// Pending batch for debounced writes
|
||||||
|
let _pendingSettings: Record<string, unknown> = {};
|
||||||
|
let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Getters
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
function getSetting(key: string): unknown {
|
||||||
|
return settings.value[key] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Settings actions
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
function setSetting(key: string, value: unknown): void {
|
||||||
|
settings.value[key] = value;
|
||||||
|
|
||||||
|
// Batch writes with a 500 ms debounce — same pattern as userService
|
||||||
|
_pendingSettings[key] = value;
|
||||||
|
|
||||||
|
if (_debounceTimer !== null) {
|
||||||
|
clearTimeout(_debounceTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
_debounceTimer = setTimeout(() => {
|
||||||
|
_flush();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _flush(): Promise<void> {
|
||||||
|
if (Object.keys(_pendingSettings).length === 0) return;
|
||||||
|
|
||||||
|
const payload = { ..._pendingSettings };
|
||||||
|
_pendingSettings = {};
|
||||||
|
_debounceTimer = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchWrapper.patch('/tenant/settings', { data: payload });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save tenant settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force-flush any pending settings writes (called on beforeunload). */
|
||||||
|
async function flushSettings(): Promise<void> {
|
||||||
|
if (_debounceTimer !== null) {
|
||||||
|
clearTimeout(_debounceTimer);
|
||||||
|
_debounceTimer = null;
|
||||||
|
}
|
||||||
|
await _flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Initialise from /init endpoint
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
function init(tenantData: Partial<TenantData> | null): void {
|
||||||
|
tenant.value = tenantData
|
||||||
|
? {
|
||||||
|
id: tenantData.id ?? null,
|
||||||
|
domain: tenantData.domain ?? null,
|
||||||
|
label: tenantData.label ?? null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
settings.value = (tenantData?.settings as Record<string, unknown>) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
tenant.value = null;
|
||||||
|
settings.value = {};
|
||||||
|
_pendingSettings = {};
|
||||||
|
if (_debounceTimer !== null) {
|
||||||
|
clearTimeout(_debounceTimer);
|
||||||
|
_debounceTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
tenant,
|
||||||
|
settings,
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
getSetting,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setSetting,
|
||||||
|
flushSettings,
|
||||||
|
init,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user