feat: theming
All checks were successful
JS Unit Tests / test (pull_request) Successful in 17s
Build Test / build (pull_request) Successful in 19s
PHP Unit Tests / test (pull_request) Successful in 54s

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-02-22 21:25:26 -05:00
parent 6975800ce5
commit b68ac538ce
7 changed files with 331 additions and 45 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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