Initial commit
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
},
|
||||
}));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: {} });
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user