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",
|
"name": "@reliancy/bface",
|
||||||
"version": "1.0.0",
|
"version": "1.0.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@reliancy/bface",
|
"name": "@reliancy/bface",
|
||||||
"version": "1.0.0",
|
"version": "1.0.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@tamagui/config": "^1.144.2",
|
"@tamagui/config": "^1.144.2",
|
||||||
|
|||||||
+37
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@reliancy/bface",
|
"name": "@reliancy/bface",
|
||||||
"version": "1.0.0",
|
"version": "1.0.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
@@ -14,9 +14,45 @@
|
|||||||
"import": "./dist/platform/*.js",
|
"import": "./dist/platform/*.js",
|
||||||
"types": "./dist/platform/*.d.ts"
|
"types": "./dist/platform/*.d.ts"
|
||||||
},
|
},
|
||||||
|
"./platform/*.js": {
|
||||||
|
"import": "./dist/platform/*.js",
|
||||||
|
"types": "./dist/platform/*.d.ts"
|
||||||
|
},
|
||||||
"./ui/*": {
|
"./ui/*": {
|
||||||
"import": "./dist/ui/*.js",
|
"import": "./dist/ui/*.js",
|
||||||
"types": "./dist/ui/*.d.ts"
|
"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": [
|
"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.
|
* 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 {
|
export class APIError extends Error {
|
||||||
constructor(message, details = {}) {
|
constructor(message, details = {}) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -51,7 +65,6 @@ class NetworkActivityManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beginRequest() {
|
beginRequest() {
|
||||||
const id = ++this._nextId;
|
|
||||||
this.activeCount += 1;
|
this.activeCount += 1;
|
||||||
|
|
||||||
if (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 {
|
export class APIClient {
|
||||||
constructor(baseURL = '/api', sharedInterceptors = null) {
|
constructor(baseURL = '/api', sharedFilterRegistry = null) {
|
||||||
this.baseURL = baseURL;
|
this.baseURL = baseURL;
|
||||||
this.interceptors = sharedInterceptors || {
|
this.filters = sharedFilterRegistry || defaultFilterRegistry;
|
||||||
request: [],
|
}
|
||||||
response: []
|
|
||||||
};
|
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) {
|
addRequestInterceptor(interceptor) {
|
||||||
this.interceptors.request.push(interceptor);
|
return this.filters.addLegacyRequestInterceptor(interceptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
addResponseInterceptor(interceptor) {
|
addResponseInterceptor(interceptor) {
|
||||||
this.interceptors.response.push(interceptor);
|
return this.filters.addLegacyResponseInterceptor(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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveURL(endpoint = '') {
|
resolveURL(endpoint = '') {
|
||||||
@@ -172,7 +222,7 @@ export class APIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scope(baseURL = '/api') {
|
scope(baseURL = '/api') {
|
||||||
return new APIClient(baseURL, this.interceptors);
|
return new APIClient(baseURL, this.filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildHeaders(options = {}) {
|
_buildHeaders(options = {}) {
|
||||||
@@ -190,14 +240,18 @@ export class APIClient {
|
|||||||
const releaseActivity = trackActivity ? networkActivityManager.beginRequest() : null;
|
const releaseActivity = trackActivity ? networkActivityManager.beginRequest() : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = this._applyRequestInterceptors({
|
const requestContext = await this.filters.applyRequestFilters({
|
||||||
...options,
|
...options,
|
||||||
|
endpoint,
|
||||||
|
url,
|
||||||
headers: this._buildHeaders(options)
|
headers: this._buildHeaders(options)
|
||||||
});
|
});
|
||||||
const response = await fetch(url, config);
|
const response = await fetch(url, toFetchInit(requestContext));
|
||||||
return this._applyResponseInterceptors(response);
|
return this.filters.applyResponseFilters(response, {
|
||||||
} catch (error) {
|
request: requestContext,
|
||||||
throw error;
|
endpoint,
|
||||||
|
url
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
releaseActivity?.();
|
releaseActivity?.();
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-3
@@ -3,7 +3,7 @@
|
|||||||
* Central config dictionary and environment discovery
|
* Central config dictionary and environment discovery
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getProvider } from './storage.js';
|
import { configureKeyValueStoreBackend, getProvider } from './storage.js';
|
||||||
import { api as apiClient } from './api.js';
|
import { api as apiClient } from './api.js';
|
||||||
|
|
||||||
// Private storage instance for config
|
// Private storage instance for config
|
||||||
@@ -86,6 +86,7 @@ export const CONFIG_KEYS = {
|
|||||||
THEME_COLOR: 'THEME_COLOR',
|
THEME_COLOR: 'THEME_COLOR',
|
||||||
BACKGROUND_COLOR: 'BACKGROUND_COLOR',
|
BACKGROUND_COLOR: 'BACKGROUND_COLOR',
|
||||||
UI_SHELL: 'UI_SHELL',
|
UI_SHELL: 'UI_SHELL',
|
||||||
|
STYLE_THEME: 'STYLE_THEME',
|
||||||
INITIAL_ROUTE: 'INITIAL_ROUTE',
|
INITIAL_ROUTE: 'INITIAL_ROUTE',
|
||||||
STORAGE_BACKEND: 'STORAGE_BACKEND',
|
STORAGE_BACKEND: 'STORAGE_BACKEND',
|
||||||
API_BASE_URL: 'API_BASE_URL',
|
API_BASE_URL: 'API_BASE_URL',
|
||||||
@@ -93,7 +94,9 @@ export const CONFIG_KEYS = {
|
|||||||
SECURITY_CONFIG: 'SECURITY_CONFIG',
|
SECURITY_CONFIG: 'SECURITY_CONFIG',
|
||||||
LOCALE: 'LOCALE',
|
LOCALE: 'LOCALE',
|
||||||
/** Development host: extra dev UI, SW dev behavior, etc. Layered via getConfig (storage → profile → bundler). */
|
/** 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
|
// 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;
|
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
|
* Initialize environment config
|
||||||
* @param {Object} appConfig - Configuration from profile
|
* @param {Object} appConfig - Configuration from profile
|
||||||
@@ -157,6 +179,12 @@ export function initEnv(appConfig) {
|
|||||||
THEME_COLOR: appConfig.theme_color || appConfig.themeColor || '#000000',
|
THEME_COLOR: appConfig.theme_color || appConfig.themeColor || '#000000',
|
||||||
BACKGROUND_COLOR: appConfig.background_color || appConfig.backgroundColor || '#ffffff',
|
BACKGROUND_COLOR: appConfig.background_color || appConfig.backgroundColor || '#ffffff',
|
||||||
UI_SHELL: appConfig.ui_shell || appConfig.uiShell || 'EmptyShell',
|
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',
|
INITIAL_ROUTE: appConfig.initial_route || appConfig.initialRoute || appConfig.ui?.initial_route || appConfig.ui?.initialRoute || '/home',
|
||||||
STORAGE_BACKEND: appConfig.storage?.backend || 'localStorage',
|
STORAGE_BACKEND: appConfig.storage?.backend || 'localStorage',
|
||||||
API_BASE_URL: resolvedApiBaseURL,
|
API_BASE_URL: resolvedApiBaseURL,
|
||||||
@@ -164,10 +192,12 @@ export function initEnv(appConfig) {
|
|||||||
SECURITY_CONFIG: appConfig.security || {},
|
SECURITY_CONFIG: appConfig.security || {},
|
||||||
LOCALE: appConfig.locale || null,
|
LOCALE: appConfig.locale || null,
|
||||||
[CONFIG_KEYS.DEV_HOST]: resolveDevHostFlag(appConfig),
|
[CONFIG_KEYS.DEV_HOST]: resolveDevHostFlag(appConfig),
|
||||||
|
[CONFIG_KEYS.SERVICE_WORKER_ENABLED]: resolveServiceWorkerEnabled(appConfig),
|
||||||
// Store full profile for advanced access
|
// Store full profile for advanced access
|
||||||
_profile: appConfig
|
_profile: appConfig
|
||||||
};
|
};
|
||||||
apiClient.setBaseURL(resolvedApiBaseURL);
|
apiClient.setBaseURL(resolvedApiBaseURL);
|
||||||
|
configureKeyValueStoreBackend(config.STORAGE_BACKEND);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSystemLocale() {
|
function resolveSystemLocale() {
|
||||||
@@ -397,7 +427,28 @@ export async function syncDocumentHeadFromConfig(options = {}) {
|
|||||||
* @param {any} altValue - Alternative value if not found
|
* @param {any} altValue - Alternative value if not found
|
||||||
* @returns {Promise<any>} Config value or altValue
|
* @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) {
|
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)
|
// 1. Check if key exists in storage first (quick check to avoid unnecessary get)
|
||||||
try {
|
try {
|
||||||
const exists = await storage.hasKey(key);
|
const exists = await storage.hasKey(key);
|
||||||
@@ -440,7 +491,7 @@ export async function getConfig(key, altValue = null) {
|
|||||||
* Otherwise, save to storage (for persistence).
|
* Otherwise, save to storage (for persistence).
|
||||||
* @param {string} key - Config key
|
* @param {string} key - Config key
|
||||||
* @param {any} value - Value to set
|
* @param {any} value - Value to set
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
export async function setConfig(key, value) {
|
export async function setConfig(key, value) {
|
||||||
if (isConfigKeyLocked(key)) {
|
if (isConfigKeyLocked(key)) {
|
||||||
@@ -520,6 +571,28 @@ export function isDevelopment() {
|
|||||||
return false;
|
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.
|
* Inverse of {@link isDevelopment} when no explicit production flag exists.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+11
-4
@@ -7,6 +7,13 @@
|
|||||||
import { normalizeRightsInput } from '../security/model/rights.js';
|
import { normalizeRightsInput } from '../security/model/rights.js';
|
||||||
import { evaluateAuthRequirements } from '../security/runtime/access-rules.js';
|
import { evaluateAuthRequirements } from '../security/runtime/access-rules.js';
|
||||||
import { getProvider } from './storage.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
|
// Menu root structure: Map of root directory IDs to MenuItem instances
|
||||||
const menuRoot = new Map();
|
const menuRoot = new Map();
|
||||||
@@ -616,7 +623,7 @@ function initializeMenuRoot() {
|
|||||||
menuRoot.set(rootConfig.id, rootItem);
|
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
|
// Initialize on module load
|
||||||
@@ -813,7 +820,7 @@ export function publishMenuItem(path, item) {
|
|||||||
// Add to parent's items Map
|
// Add to parent's items Map
|
||||||
parent.addItem(itemId, menuItem);
|
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
|
// Notify listeners of menu change
|
||||||
notifyMenuChange();
|
notifyMenuChange();
|
||||||
@@ -871,7 +878,7 @@ export function retractMenuItem(path) {
|
|||||||
|
|
||||||
const removed = parent.removeItem(itemId);
|
const removed = parent.removeItem(itemId);
|
||||||
if (removed) {
|
if (removed) {
|
||||||
console.log(`[Menu] Retracted item at: ${normalizedPath}`);
|
menuDebug(`[Menu] Retracted item at: ${normalizedPath}`);
|
||||||
// Notify listeners of menu change
|
// Notify listeners of menu change
|
||||||
notifyMenuChange();
|
notifyMenuChange();
|
||||||
} else {
|
} else {
|
||||||
@@ -999,7 +1006,7 @@ export function clearMenu() {
|
|||||||
for (const rootItem of menuRoot.values()) {
|
for (const rootItem of menuRoot.values()) {
|
||||||
rootItem.items.clear();
|
rootItem.items.clear();
|
||||||
}
|
}
|
||||||
console.log('[Menu] Cleared all items (root items preserved)');
|
menuDebug('[Menu] Cleared all items (root items preserved)');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreMenuPreferences() {
|
export async function restoreMenuPreferences() {
|
||||||
|
|||||||
+83
-11
@@ -3,9 +3,21 @@
|
|||||||
* Provides abstraction over different storage types (KeyValueStore, etc.)
|
* 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
|
// Private providers map: type.uri -> provider instance
|
||||||
const providers = new Map();
|
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
|
* KeyValueStore Class
|
||||||
* Provides key-value storage abstraction over localStorage, IndexedDB, and OPFS
|
* Provides key-value storage abstraction over localStorage, IndexedDB, and OPFS
|
||||||
@@ -13,7 +25,27 @@ const providers = new Map();
|
|||||||
class KeyValueStore {
|
class KeyValueStore {
|
||||||
constructor(name, backend = 'localStorage') {
|
constructor(name, backend = 'localStorage') {
|
||||||
this.name = name;
|
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>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async clear() {
|
async clear() {
|
||||||
@@ -115,26 +147,51 @@ class KeyValueStore {
|
|||||||
|
|
||||||
// LocalStorage implementations
|
// LocalStorage implementations
|
||||||
_getLocalStorage(key) {
|
_getLocalStorage(key) {
|
||||||
const value = localStorage.getItem(key);
|
const namespacedKey = this._namespacedKey(key);
|
||||||
return Promise.resolve(value ? JSON.parse(value) : null);
|
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) {
|
_hasKeyLocalStorage(key) {
|
||||||
return Promise.resolve(localStorage.getItem(key) !== null);
|
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) {
|
_setLocalStorage(key, value) {
|
||||||
localStorage.setItem(key, JSON.stringify(value));
|
localStorage.setItem(this._namespacedKey(key), JSON.stringify(value));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
_removeLocalStorage(key) {
|
_removeLocalStorage(key) {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(this._namespacedKey(key));
|
||||||
|
if (this.name === 'config') {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
_clearLocalStorage() {
|
_clearLocalStorage() {
|
||||||
localStorage.clear();
|
this._listNamespacedKeys().forEach((storageKey) => {
|
||||||
|
localStorage.removeItem(storageKey);
|
||||||
|
});
|
||||||
return Promise.resolve();
|
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
|
* Get or create a storage provider
|
||||||
* @param {string} type - Provider type (e.g., "kv" for KeyValueStore)
|
* @param {string} type - Provider type (e.g., "kv" for KeyValueStore)
|
||||||
* @param {string} uri - Provider URI/identifier (e.g., "config", "cache")
|
* @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
|
* @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}`;
|
const key = `${type}.${uri}`;
|
||||||
|
|
||||||
// Check if provider already exists
|
// Check if provider already exists
|
||||||
@@ -207,7 +279,7 @@ export function getProvider(type, uri) {
|
|||||||
|
|
||||||
// Create new provider based on type
|
// Create new provider based on type
|
||||||
if (type === 'kv') {
|
if (type === 'kv') {
|
||||||
const provider = new KeyValueStore(uri, 'localStorage');
|
const provider = new KeyValueStore(uri, options.backend || 'localStorage');
|
||||||
providers.set(key, provider);
|
providers.set(key, provider);
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
@@ -217,4 +289,4 @@ export function getProvider(type, uri) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Export KeyValueStore class for direct instantiation if needed
|
// Export KeyValueStore class for direct instantiation if needed
|
||||||
export { KeyValueStore };
|
export { KeyValueStore, KV_KEY_PREFIX };
|
||||||
|
|||||||
+222
-68
@@ -5,12 +5,73 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { isElectronHost, isTauriHost } from './host.js';
|
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_PATH = '/sw.js';
|
||||||
const SW_SCOPE = '/';
|
const SW_SCOPE = '/';
|
||||||
const DEV_SW_RESET_KEY = '__bface_dev_sw_reset__';
|
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() {
|
async function clearAllCaches() {
|
||||||
if ('caches' in window) {
|
if ('caches' in window) {
|
||||||
try {
|
try {
|
||||||
@@ -30,7 +91,7 @@ async function clearAllCaches() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function unregisterAllServiceWorkers() {
|
async function unregisterAllServiceWorkers() {
|
||||||
if ('serviceWorker' in navigator) {
|
if (hasServiceWorkerSupport()) {
|
||||||
try {
|
try {
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
await Promise.all(registrations.map((reg) => {
|
await Promise.all(registrations.map((reg) => {
|
||||||
@@ -38,6 +99,7 @@ async function unregisterAllServiceWorkers() {
|
|||||||
return reg.unregister();
|
return reg.unregister();
|
||||||
}));
|
}));
|
||||||
console.log(`[SW] Unregistered ${registrations.length} service worker(s)`);
|
console.log(`[SW] Unregistered ${registrations.length} service worker(s)`);
|
||||||
|
SW_REGISTRATION.updateListenerAttached = false;
|
||||||
return registrations.length;
|
return registrations.length;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SW] Failed to unregister service workers:', error);
|
console.error('[SW] Failed to unregister service workers:', error);
|
||||||
@@ -83,86 +145,151 @@ async function clearAllStorage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearPWACache() {
|
async function clearPWACache(scope = PWA_CACHE_SCOPE.ALL) {
|
||||||
console.log('Clearing all PWA caches and storage...');
|
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 {
|
try {
|
||||||
const swCount = await unregisterAllServiceWorkers();
|
const result = {
|
||||||
const cacheCount = await clearAllCaches();
|
serviceWorkers: 0,
|
||||||
await clearAllStorage();
|
caches: 0,
|
||||||
|
storage: false
|
||||||
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
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to clear PWA cache:', error);
|
console.error('Failed to clear PWA cache:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function registerServiceWorker() {
|
function attachUpdateFoundListener(registration) {
|
||||||
if (isElectronHost() || isTauriHost()) {
|
if (SW_REGISTRATION.updateListenerAttached) {
|
||||||
await unregisterAllServiceWorkers();
|
return;
|
||||||
console.log('[SW] Skipping service worker registration in desktop host');
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment());
|
SW_REGISTRATION.updateListenerAttached = true;
|
||||||
if (devHost) {
|
registration.addEventListener('updatefound', () => {
|
||||||
console.log('[SW] Skipping service worker registration in development');
|
const newWorker = registration.installing;
|
||||||
return null;
|
if (newWorker) {
|
||||||
}
|
newWorker.addEventListener('statechange', () => {
|
||||||
|
if (newWorker.state === 'installed') {
|
||||||
if ('serviceWorker' in navigator) {
|
if (navigator.serviceWorker.controller) {
|
||||||
try {
|
console.log('[SW] New service worker available, reloading...');
|
||||||
const registration = await navigator.serviceWorker.register(SW_PATH, {
|
window.location.reload();
|
||||||
scope: SW_SCOPE,
|
} else {
|
||||||
updateViaCache: 'none'
|
console.log('[SW] Service worker installed for the first time');
|
||||||
});
|
}
|
||||||
|
|
||||||
console.log('Service Worker registered:', registration);
|
|
||||||
await registration.update();
|
|
||||||
|
|
||||||
registration.addEventListener('updatefound', () => {
|
|
||||||
const newWorker = registration.installing;
|
|
||||||
if (newWorker) {
|
|
||||||
newWorker.addEventListener('statechange', () => {
|
|
||||||
if (newWorker.state === 'installed') {
|
|
||||||
if (navigator.serviceWorker.controller) {
|
|
||||||
console.log('[SW] New service worker available, reloading...');
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
console.log('[SW] Service worker installed for the first time');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return registration;
|
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) {
|
} catch (error) {
|
||||||
console.error('Service Worker registration failed:', error);
|
console.error('Service Worker registration failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
SW_REGISTRATION.promise = null;
|
||||||
}
|
}
|
||||||
}
|
})();
|
||||||
|
|
||||||
console.warn('Service Workers are not supported');
|
return SW_REGISTRATION.promise;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetServiceWorkers() {
|
async function resetServiceWorkers() {
|
||||||
|
if (!(await isServiceWorkerEnabled())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (isElectronHost() || isTauriHost()) {
|
if (isElectronHost() || isTauriHost()) {
|
||||||
await unregisterAllServiceWorkers();
|
await unregisterAllServiceWorkers();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment());
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,31 +312,52 @@ async function resetServiceWorkers() {
|
|||||||
return true;
|
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() {
|
async function getServiceWorkerStatus() {
|
||||||
if (isElectronHost() || isTauriHost()) {
|
if (isElectronHost() || isTauriHost()) {
|
||||||
return 'Desktop Disabled';
|
return 'Desktop Disabled';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
if (!(await isServiceWorkerEnabled())) {
|
||||||
|
return 'Disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasServiceWorkerSupport()) {
|
||||||
return 'Not Supported';
|
return 'Not Supported';
|
||||||
}
|
}
|
||||||
|
|
||||||
const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment());
|
const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment());
|
||||||
try {
|
try {
|
||||||
if (devHost) {
|
const registration = await resolveServiceWorkerRegistration();
|
||||||
const registration = await navigator.serviceWorker.getRegistration();
|
if (!registration) {
|
||||||
if (registration) {
|
return devHost ? 'Development Disabled' : 'Not Registered';
|
||||||
registration.update();
|
|
||||||
return 'Active';
|
|
||||||
}
|
|
||||||
return 'Development Disabled';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await navigator.serviceWorker.ready;
|
if (registration.active) {
|
||||||
return 'Active';
|
return 'Active';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registration.installing || registration.waiting) {
|
||||||
|
return 'Installing';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Inactive';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[SW] Failed to resolve service worker status:', error);
|
console.warn('[SW] Failed to resolve service worker status:', error);
|
||||||
return 'Not Supported';
|
return 'Unavailable';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,16 +366,18 @@ async function unregisterServiceWorker() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
if (hasServiceWorkerSupport()) {
|
||||||
const registration = await navigator.serviceWorker.getRegistration();
|
const registration = await resolveServiceWorkerRegistration();
|
||||||
if (registration) {
|
if (registration) {
|
||||||
await registration.unregister();
|
await registration.unregister();
|
||||||
|
SW_REGISTRATION.updateListenerAttached = false;
|
||||||
console.log('Service Worker unregistered');
|
console.log('Service Worker unregistered');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sw = {
|
export const sw = {
|
||||||
|
PWA_CACHE_SCOPE,
|
||||||
clearAllCaches,
|
clearAllCaches,
|
||||||
unregisterAllServiceWorkers,
|
unregisterAllServiceWorkers,
|
||||||
clearAllStorage,
|
clearAllStorage,
|
||||||
@@ -235,5 +385,9 @@ export const sw = {
|
|||||||
registerServiceWorker,
|
registerServiceWorker,
|
||||||
resetServiceWorkers,
|
resetServiceWorkers,
|
||||||
getServiceWorkerStatus,
|
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 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 { getRouterPath, scheduleTimeout, setRouterPath } from '../../platform/compat.js';
|
||||||
import { securityService, useSecurityState } from '../runtime/security-service.js';
|
import { securityService, useSecurityState } from '../runtime/security-service.js';
|
||||||
import { Panel } from '../../ui/components/Panel.jsx';
|
import { Panel } from '../../ui/components/Panel.jsx';
|
||||||
@@ -462,6 +462,9 @@ export function LoginDialog({ open = true, title = 'Login', subtitle = 'This rou
|
|||||||
width="100%"
|
width="100%"
|
||||||
maxWidth={520}
|
maxWidth={520}
|
||||||
>
|
>
|
||||||
|
<VisuallyHidden>
|
||||||
|
<Dialog.Title>{title}</Dialog.Title>
|
||||||
|
</VisuallyHidden>
|
||||||
<LoginPanel title={title} subtitle={subtitle} compact onComplete={() => {}} />
|
<LoginPanel title={title} subtitle={subtitle} compact onComplete={() => {}} />
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
|
|||||||
@@ -806,7 +806,7 @@ export function SecurityAdminPage() {
|
|||||||
icon="lock"
|
icon="lock"
|
||||||
title="Security"
|
title="Security"
|
||||||
description="Directory-style security administration for users, roles, realms, resources, and permits."
|
description="Directory-style security administration for users, roles, realms, resources, and permits."
|
||||||
defaultExpanded={false}
|
defaultExpanded={true}
|
||||||
persistenceKey="settings.security"
|
persistenceKey="settings.security"
|
||||||
content={content}
|
content={content}
|
||||||
contentStyle="tabs"
|
contentStyle="tabs"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getProvider } from '../../platform/storage.js';
|
import { getProvider } from '../../platform/storage.js';
|
||||||
import { api } from '../../platform/api.js';
|
import { api } from '../../platform/api.js';
|
||||||
|
import { SECURITY_REQUEST_FILTER } from '../runtime/api-auth.js';
|
||||||
import { SecurityPolicy } from './SecurityPolicy.js';
|
import { SecurityPolicy } from './SecurityPolicy.js';
|
||||||
import {
|
import {
|
||||||
AccountProfile,
|
AccountProfile,
|
||||||
@@ -57,16 +58,27 @@ export class ApiSecurityPolicy extends SecurityPolicy {
|
|||||||
|
|
||||||
async _request(path, options = {}, extra = {}) {
|
async _request(path, options = {}, extra = {}) {
|
||||||
const { authToken = null, trackActivity = true } = 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, {
|
return this.client.requestJSON(path, {
|
||||||
...options,
|
...options,
|
||||||
trackActivity,
|
trackActivity,
|
||||||
headers: {
|
skipRequestFilters: [SECURITY_REQUEST_FILTER],
|
||||||
...(options.headers || {}),
|
headers
|
||||||
...(authToken ? { Authorization: `Bearer ${authToken}` } : {})
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {}) {
|
_applyBundle(bundle = {}) {
|
||||||
this.cache.session = bundle.session ? new Session(bundle.session) : null;
|
this.cache.session = bundle.session ? new Session(bundle.session) : null;
|
||||||
this.cache.user = bundle.user ? new User(bundle.user) : null;
|
this.cache.user = bundle.user ? new User(bundle.user) : null;
|
||||||
@@ -100,9 +112,10 @@ export class ApiSecurityPolicy extends SecurityPolicy {
|
|||||||
async authenticate(credentials = {}) {
|
async authenticate(credentials = {}) {
|
||||||
const username = credentials.username || credentials.email || '';
|
const username = credentials.username || credentials.email || '';
|
||||||
const password = credentials.password || '';
|
const password = credentials.password || '';
|
||||||
const basicToken = typeof btoa === 'function'
|
if (typeof btoa !== 'function') {
|
||||||
? btoa(`${username}:${password}`)
|
throw new Error('Basic authentication requires btoa in the current runtime');
|
||||||
: Buffer.from(`${username}:${password}`, 'utf-8').toString('base64');
|
}
|
||||||
|
const basicToken = btoa(`${username}:${password}`);
|
||||||
const bundle = await this._request('/login', {
|
const bundle = await this._request('/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -462,6 +475,49 @@ export class ApiSecurityPolicy extends SecurityPolicy {
|
|||||||
}, { authToken: this.cache.session?.jwt_token });
|
}, { 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) {
|
async grantPermit(permitData) {
|
||||||
await this._ensureSessionLoaded();
|
await this._ensureSessionLoaded();
|
||||||
const created = await this._request('/admin/permits', {
|
const created = await this._request('/admin/permits', {
|
||||||
@@ -492,6 +548,20 @@ export class ApiSecurityPolicy extends SecurityPolicy {
|
|||||||
this._invalidateAdminCache();
|
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) {
|
_evaluateCached(rights, resourcePath) {
|
||||||
if (!this.cache.user?.id || !this.cache.session?.jwt_token) {
|
if (!this.cache.user?.id || !this.cache.session?.jwt_token) {
|
||||||
return {
|
return {
|
||||||
@@ -504,7 +574,14 @@ export class ApiSecurityPolicy extends SecurityPolicy {
|
|||||||
|
|
||||||
const requestedRights = normalizeRightsInput(rights);
|
const requestedRights = normalizeRightsInput(rights);
|
||||||
const targetPath = resourcePath || '/';
|
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));
|
const denyMatch = matchingPermits.find((permit) => permit.effect === 'deny' && hasRequiredRights(permit.rights, requestedRights));
|
||||||
if (denyMatch) {
|
if (denyMatch) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -287,6 +287,14 @@ export class BasicSecurityPolicy extends SecurityPolicy {
|
|||||||
await this.storage.remove(SESSION_KEY);
|
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() {
|
async listUsers() {
|
||||||
const dataset = await this._loadDataset();
|
const dataset = await this._loadDataset();
|
||||||
return clone(dataset.users);
|
return clone(dataset.users);
|
||||||
@@ -319,13 +327,26 @@ export class BasicSecurityPolicy extends SecurityPolicy {
|
|||||||
return clone(user);
|
return clone(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUser(userId, patch) {
|
async updateUser(userId, patch = {}) {
|
||||||
const dataset = await this._loadDataset();
|
const dataset = await this._loadDataset();
|
||||||
const user = dataset.users.find((item) => item.id === userId);
|
const user = dataset.users.find((item) => item.id === userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(`User not found: ${userId}`);
|
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();
|
await this._persistDataset();
|
||||||
return clone(user);
|
return clone(user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,20 +36,37 @@ export class SecurityPolicy {
|
|||||||
async getAccountProfile(_userId) { return null; }
|
async getAccountProfile(_userId) { return null; }
|
||||||
async updateAccountProfile(_userId, _patch) { throw new Error('updateAccountProfile() not implemented'); }
|
async updateAccountProfile(_userId, _patch) { throw new Error('updateAccountProfile() not implemented'); }
|
||||||
async changePassword(_userId, _passwordInput) { throw new Error('changePassword() 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 = {}) {
|
async evaluate(_userId, _rights, _resourcePath, _context = {}) {
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: false,
|
||||||
requires_login: false,
|
requires_login: true,
|
||||||
reason: 'Security policy not enforced',
|
reason: 'Security policy not configured',
|
||||||
matched_permits: []
|
matched_permits: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
evaluateSync(_userId, _rights, _resourcePath, _context = {}) {
|
evaluateSync(_userId, _rights, _resourcePath, _context = {}) {
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: false,
|
||||||
requires_login: false,
|
requires_login: true,
|
||||||
reason: 'Security policy not enforced',
|
reason: 'Security policy not configured',
|
||||||
matched_permits: []
|
matched_permits: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,79 @@
|
|||||||
export function createSecurityRequestInterceptor(securityService) {
|
import { applyRequestAuthorization } from '../../platform/api.js';
|
||||||
return (config = {}) => securityService.injectRequestConfig(config);
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 createSecurityResponseInterceptor(securityService) {
|
export function createSecurityResponseFilter(securityService) {
|
||||||
return (response) => {
|
return async (response) => {
|
||||||
if (response && response.status === 401) {
|
if (response && response.status === 401) {
|
||||||
securityService.handleUnauthorizedResponse();
|
await securityService.handleUnauthorizedResponse();
|
||||||
}
|
}
|
||||||
return response;
|
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 { getRouterPath, setRouterPath } from '../../platform/compat.js';
|
||||||
import { normalizeRightsInput } from '../model/rights.js';
|
import { normalizeRightsInput } from '../model/rights.js';
|
||||||
import { ApiSecurityPolicy, BasicSecurityPolicy, BstoreSecurityPolicy } from '../policy/index.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 = {
|
const DEFAULT_SECURITY_CONFIG = {
|
||||||
enabled: false,
|
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 {
|
class SecurityService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.state = createInitialState();
|
this.state = createInitialState();
|
||||||
@@ -76,17 +141,22 @@ class SecurityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_resolvePolicy(config) {
|
_resolvePolicy(config) {
|
||||||
if (config.provider === 'api') {
|
const providerName = normalizeProviderName(config.provider || 'basic') || 'basic';
|
||||||
return new ApiSecurityPolicy(config);
|
const providerFactory = getSecurityPolicyProvider(providerName) || getSecurityPolicyProvider('basic');
|
||||||
}
|
return providerFactory(config);
|
||||||
if (config.provider === 'bstore') {
|
|
||||||
return new BstoreSecurityPolicy(config);
|
|
||||||
}
|
|
||||||
return new BasicSecurityPolicy(config);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(config = {}) {
|
async init(config = {}) {
|
||||||
const normalizedConfig = normalizeSecurityConfig(config);
|
const normalizedConfig = normalizeSecurityConfig(config);
|
||||||
|
|
||||||
|
if (this.initPromise) {
|
||||||
|
try {
|
||||||
|
await this.initPromise;
|
||||||
|
} catch {
|
||||||
|
// Prior init failed; allow a new attempt.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const initTask = (async () => {
|
const initTask = (async () => {
|
||||||
if (!normalizedConfig.enabled) {
|
if (!normalizedConfig.enabled) {
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -129,7 +199,7 @@ class SecurityService {
|
|||||||
user,
|
user,
|
||||||
profile,
|
profile,
|
||||||
realm,
|
realm,
|
||||||
isAuthenticated: Boolean(session && user),
|
isAuthenticated: resolveAuthenticated(session, user),
|
||||||
error: null
|
error: null
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -172,31 +242,17 @@ class SecurityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
installAPIClient(apiClient) {
|
installAPIClient(apiClient) {
|
||||||
if (!apiClient || this.apiHooksInstalled) {
|
return installSecurityAPIFilters(apiClient, this);
|
||||||
return;
|
|
||||||
}
|
|
||||||
apiClient.addRequestInterceptor(createSecurityRequestInterceptor(this));
|
|
||||||
apiClient.addResponseInterceptor(createSecurityResponseInterceptor(this));
|
|
||||||
this.apiHooksInstalled = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
injectRequestConfig(config = {}) {
|
async injectRequestConfig(config = {}) {
|
||||||
if (!this.state.session?.jwt_token) {
|
const nextConfig = await createSecurityRequestFilter(this)(config);
|
||||||
return config;
|
return nextConfig;
|
||||||
}
|
|
||||||
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 injectAuthHeaders(headers = {}) {
|
async injectAuthHeaders(headers = {}) {
|
||||||
return this.injectRequestConfig({ headers }).headers;
|
const nextConfig = await this.injectRequestConfig({ headers });
|
||||||
|
return nextConfig.headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleUnauthorizedResponse() {
|
async handleUnauthorizedResponse() {
|
||||||
@@ -223,7 +279,7 @@ class SecurityService {
|
|||||||
user: result.user,
|
user: result.user,
|
||||||
profile: result.profile || null,
|
profile: result.profile || null,
|
||||||
realm,
|
realm,
|
||||||
isAuthenticated: true,
|
isAuthenticated: resolveAuthenticated(result.session, result.user),
|
||||||
error: null
|
error: null
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
@@ -276,13 +332,32 @@ class SecurityService {
|
|||||||
const user = session?.user_id ? await this.state.policy.getUser(session.user_id) : null;
|
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 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 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({
|
this.setState({
|
||||||
session,
|
session,
|
||||||
user,
|
user,
|
||||||
profile,
|
profile,
|
||||||
realm,
|
realm,
|
||||||
isAuthenticated: Boolean(session && user)
|
isAuthenticated
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.state;
|
return this.state;
|
||||||
@@ -420,6 +495,34 @@ class SecurityService {
|
|||||||
await this.state.policy.changePassword(this.state.user.id, passwordInput);
|
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) {
|
async uploadAccountAvatar(file) {
|
||||||
if (!this.state.policy || !this.state.user || typeof this.state.policy.uploadAccountAvatar !== 'function') {
|
if (!this.state.policy || !this.state.user || typeof this.state.policy.uploadAccountAvatar !== 'function') {
|
||||||
throw new Error('Avatar upload is not available');
|
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;
|
||||||
|
}
|
||||||
+137
-152
@@ -4,20 +4,21 @@
|
|||||||
* Provides extensible structure for multiple context managers (Theme, Auth, etc.)
|
* Provides extensible structure for multiple context managers (Theme, Auth, etc.)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import React, { createContext, useContext, useState, useEffect, useLayoutEffect, useCallback, useMemo } from 'react';
|
||||||
import { TamaguiProvider, Theme, createTamagui, YStack } from 'tamagui';
|
import { TamaguiProvider, Theme, createTamagui, YStack, Text } from 'tamagui';
|
||||||
import {
|
import { sw } from '../platform/worker.js';
|
||||||
sw
|
|
||||||
} from '../platform/worker.js';
|
|
||||||
import { getProvider } from '../platform/storage.js';
|
|
||||||
import * as apiClient from '../platform/api.js';
|
import * as apiClient from '../platform/api.js';
|
||||||
import * as storageModuleRef from '../platform/storage.js';
|
import * as storageModuleRef from '../platform/storage.js';
|
||||||
import * as menuRef from '../platform/menu.js';
|
import * as menuRef from '../platform/menu.js';
|
||||||
import * as envModuleRef from '../platform/env.js';
|
import * as envModuleRef from '../platform/env.js';
|
||||||
import { getConfig, setConfig, CONFIG_KEYS, createLogger, startTrace, isDevelopment } from '../platform/env.js';
|
import {
|
||||||
import { EmptyShell, LandingShell, DashboardShell, AppInfo, Router } from './components/index.js';
|
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 { 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 { 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 { THEME_MODE_CONFIG_KEY, THEME_NAME_CONFIG_KEY, THEME_MODES, themeManager } from './theme-controller.js';
|
||||||
import { securityService, useSecurityState } from '../security/runtime/security-service.js';
|
import { securityService, useSecurityState } from '../security/runtime/security-service.js';
|
||||||
@@ -40,6 +41,56 @@ const AppContext = createContext(null);
|
|||||||
const appLogger = createLogger('App');
|
const appLogger = createLogger('App');
|
||||||
const tamaguiConfigCache = new Map();
|
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') {
|
function resolveShellComponent(shellName = 'EmptyShell') {
|
||||||
const key = String(shellName ?? 'EmptyShell').trim().toLowerCase();
|
const key = String(shellName ?? 'EmptyShell').trim().toLowerCase();
|
||||||
switch (key) {
|
switch (key) {
|
||||||
@@ -70,6 +121,21 @@ function getCachedTamaguiConfig(styleThemeName) {
|
|||||||
return config;
|
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
|
// App Component
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -84,11 +150,8 @@ function App({
|
|||||||
initialThemePreferencesLoaded = false
|
initialThemePreferencesLoaded = false
|
||||||
}) {
|
}) {
|
||||||
// App state
|
// 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 [initialized, setInitialized] = useState(false);
|
||||||
|
const [initError, setInitError] = useState(null);
|
||||||
const [ShellComponent, setShellComponent] = useState(() => resolveShellComponent(initialProfile?.ui_shell ?? 'EmptyShell'));
|
const [ShellComponent, setShellComponent] = useState(() => resolveShellComponent(initialProfile?.ui_shell ?? 'EmptyShell'));
|
||||||
const [initialRoute, setInitialRoute] = useState(
|
const [initialRoute, setInitialRoute] = useState(
|
||||||
initialProfile?.initial_route ?? initialProfile?.initialRoute ?? initialProfile?.ui?.initial_route ?? initialProfile?.ui?.initialRoute ?? '/home'
|
initialProfile?.initial_route ?? initialProfile?.initialRoute ?? initialProfile?.ui?.initial_route ?? initialProfile?.ui?.initialRoute ?? '/home'
|
||||||
@@ -108,38 +171,17 @@ function App({
|
|||||||
themeManager.init(setThemeModeState, setSystemScheme, setStyleThemeName);
|
themeManager.init(setThemeModeState, setSystemScheme, setStyleThemeName);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Get style theme configuration
|
|
||||||
const styleTheme = useMemo(() => getStyleTheme(styleThemeName), [styleThemeName]);
|
const styleTheme = useMemo(() => getStyleTheme(styleThemeName), [styleThemeName]);
|
||||||
|
|
||||||
// Create Tamagui config from style theme
|
|
||||||
const tamaguiConfig = useMemo(() => getCachedTamaguiConfig(styleThemeName), [styleThemeName]);
|
const tamaguiConfig = useMemo(() => getCachedTamaguiConfig(styleThemeName), [styleThemeName]);
|
||||||
|
|
||||||
// Calculate active theme (light/dark variant)
|
// Calculate active theme (light/dark variant)
|
||||||
const activeTheme = themeMode === THEME_MODES.SYSTEM ? systemScheme : themeMode;
|
const activeTheme = themeMode === THEME_MODES.SYSTEM ? systemScheme : themeMode;
|
||||||
|
|
||||||
// Update theme manager state
|
useLayoutEffect(() => {
|
||||||
useEffect(() => {
|
|
||||||
themeManager.updateState(themeMode, systemScheme, activeTheme, styleThemeName);
|
themeManager.updateState(themeMode, systemScheme, activeTheme, styleThemeName);
|
||||||
}, [themeMode, systemScheme, activeTheme, styleThemeName]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActiveStyleThemeName(styleThemeName);
|
setActiveStyleThemeName(styleThemeName);
|
||||||
}, [styleThemeName]);
|
applyDocumentThemeSurface(styleTheme, activeTheme);
|
||||||
|
}, [themeMode, systemScheme, activeTheme, styleThemeName, styleTheme]);
|
||||||
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]);
|
|
||||||
|
|
||||||
// Load theme preferences from storage on mount
|
// Load theme preferences from storage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -155,10 +197,15 @@ function App({
|
|||||||
setThemeModeState(savedMode);
|
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);
|
const savedStyleTheme = await getConfig(THEME_NAME_CONFIG_KEY, null);
|
||||||
if (savedStyleTheme) {
|
if (savedStyleTheme) {
|
||||||
setStyleThemeName(normalizeStyleThemeName(savedStyleTheme));
|
setStyleThemeName(normalizeStyleThemeName(savedStyleTheme));
|
||||||
|
} else {
|
||||||
|
const profileStyleTheme = await getConfig(CONFIG_KEYS.STYLE_THEME, null);
|
||||||
|
if (profileStyleTheme) {
|
||||||
|
setStyleThemeName(normalizeStyleThemeName(profileStyleTheme));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[App] Failed to load theme preferences:', error);
|
console.warn('[App] Failed to load theme preferences:', error);
|
||||||
@@ -179,36 +226,6 @@ function App({
|
|||||||
return unsubscribe;
|
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
|
* Get platform services for module injection
|
||||||
* Returns services with renamed keys and includes env
|
* Returns services with renamed keys and includes env
|
||||||
@@ -241,6 +258,7 @@ function App({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Do not clear `initialized` here: it caused a blank / half-ready shell flash while re-running init.
|
// Do not clear `initialized` here: it caused a blank / half-ready shell flash while re-running init.
|
||||||
|
setInitError(null);
|
||||||
setBootModeOverride(null);
|
setBootModeOverride(null);
|
||||||
|
|
||||||
// Get platform services
|
// Get platform services
|
||||||
@@ -287,12 +305,6 @@ function App({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use services to set up app state
|
// 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
|
// Load UI shell from config
|
||||||
const shellName = await services.env.getConfig(CONFIG_KEYS.UI_SHELL, 'EmptyShell');
|
const shellName = await services.env.getConfig(CONFIG_KEYS.UI_SHELL, 'EmptyShell');
|
||||||
const Shell = resolveShellComponent(shellName);
|
const Shell = resolveShellComponent(shellName);
|
||||||
@@ -302,26 +314,9 @@ function App({
|
|||||||
const configuredInitialRoute = await services.env.getConfig(CONFIG_KEYS.INITIAL_ROUTE, '/home');
|
const configuredInitialRoute = await services.env.getConfig(CONFIG_KEYS.INITIAL_ROUTE, '/home');
|
||||||
setInitialRoute(configuredInitialRoute || '/home');
|
setInitialRoute(configuredInitialRoute || '/home');
|
||||||
|
|
||||||
// Get menu items from primary menu
|
await finalizeSecurityInit();
|
||||||
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());
|
|
||||||
|
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
finalizeSecurityInit().catch((error) => {
|
|
||||||
console.error('[Security] Background initialization failed:', error);
|
|
||||||
});
|
|
||||||
initTrace.end({
|
initTrace.end({
|
||||||
shell: shellName,
|
shell: shellName,
|
||||||
bootMode: selectedProfile?.__boot?.uiMode ?? 'runtime'
|
bootMode: selectedProfile?.__boot?.uiMode ?? 'runtime'
|
||||||
@@ -329,6 +324,7 @@ function App({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
initTrace.fail(error);
|
initTrace.fail(error);
|
||||||
appLogger.error('Failed to initialize app:', error);
|
appLogger.error('Failed to initialize app:', error);
|
||||||
|
setInitError(error instanceof Error ? error : new Error(String(error)));
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
}
|
}
|
||||||
}, [initialProfile, onInit]);
|
}, [initialProfile, onInit]);
|
||||||
@@ -338,74 +334,25 @@ function App({
|
|||||||
initializeApp();
|
initializeApp();
|
||||||
}, [initializeApp]);
|
}, [initializeApp]);
|
||||||
|
|
||||||
// Consolidated app context value
|
const appContextValue = useMemo(() => ({
|
||||||
const appContextValue = {
|
|
||||||
theme: {
|
theme: {
|
||||||
themeMode,
|
themeMode,
|
||||||
activeTheme,
|
activeTheme,
|
||||||
systemScheme,
|
systemScheme,
|
||||||
styleThemeName,
|
styleThemeName,
|
||||||
styleTheme,
|
styleTheme,
|
||||||
setThemeMode: async (mode) => {
|
...APP_THEME_HANDLERS,
|
||||||
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);
|
|
||||||
},
|
|
||||||
THEME_MODES
|
THEME_MODES
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
...securityState,
|
...securityState,
|
||||||
login: (credentials) => securityService.login(credentials),
|
...APP_SECURITY_HANDLERS
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
system: {
|
system: {
|
||||||
locale: envModuleRef.getLocaleSync(),
|
...APP_SYSTEM_HANDLERS,
|
||||||
getLocale: (altValue) => envModuleRef.getLocale(altValue),
|
locale: envModuleRef.getLocaleSync()
|
||||||
setLocale: (locale) => envModuleRef.setLocale(locale)
|
|
||||||
}
|
}
|
||||||
// Future managers can be added here: auth, etc.
|
}), [themeMode, activeTheme, systemScheme, styleThemeName, styleTheme, securityState]);
|
||||||
};
|
|
||||||
|
|
||||||
const effectiveBootMode = bootModeOverride ?? bootResult?.uiMode ?? 'runtime';
|
const effectiveBootMode = bootModeOverride ?? bootResult?.uiMode ?? 'runtime';
|
||||||
const shouldRenderBootScreen = effectiveBootMode !== 'runtime' || (!initialized && showInitialBootSplash);
|
const shouldRenderBootScreen = effectiveBootMode !== 'runtime' || (!initialized && showInitialBootSplash);
|
||||||
@@ -435,10 +382,42 @@ function App({
|
|||||||
await initializeApp();
|
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 = (
|
appContent = (
|
||||||
<Router initialPath={initialRoute}>
|
<Router initialPath={initialRoute}>
|
||||||
{/* Declarative route registration (commented out - routes now registered programmatically via modules)
|
{/* Declarative route registration (commented out - routes now registered programmatically via modules)
|
||||||
@@ -468,7 +447,11 @@ function App({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContext.Provider value={appContextValue}>
|
<AppContext.Provider value={appContextValue}>
|
||||||
<TamaguiProvider config={tamaguiConfig} defaultTheme={activeTheme}>
|
<TamaguiProvider
|
||||||
|
key={styleThemeName}
|
||||||
|
config={tamaguiConfig}
|
||||||
|
defaultTheme={activeTheme}
|
||||||
|
>
|
||||||
<Theme name={activeTheme}>
|
<Theme name={activeTheme}>
|
||||||
{shouldRenderBootScreen ? (
|
{shouldRenderBootScreen ? (
|
||||||
bootScreenContent
|
bootScreenContent
|
||||||
@@ -554,6 +537,7 @@ export { useApp, useTheme, THEME_MODES, themeManager as ThemeManager };
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.clearPWACache = sw.clearPWACache;
|
window.clearPWACache = sw.clearPWACache;
|
||||||
window.__PWA_UTILS__ = {
|
window.__PWA_UTILS__ = {
|
||||||
|
PWA_CACHE_SCOPE: sw.PWA_CACHE_SCOPE,
|
||||||
clearPWACache: sw.clearPWACache,
|
clearPWACache: sw.clearPWACache,
|
||||||
clearAllCaches: sw.clearAllCaches,
|
clearAllCaches: sw.clearAllCaches,
|
||||||
unregisterAllServiceWorkers: sw.unregisterAllServiceWorkers,
|
unregisterAllServiceWorkers: sw.unregisterAllServiceWorkers,
|
||||||
@@ -561,10 +545,11 @@ if (typeof window !== 'undefined') {
|
|||||||
};
|
};
|
||||||
|
|
||||||
console.log('💡 PWA Utilities available:');
|
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__.clearAllCaches() - Clear caches only');
|
||||||
console.log(' - __PWA_UTILS__.unregisterAllServiceWorkers() - Unregister SW 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)
|
// Note: App initialization is handled by the consuming project (e.g., app-react.jsx)
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { XStack, YStack, useMedia } from 'tamagui';
|
import { XStack, YStack } from 'tamagui';
|
||||||
import { View } from '@tamagui/core';
|
import { View } from '@tamagui/core';
|
||||||
import { ShellProvider, useShell, ToastViewport } from './Shell.jsx';
|
import { ShellProvider, useShell, ToastViewport } from './Shell.jsx';
|
||||||
|
import { useIsBelowSmBreakpoint } from '../hooks/useShellLayout.js';
|
||||||
|
|
||||||
// Section components for children placement
|
// Section components for children placement
|
||||||
const SectionContext = React.createContext(null);
|
const SectionContext = React.createContext(null);
|
||||||
@@ -52,8 +53,7 @@ const SectionContext = React.createContext(null);
|
|||||||
*/
|
*/
|
||||||
function EmptyShellInner({ children }) {
|
function EmptyShellInner({ children }) {
|
||||||
const shell = useShell();
|
const shell = useShell();
|
||||||
const media = useMedia();
|
const isMobile = useIsBelowSmBreakpoint();
|
||||||
const isMobile = !media.gtSm; // Below 801px (sm breakpoint)
|
|
||||||
|
|
||||||
// Organize children by placement
|
// Organize children by placement
|
||||||
const organizedChildren = useMemo(() => {
|
const organizedChildren = useMemo(() => {
|
||||||
@@ -136,6 +136,8 @@ function EmptyShellInner({ children }) {
|
|||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<View
|
<View
|
||||||
|
id="app-main-content"
|
||||||
|
role="main"
|
||||||
flex={1}
|
flex={1}
|
||||||
width="100%"
|
width="100%"
|
||||||
overflow="auto"
|
overflow="auto"
|
||||||
@@ -220,6 +222,8 @@ function EmptyShellInner({ children }) {
|
|||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<View
|
<View
|
||||||
|
id="app-main-content"
|
||||||
|
role="main"
|
||||||
flex={1}
|
flex={1}
|
||||||
width="100%"
|
width="100%"
|
||||||
overflow="auto"
|
overflow="auto"
|
||||||
|
|||||||
@@ -436,21 +436,131 @@ const iconMap = {
|
|||||||
'print': wrap(Printer, 'Printer'),
|
'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 */
|
/* 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
|
* 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
|
* wrapper handles theme-aware color, sizing, and weight). Unknown icons fall
|
||||||
* name is unknown.
|
* 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}
|
* @returns {React.ComponentType<{size?:string|number,color?:string,weight?:string}>|null}
|
||||||
*/
|
*/
|
||||||
export function getIcon(iconName) {
|
export function getIcon(iconName, options = {}) {
|
||||||
if (typeof iconName !== 'string' || !iconName) return null;
|
const { allowFallback = true } = options;
|
||||||
return iconMap[iconName.toLowerCase().trim()] || null;
|
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).
|
* single character (e.g. user-entered avatar glyphs).
|
||||||
*/
|
*/
|
||||||
export function IconMapper({ iconName, size = DEFAULT_SIZE, color = '$textPrimary', ...props }) {
|
export function IconMapper({ iconName, size = DEFAULT_SIZE, color = '$textPrimary', ...props }) {
|
||||||
const Icon = getIcon(iconName);
|
const Icon = getIcon(iconName, { allowFallback: false });
|
||||||
if (Icon) {
|
if (Icon) {
|
||||||
return <Icon size={size} color={color} {...props} />;
|
return <Icon size={size} color={color} {...props} />;
|
||||||
}
|
}
|
||||||
if (typeof iconName === 'string' && (iconName.length <= 2 || /[\u{1F300}-\u{1F9FF}]/u.test(iconName))) {
|
if (typeof iconName === 'string' && (iconName.length <= 2 || /[\u{1F300}-\u{1F9FF}]/u.test(iconName))) {
|
||||||
return <SizableText fontSize={resolveSize(size)} color={color}>{iconName}</SizableText>;
|
return <SizableText fontSize={resolveSize(size)} color={color}>{iconName}</SizableText>;
|
||||||
}
|
}
|
||||||
|
const FallbackIcon = getIcon(iconName);
|
||||||
|
if (FallbackIcon) {
|
||||||
|
return <FallbackIcon size={size} color={color} {...props} />;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,7 +644,7 @@ export function getIconSize(size) {
|
|||||||
* List of every registered alias. Handy for validation / docs.
|
* List of every registered alias. Handy for validation / docs.
|
||||||
*/
|
*/
|
||||||
export function getIconNames() {
|
export function getIconNames() {
|
||||||
return Object.keys(iconMap);
|
return Array.from(iconRegistry.keys()).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
export default IconMapper;
|
export default IconMapper;
|
||||||
|
|||||||
@@ -19,6 +19,29 @@ import {
|
|||||||
import { MenuItem, getMenuItemExpandedPreference, setMenuItemExpandedPreference } from '../../platform/menu.js';
|
import { MenuItem, getMenuItemExpandedPreference, setMenuItemExpandedPreference } from '../../platform/menu.js';
|
||||||
import { securityService, useSecurityState } from '../../security/runtime/security-service.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
|
* MenuItemButton Component
|
||||||
*
|
*
|
||||||
@@ -157,7 +180,7 @@ export function MenuItemButton({
|
|||||||
return NotificationManager.subscribe(syncCount);
|
return NotificationManager.subscribe(syncCount);
|
||||||
}, [menuItem.id]);
|
}, [menuItem.id]);
|
||||||
|
|
||||||
// Close popup when clicking outside (popup mode only)
|
// Close popup when clicking outside or pressing Escape (popup mode only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (effectiveExpandMode === 'popup' && popupOpen) {
|
if (effectiveExpandMode === 'popup' && popupOpen) {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
@@ -170,9 +193,20 @@ export function MenuItemButton({
|
|||||||
setPopupOpen(false);
|
setPopupOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Use compatibility layer for document event listener
|
const handleEscape = (event) => {
|
||||||
return addDocumentEventListener('mousedown', handleClickOutside);
|
if (event.key === 'Escape') {
|
||||||
|
setPopupOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeClickListener = addDocumentEventListener('mousedown', handleClickOutside);
|
||||||
|
const removeKeyListener = addDocumentEventListener('keydown', handleEscape);
|
||||||
|
return () => {
|
||||||
|
removeClickListener?.();
|
||||||
|
removeKeyListener?.();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
}, [popupOpen, effectiveExpandMode]);
|
}, [popupOpen, effectiveExpandMode]);
|
||||||
|
|
||||||
// Determine width
|
// Determine width
|
||||||
@@ -345,6 +379,14 @@ export function MenuItemButton({
|
|||||||
? (orientation === 'vertical' ? 'chevron-right' : 'chevron-down')
|
? (orientation === 'vertical' ? 'chevron-right' : 'chevron-down')
|
||||||
: (isExpanded ? 'chevron-down' : 'chevron-right');
|
: (isExpanded ? 'chevron-down' : 'chevron-right');
|
||||||
const ArrowIcon = getIcon(arrowIconName);
|
const ArrowIcon = getIcon(arrowIconName);
|
||||||
|
const menuItemA11yProps = buildMenuItemA11yProps({
|
||||||
|
menuItem,
|
||||||
|
showLabel,
|
||||||
|
hasSubitems,
|
||||||
|
popupOpen,
|
||||||
|
isExpanded,
|
||||||
|
effectiveExpandMode
|
||||||
|
});
|
||||||
|
|
||||||
// Render based on orientation
|
// Render based on orientation
|
||||||
if (orientation === 'horizontal') {
|
if (orientation === 'horizontal') {
|
||||||
@@ -365,6 +407,7 @@ export function MenuItemButton({
|
|||||||
borderRadius="$radiusSm"
|
borderRadius="$radiusSm"
|
||||||
padding={padding}
|
padding={padding}
|
||||||
opacity={menuItem.is_active !== false ? 1 : 0.55}
|
opacity={menuItem.is_active !== false ? 1 : 0.55}
|
||||||
|
{...menuItemA11yProps}
|
||||||
>
|
>
|
||||||
{/* Icon + Label (clickable main area) */}
|
{/* Icon + Label (clickable main area) */}
|
||||||
<XStack
|
<XStack
|
||||||
@@ -433,6 +476,7 @@ export function MenuItemButton({
|
|||||||
backgroundColor: '$bgPanelElev'
|
backgroundColor: '$bgPanelElev'
|
||||||
}}
|
}}
|
||||||
onPress={handleToggleExpand}
|
onPress={handleToggleExpand}
|
||||||
|
accessibilityLabel={`${isExpanded || popupOpen ? 'Collapse' : 'Expand'} ${menuItem.label || 'menu group'}`}
|
||||||
>
|
>
|
||||||
<ArrowIcon
|
<ArrowIcon
|
||||||
size={CHEVRON_SIZE}
|
size={CHEVRON_SIZE}
|
||||||
@@ -514,6 +558,7 @@ export function MenuItemButton({
|
|||||||
borderRadius="$radiusSm"
|
borderRadius="$radiusSm"
|
||||||
padding={padding}
|
padding={padding}
|
||||||
opacity={menuItem.is_active !== false ? 1 : 0.55}
|
opacity={menuItem.is_active !== false ? 1 : 0.55}
|
||||||
|
{...menuItemA11yProps}
|
||||||
>
|
>
|
||||||
{/* Icon + Label (clickable main area) */}
|
{/* Icon + Label (clickable main area) */}
|
||||||
<XStack
|
<XStack
|
||||||
@@ -582,6 +627,7 @@ export function MenuItemButton({
|
|||||||
backgroundColor: '$bgPanelElev'
|
backgroundColor: '$bgPanelElev'
|
||||||
}}
|
}}
|
||||||
onPress={handleToggleExpand}
|
onPress={handleToggleExpand}
|
||||||
|
accessibilityLabel={`${isExpanded || popupOpen ? 'Collapse' : 'Expand'} ${menuItem.label || 'menu group'}`}
|
||||||
>
|
>
|
||||||
<ArrowIcon
|
<ArrowIcon
|
||||||
size={CHEVRON_SIZE}
|
size={CHEVRON_SIZE}
|
||||||
|
|||||||
@@ -5,12 +5,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { Suspense, createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import React, { Suspense, createContext, useContext, useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { Spinner, YStack } from 'tamagui';
|
||||||
import { InvokeHandlers } from '../../platform/menu.js';
|
import { InvokeHandlers } from '../../platform/menu.js';
|
||||||
import { openExternalURL, getRouterPath, setRouterPath, subscribeToPathChanges } from '../../platform/compat.js';
|
import { openExternalURL, getRouterPath, setRouterPath, subscribeToPathChanges } from '../../platform/compat.js';
|
||||||
import { ErrorPage } from '../../security/pages/ErrorPage.jsx';
|
import { ErrorPage } from '../../security/pages/ErrorPage.jsx';
|
||||||
import { LoginDialog, LoginPage } from '../../security/pages/LoginPage.jsx';
|
|
||||||
import { evaluateRouteAccess } from '../../security/runtime/route-guards.js';
|
import { evaluateRouteAccess } from '../../security/runtime/route-guards.js';
|
||||||
import { securityService, useSecurityState } from '../../security/runtime/security-service.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) {
|
function getComponentLabel(component) {
|
||||||
if (!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'}`;
|
const routeSignature = `${route.path}|${getComponentLabel(route.component)}|${route.fragment_path || ''}|${route.is_fragment ? 'fragment' : 'route'}`;
|
||||||
if (lastLoggedRouteRef.current !== routeSignature) {
|
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;
|
lastLoggedRouteRef.current = routeSignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,23 +463,44 @@ function SelectedComponent({ placement, fallback }) {
|
|||||||
};
|
};
|
||||||
}, [route.path, route.options, securityState.enabled, securityState.isAuthenticated, securityState.user?.id]);
|
}, [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 no route is active, render fallback or null
|
||||||
if (!route.component) {
|
if (!route.component) {
|
||||||
console.log('[Router.SelectedComponent] No route component, rendering fallback or null');
|
routerDebug('[Router.SelectedComponent] No route component, rendering fallback or null');
|
||||||
return fallback ? <fallback /> : null;
|
if (fallback) {
|
||||||
}
|
const Fallback = fallback;
|
||||||
|
return <Fallback />;
|
||||||
if (guardState.evaluating) {
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (guardState.pending) {
|
if (guardState.evaluating || guardState.pending) {
|
||||||
return null;
|
return (
|
||||||
|
<YStack flex={1} minHeight={240} alignItems="center" justifyContent="center" padding="$4">
|
||||||
|
<Spinner size="large" color="$accentColor" />
|
||||||
|
</YStack>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!guardState.allowed) {
|
if (!guardState.allowed) {
|
||||||
if (guardState.requires_login) {
|
if (guardState.requires_login) {
|
||||||
return <LoginDialog subtitle="This route requires an authenticated user." />;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -490,9 +518,6 @@ function SelectedComponent({ placement, fallback }) {
|
|||||||
// Note: For fragment routes, this will be the parent component
|
// 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
|
// The parent component can use useRoute() to get the fragment_path to know which fragment is active
|
||||||
const RouteComponent = route.component;
|
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 (
|
return (
|
||||||
<RouteErrorBoundary
|
<RouteErrorBoundary
|
||||||
resetKey={routeSignature}
|
resetKey={routeSignature}
|
||||||
@@ -640,8 +665,8 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
|||||||
const getSelectedRoute = useCallback(() => {
|
const getSelectedRoute = useCallback(() => {
|
||||||
const match = findRoute(navigationState.currentPath);
|
const match = findRoute(navigationState.currentPath);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
console.log('[Router] getSelectedRoute() - No match for path:', navigationState.currentPath);
|
routerDebug('[Router] getSelectedRoute() - No match for path:', navigationState.currentPath);
|
||||||
console.log('[Router] Available routes:', Array.from(routesRef.current.keys()));
|
routerDebug('[Router] Available routes:', Array.from(routesRef.current.keys()));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,10 +679,10 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
|||||||
if (parentRoute) {
|
if (parentRoute) {
|
||||||
// Use parent component for rendering, but keep original path
|
// Use parent component for rendering, but keep original path
|
||||||
componentToRender = parentRoute.component;
|
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 {
|
} else {
|
||||||
// No parent found, fallback to fragment route itself
|
// 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;
|
componentToRender = match.component;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -671,7 +696,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
|||||||
is_fragment: match.is_fragment || false,
|
is_fragment: match.is_fragment || false,
|
||||||
fragment_path: match.is_fragment ? navigationState.currentPath : null // Store fragment path if applicable
|
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;
|
return route;
|
||||||
}, [navigationState.currentPath, navigationState.routeState, findRoute, findParentRoute]);
|
}, [navigationState.currentPath, navigationState.routeState, findRoute, findParentRoute]);
|
||||||
|
|
||||||
@@ -679,8 +704,8 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
|||||||
const navigate = useCallback(async (path, options = {}) => {
|
const navigate = useCallback(async (path, options = {}) => {
|
||||||
const { replace = false, state = null } = options;
|
const { replace = false, state = null } = options;
|
||||||
|
|
||||||
console.log('[Router] navigate() called with path:', path);
|
routerDebug('[Router] navigate() called with path:', path);
|
||||||
console.log('[Router] Available routes:', Array.from(routesRef.current.keys()));
|
routerDebug('[Router] Available routes:', Array.from(routesRef.current.keys()));
|
||||||
|
|
||||||
// Find matching route
|
// Find matching route
|
||||||
const match = findRoute(path);
|
const match = findRoute(path);
|
||||||
@@ -690,7 +715,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
|||||||
return;
|
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)
|
// Update browser URL and save to storage (via compat layer)
|
||||||
await setRouterPath(path, replace, { notify: false, state });
|
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
|
// Call onRouteChange callback
|
||||||
if (onRouteChangeRef.current) {
|
if (onRouteChangeRef.current) {
|
||||||
@@ -841,7 +866,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
|||||||
// Set up goToPage handler
|
// Set up goToPage handler
|
||||||
InvokeHandlers.goToPage = (menuItem, eventSource, event) => {
|
InvokeHandlers.goToPage = (menuItem, eventSource, event) => {
|
||||||
if (menuItem.invoke_target) {
|
if (menuItem.invoke_target) {
|
||||||
console.log('[Router] Navigating to:', menuItem.invoke_target);
|
routerDebug('[Router] Navigating to:', menuItem.invoke_target);
|
||||||
navigate(menuItem.invoke_target);
|
navigate(menuItem.invoke_target);
|
||||||
} else {
|
} else {
|
||||||
console.warn('[Router] MenuItem missing invoke_target for goToPage');
|
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)
|
// Set up goToModal handler (placeholder - to be implemented)
|
||||||
InvokeHandlers.goToModal = (menuItem, eventSource, event) => {
|
InvokeHandlers.goToModal = (menuItem, eventSource, event) => {
|
||||||
if (menuItem.invoke_target) {
|
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
|
// TODO: Implement modal system
|
||||||
} else {
|
} else {
|
||||||
console.warn('[Router] MenuItem missing invoke_target for goToModal');
|
console.warn('[Router] MenuItem missing invoke_target for goToModal');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[Router] InvokeHandlers configured');
|
routerDebug('[Router] InvokeHandlers configured');
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
// Load initial path from browser URL or storage
|
// 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
|
if (navigationState.initialPathLoaded) return; // Only load once
|
||||||
|
|
||||||
const actualInitialPath = await getRouterPath(initialPath);
|
const actualInitialPath = await getRouterPath(initialPath);
|
||||||
console.log('[Router] Initial path resolved:', actualInitialPath, '(fallback:', initialPath, ')');
|
routerDebug('[Router] Initial path resolved:', actualInitialPath, '(fallback:', initialPath, ')');
|
||||||
|
|
||||||
setNavigationState({
|
setNavigationState({
|
||||||
initialPathLoaded: true,
|
initialPathLoaded: true,
|
||||||
@@ -896,7 +921,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
|||||||
if (!navigationState.initialPathLoaded) return; // Wait for initial path to load
|
if (!navigationState.initialPathLoaded) return; // Wait for initial path to load
|
||||||
|
|
||||||
const unsubscribe = subscribeToPathChanges((path, state) => {
|
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
|
// Always adopt the browser path first. Route registration can lag
|
||||||
// behind URL changes during boot or auth redirects.
|
// 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
|
...routeData // Preserve additional properties like is_fragment
|
||||||
});
|
});
|
||||||
if (isNewRoute) {
|
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++;
|
newRoutesCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -973,7 +998,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
|||||||
|
|
||||||
// Log registered routes for debugging (only if new routes were added)
|
// Log registered routes for debugging (only if new routes were added)
|
||||||
if (newRoutesCount > 0) {
|
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
|
// 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()));
|
console.warn('[Router] Available routes:', Array.from(routes.keys()));
|
||||||
// Fall back to initialPath if current path doesn't match
|
// Fall back to initialPath if current path doesn't match
|
||||||
if (navigationState.currentPath !== initialPath) {
|
if (navigationState.currentPath !== initialPath) {
|
||||||
console.log('[Router] Falling back to initialPath:', initialPath);
|
routerDebug('[Router] Falling back to initialPath:', initialPath);
|
||||||
setNavigationState({
|
setNavigationState({
|
||||||
initialPathLoaded: true,
|
initialPathLoaded: true,
|
||||||
history: [{ path: initialPath, state: null, timestamp: Date.now() }],
|
history: [{ path: initialPath, state: null, timestamp: Date.now() }],
|
||||||
|
|||||||
@@ -331,6 +331,9 @@ class ToastManager {
|
|||||||
|
|
||||||
// Set new timeout with remaining time
|
// Set new timeout with remaining time
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (toast.persistToNotifications) {
|
||||||
|
notificationCenterManager.addFromToast(toast);
|
||||||
|
}
|
||||||
this.hide(id);
|
this.hide(id);
|
||||||
this._timeouts.delete(id);
|
this._timeouts.delete(id);
|
||||||
}, remaining);
|
}, remaining);
|
||||||
@@ -1035,6 +1038,10 @@ function NetworkActivityOverlay({ visible = false }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack
|
<YStack
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy
|
||||||
|
accessibilityLabel="Network activity in progress"
|
||||||
position="fixed"
|
position="fixed"
|
||||||
top={0}
|
top={0}
|
||||||
left={0}
|
left={0}
|
||||||
|
|||||||
@@ -6,34 +6,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo, useState, useEffect } from 'react';
|
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 { View } from '@tamagui/core';
|
||||||
import { getConfig, setConfig, CONFIG_KEYS } from '../../platform/env.js';
|
import { getConfig, setConfig } 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 { MenuItemButton } from './MenuItemButton.jsx';
|
||||||
import { PersonalMenuItem } from './PersonalMenuItem.jsx';
|
import { PersonalMenuItem } from './PersonalMenuItem.jsx';
|
||||||
import { getIcon } from './IconMapper.jsx';
|
import { getIcon } from './IconMapper.jsx';
|
||||||
import { useShell } from './Shell.jsx';
|
import { useShell } from './Shell.jsx';
|
||||||
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
|
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
|
||||||
|
import { useIsBelowSmBreakpoint } from '../hooks/useShellLayout.js';
|
||||||
/**
|
import { useMenuVersion } from '../hooks/useMenuVersion.js';
|
||||||
* Hook to track menu changes and force re-render
|
import { useShellBrandConfig } from '../hooks/useShellBrandConfig.js';
|
||||||
*/
|
import { useEscapeDismiss } from '../hooks/useEscapeDismiss.js';
|
||||||
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
|
* Shared logic for organizing children and menu items
|
||||||
@@ -135,19 +120,7 @@ function SideBarWide({
|
|||||||
collapsedWidth = 80,
|
collapsedWidth = 80,
|
||||||
secondaryStyle = 'inline'
|
secondaryStyle = 'inline'
|
||||||
}) {
|
}) {
|
||||||
const [brandLogo, setBrandLogo] = useState(null);
|
const { brandLogo, appName } = useShellBrandConfig();
|
||||||
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 organizedChildren = useSideBarContent(children);
|
const organizedChildren = useSideBarContent(children);
|
||||||
const shell = useShell();
|
const shell = useShell();
|
||||||
|
|
||||||
@@ -255,33 +228,14 @@ function SideBarWide({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toggle Button (chevron) - matches MenuItemButton chevron style */}
|
<Button
|
||||||
<XStack
|
size="$3"
|
||||||
cursor="pointer"
|
chromeless
|
||||||
alignItems="center"
|
accessibilityLabel={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
justifyContent="center"
|
icon={getIcon(isCollapsed ? 'chevrons-right' : 'chevrons-left')}
|
||||||
padding="$1"
|
color="$textSecondary"
|
||||||
hoverStyle={{
|
|
||||||
backgroundColor: '$bgPage'
|
|
||||||
}}
|
|
||||||
pressStyle={{
|
|
||||||
backgroundColor: '$bgPanelElev'
|
|
||||||
}}
|
|
||||||
onPress={handleToggle}
|
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"
|
|
||||||
color="$textSecondary"
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</XStack>
|
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
{/* Middle Side - Primary menu items and other content */}
|
{/* Middle Side - Primary menu items and other content */}
|
||||||
@@ -359,22 +313,12 @@ function SideBarWide({
|
|||||||
* Hamburger menu button + Sheet for menu items
|
* Hamburger menu button + Sheet for menu items
|
||||||
*/
|
*/
|
||||||
function SideBarNarrow({ children }) {
|
function SideBarNarrow({ children }) {
|
||||||
const [brandLogo, setBrandLogo] = useState(null);
|
const { brandLogo, appName } = useShellBrandConfig();
|
||||||
const [appName, setAppName] = useState(null);
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
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);
|
const organizedChildren = useSideBarContent(children);
|
||||||
|
|
||||||
|
useEscapeDismiss(menuOpen, () => setMenuOpen(false));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<XStack
|
<XStack
|
||||||
@@ -393,6 +337,8 @@ function SideBarNarrow({ children }) {
|
|||||||
chromeless
|
chromeless
|
||||||
icon={getIcon('menu')}
|
icon={getIcon('menu')}
|
||||||
color="$textPrimary"
|
color="$textPrimary"
|
||||||
|
accessibilityLabel="Open navigation menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
onPress={() => setMenuOpen(true)}
|
onPress={() => setMenuOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -438,8 +384,11 @@ function SideBarNarrow({ children }) {
|
|||||||
>
|
>
|
||||||
<Sheet.Overlay backgroundColor="$scrim" />
|
<Sheet.Overlay backgroundColor="$scrim" />
|
||||||
<Sheet.Handle />
|
<Sheet.Handle />
|
||||||
<Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel">
|
<Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel" accessibilityLabel="Navigation menu">
|
||||||
<YStack gap="$2" width="100%">
|
<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 */}
|
{/* Primary Menu Items */}
|
||||||
{organizedChildren.primaryMenuItems.map((item) => (
|
{organizedChildren.primaryMenuItems.map((item) => (
|
||||||
<MenuItemButton
|
<MenuItemButton
|
||||||
@@ -494,8 +443,7 @@ function SideBarNarrow({ children }) {
|
|||||||
* @param {string} [props.secondaryStyle='inline'] - Secondary menu rendering: 'stacked' | 'inline' (wide only)
|
* @param {string} [props.secondaryStyle='inline'] - Secondary menu rendering: 'stacked' | 'inline' (wide only)
|
||||||
*/
|
*/
|
||||||
export function SideBar(props) {
|
export function SideBar(props) {
|
||||||
const media = useMedia();
|
const isNarrow = useIsBelowSmBreakpoint();
|
||||||
const isNarrow = !media.gtSm; // Below 801px (sm breakpoint)
|
|
||||||
|
|
||||||
// Use conditional rendering based on screen size
|
// Use conditional rendering based on screen size
|
||||||
if (isNarrow) {
|
if (isNarrow) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { Button, ScrollView, Text, XStack, YStack } from 'tamagui';
|
import { Button, ScrollView, Text, XStack, YStack } from 'tamagui';
|
||||||
import { getIcon } from './IconMapper.jsx';
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
import { useEscapeDismiss } from '../hooks/useEscapeDismiss.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toolbar / footer action button.
|
* Toolbar / footer action button.
|
||||||
@@ -35,6 +37,8 @@ export function SidePanelShell({
|
|||||||
}) {
|
}) {
|
||||||
const [mounted, setMounted] = useState(open);
|
const [mounted, setMounted] = useState(open);
|
||||||
|
|
||||||
|
useEscapeDismiss(open, onClose);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -51,7 +55,7 @@ export function SidePanelShell({
|
|||||||
|
|
||||||
const CloseIcon = getIcon('close');
|
const CloseIcon = getIcon('close');
|
||||||
|
|
||||||
return (
|
const panel = (
|
||||||
<YStack
|
<YStack
|
||||||
position="fixed"
|
position="fixed"
|
||||||
top={0}
|
top={0}
|
||||||
@@ -74,6 +78,9 @@ export function SidePanelShell({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<YStack
|
<YStack
|
||||||
|
role="dialog"
|
||||||
|
aria-modal
|
||||||
|
aria-labelledby="side-panel-shell-title"
|
||||||
position="absolute"
|
position="absolute"
|
||||||
top={0}
|
top={0}
|
||||||
right={0}
|
right={0}
|
||||||
@@ -101,7 +108,7 @@ export function SidePanelShell({
|
|||||||
borderBottomColor="$lineSubtle"
|
borderBottomColor="$lineSubtle"
|
||||||
backgroundColor="$bgPanel"
|
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}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack alignItems="center" gap="$2" flexWrap="wrap" justifyContent="flex-end">
|
<XStack alignItems="center" gap="$2" flexWrap="wrap" justifyContent="flex-end">
|
||||||
@@ -143,6 +150,12 @@ export function SidePanelShell({
|
|||||||
</YStack>
|
</YStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (typeof document === 'undefined' || !document.body) {
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(panel, document.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SidePanelShell;
|
export default SidePanelShell;
|
||||||
|
|||||||
+22
-110
@@ -6,34 +6,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo, useState, useEffect } from 'react';
|
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 { View } from '@tamagui/core';
|
||||||
import { getConfig, setConfig, CONFIG_KEYS } from '../../platform/env.js';
|
import { getRootItem, MenuItem } from '../../platform/menu.js';
|
||||||
import { getRootItem, subscribeToMenuChanges, getMenuVersion, MenuItem } from '../../platform/menu.js';
|
|
||||||
import { MenuItemButton } from './MenuItemButton.jsx';
|
import { MenuItemButton } from './MenuItemButton.jsx';
|
||||||
import { PersonalMenuItem } from './PersonalMenuItem.jsx';
|
import { PersonalMenuItem } from './PersonalMenuItem.jsx';
|
||||||
import { getIcon } from './IconMapper.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';
|
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
|
* Shared logic for organizing children and menu items
|
||||||
* Used by both Wide and Narrow variants
|
* Used by both Wide and Narrow variants
|
||||||
@@ -169,19 +153,7 @@ function TopBarWide({
|
|||||||
middleSideWidth = 0,
|
middleSideWidth = 0,
|
||||||
rightSideWidth = 0
|
rightSideWidth = 0
|
||||||
}) {
|
}) {
|
||||||
const [brandLogo, setBrandLogo] = useState(null);
|
const { brandLogo, appName } = useShellBrandConfig();
|
||||||
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 organizedChildren = useTopBarContent(children);
|
const organizedChildren = useTopBarContent(children);
|
||||||
|
|
||||||
const effectiveRightWidth = rightSideWidth > 0 ? rightSideWidth : (organizedChildren.hasRightSideContent ? 'auto' : 0);
|
const effectiveRightWidth = rightSideWidth > 0 ? rightSideWidth : (organizedChildren.hasRightSideContent ? 'auto' : 0);
|
||||||
@@ -256,38 +228,7 @@ function TopBarWide({
|
|||||||
borderLeftColor="$lineSubtle"
|
borderLeftColor="$lineSubtle"
|
||||||
style={{ flexShrink: 0 }}
|
style={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
{organizedChildren.secondaryMenuItems.length > 0 && (
|
{organizedChildren.sections.rightSide}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</XStack>
|
</XStack>
|
||||||
)}
|
)}
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -299,22 +240,12 @@ function TopBarWide({
|
|||||||
* Hamburger menu button + Sheet for menu items
|
* Hamburger menu button + Sheet for menu items
|
||||||
*/
|
*/
|
||||||
function TopBarNarrow({ children }) {
|
function TopBarNarrow({ children }) {
|
||||||
const [brandLogo, setBrandLogo] = useState(null);
|
const { brandLogo, appName } = useShellBrandConfig();
|
||||||
const [appName, setAppName] = useState(null);
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
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);
|
const organizedChildren = useTopBarContent(children);
|
||||||
|
|
||||||
|
useEscapeDismiss(menuOpen, () => setMenuOpen(false));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<XStack
|
<XStack
|
||||||
@@ -333,6 +264,8 @@ function TopBarNarrow({ children }) {
|
|||||||
chromeless
|
chromeless
|
||||||
icon={getIcon('menu')}
|
icon={getIcon('menu')}
|
||||||
color="$textPrimary"
|
color="$textPrimary"
|
||||||
|
accessibilityLabel="Open navigation menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
onPress={() => setMenuOpen(true)}
|
onPress={() => setMenuOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -353,33 +286,10 @@ function TopBarNarrow({ children }) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Secondary Menu Items - render in topbar, left of personal menu */}
|
{/* Custom right-side slots, secondary menu, and personal menu */}
|
||||||
{organizedChildren.secondaryMenuItems.length > 0 && (
|
{organizedChildren.sections.rightSide.length > 0 && (
|
||||||
<XStack flexShrink={0} alignItems="center" gap="$1">
|
<XStack flexShrink={0} alignItems="center" gap="$1">
|
||||||
{organizedChildren.secondaryMenuItems.map((item) => (
|
{organizedChildren.sections.rightSide}
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
</XStack>
|
</XStack>
|
||||||
)}
|
)}
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -394,8 +304,11 @@ function TopBarNarrow({ children }) {
|
|||||||
>
|
>
|
||||||
<Sheet.Overlay backgroundColor="$scrim" />
|
<Sheet.Overlay backgroundColor="$scrim" />
|
||||||
<Sheet.Handle />
|
<Sheet.Handle />
|
||||||
<Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel">
|
<Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel" accessibilityLabel="Navigation menu">
|
||||||
<YStack gap="$2" width="100%">
|
<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 */}
|
{/* Primary Menu Items - render with vertical orientation in Sheet */}
|
||||||
{organizedChildren.primaryMenuItems.map((item) => (
|
{organizedChildren.primaryMenuItems.map((item) => (
|
||||||
<MenuItemButton
|
<MenuItemButton
|
||||||
@@ -432,8 +345,7 @@ function TopBarNarrow({ children }) {
|
|||||||
* @param {number} [props.rightSideWidth=0] - Right side width (default: 0, wide only)
|
* @param {number} [props.rightSideWidth=0] - Right side width (default: 0, wide only)
|
||||||
*/
|
*/
|
||||||
export function TopBar(props) {
|
export function TopBar(props) {
|
||||||
const media = useMedia();
|
const isNarrow = useIsBelowSmBreakpoint();
|
||||||
const isNarrow = !media.gtSm; // Below 801px (sm breakpoint)
|
|
||||||
|
|
||||||
// Use conditional rendering based on screen size
|
// Use conditional rendering based on screen size
|
||||||
if (isNarrow) {
|
if (isNarrow) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export { StorageAdapter } from './storage/StorageAdapter.js';
|
|||||||
export { OpfsStorageAdapter, default as OpfsStorageAdapterDefault } from './storage/OpfsStorageAdapter.js';
|
export { OpfsStorageAdapter, default as OpfsStorageAdapterDefault } from './storage/OpfsStorageAdapter.js';
|
||||||
export { registerStorageFileView, default as StorageBrowser, default as StorageBrowserDefault } from './storage/StorageBrowser.jsx';
|
export { registerStorageFileView, default as StorageBrowser, default as StorageBrowserDefault } from './storage/StorageBrowser.jsx';
|
||||||
export { registerShell, unregisterShell, resolveRegisteredShell, listRegisteredShells, clearRegisteredShells } from './shell-registry.js';
|
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 * from './grid/index.js';
|
||||||
export { getTypographyRoleProps, getStyleTypography, TYPOGRAPHY_ROLE_KEYS } from '../styles/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',
|
'thin', 'light', 'regular', 'bold', 'fill', 'duotone',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const STYLE_THEMES = {
|
export const DEFAULT_STYLE_THEME = 'fluent-flat';
|
||||||
azure: AzureTheme,
|
|
||||||
'fluent-flat': FluentFlatTheme,
|
|
||||||
apple: AppleTheme,
|
|
||||||
material: MaterialTheme,
|
|
||||||
minimal: MinimalTheme,
|
|
||||||
colorful: ColorfulTheme,
|
|
||||||
};
|
|
||||||
|
|
||||||
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([
|
export const TYPOGRAPHY_ROLE_KEYS = Object.freeze([
|
||||||
'fieldLabel',
|
'fieldLabel',
|
||||||
@@ -161,6 +170,83 @@ const DEFAULT_TYPOGRAPHY_ROLES = Object.freeze({
|
|||||||
|
|
||||||
let activeStyleThemeName = DEFAULT_STYLE_THEME;
|
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.
|
* Validate that a preset implements the contract.
|
||||||
* Logs a warning per missing key per variant; never throws.
|
* 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.
|
* Map arbitrary input (storage, profile, UI) to a registered style theme id.
|
||||||
* Case-insensitive; unknown values fall back to {@link DEFAULT_STYLE_THEME}.
|
* Case-insensitive; unknown values fall back to {@link DEFAULT_STYLE_THEME}.
|
||||||
* @param {string} [name]
|
* @param {string} [name]
|
||||||
* @returns {keyof typeof STYLE_THEMES}
|
* @param {{ fallback?: string }} [options]
|
||||||
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function normalizeStyleThemeName(name) {
|
export function normalizeStyleThemeName(name, { fallback = DEFAULT_STYLE_THEME } = {}) {
|
||||||
const key = typeof name === 'string' ? name.trim().toLowerCase() : '';
|
const key = normalizeStyleThemeId(name);
|
||||||
if (key && STYLE_THEMES[key]) {
|
if (key && styleThemeRegistry.has(key)) {
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
const resolvedFallback = normalizeStyleThemeId(fallback);
|
||||||
|
if (resolvedFallback && styleThemeRegistry.has(resolvedFallback)) {
|
||||||
|
return resolvedFallback;
|
||||||
|
}
|
||||||
return DEFAULT_STYLE_THEME;
|
return DEFAULT_STYLE_THEME;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a style theme by name.
|
* 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
|
* @returns {Object} Theme configuration
|
||||||
*/
|
*/
|
||||||
export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
|
export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
|
||||||
const key = normalizeStyleThemeName(themeName);
|
const key = normalizeStyleThemeName(themeName);
|
||||||
return STYLE_THEMES[key];
|
return styleThemeRegistry.get(key) || styleThemeRegistry.get(DEFAULT_STYLE_THEME);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setActiveStyleThemeName(themeName) {
|
export function setActiveStyleThemeName(themeName) {
|
||||||
@@ -257,7 +348,7 @@ export function getTypographyRoleProps(role, overrides = null, themeName = activ
|
|||||||
* @returns {string[]} Array of theme names
|
* @returns {string[]} Array of theme names
|
||||||
*/
|
*/
|
||||||
export function getStyleThemeNames() {
|
export function getStyleThemeNames() {
|
||||||
return Object.keys(STYLE_THEMES);
|
return listStyleThemes();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
body {
|
body {
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #333;
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+51
-1
@@ -7,13 +7,17 @@ import { test, describe, beforeEach } from 'node:test';
|
|||||||
import assert from 'node:assert';
|
import assert from 'node:assert';
|
||||||
import {
|
import {
|
||||||
initEnv,
|
initEnv,
|
||||||
getConfig,
|
getConfig,
|
||||||
|
getConfigSync,
|
||||||
setConfig,
|
setConfig,
|
||||||
getConfigDict,
|
getConfigDict,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
isProduction,
|
isProduction,
|
||||||
|
isServiceWorkerEnabledSync,
|
||||||
|
resolveServiceWorkerEnabled,
|
||||||
CONFIG_KEYS
|
CONFIG_KEYS
|
||||||
} from '../src/platform/env.js';
|
} from '../src/platform/env.js';
|
||||||
|
import { getProvider } from '../src/platform/storage.js';
|
||||||
|
|
||||||
describe('env.js', () => {
|
describe('env.js', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -90,6 +94,34 @@ describe('env.js', () => {
|
|||||||
// May return null or undefined depending on import.meta.env availability
|
// May return null or undefined depending on import.meta.env availability
|
||||||
assert.ok(value === null || value === undefined);
|
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', () => {
|
describe('setConfig', () => {
|
||||||
@@ -148,6 +180,24 @@ describe('env.js', () => {
|
|||||||
assert.ok('STORAGE_BACKEND' in CONFIG_KEYS);
|
assert.ok('STORAGE_BACKEND' in CONFIG_KEYS);
|
||||||
assert.ok('API_BASE_URL' in CONFIG_KEYS);
|
assert.ok('API_BASE_URL' in CONFIG_KEYS);
|
||||||
assert.ok('MODULES' 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') {
|
if (id === 'react' || id === 'react/jsx-runtime' || id === 'react-dom' || id === 'react-dom/client') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (id === '@phosphor-icons/react' || id.startsWith('@phosphor-icons/react/')) return true;
|
||||||
if (id === 'tamagui' || id.startsWith('tamagui/')) return true;
|
if (id === 'tamagui' || id.startsWith('tamagui/')) return true;
|
||||||
if (id.startsWith('@tamagui/')) return true;
|
if (id.startsWith('@tamagui/')) return true;
|
||||||
return false;
|
return false;
|
||||||
@@ -28,7 +29,12 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
lib: {
|
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']
|
formats: ['es']
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
|||||||
Reference in New Issue
Block a user