Initial commit

This commit is contained in:
Maurus Decimus
2026-04-20 15:02:57 +02:00
parent e72c9614f1
commit 4470616775
115 changed files with 26207 additions and 21 deletions
+109
View File
@@ -0,0 +1,109 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useAccountStore } from './accountStore';
describe('accountStore', () => {
beforeEach(() => {
useAccountStore.setState({
permissions: [],
edition: 'community',
locale: 'en',
});
});
describe('setAccountInfo', () => {
it('stores permissions, edition, and locale', () => {
useAccountStore.getState().setAccountInfo(['sysAccountGet', 'sysAccountCreate'], 'enterprise', 'de');
const state = useAccountStore.getState();
expect(state.permissions).toEqual(['sysAccountGet', 'sysAccountCreate']);
expect(state.edition).toBe('enterprise');
expect(state.locale).toBe('de');
});
it('replaces previous values on subsequent calls', () => {
const { setAccountInfo } = useAccountStore.getState();
setAccountInfo(['a'], 'enterprise', 'fr');
setAccountInfo(['b', 'c'], 'oss', 'ja');
const state = useAccountStore.getState();
expect(state.permissions).toEqual(['b', 'c']);
expect(state.edition).toBe('oss');
expect(state.locale).toBe('ja');
});
it('accepts empty permissions array', () => {
useAccountStore.getState().setAccountInfo([], 'community', 'en');
expect(useAccountStore.getState().permissions).toEqual([]);
});
});
describe('hasPermission', () => {
it('returns true when the permission exists', () => {
useAccountStore.getState().setAccountInfo(['sysAccountGet', 'sysAccountCreate'], 'community', 'en');
expect(useAccountStore.getState().hasPermission('sysAccountGet')).toBe(true);
});
it('returns false when the permission does not exist', () => {
useAccountStore.getState().setAccountInfo(['sysAccountGet'], 'community', 'en');
expect(useAccountStore.getState().hasPermission('sysAccountDestroy')).toBe(false);
});
it('returns false when permissions are empty', () => {
expect(useAccountStore.getState().hasPermission('anything')).toBe(false);
});
});
describe('hasObjectPermission', () => {
it('builds correct permission string from prefix and action', () => {
useAccountStore.getState().setAccountInfo(['sysAccountGet'], 'community', 'en');
expect(useAccountStore.getState().hasObjectPermission('sysAccount', 'Get')).toBe(true);
});
it('returns false for non-matching action', () => {
useAccountStore.getState().setAccountInfo(['sysAccountGet'], 'community', 'en');
expect(useAccountStore.getState().hasObjectPermission('sysAccount', 'Destroy')).toBe(false);
});
it('returns false for non-matching prefix', () => {
useAccountStore.getState().setAccountInfo(['sysAccountGet'], 'community', 'en');
expect(useAccountStore.getState().hasObjectPermission('sysDomain', 'Get')).toBe(false);
});
it('works with all action types', () => {
useAccountStore.getState().setAccountInfo(['fooCreate', 'fooUpdate', 'fooDestroy', 'fooGet'], 'community', 'en');
const state = useAccountStore.getState();
expect(state.hasObjectPermission('foo', 'Create')).toBe(true);
expect(state.hasObjectPermission('foo', 'Update')).toBe(true);
expect(state.hasObjectPermission('foo', 'Destroy')).toBe(true);
expect(state.hasObjectPermission('foo', 'Get')).toBe(true);
});
});
describe('edition', () => {
it('defaults to community', () => {
expect(useAccountStore.getState().edition).toBe('community');
});
it('can be changed to enterprise', () => {
useAccountStore.getState().setAccountInfo([], 'enterprise', 'en');
expect(useAccountStore.getState().edition).toBe('enterprise');
});
it('can be changed to oss', () => {
useAccountStore.getState().setAccountInfo([], 'oss', 'en');
expect(useAccountStore.getState().edition).toBe('oss');
});
});
});
+37
View File
@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { create } from 'zustand';
type Edition = 'enterprise' | 'community' | 'oss';
interface AccountState {
permissions: string[];
edition: Edition;
locale: string;
setAccountInfo: (permissions: string[], edition: Edition, locale: string) => void;
hasPermission: (perm: string) => boolean;
hasObjectPermission: (prefix: string, action: 'Get' | 'Query' | 'Create' | 'Update' | 'Destroy') => boolean;
}
export const useAccountStore = create<AccountState>()((set, get) => ({
permissions: [],
edition: 'community',
locale: 'en',
setAccountInfo: (permissions, edition, locale) => {
set({ permissions, edition, locale });
},
hasPermission: (perm) => {
return get().permissions.includes(perm);
},
hasObjectPermission: (prefix, action) => {
return get().permissions.includes(`${prefix}${action}`);
},
}));
+180
View File
@@ -0,0 +1,180 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useAuthStore } from './authStore';
const initialState = {
accessToken: null,
refreshToken: null,
tokenExpiresAt: null,
tokenEndpoint: null,
accounts: {},
primaryAccountId: null,
activeAccountId: null,
apiUrl: null,
};
describe('authStore', () => {
beforeEach(() => {
useAuthStore.setState(initialState);
vi.restoreAllMocks();
});
describe('setTokens', () => {
it('stores access and refresh tokens', () => {
useAuthStore.getState().setTokens('acc-123', 'ref-456', 3600, 'https://auth/token');
const state = useAuthStore.getState();
expect(state.accessToken).toBe('acc-123');
expect(state.refreshToken).toBe('ref-456');
});
it('computes expiration time from expiresIn', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
useAuthStore.getState().setTokens('a', 'r', 3600, 'https://auth/token');
expect(useAuthStore.getState().tokenExpiresAt).toBe(now + 3600 * 1000);
});
it('stores token endpoint', () => {
useAuthStore.getState().setTokens('a', 'r', 3600, 'https://example.com/token');
expect(useAuthStore.getState().tokenEndpoint).toBe('https://example.com/token');
});
});
describe('isAuthenticated', () => {
it('returns false when no token is set', () => {
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
});
it('returns true when token exists and is not expired', () => {
const now = Date.now();
useAuthStore.setState({
accessToken: 'tok',
tokenExpiresAt: now + 60_000,
});
expect(useAuthStore.getState().isAuthenticated()).toBe(true);
});
it('returns false when token is expired', () => {
useAuthStore.setState({
accessToken: 'tok',
tokenExpiresAt: Date.now() - 1000,
});
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
});
it('returns false when accessToken is null even if expiry is in the future', () => {
useAuthStore.setState({
accessToken: null,
tokenExpiresAt: Date.now() + 60_000,
});
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
});
});
describe('isTokenExpiringSoon', () => {
it('returns false when tokenExpiresAt is null', () => {
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(false);
});
it('returns true within 60 seconds of expiration', () => {
useAuthStore.setState({ tokenExpiresAt: Date.now() + 30_000 });
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(true);
});
it('returns false when more than 60 seconds remain', () => {
useAuthStore.setState({ tokenExpiresAt: Date.now() + 120_000 });
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(false);
});
it('returns true when token is already expired', () => {
useAuthStore.setState({ tokenExpiresAt: Date.now() - 5000 });
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(true);
});
});
describe('setSession', () => {
it('stores accounts and primaryAccountId', () => {
const accounts = {
'acc-1': { name: 'Personal', isPersonal: true },
'acc-2': { name: 'Work', isPersonal: false },
};
useAuthStore.getState().setSession(accounts, 'acc-1', 'https://api');
const state = useAuthStore.getState();
expect(state.accounts).toEqual(accounts);
expect(state.primaryAccountId).toBe('acc-1');
expect(state.apiUrl).toBe('https://api');
});
it('sets activeAccountId to primaryAccountId', () => {
useAuthStore.getState().setSession({ 'acc-1': { name: 'A', isPersonal: true } }, 'acc-1', 'https://api');
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
});
});
describe('switchAccount', () => {
it('changes activeAccountId when account exists', () => {
useAuthStore.setState({
accounts: {
a1: { name: 'A1', isPersonal: true },
a2: { name: 'A2', isPersonal: false },
},
activeAccountId: 'a1',
});
useAuthStore.getState().switchAccount('a2');
expect(useAuthStore.getState().activeAccountId).toBe('a2');
});
it('does nothing for unknown account id', () => {
useAuthStore.setState({
accounts: { a1: { name: 'A1', isPersonal: true } },
activeAccountId: 'a1',
});
useAuthStore.getState().switchAccount('nonexistent');
expect(useAuthStore.getState().activeAccountId).toBe('a1');
});
});
describe('logout', () => {
it('clears all state', () => {
useAuthStore.setState({
accessToken: 'tok',
refreshToken: 'ref',
tokenExpiresAt: 99999,
tokenEndpoint: 'https://auth/token',
accounts: { a: { name: 'A', isPersonal: true } },
primaryAccountId: 'a',
activeAccountId: 'a',
apiUrl: 'https://api',
});
useAuthStore.getState().logout();
const state = useAuthStore.getState();
expect(state.accessToken).toBeNull();
expect(state.refreshToken).toBeNull();
expect(state.tokenExpiresAt).toBeNull();
expect(state.tokenEndpoint).toBeNull();
expect(state.accounts).toEqual({});
expect(state.primaryAccountId).toBeNull();
expect(state.activeAccountId).toBeNull();
expect(state.apiUrl).toBeNull();
});
});
});
+129
View File
@@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AccountInfo {
name: string;
isPersonal: boolean;
}
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
tokenExpiresAt: number | null;
tokenEndpoint: string | null;
accounts: Record<string, AccountInfo>;
primaryAccountId: string | null;
activeAccountId: string | null;
apiUrl: string | null;
maxObjectsInGet: number;
maxObjectsInSet: number;
setTokens: (access: string, refresh: string, expiresIn: number, tokenEndpoint: string) => void;
setSession: (
accounts: Record<string, AccountInfo>,
primaryAccountId: string,
apiUrl: string,
maxObjectsInGet?: number,
maxObjectsInSet?: number,
) => void;
switchAccount: (accountId: string) => void;
logout: () => void;
isAuthenticated: () => boolean;
isTokenExpiringSoon: () => boolean;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
accessToken: null,
refreshToken: null,
tokenExpiresAt: null,
tokenEndpoint: null,
accounts: {},
primaryAccountId: null,
activeAccountId: null,
apiUrl: null,
maxObjectsInGet: 500,
maxObjectsInSet: 500,
setTokens: (access, refresh, expiresIn, tokenEndpoint) => {
set({
accessToken: access,
refreshToken: refresh,
tokenExpiresAt: Date.now() + expiresIn * 1000,
tokenEndpoint,
});
},
setSession: (accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet) => {
set({
accounts,
primaryAccountId,
activeAccountId: primaryAccountId,
apiUrl,
...(maxObjectsInGet !== undefined ? { maxObjectsInGet } : {}),
...(maxObjectsInSet !== undefined ? { maxObjectsInSet } : {}),
});
},
switchAccount: (accountId) => {
const { accounts } = get();
if (accounts[accountId]) {
set({ activeAccountId: accountId });
}
},
logout: () => {
set({
accessToken: null,
refreshToken: null,
tokenExpiresAt: null,
tokenEndpoint: null,
accounts: {},
primaryAccountId: null,
activeAccountId: null,
apiUrl: null,
});
},
isAuthenticated: () => {
const { accessToken, tokenExpiresAt } = get();
return accessToken !== null && tokenExpiresAt !== null && Date.now() < tokenExpiresAt;
},
isTokenExpiringSoon: () => {
const { tokenExpiresAt } = get();
if (tokenExpiresAt === null) return false;
return tokenExpiresAt - Date.now() < 60_000;
},
}),
{
name: 'stalwart-auth',
storage: {
getItem: (name) => {
const value = sessionStorage.getItem(name);
return value ? JSON.parse(value) : null;
},
setItem: (name, value) => {
sessionStorage.setItem(name, JSON.stringify(value));
},
removeItem: (name) => {
sessionStorage.removeItem(name);
},
},
partialize: (state) =>
({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
tokenExpiresAt: state.tokenExpiresAt,
tokenEndpoint: state.tokenEndpoint,
}) as AuthState,
},
),
);
+94
View File
@@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useCacheStore } from './cacheStore';
describe('cacheStore', () => {
beforeEach(() => {
useCacheStore.setState({ displayNames: {} });
});
describe('setDisplayNames', () => {
it('stores entries for an object type', () => {
useCacheStore.getState().setDisplayNames('user', { u1: 'Alice', u2: 'Bob' });
expect(useCacheStore.getState().displayNames).toEqual({
user: { u1: 'Alice', u2: 'Bob' },
});
});
it('merges with existing entries for the same object type', () => {
const { setDisplayNames } = useCacheStore.getState();
setDisplayNames('user', { u1: 'Alice' });
setDisplayNames('user', { u2: 'Bob' });
expect(useCacheStore.getState().displayNames.user).toEqual({
u1: 'Alice',
u2: 'Bob',
});
});
it('overwrites individual entries when keys collide', () => {
const { setDisplayNames } = useCacheStore.getState();
setDisplayNames('user', { u1: 'Alice' });
setDisplayNames('user', { u1: 'Alicia' });
expect(useCacheStore.getState().displayNames.user.u1).toBe('Alicia');
});
});
describe('getDisplayName', () => {
it('returns cached name', () => {
useCacheStore.getState().setDisplayNames('user', { u1: 'Alice' });
expect(useCacheStore.getState().getDisplayName('user', 'u1')).toBe('Alice');
});
it('returns undefined for unknown id', () => {
useCacheStore.getState().setDisplayNames('user', { u1: 'Alice' });
expect(useCacheStore.getState().getDisplayName('user', 'u99')).toBeUndefined();
});
it('returns undefined for unknown object type', () => {
expect(useCacheStore.getState().getDisplayName('domain', 'd1')).toBeUndefined();
});
});
describe('invalidateCache', () => {
it('removes entries for a specific object type', () => {
const { setDisplayNames } = useCacheStore.getState();
setDisplayNames('user', { u1: 'Alice' });
setDisplayNames('domain', { d1: 'example.com' });
useCacheStore.getState().invalidateCache('user');
const state = useCacheStore.getState();
expect(state.displayNames.user).toBeUndefined();
expect(state.displayNames.domain).toEqual({ d1: 'example.com' });
});
it('is a no-op when object type does not exist', () => {
useCacheStore.getState().setDisplayNames('user', { u1: 'Alice' });
useCacheStore.getState().invalidateCache('nonexistent');
expect(useCacheStore.getState().displayNames.user).toEqual({ u1: 'Alice' });
});
});
describe('multiple object types', () => {
it('stores different object types independently', () => {
const { setDisplayNames } = useCacheStore.getState();
setDisplayNames('user', { u1: 'Alice' });
setDisplayNames('domain', { d1: 'example.com' });
setDisplayNames('group', { g1: 'Admins' });
const state = useCacheStore.getState();
expect(state.getDisplayName('user', 'u1')).toBe('Alice');
expect(state.getDisplayName('domain', 'd1')).toBe('example.com');
expect(state.getDisplayName('group', 'g1')).toBe('Admins');
});
});
});
+78
View File
@@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { create } from 'zustand';
export interface ObjectListEntry {
id: string;
label: string;
}
interface CacheState {
displayNames: Record<string, Record<string, string>>;
objectLists: Record<string, ObjectListEntry[]>;
setDisplayNames: (objectType: string, entries: Record<string, string>) => void;
getDisplayName: (objectType: string, id: string) => string | undefined;
invalidateCache: (objectType: string) => void;
setObjectList: (key: string, entries: ObjectListEntry[]) => void;
getObjectList: (key: string) => ObjectListEntry[] | undefined;
invalidateObjectList: (key: string) => void;
invalidateAllObjectLists: () => void;
}
export const useCacheStore = create<CacheState>()((set, get) => ({
displayNames: {},
objectLists: {},
setDisplayNames: (objectType, entries) => {
set((state) => ({
displayNames: {
...state.displayNames,
[objectType]: {
...state.displayNames[objectType],
...entries,
},
},
}));
},
getDisplayName: (objectType, id) => {
return get().displayNames[objectType]?.[id];
},
invalidateCache: (objectType) => {
set((state) => {
const { [objectType]: _removed, ...rest } = state.displayNames;
void _removed;
return { displayNames: rest };
});
},
setObjectList: (key, entries) => {
set((state) => ({
objectLists: { ...state.objectLists, [key]: entries },
}));
},
getObjectList: (key) => {
return get().objectLists[key];
},
invalidateObjectList: (key) => {
set((state) => {
const { [key]: _removed, ...rest } = state.objectLists;
void _removed;
return { objectLists: rest };
});
},
invalidateAllObjectLists: () => {
set({ objectLists: {} });
},
}));
+176
View File
@@ -0,0 +1,176 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { create } from 'zustand';
import type { Schema, LayoutSubItem } from '@/types/schema';
export interface SearchIndexEntry {
text: string;
type: 'link' | 'field' | 'form';
viewName: string;
section: string;
breadcrumb: string;
icon?: string;
objectType?: 'object' | 'singleton' | 'view';
keywords?: string[];
}
interface SchemaState {
schema: Schema | null;
isLoaded: boolean;
viewToSection: Record<string, string>;
searchIndex: SearchIndexEntry[];
setSchema: (schema: Schema) => void;
}
function walkLayouts(schema: Schema): { viewToSection: Record<string, string>; linkEntries: SearchIndexEntry[] } {
const viewToSection: Record<string, string> = {};
const linkEntries: SearchIndexEntry[] = [];
function visit(items: LayoutSubItem[], sectionName: string, parentPath: string): void {
for (const sub of items) {
if (sub.type === 'link') {
if (!(sub.viewName in viewToSection)) {
viewToSection[sub.viewName] = sectionName;
}
linkEntries.push({
text: sub.name,
type: 'link',
viewName: sub.viewName,
section: sectionName,
breadcrumb: `${parentPath} > ${sub.name}`,
});
} else if (sub.type === 'container') {
visit(sub.items, sectionName, `${parentPath} > ${sub.name}`);
}
}
}
for (const layout of schema.layouts) {
const sectionName = layout.name;
for (const item of layout.items) {
if ('link' in item) {
if (!(item.link.viewName in viewToSection)) {
viewToSection[item.link.viewName] = sectionName;
}
linkEntries.push({
text: item.link.name,
type: 'link',
viewName: item.link.viewName,
section: sectionName,
breadcrumb: `${sectionName} > ${item.link.name}`,
icon: item.link.icon,
});
} else if ('container' in item) {
visit(item.container.items, sectionName, `${sectionName} > ${item.container.name}`);
}
}
}
return { viewToSection, linkEntries };
}
function buildSearchIndex(
schema: Schema,
viewToSection: Record<string, string>,
linkEntries: SearchIndexEntry[],
): SearchIndexEntry[] {
const entries: SearchIndexEntry[] = [...linkEntries];
function displayNameFor(viewName: string): string {
const obj = schema.objects[viewName];
if (!obj) return viewName.replace(/^x:/, '');
let resolvedName = viewName;
let resolvedObj = obj;
if (obj.type === 'view') {
const parent = schema.objects[obj.objectName];
if (parent && parent.type !== 'view') {
resolvedName = obj.objectName;
resolvedObj = parent;
}
}
if (resolvedObj.type === 'singleton') {
const form = schema.forms[viewName] ?? schema.forms[resolvedName];
if (form?.title) return form.title;
}
if (resolvedObj.type === 'object') {
const list = schema.lists[viewName] ?? schema.lists[resolvedName];
if (list?.singularName) {
return list.singularName.charAt(0).toUpperCase() + list.singularName.slice(1);
}
}
return viewName.replace(/^x:/, '');
}
for (const [name, obj] of Object.entries(schema.objects)) {
if (obj.type !== 'view' && obj.description) {
const section = viewToSection[name] ?? '';
const display = displayNameFor(name);
entries.push({
text: obj.description,
type: 'link',
viewName: name,
section,
breadcrumb: section ? `${section} > ${display}` : display,
objectType: obj.type,
});
}
}
for (const [formKey, form] of Object.entries(schema.forms)) {
const viewName = formKey;
const section = viewToSection[viewName] ?? '';
const display = displayNameFor(viewName);
for (const formSection of form.sections) {
if (formSection.title) {
entries.push({
text: formSection.title,
type: 'form',
viewName,
section,
breadcrumb: section ? `${section} > ${display} > ${formSection.title}` : `${display} > ${formSection.title}`,
});
}
for (const field of formSection.fields) {
const keywords: string[] = [];
if (field.name && field.name !== '@type') {
keywords.push(field.name);
}
entries.push({
text: field.label,
type: 'field',
viewName,
section,
breadcrumb: section ? `${section} > ${display} > ${field.label}` : `${display} > ${field.label}`,
keywords,
});
}
}
}
return entries;
}
export const useSchemaStore = create<SchemaState>()((set) => ({
schema: null,
isLoaded: false,
viewToSection: {},
searchIndex: [],
setSchema: (schema) => {
const { viewToSection, linkEntries } = walkLayouts(schema);
const searchIndex = buildSearchIndex(schema, viewToSection, linkEntries);
set({
schema,
isLoaded: true,
viewToSection,
searchIndex,
});
},
}));
+77
View File
@@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'light' | 'dark';
interface UIState {
theme: Theme;
sidebarOpen: boolean;
activeSection: string;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setActiveSection: (section: string) => void;
}
function applyThemeClass(theme: Theme) {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
export const useUIStore = create<UIState>()(
persist(
(set, get) => ({
theme:
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
sidebarOpen: true,
activeSection: '',
toggleTheme: () => {
const next = get().theme === 'light' ? 'dark' : 'light';
applyThemeClass(next);
set({ theme: next });
},
setTheme: (theme) => {
applyThemeClass(theme);
set({ theme });
},
toggleSidebar: () => {
set({ sidebarOpen: !get().sidebarOpen });
},
setSidebarOpen: (open) => {
set({ sidebarOpen: open });
},
setActiveSection: (section) => {
set({ activeSection: section });
},
}),
{
name: 'stalwart-ui',
partialize: (state) => ({
theme: state.theme,
}),
onRehydrateStorage: () => {
return (state) => {
if (state) {
applyThemeClass(state.theme);
}
};
},
},
),
);