Release 1.0.8 with platform, security, and UI hardening.
Adds API filter registry, style theme registry, SW bitmask cache clear, KV namespacing, session expiry checks, accessibility improvements, and expanded test coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@reliancy/bface",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@reliancy/bface",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.4",
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@tamagui/config": "^1.144.2",
|
||||
|
||||
+37
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@reliancy/bface",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.8",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
@@ -14,9 +14,45 @@
|
||||
"import": "./dist/platform/*.js",
|
||||
"types": "./dist/platform/*.d.ts"
|
||||
},
|
||||
"./platform/*.js": {
|
||||
"import": "./dist/platform/*.js",
|
||||
"types": "./dist/platform/*.d.ts"
|
||||
},
|
||||
"./ui/*": {
|
||||
"import": "./dist/ui/*.js",
|
||||
"types": "./dist/ui/*.d.ts"
|
||||
},
|
||||
"./ui/components": {
|
||||
"import": "./dist/ui/components/index.js",
|
||||
"types": "./dist/ui/components/index.d.ts"
|
||||
},
|
||||
"./ui/*.js": {
|
||||
"import": "./dist/ui/*.js",
|
||||
"types": "./dist/ui/*.d.ts"
|
||||
},
|
||||
"./ui/*.jsx": {
|
||||
"import": "./dist/ui/*.js",
|
||||
"types": "./dist/ui/*.d.ts"
|
||||
},
|
||||
"./security/*": {
|
||||
"import": "./dist/security/*.js",
|
||||
"types": "./dist/security/*.d.ts"
|
||||
},
|
||||
"./security/*.js": {
|
||||
"import": "./dist/security/*.js",
|
||||
"types": "./dist/security/*.d.ts"
|
||||
},
|
||||
"./security/*.jsx": {
|
||||
"import": "./dist/security/*.js",
|
||||
"types": "./dist/security/*.d.ts"
|
||||
},
|
||||
"./data/*": {
|
||||
"import": "./dist/data/*.js",
|
||||
"types": "./dist/data/*.d.ts"
|
||||
},
|
||||
"./data/*.js": {
|
||||
"import": "./dist/data/*.js",
|
||||
"types": "./dist/data/*.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Named request/response filter registry for the shared API client.
|
||||
* Apps and platform modules register filters to mutate outbound requests
|
||||
* (auth headers, tracing, tenancy) and inbound responses (401 handling).
|
||||
*/
|
||||
|
||||
const FETCH_INIT_KEYS = new Set([
|
||||
'method',
|
||||
'headers',
|
||||
'body',
|
||||
'mode',
|
||||
'credentials',
|
||||
'cache',
|
||||
'redirect',
|
||||
'referrer',
|
||||
'referrerPolicy',
|
||||
'integrity',
|
||||
'keepalive',
|
||||
'signal',
|
||||
'window'
|
||||
]);
|
||||
|
||||
function normalizeFilterName(name = '') {
|
||||
return String(name || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function resolveSkipSet(options = {}, optionKey) {
|
||||
const value = options?.[optionKey];
|
||||
if (value === true) {
|
||||
return 'ALL';
|
||||
}
|
||||
if (!value) {
|
||||
return new Set();
|
||||
}
|
||||
const list = Array.isArray(value) ? value : [value];
|
||||
return new Set(list.map((entry) => normalizeFilterName(entry)).filter(Boolean));
|
||||
}
|
||||
|
||||
function shouldSkipFilter(filterName, skipSet) {
|
||||
if (skipSet === 'ALL') {
|
||||
return true;
|
||||
}
|
||||
return skipSet.has(normalizeFilterName(filterName));
|
||||
}
|
||||
|
||||
export function normalizeRequestAuthorization(authorization) {
|
||||
if (!authorization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof authorization === 'string') {
|
||||
const value = authorization.trim();
|
||||
return value ? { name: 'Authorization', value } : null;
|
||||
}
|
||||
|
||||
if (typeof authorization !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authorization.scheme != null && authorization.token != null) {
|
||||
const scheme = String(authorization.scheme).trim();
|
||||
const token = String(authorization.token).trim();
|
||||
if (!scheme || !token) {
|
||||
return null;
|
||||
}
|
||||
return { name: 'Authorization', value: `${scheme} ${token}` };
|
||||
}
|
||||
|
||||
const name = authorization.name || authorization.header || 'Authorization';
|
||||
const value = authorization.value;
|
||||
if (value == null || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: String(name).trim() || 'Authorization',
|
||||
value: String(value)
|
||||
};
|
||||
}
|
||||
|
||||
export function applyRequestAuthorization(headers, authorization) {
|
||||
const normalized = normalizeRequestAuthorization(authorization);
|
||||
if (!normalized) {
|
||||
return headers instanceof Headers ? headers : new Headers(headers || {});
|
||||
}
|
||||
|
||||
const nextHeaders = headers instanceof Headers
|
||||
? new Headers(headers)
|
||||
: new Headers(headers || {});
|
||||
nextHeaders.set(normalized.name, normalized.value);
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
export function toFetchInit(requestContext = {}) {
|
||||
const init = {};
|
||||
for (const key of FETCH_INIT_KEYS) {
|
||||
if (requestContext[key] !== undefined) {
|
||||
init[key] = requestContext[key];
|
||||
}
|
||||
}
|
||||
return init;
|
||||
}
|
||||
|
||||
export function createAPIFilterRegistry() {
|
||||
const requestFilters = new Map();
|
||||
const responseFilters = new Map();
|
||||
let legacyRequestSeq = 0;
|
||||
let legacyResponseSeq = 0;
|
||||
|
||||
function sortEntries(filtersMap) {
|
||||
return Array.from(filtersMap.entries()).sort((left, right) => {
|
||||
const priorityDelta = (left[1].priority ?? 0) - (right[1].priority ?? 0);
|
||||
if (priorityDelta !== 0) {
|
||||
return priorityDelta;
|
||||
}
|
||||
return left[0].localeCompare(right[0]);
|
||||
});
|
||||
}
|
||||
|
||||
function registerRequestFilter(filterName, filterFn, { priority = 0 } = {}) {
|
||||
const normalizedName = normalizeFilterName(filterName);
|
||||
if (!normalizedName) {
|
||||
throw new Error('Request filter name is required');
|
||||
}
|
||||
if (typeof filterFn !== 'function') {
|
||||
throw new Error('Request filter must be a function');
|
||||
}
|
||||
|
||||
requestFilters.set(normalizedName, {
|
||||
name: normalizedName,
|
||||
priority,
|
||||
fn: filterFn
|
||||
});
|
||||
return normalizedName;
|
||||
}
|
||||
|
||||
function unregisterRequestFilter(filterName) {
|
||||
return requestFilters.delete(normalizeFilterName(filterName));
|
||||
}
|
||||
|
||||
function listRequestFilters() {
|
||||
return sortEntries(requestFilters).map(([name, entry]) => ({
|
||||
name,
|
||||
priority: entry.priority
|
||||
}));
|
||||
}
|
||||
|
||||
function registerResponseFilter(filterName, filterFn, { priority = 0 } = {}) {
|
||||
const normalizedName = normalizeFilterName(filterName);
|
||||
if (!normalizedName) {
|
||||
throw new Error('Response filter name is required');
|
||||
}
|
||||
if (typeof filterFn !== 'function') {
|
||||
throw new Error('Response filter must be a function');
|
||||
}
|
||||
|
||||
responseFilters.set(normalizedName, {
|
||||
name: normalizedName,
|
||||
priority,
|
||||
fn: filterFn
|
||||
});
|
||||
return normalizedName;
|
||||
}
|
||||
|
||||
function unregisterResponseFilter(filterName) {
|
||||
return responseFilters.delete(normalizeFilterName(filterName));
|
||||
}
|
||||
|
||||
function listResponseFilters() {
|
||||
return sortEntries(responseFilters).map(([name, entry]) => ({
|
||||
name,
|
||||
priority: entry.priority
|
||||
}));
|
||||
}
|
||||
|
||||
function addLegacyRequestInterceptor(interceptor) {
|
||||
if (typeof interceptor !== 'function') {
|
||||
throw new Error('Request interceptor must be a function');
|
||||
}
|
||||
legacyRequestSeq += 1;
|
||||
return registerRequestFilter(`legacy.request.${legacyRequestSeq}`, async (requestContext) => {
|
||||
const result = interceptor(requestContext);
|
||||
return result ?? requestContext;
|
||||
}, { priority: 0 });
|
||||
}
|
||||
|
||||
function addLegacyResponseInterceptor(interceptor) {
|
||||
if (typeof interceptor !== 'function') {
|
||||
throw new Error('Response interceptor must be a function');
|
||||
}
|
||||
legacyResponseSeq += 1;
|
||||
return registerResponseFilter(`legacy.response.${legacyResponseSeq}`, async (response, context) => {
|
||||
const result = interceptor(response, context);
|
||||
return result ?? response;
|
||||
}, { priority: 0 });
|
||||
}
|
||||
|
||||
async function applyRequestFilters(requestContext = {}) {
|
||||
let nextContext = { ...requestContext };
|
||||
const skipSet = resolveSkipSet(nextContext, 'skipRequestFilters');
|
||||
|
||||
for (const [, entry] of sortEntries(requestFilters)) {
|
||||
if (shouldSkipFilter(entry.name, skipSet)) {
|
||||
continue;
|
||||
}
|
||||
nextContext = await entry.fn(nextContext);
|
||||
if (!nextContext || typeof nextContext !== 'object') {
|
||||
throw new Error(`Request filter "${entry.name}" must return a request context object`);
|
||||
}
|
||||
}
|
||||
|
||||
return nextContext;
|
||||
}
|
||||
|
||||
async function applyResponseFilters(response, responseContext = {}) {
|
||||
let nextResponse = response;
|
||||
const skipSet = resolveSkipSet(responseContext.request || {}, 'skipResponseFilters');
|
||||
|
||||
for (const [, entry] of sortEntries(responseFilters)) {
|
||||
if (shouldSkipFilter(entry.name, skipSet)) {
|
||||
continue;
|
||||
}
|
||||
nextResponse = await entry.fn(nextResponse, responseContext);
|
||||
}
|
||||
|
||||
return nextResponse;
|
||||
}
|
||||
|
||||
function clearRequestFilters() {
|
||||
requestFilters.clear();
|
||||
legacyRequestSeq = 0;
|
||||
}
|
||||
|
||||
function clearResponseFilters() {
|
||||
responseFilters.clear();
|
||||
legacyResponseSeq = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
registerRequestFilter,
|
||||
unregisterRequestFilter,
|
||||
listRequestFilters,
|
||||
registerResponseFilter,
|
||||
unregisterResponseFilter,
|
||||
listResponseFilters,
|
||||
addLegacyRequestInterceptor,
|
||||
addLegacyResponseInterceptor,
|
||||
applyRequestFilters,
|
||||
applyResponseFilters,
|
||||
clearRequestFilters,
|
||||
clearResponseFilters
|
||||
};
|
||||
}
|
||||
+82
-28
@@ -3,6 +3,20 @@
|
||||
* Shared fetch wrapper for /api/* endpoints with consistent parsing and errors.
|
||||
*/
|
||||
|
||||
import {
|
||||
applyRequestAuthorization,
|
||||
createAPIFilterRegistry,
|
||||
normalizeRequestAuthorization,
|
||||
toFetchInit
|
||||
} from './api-filters.js';
|
||||
|
||||
export {
|
||||
applyRequestAuthorization,
|
||||
createAPIFilterRegistry,
|
||||
normalizeRequestAuthorization,
|
||||
toFetchInit
|
||||
} from './api-filters.js';
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(message, details = {}) {
|
||||
super(message);
|
||||
@@ -51,7 +65,6 @@ class NetworkActivityManager {
|
||||
}
|
||||
|
||||
beginRequest() {
|
||||
const id = ++this._nextId;
|
||||
this.activeCount += 1;
|
||||
|
||||
if (this.activeCount === 1) {
|
||||
@@ -132,35 +145,72 @@ function createAPIError(response, payload, url) {
|
||||
});
|
||||
}
|
||||
|
||||
const defaultFilterRegistry = createAPIFilterRegistry();
|
||||
|
||||
export function registerRequestFilter(filterName, filterFn, options = {}) {
|
||||
return defaultFilterRegistry.registerRequestFilter(filterName, filterFn, options);
|
||||
}
|
||||
|
||||
export function unregisterRequestFilter(filterName) {
|
||||
return defaultFilterRegistry.unregisterRequestFilter(filterName);
|
||||
}
|
||||
|
||||
export function listRequestFilters() {
|
||||
return defaultFilterRegistry.listRequestFilters();
|
||||
}
|
||||
|
||||
export function registerResponseFilter(filterName, filterFn, options = {}) {
|
||||
return defaultFilterRegistry.registerResponseFilter(filterName, filterFn, options);
|
||||
}
|
||||
|
||||
export function unregisterResponseFilter(filterName) {
|
||||
return defaultFilterRegistry.unregisterResponseFilter(filterName);
|
||||
}
|
||||
|
||||
export function listResponseFilters() {
|
||||
return defaultFilterRegistry.listResponseFilters();
|
||||
}
|
||||
|
||||
export class APIClient {
|
||||
constructor(baseURL = '/api', sharedInterceptors = null) {
|
||||
constructor(baseURL = '/api', sharedFilterRegistry = null) {
|
||||
this.baseURL = baseURL;
|
||||
this.interceptors = sharedInterceptors || {
|
||||
request: [],
|
||||
response: []
|
||||
};
|
||||
this.filters = sharedFilterRegistry || defaultFilterRegistry;
|
||||
}
|
||||
|
||||
getFilterRegistry() {
|
||||
return this.filters;
|
||||
}
|
||||
|
||||
registerRequestFilter(filterName, filterFn, options = {}) {
|
||||
return this.filters.registerRequestFilter(filterName, filterFn, options);
|
||||
}
|
||||
|
||||
unregisterRequestFilter(filterName) {
|
||||
return this.filters.unregisterRequestFilter(filterName);
|
||||
}
|
||||
|
||||
listRequestFilters() {
|
||||
return this.filters.listRequestFilters();
|
||||
}
|
||||
|
||||
registerResponseFilter(filterName, filterFn, options = {}) {
|
||||
return this.filters.registerResponseFilter(filterName, filterFn, options);
|
||||
}
|
||||
|
||||
unregisterResponseFilter(filterName) {
|
||||
return this.filters.unregisterResponseFilter(filterName);
|
||||
}
|
||||
|
||||
listResponseFilters() {
|
||||
return this.filters.listResponseFilters();
|
||||
}
|
||||
|
||||
addRequestInterceptor(interceptor) {
|
||||
this.interceptors.request.push(interceptor);
|
||||
return this.filters.addLegacyRequestInterceptor(interceptor);
|
||||
}
|
||||
|
||||
addResponseInterceptor(interceptor) {
|
||||
this.interceptors.response.push(interceptor);
|
||||
}
|
||||
|
||||
_applyRequestInterceptors(config) {
|
||||
return this.interceptors.request.reduce(
|
||||
(acc, interceptor) => interceptor(acc),
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
_applyResponseInterceptors(response) {
|
||||
return this.interceptors.response.reduce(
|
||||
(acc, interceptor) => interceptor(acc),
|
||||
response
|
||||
);
|
||||
return this.filters.addLegacyResponseInterceptor(interceptor);
|
||||
}
|
||||
|
||||
resolveURL(endpoint = '') {
|
||||
@@ -172,7 +222,7 @@ export class APIClient {
|
||||
}
|
||||
|
||||
scope(baseURL = '/api') {
|
||||
return new APIClient(baseURL, this.interceptors);
|
||||
return new APIClient(baseURL, this.filters);
|
||||
}
|
||||
|
||||
_buildHeaders(options = {}) {
|
||||
@@ -190,14 +240,18 @@ export class APIClient {
|
||||
const releaseActivity = trackActivity ? networkActivityManager.beginRequest() : null;
|
||||
|
||||
try {
|
||||
const config = this._applyRequestInterceptors({
|
||||
const requestContext = await this.filters.applyRequestFilters({
|
||||
...options,
|
||||
endpoint,
|
||||
url,
|
||||
headers: this._buildHeaders(options)
|
||||
});
|
||||
const response = await fetch(url, config);
|
||||
return this._applyResponseInterceptors(response);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
const response = await fetch(url, toFetchInit(requestContext));
|
||||
return this.filters.applyResponseFilters(response, {
|
||||
request: requestContext,
|
||||
endpoint,
|
||||
url
|
||||
});
|
||||
} finally {
|
||||
releaseActivity?.();
|
||||
}
|
||||
|
||||
+76
-3
@@ -3,7 +3,7 @@
|
||||
* Central config dictionary and environment discovery
|
||||
*/
|
||||
|
||||
import { getProvider } from './storage.js';
|
||||
import { configureKeyValueStoreBackend, getProvider } from './storage.js';
|
||||
import { api as apiClient } from './api.js';
|
||||
|
||||
// Private storage instance for config
|
||||
@@ -86,6 +86,7 @@ export const CONFIG_KEYS = {
|
||||
THEME_COLOR: 'THEME_COLOR',
|
||||
BACKGROUND_COLOR: 'BACKGROUND_COLOR',
|
||||
UI_SHELL: 'UI_SHELL',
|
||||
STYLE_THEME: 'STYLE_THEME',
|
||||
INITIAL_ROUTE: 'INITIAL_ROUTE',
|
||||
STORAGE_BACKEND: 'STORAGE_BACKEND',
|
||||
API_BASE_URL: 'API_BASE_URL',
|
||||
@@ -93,7 +94,9 @@ export const CONFIG_KEYS = {
|
||||
SECURITY_CONFIG: 'SECURITY_CONFIG',
|
||||
LOCALE: 'LOCALE',
|
||||
/** Development host: extra dev UI, SW dev behavior, etc. Layered via getConfig (storage → profile → bundler). */
|
||||
DEV_HOST: 'DEV_HOST'
|
||||
DEV_HOST: 'DEV_HOST',
|
||||
/** Whether the app should register and use a service worker. */
|
||||
SERVICE_WORKER_ENABLED: 'SERVICE_WORKER_ENABLED'
|
||||
};
|
||||
|
||||
// do not allow edits on these keys via setConfig - they are meant to be set from profile or env vars and not overridden at runtime
|
||||
@@ -136,6 +139,25 @@ function resolveDesktopApiBaseURL() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve service-worker enablement from profile.
|
||||
* Defaults to true for backward compatibility with PWA templates.
|
||||
* @param {Object|null|undefined} appConfig
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function resolveServiceWorkerEnabled(appConfig = {}) {
|
||||
if (typeof appConfig?.service_worker?.enabled === 'boolean') {
|
||||
return appConfig.service_worker.enabled;
|
||||
}
|
||||
if (typeof appConfig?.serviceWorker?.enabled === 'boolean') {
|
||||
return appConfig.serviceWorker.enabled;
|
||||
}
|
||||
if (typeof appConfig?.pwa?.service_worker?.enabled === 'boolean') {
|
||||
return appConfig.pwa.service_worker.enabled;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize environment config
|
||||
* @param {Object} appConfig - Configuration from profile
|
||||
@@ -157,6 +179,12 @@ export function initEnv(appConfig) {
|
||||
THEME_COLOR: appConfig.theme_color || appConfig.themeColor || '#000000',
|
||||
BACKGROUND_COLOR: appConfig.background_color || appConfig.backgroundColor || '#ffffff',
|
||||
UI_SHELL: appConfig.ui_shell || appConfig.uiShell || 'EmptyShell',
|
||||
STYLE_THEME:
|
||||
appConfig.style_theme
|
||||
|| appConfig.styleTheme
|
||||
|| appConfig.ui?.style_theme
|
||||
|| appConfig.ui?.styleTheme
|
||||
|| null,
|
||||
INITIAL_ROUTE: appConfig.initial_route || appConfig.initialRoute || appConfig.ui?.initial_route || appConfig.ui?.initialRoute || '/home',
|
||||
STORAGE_BACKEND: appConfig.storage?.backend || 'localStorage',
|
||||
API_BASE_URL: resolvedApiBaseURL,
|
||||
@@ -164,10 +192,12 @@ export function initEnv(appConfig) {
|
||||
SECURITY_CONFIG: appConfig.security || {},
|
||||
LOCALE: appConfig.locale || null,
|
||||
[CONFIG_KEYS.DEV_HOST]: resolveDevHostFlag(appConfig),
|
||||
[CONFIG_KEYS.SERVICE_WORKER_ENABLED]: resolveServiceWorkerEnabled(appConfig),
|
||||
// Store full profile for advanced access
|
||||
_profile: appConfig
|
||||
};
|
||||
apiClient.setBaseURL(resolvedApiBaseURL);
|
||||
configureKeyValueStoreBackend(config.STORAGE_BACKEND);
|
||||
}
|
||||
|
||||
function resolveSystemLocale() {
|
||||
@@ -397,7 +427,28 @@ export async function syncDocumentHeadFromConfig(options = {}) {
|
||||
* @param {any} altValue - Alternative value if not found
|
||||
* @returns {Promise<any>} Config value or altValue
|
||||
*/
|
||||
export function getConfigSync(key, altValue = null) {
|
||||
if (config.hasOwnProperty(key)) {
|
||||
return config[key];
|
||||
}
|
||||
|
||||
if (typeof import.meta !== 'undefined' && import.meta.env) {
|
||||
const envKey = `VITE_${key}`;
|
||||
const envValue = import.meta.env[envKey];
|
||||
if (envValue !== undefined) {
|
||||
return envValue;
|
||||
}
|
||||
}
|
||||
|
||||
return altValue;
|
||||
}
|
||||
|
||||
export async function getConfig(key, altValue = null) {
|
||||
// Locked profile keys cannot be overridden by persisted storage values.
|
||||
if (isConfigKeyLocked(key) && config.hasOwnProperty(key)) {
|
||||
return config[key];
|
||||
}
|
||||
|
||||
// 1. Check if key exists in storage first (quick check to avoid unnecessary get)
|
||||
try {
|
||||
const exists = await storage.hasKey(key);
|
||||
@@ -440,7 +491,7 @@ export async function getConfig(key, altValue = null) {
|
||||
* Otherwise, save to storage (for persistence).
|
||||
* @param {string} key - Config key
|
||||
* @param {any} value - Value to set
|
||||
* @returns {Promise<void>}
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function setConfig(key, value) {
|
||||
if (isConfigKeyLocked(key)) {
|
||||
@@ -520,6 +571,28 @@ export function isDevelopment() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether service worker registration is enabled for this app.
|
||||
* Synchronous: uses in-memory config after {@link initEnv} / {@link bootstrapEnv}.
|
||||
*/
|
||||
export function isServiceWorkerEnabledSync(altValue = true) {
|
||||
if (config && Object.prototype.hasOwnProperty.call(config, CONFIG_KEYS.SERVICE_WORKER_ENABLED)) {
|
||||
return Boolean(config[CONFIG_KEYS.SERVICE_WORKER_ENABLED]);
|
||||
}
|
||||
return altValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async profile-aware service worker enablement check.
|
||||
*/
|
||||
export async function isServiceWorkerEnabled(altValue = true) {
|
||||
const configured = await getConfig(CONFIG_KEYS.SERVICE_WORKER_ENABLED, null);
|
||||
if (typeof configured === 'boolean') {
|
||||
return configured;
|
||||
}
|
||||
return isServiceWorkerEnabledSync(altValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of {@link isDevelopment} when no explicit production flag exists.
|
||||
*/
|
||||
|
||||
+11
-4
@@ -7,6 +7,13 @@
|
||||
import { normalizeRightsInput } from '../security/model/rights.js';
|
||||
import { evaluateAuthRequirements } from '../security/runtime/access-rules.js';
|
||||
import { getProvider } from './storage.js';
|
||||
import { isDevelopment } from './env.js';
|
||||
|
||||
function menuDebug(...args) {
|
||||
if (isDevelopment()) {
|
||||
console.log(...args);
|
||||
}
|
||||
}
|
||||
|
||||
// Menu root structure: Map of root directory IDs to MenuItem instances
|
||||
const menuRoot = new Map();
|
||||
@@ -616,7 +623,7 @@ function initializeMenuRoot() {
|
||||
menuRoot.set(rootConfig.id, rootItem);
|
||||
}
|
||||
|
||||
console.log('[Menu] Initialized menu root with 4 directory items');
|
||||
menuDebug('[Menu] Initialized menu root with 4 directory items');
|
||||
}
|
||||
|
||||
// Initialize on module load
|
||||
@@ -813,7 +820,7 @@ export function publishMenuItem(path, item) {
|
||||
// Add to parent's items Map
|
||||
parent.addItem(itemId, menuItem);
|
||||
|
||||
console.log(`[Menu] Published item at: ${normalizedPath} (ID: ${itemId})`);
|
||||
menuDebug(`[Menu] Published item at: ${normalizedPath} (ID: ${itemId})`);
|
||||
|
||||
// Notify listeners of menu change
|
||||
notifyMenuChange();
|
||||
@@ -871,7 +878,7 @@ export function retractMenuItem(path) {
|
||||
|
||||
const removed = parent.removeItem(itemId);
|
||||
if (removed) {
|
||||
console.log(`[Menu] Retracted item at: ${normalizedPath}`);
|
||||
menuDebug(`[Menu] Retracted item at: ${normalizedPath}`);
|
||||
// Notify listeners of menu change
|
||||
notifyMenuChange();
|
||||
} else {
|
||||
@@ -999,7 +1006,7 @@ export function clearMenu() {
|
||||
for (const rootItem of menuRoot.values()) {
|
||||
rootItem.items.clear();
|
||||
}
|
||||
console.log('[Menu] Cleared all items (root items preserved)');
|
||||
menuDebug('[Menu] Cleared all items (root items preserved)');
|
||||
}
|
||||
|
||||
export async function restoreMenuPreferences() {
|
||||
|
||||
+81
-9
@@ -3,9 +3,21 @@
|
||||
* Provides abstraction over different storage types (KeyValueStore, etc.)
|
||||
*/
|
||||
|
||||
const KV_KEY_PREFIX = '__bface_kv:';
|
||||
const SUPPORTED_BACKENDS = new Set(['localStorage']);
|
||||
|
||||
// Private providers map: type.uri -> provider instance
|
||||
const providers = new Map();
|
||||
|
||||
function normalizeStorageBackend(backend = 'localStorage') {
|
||||
const normalized = String(backend || 'localStorage').trim();
|
||||
if (!SUPPORTED_BACKENDS.has(normalized)) {
|
||||
console.warn(`[Storage] Backend "${normalized}" is not implemented; using localStorage`);
|
||||
return 'localStorage';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyValueStore Class
|
||||
* Provides key-value storage abstraction over localStorage, IndexedDB, and OPFS
|
||||
@@ -13,7 +25,27 @@ const providers = new Map();
|
||||
class KeyValueStore {
|
||||
constructor(name, backend = 'localStorage') {
|
||||
this.name = name;
|
||||
this.backend = backend; // 'localStorage' | 'indexedDB' | 'opfs'
|
||||
this.backend = normalizeStorageBackend(backend);
|
||||
}
|
||||
|
||||
setBackend(backend = 'localStorage') {
|
||||
this.backend = normalizeStorageBackend(backend);
|
||||
}
|
||||
|
||||
_namespacedKey(key) {
|
||||
return `${KV_KEY_PREFIX}${this.name}:${key}`;
|
||||
}
|
||||
|
||||
_listNamespacedKeys() {
|
||||
const prefix = `${KV_KEY_PREFIX}${this.name}:`;
|
||||
const keys = [];
|
||||
for (let index = 0; index < localStorage.length; index += 1) {
|
||||
const storageKey = localStorage.key(index);
|
||||
if (storageKey && storageKey.startsWith(prefix)) {
|
||||
keys.push(storageKey);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +111,7 @@ class KeyValueStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all storage
|
||||
* Clear all storage owned by this provider
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async clear() {
|
||||
@@ -115,26 +147,51 @@ class KeyValueStore {
|
||||
|
||||
// LocalStorage implementations
|
||||
_getLocalStorage(key) {
|
||||
const value = localStorage.getItem(key);
|
||||
return Promise.resolve(value ? JSON.parse(value) : null);
|
||||
const namespacedKey = this._namespacedKey(key);
|
||||
const value = localStorage.getItem(namespacedKey);
|
||||
if (value !== null) {
|
||||
return Promise.resolve(JSON.parse(value));
|
||||
}
|
||||
|
||||
// Legacy flat key fallback for the shared config provider.
|
||||
if (this.name === 'config') {
|
||||
const legacyValue = localStorage.getItem(key);
|
||||
return Promise.resolve(legacyValue ? JSON.parse(legacyValue) : null);
|
||||
}
|
||||
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
_hasKeyLocalStorage(key) {
|
||||
const namespacedKey = this._namespacedKey(key);
|
||||
if (localStorage.getItem(namespacedKey) !== null) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
if (this.name === 'config') {
|
||||
return Promise.resolve(localStorage.getItem(key) !== null);
|
||||
}
|
||||
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
_setLocalStorage(key, value) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
localStorage.setItem(this._namespacedKey(key), JSON.stringify(value));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
_removeLocalStorage(key) {
|
||||
localStorage.removeItem(this._namespacedKey(key));
|
||||
if (this.name === 'config') {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
_clearLocalStorage() {
|
||||
localStorage.clear();
|
||||
this._listNamespacedKeys().forEach((storageKey) => {
|
||||
localStorage.removeItem(storageKey);
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -191,13 +248,28 @@ class KeyValueStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply profile storage backend to all existing KV providers.
|
||||
* @param {string} backend
|
||||
*/
|
||||
export function configureKeyValueStoreBackend(backend = 'localStorage') {
|
||||
const normalized = normalizeStorageBackend(backend);
|
||||
for (const provider of providers.values()) {
|
||||
if (provider instanceof KeyValueStore) {
|
||||
provider.setBackend(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a storage provider
|
||||
* @param {string} type - Provider type (e.g., "kv" for KeyValueStore)
|
||||
* @param {string} uri - Provider URI/identifier (e.g., "config", "cache")
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.backend] - Optional backend override
|
||||
* @returns {KeyValueStore|null} Provider instance or null if type not supported
|
||||
*/
|
||||
export function getProvider(type, uri) {
|
||||
export function getProvider(type, uri, options = {}) {
|
||||
const key = `${type}.${uri}`;
|
||||
|
||||
// Check if provider already exists
|
||||
@@ -207,7 +279,7 @@ export function getProvider(type, uri) {
|
||||
|
||||
// Create new provider based on type
|
||||
if (type === 'kv') {
|
||||
const provider = new KeyValueStore(uri, 'localStorage');
|
||||
const provider = new KeyValueStore(uri, options.backend || 'localStorage');
|
||||
providers.set(key, provider);
|
||||
return provider;
|
||||
}
|
||||
@@ -217,4 +289,4 @@ export function getProvider(type, uri) {
|
||||
}
|
||||
|
||||
// Export KeyValueStore class for direct instantiation if needed
|
||||
export { KeyValueStore };
|
||||
export { KeyValueStore, KV_KEY_PREFIX };
|
||||
|
||||
+211
-57
@@ -5,12 +5,73 @@
|
||||
*/
|
||||
|
||||
import { isElectronHost, isTauriHost } from './host.js';
|
||||
import { getConfig, isDevelopment, CONFIG_KEYS } from './env.js';
|
||||
import {
|
||||
getConfig,
|
||||
isDevelopment,
|
||||
isServiceWorkerEnabled,
|
||||
isServiceWorkerEnabledSync,
|
||||
CONFIG_KEYS
|
||||
} from './env.js';
|
||||
|
||||
const SW_PATH = '/sw.js';
|
||||
const SW_SCOPE = '/';
|
||||
const DEV_SW_RESET_KEY = '__bface_dev_sw_reset__';
|
||||
|
||||
/** Bit flags for {@link clearPWACache}. Combine with `|`. */
|
||||
export const PWA_CACHE_SCOPE = {
|
||||
SERVICE_WORKERS: 1,
|
||||
CACHES: 2,
|
||||
STORAGE: 4,
|
||||
ALL: 1 | 2 | 4
|
||||
};
|
||||
|
||||
const SW_REGISTRATION = {
|
||||
promise: null,
|
||||
updateListenerAttached: false
|
||||
};
|
||||
|
||||
function hasServiceWorkerSupport() {
|
||||
return typeof navigator !== 'undefined' && 'serviceWorker' in navigator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve what the service worker layer should do for the current host/profile.
|
||||
* @returns {Promise<{ action: 'register' | 'unregister' | 'skip', reason: string }>}
|
||||
*/
|
||||
export async function resolveServiceWorkerAction() {
|
||||
if (isElectronHost() || isTauriHost()) {
|
||||
return { action: 'unregister', reason: 'desktop_host' };
|
||||
}
|
||||
|
||||
if (!(await isServiceWorkerEnabled())) {
|
||||
return { action: 'unregister', reason: 'disabled_by_profile' };
|
||||
}
|
||||
|
||||
if (!hasServiceWorkerSupport()) {
|
||||
return { action: 'skip', reason: 'not_supported' };
|
||||
}
|
||||
|
||||
const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment());
|
||||
if (devHost) {
|
||||
return { action: 'unregister', reason: 'development' };
|
||||
}
|
||||
|
||||
return { action: 'register', reason: 'enabled' };
|
||||
}
|
||||
|
||||
async function canUseServiceWorker() {
|
||||
const policy = await resolveServiceWorkerAction();
|
||||
return policy.action === 'register';
|
||||
}
|
||||
|
||||
async function ensureServiceWorkerDisabled() {
|
||||
if (!hasServiceWorkerSupport()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return unregisterAllServiceWorkers();
|
||||
}
|
||||
|
||||
async function clearAllCaches() {
|
||||
if ('caches' in window) {
|
||||
try {
|
||||
@@ -30,7 +91,7 @@ async function clearAllCaches() {
|
||||
}
|
||||
|
||||
async function unregisterAllServiceWorkers() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
if (hasServiceWorkerSupport()) {
|
||||
try {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(registrations.map((reg) => {
|
||||
@@ -38,6 +99,7 @@ async function unregisterAllServiceWorkers() {
|
||||
return reg.unregister();
|
||||
}));
|
||||
console.log(`[SW] Unregistered ${registrations.length} service worker(s)`);
|
||||
SW_REGISTRATION.updateListenerAttached = false;
|
||||
return registrations.length;
|
||||
} catch (error) {
|
||||
console.error('[SW] Failed to unregister service workers:', error);
|
||||
@@ -83,51 +145,59 @@ async function clearAllStorage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPWACache() {
|
||||
console.log('Clearing all PWA caches and storage...');
|
||||
async function clearPWACache(scope = PWA_CACHE_SCOPE.ALL) {
|
||||
const mask = typeof scope === 'number' ? scope : PWA_CACHE_SCOPE.ALL;
|
||||
const parts = [];
|
||||
|
||||
if (mask & PWA_CACHE_SCOPE.SERVICE_WORKERS) {
|
||||
parts.push('service workers');
|
||||
}
|
||||
if (mask & PWA_CACHE_SCOPE.CACHES) {
|
||||
parts.push('caches');
|
||||
}
|
||||
if (mask & PWA_CACHE_SCOPE.STORAGE) {
|
||||
parts.push('storage');
|
||||
}
|
||||
|
||||
console.log(`Clearing PWA scope: ${parts.join(', ') || 'none'}...`);
|
||||
|
||||
try {
|
||||
const swCount = await unregisterAllServiceWorkers();
|
||||
const cacheCount = await clearAllCaches();
|
||||
await clearAllStorage();
|
||||
|
||||
console.log(`PWA cache cleared: ${swCount} service worker(s), ${cacheCount} cache(s), and all storage`);
|
||||
console.log('Reload the page to re-register service workers');
|
||||
|
||||
return {
|
||||
serviceWorkers: swCount,
|
||||
caches: cacheCount,
|
||||
storage: true
|
||||
const result = {
|
||||
serviceWorkers: 0,
|
||||
caches: 0,
|
||||
storage: false
|
||||
};
|
||||
|
||||
if (mask & PWA_CACHE_SCOPE.SERVICE_WORKERS) {
|
||||
result.serviceWorkers = await unregisterAllServiceWorkers();
|
||||
}
|
||||
if (mask & PWA_CACHE_SCOPE.CACHES) {
|
||||
result.caches = await clearAllCaches();
|
||||
}
|
||||
if (mask & PWA_CACHE_SCOPE.STORAGE) {
|
||||
result.storage = await clearAllStorage();
|
||||
}
|
||||
|
||||
console.log(
|
||||
`PWA cache cleared: ${result.serviceWorkers} service worker(s), ${result.caches} cache(s), storage=${result.storage}`
|
||||
);
|
||||
if (mask & PWA_CACHE_SCOPE.SERVICE_WORKERS) {
|
||||
console.log('Reload the page to re-register service workers');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to clear PWA cache:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerServiceWorker() {
|
||||
if (isElectronHost() || isTauriHost()) {
|
||||
await unregisterAllServiceWorkers();
|
||||
console.log('[SW] Skipping service worker registration in desktop host');
|
||||
return null;
|
||||
function attachUpdateFoundListener(registration) {
|
||||
if (SW_REGISTRATION.updateListenerAttached) {
|
||||
return;
|
||||
}
|
||||
|
||||
const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment());
|
||||
if (devHost) {
|
||||
console.log('[SW] Skipping service worker registration in development');
|
||||
return null;
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register(SW_PATH, {
|
||||
scope: SW_SCOPE,
|
||||
updateViaCache: 'none'
|
||||
});
|
||||
|
||||
console.log('Service Worker registered:', registration);
|
||||
await registration.update();
|
||||
|
||||
SW_REGISTRATION.updateListenerAttached = true;
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
if (newWorker) {
|
||||
@@ -143,26 +213,83 @@ async function registerServiceWorker() {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return registration;
|
||||
} catch (error) {
|
||||
console.error('Service Worker registration failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('Service Workers are not supported');
|
||||
async function executeServiceWorkerPolicy(policy) {
|
||||
if (policy.action === 'unregister') {
|
||||
const removed = await unregisterAllServiceWorkers();
|
||||
if (policy.reason === 'disabled_by_profile') {
|
||||
if (removed > 0) {
|
||||
console.log('[SW] Service worker disabled by profile; removed stale registration(s)');
|
||||
} else {
|
||||
console.log('[SW] Service worker disabled by profile');
|
||||
}
|
||||
} else if (policy.reason === 'desktop_host') {
|
||||
console.log('[SW] Skipping service worker registration in desktop host');
|
||||
} else if (policy.reason === 'development') {
|
||||
if (removed > 0) {
|
||||
console.log(`[SW] Development mode: unregistered ${removed} stale service worker(s)`);
|
||||
}
|
||||
console.log('[SW] Skipping service worker registration in development');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (policy.action === 'skip') {
|
||||
if (policy.reason === 'not_supported') {
|
||||
console.warn('[SW] Service workers are not supported');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let registration = await resolveServiceWorkerRegistration();
|
||||
if (!registration) {
|
||||
registration = await navigator.serviceWorker.register(SW_PATH, {
|
||||
scope: SW_SCOPE,
|
||||
updateViaCache: 'none'
|
||||
});
|
||||
console.log('Service Worker registered:', registration);
|
||||
} else {
|
||||
console.log('[SW] Reusing existing service worker registration:', registration.scope);
|
||||
}
|
||||
|
||||
attachUpdateFoundListener(registration);
|
||||
await registration.update();
|
||||
return registration;
|
||||
}
|
||||
|
||||
async function registerServiceWorker() {
|
||||
if (SW_REGISTRATION.promise) {
|
||||
return SW_REGISTRATION.promise;
|
||||
}
|
||||
|
||||
SW_REGISTRATION.promise = (async () => {
|
||||
try {
|
||||
const policy = await resolveServiceWorkerAction();
|
||||
return await executeServiceWorkerPolicy(policy);
|
||||
} catch (error) {
|
||||
console.error('Service Worker registration failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
SW_REGISTRATION.promise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return SW_REGISTRATION.promise;
|
||||
}
|
||||
|
||||
async function resetServiceWorkers() {
|
||||
if (!(await isServiceWorkerEnabled())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isElectronHost() || isTauriHost()) {
|
||||
await unregisterAllServiceWorkers();
|
||||
return false;
|
||||
}
|
||||
|
||||
const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment());
|
||||
if (!devHost || typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
||||
if (!devHost || typeof window === 'undefined' || !hasServiceWorkerSupport()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -185,31 +312,52 @@ async function resetServiceWorkers() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function resolveServiceWorkerRegistration() {
|
||||
if (!hasServiceWorkerSupport()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await navigator.serviceWorker.getRegistration(SW_SCOPE)
|
||||
|| await navigator.serviceWorker.getRegistration();
|
||||
} catch (error) {
|
||||
console.warn('[SW] Failed to read service worker registration:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getServiceWorkerStatus() {
|
||||
if (isElectronHost() || isTauriHost()) {
|
||||
return 'Desktop Disabled';
|
||||
}
|
||||
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
||||
if (!(await isServiceWorkerEnabled())) {
|
||||
return 'Disabled';
|
||||
}
|
||||
|
||||
if (!hasServiceWorkerSupport()) {
|
||||
return 'Not Supported';
|
||||
}
|
||||
|
||||
const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment());
|
||||
try {
|
||||
if (devHost) {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
if (registration) {
|
||||
registration.update();
|
||||
return 'Active';
|
||||
}
|
||||
return 'Development Disabled';
|
||||
const registration = await resolveServiceWorkerRegistration();
|
||||
if (!registration) {
|
||||
return devHost ? 'Development Disabled' : 'Not Registered';
|
||||
}
|
||||
|
||||
await navigator.serviceWorker.ready;
|
||||
if (registration.active) {
|
||||
return 'Active';
|
||||
}
|
||||
|
||||
if (registration.installing || registration.waiting) {
|
||||
return 'Installing';
|
||||
}
|
||||
|
||||
return 'Inactive';
|
||||
} catch (error) {
|
||||
console.warn('[SW] Failed to resolve service worker status:', error);
|
||||
return 'Not Supported';
|
||||
return 'Unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,16 +366,18 @@ async function unregisterServiceWorker() {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
if (hasServiceWorkerSupport()) {
|
||||
const registration = await resolveServiceWorkerRegistration();
|
||||
if (registration) {
|
||||
await registration.unregister();
|
||||
SW_REGISTRATION.updateListenerAttached = false;
|
||||
console.log('Service Worker unregistered');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const sw = {
|
||||
PWA_CACHE_SCOPE,
|
||||
clearAllCaches,
|
||||
unregisterAllServiceWorkers,
|
||||
clearAllStorage,
|
||||
@@ -235,5 +385,9 @@ export const sw = {
|
||||
registerServiceWorker,
|
||||
resetServiceWorkers,
|
||||
getServiceWorkerStatus,
|
||||
unregisterServiceWorker
|
||||
unregisterServiceWorker,
|
||||
isEnabled: isServiceWorkerEnabled,
|
||||
isEnabledSync: isServiceWorkerEnabledSync,
|
||||
ensureDisabled: ensureServiceWorkerDisabled,
|
||||
resolveAction: resolveServiceWorkerAction
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Adapt, Button, Dialog, Image, Input, Label, Paragraph, Sheet, Text, XStack, YStack } from 'tamagui';
|
||||
import { Adapt, Button, Dialog, Image, Input, Label, Paragraph, Sheet, Text, VisuallyHidden, XStack, YStack } from 'tamagui';
|
||||
import { getRouterPath, scheduleTimeout, setRouterPath } from '../../platform/compat.js';
|
||||
import { securityService, useSecurityState } from '../runtime/security-service.js';
|
||||
import { Panel } from '../../ui/components/Panel.jsx';
|
||||
@@ -462,6 +462,9 @@ export function LoginDialog({ open = true, title = 'Login', subtitle = 'This rou
|
||||
width="100%"
|
||||
maxWidth={520}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</VisuallyHidden>
|
||||
<LoginPanel title={title} subtitle={subtitle} compact onComplete={() => {}} />
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
|
||||
@@ -806,7 +806,7 @@ export function SecurityAdminPage() {
|
||||
icon="lock"
|
||||
title="Security"
|
||||
description="Directory-style security administration for users, roles, realms, resources, and permits."
|
||||
defaultExpanded={false}
|
||||
defaultExpanded={true}
|
||||
persistenceKey="settings.security"
|
||||
content={content}
|
||||
contentStyle="tabs"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getProvider } from '../../platform/storage.js';
|
||||
import { api } from '../../platform/api.js';
|
||||
import { SECURITY_REQUEST_FILTER } from '../runtime/api-auth.js';
|
||||
import { SecurityPolicy } from './SecurityPolicy.js';
|
||||
import {
|
||||
AccountProfile,
|
||||
@@ -57,16 +58,27 @@ export class ApiSecurityPolicy extends SecurityPolicy {
|
||||
|
||||
async _request(path, options = {}, extra = {}) {
|
||||
const { authToken = null, trackActivity = true } = extra;
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (authToken) {
|
||||
headers.set('Authorization', `Bearer ${authToken}`);
|
||||
}
|
||||
|
||||
return this.client.requestJSON(path, {
|
||||
...options,
|
||||
trackActivity,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
...(authToken ? { Authorization: `Bearer ${authToken}` } : {})
|
||||
}
|
||||
skipRequestFilters: [SECURITY_REQUEST_FILTER],
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
async getRequestAuthorization(_requestContext, { session } = {}) {
|
||||
const token = session?.jwt_token || this.cache.session?.jwt_token || null;
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return { scheme: 'Bearer', token };
|
||||
}
|
||||
|
||||
_applyBundle(bundle = {}) {
|
||||
this.cache.session = bundle.session ? new Session(bundle.session) : null;
|
||||
this.cache.user = bundle.user ? new User(bundle.user) : null;
|
||||
@@ -100,9 +112,10 @@ export class ApiSecurityPolicy extends SecurityPolicy {
|
||||
async authenticate(credentials = {}) {
|
||||
const username = credentials.username || credentials.email || '';
|
||||
const password = credentials.password || '';
|
||||
const basicToken = typeof btoa === 'function'
|
||||
? btoa(`${username}:${password}`)
|
||||
: Buffer.from(`${username}:${password}`, 'utf-8').toString('base64');
|
||||
if (typeof btoa !== 'function') {
|
||||
throw new Error('Basic authentication requires btoa in the current runtime');
|
||||
}
|
||||
const basicToken = btoa(`${username}:${password}`);
|
||||
const bundle = await this._request('/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -462,6 +475,49 @@ export class ApiSecurityPolicy extends SecurityPolicy {
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
}
|
||||
|
||||
async listAccountSettings(userId) {
|
||||
await this._ensureSessionLoaded();
|
||||
if (!this.cache.user || this.cache.user.id !== userId) {
|
||||
throw new Error('Account settings are only available for the authenticated user');
|
||||
}
|
||||
const rows = await this._request('/account/settings', {
|
||||
method: 'GET'
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
return Array.isArray(rows) ? clone(rows) : [];
|
||||
}
|
||||
|
||||
async getAccountSetting(userId, key) {
|
||||
await this._ensureSessionLoaded();
|
||||
if (!this.cache.user || this.cache.user.id !== userId) {
|
||||
throw new Error('Account settings are only available for the authenticated user');
|
||||
}
|
||||
return clone(await this._request(`/account/settings/${encodeURIComponent(key)}`, {
|
||||
method: 'GET'
|
||||
}, { authToken: this.cache.session?.jwt_token }));
|
||||
}
|
||||
|
||||
async updateAccountSetting(userId, key, patch = {}) {
|
||||
await this._ensureSessionLoaded();
|
||||
if (!this.cache.user || this.cache.user.id !== userId) {
|
||||
throw new Error('Account settings are only available for the authenticated user');
|
||||
}
|
||||
return clone(await this._request(`/account/settings/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
}, { authToken: this.cache.session?.jwt_token }));
|
||||
}
|
||||
|
||||
async deleteAccountSetting(userId, key) {
|
||||
await this._ensureSessionLoaded();
|
||||
if (!this.cache.user || this.cache.user.id !== userId) {
|
||||
throw new Error('Account settings are only available for the authenticated user');
|
||||
}
|
||||
return this._request(`/account/settings/${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE'
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
}
|
||||
|
||||
async grantPermit(permitData) {
|
||||
await this._ensureSessionLoaded();
|
||||
const created = await this._request('/admin/permits', {
|
||||
@@ -492,6 +548,20 @@ export class ApiSecurityPolicy extends SecurityPolicy {
|
||||
this._invalidateAdminCache();
|
||||
}
|
||||
|
||||
_buildPrincipals(user) {
|
||||
if (!user?.id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const principals = [{ principal_type: 'user', principal_id: user.id }];
|
||||
if (Array.isArray(user.role_ids)) {
|
||||
user.role_ids.forEach((roleId) => {
|
||||
principals.push({ principal_type: 'role', principal_id: roleId });
|
||||
});
|
||||
}
|
||||
return principals;
|
||||
}
|
||||
|
||||
_evaluateCached(rights, resourcePath) {
|
||||
if (!this.cache.user?.id || !this.cache.session?.jwt_token) {
|
||||
return {
|
||||
@@ -504,7 +574,14 @@ export class ApiSecurityPolicy extends SecurityPolicy {
|
||||
|
||||
const requestedRights = normalizeRightsInput(rights);
|
||||
const targetPath = resourcePath || '/';
|
||||
const matchingPermits = this.cache.permits.filter((permit) => pathMatches(permit.resource_path, targetPath));
|
||||
const principals = this._buildPrincipals(this.cache.user);
|
||||
const matchingPermits = this.cache.permits.filter((permit) => {
|
||||
const principalMatch = principals.some((principal) => (
|
||||
principal.principal_type === permit.principal_type
|
||||
&& principal.principal_id === permit.principal_id
|
||||
));
|
||||
return principalMatch && pathMatches(permit.resource_path, targetPath);
|
||||
});
|
||||
const denyMatch = matchingPermits.find((permit) => permit.effect === 'deny' && hasRequiredRights(permit.rights, requestedRights));
|
||||
if (denyMatch) {
|
||||
return {
|
||||
|
||||
@@ -287,6 +287,14 @@ export class BasicSecurityPolicy extends SecurityPolicy {
|
||||
await this.storage.remove(SESSION_KEY);
|
||||
}
|
||||
|
||||
async getRequestAuthorization(_requestContext, { session } = {}) {
|
||||
const token = session?.jwt_token || null;
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return { scheme: 'Bearer', token };
|
||||
}
|
||||
|
||||
async listUsers() {
|
||||
const dataset = await this._loadDataset();
|
||||
return clone(dataset.users);
|
||||
@@ -319,13 +327,26 @@ export class BasicSecurityPolicy extends SecurityPolicy {
|
||||
return clone(user);
|
||||
}
|
||||
|
||||
async updateUser(userId, patch) {
|
||||
async updateUser(userId, patch = {}) {
|
||||
const dataset = await this._loadDataset();
|
||||
const user = dataset.users.find((item) => item.id === userId);
|
||||
if (!user) {
|
||||
throw new Error(`User not found: ${userId}`);
|
||||
}
|
||||
Object.assign(user, patch, { updated_on: nowISO() });
|
||||
|
||||
const {
|
||||
password,
|
||||
password_hash: incomingPasswordHash,
|
||||
...safePatch
|
||||
} = patch;
|
||||
|
||||
if (password) {
|
||||
safePatch.password_hash = await hashPassword(password);
|
||||
} else if (incomingPasswordHash !== undefined) {
|
||||
safePatch.password_hash = incomingPasswordHash;
|
||||
}
|
||||
|
||||
Object.assign(user, safePatch, { updated_on: nowISO() });
|
||||
await this._persistDataset();
|
||||
return clone(user);
|
||||
}
|
||||
|
||||
@@ -36,20 +36,37 @@ export class SecurityPolicy {
|
||||
async getAccountProfile(_userId) { return null; }
|
||||
async updateAccountProfile(_userId, _patch) { throw new Error('updateAccountProfile() not implemented'); }
|
||||
async changePassword(_userId, _passwordInput) { throw new Error('changePassword() not implemented'); }
|
||||
async listAccountSettings(_userId) { return []; }
|
||||
async getAccountSetting(_userId, _key) { return null; }
|
||||
async updateAccountSetting(_userId, _key, _patch) { throw new Error('updateAccountSetting() not implemented'); }
|
||||
async deleteAccountSetting(_userId, _key) { throw new Error('deleteAccountSetting() not implemented'); }
|
||||
|
||||
/**
|
||||
* Optional hook for API request auth injection.
|
||||
* Return null to skip, a full header value string, `{ scheme, token }`,
|
||||
* or `{ name, value }` / `{ header, value }` for custom headers.
|
||||
* @param {Object} _requestContext
|
||||
* @param {Object} _securityContext
|
||||
* @returns {Promise<null|string|Object>}
|
||||
*/
|
||||
async getRequestAuthorization(_requestContext, _securityContext = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async evaluate(_userId, _rights, _resourcePath, _context = {}) {
|
||||
return {
|
||||
allowed: true,
|
||||
requires_login: false,
|
||||
reason: 'Security policy not enforced',
|
||||
allowed: false,
|
||||
requires_login: true,
|
||||
reason: 'Security policy not configured',
|
||||
matched_permits: []
|
||||
};
|
||||
}
|
||||
|
||||
evaluateSync(_userId, _rights, _resourcePath, _context = {}) {
|
||||
return {
|
||||
allowed: true,
|
||||
requires_login: false,
|
||||
reason: 'Security policy not enforced',
|
||||
allowed: false,
|
||||
requires_login: true,
|
||||
reason: 'Security policy not configured',
|
||||
matched_permits: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,79 @@
|
||||
export function createSecurityRequestInterceptor(securityService) {
|
||||
return (config = {}) => securityService.injectRequestConfig(config);
|
||||
import { applyRequestAuthorization } from '../../platform/api.js';
|
||||
|
||||
export const SECURITY_REQUEST_FILTER = 'security.auth';
|
||||
export const SECURITY_RESPONSE_FILTER = 'security.unauthorized';
|
||||
|
||||
export function createSecurityRequestFilter(securityService) {
|
||||
return async (requestContext = {}) => {
|
||||
if (!securityService.state.enabled || !securityService.state.policy) {
|
||||
return requestContext;
|
||||
}
|
||||
|
||||
export function createSecurityResponseInterceptor(securityService) {
|
||||
return (response) => {
|
||||
const policy = securityService.state.policy;
|
||||
let authorization = null;
|
||||
|
||||
if (typeof policy.getRequestAuthorization === 'function') {
|
||||
authorization = await policy.getRequestAuthorization(requestContext, {
|
||||
session: securityService.state.session,
|
||||
user: securityService.state.user,
|
||||
profile: securityService.state.profile,
|
||||
realm: securityService.state.realm,
|
||||
config: securityService.state.config,
|
||||
isAuthenticated: securityService.state.isAuthenticated,
|
||||
provider: securityService.state.provider
|
||||
});
|
||||
} else if (typeof policy.getRequestAuthToken === 'function') {
|
||||
const legacyToken = policy.getRequestAuthToken(requestContext);
|
||||
if (legacyToken) {
|
||||
authorization = { scheme: 'Bearer', token: legacyToken };
|
||||
}
|
||||
}
|
||||
|
||||
if (!authorization) {
|
||||
return requestContext;
|
||||
}
|
||||
|
||||
return {
|
||||
...requestContext,
|
||||
headers: applyRequestAuthorization(requestContext.headers, authorization)
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function createSecurityResponseFilter(securityService) {
|
||||
return async (response) => {
|
||||
if (response && response.status === 401) {
|
||||
securityService.handleUnauthorizedResponse();
|
||||
await securityService.handleUnauthorizedResponse();
|
||||
}
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
export function installSecurityAPIFilters(apiClient, securityService) {
|
||||
if (!apiClient || !securityService || securityService.apiHooksInstalled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
apiClient.registerRequestFilter(
|
||||
SECURITY_REQUEST_FILTER,
|
||||
createSecurityRequestFilter(securityService),
|
||||
{ priority: 100 }
|
||||
);
|
||||
apiClient.registerResponseFilter(
|
||||
SECURITY_RESPONSE_FILTER,
|
||||
createSecurityResponseFilter(securityService),
|
||||
{ priority: 100 }
|
||||
);
|
||||
securityService.apiHooksInstalled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @deprecated Use createSecurityRequestFilter via installSecurityAPIFilters */
|
||||
export function createSecurityRequestInterceptor(securityService) {
|
||||
return (config = {}) => createSecurityRequestFilter(securityService)(config);
|
||||
}
|
||||
|
||||
/** @deprecated Use createSecurityResponseFilter via installSecurityAPIFilters */
|
||||
export function createSecurityResponseInterceptor(securityService) {
|
||||
return (response) => createSecurityResponseFilter(securityService)(response);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import { useSyncExternalStore } from 'react';
|
||||
import { getRouterPath, setRouterPath } from '../../platform/compat.js';
|
||||
import { normalizeRightsInput } from '../model/rights.js';
|
||||
import { ApiSecurityPolicy, BasicSecurityPolicy, BstoreSecurityPolicy } from '../policy/index.js';
|
||||
import { createSecurityRequestInterceptor, createSecurityResponseInterceptor } from './api-auth.js';
|
||||
import { createSecurityRequestFilter, installSecurityAPIFilters } from './api-auth.js';
|
||||
import { isSessionActive } from './session-utils.js';
|
||||
|
||||
function resolveAuthenticated(session, user) {
|
||||
return Boolean(session && user && isSessionActive(session));
|
||||
}
|
||||
|
||||
const DEFAULT_SECURITY_CONFIG = {
|
||||
enabled: false,
|
||||
@@ -39,6 +44,66 @@ function createInitialState() {
|
||||
};
|
||||
}
|
||||
|
||||
const securityPolicyProviders = new Map([
|
||||
['api', (config) => new ApiSecurityPolicy(config)],
|
||||
['basic', (config) => new BasicSecurityPolicy(config)],
|
||||
['bstore', (config) => new BstoreSecurityPolicy(config)]
|
||||
]);
|
||||
|
||||
function normalizeProviderName(providerName = '') {
|
||||
return String(providerName || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function coerceSecurityPolicyProvider(providerOrFactory) {
|
||||
if (typeof providerOrFactory !== 'function') {
|
||||
throw new Error('Security policy provider must be a function or class');
|
||||
}
|
||||
|
||||
const prototype = providerOrFactory.prototype;
|
||||
const looksLikePolicyClass = prototype && (
|
||||
typeof prototype.authenticate === 'function'
|
||||
|| typeof prototype.getCurrentSession === 'function'
|
||||
|| typeof prototype.evaluate === 'function'
|
||||
);
|
||||
|
||||
if (looksLikePolicyClass) {
|
||||
return (config) => new providerOrFactory(config);
|
||||
}
|
||||
|
||||
return providerOrFactory;
|
||||
}
|
||||
|
||||
export function registerSecurityPolicyProvider(providerName, providerOrFactory) {
|
||||
const normalizedName = normalizeProviderName(providerName);
|
||||
if (!normalizedName) {
|
||||
throw new Error('Security policy provider name is required');
|
||||
}
|
||||
|
||||
const providerFactory = coerceSecurityPolicyProvider(providerOrFactory);
|
||||
securityPolicyProviders.set(normalizedName, providerFactory);
|
||||
return providerFactory;
|
||||
}
|
||||
|
||||
export function unregisterSecurityPolicyProvider(providerName) {
|
||||
const normalizedName = normalizeProviderName(providerName);
|
||||
if (!normalizedName) {
|
||||
return false;
|
||||
}
|
||||
if (['api', 'basic', 'bstore'].includes(normalizedName)) {
|
||||
console.warn(`[Security] Refusing to unregister built-in policy provider "${normalizedName}"`);
|
||||
return false;
|
||||
}
|
||||
return securityPolicyProviders.delete(normalizedName);
|
||||
}
|
||||
|
||||
export function getSecurityPolicyProvider(providerName) {
|
||||
return securityPolicyProviders.get(normalizeProviderName(providerName)) || null;
|
||||
}
|
||||
|
||||
export function listSecurityPolicyProviders() {
|
||||
return Array.from(securityPolicyProviders.keys()).sort();
|
||||
}
|
||||
|
||||
class SecurityService {
|
||||
constructor() {
|
||||
this.state = createInitialState();
|
||||
@@ -76,17 +141,22 @@ class SecurityService {
|
||||
}
|
||||
|
||||
_resolvePolicy(config) {
|
||||
if (config.provider === 'api') {
|
||||
return new ApiSecurityPolicy(config);
|
||||
}
|
||||
if (config.provider === 'bstore') {
|
||||
return new BstoreSecurityPolicy(config);
|
||||
}
|
||||
return new BasicSecurityPolicy(config);
|
||||
const providerName = normalizeProviderName(config.provider || 'basic') || 'basic';
|
||||
const providerFactory = getSecurityPolicyProvider(providerName) || getSecurityPolicyProvider('basic');
|
||||
return providerFactory(config);
|
||||
}
|
||||
|
||||
async init(config = {}) {
|
||||
const normalizedConfig = normalizeSecurityConfig(config);
|
||||
|
||||
if (this.initPromise) {
|
||||
try {
|
||||
await this.initPromise;
|
||||
} catch {
|
||||
// Prior init failed; allow a new attempt.
|
||||
}
|
||||
}
|
||||
|
||||
const initTask = (async () => {
|
||||
if (!normalizedConfig.enabled) {
|
||||
this.setState({
|
||||
@@ -129,7 +199,7 @@ class SecurityService {
|
||||
user,
|
||||
profile,
|
||||
realm,
|
||||
isAuthenticated: Boolean(session && user),
|
||||
isAuthenticated: resolveAuthenticated(session, user),
|
||||
error: null
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -172,31 +242,17 @@ class SecurityService {
|
||||
}
|
||||
|
||||
installAPIClient(apiClient) {
|
||||
if (!apiClient || this.apiHooksInstalled) {
|
||||
return;
|
||||
}
|
||||
apiClient.addRequestInterceptor(createSecurityRequestInterceptor(this));
|
||||
apiClient.addResponseInterceptor(createSecurityResponseInterceptor(this));
|
||||
this.apiHooksInstalled = true;
|
||||
return installSecurityAPIFilters(apiClient, this);
|
||||
}
|
||||
|
||||
injectRequestConfig(config = {}) {
|
||||
if (!this.state.session?.jwt_token) {
|
||||
return config;
|
||||
}
|
||||
const incomingHeaders = config.headers;
|
||||
const headers = incomingHeaders instanceof Headers
|
||||
? new Headers(incomingHeaders)
|
||||
: new Headers(incomingHeaders || {});
|
||||
headers.set('Authorization', `Bearer ${this.state.session.jwt_token}`);
|
||||
return {
|
||||
...config,
|
||||
headers
|
||||
};
|
||||
async injectRequestConfig(config = {}) {
|
||||
const nextConfig = await createSecurityRequestFilter(this)(config);
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
async injectAuthHeaders(headers = {}) {
|
||||
return this.injectRequestConfig({ headers }).headers;
|
||||
const nextConfig = await this.injectRequestConfig({ headers });
|
||||
return nextConfig.headers;
|
||||
}
|
||||
|
||||
async handleUnauthorizedResponse() {
|
||||
@@ -223,7 +279,7 @@ class SecurityService {
|
||||
user: result.user,
|
||||
profile: result.profile || null,
|
||||
realm,
|
||||
isAuthenticated: true,
|
||||
isAuthenticated: resolveAuthenticated(result.session, result.user),
|
||||
error: null
|
||||
});
|
||||
return result;
|
||||
@@ -276,13 +332,32 @@ class SecurityService {
|
||||
const user = session?.user_id ? await this.state.policy.getUser(session.user_id) : null;
|
||||
const realm = user?.realm_id ? await this.state.policy.getRealm(user.realm_id) : null;
|
||||
const profile = user ? await this.state.policy.getAccountProfile(user.id) : null;
|
||||
const isAuthenticated = resolveAuthenticated(session, user);
|
||||
const sameIdentity =
|
||||
this.state.user?.id === user?.id &&
|
||||
this.state.realm?.id === realm?.id &&
|
||||
this.state.profile?.updated_on === profile?.updated_on &&
|
||||
this.state.profile?.email === profile?.email &&
|
||||
this.state.profile?.display_name === profile?.display_name &&
|
||||
this.state.isAuthenticated === isAuthenticated;
|
||||
|
||||
if (sameIdentity) {
|
||||
this.setState({
|
||||
session,
|
||||
user,
|
||||
profile,
|
||||
realm,
|
||||
isAuthenticated
|
||||
});
|
||||
return this.state;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
session,
|
||||
user,
|
||||
profile,
|
||||
realm,
|
||||
isAuthenticated: Boolean(session && user)
|
||||
isAuthenticated
|
||||
});
|
||||
|
||||
return this.state;
|
||||
@@ -420,6 +495,34 @@ class SecurityService {
|
||||
await this.state.policy.changePassword(this.state.user.id, passwordInput);
|
||||
}
|
||||
|
||||
async listAccountSettings() {
|
||||
if (!this.state.policy || !this.state.user || typeof this.state.policy.listAccountSettings !== 'function') {
|
||||
return [];
|
||||
}
|
||||
return this.state.policy.listAccountSettings(this.state.user.id);
|
||||
}
|
||||
|
||||
async getAccountSetting(key) {
|
||||
if (!this.state.policy || !this.state.user || typeof this.state.policy.getAccountSetting !== 'function') {
|
||||
return null;
|
||||
}
|
||||
return this.state.policy.getAccountSetting(this.state.user.id, key);
|
||||
}
|
||||
|
||||
async updateAccountSetting(key, patch) {
|
||||
if (!this.state.policy || !this.state.user || typeof this.state.policy.updateAccountSetting !== 'function') {
|
||||
throw new Error('Account settings are not available');
|
||||
}
|
||||
return this.state.policy.updateAccountSetting(this.state.user.id, key, patch);
|
||||
}
|
||||
|
||||
async deleteAccountSetting(key) {
|
||||
if (!this.state.policy || !this.state.user || typeof this.state.policy.deleteAccountSetting !== 'function') {
|
||||
throw new Error('Account settings are not available');
|
||||
}
|
||||
return this.state.policy.deleteAccountSetting(this.state.user.id, key);
|
||||
}
|
||||
|
||||
async uploadAccountAvatar(file) {
|
||||
if (!this.state.policy || !this.state.user || typeof this.state.policy.uploadAccountAvatar !== 'function') {
|
||||
throw new Error('Avatar upload is not available');
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @param {import('../model/Session.js').Session | Object | null | undefined} session
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSessionActive(session) {
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.status === 'expired' || session.status === 'revoked') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!session.expires_on) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const expiresAt = Date.parse(session.expires_on);
|
||||
if (Number.isNaN(expiresAt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Date.now() < expiresAt;
|
||||
}
|
||||
+136
-151
@@ -4,20 +4,21 @@
|
||||
* Provides extensible structure for multiple context managers (Theme, Auth, etc.)
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { TamaguiProvider, Theme, createTamagui, YStack } from 'tamagui';
|
||||
import {
|
||||
sw
|
||||
} from '../platform/worker.js';
|
||||
import { getProvider } from '../platform/storage.js';
|
||||
import React, { createContext, useContext, useState, useEffect, useLayoutEffect, useCallback, useMemo } from 'react';
|
||||
import { TamaguiProvider, Theme, createTamagui, YStack, Text } from 'tamagui';
|
||||
import { sw } from '../platform/worker.js';
|
||||
import * as apiClient from '../platform/api.js';
|
||||
import * as storageModuleRef from '../platform/storage.js';
|
||||
import * as menuRef from '../platform/menu.js';
|
||||
import * as envModuleRef from '../platform/env.js';
|
||||
import { getConfig, setConfig, CONFIG_KEYS, createLogger, startTrace, isDevelopment } from '../platform/env.js';
|
||||
import { EmptyShell, LandingShell, DashboardShell, AppInfo, Router } from './components/index.js';
|
||||
import {
|
||||
getConfig,
|
||||
CONFIG_KEYS,
|
||||
createLogger,
|
||||
startTrace
|
||||
} from '../platform/env.js';
|
||||
import { EmptyShell, LandingShell, DashboardShell, Router } from './components/index.js';
|
||||
import { resolveRegisteredShell } from './components/shell-registry.js';
|
||||
import { LoginPage } from '../security/pages/LoginPage.jsx';
|
||||
import { getStyleTheme, DEFAULT_STYLE_THEME, normalizeStyleThemeName, setActiveStyleThemeName } from './styles/index.js';
|
||||
import { THEME_MODE_CONFIG_KEY, THEME_NAME_CONFIG_KEY, THEME_MODES, themeManager } from './theme-controller.js';
|
||||
import { securityService, useSecurityState } from '../security/runtime/security-service.js';
|
||||
@@ -40,6 +41,56 @@ const AppContext = createContext(null);
|
||||
const appLogger = createLogger('App');
|
||||
const tamaguiConfigCache = new Map();
|
||||
|
||||
const APP_THEME_HANDLERS = {
|
||||
setThemeMode: (mode) => themeManager.setMode(mode),
|
||||
setStyleTheme: (themeName) => themeManager.setStyleTheme(themeName),
|
||||
toggleTheme: () => themeManager.toggle()
|
||||
};
|
||||
|
||||
const APP_SECURITY_HANDLERS = {
|
||||
login: (credentials) => securityService.login(credentials),
|
||||
logout: (options) => securityService.logout(options),
|
||||
refreshSession: () => securityService.refreshSession(),
|
||||
injectAuthHeaders: (headers) => securityService.injectAuthHeaders(headers),
|
||||
userRequired: (options) => securityService.userRequired(options),
|
||||
userPermitted: (rights, resourcePath, options) => securityService.userPermitted(rights, resourcePath, options),
|
||||
isPermitted: (rights, resourcePath, options) => securityService.isPermitted(rights, resourcePath, options),
|
||||
registerResource: (resource) => securityService.registerResource(resource),
|
||||
updateAccountProfile: (patch) => securityService.updateAccountProfile(patch),
|
||||
changePassword: (passwordInput) => securityService.changePassword(passwordInput),
|
||||
listAccountSettings: () => securityService.listAccountSettings(),
|
||||
getAccountSetting: (key) => securityService.getAccountSetting(key),
|
||||
updateAccountSetting: (key, patch) => securityService.updateAccountSetting(key, patch),
|
||||
deleteAccountSetting: (key) => securityService.deleteAccountSetting(key),
|
||||
listUsers: () => securityService.listUsers(),
|
||||
createUser: (userData) => securityService.createUser(userData),
|
||||
updateUser: (userId, patch) => securityService.updateUser(userId, patch),
|
||||
deleteUser: (userId) => securityService.deleteUser(userId),
|
||||
listRoles: () => securityService.listRoles(),
|
||||
listSubjects: () => securityService.listSubjects(),
|
||||
createRole: (roleData) => securityService.createRole(roleData),
|
||||
updateRole: (roleId, patch) => securityService.updateRole(roleId, patch),
|
||||
deleteRole: (roleId) => securityService.deleteRole(roleId),
|
||||
listRealms: () => securityService.listRealms(),
|
||||
createRealm: (realmData) => securityService.createRealm(realmData),
|
||||
updateRealm: (realmId, patch) => securityService.updateRealm(realmId, patch),
|
||||
deleteRealm: (realmId) => securityService.deleteRealm(realmId),
|
||||
listResources: () => securityService.listResources(),
|
||||
createResource: (resource) => securityService.createResource(resource),
|
||||
updateResource: (path, patch) => securityService.updateResource(path, patch),
|
||||
deleteResource: (path) => securityService.deleteResource(path),
|
||||
listPermits: () => securityService.listPermits(),
|
||||
createPermit: (permit) => securityService.createPermit(permit),
|
||||
updatePermit: (permitId, patch) => securityService.updatePermit(permitId, patch),
|
||||
deletePermit: (permitId) => securityService.deletePermit(permitId)
|
||||
};
|
||||
|
||||
const APP_SYSTEM_HANDLERS = {
|
||||
getLocale: (altValue) => envModuleRef.getLocale(altValue),
|
||||
setLocale: (locale) => envModuleRef.setLocale(locale),
|
||||
getLocaleSync: () => envModuleRef.getLocaleSync()
|
||||
};
|
||||
|
||||
function resolveShellComponent(shellName = 'EmptyShell') {
|
||||
const key = String(shellName ?? 'EmptyShell').trim().toLowerCase();
|
||||
switch (key) {
|
||||
@@ -70,6 +121,21 @@ function getCachedTamaguiConfig(styleThemeName) {
|
||||
return config;
|
||||
}
|
||||
|
||||
function applyDocumentThemeSurface(styleTheme, activeTheme) {
|
||||
const resolvedDocumentBackground =
|
||||
styleTheme?.themes?.[activeTheme]?.bgPage
|
||||
|| styleTheme?.themes?.[activeTheme]?.background
|
||||
|| null;
|
||||
|
||||
if (resolvedDocumentBackground) {
|
||||
envModuleRef.setDocumentBackground(resolvedDocumentBackground);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.style.colorScheme = activeTheme === THEME_MODES.DARK ? 'dark' : 'light';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// App Component
|
||||
// ============================================================================
|
||||
@@ -84,11 +150,8 @@ function App({
|
||||
initialThemePreferencesLoaded = false
|
||||
}) {
|
||||
// App state
|
||||
const [swStatus, setSwStatus] = useState('Checking...');
|
||||
const [storageBackend, setStorageBackend] = useState('localStorage');
|
||||
const [appName, setAppName] = useState(initialProfile?.id ?? '');
|
||||
const [menuItems, setMenuItems] = useState([]);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [initError, setInitError] = useState(null);
|
||||
const [ShellComponent, setShellComponent] = useState(() => resolveShellComponent(initialProfile?.ui_shell ?? 'EmptyShell'));
|
||||
const [initialRoute, setInitialRoute] = useState(
|
||||
initialProfile?.initial_route ?? initialProfile?.initialRoute ?? initialProfile?.ui?.initial_route ?? initialProfile?.ui?.initialRoute ?? '/home'
|
||||
@@ -108,38 +171,17 @@ function App({
|
||||
themeManager.init(setThemeModeState, setSystemScheme, setStyleThemeName);
|
||||
}, []);
|
||||
|
||||
// Get style theme configuration
|
||||
const styleTheme = useMemo(() => getStyleTheme(styleThemeName), [styleThemeName]);
|
||||
|
||||
// Create Tamagui config from style theme
|
||||
const tamaguiConfig = useMemo(() => getCachedTamaguiConfig(styleThemeName), [styleThemeName]);
|
||||
|
||||
// Calculate active theme (light/dark variant)
|
||||
const activeTheme = themeMode === THEME_MODES.SYSTEM ? systemScheme : themeMode;
|
||||
|
||||
// Update theme manager state
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
themeManager.updateState(themeMode, systemScheme, activeTheme, styleThemeName);
|
||||
}, [themeMode, systemScheme, activeTheme, styleThemeName]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveStyleThemeName(styleThemeName);
|
||||
}, [styleThemeName]);
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedDocumentBackground =
|
||||
styleTheme?.themes?.[activeTheme]?.bgPage
|
||||
|| styleTheme?.themes?.[activeTheme]?.background
|
||||
|| null;
|
||||
|
||||
if (resolvedDocumentBackground) {
|
||||
envModuleRef.setDocumentBackground(resolvedDocumentBackground);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.style.colorScheme = activeTheme === THEME_MODES.DARK ? 'dark' : 'light';
|
||||
}
|
||||
}, [activeTheme, styleTheme]);
|
||||
applyDocumentThemeSurface(styleTheme, activeTheme);
|
||||
}, [themeMode, systemScheme, activeTheme, styleThemeName, styleTheme]);
|
||||
|
||||
// Load theme preferences from storage on mount
|
||||
useEffect(() => {
|
||||
@@ -155,10 +197,15 @@ function App({
|
||||
setThemeModeState(savedMode);
|
||||
}
|
||||
|
||||
// Load style theme (material/minimal/colorful)
|
||||
// Load style theme (profile default, then user storage)
|
||||
const savedStyleTheme = await getConfig(THEME_NAME_CONFIG_KEY, null);
|
||||
if (savedStyleTheme) {
|
||||
setStyleThemeName(normalizeStyleThemeName(savedStyleTheme));
|
||||
} else {
|
||||
const profileStyleTheme = await getConfig(CONFIG_KEYS.STYLE_THEME, null);
|
||||
if (profileStyleTheme) {
|
||||
setStyleThemeName(normalizeStyleThemeName(profileStyleTheme));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[App] Failed to load theme preferences:', error);
|
||||
@@ -179,36 +226,6 @@ function App({
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Save theme mode preference to storage when it changes
|
||||
useEffect(() => {
|
||||
async function saveThemePreference() {
|
||||
try {
|
||||
await setConfig(THEME_MODE_CONFIG_KEY, themeMode);
|
||||
} catch (error) {
|
||||
console.warn('[App] Failed to save theme preference:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (themePreferencesLoaded) {
|
||||
saveThemePreference();
|
||||
}
|
||||
}, [themeMode, themePreferencesLoaded]);
|
||||
|
||||
// Save style theme preference to storage when it changes
|
||||
useEffect(() => {
|
||||
async function saveStyleThemePreference() {
|
||||
try {
|
||||
await setConfig(THEME_NAME_CONFIG_KEY, styleThemeName);
|
||||
} catch (error) {
|
||||
console.warn('[App] Failed to save style theme preference:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (themePreferencesLoaded) {
|
||||
saveStyleThemePreference();
|
||||
}
|
||||
}, [styleThemeName, themePreferencesLoaded]);
|
||||
|
||||
/**
|
||||
* Get platform services for module injection
|
||||
* Returns services with renamed keys and includes env
|
||||
@@ -241,6 +258,7 @@ function App({
|
||||
|
||||
try {
|
||||
// Do not clear `initialized` here: it caused a blank / half-ready shell flash while re-running init.
|
||||
setInitError(null);
|
||||
setBootModeOverride(null);
|
||||
|
||||
// Get platform services
|
||||
@@ -287,12 +305,6 @@ function App({
|
||||
}
|
||||
|
||||
// Use services to set up app state
|
||||
const name = await services.env.getConfig(CONFIG_KEYS.APP_NAME, 'default');
|
||||
setAppName(name);
|
||||
|
||||
const backend = await services.env.getConfig(CONFIG_KEYS.STORAGE_BACKEND, 'localStorage');
|
||||
setStorageBackend(backend);
|
||||
|
||||
// Load UI shell from config
|
||||
const shellName = await services.env.getConfig(CONFIG_KEYS.UI_SHELL, 'EmptyShell');
|
||||
const Shell = resolveShellComponent(shellName);
|
||||
@@ -302,26 +314,9 @@ function App({
|
||||
const configuredInitialRoute = await services.env.getConfig(CONFIG_KEYS.INITIAL_ROUTE, '/home');
|
||||
setInitialRoute(configuredInitialRoute || '/home');
|
||||
|
||||
// Get menu items from primary menu
|
||||
if (services.menu) {
|
||||
// Query primary menu (returns root + all nested items)
|
||||
const allPrimaryItems = services.menu.queryMenuItems('/primary');
|
||||
appLogger.debug('Primary menu items found:', allPrimaryItems);
|
||||
|
||||
// Filter out the root "Primary" item, keep only actual menu items
|
||||
const items = allPrimaryItems.filter(item => item.path !== '/primary');
|
||||
appLogger.debug('Filtered menu items (excluding root):', items);
|
||||
setMenuItems(items);
|
||||
} else {
|
||||
appLogger.warn('Menu service not available');
|
||||
}
|
||||
|
||||
setSwStatus(await sw.getServiceWorkerStatus());
|
||||
await finalizeSecurityInit();
|
||||
|
||||
setInitialized(true);
|
||||
finalizeSecurityInit().catch((error) => {
|
||||
console.error('[Security] Background initialization failed:', error);
|
||||
});
|
||||
initTrace.end({
|
||||
shell: shellName,
|
||||
bootMode: selectedProfile?.__boot?.uiMode ?? 'runtime'
|
||||
@@ -329,6 +324,7 @@ function App({
|
||||
} catch (error) {
|
||||
initTrace.fail(error);
|
||||
appLogger.error('Failed to initialize app:', error);
|
||||
setInitError(error instanceof Error ? error : new Error(String(error)));
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [initialProfile, onInit]);
|
||||
@@ -338,74 +334,25 @@ function App({
|
||||
initializeApp();
|
||||
}, [initializeApp]);
|
||||
|
||||
// Consolidated app context value
|
||||
const appContextValue = {
|
||||
const appContextValue = useMemo(() => ({
|
||||
theme: {
|
||||
themeMode,
|
||||
activeTheme,
|
||||
systemScheme,
|
||||
styleThemeName,
|
||||
styleTheme,
|
||||
setThemeMode: async (mode) => {
|
||||
if (!Object.values(THEME_MODES).includes(mode)) {
|
||||
console.warn('[App] Invalid theme mode:', mode);
|
||||
return;
|
||||
}
|
||||
setThemeModeState(mode);
|
||||
},
|
||||
setStyleTheme: async (themeName) => {
|
||||
setStyleThemeName(normalizeStyleThemeName(themeName));
|
||||
},
|
||||
toggleTheme: () => {
|
||||
const nextMode =
|
||||
themeMode === THEME_MODES.LIGHT ? THEME_MODES.DARK :
|
||||
themeMode === THEME_MODES.DARK ? THEME_MODES.SYSTEM :
|
||||
THEME_MODES.LIGHT;
|
||||
setThemeModeState(nextMode);
|
||||
},
|
||||
...APP_THEME_HANDLERS,
|
||||
THEME_MODES
|
||||
},
|
||||
security: {
|
||||
...securityState,
|
||||
login: (credentials) => securityService.login(credentials),
|
||||
logout: (options) => securityService.logout(options),
|
||||
refreshSession: () => securityService.refreshSession(),
|
||||
injectAuthHeaders: (headers) => securityService.injectAuthHeaders(headers),
|
||||
userRequired: (options) => securityService.userRequired(options),
|
||||
userPermitted: (rights, resourcePath, options) => securityService.userPermitted(rights, resourcePath, options),
|
||||
isPermitted: (rights, resourcePath, options) => securityService.isPermitted(rights, resourcePath, options),
|
||||
registerResource: (resource) => securityService.registerResource(resource),
|
||||
updateAccountProfile: (patch) => securityService.updateAccountProfile(patch),
|
||||
changePassword: (passwordInput) => securityService.changePassword(passwordInput),
|
||||
listUsers: () => securityService.listUsers(),
|
||||
createUser: (userData) => securityService.createUser(userData),
|
||||
updateUser: (userId, patch) => securityService.updateUser(userId, patch),
|
||||
deleteUser: (userId) => securityService.deleteUser(userId),
|
||||
listRoles: () => securityService.listRoles(),
|
||||
listSubjects: () => securityService.listSubjects(),
|
||||
createRole: (roleData) => securityService.createRole(roleData),
|
||||
updateRole: (roleId, patch) => securityService.updateRole(roleId, patch),
|
||||
deleteRole: (roleId) => securityService.deleteRole(roleId),
|
||||
listRealms: () => securityService.listRealms(),
|
||||
createRealm: (realmData) => securityService.createRealm(realmData),
|
||||
updateRealm: (realmId, patch) => securityService.updateRealm(realmId, patch),
|
||||
deleteRealm: (realmId) => securityService.deleteRealm(realmId),
|
||||
listResources: () => securityService.listResources(),
|
||||
createResource: (resource) => securityService.createResource(resource),
|
||||
updateResource: (path, patch) => securityService.updateResource(path, patch),
|
||||
deleteResource: (path) => securityService.deleteResource(path),
|
||||
listPermits: () => securityService.listPermits(),
|
||||
createPermit: (permit) => securityService.createPermit(permit),
|
||||
updatePermit: (permitId, patch) => securityService.updatePermit(permitId, patch),
|
||||
deletePermit: (permitId) => securityService.deletePermit(permitId)
|
||||
...APP_SECURITY_HANDLERS
|
||||
},
|
||||
system: {
|
||||
locale: envModuleRef.getLocaleSync(),
|
||||
getLocale: (altValue) => envModuleRef.getLocale(altValue),
|
||||
setLocale: (locale) => envModuleRef.setLocale(locale)
|
||||
...APP_SYSTEM_HANDLERS,
|
||||
locale: envModuleRef.getLocaleSync()
|
||||
}
|
||||
// Future managers can be added here: auth, etc.
|
||||
};
|
||||
}), [themeMode, activeTheme, systemScheme, styleThemeName, styleTheme, securityState]);
|
||||
|
||||
const effectiveBootMode = bootModeOverride ?? bootResult?.uiMode ?? 'runtime';
|
||||
const shouldRenderBootScreen = effectiveBootMode !== 'runtime' || (!initialized && showInitialBootSplash);
|
||||
@@ -435,10 +382,42 @@ function App({
|
||||
await initializeApp();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
bootScreenContent = (
|
||||
<YStack
|
||||
minHeight="100vh"
|
||||
width="100%"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="$bgPage"
|
||||
padding="$4"
|
||||
>
|
||||
<Text color="$textSecondary">Starting application...</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldRenderBootScreen && !shouldHoldDuringInit) {
|
||||
if (initError && initialized && !shouldRenderBootScreen) {
|
||||
appContent = (
|
||||
<YStack
|
||||
minHeight="100vh"
|
||||
width="100%"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="$bgPage"
|
||||
padding="$4"
|
||||
gap="$3"
|
||||
>
|
||||
<Text fontSize="$6" fontWeight="600" color="$textPrimary">
|
||||
Application failed to start
|
||||
</Text>
|
||||
<Text color="$textSecondary" textAlign="center" maxWidth={480}>
|
||||
{initError.message || 'An unexpected error occurred during initialization.'}
|
||||
</Text>
|
||||
</YStack>
|
||||
);
|
||||
} else if (!shouldRenderBootScreen && !shouldHoldDuringInit) {
|
||||
appContent = (
|
||||
<Router initialPath={initialRoute}>
|
||||
{/* Declarative route registration (commented out - routes now registered programmatically via modules)
|
||||
@@ -468,7 +447,11 @@ function App({
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={appContextValue}>
|
||||
<TamaguiProvider config={tamaguiConfig} defaultTheme={activeTheme}>
|
||||
<TamaguiProvider
|
||||
key={styleThemeName}
|
||||
config={tamaguiConfig}
|
||||
defaultTheme={activeTheme}
|
||||
>
|
||||
<Theme name={activeTheme}>
|
||||
{shouldRenderBootScreen ? (
|
||||
bootScreenContent
|
||||
@@ -554,6 +537,7 @@ export { useApp, useTheme, THEME_MODES, themeManager as ThemeManager };
|
||||
if (typeof window !== 'undefined') {
|
||||
window.clearPWACache = sw.clearPWACache;
|
||||
window.__PWA_UTILS__ = {
|
||||
PWA_CACHE_SCOPE: sw.PWA_CACHE_SCOPE,
|
||||
clearPWACache: sw.clearPWACache,
|
||||
clearAllCaches: sw.clearAllCaches,
|
||||
unregisterAllServiceWorkers: sw.unregisterAllServiceWorkers,
|
||||
@@ -561,10 +545,11 @@ if (typeof window !== 'undefined') {
|
||||
};
|
||||
|
||||
console.log('💡 PWA Utilities available:');
|
||||
console.log(' - clearPWACache() - Clear everything (caches, SW, storage)');
|
||||
console.log(' - clearPWACache(mask) - Clear by scope bitmask (default: all)');
|
||||
console.log(' - clearPWACache(__PWA_UTILS__.PWA_CACHE_SCOPE.CACHES | __PWA_UTILS__.PWA_CACHE_SCOPE.SERVICE_WORKERS)');
|
||||
console.log(' - __PWA_UTILS__.clearAllCaches() - Clear caches only');
|
||||
console.log(' - __PWA_UTILS__.unregisterAllServiceWorkers() - Unregister SW only');
|
||||
console.log(' - __PWA_UTILS__.clearAllStorage() - Clear storage only');
|
||||
console.log(' - __PWA_UTILS__.clearAllStorage() - Clear storage only (includes auth)');
|
||||
}
|
||||
|
||||
// Note: App initialization is handled by the consuming project (e.g., app-react.jsx)
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { XStack, YStack, useMedia } from 'tamagui';
|
||||
import { XStack, YStack } from 'tamagui';
|
||||
import { View } from '@tamagui/core';
|
||||
import { ShellProvider, useShell, ToastViewport } from './Shell.jsx';
|
||||
import { useIsBelowSmBreakpoint } from '../hooks/useShellLayout.js';
|
||||
|
||||
// Section components for children placement
|
||||
const SectionContext = React.createContext(null);
|
||||
@@ -52,8 +53,7 @@ const SectionContext = React.createContext(null);
|
||||
*/
|
||||
function EmptyShellInner({ children }) {
|
||||
const shell = useShell();
|
||||
const media = useMedia();
|
||||
const isMobile = !media.gtSm; // Below 801px (sm breakpoint)
|
||||
const isMobile = useIsBelowSmBreakpoint();
|
||||
|
||||
// Organize children by placement
|
||||
const organizedChildren = useMemo(() => {
|
||||
@@ -136,6 +136,8 @@ function EmptyShellInner({ children }) {
|
||||
|
||||
{/* Main Content */}
|
||||
<View
|
||||
id="app-main-content"
|
||||
role="main"
|
||||
flex={1}
|
||||
width="100%"
|
||||
overflow="auto"
|
||||
@@ -220,6 +222,8 @@ function EmptyShellInner({ children }) {
|
||||
|
||||
{/* Main Content */}
|
||||
<View
|
||||
id="app-main-content"
|
||||
role="main"
|
||||
flex={1}
|
||||
width="100%"
|
||||
overflow="auto"
|
||||
|
||||
@@ -436,21 +436,131 @@ const iconMap = {
|
||||
'print': wrap(Printer, 'Printer'),
|
||||
};
|
||||
|
||||
const iconRegistry = new Map(Object.entries(iconMap));
|
||||
let fallbackIconName = 'help';
|
||||
let fallbackIconComponent = null;
|
||||
|
||||
function normalizeIconName(iconName) {
|
||||
if (typeof iconName !== 'string') return '';
|
||||
return iconName.toLowerCase().trim();
|
||||
}
|
||||
|
||||
function wrapRegisteredIcon(iconName, Icon) {
|
||||
if (!Icon || (typeof Icon !== 'function' && typeof Icon !== 'object')) {
|
||||
return null;
|
||||
}
|
||||
return wrap(Icon, iconName);
|
||||
}
|
||||
|
||||
function resolveFallbackIcon(requestedName = '') {
|
||||
const normalizedRequestedName = normalizeIconName(requestedName);
|
||||
const normalizedFallbackName = normalizeIconName(fallbackIconName);
|
||||
|
||||
if (normalizedFallbackName && normalizedFallbackName !== normalizedRequestedName) {
|
||||
return iconRegistry.get(normalizedFallbackName) || null;
|
||||
}
|
||||
|
||||
return fallbackIconComponent || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Public API */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function registerIcon(iconName, Icon, options = {}) {
|
||||
const normalized = normalizeIconName(iconName);
|
||||
const { override = false } = options;
|
||||
|
||||
if (!normalized) {
|
||||
console.warn('[IconMapper] registerIcon() requires a non-empty icon name');
|
||||
return false;
|
||||
}
|
||||
|
||||
const wrappedIcon = wrapRegisteredIcon(normalized, Icon);
|
||||
if (!wrappedIcon) {
|
||||
console.warn(`[IconMapper] registerIcon("${normalized}") requires a valid icon component`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (iconRegistry.has(normalized) && !override) {
|
||||
console.warn(`[IconMapper] Icon "${normalized}" is already registered. Pass { override: true } to replace it.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
iconRegistry.set(normalized, wrappedIcon);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerIcons(iconRecord, options = {}) {
|
||||
if (!iconRecord || typeof iconRecord !== 'object') {
|
||||
console.warn('[IconMapper] registerIcons() requires an object map of icon names to components');
|
||||
return [];
|
||||
}
|
||||
|
||||
const registeredNames = [];
|
||||
Object.entries(iconRecord).forEach(([iconName, Icon]) => {
|
||||
if (registerIcon(iconName, Icon, options)) {
|
||||
registeredNames.push(normalizeIconName(iconName));
|
||||
}
|
||||
});
|
||||
return registeredNames;
|
||||
}
|
||||
|
||||
export function hasIcon(iconName) {
|
||||
return iconRegistry.has(normalizeIconName(iconName));
|
||||
}
|
||||
|
||||
export function setFallbackIcon(iconNameOrComponent = null) {
|
||||
if (typeof iconNameOrComponent === 'string') {
|
||||
fallbackIconName = normalizeIconName(iconNameOrComponent);
|
||||
fallbackIconComponent = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!iconNameOrComponent) {
|
||||
fallbackIconName = '';
|
||||
fallbackIconComponent = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
const wrappedIcon = wrapRegisteredIcon('FallbackIcon', iconNameOrComponent);
|
||||
if (!wrappedIcon) {
|
||||
console.warn('[IconMapper] setFallbackIcon() requires an icon alias, component, or null');
|
||||
return false;
|
||||
}
|
||||
|
||||
fallbackIconName = '';
|
||||
fallbackIconComponent = wrappedIcon;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getFallbackIconName() {
|
||||
return normalizeIconName(fallbackIconName) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a wrapped Phosphor icon by name. Returns a React component (the
|
||||
* wrapper handles theme-aware color, sizing, and weight) or `null` if the
|
||||
* name is unknown.
|
||||
* wrapper handles theme-aware color, sizing, and weight). Unknown icons fall
|
||||
* back to the configured fallback icon when available.
|
||||
*
|
||||
* @param {string} iconName - canonical alias from {@link iconMap}
|
||||
* @param {string} iconName - canonical alias from the icon registry
|
||||
* @param {{allowFallback?: boolean}} [options]
|
||||
* @returns {React.ComponentType<{size?:string|number,color?:string,weight?:string}>|null}
|
||||
*/
|
||||
export function getIcon(iconName) {
|
||||
if (typeof iconName !== 'string' || !iconName) return null;
|
||||
return iconMap[iconName.toLowerCase().trim()] || null;
|
||||
export function getIcon(iconName, options = {}) {
|
||||
const { allowFallback = true } = options;
|
||||
const normalized = normalizeIconName(iconName);
|
||||
|
||||
if (!normalized) {
|
||||
return allowFallback ? resolveFallbackIcon(normalized) : null;
|
||||
}
|
||||
|
||||
const registeredIcon = iconRegistry.get(normalized) || null;
|
||||
if (registeredIcon) {
|
||||
return registeredIcon;
|
||||
}
|
||||
|
||||
return allowFallback ? resolveFallbackIcon(normalized) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -459,13 +569,17 @@ export function getIcon(iconName) {
|
||||
* single character (e.g. user-entered avatar glyphs).
|
||||
*/
|
||||
export function IconMapper({ iconName, size = DEFAULT_SIZE, color = '$textPrimary', ...props }) {
|
||||
const Icon = getIcon(iconName);
|
||||
const Icon = getIcon(iconName, { allowFallback: false });
|
||||
if (Icon) {
|
||||
return <Icon size={size} color={color} {...props} />;
|
||||
}
|
||||
if (typeof iconName === 'string' && (iconName.length <= 2 || /[\u{1F300}-\u{1F9FF}]/u.test(iconName))) {
|
||||
return <SizableText fontSize={resolveSize(size)} color={color}>{iconName}</SizableText>;
|
||||
}
|
||||
const FallbackIcon = getIcon(iconName);
|
||||
if (FallbackIcon) {
|
||||
return <FallbackIcon size={size} color={color} {...props} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -530,7 +644,7 @@ export function getIconSize(size) {
|
||||
* List of every registered alias. Handy for validation / docs.
|
||||
*/
|
||||
export function getIconNames() {
|
||||
return Object.keys(iconMap);
|
||||
return Array.from(iconRegistry.keys()).sort();
|
||||
}
|
||||
|
||||
export default IconMapper;
|
||||
|
||||
@@ -19,6 +19,29 @@ import {
|
||||
import { MenuItem, getMenuItemExpandedPreference, setMenuItemExpandedPreference } from '../../platform/menu.js';
|
||||
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
|
||||
|
||||
function buildMenuItemA11yProps({
|
||||
menuItem,
|
||||
showLabel,
|
||||
hasSubitems,
|
||||
popupOpen,
|
||||
isExpanded,
|
||||
effectiveExpandMode
|
||||
}) {
|
||||
const props = {
|
||||
accessibilityRole: 'menuitem'
|
||||
};
|
||||
|
||||
if (!showLabel && menuItem.label) {
|
||||
props.accessibilityLabel = menuItem.label;
|
||||
}
|
||||
|
||||
if (hasSubitems) {
|
||||
props['aria-expanded'] = effectiveExpandMode === 'popup' ? popupOpen : isExpanded;
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
/**
|
||||
* MenuItemButton Component
|
||||
*
|
||||
@@ -157,7 +180,7 @@ export function MenuItemButton({
|
||||
return NotificationManager.subscribe(syncCount);
|
||||
}, [menuItem.id]);
|
||||
|
||||
// Close popup when clicking outside (popup mode only)
|
||||
// Close popup when clicking outside or pressing Escape (popup mode only)
|
||||
useEffect(() => {
|
||||
if (effectiveExpandMode === 'popup' && popupOpen) {
|
||||
const handleClickOutside = (event) => {
|
||||
@@ -170,9 +193,20 @@ export function MenuItemButton({
|
||||
setPopupOpen(false);
|
||||
}
|
||||
};
|
||||
// Use compatibility layer for document event listener
|
||||
return addDocumentEventListener('mousedown', handleClickOutside);
|
||||
const handleEscape = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setPopupOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeClickListener = addDocumentEventListener('mousedown', handleClickOutside);
|
||||
const removeKeyListener = addDocumentEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
removeClickListener?.();
|
||||
removeKeyListener?.();
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [popupOpen, effectiveExpandMode]);
|
||||
|
||||
// Determine width
|
||||
@@ -345,6 +379,14 @@ export function MenuItemButton({
|
||||
? (orientation === 'vertical' ? 'chevron-right' : 'chevron-down')
|
||||
: (isExpanded ? 'chevron-down' : 'chevron-right');
|
||||
const ArrowIcon = getIcon(arrowIconName);
|
||||
const menuItemA11yProps = buildMenuItemA11yProps({
|
||||
menuItem,
|
||||
showLabel,
|
||||
hasSubitems,
|
||||
popupOpen,
|
||||
isExpanded,
|
||||
effectiveExpandMode
|
||||
});
|
||||
|
||||
// Render based on orientation
|
||||
if (orientation === 'horizontal') {
|
||||
@@ -365,6 +407,7 @@ export function MenuItemButton({
|
||||
borderRadius="$radiusSm"
|
||||
padding={padding}
|
||||
opacity={menuItem.is_active !== false ? 1 : 0.55}
|
||||
{...menuItemA11yProps}
|
||||
>
|
||||
{/* Icon + Label (clickable main area) */}
|
||||
<XStack
|
||||
@@ -433,6 +476,7 @@ export function MenuItemButton({
|
||||
backgroundColor: '$bgPanelElev'
|
||||
}}
|
||||
onPress={handleToggleExpand}
|
||||
accessibilityLabel={`${isExpanded || popupOpen ? 'Collapse' : 'Expand'} ${menuItem.label || 'menu group'}`}
|
||||
>
|
||||
<ArrowIcon
|
||||
size={CHEVRON_SIZE}
|
||||
@@ -514,6 +558,7 @@ export function MenuItemButton({
|
||||
borderRadius="$radiusSm"
|
||||
padding={padding}
|
||||
opacity={menuItem.is_active !== false ? 1 : 0.55}
|
||||
{...menuItemA11yProps}
|
||||
>
|
||||
{/* Icon + Label (clickable main area) */}
|
||||
<XStack
|
||||
@@ -582,6 +627,7 @@ export function MenuItemButton({
|
||||
backgroundColor: '$bgPanelElev'
|
||||
}}
|
||||
onPress={handleToggleExpand}
|
||||
accessibilityLabel={`${isExpanded || popupOpen ? 'Collapse' : 'Expand'} ${menuItem.label || 'menu group'}`}
|
||||
>
|
||||
<ArrowIcon
|
||||
size={CHEVRON_SIZE}
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
*/
|
||||
|
||||
import React, { Suspense, createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Spinner, YStack } from 'tamagui';
|
||||
import { InvokeHandlers } from '../../platform/menu.js';
|
||||
import { openExternalURL, getRouterPath, setRouterPath, subscribeToPathChanges } from '../../platform/compat.js';
|
||||
import { ErrorPage } from '../../security/pages/ErrorPage.jsx';
|
||||
import { LoginDialog, LoginPage } from '../../security/pages/LoginPage.jsx';
|
||||
import { evaluateRouteAccess } from '../../security/runtime/route-guards.js';
|
||||
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
|
||||
import { isDevelopment } from '../../platform/env.js';
|
||||
|
||||
function routerDebug(...args) {
|
||||
if (isDevelopment()) {
|
||||
console.log(...args);
|
||||
}
|
||||
}
|
||||
|
||||
function getComponentLabel(component) {
|
||||
if (!component) {
|
||||
@@ -400,7 +407,7 @@ function SelectedComponent({ placement, fallback }) {
|
||||
|
||||
const routeSignature = `${route.path}|${getComponentLabel(route.component)}|${route.fragment_path || ''}|${route.is_fragment ? 'fragment' : 'route'}`;
|
||||
if (lastLoggedRouteRef.current !== routeSignature) {
|
||||
console.log('[Router.SelectedComponent] Rendering. Route:', route.path, 'Component:', getComponentLabel(route.component), route.is_fragment ? '(fragment)' : '');
|
||||
routerDebug('[Router.SelectedComponent] Rendering. Route:', route.path, 'Component:', getComponentLabel(route.component), route.is_fragment ? '(fragment)' : '');
|
||||
lastLoggedRouteRef.current = routeSignature;
|
||||
}
|
||||
|
||||
@@ -456,23 +463,44 @@ function SelectedComponent({ placement, fallback }) {
|
||||
};
|
||||
}, [route.path, route.options, securityState.enabled, securityState.isAuthenticated, securityState.user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (guardState.evaluating || guardState.pending || guardState.allowed || !guardState.requires_login) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loginRoute = securityState.config?.login_route || '/login';
|
||||
if (!loginRoute || route.path === loginRoute) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRouterPath(loginRoute, true, {
|
||||
state: route.path ? { redirect_to: route.path } : null
|
||||
}).catch((error) => {
|
||||
console.warn('[Router] Failed to redirect to login route:', error);
|
||||
});
|
||||
}, [guardState.allowed, guardState.evaluating, guardState.pending, guardState.requires_login, route.path, securityState.config?.login_route]);
|
||||
|
||||
// If no route is active, render fallback or null
|
||||
if (!route.component) {
|
||||
console.log('[Router.SelectedComponent] No route component, rendering fallback or null');
|
||||
return fallback ? <fallback /> : null;
|
||||
routerDebug('[Router.SelectedComponent] No route component, rendering fallback or null');
|
||||
if (fallback) {
|
||||
const Fallback = fallback;
|
||||
return <Fallback />;
|
||||
}
|
||||
|
||||
if (guardState.evaluating) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (guardState.pending) {
|
||||
return null;
|
||||
if (guardState.evaluating || guardState.pending) {
|
||||
return (
|
||||
<YStack flex={1} minHeight={240} alignItems="center" justifyContent="center" padding="$4">
|
||||
<Spinner size="large" color="$accentColor" />
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!guardState.allowed) {
|
||||
if (guardState.requires_login) {
|
||||
return <LoginDialog subtitle="This route requires an authenticated user." />;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -490,9 +518,6 @@ function SelectedComponent({ placement, fallback }) {
|
||||
// Note: For fragment routes, this will be the parent component
|
||||
// The parent component can use useRoute() to get the fragment_path to know which fragment is active
|
||||
const RouteComponent = route.component;
|
||||
if (lastLoggedRouteRef.current === routeSignature) {
|
||||
console.log('[Router.SelectedComponent] Rendering component:', getComponentLabel(RouteComponent), route.fragment_path ? `(fragment path: ${route.fragment_path})` : '');
|
||||
}
|
||||
return (
|
||||
<RouteErrorBoundary
|
||||
resetKey={routeSignature}
|
||||
@@ -640,8 +665,8 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
const getSelectedRoute = useCallback(() => {
|
||||
const match = findRoute(navigationState.currentPath);
|
||||
if (!match) {
|
||||
console.log('[Router] getSelectedRoute() - No match for path:', navigationState.currentPath);
|
||||
console.log('[Router] Available routes:', Array.from(routesRef.current.keys()));
|
||||
routerDebug('[Router] getSelectedRoute() - No match for path:', navigationState.currentPath);
|
||||
routerDebug('[Router] Available routes:', Array.from(routesRef.current.keys()));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -654,10 +679,10 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
if (parentRoute) {
|
||||
// Use parent component for rendering, but keep original path
|
||||
componentToRender = parentRoute.component;
|
||||
console.log('[Router] Fragment route detected:', navigationState.currentPath, '→ Using parent:', parentRoute.path);
|
||||
routerDebug('[Router] Fragment route detected:', navigationState.currentPath, '→ Using parent:', parentRoute.path);
|
||||
} else {
|
||||
// No parent found, fallback to fragment route itself
|
||||
console.log('[Router] Fragment route with no parent, using fragment component:', navigationState.currentPath);
|
||||
routerDebug('[Router] Fragment route with no parent, using fragment component:', navigationState.currentPath);
|
||||
componentToRender = match.component;
|
||||
}
|
||||
}
|
||||
@@ -671,7 +696,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
is_fragment: match.is_fragment || false,
|
||||
fragment_path: match.is_fragment ? navigationState.currentPath : null // Store fragment path if applicable
|
||||
};
|
||||
console.log('[Router] getSelectedRoute() - Returning:', route.path, '→', getComponentLabel(route.component), match.is_fragment ? '(fragment)' : '');
|
||||
routerDebug('[Router] getSelectedRoute() - Returning:', route.path, '→', getComponentLabel(route.component), match.is_fragment ? '(fragment)' : '');
|
||||
return route;
|
||||
}, [navigationState.currentPath, navigationState.routeState, findRoute, findParentRoute]);
|
||||
|
||||
@@ -679,8 +704,8 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
const navigate = useCallback(async (path, options = {}) => {
|
||||
const { replace = false, state = null } = options;
|
||||
|
||||
console.log('[Router] navigate() called with path:', path);
|
||||
console.log('[Router] Available routes:', Array.from(routesRef.current.keys()));
|
||||
routerDebug('[Router] navigate() called with path:', path);
|
||||
routerDebug('[Router] Available routes:', Array.from(routesRef.current.keys()));
|
||||
|
||||
// Find matching route
|
||||
const match = findRoute(path);
|
||||
@@ -690,7 +715,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Router] Route matched:', path, '→', getComponentLabel(match.component));
|
||||
routerDebug('[Router] Route matched:', path, '→', getComponentLabel(match.component));
|
||||
|
||||
// Update browser URL and save to storage (via compat layer)
|
||||
await setRouterPath(path, replace, { notify: false, state });
|
||||
@@ -709,7 +734,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
};
|
||||
});
|
||||
|
||||
console.log('[Router] Navigation complete. Current path:', path);
|
||||
routerDebug('[Router] Navigation complete. Current path:', path);
|
||||
|
||||
// Call onRouteChange callback
|
||||
if (onRouteChangeRef.current) {
|
||||
@@ -841,7 +866,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
// Set up goToPage handler
|
||||
InvokeHandlers.goToPage = (menuItem, eventSource, event) => {
|
||||
if (menuItem.invoke_target) {
|
||||
console.log('[Router] Navigating to:', menuItem.invoke_target);
|
||||
routerDebug('[Router] Navigating to:', menuItem.invoke_target);
|
||||
navigate(menuItem.invoke_target);
|
||||
} else {
|
||||
console.warn('[Router] MenuItem missing invoke_target for goToPage');
|
||||
@@ -861,14 +886,14 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
// Set up goToModal handler (placeholder - to be implemented)
|
||||
InvokeHandlers.goToModal = (menuItem, eventSource, event) => {
|
||||
if (menuItem.invoke_target) {
|
||||
console.log('[Router] Modal not yet implemented:', menuItem.invoke_target);
|
||||
routerDebug('[Router] Modal not yet implemented:', menuItem.invoke_target);
|
||||
// TODO: Implement modal system
|
||||
} else {
|
||||
console.warn('[Router] MenuItem missing invoke_target for goToModal');
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[Router] InvokeHandlers configured');
|
||||
routerDebug('[Router] InvokeHandlers configured');
|
||||
}, [navigate]);
|
||||
|
||||
// Load initial path from browser URL or storage
|
||||
@@ -877,7 +902,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
if (navigationState.initialPathLoaded) return; // Only load once
|
||||
|
||||
const actualInitialPath = await getRouterPath(initialPath);
|
||||
console.log('[Router] Initial path resolved:', actualInitialPath, '(fallback:', initialPath, ')');
|
||||
routerDebug('[Router] Initial path resolved:', actualInitialPath, '(fallback:', initialPath, ')');
|
||||
|
||||
setNavigationState({
|
||||
initialPathLoaded: true,
|
||||
@@ -896,7 +921,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
if (!navigationState.initialPathLoaded) return; // Wait for initial path to load
|
||||
|
||||
const unsubscribe = subscribeToPathChanges((path, state) => {
|
||||
console.log('[Router] Browser URL changed (popstate):', path);
|
||||
routerDebug('[Router] Browser URL changed (popstate):', path);
|
||||
|
||||
// Always adopt the browser path first. Route registration can lag
|
||||
// behind URL changes during boot or auth redirects.
|
||||
@@ -965,7 +990,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
...routeData // Preserve additional properties like is_fragment
|
||||
});
|
||||
if (isNewRoute) {
|
||||
console.log('[Router] Registered route:', route.path, '→', getComponentLabel(route.component), route.is_fragment ? '(fragment)' : '');
|
||||
routerDebug('[Router] Registered route:', route.path, '→', getComponentLabel(route.component), route.is_fragment ? '(fragment)' : '');
|
||||
newRoutesCount++;
|
||||
}
|
||||
}
|
||||
@@ -973,7 +998,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
|
||||
// Log registered routes for debugging (only if new routes were added)
|
||||
if (newRoutesCount > 0) {
|
||||
console.log('[Router] Registered routes:', Array.from(routesRef.current.keys()));
|
||||
routerDebug('[Router] Registered routes:', Array.from(routesRef.current.keys()));
|
||||
}
|
||||
|
||||
// Trigger re-render by updating routesVersion so findRoute gets recreated
|
||||
@@ -999,7 +1024,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
console.warn('[Router] Available routes:', Array.from(routes.keys()));
|
||||
// Fall back to initialPath if current path doesn't match
|
||||
if (navigationState.currentPath !== initialPath) {
|
||||
console.log('[Router] Falling back to initialPath:', initialPath);
|
||||
routerDebug('[Router] Falling back to initialPath:', initialPath);
|
||||
setNavigationState({
|
||||
initialPathLoaded: true,
|
||||
history: [{ path: initialPath, state: null, timestamp: Date.now() }],
|
||||
|
||||
@@ -331,6 +331,9 @@ class ToastManager {
|
||||
|
||||
// Set new timeout with remaining time
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (toast.persistToNotifications) {
|
||||
notificationCenterManager.addFromToast(toast);
|
||||
}
|
||||
this.hide(id);
|
||||
this._timeouts.delete(id);
|
||||
}, remaining);
|
||||
@@ -1035,6 +1038,10 @@ function NetworkActivityOverlay({ visible = false }) {
|
||||
|
||||
return (
|
||||
<YStack
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy
|
||||
accessibilityLabel="Network activity in progress"
|
||||
position="fixed"
|
||||
top={0}
|
||||
left={0}
|
||||
|
||||
@@ -6,34 +6,19 @@
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { XStack, YStack, Text, Image, Sheet, Button, useMedia } from 'tamagui';
|
||||
import { XStack, YStack, Text, Image, Sheet, Button } from 'tamagui';
|
||||
import { View } from '@tamagui/core';
|
||||
import { getConfig, setConfig, CONFIG_KEYS } from '../../platform/env.js';
|
||||
import { getRootItem, subscribeToMenuChanges, getMenuVersion, MenuItem } from '../../platform/menu.js';
|
||||
import { getConfig, setConfig } from '../../platform/env.js';
|
||||
import { getRootItem, MenuItem } from '../../platform/menu.js';
|
||||
import { MenuItemButton } from './MenuItemButton.jsx';
|
||||
import { PersonalMenuItem } from './PersonalMenuItem.jsx';
|
||||
import { getIcon } from './IconMapper.jsx';
|
||||
import { useShell } from './Shell.jsx';
|
||||
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
|
||||
|
||||
/**
|
||||
* Hook to track menu changes and force re-render
|
||||
*/
|
||||
function useMenuVersion() {
|
||||
const [version, setVersion] = useState(() => getMenuVersion());
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeToMenuChanges((newVersion) => {
|
||||
setVersion(newVersion);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return version;
|
||||
}
|
||||
import { useIsBelowSmBreakpoint } from '../hooks/useShellLayout.js';
|
||||
import { useMenuVersion } from '../hooks/useMenuVersion.js';
|
||||
import { useShellBrandConfig } from '../hooks/useShellBrandConfig.js';
|
||||
import { useEscapeDismiss } from '../hooks/useEscapeDismiss.js';
|
||||
|
||||
/**
|
||||
* Shared logic for organizing children and menu items
|
||||
@@ -135,19 +120,7 @@ function SideBarWide({
|
||||
collapsedWidth = 80,
|
||||
secondaryStyle = 'inline'
|
||||
}) {
|
||||
const [brandLogo, setBrandLogo] = useState(null);
|
||||
const [appName, setAppName] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadConfig() {
|
||||
const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null);
|
||||
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null);
|
||||
setBrandLogo(logo);
|
||||
setAppName(name);
|
||||
}
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const { brandLogo, appName } = useShellBrandConfig();
|
||||
const organizedChildren = useSideBarContent(children);
|
||||
const shell = useShell();
|
||||
|
||||
@@ -255,33 +228,14 @@ function SideBarWide({
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Toggle Button (chevron) - matches MenuItemButton chevron style */}
|
||||
<XStack
|
||||
cursor="pointer"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding="$1"
|
||||
hoverStyle={{
|
||||
backgroundColor: '$bgPage'
|
||||
}}
|
||||
pressStyle={{
|
||||
backgroundColor: '$bgPanelElev'
|
||||
}}
|
||||
onPress={handleToggle}
|
||||
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{(() => {
|
||||
const ChevronIcon = getIcon(isCollapsed ? 'chevrons-right' : 'chevrons-left');
|
||||
if (!ChevronIcon) return null;
|
||||
return (
|
||||
<ChevronIcon
|
||||
size="sm"
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
accessibilityLabel={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
icon={getIcon(isCollapsed ? 'chevrons-right' : 'chevrons-left')}
|
||||
color="$textSecondary"
|
||||
style={{ flexShrink: 0 }}
|
||||
onPress={handleToggle}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</XStack>
|
||||
</XStack>
|
||||
|
||||
{/* Middle Side - Primary menu items and other content */}
|
||||
@@ -359,22 +313,12 @@ function SideBarWide({
|
||||
* Hamburger menu button + Sheet for menu items
|
||||
*/
|
||||
function SideBarNarrow({ children }) {
|
||||
const [brandLogo, setBrandLogo] = useState(null);
|
||||
const [appName, setAppName] = useState(null);
|
||||
const { brandLogo, appName } = useShellBrandConfig();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadConfig() {
|
||||
const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null);
|
||||
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null);
|
||||
setBrandLogo(logo);
|
||||
setAppName(name);
|
||||
}
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const organizedChildren = useSideBarContent(children);
|
||||
|
||||
useEscapeDismiss(menuOpen, () => setMenuOpen(false));
|
||||
|
||||
return (
|
||||
<>
|
||||
<XStack
|
||||
@@ -393,6 +337,8 @@ function SideBarNarrow({ children }) {
|
||||
chromeless
|
||||
icon={getIcon('menu')}
|
||||
color="$textPrimary"
|
||||
accessibilityLabel="Open navigation menu"
|
||||
aria-expanded={menuOpen}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
/>
|
||||
|
||||
@@ -438,8 +384,11 @@ function SideBarNarrow({ children }) {
|
||||
>
|
||||
<Sheet.Overlay backgroundColor="$scrim" />
|
||||
<Sheet.Handle />
|
||||
<Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel">
|
||||
<YStack gap="$2" width="100%">
|
||||
<Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel" accessibilityLabel="Navigation menu">
|
||||
<YStack gap="$2" width="100%" role="menu" aria-label="Navigation menu">
|
||||
<Text id="sidebar-mobile-menu-title" fontSize="$5" fontWeight="600" color="$textPrimary">
|
||||
Navigation
|
||||
</Text>
|
||||
{/* Primary Menu Items */}
|
||||
{organizedChildren.primaryMenuItems.map((item) => (
|
||||
<MenuItemButton
|
||||
@@ -494,8 +443,7 @@ function SideBarNarrow({ children }) {
|
||||
* @param {string} [props.secondaryStyle='inline'] - Secondary menu rendering: 'stacked' | 'inline' (wide only)
|
||||
*/
|
||||
export function SideBar(props) {
|
||||
const media = useMedia();
|
||||
const isNarrow = !media.gtSm; // Below 801px (sm breakpoint)
|
||||
const isNarrow = useIsBelowSmBreakpoint();
|
||||
|
||||
// Use conditional rendering based on screen size
|
||||
if (isNarrow) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button, ScrollView, Text, XStack, YStack } from 'tamagui';
|
||||
import { getIcon } from './IconMapper.jsx';
|
||||
import { useEscapeDismiss } from '../hooks/useEscapeDismiss.js';
|
||||
|
||||
/**
|
||||
* Toolbar / footer action button.
|
||||
@@ -35,6 +37,8 @@ export function SidePanelShell({
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(open);
|
||||
|
||||
useEscapeDismiss(open, onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
@@ -51,7 +55,7 @@ export function SidePanelShell({
|
||||
|
||||
const CloseIcon = getIcon('close');
|
||||
|
||||
return (
|
||||
const panel = (
|
||||
<YStack
|
||||
position="fixed"
|
||||
top={0}
|
||||
@@ -74,6 +78,9 @@ export function SidePanelShell({
|
||||
/>
|
||||
|
||||
<YStack
|
||||
role="dialog"
|
||||
aria-modal
|
||||
aria-labelledby="side-panel-shell-title"
|
||||
position="absolute"
|
||||
top={0}
|
||||
right={0}
|
||||
@@ -101,7 +108,7 @@ export function SidePanelShell({
|
||||
borderBottomColor="$lineSubtle"
|
||||
backgroundColor="$bgPanel"
|
||||
>
|
||||
<Text fontSize="$6" fontWeight="600" color="$textPrimary" flex={1}>
|
||||
<Text id="side-panel-shell-title" fontSize="$6" fontWeight="600" color="$textPrimary" flex={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<XStack alignItems="center" gap="$2" flexWrap="wrap" justifyContent="flex-end">
|
||||
@@ -143,6 +150,12 @@ export function SidePanelShell({
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined' || !document.body) {
|
||||
return panel;
|
||||
}
|
||||
|
||||
return createPortal(panel, document.body);
|
||||
}
|
||||
|
||||
export default SidePanelShell;
|
||||
|
||||
+22
-110
@@ -6,34 +6,18 @@
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { XStack, YStack, Text, Image, Sheet, Button, useMedia } from 'tamagui';
|
||||
import { XStack, YStack, Text, Image, Sheet, Button } from 'tamagui';
|
||||
import { View } from '@tamagui/core';
|
||||
import { getConfig, setConfig, CONFIG_KEYS } from '../../platform/env.js';
|
||||
import { getRootItem, subscribeToMenuChanges, getMenuVersion, MenuItem } from '../../platform/menu.js';
|
||||
import { getRootItem, MenuItem } from '../../platform/menu.js';
|
||||
import { MenuItemButton } from './MenuItemButton.jsx';
|
||||
import { PersonalMenuItem } from './PersonalMenuItem.jsx';
|
||||
import { getIcon } from './IconMapper.jsx';
|
||||
import { useIsBelowSmBreakpoint } from '../hooks/useShellLayout.js';
|
||||
import { useMenuVersion } from '../hooks/useMenuVersion.js';
|
||||
import { useShellBrandConfig } from '../hooks/useShellBrandConfig.js';
|
||||
import { useEscapeDismiss } from '../hooks/useEscapeDismiss.js';
|
||||
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
|
||||
|
||||
/**
|
||||
* Hook to track menu changes and force re-render
|
||||
*/
|
||||
function useMenuVersion() {
|
||||
const [version, setVersion] = useState(() => getMenuVersion());
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeToMenuChanges((newVersion) => {
|
||||
setVersion(newVersion);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared logic for organizing children and menu items
|
||||
* Used by both Wide and Narrow variants
|
||||
@@ -169,19 +153,7 @@ function TopBarWide({
|
||||
middleSideWidth = 0,
|
||||
rightSideWidth = 0
|
||||
}) {
|
||||
const [brandLogo, setBrandLogo] = useState(null);
|
||||
const [appName, setAppName] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadConfig() {
|
||||
const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null);
|
||||
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null);
|
||||
setBrandLogo(logo);
|
||||
setAppName(name);
|
||||
}
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const { brandLogo, appName } = useShellBrandConfig();
|
||||
const organizedChildren = useTopBarContent(children);
|
||||
|
||||
const effectiveRightWidth = rightSideWidth > 0 ? rightSideWidth : (organizedChildren.hasRightSideContent ? 'auto' : 0);
|
||||
@@ -256,38 +228,7 @@ function TopBarWide({
|
||||
borderLeftColor="$lineSubtle"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{organizedChildren.secondaryMenuItems.length > 0 && (
|
||||
<XStack alignItems="center" gap="$1">
|
||||
{organizedChildren.secondaryMenuItems.map((item) => (
|
||||
<MenuItemButton
|
||||
key={item.id || item.path}
|
||||
menuItem={item}
|
||||
orientation="horizontal"
|
||||
displayStyle="icon_only"
|
||||
padding="$1"
|
||||
stateVersion={organizedChildren.menuVersion}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem && (
|
||||
<XStack
|
||||
alignItems="center"
|
||||
paddingLeft={organizedChildren.secondaryMenuItems.length > 0 ? "$2" : undefined}
|
||||
marginLeft={organizedChildren.secondaryMenuItems.length > 0 ? "$1" : undefined}
|
||||
borderLeftWidth={organizedChildren.secondaryMenuItems.length > 0 ? 1 : 0}
|
||||
borderLeftColor="$lineSubtle"
|
||||
>
|
||||
<PersonalMenuItem
|
||||
key="personal-menu"
|
||||
personalRoot={organizedChildren.personalRoot}
|
||||
orientation="horizontal"
|
||||
expand_mode="popup"
|
||||
stateVersion={organizedChildren.menuVersion}
|
||||
/>
|
||||
</XStack>
|
||||
)}
|
||||
{organizedChildren.sections.rightSide}
|
||||
</XStack>
|
||||
)}
|
||||
</XStack>
|
||||
@@ -299,22 +240,12 @@ function TopBarWide({
|
||||
* Hamburger menu button + Sheet for menu items
|
||||
*/
|
||||
function TopBarNarrow({ children }) {
|
||||
const [brandLogo, setBrandLogo] = useState(null);
|
||||
const [appName, setAppName] = useState(null);
|
||||
const { brandLogo, appName } = useShellBrandConfig();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadConfig() {
|
||||
const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null);
|
||||
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null);
|
||||
setBrandLogo(logo);
|
||||
setAppName(name);
|
||||
}
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const organizedChildren = useTopBarContent(children);
|
||||
|
||||
useEscapeDismiss(menuOpen, () => setMenuOpen(false));
|
||||
|
||||
return (
|
||||
<>
|
||||
<XStack
|
||||
@@ -333,6 +264,8 @@ function TopBarNarrow({ children }) {
|
||||
chromeless
|
||||
icon={getIcon('menu')}
|
||||
color="$textPrimary"
|
||||
accessibilityLabel="Open navigation menu"
|
||||
aria-expanded={menuOpen}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
/>
|
||||
|
||||
@@ -353,33 +286,10 @@ function TopBarNarrow({ children }) {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Secondary Menu Items - render in topbar, left of personal menu */}
|
||||
{organizedChildren.secondaryMenuItems.length > 0 && (
|
||||
{/* Custom right-side slots, secondary menu, and personal menu */}
|
||||
{organizedChildren.sections.rightSide.length > 0 && (
|
||||
<XStack flexShrink={0} alignItems="center" gap="$1">
|
||||
{organizedChildren.secondaryMenuItems.map((item) => (
|
||||
<MenuItemButton
|
||||
key={item.id || item.path}
|
||||
menuItem={item}
|
||||
orientation="horizontal"
|
||||
displayStyle="icon_only"
|
||||
padding="$1"
|
||||
stateVersion={organizedChildren.menuVersion}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{/* Personal Menu (only on mobile) - render with horizontal orientation, right-aligned */}
|
||||
{organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem && (
|
||||
<XStack flexShrink={0}>
|
||||
<PersonalMenuItem
|
||||
key="personal-menu"
|
||||
personalRoot={organizedChildren.personalRoot}
|
||||
orientation="horizontal"
|
||||
expand_mode="popup"
|
||||
width="auto"
|
||||
stateVersion={organizedChildren.menuVersion}
|
||||
/>
|
||||
{organizedChildren.sections.rightSide}
|
||||
</XStack>
|
||||
)}
|
||||
</XStack>
|
||||
@@ -394,8 +304,11 @@ function TopBarNarrow({ children }) {
|
||||
>
|
||||
<Sheet.Overlay backgroundColor="$scrim" />
|
||||
<Sheet.Handle />
|
||||
<Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel">
|
||||
<YStack gap="$2" width="100%">
|
||||
<Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel" accessibilityLabel="Navigation menu">
|
||||
<YStack gap="$2" width="100%" role="menu" aria-label="Navigation menu">
|
||||
<Text id="topbar-mobile-menu-title" fontSize="$5" fontWeight="600" color="$textPrimary">
|
||||
Navigation
|
||||
</Text>
|
||||
{/* Primary Menu Items - render with vertical orientation in Sheet */}
|
||||
{organizedChildren.primaryMenuItems.map((item) => (
|
||||
<MenuItemButton
|
||||
@@ -432,8 +345,7 @@ function TopBarNarrow({ children }) {
|
||||
* @param {number} [props.rightSideWidth=0] - Right side width (default: 0, wide only)
|
||||
*/
|
||||
export function TopBar(props) {
|
||||
const media = useMedia();
|
||||
const isNarrow = !media.gtSm; // Below 801px (sm breakpoint)
|
||||
const isNarrow = useIsBelowSmBreakpoint();
|
||||
|
||||
// Use conditional rendering based on screen size
|
||||
if (isNarrow) {
|
||||
|
||||
@@ -30,6 +30,7 @@ export { StorageAdapter } from './storage/StorageAdapter.js';
|
||||
export { OpfsStorageAdapter, default as OpfsStorageAdapterDefault } from './storage/OpfsStorageAdapter.js';
|
||||
export { registerStorageFileView, default as StorageBrowser, default as StorageBrowserDefault } from './storage/StorageBrowser.jsx';
|
||||
export { registerShell, unregisterShell, resolveRegisteredShell, listRegisteredShells, clearRegisteredShells } from './shell-registry.js';
|
||||
export { IconMapper, getIcon, getIconNames, registerIcon, registerIcons, hasIcon, setFallbackIcon, getFallbackIconName } from './IconMapper.jsx';
|
||||
export * from './grid/index.js';
|
||||
export { getTypographyRoleProps, getStyleTypography, TYPOGRAPHY_ROLE_KEYS } from '../styles/index.js';
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
import { addDocumentEventListener } from '../../platform/compat.js';
|
||||
|
||||
/**
|
||||
* @param {boolean} active
|
||||
* @param {(() => void) | null | undefined} onDismiss
|
||||
*/
|
||||
export function useEscapeDismiss(active, onDismiss) {
|
||||
useEffect(() => {
|
||||
if (!active || typeof onDismiss !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
|
||||
return addDocumentEventListener('keydown', handleKeyDown);
|
||||
}, [active, onDismiss]);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getMenuVersion, subscribeToMenuChanges } from '../../platform/menu.js';
|
||||
|
||||
export function useMenuVersion() {
|
||||
const [version, setVersion] = useState(() => getMenuVersion());
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeToMenuChanges((newVersion) => {
|
||||
setVersion(newVersion);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return version;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getConfig, CONFIG_KEYS } from '../../platform/env.js';
|
||||
|
||||
export function useShellBrandConfig() {
|
||||
const [brandLogo, setBrandLogo] = useState(null);
|
||||
const [appName, setAppName] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadConfig() {
|
||||
const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null);
|
||||
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null);
|
||||
if (!cancelled) {
|
||||
setBrandLogo(logo);
|
||||
setAppName(name);
|
||||
}
|
||||
}
|
||||
|
||||
loadConfig();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { brandLogo, appName };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Matches Tamagui `gtSm` (wide shell above this width). */
|
||||
export const SHELL_SM_MAX_WIDTH = 800;
|
||||
export const SHELL_BELOW_SM_MEDIA_QUERY = `(max-width: ${SHELL_SM_MAX_WIDTH}px)`;
|
||||
|
||||
export function getIsBelowSmBreakpoint() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia(SHELL_BELOW_SM_MEDIA_QUERY).matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive shell layout without Tamagui `useMedia()`.
|
||||
* Avoids setState-during-render warnings when the Tamagui config changes.
|
||||
*/
|
||||
export function useIsBelowSmBreakpoint() {
|
||||
const [isBelowSm, setIsBelowSm] = useState(getIsBelowSmBreakpoint);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(SHELL_BELOW_SM_MEDIA_QUERY);
|
||||
const handleChange = (event) => {
|
||||
setIsBelowSm(event.matches);
|
||||
};
|
||||
|
||||
setIsBelowSm(mediaQuery.matches);
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isBelowSm;
|
||||
}
|
||||
|
||||
export function useIsWideShellLayout() {
|
||||
return !useIsBelowSmBreakpoint();
|
||||
}
|
||||
+107
-16
@@ -107,16 +107,25 @@ export const ICON_WEIGHTS = Object.freeze([
|
||||
'thin', 'light', 'regular', 'bold', 'fill', 'duotone',
|
||||
]);
|
||||
|
||||
export const STYLE_THEMES = {
|
||||
azure: AzureTheme,
|
||||
'fluent-flat': FluentFlatTheme,
|
||||
apple: AppleTheme,
|
||||
material: MaterialTheme,
|
||||
minimal: MinimalTheme,
|
||||
colorful: ColorfulTheme,
|
||||
};
|
||||
export const DEFAULT_STYLE_THEME = 'fluent-flat';
|
||||
|
||||
export const DEFAULT_STYLE_THEME = 'azure';
|
||||
const BUILTIN_STYLE_THEME_IDS = Object.freeze([
|
||||
'azure',
|
||||
'fluent-flat',
|
||||
'apple',
|
||||
'material',
|
||||
'minimal',
|
||||
'colorful',
|
||||
]);
|
||||
|
||||
const styleThemeRegistry = new Map([
|
||||
['azure', AzureTheme],
|
||||
['fluent-flat', FluentFlatTheme],
|
||||
['apple', AppleTheme],
|
||||
['material', MaterialTheme],
|
||||
['minimal', MinimalTheme],
|
||||
['colorful', ColorfulTheme],
|
||||
]);
|
||||
|
||||
export const TYPOGRAPHY_ROLE_KEYS = Object.freeze([
|
||||
'fieldLabel',
|
||||
@@ -161,6 +170,83 @@ const DEFAULT_TYPOGRAPHY_ROLES = Object.freeze({
|
||||
|
||||
let activeStyleThemeName = DEFAULT_STYLE_THEME;
|
||||
|
||||
function normalizeStyleThemeId(themeName = '') {
|
||||
return String(themeName || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function coerceStyleThemePreset(presetOrFactory) {
|
||||
if (typeof presetOrFactory === 'function') {
|
||||
return presetOrFactory();
|
||||
}
|
||||
|
||||
if (presetOrFactory && typeof presetOrFactory === 'object') {
|
||||
return presetOrFactory;
|
||||
}
|
||||
|
||||
throw new Error('Style theme preset must be an object or factory function');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a style theme preset by id. Apps can extend the built-in catalog.
|
||||
* @param {string} themeName
|
||||
* @param {Object|Function} presetOrFactory - Tamagui preset object or factory
|
||||
* @returns {Object} Registered preset
|
||||
*/
|
||||
export function registerStyleTheme(themeName, presetOrFactory) {
|
||||
const normalizedName = normalizeStyleThemeId(themeName);
|
||||
if (!normalizedName) {
|
||||
throw new Error('Style theme name is required');
|
||||
}
|
||||
|
||||
const preset = coerceStyleThemePreset(presetOrFactory);
|
||||
const resolvedName = normalizeStyleThemeId(preset.name || normalizedName);
|
||||
validateStyleTheme({ ...preset, name: resolvedName });
|
||||
styleThemeRegistry.set(resolvedName, preset);
|
||||
return preset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a registered style theme. Built-in presets cannot be unregistered.
|
||||
* @param {string} themeName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function unregisterStyleTheme(themeName) {
|
||||
const normalizedName = normalizeStyleThemeId(themeName);
|
||||
if (!normalizedName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (BUILTIN_STYLE_THEME_IDS.includes(normalizedName)) {
|
||||
console.warn(`[styles] Refusing to unregister built-in style theme "${normalizedName}"`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return styleThemeRegistry.delete(normalizedName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} themeName
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function getRegisteredStyleTheme(themeName) {
|
||||
return styleThemeRegistry.get(normalizeStyleThemeId(themeName)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function listStyleThemes() {
|
||||
return Array.from(styleThemeRegistry.keys()).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only snapshot of the current registry (for debugging and tooling).
|
||||
* @returns {Record<string, Object>}
|
||||
*/
|
||||
export function getStyleThemesSnapshot() {
|
||||
return Object.fromEntries(styleThemeRegistry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a preset implements the contract.
|
||||
* Logs a warning per missing key per variant; never throws.
|
||||
@@ -204,24 +290,29 @@ export function validateStyleTheme(preset) {
|
||||
* Map arbitrary input (storage, profile, UI) to a registered style theme id.
|
||||
* Case-insensitive; unknown values fall back to {@link DEFAULT_STYLE_THEME}.
|
||||
* @param {string} [name]
|
||||
* @returns {keyof typeof STYLE_THEMES}
|
||||
* @param {{ fallback?: string }} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeStyleThemeName(name) {
|
||||
const key = typeof name === 'string' ? name.trim().toLowerCase() : '';
|
||||
if (key && STYLE_THEMES[key]) {
|
||||
export function normalizeStyleThemeName(name, { fallback = DEFAULT_STYLE_THEME } = {}) {
|
||||
const key = normalizeStyleThemeId(name);
|
||||
if (key && styleThemeRegistry.has(key)) {
|
||||
return key;
|
||||
}
|
||||
const resolvedFallback = normalizeStyleThemeId(fallback);
|
||||
if (resolvedFallback && styleThemeRegistry.has(resolvedFallback)) {
|
||||
return resolvedFallback;
|
||||
}
|
||||
return DEFAULT_STYLE_THEME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a style theme by name.
|
||||
* @param {string} themeName - Theme name ('azure', 'fluent-flat', 'apple', 'material', 'minimal', 'colorful')
|
||||
* @param {string} themeName
|
||||
* @returns {Object} Theme configuration
|
||||
*/
|
||||
export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
|
||||
const key = normalizeStyleThemeName(themeName);
|
||||
return STYLE_THEMES[key];
|
||||
return styleThemeRegistry.get(key) || styleThemeRegistry.get(DEFAULT_STYLE_THEME);
|
||||
}
|
||||
|
||||
export function setActiveStyleThemeName(themeName) {
|
||||
@@ -257,7 +348,7 @@ export function getTypographyRoleProps(role, overrides = null, themeName = activ
|
||||
* @returns {string[]} Array of theme names
|
||||
*/
|
||||
export function getStyleThemeNames() {
|
||||
return Object.keys(STYLE_THEMES);
|
||||
return listStyleThemes();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
#app {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { beforeEach, describe, test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import {
|
||||
applyRequestAuthorization,
|
||||
createAPIFilterRegistry,
|
||||
normalizeRequestAuthorization
|
||||
} from '../src/platform/api-filters.js';
|
||||
import {
|
||||
SECURITY_REQUEST_FILTER,
|
||||
createSecurityRequestFilter
|
||||
} from '../src/security/runtime/api-auth.js';
|
||||
|
||||
describe('api-filters', () => {
|
||||
/** @type {ReturnType<typeof createAPIFilterRegistry>} */
|
||||
let registry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = createAPIFilterRegistry();
|
||||
});
|
||||
|
||||
test('normalizeRequestAuthorization supports string, scheme/token, and custom headers', () => {
|
||||
assert.deepStrictEqual(
|
||||
normalizeRequestAuthorization('Bearer abc'),
|
||||
{ name: 'Authorization', value: 'Bearer abc' }
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
normalizeRequestAuthorization({ scheme: 'Basic', token: 'dXNlcjpwYXNz' }),
|
||||
{ name: 'Authorization', value: 'Basic dXNlcjpwYXNz' }
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
normalizeRequestAuthorization({ name: 'X-Api-Key', value: 'secret' }),
|
||||
{ name: 'X-Api-Key', value: 'secret' }
|
||||
);
|
||||
});
|
||||
|
||||
test('applyRequestFilters runs in priority order and supports async filters', async () => {
|
||||
const calls = [];
|
||||
|
||||
registry.registerRequestFilter('second', async (ctx) => {
|
||||
calls.push('second');
|
||||
return {
|
||||
...ctx,
|
||||
headers: applyRequestAuthorization(ctx.headers, { name: 'X-Second', value: '2' })
|
||||
};
|
||||
}, { priority: 20 });
|
||||
|
||||
registry.registerRequestFilter('first', async (ctx) => {
|
||||
calls.push('first');
|
||||
return {
|
||||
...ctx,
|
||||
headers: applyRequestAuthorization(ctx.headers, { name: 'X-First', value: '1' })
|
||||
};
|
||||
}, { priority: 10 });
|
||||
|
||||
const result = await registry.applyRequestFilters({
|
||||
url: '/api/items',
|
||||
endpoint: '/items',
|
||||
headers: new Headers()
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(calls, ['first', 'second']);
|
||||
assert.strictEqual(result.headers.get('X-First'), '1');
|
||||
assert.strictEqual(result.headers.get('X-Second'), '2');
|
||||
});
|
||||
|
||||
test('skipRequestFilters can skip named filters', async () => {
|
||||
registry.registerRequestFilter('tenant', (ctx) => ({
|
||||
...ctx,
|
||||
headers: applyRequestAuthorization(ctx.headers, { name: 'X-Tenant', value: 'acme' })
|
||||
}), { priority: 10 });
|
||||
|
||||
registry.registerRequestFilter('auth', (ctx) => ({
|
||||
...ctx,
|
||||
headers: applyRequestAuthorization(ctx.headers, { scheme: 'Bearer', token: 'token' })
|
||||
}), { priority: 100 });
|
||||
|
||||
const result = await registry.applyRequestFilters({
|
||||
url: '/api/login',
|
||||
endpoint: '/login',
|
||||
headers: new Headers(),
|
||||
skipRequestFilters: ['auth']
|
||||
});
|
||||
|
||||
assert.strictEqual(result.headers.get('X-Tenant'), 'acme');
|
||||
assert.strictEqual(result.headers.get('Authorization'), null);
|
||||
});
|
||||
|
||||
test('security request filter delegates authorization to the active policy', async () => {
|
||||
const securityService = {
|
||||
state: {
|
||||
enabled: true,
|
||||
provider: 'basic',
|
||||
isAuthenticated: true,
|
||||
session: { jwt_token: 'ignored-if-policy-returns' },
|
||||
user: { id: 'user-1' },
|
||||
profile: null,
|
||||
realm: null,
|
||||
config: {},
|
||||
policy: {
|
||||
async getRequestAuthorization() {
|
||||
return { name: 'X-Api-Key', value: 'policy-key' };
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await createSecurityRequestFilter(securityService)({
|
||||
url: '/api/items',
|
||||
endpoint: '/items',
|
||||
headers: new Headers()
|
||||
});
|
||||
|
||||
assert.strictEqual(result.headers.get('X-Api-Key'), 'policy-key');
|
||||
assert.strictEqual(result.headers.get('Authorization'), null);
|
||||
});
|
||||
|
||||
test('installable security filter id is stable', () => {
|
||||
assert.strictEqual(SECURITY_REQUEST_FILTER, 'security.auth');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { ApiSecurityPolicy } from '../src/security/policy/ApiSecurityPolicy.js';
|
||||
import { Permit, Session, User } from '../src/security/model/index.js';
|
||||
import { SECURITY_RIGHTS } from '../src/security/model/rights.js';
|
||||
|
||||
describe('ApiSecurityPolicy permit evaluation', () => {
|
||||
test('evaluateSync matches permits by principal, not path alone', () => {
|
||||
const policy = new ApiSecurityPolicy({});
|
||||
policy.cache.user = new User({ id: 'user-1', role_ids: ['role-a'] });
|
||||
policy.cache.session = new Session({ jwt_token: 'jwt-test' });
|
||||
|
||||
policy.cache.permits = [
|
||||
new Permit({
|
||||
principal_type: 'user',
|
||||
principal_id: 'other-user',
|
||||
resource_path: '/app',
|
||||
effect: 'allow',
|
||||
rights: SECURITY_RIGHTS.read
|
||||
})
|
||||
];
|
||||
|
||||
const denied = policy.evaluateSync('user-1', 'read', '/app');
|
||||
assert.strictEqual(denied.allowed, false);
|
||||
|
||||
policy.cache.permits = [
|
||||
new Permit({
|
||||
principal_type: 'user',
|
||||
principal_id: 'user-1',
|
||||
resource_path: '/app',
|
||||
effect: 'allow',
|
||||
rights: SECURITY_RIGHTS.read
|
||||
})
|
||||
];
|
||||
|
||||
const allowed = policy.evaluateSync('user-1', 'read', '/app');
|
||||
assert.strictEqual(allowed.allowed, true);
|
||||
|
||||
policy.cache.permits = [
|
||||
new Permit({
|
||||
principal_type: 'role',
|
||||
principal_id: 'role-a',
|
||||
resource_path: '/app',
|
||||
effect: 'allow',
|
||||
rights: SECURITY_RIGHTS.read
|
||||
})
|
||||
];
|
||||
|
||||
const roleAllowed = policy.evaluateSync('user-1', 'read', '/app');
|
||||
assert.strictEqual(roleAllowed.allowed, true);
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,16 @@ import assert from 'node:assert';
|
||||
import {
|
||||
initEnv,
|
||||
getConfig,
|
||||
getConfigSync,
|
||||
setConfig,
|
||||
getConfigDict,
|
||||
isDevelopment,
|
||||
isProduction,
|
||||
isServiceWorkerEnabledSync,
|
||||
resolveServiceWorkerEnabled,
|
||||
CONFIG_KEYS
|
||||
} from '../src/platform/env.js';
|
||||
import { getProvider } from '../src/platform/storage.js';
|
||||
|
||||
describe('env.js', () => {
|
||||
beforeEach(() => {
|
||||
@@ -90,6 +94,34 @@ describe('env.js', () => {
|
||||
// May return null or undefined depending on import.meta.env availability
|
||||
assert.ok(value === null || value === undefined);
|
||||
});
|
||||
|
||||
test('locked keys ignore persisted storage overrides', async () => {
|
||||
initEnv({
|
||||
name: 'TestApp',
|
||||
api: { base_url: '/api/profile' },
|
||||
modules: ['rt', 'game']
|
||||
});
|
||||
|
||||
const configStorage = getProvider('kv', 'config');
|
||||
await configStorage.set(CONFIG_KEYS.API_BASE_URL, '/api/stale');
|
||||
await configStorage.set(CONFIG_KEYS.MODULES, ['stale']);
|
||||
|
||||
assert.strictEqual(await getConfig(CONFIG_KEYS.API_BASE_URL), '/api/profile');
|
||||
assert.deepStrictEqual(await getConfig(CONFIG_KEYS.MODULES), ['rt', 'game']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfigSync', () => {
|
||||
test('should return in-memory config without storage reads', () => {
|
||||
initEnv({
|
||||
name: 'SyncApp',
|
||||
api: { base_url: '/api/sync' }
|
||||
});
|
||||
|
||||
assert.strictEqual(getConfigSync(CONFIG_KEYS.APP_NAME), 'SyncApp');
|
||||
assert.strictEqual(getConfigSync(CONFIG_KEYS.API_BASE_URL), '/api/sync');
|
||||
assert.strictEqual(getConfigSync('MISSING', 'fallback'), 'fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setConfig', () => {
|
||||
@@ -148,6 +180,24 @@ describe('env.js', () => {
|
||||
assert.ok('STORAGE_BACKEND' in CONFIG_KEYS);
|
||||
assert.ok('API_BASE_URL' in CONFIG_KEYS);
|
||||
assert.ok('MODULES' in CONFIG_KEYS);
|
||||
assert.ok('SERVICE_WORKER_ENABLED' in CONFIG_KEYS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('service worker profile flag', () => {
|
||||
test('resolveServiceWorkerEnabled defaults to true', () => {
|
||||
assert.strictEqual(resolveServiceWorkerEnabled({}), true);
|
||||
});
|
||||
|
||||
test('resolveServiceWorkerEnabled reads service_worker.enabled', () => {
|
||||
assert.strictEqual(resolveServiceWorkerEnabled({ service_worker: { enabled: false } }), false);
|
||||
assert.strictEqual(resolveServiceWorkerEnabled({ pwa: { service_worker: { enabled: false } } }), false);
|
||||
});
|
||||
|
||||
test('initEnv seeds SERVICE_WORKER_ENABLED into config', () => {
|
||||
initEnv({ service_worker: { enabled: false } });
|
||||
assert.strictEqual(isServiceWorkerEnabledSync(), false);
|
||||
assert.strictEqual(getConfigDict()[CONFIG_KEYS.SERVICE_WORKER_ENABLED], false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { SecurityPolicy } from '../src/security/policy/SecurityPolicy.js';
|
||||
|
||||
describe('SecurityPolicy defaults', () => {
|
||||
test('evaluate fails closed when not implemented by a concrete policy', async () => {
|
||||
const policy = new SecurityPolicy();
|
||||
const result = await policy.evaluate('user-1', 'read', '/app');
|
||||
assert.strictEqual(result.allowed, false);
|
||||
assert.strictEqual(result.requires_login, true);
|
||||
});
|
||||
|
||||
test('evaluateSync fails closed when not implemented by a concrete policy', () => {
|
||||
const policy = new SecurityPolicy();
|
||||
const result = policy.evaluateSync('user-1', 'read', '/app');
|
||||
assert.strictEqual(result.allowed, false);
|
||||
assert.strictEqual(result.requires_login, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { isSessionActive } from '../src/security/runtime/session-utils.js';
|
||||
|
||||
describe('session-utils', () => {
|
||||
test('isSessionActive returns false for missing or expired sessions', () => {
|
||||
assert.strictEqual(isSessionActive(null), false);
|
||||
assert.strictEqual(isSessionActive({ status: 'expired' }), false);
|
||||
assert.strictEqual(isSessionActive({
|
||||
status: 'active',
|
||||
expires_on: new Date(Date.now() - 60_000).toISOString()
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('isSessionActive returns true for active unexpired sessions', () => {
|
||||
assert.strictEqual(isSessionActive({ status: 'active' }), true);
|
||||
assert.strictEqual(isSessionActive({
|
||||
status: 'active',
|
||||
expires_on: new Date(Date.now() + 60_000).toISOString()
|
||||
}), true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { beforeEach, describe, test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { KeyValueStore, KV_KEY_PREFIX } from '../src/platform/storage.js';
|
||||
|
||||
describe('KeyValueStore namespacing', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test('isolates values by provider name', async () => {
|
||||
const configStore = new KeyValueStore('config', 'localStorage');
|
||||
const securityStore = new KeyValueStore('security.basic', 'localStorage');
|
||||
|
||||
await configStore.set('theme.mode', 'dark');
|
||||
await securityStore.set('security.basic.session', { user_id: 'u1' });
|
||||
|
||||
assert.strictEqual(await configStore.get('theme.mode'), 'dark');
|
||||
assert.deepStrictEqual(await securityStore.get('security.basic.session'), { user_id: 'u1' });
|
||||
assert.strictEqual(await configStore.get('security.basic.session', null), null);
|
||||
|
||||
assert.strictEqual(
|
||||
localStorage.getItem(`${KV_KEY_PREFIX}config:theme.mode`),
|
||||
JSON.stringify('dark')
|
||||
);
|
||||
assert.strictEqual(
|
||||
localStorage.getItem(`${KV_KEY_PREFIX}security.basic:security.basic.session`),
|
||||
JSON.stringify({ user_id: 'u1' })
|
||||
);
|
||||
});
|
||||
|
||||
test('clear removes only keys owned by the provider', async () => {
|
||||
const configStore = new KeyValueStore('config', 'localStorage');
|
||||
const securityStore = new KeyValueStore('security.basic', 'localStorage');
|
||||
|
||||
await configStore.set('theme.mode', 'dark');
|
||||
await securityStore.set('security.basic.session', { user_id: 'u1' });
|
||||
|
||||
await configStore.clear();
|
||||
|
||||
assert.strictEqual(await configStore.get('theme.mode', null), null);
|
||||
assert.deepStrictEqual(await securityStore.get('security.basic.session'), { user_id: 'u1' });
|
||||
});
|
||||
|
||||
test('config provider reads legacy flat keys as fallback', async () => {
|
||||
localStorage.setItem('theme.mode', JSON.stringify('light'));
|
||||
|
||||
const configStore = new KeyValueStore('config', 'localStorage');
|
||||
assert.strictEqual(await configStore.get('theme.mode'), 'light');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { PWA_CACHE_SCOPE } from '../src/platform/worker.js';
|
||||
|
||||
describe('worker PWA cache scope', () => {
|
||||
test('PWA_CACHE_SCOPE exposes composable bit flags', () => {
|
||||
assert.strictEqual(PWA_CACHE_SCOPE.SERVICE_WORKERS, 1);
|
||||
assert.strictEqual(PWA_CACHE_SCOPE.CACHES, 2);
|
||||
assert.strictEqual(PWA_CACHE_SCOPE.STORAGE, 4);
|
||||
assert.strictEqual(PWA_CACHE_SCOPE.ALL, 7);
|
||||
assert.strictEqual(
|
||||
PWA_CACHE_SCOPE.CACHES | PWA_CACHE_SCOPE.SERVICE_WORKERS,
|
||||
3
|
||||
);
|
||||
});
|
||||
});
|
||||
+7
-1
@@ -11,6 +11,7 @@ function isExternal(id) {
|
||||
if (id === 'react' || id === 'react/jsx-runtime' || id === 'react-dom' || id === 'react-dom/client') {
|
||||
return true;
|
||||
}
|
||||
if (id === '@phosphor-icons/react' || id.startsWith('@phosphor-icons/react/')) return true;
|
||||
if (id === 'tamagui' || id.startsWith('tamagui/')) return true;
|
||||
if (id.startsWith('@tamagui/')) return true;
|
||||
return false;
|
||||
@@ -28,7 +29,12 @@ export default defineConfig({
|
||||
],
|
||||
build: {
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'src/index.js'),
|
||||
entry: {
|
||||
index: path.resolve(__dirname, 'src/index.js'),
|
||||
'ui/components/index': path.resolve(__dirname, 'src/ui/components/index.js'),
|
||||
'ui/route-loading': path.resolve(__dirname, 'src/ui/route-loading.js'),
|
||||
'ui/pages/SettingsPage': path.resolve(__dirname, 'src/ui/pages/SettingsPage.jsx')
|
||||
},
|
||||
formats: ['es']
|
||||
},
|
||||
rollupOptions: {
|
||||
|
||||
Reference in New Issue
Block a user