Update bface UI and security work
This commit is contained in:
+220
-73
@@ -1,38 +1,154 @@
|
||||
/**
|
||||
* API Client
|
||||
* Fetch wrappers for /api/* endpoints with offline support
|
||||
* Shared fetch wrapper for /api/* endpoints with consistent parsing and errors.
|
||||
*/
|
||||
|
||||
class APIClient {
|
||||
constructor(baseURL = '/api') {
|
||||
export class APIError extends Error {
|
||||
constructor(message, details = {}) {
|
||||
super(message);
|
||||
this.name = 'APIError';
|
||||
this.status = details.status || 0;
|
||||
this.payload = details.payload;
|
||||
this.response = details.response || null;
|
||||
this.url = details.url || '';
|
||||
}
|
||||
}
|
||||
|
||||
class NetworkActivityManager {
|
||||
constructor({ delayMs = 250 } = {}) {
|
||||
this.delayMs = delayMs;
|
||||
this.activeCount = 0;
|
||||
this.visible = false;
|
||||
this.listeners = new Set();
|
||||
this._nextId = 0;
|
||||
this._timer = null;
|
||||
}
|
||||
|
||||
subscribe(listener) {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
activeCount: this.activeCount,
|
||||
visible: this.visible,
|
||||
pending: this.activeCount > 0
|
||||
};
|
||||
}
|
||||
|
||||
_emit() {
|
||||
const snapshot = this.getState();
|
||||
this.listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(snapshot);
|
||||
} catch (error) {
|
||||
console.warn('[API] Network activity listener failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
beginRequest() {
|
||||
const id = ++this._nextId;
|
||||
this.activeCount += 1;
|
||||
|
||||
if (this.activeCount === 1) {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
}
|
||||
this._timer = setTimeout(() => {
|
||||
this._timer = null;
|
||||
if (this.activeCount > 0 && !this.visible) {
|
||||
this.visible = true;
|
||||
this._emit();
|
||||
}
|
||||
}, this.delayMs);
|
||||
}
|
||||
|
||||
this._emit();
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
this.activeCount = Math.max(0, this.activeCount - 1);
|
||||
if (this.activeCount === 0) {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
if (this.visible) {
|
||||
this.visible = false;
|
||||
}
|
||||
}
|
||||
this._emit();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isFormDataBody(body) {
|
||||
return typeof FormData !== 'undefined' && body instanceof FormData;
|
||||
}
|
||||
|
||||
async function parseResponsePayload(response) {
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = String(response.headers?.get?.('content-type') || '').toLowerCase();
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function createAPIError(response, payload, url) {
|
||||
const message = typeof payload === 'object' && payload?.detail
|
||||
? payload.detail
|
||||
: `Request failed: ${response?.status || 0}`;
|
||||
return new APIError(message, {
|
||||
status: response?.status || 0,
|
||||
payload,
|
||||
response,
|
||||
url
|
||||
});
|
||||
}
|
||||
|
||||
export class APIClient {
|
||||
constructor(baseURL = '/api', sharedInterceptors = null) {
|
||||
this.baseURL = baseURL;
|
||||
this.interceptors = {
|
||||
this.interceptors = sharedInterceptors || {
|
||||
request: [],
|
||||
response: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add request interceptor
|
||||
* @param {Function} interceptor - (config) => config
|
||||
*/
|
||||
addRequestInterceptor(interceptor) {
|
||||
this.interceptors.request.push(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add response interceptor
|
||||
* @param {Function} interceptor - (response) => response
|
||||
*/
|
||||
addResponseInterceptor(interceptor) {
|
||||
this.interceptors.response.push(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute request interceptors
|
||||
* @param {RequestInit} config
|
||||
* @returns {RequestInit}
|
||||
*/
|
||||
_applyRequestInterceptors(config) {
|
||||
return this.interceptors.request.reduce(
|
||||
(acc, interceptor) => interceptor(acc),
|
||||
@@ -40,11 +156,6 @@ class APIClient {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute response interceptors
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
_applyResponseInterceptors(response) {
|
||||
return this.interceptors.response.reduce(
|
||||
(acc, interceptor) => interceptor(acc),
|
||||
@@ -52,65 +163,95 @@ class APIClient {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an endpoint against the configured API base URL
|
||||
* @param {string} endpoint
|
||||
* @returns {string}
|
||||
*/
|
||||
resolveURL(endpoint = '') {
|
||||
return `${this.baseURL}${endpoint}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the configured API base URL for future requests.
|
||||
* @param {string} baseURL
|
||||
*/
|
||||
setBaseURL(baseURL = '/api') {
|
||||
this.baseURL = baseURL || '/api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Make API request
|
||||
* @param {string} endpoint
|
||||
* @param {RequestInit} options
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
scope(baseURL = '/api') {
|
||||
return new APIClient(baseURL, this.interceptors);
|
||||
}
|
||||
|
||||
_buildHeaders(options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
const body = options.body;
|
||||
if (!isFormDataBody(body) && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const url = this.resolveURL(endpoint);
|
||||
const config = this._applyRequestInterceptors({
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
const trackActivity = options.trackActivity !== false;
|
||||
const releaseActivity = trackActivity ? networkActivityManager.beginRequest() : null;
|
||||
|
||||
try {
|
||||
const config = this._applyRequestInterceptors({
|
||||
...options,
|
||||
headers: this._buildHeaders(options)
|
||||
});
|
||||
const response = await fetch(url, config);
|
||||
return this._applyResponseInterceptors(response);
|
||||
} catch (error) {
|
||||
// TODO: Implement offline queue management
|
||||
throw error;
|
||||
} finally {
|
||||
releaseActivity?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request
|
||||
* @param {string} endpoint
|
||||
* @param {RequestInit} options
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async requestJSON(endpoint, options = {}) {
|
||||
const response = await this.request(endpoint, options);
|
||||
const payload = await parseResponsePayload(response);
|
||||
if (!response.ok) {
|
||||
throw createAPIError(response, payload, this.resolveURL(endpoint));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async requestText(endpoint, options = {}) {
|
||||
const response = await this.request(endpoint, options);
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
let payload = text;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
// Keep text payload.
|
||||
}
|
||||
throw createAPIError(response, payload, this.resolveURL(endpoint));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async requestBlob(endpoint, options = {}) {
|
||||
const response = await this.request(endpoint, options);
|
||||
if (!response.ok) {
|
||||
const payload = await parseResponsePayload(response);
|
||||
throw createAPIError(response, payload, this.resolveURL(endpoint));
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async get(endpoint, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request
|
||||
* @param {string} endpoint
|
||||
* @param {any} data
|
||||
* @param {RequestInit} options
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async getJSON(endpoint, options = {}) {
|
||||
return this.requestJSON(endpoint, { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
async getText(endpoint, options = {}) {
|
||||
return this.requestText(endpoint, { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
async getBlob(endpoint, options = {}) {
|
||||
return this.requestBlob(endpoint, { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
async post(endpoint, data, options = {}) {
|
||||
return this.request(endpoint, {
|
||||
...options,
|
||||
@@ -119,13 +260,14 @@ class APIClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request
|
||||
* @param {string} endpoint
|
||||
* @param {any} data
|
||||
* @param {RequestInit} options
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async postJSON(endpoint, data, options = {}) {
|
||||
return this.requestJSON(endpoint, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: isFormDataBody(data) ? data : JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async put(endpoint, data, options = {}) {
|
||||
return this.request(endpoint, {
|
||||
...options,
|
||||
@@ -134,17 +276,22 @@ class APIClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
* @param {string} endpoint
|
||||
* @param {RequestInit} options
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async putJSON(endpoint, data, options = {}) {
|
||||
return this.requestJSON(endpoint, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: isFormDataBody(data) ? data : JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
async delete(endpoint, options = {}) {
|
||||
return this.request(endpoint, { ...options, method: 'DELETE' });
|
||||
}
|
||||
|
||||
async deleteJSON(endpoint, options = {}) {
|
||||
return this.requestJSON(endpoint, { ...options, method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const api = new APIClient();
|
||||
|
||||
export const networkActivityManager = new NetworkActivityManager();
|
||||
|
||||
+25
-3
@@ -81,8 +81,10 @@ export const CONFIG_KEYS = {
|
||||
APP_NAME: 'APP_NAME',
|
||||
APP_DISPLAY_NAME: 'APP_DISPLAY_NAME',
|
||||
APP_DESCRIPTION: 'APP_DESCRIPTION',
|
||||
FAVICON: 'FAVICON',
|
||||
BRAND_LOGO: 'BRAND_LOGO',
|
||||
THEME_COLOR: 'THEME_COLOR',
|
||||
BACKGROUND_COLOR: 'BACKGROUND_COLOR',
|
||||
UI_SHELL: 'UI_SHELL',
|
||||
INITIAL_ROUTE: 'INITIAL_ROUTE',
|
||||
STORAGE_BACKEND: 'STORAGE_BACKEND',
|
||||
@@ -150,8 +152,10 @@ export function initEnv(appConfig) {
|
||||
APP_NAME: appConfig.id || appConfig.name,
|
||||
APP_DISPLAY_NAME: appConfig.displayName || appConfig.short_name || appConfig.name,
|
||||
APP_DESCRIPTION: appConfig.description || '',
|
||||
FAVICON: appConfig.favicon || appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg',
|
||||
BRAND_LOGO: appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg',
|
||||
THEME_COLOR: appConfig.theme_color || appConfig.themeColor || '#000000',
|
||||
BACKGROUND_COLOR: appConfig.background_color || appConfig.backgroundColor || '#ffffff',
|
||||
UI_SHELL: appConfig.ui_shell || appConfig.uiShell || 'EmptyShell',
|
||||
INITIAL_ROUTE: appConfig.initial_route || appConfig.initialRoute || appConfig.ui?.initial_route || appConfig.ui?.initialRoute || '/home',
|
||||
STORAGE_BACKEND: appConfig.storage?.backend || 'localStorage',
|
||||
@@ -318,6 +322,18 @@ function setMetaTag(name, content) {
|
||||
element.setAttribute('content', content);
|
||||
}
|
||||
|
||||
export function setDocumentBackground(color) {
|
||||
if (typeof document === 'undefined' || !color) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.style.backgroundColor = color;
|
||||
|
||||
if (document.body) {
|
||||
document.body.style.backgroundColor = color;
|
||||
}
|
||||
}
|
||||
|
||||
function setFavicon(href) {
|
||||
if (typeof document === 'undefined' || !href) {
|
||||
return;
|
||||
@@ -344,25 +360,31 @@ export async function syncDocumentHeadFromConfig(options = {}) {
|
||||
const {
|
||||
titleFallback = 'PWA Template',
|
||||
descriptionFallback = '',
|
||||
themeColorFallback = '#000000'
|
||||
themeColorFallback = '#000000',
|
||||
backgroundColorFallback = '#ffffff'
|
||||
} = options;
|
||||
|
||||
const [title, description, themeColor, brandLogo] = await Promise.all([
|
||||
const [favicon, title, description, themeColor, backgroundColor, brandLogo] = await Promise.all([
|
||||
getConfig(CONFIG_KEYS.FAVICON, '/favicon.svg'),
|
||||
getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, titleFallback),
|
||||
getConfig(CONFIG_KEYS.APP_DESCRIPTION, descriptionFallback),
|
||||
getConfig(CONFIG_KEYS.THEME_COLOR, themeColorFallback),
|
||||
getConfig(CONFIG_KEYS.BACKGROUND_COLOR, backgroundColorFallback),
|
||||
getConfig(CONFIG_KEYS.BRAND_LOGO, '/favicon.svg')
|
||||
]);
|
||||
|
||||
document.title = title || titleFallback;
|
||||
setMetaTag('description', description || descriptionFallback);
|
||||
setMetaTag('theme-color', themeColor || themeColorFallback);
|
||||
setFavicon(brandLogo || '/favicon.svg');
|
||||
setDocumentBackground(backgroundColor || backgroundColorFallback);
|
||||
setFavicon(favicon || '/favicon.svg');
|
||||
|
||||
return {
|
||||
title: title || titleFallback,
|
||||
description: description || descriptionFallback,
|
||||
themeColor: themeColor || themeColorFallback,
|
||||
backgroundColor: backgroundColor || backgroundColorFallback,
|
||||
favicon: favicon || '/favicon.svg',
|
||||
brandLogo: brandLogo || '/favicon.svg'
|
||||
};
|
||||
}
|
||||
|
||||
+13
-2
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { normalizeRightsInput } from '../security/model/rights.js';
|
||||
import { evaluateAuthRequirements } from '../security/runtime/access-rules.js';
|
||||
import { getProvider } from './storage.js';
|
||||
|
||||
// Menu root structure: Map of root directory IDs to MenuItem instances
|
||||
@@ -138,6 +139,7 @@ export class MenuItem {
|
||||
* @param {boolean} [config.is_active=true] - Whether menu item is active/enabled
|
||||
* @param {string} [config.style='both'] - Display style: 'both', 'label_only', or 'icon_only'
|
||||
* @param {string} [config.with_permits=''] - Comma-delimited permissions required (e.g., "read,write")
|
||||
* @param {Object} [config.auth] - Auth visibility requirements
|
||||
* @param {Object} [config.attrs={}] - Additional attributes
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
@@ -196,6 +198,7 @@ export class MenuItem {
|
||||
? [...config.tags]
|
||||
: (typeof config.tags === 'string' ? [config.tags] : []);
|
||||
this.visible_when_permitted = config.visible_when_permitted || null;
|
||||
this.auth = config.auth || null;
|
||||
|
||||
// Validate style
|
||||
const validStyles = ['both', 'label_only', 'icon_only'];
|
||||
@@ -223,7 +226,7 @@ export class MenuItem {
|
||||
|
||||
// Copy any other properties
|
||||
Object.keys(config).forEach(key => {
|
||||
if (!['id', 'label', 'tag', 'icon', 'invoke', 'invoke_type', 'invoke_target', 'handler', 'items', 'path', 'is_active', 'is_visible', 'style', 'with_permits', 'tags', 'visible_when_permitted', 'attrs'].includes(key)) {
|
||||
if (!['id', 'label', 'tag', 'icon', 'invoke', 'invoke_type', 'invoke_target', 'handler', 'items', 'path', 'is_active', 'is_visible', 'style', 'with_permits', 'tags', 'visible_when_permitted', 'auth', 'attrs'].includes(key)) {
|
||||
this[key] = config[key];
|
||||
}
|
||||
});
|
||||
@@ -255,11 +258,12 @@ export class MenuItem {
|
||||
with_permits: this.with_permits,
|
||||
tags: [...this.tags],
|
||||
visible_when_permitted: this.visible_when_permitted ? { ...this.visible_when_permitted } : null,
|
||||
auth: this.auth ? { ...this.auth } : null,
|
||||
...this.attrs,
|
||||
// Include any additional properties
|
||||
...Object.fromEntries(
|
||||
Object.entries(this).filter(([key]) =>
|
||||
!['id', 'label', 'tag', 'icon', 'invoke', 'invoke_type', 'invoke_target', 'items', 'path', 'is_active', 'is_visible', 'style', 'with_permits', 'tags', 'visible_when_permitted', 'attrs'].includes(key)
|
||||
!['id', 'label', 'tag', 'icon', 'invoke', 'invoke_type', 'invoke_target', 'items', 'path', 'is_active', 'is_visible', 'style', 'with_permits', 'tags', 'visible_when_permitted', 'auth', 'attrs'].includes(key)
|
||||
)
|
||||
)
|
||||
};
|
||||
@@ -392,6 +396,13 @@ export class MenuItem {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPermitted(security = null) {
|
||||
if (this.auth && typeof this.auth === 'object') {
|
||||
const authResult = evaluateAuthRequirements(security, this.auth);
|
||||
if (!authResult.allowed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const rule = this.getPermissionVisibilityRule();
|
||||
if (!rule) {
|
||||
return true;
|
||||
|
||||
@@ -5,7 +5,13 @@ export class Permit {
|
||||
this.id = data.id || null;
|
||||
this.principal_type = data.principal_type || 'role';
|
||||
this.principal_id = data.principal_id || '';
|
||||
this.resource_path = data.resource_path || '*';
|
||||
this.principal_name = data.principal_name || '';
|
||||
this.principal_display = data.principal_display || '';
|
||||
this.subject_id = data.subject_id || null;
|
||||
this.subject_kind = data.subject_kind || '';
|
||||
this.subject_name = data.subject_name || '';
|
||||
this.subject_display = data.subject_display || '';
|
||||
this.resource_path = data.resource_path || data.subject_name || '*';
|
||||
this.rights = normalizeRightsInput(data.rights || 0);
|
||||
this.effect = data.effect || 'allow';
|
||||
this.created_on = data.created_on || new Date().toISOString();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export class Securable {
|
||||
constructor(data = {}) {
|
||||
this.id = data.id || null;
|
||||
this.kind = data.kind || 'Securable';
|
||||
this.name = data.name || '';
|
||||
this.display_name = data.display_name || data.displayName || data.name || '';
|
||||
this.realm_id = data.realm_id || 'local';
|
||||
this.created_on = data.created_on || new Date().toISOString();
|
||||
this.updated_on = data.updated_on || this.created_on;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export class Session {
|
||||
this.issued_on = data.issued_on || new Date().toISOString();
|
||||
this.expires_on = data.expires_on || null;
|
||||
this.auth_provider = data.auth_provider || 'basic';
|
||||
this.claims = data.claims || {};
|
||||
this.status = data.status || 'active';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export class User {
|
||||
this.image_url = data.image_url || '';
|
||||
this.realm_id = data.realm_id || 'local';
|
||||
this.role_ids = Array.isArray(data.role_ids) ? [...data.role_ids] : [];
|
||||
this.role_names = Array.isArray(data.role_names) ? [...data.role_names] : [];
|
||||
this.is_admin = Boolean(data.is_admin);
|
||||
this.status = data.status || 'active';
|
||||
this.password_hash = data.password_hash || '';
|
||||
this.created_on = data.created_on || new Date().toISOString();
|
||||
|
||||
@@ -3,6 +3,7 @@ export { Permit } from './Permit.js';
|
||||
export { Realm } from './Realm.js';
|
||||
export { Resource } from './Resource.js';
|
||||
export { Role } from './Role.js';
|
||||
export { Securable } from './Securable.js';
|
||||
export { Session } from './Session.js';
|
||||
export { User } from './User.js';
|
||||
export {
|
||||
|
||||
@@ -7,6 +7,13 @@ export const SECURITY_RIGHTS = {
|
||||
};
|
||||
|
||||
export const SECURITY_RIGHT_NAMES = Object.keys(SECURITY_RIGHTS);
|
||||
export const SECURITY_RIGHT_SHORT_LABELS = {
|
||||
read: 'R',
|
||||
write: 'W',
|
||||
delete: 'D',
|
||||
execute: 'X',
|
||||
secure: 'S'
|
||||
};
|
||||
|
||||
export function normalizeRightsInput(rights = 0) {
|
||||
if (typeof rights === 'number' && Number.isFinite(rights)) {
|
||||
|
||||
@@ -97,9 +97,18 @@ export function AccountProfilePage() {
|
||||
if (!selection?.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof security.uploadAccountAvatar === 'function' && security.user?.id) {
|
||||
const profilePatch = await security.uploadAccountAvatar(selection.file);
|
||||
setForm((state) => ({ ...state, image_url: profilePatch?.image_url || selection.result || '' }));
|
||||
setUploadMessage(`Uploaded ${selection.file.name}`);
|
||||
} else {
|
||||
setForm((state) => ({ ...state, image_url: selection.result || '' }));
|
||||
setUploadMessage(`Selected ${selection.file.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
setUploadMessage(error?.message || `Failed to upload ${selection.file.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const clearProfileImage = () => {
|
||||
@@ -148,7 +157,7 @@ export function AccountProfilePage() {
|
||||
gap="$3"
|
||||
padding="$4"
|
||||
backgroundColor="$accentSurface"
|
||||
borderRadius="$5"
|
||||
borderRadius="$radiusLg"
|
||||
borderWidth={1}
|
||||
borderColor="$accentBorder"
|
||||
alignItems="center"
|
||||
@@ -187,7 +196,7 @@ export function AccountProfilePage() {
|
||||
Select Image
|
||||
</Button>
|
||||
{form.image_url ? (
|
||||
<Button size="$3" chromeless onPress={clearProfileImage}>
|
||||
<Button size="$3" onPress={clearProfileImage}>
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ErrorPage({
|
||||
gap="$4"
|
||||
borderWidth={1}
|
||||
borderColor="$accentBorder"
|
||||
borderRadius="$6"
|
||||
borderRadius="$radiusLg"
|
||||
backgroundColor="$accentSurface"
|
||||
shadowColor="$shadowColor"
|
||||
shadowOpacity={0.18}
|
||||
@@ -49,7 +49,7 @@ export function ErrorPage({
|
||||
maxHeight={260}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
borderRadius="$4"
|
||||
borderRadius="$radiusMd"
|
||||
backgroundColor="$background"
|
||||
padding="$4"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { forwardRef, useRef, useState } from 'react';
|
||||
import { Button, Input, Label, Paragraph, Text, YStack } from 'tamagui';
|
||||
import { getRouterPath, setRouterPath } from '../../platform/compat.js';
|
||||
import React, { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Adapt, Button, Dialog, Image, Input, Label, Paragraph, Sheet, Text, XStack, YStack } from 'tamagui';
|
||||
import { getRouterPath, scheduleTimeout, setRouterPath } from '../../platform/compat.js';
|
||||
import { securityService, useSecurityState } from '../runtime/security-service.js';
|
||||
import { Panel } from '../../ui/components/Panel.jsx';
|
||||
import { CONFIG_KEYS, getConfig } from '../../platform/env.js';
|
||||
|
||||
const LoginField = forwardRef(function LoginField({ id, label, error, ...props }, ref) {
|
||||
return (
|
||||
@@ -22,12 +23,60 @@ const LoginField = forwardRef(function LoginField({ id, label, error, ...props }
|
||||
);
|
||||
});
|
||||
|
||||
export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign in to continue' }) {
|
||||
async function resolvePostLoginTarget(security) {
|
||||
const loginRoute = security.config.login_route || '/login';
|
||||
const historyState = typeof window !== 'undefined' && window.history ? window.history.state : null;
|
||||
const redirectTo = historyState && typeof historyState === 'object' ? historyState.redirect_to : null;
|
||||
if (redirectTo && redirectTo !== loginRoute) {
|
||||
return redirectTo;
|
||||
}
|
||||
const currentPath = await getRouterPath('/home');
|
||||
if (currentPath && currentPath !== loginRoute && !currentPath.startsWith(`${loginRoute}/`)) {
|
||||
return currentPath;
|
||||
}
|
||||
return '/home';
|
||||
}
|
||||
|
||||
function normalizeInternalPath(target) {
|
||||
const raw = String(target || '').trim();
|
||||
if (!raw) {
|
||||
return '/login';
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(raw, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
|
||||
return `${parsed.pathname || ''}${parsed.search || ''}${parsed.hash || ''}` || '/login';
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function LoginForm({ compact = false, subtitle = '', onComplete = null }) {
|
||||
const security = useSecurityState();
|
||||
const passwordInputRef = useRef(null);
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [brandLogo, setBrandLogo] = useState('');
|
||||
const [appName, setAppName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
async function loadBranding() {
|
||||
const [logo, name] = await Promise.all([
|
||||
getConfig(CONFIG_KEYS.BRAND_LOGO, ''),
|
||||
getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, ''),
|
||||
]);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setBrandLogo(String(logo || ''));
|
||||
setAppName(String(name || ''));
|
||||
}
|
||||
loadBranding();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setErrorMessage('');
|
||||
@@ -44,10 +93,21 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
|
||||
username: identifier,
|
||||
password
|
||||
});
|
||||
const currentPath = await getRouterPath('/home');
|
||||
if (currentPath === (security.config.login_route || '/login')) {
|
||||
await setRouterPath('/home', true);
|
||||
const targetPath = await resolvePostLoginTarget(security);
|
||||
if (!compact || targetPath === '/home') {
|
||||
await setRouterPath(targetPath, true, { state: null });
|
||||
if (!compact) {
|
||||
scheduleTimeout(() => {
|
||||
setRouterPath(targetPath, true, { state: null }).catch(() => {});
|
||||
}, 50);
|
||||
}
|
||||
} else {
|
||||
const currentPath = await getRouterPath('/home');
|
||||
if (!currentPath || currentPath === (security.config.login_route || '/login')) {
|
||||
await setRouterPath(targetPath, true, { state: null });
|
||||
}
|
||||
}
|
||||
await onComplete?.({ targetPath });
|
||||
} catch (error) {
|
||||
setErrorMessage(error.message || 'Login failed');
|
||||
}
|
||||
@@ -61,28 +121,43 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
|
||||
handleSubmit();
|
||||
};
|
||||
|
||||
const handleForgotPassword = async () => {
|
||||
await setRouterPath('/login/reset', true, { state: typeof window !== 'undefined' && window.history ? window.history.state : null });
|
||||
};
|
||||
|
||||
const handleFormSubmit = (event) => {
|
||||
event?.preventDefault?.();
|
||||
handleSubmit();
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Panel
|
||||
icon="login"
|
||||
title={title}
|
||||
width="100%"
|
||||
headerFront={{ color: '$textPrimary' }}
|
||||
headerBack={{ backgroundColor: '$bgPanel' }}
|
||||
>
|
||||
return (
|
||||
<YStack
|
||||
tag="form"
|
||||
gap="$4"
|
||||
onSubmit={handleFormSubmit}
|
||||
autoComplete="on"
|
||||
>
|
||||
<Paragraph color="$textMuted">
|
||||
<YStack alignItems="center" gap="$3" paddingBottom="$2">
|
||||
<XStack alignItems="center" justifyContent="center" gap="$3" width="100%">
|
||||
{brandLogo ? (
|
||||
<Image
|
||||
source={{ uri: brandLogo }}
|
||||
width={48}
|
||||
height={48}
|
||||
borderRadius="$radiusMd"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : null}
|
||||
<Text fontSize="$8" fontWeight="700" color="$textPrimary" textAlign="center">
|
||||
{appName || 'Account'}
|
||||
</Text>
|
||||
</XStack>
|
||||
{subtitle ? (
|
||||
<Paragraph color="$textMuted" textAlign="center">
|
||||
{subtitle}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</YStack>
|
||||
<LoginField
|
||||
id="login-identifier"
|
||||
label="Username or email"
|
||||
@@ -141,17 +216,273 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
|
||||
Security is still initializing.
|
||||
</Paragraph>
|
||||
) : null}
|
||||
<Paragraph fontSize="$3" color="$textMuted">
|
||||
<XStack alignItems="center" justifyContent="space-between" gap="$3">
|
||||
<Paragraph fontSize="$3" color="$textMuted" flex={1}>
|
||||
Demo credentials: admin / admin or demo / demo
|
||||
</Paragraph>
|
||||
<Button chromeless paddingHorizontal={0} minHeight={0} height="auto" onPress={handleForgotPassword}>
|
||||
Forgot password?
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return content;
|
||||
export function LoginResetForm({
|
||||
mode = 'request',
|
||||
token = '',
|
||||
subtitle = '',
|
||||
onRequestReset = null,
|
||||
onCompleteReset = null,
|
||||
}) {
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [feedbackMessage, setFeedbackMessage] = useState('');
|
||||
const [previewLink, setPreviewLink] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const effectiveSubtitle = subtitle || (mode === 'complete'
|
||||
? 'Choose a new password for your account.'
|
||||
: 'Enter your username or email to request a password reset link.');
|
||||
|
||||
const handleBackToLogin = async () => {
|
||||
await setRouterPath('/login', true, { state: null });
|
||||
};
|
||||
|
||||
const handleRequest = async () => {
|
||||
setErrorMessage('');
|
||||
setFeedbackMessage('');
|
||||
setPreviewLink('');
|
||||
if (!identifier.trim()) {
|
||||
setErrorMessage('Please enter your username or email.');
|
||||
return;
|
||||
}
|
||||
if (typeof onRequestReset !== 'function') {
|
||||
setErrorMessage('Password reset is not configured for this application.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await onRequestReset({ identifier: identifier.trim() });
|
||||
setFeedbackMessage(String(result?.message || 'If the account exists, reset instructions are ready.'));
|
||||
setPreviewLink(String(result?.preview_reset_link || ''));
|
||||
} catch (error) {
|
||||
setErrorMessage(error?.message || 'Password reset request failed.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
setErrorMessage('');
|
||||
setFeedbackMessage('');
|
||||
if (!token) {
|
||||
setErrorMessage('This reset link is missing a token.');
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setErrorMessage('Please enter a new password.');
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setErrorMessage('The passwords do not match.');
|
||||
return;
|
||||
}
|
||||
if (typeof onCompleteReset !== 'function') {
|
||||
setErrorMessage('Password reset completion is not configured for this application.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await onCompleteReset({ token, new_password: password });
|
||||
setFeedbackMessage(String(result?.message || 'Your password has been updated. You can now sign in.'));
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (error) {
|
||||
setErrorMessage(error?.message || 'Password reset failed.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === 'complete') {
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<Paragraph color="$textMuted">{effectiveSubtitle}</Paragraph>
|
||||
<LoginField
|
||||
id="reset-password"
|
||||
label="New password"
|
||||
error={Boolean(errorMessage)}
|
||||
placeholder="New password"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
secureTextEntry
|
||||
autoComplete="new-password"
|
||||
textContentType="newPassword"
|
||||
/>
|
||||
<LoginField
|
||||
id="reset-password-confirm"
|
||||
label="Confirm password"
|
||||
error={Boolean(errorMessage)}
|
||||
placeholder="Confirm password"
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
secureTextEntry
|
||||
autoComplete="new-password"
|
||||
textContentType="newPassword"
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<Text color="$danger" fontSize="$4">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
{feedbackMessage ? (
|
||||
<Text color="$success" fontSize="$4">
|
||||
{feedbackMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
<Button theme="accent" onPress={handleComplete} disabled={submitting}>
|
||||
{submitting ? 'Updating Password...' : 'Set New Password'}
|
||||
</Button>
|
||||
<Button chromeless alignSelf="flex-start" paddingHorizontal={0} onPress={handleBackToLogin}>
|
||||
Back to login
|
||||
</Button>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<Paragraph color="$textMuted">{effectiveSubtitle}</Paragraph>
|
||||
<LoginField
|
||||
id="reset-identifier"
|
||||
label="Username or email"
|
||||
error={Boolean(errorMessage)}
|
||||
placeholder="Username or email"
|
||||
value={identifier}
|
||||
onChangeText={setIdentifier}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
autoComplete="username"
|
||||
textContentType="username"
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<Text color="$danger" fontSize="$4">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
{feedbackMessage ? (
|
||||
<YStack gap="$2">
|
||||
<Text color="$success" fontSize="$4">
|
||||
{feedbackMessage}
|
||||
</Text>
|
||||
{previewLink ? (
|
||||
<Button chromeless alignSelf="flex-start" paddingHorizontal={0} onPress={() => setRouterPath(normalizeInternalPath(previewLink), true, { state: null })}>
|
||||
Open preview reset link
|
||||
</Button>
|
||||
) : null}
|
||||
</YStack>
|
||||
) : null}
|
||||
<Button theme="accent" onPress={handleRequest} disabled={submitting}>
|
||||
{submitting ? 'Requesting Reset...' : 'Request Password Reset'}
|
||||
</Button>
|
||||
<Button chromeless alignSelf="flex-start" paddingHorizontal={0} onPress={handleBackToLogin}>
|
||||
Back to login
|
||||
</Button>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPanel({
|
||||
title = 'Login',
|
||||
subtitle = '',
|
||||
compact = false,
|
||||
onComplete = null,
|
||||
mode = 'login',
|
||||
resetToken = '',
|
||||
onRequestReset = null,
|
||||
onCompleteReset = null,
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (mode === 'reset-request') {
|
||||
return <LoginResetForm mode="request" subtitle={subtitle} onRequestReset={onRequestReset} />;
|
||||
}
|
||||
if (mode === 'reset-complete') {
|
||||
return <LoginResetForm mode="complete" token={resetToken} subtitle={subtitle} onCompleteReset={onCompleteReset} />;
|
||||
}
|
||||
return <LoginForm compact={compact} subtitle={subtitle} onComplete={onComplete} />;
|
||||
}, [compact, mode, onComplete, onCompleteReset, onRequestReset, resetToken, subtitle]);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
icon="login"
|
||||
title={title}
|
||||
width="100%"
|
||||
headerFront={{ color: '$textPrimary' }}
|
||||
headerBack={{ backgroundColor: '$bgPanel' }}
|
||||
>
|
||||
{content}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginDialog({ open = true, title = 'Login', subtitle = 'This route requires an authenticated user.' }) {
|
||||
return (
|
||||
<Dialog modal open={open}>
|
||||
<Adapt when="sm" platform="touch">
|
||||
<Sheet modal dismissOnSnapToBottom={false} snapPoints={[85]} zIndex={22000}>
|
||||
<Sheet.Frame backgroundColor="$bgPage" padding="$4">
|
||||
<Adapt.Contents />
|
||||
</Sheet.Frame>
|
||||
<Sheet.Overlay backgroundColor="$scrim" />
|
||||
</Sheet>
|
||||
</Adapt>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
key="overlay"
|
||||
backgroundColor="$scrim"
|
||||
opacity={0.7}
|
||||
/>
|
||||
<Dialog.Content
|
||||
key="content"
|
||||
bordered
|
||||
elevate
|
||||
backgroundColor="transparent"
|
||||
borderWidth={0}
|
||||
shadowOpacity={0}
|
||||
padding={0}
|
||||
width="100%"
|
||||
maxWidth={520}
|
||||
>
|
||||
<LoginPanel title={title} subtitle={subtitle} compact onComplete={() => {}} />
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginPage({ title = 'Login', subtitle = '' }) {
|
||||
return (
|
||||
<LoginPageMode title={title} subtitle={subtitle} mode="login" />
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginPageMode({
|
||||
title = 'Login',
|
||||
subtitle = '',
|
||||
mode = 'login',
|
||||
resetToken = '',
|
||||
onRequestReset = null,
|
||||
onCompleteReset = null,
|
||||
}) {
|
||||
return (
|
||||
<YStack
|
||||
flex={1}
|
||||
@@ -163,7 +494,14 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
|
||||
backgroundColor="$bgPage"
|
||||
>
|
||||
<YStack width="100%" maxWidth={520}>
|
||||
{content}
|
||||
<LoginPanel
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
mode={mode}
|
||||
resetToken={resetToken}
|
||||
onRequestReset={onRequestReset}
|
||||
onCompleteReset={onCompleteReset}
|
||||
/>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,141 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Paragraph, Text, XStack, YStack } from 'tamagui';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Paragraph, Text, XStack, YStack } from 'tamagui';
|
||||
import { DirView } from '../../ui/components/DirView.jsx';
|
||||
import { FormField } from '../../ui/components/FormField.jsx';
|
||||
import { SettingsPanel } from '../../ui/components/SettingsPanel.jsx';
|
||||
import { rightsToArray } from '../model/rights.js';
|
||||
import { SECURITY_RIGHTS, SECURITY_RIGHT_SHORT_LABELS, normalizeRightsInput } from '../model/rights.js';
|
||||
import { CodedValueBadges } from '../../ui/components/CodedValues.jsx';
|
||||
import { useApp } from '../../ui/App.jsx';
|
||||
|
||||
function renderSectionBody(items, renderItem, emptyText) {
|
||||
function createLocalDataModel(rows, idField, searchableFields) {
|
||||
const normalizedRows = Array.isArray(rows) ? rows : [];
|
||||
|
||||
return {
|
||||
getIdField() {
|
||||
return idField;
|
||||
},
|
||||
async queryRecords(query = {}) {
|
||||
const search = String(query?.filter_by?.search || '').trim().toLowerCase();
|
||||
const pageSize = Number(query?.page_size || 10);
|
||||
const offset = Number(query?.offset || 0);
|
||||
const sortRule = Array.isArray(query?.sort_by) ? query.sort_by[0] : null;
|
||||
let filtered = normalizedRows;
|
||||
|
||||
if (search) {
|
||||
filtered = normalizedRows.filter((row) => searchableFields.some((field) => {
|
||||
const value = row?.[field];
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(', ').toLowerCase().includes(search);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return JSON.stringify(value).toLowerCase().includes(search);
|
||||
}
|
||||
return String(value ?? '').toLowerCase().includes(search);
|
||||
}));
|
||||
}
|
||||
|
||||
if (sortRule?.field) {
|
||||
const direction = sortRule.direction === 'desc' || sortRule.direction === 'descending' ? -1 : 1;
|
||||
filtered = [...filtered].sort((left, right) => {
|
||||
const leftValue = left?.[sortRule.field];
|
||||
const rightValue = right?.[sortRule.field];
|
||||
const leftText = Array.isArray(leftValue) ? leftValue.join(', ') : String(leftValue ?? '');
|
||||
const rightText = Array.isArray(rightValue) ? rightValue.join(', ') : String(rightValue ?? '');
|
||||
return leftText.localeCompare(rightText, undefined, { numeric: true, sensitivity: 'base' }) * direction;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
total: filtered.length,
|
||||
rows: filtered.slice(offset, offset + pageSize)
|
||||
};
|
||||
},
|
||||
async querySummary() {
|
||||
return { items: [] };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function SectionForm({
|
||||
title,
|
||||
mode,
|
||||
visible,
|
||||
fields,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
busy = false
|
||||
}) {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack
|
||||
gap="$3"
|
||||
padding="$4"
|
||||
borderWidth={1}
|
||||
borderColor="$lineSubtle"
|
||||
borderRadius="$radiusLg"
|
||||
backgroundColor="$bgPanel"
|
||||
>
|
||||
<Text fontWeight="700">{mode === 'edit' ? `Edit ${title}` : `Create ${title}`}</Text>
|
||||
<YStack gap="$3">
|
||||
{items.length > 0 ? items.map(renderItem) : (
|
||||
<Paragraph color="$color" opacity={0.7}>
|
||||
{emptyText}
|
||||
</Paragraph>
|
||||
)}
|
||||
{fields.map((field) => (
|
||||
<XStack key={field.id} gap="$4" alignItems="flex-start" flexWrap="wrap">
|
||||
<Text width={140} flexShrink={0} fontSize="$4" fontWeight="600" paddingTop="$2">
|
||||
{field.label}
|
||||
</Text>
|
||||
<YStack flex={1} minWidth={280}>
|
||||
<FormField
|
||||
id={field.id}
|
||||
type={field.type || 'text'}
|
||||
value={field.value}
|
||||
options={field.options}
|
||||
codeMap={field.codeMap}
|
||||
labels={field.labels}
|
||||
placeholder={field.placeholder}
|
||||
helperText={field.helperText}
|
||||
readOnly={field.readOnly}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</YStack>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
<XStack gap="$3">
|
||||
<Button onPress={onSubmit} disabled={busy}>
|
||||
{busy ? 'Working...' : mode === 'edit' ? 'Save Changes' : 'Create'}
|
||||
</Button>
|
||||
<Button chromeless onPress={onCancel} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</XStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionCell({ onDelete, busy = false }) {
|
||||
return (
|
||||
<XStack justifyContent="flex-end" width="100%">
|
||||
<Button
|
||||
size="$2"
|
||||
theme="red"
|
||||
onMouseDown={(event) => event?.stopPropagation?.()}
|
||||
onClick={(event) => {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
onDelete?.();
|
||||
}}
|
||||
onPress={(event) => {
|
||||
event?.stopPropagation?.();
|
||||
onDelete?.();
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,37 +145,81 @@ export function SecurityAdminPage() {
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [realms, setRealms] = useState([]);
|
||||
const [resources, setResources] = useState([]);
|
||||
const [subjects, setSubjects] = useState([]);
|
||||
const [permits, setPermits] = useState([]);
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState('');
|
||||
const [loadingData, setLoadingData] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const [userEditor, setUserEditor] = useState({
|
||||
open: false,
|
||||
mode: 'create',
|
||||
selectedId: null,
|
||||
values: {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
realm_id: 'local',
|
||||
role_ids: [],
|
||||
password: '',
|
||||
status: 'active'
|
||||
}
|
||||
});
|
||||
const [roleEditor, setRoleEditor] = useState({
|
||||
open: false,
|
||||
mode: 'create',
|
||||
selectedId: null,
|
||||
values: { name: '', description: '', realm_id: 'local' }
|
||||
});
|
||||
const [resourceEditor, setResourceEditor] = useState({
|
||||
open: false,
|
||||
mode: 'create',
|
||||
selectedId: null,
|
||||
values: { path: '', realm_id: 'local', type: 'ui-route', metadata_json: '{}' }
|
||||
});
|
||||
const [permitEditor, setPermitEditor] = useState({
|
||||
open: false,
|
||||
mode: 'create',
|
||||
selectedId: null,
|
||||
values: { principal_key: '', subject_id: '', rights: normalizeRightsInput(['read']), effect: 'allow' }
|
||||
});
|
||||
|
||||
async function loadSecurityData() {
|
||||
try {
|
||||
const [nextUsers, nextRoles, nextRealms, nextResources, nextPermits] = await Promise.all([
|
||||
setLoadingData(true);
|
||||
const [nextUsers, nextRoles, nextRealms, nextResources, nextSubjects, nextPermits] = await Promise.all([
|
||||
security.listUsers(),
|
||||
security.listRoles(),
|
||||
security.listRealms(),
|
||||
security.listResources(),
|
||||
security.listSubjects(),
|
||||
security.listPermits()
|
||||
]);
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUsers(nextUsers);
|
||||
setRoles(nextRoles);
|
||||
setRealms(nextRealms);
|
||||
setResources(nextResources);
|
||||
setSubjects(nextSubjects);
|
||||
setPermits(nextPermits);
|
||||
setLoadingData(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
await loadSecurityData();
|
||||
} catch (error) {
|
||||
console.warn('[SecurityAdminPage] Failed to load security data:', error);
|
||||
if (active) {
|
||||
setMessage(error?.message || 'Failed to load security data');
|
||||
setLoadingData(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (security.enabled && security.isAuthenticated) {
|
||||
loadSecurityData();
|
||||
run();
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -60,76 +227,585 @@ export function SecurityAdminPage() {
|
||||
};
|
||||
}, [security.enabled, security.isAuthenticated, security.user?.id]);
|
||||
|
||||
async function execute(actionId, fn) {
|
||||
setBusy(actionId);
|
||||
setMessage('');
|
||||
try {
|
||||
await fn();
|
||||
await loadSecurityData();
|
||||
} catch (error) {
|
||||
setMessage(error?.message || 'Operation failed');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() => roles.map((role) => ({ value: role.id, label: `role:${role.name}` })),
|
||||
[roles]
|
||||
);
|
||||
const realmOptions = useMemo(
|
||||
() => realms.map((realm) => ({ value: realm.id, label: realm.name || realm.id })),
|
||||
[realms]
|
||||
);
|
||||
const subjectOptions = useMemo(
|
||||
() => subjects.map((subject) => ({
|
||||
value: String(subject.id),
|
||||
label: `${subject.kind}:${subject.display_name || subject.name || subject.id}`
|
||||
})),
|
||||
[subjects]
|
||||
);
|
||||
const principalOptions = useMemo(
|
||||
() => [
|
||||
...roles.map((role) => ({ value: `role:${role.id}`, label: `role:${role.name}` })),
|
||||
...users.map((user) => ({ value: `user:${user.id}`, label: `user:${user.display_name || user.username}` }))
|
||||
],
|
||||
[roles, users]
|
||||
);
|
||||
|
||||
const roleNameById = useMemo(
|
||||
() => Object.fromEntries(roles.map((role) => [String(role.id), role.name || String(role.id)])),
|
||||
[roles]
|
||||
);
|
||||
const userNameById = useMemo(
|
||||
() => Object.fromEntries(users.map((user) => [String(user.id), user.display_name || user.username || String(user.id)])),
|
||||
[users]
|
||||
);
|
||||
|
||||
const userDataModel = useMemo(
|
||||
() => createLocalDataModel(users, 'id', ['username', 'display_name', 'email', 'realm_id', 'role_ids', 'role_names']),
|
||||
[users]
|
||||
);
|
||||
const roleDataModel = useMemo(
|
||||
() => createLocalDataModel(roles, 'id', ['name', 'description', 'realm_id']),
|
||||
[roles]
|
||||
);
|
||||
const realmDataModel = useMemo(
|
||||
() => createLocalDataModel(realms, 'id', ['name', 'description', 'id']),
|
||||
[realms]
|
||||
);
|
||||
const resourceDataModel = useMemo(
|
||||
() => createLocalDataModel(resources, 'path', ['path', 'realm_id', 'type']),
|
||||
[resources]
|
||||
);
|
||||
const permitDataModel = useMemo(
|
||||
() => createLocalDataModel(permits, 'id', ['principal_type', 'principal_id', 'principal_name', 'principal_display', 'subject_kind', 'subject_name', 'subject_display', 'resource_path', 'effect']),
|
||||
[permits]
|
||||
);
|
||||
const loadingSummary = useMemo(() => (
|
||||
loadingData
|
||||
? [{ id: 'loading', label: 'Status', value: 'Loading security data...' }]
|
||||
: [{ id: 'count', label: 'Items', value: String(users.length) }]
|
||||
), [loadingData, users.length]);
|
||||
|
||||
const userColumns = useMemo(() => ([
|
||||
{ id: 'username', label: 'Username', minWidth: 140, flex: 1.1 },
|
||||
{ id: 'display_name', label: 'Display Name', minWidth: 180, flex: 1.4 },
|
||||
{ id: 'email', label: 'Email', minWidth: 220, flex: 1.7 },
|
||||
{
|
||||
id: 'role_names',
|
||||
label: 'Roles',
|
||||
minWidth: 220,
|
||||
flex: 1.6,
|
||||
render: (_value, record) => {
|
||||
const names = Array.isArray(record?.role_names) && record.role_names.length
|
||||
? record.role_names
|
||||
: (Array.isArray(record?.role_ids) ? record.role_ids.map((roleId) => roleNameById[String(roleId)] || String(roleId)) : []);
|
||||
return (
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{names.map((name) => (
|
||||
<XStack
|
||||
key={name}
|
||||
paddingHorizontal="$2.5"
|
||||
paddingVertical="$1"
|
||||
borderRadius="$radiusLg"
|
||||
borderWidth={1}
|
||||
borderColor="$lineSubtle"
|
||||
backgroundColor="$bgPage"
|
||||
>
|
||||
<Text fontSize="$2" color="$textPrimary">{name}</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
minWidth: 120,
|
||||
flex: 0.9,
|
||||
align: 'right',
|
||||
sortable: false,
|
||||
render: (_value, record) => (
|
||||
<ActionCell
|
||||
busy={busy === `delete-user-${record.id}`}
|
||||
onDelete={() => execute(`delete-user-${record.id}`, () => security.deleteUser(record.id))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]), [busy, roleNameById, security]);
|
||||
|
||||
const roleColumns = useMemo(() => ([
|
||||
{ id: 'name', label: 'Role', minWidth: 220, flex: 1.4, render: (value) => `role:${value || ''}` },
|
||||
{ id: 'description', label: 'Description', minWidth: 260, flex: 2 },
|
||||
{ id: 'realm_id', label: 'Realm', minWidth: 140, flex: 1 },
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
minWidth: 120,
|
||||
flex: 0.9,
|
||||
align: 'right',
|
||||
sortable: false,
|
||||
render: (_value, record) => (
|
||||
<ActionCell
|
||||
busy={busy === `delete-role-${record.id}`}
|
||||
onDelete={() => execute(`delete-role-${record.id}`, () => security.deleteRole(record.id))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]), [busy, security]);
|
||||
|
||||
const realmColumns = useMemo(() => ([
|
||||
{ id: 'name', label: 'Name', minWidth: 220, flex: 1.3 },
|
||||
{ id: 'id', label: 'Realm ID', minWidth: 160, flex: 1 },
|
||||
{ id: 'description', label: 'Description', minWidth: 320, flex: 2 }
|
||||
]), []);
|
||||
|
||||
const resourceColumns = useMemo(() => ([
|
||||
{ id: 'path', label: 'Path', minWidth: 240, flex: 1.8 },
|
||||
{ id: 'realm_id', label: 'Realm', minWidth: 140, flex: 1 },
|
||||
{ id: 'type', label: 'Type', minWidth: 140, flex: 1 },
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
minWidth: 120,
|
||||
flex: 0.9,
|
||||
align: 'right',
|
||||
sortable: false,
|
||||
render: (_value, record) => (
|
||||
<ActionCell
|
||||
busy={busy === `delete-resource-${record.path}`}
|
||||
onDelete={() => execute(`delete-resource-${record.path}`, () => security.deleteResource(record.path))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]), [busy, security]);
|
||||
|
||||
const permitColumns = useMemo(() => ([
|
||||
{
|
||||
id: 'principal_display',
|
||||
label: 'Principal',
|
||||
minWidth: 220,
|
||||
flex: 1.6,
|
||||
render: (_value, record) => {
|
||||
if (record?.principal_display) {
|
||||
return record.principal_display;
|
||||
}
|
||||
const type = record?.principal_type || 'role';
|
||||
const key = String(record?.principal_id ?? '');
|
||||
const name = type === 'user'
|
||||
? (userNameById[key] || record?.principal_name || key)
|
||||
: (roleNameById[key] || record?.principal_name || key);
|
||||
return `${type}:${name}`;
|
||||
}
|
||||
},
|
||||
{ id: 'subject_display', label: 'Subject', minWidth: 240, flex: 1.8 },
|
||||
{
|
||||
id: 'rights',
|
||||
label: 'Rights',
|
||||
minWidth: 110,
|
||||
flex: 0.9,
|
||||
render: (value) => <CodedValueBadges codeMap={SECURITY_RIGHTS} labels={SECURITY_RIGHT_SHORT_LABELS} value={value} compact />
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Actions',
|
||||
minWidth: 120,
|
||||
flex: 0.9,
|
||||
align: 'right',
|
||||
sortable: false,
|
||||
render: (_value, record) => (
|
||||
<ActionCell
|
||||
busy={busy === `delete-permit-${record.id}`}
|
||||
onDelete={() => execute(`delete-permit-${record.id}`, () => security.deletePermit(record.id))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]), [busy, roleNameById, security, userNameById]);
|
||||
|
||||
function openCreateUser() {
|
||||
setUserEditor({
|
||||
open: true,
|
||||
mode: 'create',
|
||||
selectedId: null,
|
||||
values: {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
realm_id: realmOptions[0]?.value || 'local',
|
||||
role_ids: [],
|
||||
password: '',
|
||||
status: 'active'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openEditUser(record) {
|
||||
setUserEditor({
|
||||
open: true,
|
||||
mode: 'edit',
|
||||
selectedId: record.id,
|
||||
values: {
|
||||
username: record.username || '',
|
||||
display_name: record.display_name || '',
|
||||
email: record.email || '',
|
||||
realm_id: record.realm_id || (realmOptions[0]?.value || 'local'),
|
||||
role_ids: Array.isArray(record.role_ids) ? record.role_ids : [],
|
||||
password: '',
|
||||
status: record.status || 'active'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateRole() {
|
||||
setRoleEditor({
|
||||
open: true,
|
||||
mode: 'create',
|
||||
selectedId: null,
|
||||
values: { name: '', description: '', realm_id: realmOptions[0]?.value || 'local' }
|
||||
});
|
||||
}
|
||||
|
||||
function openEditRole(record) {
|
||||
setRoleEditor({
|
||||
open: true,
|
||||
mode: 'edit',
|
||||
selectedId: record.id,
|
||||
values: { name: record.name || '', description: record.description || '', realm_id: record.realm_id || (realmOptions[0]?.value || 'local') }
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateResource() {
|
||||
setResourceEditor({
|
||||
open: true,
|
||||
mode: 'create',
|
||||
selectedId: null,
|
||||
values: { path: '', realm_id: realmOptions[0]?.value || 'local', type: 'ui-route', metadata_json: '{}' }
|
||||
});
|
||||
}
|
||||
|
||||
function openEditResource(record) {
|
||||
setResourceEditor({
|
||||
open: true,
|
||||
mode: 'edit',
|
||||
selectedId: record.path,
|
||||
values: {
|
||||
path: record.path || '',
|
||||
realm_id: record.realm_id || (realmOptions[0]?.value || 'local'),
|
||||
type: record.type || 'ui-route',
|
||||
metadata_json: JSON.stringify(record.metadata || {}, null, 2)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openCreatePermit() {
|
||||
setPermitEditor({
|
||||
open: true,
|
||||
mode: 'create',
|
||||
selectedId: null,
|
||||
values: {
|
||||
principal_key: principalOptions[0]?.value || '',
|
||||
subject_id: subjectOptions[0]?.value || '',
|
||||
rights: normalizeRightsInput(['read']),
|
||||
effect: 'allow'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openEditPermit(record) {
|
||||
setPermitEditor({
|
||||
open: true,
|
||||
mode: 'edit',
|
||||
selectedId: record.id,
|
||||
values: {
|
||||
principal_key: `${record.principal_type || 'role'}:${record.principal_id || ''}`,
|
||||
subject_id: record.subject_id != null ? String(record.subject_id) : '',
|
||||
rights: normalizeRightsInput(record.rights),
|
||||
effect: record.effect || 'allow'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const userFormFields = [
|
||||
{ id: 'username', label: 'Username', value: userEditor.values.username },
|
||||
{ id: 'display_name', label: 'Display Name', value: userEditor.values.display_name },
|
||||
{ id: 'email', label: 'Email', value: userEditor.values.email },
|
||||
{ id: 'realm_id', label: 'Realm', type: 'select', value: userEditor.values.realm_id, options: realmOptions },
|
||||
{ id: 'role_ids', label: 'Roles', type: 'multiselect', value: userEditor.values.role_ids, options: roleOptions, helperText: 'Select one or more roles.' },
|
||||
{ id: 'status', label: 'Status', type: 'select', value: userEditor.values.status, options: [{ value: 'active', label: 'active' }, { value: 'disabled', label: 'disabled' }] },
|
||||
{ id: 'password', label: userEditor.mode === 'edit' ? 'New Password' : 'Initial Password', value: userEditor.values.password, helperText: userEditor.mode === 'edit' ? 'Leave blank to keep current password.' : 'Used for the first login.' }
|
||||
];
|
||||
|
||||
const roleFormFields = [
|
||||
{ id: 'name', label: 'Role Name', value: roleEditor.values.name },
|
||||
{ id: 'description', label: 'Description', value: roleEditor.values.description },
|
||||
{ id: 'realm_id', label: 'Realm', type: 'select', value: roleEditor.values.realm_id, options: realmOptions }
|
||||
];
|
||||
|
||||
const resourceFormFields = [
|
||||
{ id: 'path', label: 'Path', value: resourceEditor.values.path, readOnly: resourceEditor.mode === 'edit', helperText: resourceEditor.mode === 'edit' ? 'Path is the resource identity and is read only while editing.' : '' },
|
||||
{ id: 'realm_id', label: 'Realm', type: 'select', value: resourceEditor.values.realm_id, options: realmOptions },
|
||||
{ id: 'type', label: 'Type', value: resourceEditor.values.type },
|
||||
{ id: 'metadata_json', label: 'Metadata JSON', type: 'textarea', value: resourceEditor.values.metadata_json }
|
||||
];
|
||||
|
||||
const permitFormFields = [
|
||||
{ id: 'principal_key', label: 'Principal', type: 'select', value: permitEditor.values.principal_key, options: principalOptions },
|
||||
{ id: 'subject_id', label: 'Subject', type: 'select', value: permitEditor.values.subject_id, options: subjectOptions },
|
||||
{ id: 'effect', label: 'Effect', type: 'select', value: permitEditor.values.effect, options: [{ value: 'allow', label: 'allow' }, { value: 'deny', label: 'deny' }] },
|
||||
{ id: 'rights', label: 'Rights', type: 'coded-checkboxes', value: permitEditor.values.rights, codeMap: SECURITY_RIGHTS }
|
||||
];
|
||||
|
||||
const content = [
|
||||
{
|
||||
id: 'users',
|
||||
label: 'Users',
|
||||
icon: 'group',
|
||||
content: renderSectionBody(users, (user) => (
|
||||
<XStack key={user.id} justifyContent="space-between" flexWrap="wrap" gap="$2">
|
||||
<YStack>
|
||||
<Text fontWeight="700">{user.display_name || user.username}</Text>
|
||||
<Paragraph color="$color" opacity={0.7}>{user.email}</Paragraph>
|
||||
</YStack>
|
||||
<Text color="$accentColor">{(user.role_ids || []).join(', ') || 'no roles'}</Text>
|
||||
</XStack>
|
||||
), 'No users available.')
|
||||
content: (
|
||||
<DirView
|
||||
title="Users"
|
||||
dataModel={userDataModel}
|
||||
columns={userColumns}
|
||||
pageSize={8}
|
||||
showSummary={false}
|
||||
searchConfig={{ enabled: true, placeholder: 'Search users...' }}
|
||||
toolbarActions={[
|
||||
{
|
||||
id: 'create-user',
|
||||
label: userEditor.open && userEditor.mode === 'create' ? 'Creating' : 'Create',
|
||||
onPress: openCreateUser
|
||||
}
|
||||
]}
|
||||
bodyHeaderContent={(
|
||||
<SectionForm
|
||||
title="User"
|
||||
mode={userEditor.mode}
|
||||
visible={userEditor.open}
|
||||
fields={userFormFields}
|
||||
busy={busy === 'save-user'}
|
||||
onChange={(fieldId, value) => setUserEditor((current) => ({
|
||||
...current,
|
||||
values: { ...current.values, [fieldId]: value }
|
||||
}))}
|
||||
onCancel={() => setUserEditor((current) => ({ ...current, open: false, selectedId: null }))}
|
||||
onSubmit={() => execute('save-user', async () => {
|
||||
const payload = {
|
||||
...userEditor.values,
|
||||
role_ids: Array.isArray(userEditor.values.role_ids) ? userEditor.values.role_ids : []
|
||||
};
|
||||
if (userEditor.mode === 'edit') {
|
||||
await security.updateUser(userEditor.selectedId, payload);
|
||||
setMessage('User updated');
|
||||
} else {
|
||||
await security.createUser(payload);
|
||||
setMessage('User created');
|
||||
}
|
||||
setUserEditor((current) => ({ ...current, open: false, selectedId: null }));
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
onRowClick={openEditUser}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'roles',
|
||||
label: 'Roles',
|
||||
icon: 'lock',
|
||||
content: renderSectionBody(roles, (role) => (
|
||||
<YStack key={role.id} gap="$1">
|
||||
<Text fontWeight="700">{role.name}</Text>
|
||||
<Paragraph color="$color" opacity={0.7}>{role.description || role.id}</Paragraph>
|
||||
</YStack>
|
||||
), 'No roles available.')
|
||||
content: (
|
||||
<DirView
|
||||
title="Roles"
|
||||
dataModel={roleDataModel}
|
||||
columns={roleColumns}
|
||||
pageSize={8}
|
||||
showSummary={false}
|
||||
searchConfig={{ enabled: true, placeholder: 'Search roles...' }}
|
||||
toolbarActions={[
|
||||
{
|
||||
id: 'create-role',
|
||||
label: roleEditor.open && roleEditor.mode === 'create' ? 'Creating' : 'Create',
|
||||
onPress: openCreateRole
|
||||
}
|
||||
]}
|
||||
bodyHeaderContent={(
|
||||
<SectionForm
|
||||
title="Role"
|
||||
mode={roleEditor.mode}
|
||||
visible={roleEditor.open}
|
||||
fields={roleFormFields}
|
||||
busy={busy === 'save-role'}
|
||||
onChange={(fieldId, value) => setRoleEditor((current) => ({
|
||||
...current,
|
||||
values: { ...current.values, [fieldId]: value }
|
||||
}))}
|
||||
onCancel={() => setRoleEditor((current) => ({ ...current, open: false, selectedId: null }))}
|
||||
onSubmit={() => execute('save-role', async () => {
|
||||
if (roleEditor.mode === 'edit') {
|
||||
await security.updateRole(roleEditor.selectedId, roleEditor.values);
|
||||
setMessage('Role updated');
|
||||
} else {
|
||||
await security.createRole(roleEditor.values);
|
||||
setMessage('Role created');
|
||||
}
|
||||
setRoleEditor((current) => ({ ...current, open: false, selectedId: null }));
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
onRowClick={openEditRole}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'realms',
|
||||
label: 'Realms',
|
||||
icon: 'network',
|
||||
content: renderSectionBody(realms, (realm) => (
|
||||
<YStack key={realm.id} gap="$1">
|
||||
<Text fontWeight="700">{realm.name}</Text>
|
||||
<Paragraph color="$color" opacity={0.7}>{realm.description || realm.id}</Paragraph>
|
||||
</YStack>
|
||||
), 'No realms available.')
|
||||
content: (
|
||||
<DirView
|
||||
title="Realms"
|
||||
dataModel={realmDataModel}
|
||||
columns={realmColumns}
|
||||
pageSize={8}
|
||||
showSummary={false}
|
||||
searchConfig={{ enabled: true, placeholder: 'Search realms...' }}
|
||||
bodyHeaderContent={(
|
||||
<Paragraph color="$textSecondary">
|
||||
Realms are read only for now. We seed the local realm in backend code and keep it stable while the rest of security matures.
|
||||
</Paragraph>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
label: 'Resources',
|
||||
icon: 'library',
|
||||
content: renderSectionBody(resources, (resource) => (
|
||||
<YStack key={resource.path} gap="$1">
|
||||
<Text fontWeight="700">{resource.path}</Text>
|
||||
<Paragraph color="$color" opacity={0.7}>{resource.type} in realm {resource.realm_id}</Paragraph>
|
||||
</YStack>
|
||||
), 'No resources registered.')
|
||||
content: (
|
||||
<DirView
|
||||
title="Resources"
|
||||
dataModel={resourceDataModel}
|
||||
columns={resourceColumns}
|
||||
pageSize={8}
|
||||
showSummary={false}
|
||||
searchConfig={{ enabled: true, placeholder: 'Search resources...' }}
|
||||
toolbarActions={[
|
||||
{
|
||||
id: 'create-resource',
|
||||
label: resourceEditor.open && resourceEditor.mode === 'create' ? 'Creating' : 'Create',
|
||||
onPress: openCreateResource
|
||||
}
|
||||
]}
|
||||
bodyHeaderContent={(
|
||||
<SectionForm
|
||||
title="Resource"
|
||||
mode={resourceEditor.mode}
|
||||
visible={resourceEditor.open}
|
||||
fields={resourceFormFields}
|
||||
busy={busy === 'save-resource'}
|
||||
onChange={(fieldId, value) => setResourceEditor((current) => ({
|
||||
...current,
|
||||
values: { ...current.values, [fieldId]: value }
|
||||
}))}
|
||||
onCancel={() => setResourceEditor((current) => ({ ...current, open: false, selectedId: null }))}
|
||||
onSubmit={() => execute('save-resource', async () => {
|
||||
const payload = {
|
||||
path: resourceEditor.values.path,
|
||||
realm_id: resourceEditor.values.realm_id,
|
||||
type: resourceEditor.values.type,
|
||||
metadata: JSON.parse(resourceEditor.values.metadata_json || '{}')
|
||||
};
|
||||
if (resourceEditor.mode === 'edit') {
|
||||
await security.updateResource(resourceEditor.selectedId, payload);
|
||||
setMessage('Resource updated');
|
||||
} else {
|
||||
await security.createResource(payload);
|
||||
setMessage('Resource created');
|
||||
}
|
||||
setResourceEditor((current) => ({ ...current, open: false, selectedId: null }));
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
onRowClick={openEditResource}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'permits',
|
||||
label: 'Permits',
|
||||
icon: 'lock',
|
||||
content: renderSectionBody(permits, (permit) => (
|
||||
<YStack key={permit.id} gap="$1">
|
||||
<Text fontWeight="700">
|
||||
{permit.effect.toUpperCase()} {permit.principal_type}:{permit.principal_id}
|
||||
</Text>
|
||||
<Paragraph color="$color" opacity={0.7}>
|
||||
{permit.resource_path} {'->'} {rightsToArray(permit.rights).join(', ') || 'none'}
|
||||
</Paragraph>
|
||||
</YStack>
|
||||
), 'No permits registered.')
|
||||
},
|
||||
content: (
|
||||
<DirView
|
||||
title="Permits"
|
||||
dataModel={permitDataModel}
|
||||
columns={permitColumns}
|
||||
pageSize={8}
|
||||
showSummary={false}
|
||||
searchConfig={{ enabled: true, placeholder: 'Search permits...' }}
|
||||
toolbarActions={[
|
||||
{
|
||||
id: 'create-permit',
|
||||
label: permitEditor.open && permitEditor.mode === 'create' ? 'Creating' : 'Create',
|
||||
onPress: openCreatePermit
|
||||
}
|
||||
]}
|
||||
bodyHeaderContent={(
|
||||
<SectionForm
|
||||
title="Permit"
|
||||
mode={permitEditor.mode}
|
||||
visible={permitEditor.open}
|
||||
fields={permitFormFields}
|
||||
busy={busy === 'save-permit'}
|
||||
onChange={(fieldId, value) => setPermitEditor((current) => ({
|
||||
...current,
|
||||
values: { ...current.values, [fieldId]: value }
|
||||
}))}
|
||||
onCancel={() => setPermitEditor((current) => ({ ...current, open: false, selectedId: null }))}
|
||||
onSubmit={() => execute('save-permit', async () => {
|
||||
const payload = { ...permitEditor.values };
|
||||
const [principalType, principalId] = String(payload.principal_key || '').split(':');
|
||||
payload.principal_type = principalType || 'role';
|
||||
payload.principal_id = principalId ? Number(principalId) : null;
|
||||
payload.subject_id = payload.subject_id ? Number(payload.subject_id) : null;
|
||||
payload.resource_path = null;
|
||||
delete payload.principal_key;
|
||||
if (permitEditor.mode === 'edit') {
|
||||
await security.updatePermit(permitEditor.selectedId, payload);
|
||||
setMessage('Permit updated');
|
||||
} else {
|
||||
await security.createPermit(payload);
|
||||
setMessage('Permit created');
|
||||
}
|
||||
setPermitEditor((current) => ({ ...current, open: false, selectedId: null }));
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
onRowClick={openEditPermit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const loadingNotice = loadingData ? (
|
||||
<Paragraph color="$color" opacity={0.75}>
|
||||
Loading security data...
|
||||
</Paragraph>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<SettingsPanel
|
||||
icon="lock"
|
||||
title="Security"
|
||||
description="Security provider details, authenticated state, and the currently loaded policy dataset."
|
||||
description="Directory-style security administration for users, roles, realms, resources, and permits."
|
||||
defaultExpanded={false}
|
||||
persistenceKey="settings.security"
|
||||
content={content}
|
||||
@@ -140,12 +816,18 @@ export function SecurityAdminPage() {
|
||||
<Text color="$accentColor" fontWeight="700">
|
||||
Provider: {security.config.provider}
|
||||
</Text>
|
||||
{loadingNotice}
|
||||
<Paragraph color="$color" opacity={0.75}>
|
||||
Security is {security.enabled ? 'enabled' : 'disabled'}.
|
||||
</Paragraph>
|
||||
<Paragraph color="$color" opacity={0.75}>
|
||||
Authenticated user: {security.user?.display_name || security.user?.username || 'none'}
|
||||
</Paragraph>
|
||||
{message ? (
|
||||
<Paragraph color="$accentColor">
|
||||
{message}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</YStack>
|
||||
</YStack>
|
||||
</SettingsPanel>
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
import { getProvider } from '../../platform/storage.js';
|
||||
import { api } from '../../platform/api.js';
|
||||
import { SecurityPolicy } from './SecurityPolicy.js';
|
||||
import {
|
||||
AccountProfile,
|
||||
Permit,
|
||||
Realm,
|
||||
Resource,
|
||||
Role,
|
||||
Securable,
|
||||
Session,
|
||||
User,
|
||||
hasRequiredRights,
|
||||
normalizeRightsInput
|
||||
} from '../model/index.js';
|
||||
|
||||
const SESSION_KEY = 'security.api.session';
|
||||
|
||||
function clone(value) {
|
||||
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function pathMatches(resourcePath, targetPath) {
|
||||
if (!resourcePath || resourcePath === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (resourcePath.endsWith('*')) {
|
||||
return targetPath.startsWith(resourcePath.slice(0, -1));
|
||||
}
|
||||
|
||||
return targetPath === resourcePath || targetPath.startsWith(`${resourcePath}/`);
|
||||
}
|
||||
|
||||
export class ApiSecurityPolicy extends SecurityPolicy {
|
||||
constructor(config = {}) {
|
||||
super(config);
|
||||
this.storage = getProvider('kv', 'security.api');
|
||||
this.baseURL = config.base_url || '/api/security';
|
||||
this.client = api.scope(this.baseURL);
|
||||
this.cache = {
|
||||
session: null,
|
||||
user: null,
|
||||
profile: null,
|
||||
realm: null,
|
||||
resources: [],
|
||||
permits: [],
|
||||
subjects: [],
|
||||
admin: null,
|
||||
adminPromise: null
|
||||
};
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this._hydrateCurrentSession();
|
||||
}
|
||||
|
||||
async _request(path, options = {}, extra = {}) {
|
||||
const { authToken = null, trackActivity = true } = extra;
|
||||
return this.client.requestJSON(path, {
|
||||
...options,
|
||||
trackActivity,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
...(authToken ? { Authorization: `Bearer ${authToken}` } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_applyBundle(bundle = {}) {
|
||||
this.cache.session = bundle.session ? new Session(bundle.session) : null;
|
||||
this.cache.user = bundle.user ? new User(bundle.user) : null;
|
||||
this.cache.profile = bundle.profile ? new AccountProfile(bundle.profile) : null;
|
||||
this.cache.realm = bundle.realm ? new Realm(bundle.realm) : null;
|
||||
this.cache.resources = Array.isArray(bundle.resources) ? bundle.resources.map((item) => new Resource(item)) : [];
|
||||
this.cache.permits = Array.isArray(bundle.permits) ? bundle.permits.map((item) => new Permit(item)) : [];
|
||||
}
|
||||
|
||||
async _hydrateCurrentSession() {
|
||||
const stored = await this.storage.get(SESSION_KEY, null);
|
||||
if (!stored?.jwt_token) {
|
||||
await this.clearSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const bundle = await this._request('/session', { method: 'GET' }, { authToken: stored.jwt_token, trackActivity: false });
|
||||
this._applyBundle(bundle);
|
||||
await this.saveSession(this.cache.session);
|
||||
return this.cache.session;
|
||||
} catch (error) {
|
||||
await this.clearSession();
|
||||
if (error?.status === 401) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate(credentials = {}) {
|
||||
const username = credentials.username || credentials.email || '';
|
||||
const password = credentials.password || '';
|
||||
const basicToken = typeof btoa === 'function'
|
||||
? btoa(`${username}:${password}`)
|
||||
: Buffer.from(`${username}:${password}`, 'utf-8').toString('base64');
|
||||
const bundle = await this._request('/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${basicToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deterministic: Boolean(credentials.deterministic),
|
||||
products: Array.isArray(credentials.products) ? credentials.products : [],
|
||||
licence_key: credentials.licence_key || null,
|
||||
dimensions: Array.isArray(credentials.dimensions) ? credentials.dimensions : []
|
||||
})
|
||||
});
|
||||
this._applyBundle(bundle);
|
||||
await this.saveSession(this.cache.session);
|
||||
this.cache.admin = null;
|
||||
this.cache.adminPromise = null;
|
||||
return {
|
||||
session: clone(this.cache.session),
|
||||
user: clone(this.cache.user),
|
||||
profile: clone(this.cache.profile)
|
||||
};
|
||||
}
|
||||
|
||||
async logout(session) {
|
||||
const token = session?.jwt_token || this.cache.session?.jwt_token || null;
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this._request('/logout', { method: 'POST' }, { authToken: token });
|
||||
} catch (error) {
|
||||
if (error?.status !== 401) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentSession() {
|
||||
if (this.cache.session?.jwt_token) {
|
||||
return clone(this.cache.session);
|
||||
}
|
||||
const session = await this._hydrateCurrentSession();
|
||||
return clone(session);
|
||||
}
|
||||
|
||||
async saveSession(session) {
|
||||
if (!session) {
|
||||
await this.clearSession();
|
||||
return;
|
||||
}
|
||||
await this.storage.set(SESSION_KEY, clone(session));
|
||||
}
|
||||
|
||||
async clearSession() {
|
||||
this.cache = {
|
||||
session: null,
|
||||
user: null,
|
||||
profile: null,
|
||||
realm: null,
|
||||
resources: [],
|
||||
permits: [],
|
||||
subjects: [],
|
||||
admin: null,
|
||||
adminPromise: null
|
||||
};
|
||||
await this.storage.remove(SESSION_KEY);
|
||||
}
|
||||
|
||||
async _ensureSessionLoaded() {
|
||||
if (this.cache.session?.jwt_token && this.cache.user?.id) {
|
||||
return this.cache.session;
|
||||
}
|
||||
return this._hydrateCurrentSession();
|
||||
}
|
||||
|
||||
async _loadAdminDataset() {
|
||||
await this._ensureSessionLoaded();
|
||||
if (!this.cache.session?.jwt_token) {
|
||||
return null;
|
||||
}
|
||||
if (this.cache.admin) {
|
||||
return this.cache.admin;
|
||||
}
|
||||
if (this.cache.adminPromise) {
|
||||
return this.cache.adminPromise;
|
||||
}
|
||||
const token = this.cache.session.jwt_token;
|
||||
this.cache.adminPromise = (async () => {
|
||||
const [users, roles, realms, resources, subjects, permits] = await Promise.all([
|
||||
this._request('/admin/users', { method: 'GET' }, { authToken: token }),
|
||||
this._request('/admin/roles', { method: 'GET' }, { authToken: token }),
|
||||
this._request('/admin/realms', { method: 'GET' }, { authToken: token }),
|
||||
this._request('/admin/resources', { method: 'GET' }, { authToken: token }),
|
||||
this._request('/admin/subjects', { method: 'GET' }, { authToken: token }),
|
||||
this._request('/admin/permits', { method: 'GET' }, { authToken: token })
|
||||
]);
|
||||
this.cache.admin = {
|
||||
users: Array.isArray(users) ? users.map((item) => new User(item)) : [],
|
||||
roles: Array.isArray(roles) ? roles.map((item) => new Role(item)) : [],
|
||||
realms: Array.isArray(realms) ? realms.map((item) => new Realm(item)) : [],
|
||||
resources: Array.isArray(resources) ? resources.map((item) => new Resource(item)) : [],
|
||||
subjects: Array.isArray(subjects) ? subjects.map((item) => new Securable(item)) : [],
|
||||
permits: Array.isArray(permits) ? permits.map((item) => new Permit(item)) : []
|
||||
};
|
||||
return this.cache.admin;
|
||||
})();
|
||||
try {
|
||||
return await this.cache.adminPromise;
|
||||
} finally {
|
||||
this.cache.adminPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async listUsers() {
|
||||
const admin = await this._loadAdminDataset();
|
||||
return clone(admin?.users || []);
|
||||
}
|
||||
|
||||
async getUser(userId) {
|
||||
await this._ensureSessionLoaded();
|
||||
if (this.cache.user?.id === userId) {
|
||||
return clone(this.cache.user);
|
||||
}
|
||||
const admin = await this._loadAdminDataset();
|
||||
return clone(admin?.users?.find((item) => item.id === userId) || null);
|
||||
}
|
||||
|
||||
async listRoles() {
|
||||
const admin = await this._loadAdminDataset();
|
||||
return clone(admin?.roles || []);
|
||||
}
|
||||
|
||||
async listSubjects() {
|
||||
const admin = await this._loadAdminDataset();
|
||||
return clone(admin?.subjects || []);
|
||||
}
|
||||
|
||||
async listRealms() {
|
||||
const admin = await this._loadAdminDataset();
|
||||
return clone(admin?.realms || []);
|
||||
}
|
||||
|
||||
async getRealm(realmId) {
|
||||
await this._ensureSessionLoaded();
|
||||
if (this.cache.realm?.id === realmId) {
|
||||
return clone(this.cache.realm);
|
||||
}
|
||||
const admin = await this._loadAdminDataset();
|
||||
return clone(admin?.realms?.find((item) => item.id === realmId) || null);
|
||||
}
|
||||
|
||||
_invalidateAdminCache() {
|
||||
this.cache.admin = null;
|
||||
this.cache.adminPromise = null;
|
||||
}
|
||||
|
||||
async createUser(userData) {
|
||||
await this._ensureSessionLoaded();
|
||||
const created = await this._request('/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userData)
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this._invalidateAdminCache();
|
||||
return created ? new User(created) : null;
|
||||
}
|
||||
|
||||
async updateUser(userId, patch) {
|
||||
await this._ensureSessionLoaded();
|
||||
const updated = await this._request(`/admin/users/${encodeURIComponent(userId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this._invalidateAdminCache();
|
||||
return updated ? new User(updated) : null;
|
||||
}
|
||||
|
||||
async deleteUser(userId) {
|
||||
await this._ensureSessionLoaded();
|
||||
await this._request(`/admin/users/${encodeURIComponent(userId)}`, {
|
||||
method: 'DELETE'
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this._invalidateAdminCache();
|
||||
}
|
||||
|
||||
async getRole(roleId) {
|
||||
const admin = await this._loadAdminDataset();
|
||||
return clone(admin?.roles?.find((item) => item.id === roleId) || null);
|
||||
}
|
||||
|
||||
async createRole(roleData) {
|
||||
await this._ensureSessionLoaded();
|
||||
const created = await this._request('/admin/roles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(roleData)
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this._invalidateAdminCache();
|
||||
return created ? new Role(created) : null;
|
||||
}
|
||||
|
||||
async updateRole(roleId, patch) {
|
||||
await this._ensureSessionLoaded();
|
||||
const updated = await this._request(`/admin/roles/${encodeURIComponent(roleId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this._invalidateAdminCache();
|
||||
return updated ? new Role(updated) : null;
|
||||
}
|
||||
|
||||
async deleteRole(roleId) {
|
||||
await this._ensureSessionLoaded();
|
||||
await this._request(`/admin/roles/${encodeURIComponent(roleId)}`, {
|
||||
method: 'DELETE'
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this._invalidateAdminCache();
|
||||
}
|
||||
|
||||
async createRealm(realmData) {
|
||||
await this._ensureSessionLoaded();
|
||||
const created = await this._request('/admin/realms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(realmData)
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this._invalidateAdminCache();
|
||||
return created ? new Realm(created) : null;
|
||||
}
|
||||
|
||||
async updateRealm(realmId, patch) {
|
||||
await this._ensureSessionLoaded();
|
||||
const updated = await this._request(`/admin/realms/${encodeURIComponent(realmId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this._invalidateAdminCache();
|
||||
return updated ? new Realm(updated) : null;
|
||||
}
|
||||
|
||||
async deleteRealm(realmId) {
|
||||
await this._ensureSessionLoaded();
|
||||
await this._request(`/admin/realms/${encodeURIComponent(realmId)}`, {
|
||||
method: 'DELETE'
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this._invalidateAdminCache();
|
||||
}
|
||||
|
||||
async registerResource(resource) {
|
||||
await this._ensureSessionLoaded();
|
||||
const created = await this._request('/admin/resources', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(resource)
|
||||
}, { authToken: this.cache.session?.jwt_token, trackActivity: false });
|
||||
this._invalidateAdminCache();
|
||||
return created ? new Resource(created) : null;
|
||||
}
|
||||
|
||||
async updateResource(path, patch) {
|
||||
await this._ensureSessionLoaded();
|
||||
const normalized = String(path || '').replace(/^\/+/, '');
|
||||
const updated = await this._request(`/admin/resources/${normalized}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this._invalidateAdminCache();
|
||||
return updated ? new Resource(updated) : null;
|
||||
}
|
||||
|
||||
async deleteResource(path) {
|
||||
await this._ensureSessionLoaded();
|
||||
const normalized = String(path || '').replace(/^\/+/, '');
|
||||
await this._request(`/admin/resources/${normalized}`, {
|
||||
method: 'DELETE'
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this._invalidateAdminCache();
|
||||
}
|
||||
|
||||
async listResources() {
|
||||
const admin = await this._loadAdminDataset();
|
||||
if (admin?.resources?.length) {
|
||||
return clone(admin.resources);
|
||||
}
|
||||
await this._ensureSessionLoaded();
|
||||
return clone(this.cache.resources);
|
||||
}
|
||||
|
||||
async listPermits() {
|
||||
const admin = await this._loadAdminDataset();
|
||||
if (admin?.permits?.length) {
|
||||
return clone(admin.permits);
|
||||
}
|
||||
await this._ensureSessionLoaded();
|
||||
return clone(this.cache.permits);
|
||||
}
|
||||
|
||||
async getAccountProfile(userId) {
|
||||
await this._ensureSessionLoaded();
|
||||
if (this.cache.profile && this.cache.profile.user_id === userId) {
|
||||
return clone(this.cache.profile);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async updateAccountProfile(userId, patch) {
|
||||
await this._ensureSessionLoaded();
|
||||
if (!this.cache.user || this.cache.user.id !== userId) {
|
||||
throw new Error('Profile update is only available for the authenticated user');
|
||||
}
|
||||
const profile = await this._request('/account/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this.cache.profile = profile ? new AccountProfile(profile) : null;
|
||||
if (this.cache.user) {
|
||||
if (patch.display_name !== undefined) this.cache.user.display_name = patch.display_name;
|
||||
if (patch.email !== undefined) this.cache.user.email = patch.email;
|
||||
if (patch.image_url !== undefined) this.cache.user.image_url = patch.image_url;
|
||||
}
|
||||
return clone(this.cache.profile);
|
||||
}
|
||||
|
||||
async uploadAccountAvatar(userId, file) {
|
||||
await this._ensureSessionLoaded();
|
||||
if (!this.cache.user || this.cache.user.id !== userId) {
|
||||
throw new Error('Avatar upload is only available for the authenticated user');
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const profile = await this._request('/account/avatar', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this.cache.profile = profile ? new AccountProfile(profile) : null;
|
||||
if (this.cache.user && profile?.image_url !== undefined) {
|
||||
this.cache.user.image_url = profile.image_url;
|
||||
}
|
||||
return clone(this.cache.profile);
|
||||
}
|
||||
|
||||
async changePassword(userId, passwordInput = {}) {
|
||||
await this._ensureSessionLoaded();
|
||||
if (!this.cache.user || this.cache.user.id !== userId) {
|
||||
throw new Error('Password change is only available for the authenticated user');
|
||||
}
|
||||
await this._request('/account/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(passwordInput)
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
}
|
||||
|
||||
async grantPermit(permitData) {
|
||||
await this._ensureSessionLoaded();
|
||||
const created = await this._request('/admin/permits', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(permitData)
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this._invalidateAdminCache();
|
||||
return created ? new Permit(created) : null;
|
||||
}
|
||||
|
||||
async updatePermit(permitId, patch) {
|
||||
await this._ensureSessionLoaded();
|
||||
const updated = await this._request(`/admin/permits/${encodeURIComponent(permitId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch)
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this._invalidateAdminCache();
|
||||
return updated ? new Permit(updated) : null;
|
||||
}
|
||||
|
||||
async revokePermit(permitId) {
|
||||
await this._ensureSessionLoaded();
|
||||
await this._request(`/admin/permits/${encodeURIComponent(permitId)}`, {
|
||||
method: 'DELETE'
|
||||
}, { authToken: this.cache.session?.jwt_token });
|
||||
this._invalidateAdminCache();
|
||||
}
|
||||
|
||||
_evaluateCached(rights, resourcePath) {
|
||||
if (!this.cache.user?.id || !this.cache.session?.jwt_token) {
|
||||
return {
|
||||
allowed: false,
|
||||
requires_login: true,
|
||||
reason: 'Authentication required',
|
||||
matched_permits: []
|
||||
};
|
||||
}
|
||||
|
||||
const requestedRights = normalizeRightsInput(rights);
|
||||
const targetPath = resourcePath || '/';
|
||||
const matchingPermits = this.cache.permits.filter((permit) => pathMatches(permit.resource_path, targetPath));
|
||||
const denyMatch = matchingPermits.find((permit) => permit.effect === 'deny' && hasRequiredRights(permit.rights, requestedRights));
|
||||
if (denyMatch) {
|
||||
return {
|
||||
allowed: false,
|
||||
requires_login: false,
|
||||
reason: 'Denied by explicit policy',
|
||||
matched_permits: clone([denyMatch])
|
||||
};
|
||||
}
|
||||
const allowMatches = matchingPermits.filter((permit) => permit.effect === 'allow' && hasRequiredRights(permit.rights, requestedRights));
|
||||
if (allowMatches.length > 0 || requestedRights === 0) {
|
||||
return {
|
||||
allowed: true,
|
||||
requires_login: false,
|
||||
reason: allowMatches.length > 0 ? 'Permit granted' : 'No specific rights requested',
|
||||
matched_permits: clone(allowMatches)
|
||||
};
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
requires_login: false,
|
||||
reason: `Missing required rights on ${targetPath}`,
|
||||
matched_permits: clone(matchingPermits)
|
||||
};
|
||||
}
|
||||
|
||||
async evaluate(userId, rights, resourcePath, context = {}) {
|
||||
await this._ensureSessionLoaded();
|
||||
if (!this.cache.user?.id || this.cache.user.id !== userId) {
|
||||
return {
|
||||
allowed: false,
|
||||
requires_login: true,
|
||||
reason: 'Authentication required',
|
||||
matched_permits: []
|
||||
};
|
||||
}
|
||||
return this._evaluateCached(rights, resourcePath || context.resource_path || '/');
|
||||
}
|
||||
|
||||
evaluateSync(userId, rights, resourcePath, context = {}) {
|
||||
if (!this.cache.user?.id || this.cache.user.id !== userId) {
|
||||
return {
|
||||
allowed: false,
|
||||
requires_login: true,
|
||||
reason: 'Authentication required',
|
||||
matched_permits: []
|
||||
};
|
||||
}
|
||||
return this._evaluateCached(rights, resourcePath || context.resource_path || '/');
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,3 @@
|
||||
import { SecurityPolicy } from './SecurityPolicy.js';
|
||||
import { ApiSecurityPolicy } from './ApiSecurityPolicy.js';
|
||||
|
||||
export class BstoreSecurityPolicy extends SecurityPolicy {
|
||||
async init() {
|
||||
throw new Error('BstoreSecurityPolicy is not implemented yet');
|
||||
}
|
||||
|
||||
evaluateSync() {
|
||||
return {
|
||||
allowed: false,
|
||||
requires_login: false,
|
||||
reason: 'BstoreSecurityPolicy sync evaluation is not implemented',
|
||||
matched_permits: []
|
||||
};
|
||||
}
|
||||
}
|
||||
export class BstoreSecurityPolicy extends ApiSecurityPolicy {}
|
||||
|
||||
@@ -15,6 +15,7 @@ export class SecurityPolicy {
|
||||
async updateUser(_userId, _patch) { throw new Error('updateUser() not implemented'); }
|
||||
async deleteUser(_userId) { throw new Error('deleteUser() not implemented'); }
|
||||
async listRoles(_realmId = null) { return []; }
|
||||
async listSubjects() { return []; }
|
||||
async getRole(_roleId) { return null; }
|
||||
async createRole(_roleData) { throw new Error('createRole() not implemented'); }
|
||||
async updateRole(_roleId, _patch) { throw new Error('updateRole() not implemented'); }
|
||||
@@ -23,10 +24,14 @@ export class SecurityPolicy {
|
||||
async getRealm(_realmId) { return null; }
|
||||
async createRealm(_realmData) { throw new Error('createRealm() not implemented'); }
|
||||
async updateRealm(_realmId, _patch) { throw new Error('updateRealm() not implemented'); }
|
||||
async deleteRealm(_realmId) { throw new Error('deleteRealm() not implemented'); }
|
||||
async registerResource(_resource) { throw new Error('registerResource() not implemented'); }
|
||||
async updateResource(_path, _patch) { throw new Error('updateResource() not implemented'); }
|
||||
async deleteResource(_path) { throw new Error('deleteResource() not implemented'); }
|
||||
async listResources(_realmId = null) { return []; }
|
||||
async listPermits(_filters = {}) { return []; }
|
||||
async grantPermit(_permit) { throw new Error('grantPermit() not implemented'); }
|
||||
async updatePermit(_permitId, _patch) { throw new Error('updatePermit() not implemented'); }
|
||||
async revokePermit(_permitId) { throw new Error('revokePermit() not implemented'); }
|
||||
async getAccountProfile(_userId) { return null; }
|
||||
async updateAccountProfile(_userId, _patch) { throw new Error('updateAccountProfile() not implemented'); }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { SecurityPolicy } from './SecurityPolicy.js';
|
||||
export { ApiSecurityPolicy } from './ApiSecurityPolicy.js';
|
||||
export { BasicSecurityPolicy } from './BasicSecurityPolicy.js';
|
||||
export { BstoreSecurityPolicy } from './BstoreSecurityPolicy.js';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
function lowerText(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function arrayOfLower(values) {
|
||||
return Array.isArray(values) ? values.map(lowerText).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
export function getSessionClaims(securityState = {}) {
|
||||
return securityState?.session?.claims || {};
|
||||
}
|
||||
|
||||
export function getUserRoles(securityState = {}) {
|
||||
const userRoles = arrayOfLower(securityState?.user?.role_names);
|
||||
const claimRoles = arrayOfLower(getSessionClaims(securityState).roles);
|
||||
return Array.from(new Set([...userRoles, ...claimRoles]));
|
||||
}
|
||||
|
||||
export function isAdminUser(securityState = {}) {
|
||||
return Boolean(securityState?.user?.is_admin || getSessionClaims(securityState).is_admin);
|
||||
}
|
||||
|
||||
export function hasRequiredProducts(securityState = {}, requiredProducts = []) {
|
||||
if (!Array.isArray(requiredProducts) || requiredProducts.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const granted = arrayOfLower(getSessionClaims(securityState).products);
|
||||
if (granted.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return requiredProducts.some((product) => granted.includes(lowerText(product)));
|
||||
}
|
||||
|
||||
export function hasRequiredClaims(securityState = {}, requiredClaims = null) {
|
||||
if (!requiredClaims || typeof requiredClaims !== 'object') {
|
||||
return true;
|
||||
}
|
||||
const claims = getSessionClaims(securityState);
|
||||
return Object.entries(requiredClaims).every(([key, expected]) => {
|
||||
const actual = claims?.[key];
|
||||
if (Array.isArray(expected)) {
|
||||
const expectedSet = arrayOfLower(expected);
|
||||
const actualSet = arrayOfLower(Array.isArray(actual) ? actual : [actual]);
|
||||
return expectedSet.every((value) => actualSet.includes(value));
|
||||
}
|
||||
return actual === expected;
|
||||
});
|
||||
}
|
||||
|
||||
export function evaluateAuthRequirements(securityState = {}, requirements = {}) {
|
||||
const requireUser = Boolean(requirements.require_user);
|
||||
const requireAdmin = Boolean(requirements.require_admin);
|
||||
const requiredRoles = arrayOfLower(requirements.required_roles);
|
||||
const requiredProducts = requirements.required_products || [];
|
||||
const requiredClaims = requirements.required_claims || null;
|
||||
|
||||
if (requireUser && !securityState?.isAuthenticated) {
|
||||
return { allowed: false, requires_login: true, reason: 'Login required' };
|
||||
}
|
||||
|
||||
if (requireAdmin && !isAdminUser(securityState)) {
|
||||
return { allowed: false, requires_login: false, reason: 'Administrator access required' };
|
||||
}
|
||||
|
||||
if (requiredRoles.length > 0) {
|
||||
const grantedRoles = getUserRoles(securityState);
|
||||
if (!requiredRoles.some((role) => grantedRoles.includes(role))) {
|
||||
return { allowed: false, requires_login: false, reason: 'Required role missing' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRequiredProducts(securityState, requiredProducts)) {
|
||||
return { allowed: false, requires_login: false, reason: 'Required product access missing' };
|
||||
}
|
||||
|
||||
if (!hasRequiredClaims(securityState, requiredClaims)) {
|
||||
return { allowed: false, requires_login: false, reason: 'Required token claims missing' };
|
||||
}
|
||||
|
||||
return { allowed: true, requires_login: false, reason: 'Auth requirements satisfied' };
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import { normalizeRightsInput } from '../model/rights.js';
|
||||
import { evaluateAuthRequirements } from './access-rules.js';
|
||||
|
||||
export async function evaluateRouteAccess(route = {}, securityService) {
|
||||
const securityState = securityService.getState();
|
||||
const options = route?.options || {};
|
||||
const resourcePath = options.resource_path || route?.path || '/';
|
||||
const requestedRights = normalizeRightsInput(options.required_rights || 0);
|
||||
const authRequirements = {
|
||||
require_user: options.require_user,
|
||||
require_admin: options.require_admin,
|
||||
required_roles: options.required_roles,
|
||||
required_products: options.required_products,
|
||||
required_claims: options.required_claims,
|
||||
};
|
||||
|
||||
if (!securityState.enabled) {
|
||||
return {
|
||||
@@ -14,7 +22,14 @@ export async function evaluateRouteAccess(route = {}, securityService) {
|
||||
};
|
||||
}
|
||||
|
||||
if (!options.require_user && requestedRights === 0) {
|
||||
const needsAuthCheck = Object.values(authRequirements).some((value) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0;
|
||||
}
|
||||
return Boolean(value);
|
||||
});
|
||||
|
||||
if (!needsAuthCheck && requestedRights === 0) {
|
||||
return {
|
||||
allowed: true,
|
||||
requires_login: false,
|
||||
@@ -22,14 +37,22 @@ export async function evaluateRouteAccess(route = {}, securityService) {
|
||||
};
|
||||
}
|
||||
|
||||
if (options.require_user && !securityState.isAuthenticated) {
|
||||
if (!securityState.initialized || securityState.loading) {
|
||||
return {
|
||||
allowed: false,
|
||||
requires_login: true,
|
||||
reason: 'Login required for route'
|
||||
requires_login: false,
|
||||
pending: true,
|
||||
reason: 'Security state is still initializing'
|
||||
};
|
||||
}
|
||||
|
||||
if (needsAuthCheck) {
|
||||
const authResult = evaluateAuthRequirements(securityState, authRequirements);
|
||||
if (!authResult.allowed) {
|
||||
return authResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedRights !== 0) {
|
||||
return securityService.userPermitted(requestedRights, resourcePath, { redirectOnFail: false });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { setRouterPath } from '../../platform/compat.js';
|
||||
import { getRouterPath, setRouterPath } from '../../platform/compat.js';
|
||||
import { normalizeRightsInput } from '../model/rights.js';
|
||||
import { BasicSecurityPolicy, BstoreSecurityPolicy } from '../policy/index.js';
|
||||
import { ApiSecurityPolicy, BasicSecurityPolicy, BstoreSecurityPolicy } from '../policy/index.js';
|
||||
import { createSecurityRequestInterceptor, createSecurityResponseInterceptor } from './api-auth.js';
|
||||
|
||||
const DEFAULT_SECURITY_CONFIG = {
|
||||
@@ -44,6 +44,9 @@ class SecurityService {
|
||||
this.state = createInitialState();
|
||||
this.listeners = new Set();
|
||||
this.apiHooksInstalled = false;
|
||||
this.initPromise = null;
|
||||
this.registeredResourceKeys = new Set();
|
||||
this.resourceRegistrationPromises = new Map();
|
||||
}
|
||||
|
||||
subscribe(listener) {
|
||||
@@ -73,6 +76,9 @@ class SecurityService {
|
||||
}
|
||||
|
||||
_resolvePolicy(config) {
|
||||
if (config.provider === 'api') {
|
||||
return new ApiSecurityPolicy(config);
|
||||
}
|
||||
if (config.provider === 'bstore') {
|
||||
return new BstoreSecurityPolicy(config);
|
||||
}
|
||||
@@ -81,7 +87,7 @@ class SecurityService {
|
||||
|
||||
async init(config = {}) {
|
||||
const normalizedConfig = normalizeSecurityConfig(config);
|
||||
|
||||
const initTask = (async () => {
|
||||
if (!normalizedConfig.enabled) {
|
||||
this.setState({
|
||||
...createInitialState(),
|
||||
@@ -100,7 +106,7 @@ class SecurityService {
|
||||
enabled: true,
|
||||
provider: normalizedConfig.provider,
|
||||
requireLogin: normalizedConfig.require_login,
|
||||
config: normalizedConfig
|
||||
config: normalizedConfig,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -145,6 +151,23 @@ class SecurityService {
|
||||
});
|
||||
}
|
||||
|
||||
return this.state;
|
||||
})();
|
||||
this.initPromise = initTask;
|
||||
|
||||
return initTask;
|
||||
}
|
||||
|
||||
async waitUntilInitialized() {
|
||||
if (this.state.initialized || !this.initPromise) {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.initPromise;
|
||||
} catch {
|
||||
// State already captures the initialization failure.
|
||||
}
|
||||
return this.state;
|
||||
}
|
||||
|
||||
@@ -161,12 +184,14 @@ class SecurityService {
|
||||
if (!this.state.session?.jwt_token) {
|
||||
return config;
|
||||
}
|
||||
const incomingHeaders = config.headers;
|
||||
const headers = incomingHeaders instanceof Headers
|
||||
? new Headers(incomingHeaders)
|
||||
: new Headers(incomingHeaders || {});
|
||||
headers.set('Authorization', `Bearer ${this.state.session.jwt_token}`);
|
||||
return {
|
||||
...config,
|
||||
headers: {
|
||||
...(config.headers || {}),
|
||||
Authorization: `Bearer ${this.state.session.jwt_token}`
|
||||
}
|
||||
headers
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,7 +201,10 @@ class SecurityService {
|
||||
|
||||
async handleUnauthorizedResponse() {
|
||||
if (this.state.isAuthenticated) {
|
||||
await this.logout({ redirect: true });
|
||||
const currentPath = await getRouterPath('/home');
|
||||
const loginRoute = this.state.config.login_route || '/login';
|
||||
const redirectTo = currentPath && currentPath !== loginRoute ? currentPath : null;
|
||||
await this.logout({ redirect: true, redirectTo });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +237,7 @@ class SecurityService {
|
||||
}
|
||||
|
||||
async logout(options = {}) {
|
||||
const { redirect = true } = options;
|
||||
const { redirect = true, redirectTo = null } = options;
|
||||
|
||||
if (this.state.policy && this.state.session) {
|
||||
try {
|
||||
@@ -233,7 +261,9 @@ class SecurityService {
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
await setRouterPath(this.state.config.logout_route || this.state.config.login_route || '/login', true);
|
||||
await setRouterPath(this.state.config.logout_route || this.state.config.login_route || '/login', true, {
|
||||
state: redirectTo ? { redirect_to: redirectTo } : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,10 +289,40 @@ class SecurityService {
|
||||
}
|
||||
|
||||
async registerResource(resource) {
|
||||
if (this.state.enabled && !this.state.initialized) {
|
||||
await this.waitUntilInitialized();
|
||||
}
|
||||
if (!this.state.policy) {
|
||||
return null;
|
||||
}
|
||||
return this.state.policy.registerResource(resource);
|
||||
if (this.state.provider === 'api' && !this.state.isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
const resourcePath = String(resource?.path || '').trim();
|
||||
const resourceType = String(resource?.type || '').trim();
|
||||
if (!resourcePath) {
|
||||
return null;
|
||||
}
|
||||
const key = `${resourceType}:${resourcePath}`;
|
||||
if (this.registeredResourceKeys.has(key)) {
|
||||
return null;
|
||||
}
|
||||
if (this.resourceRegistrationPromises.has(key)) {
|
||||
return this.resourceRegistrationPromises.get(key);
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
const registered = await this.state.policy.registerResource(resource);
|
||||
this.registeredResourceKeys.add(key);
|
||||
return registered;
|
||||
} finally {
|
||||
this.resourceRegistrationPromises.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
this.resourceRegistrationPromises.set(key, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async userRequired(options = {}) {
|
||||
@@ -275,7 +335,10 @@ class SecurityService {
|
||||
}
|
||||
|
||||
if (options.redirect !== false) {
|
||||
await setRouterPath(this.state.config.login_route || '/login', true);
|
||||
const currentPath = await getRouterPath('/home');
|
||||
await setRouterPath(this.state.config.login_route || '/login', true, {
|
||||
state: currentPath ? { redirect_to: currentPath } : null
|
||||
});
|
||||
}
|
||||
|
||||
return { allowed: false, requires_login: true, reason: 'User login required' };
|
||||
@@ -289,7 +352,10 @@ class SecurityService {
|
||||
if (!this.state.isAuthenticated) {
|
||||
const response = { allowed: false, requires_login: true, reason: 'User login required', matched_permits: [] };
|
||||
if (options.redirectOnFail) {
|
||||
await setRouterPath(this.state.config.login_route || '/login', true);
|
||||
const currentPath = await getRouterPath('/home');
|
||||
await setRouterPath(this.state.config.login_route || '/login', true, {
|
||||
state: currentPath ? { redirect_to: currentPath } : null
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -302,7 +368,10 @@ class SecurityService {
|
||||
);
|
||||
|
||||
if (!result.allowed && result.requires_login && options.redirectOnFail) {
|
||||
await setRouterPath(this.state.config.login_route || '/login', true);
|
||||
const currentPath = await getRouterPath('/home');
|
||||
await setRouterPath(this.state.config.login_route || '/login', true, {
|
||||
state: currentPath ? { redirect_to: currentPath } : null
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -351,11 +420,44 @@ class SecurityService {
|
||||
await this.state.policy.changePassword(this.state.user.id, passwordInput);
|
||||
}
|
||||
|
||||
async uploadAccountAvatar(file) {
|
||||
if (!this.state.policy || !this.state.user || typeof this.state.policy.uploadAccountAvatar !== 'function') {
|
||||
throw new Error('Avatar upload is not available');
|
||||
}
|
||||
const profile = await this.state.policy.uploadAccountAvatar(this.state.user.id, file);
|
||||
const user = await this.state.policy.getUser(this.state.user.id);
|
||||
this.setState({
|
||||
profile,
|
||||
user
|
||||
});
|
||||
return profile;
|
||||
}
|
||||
|
||||
async listUsers() { return this.state.policy ? this.state.policy.listUsers() : []; }
|
||||
async createUser(userData) { return this.state.policy ? this.state.policy.createUser(userData) : null; }
|
||||
async updateUser(userId, patch) { return this.state.policy ? this.state.policy.updateUser(userId, patch) : null; }
|
||||
async deleteUser(userId) { return this.state.policy ? this.state.policy.deleteUser(userId) : null; }
|
||||
async listRoles() { return this.state.policy ? this.state.policy.listRoles() : []; }
|
||||
async listSubjects() { return this.state.policy ? this.state.policy.listSubjects() : []; }
|
||||
async createRole(roleData) { return this.state.policy ? this.state.policy.createRole(roleData) : null; }
|
||||
async updateRole(roleId, patch) { return this.state.policy ? this.state.policy.updateRole(roleId, patch) : null; }
|
||||
async deleteRole(roleId) { return this.state.policy ? this.state.policy.deleteRole(roleId) : null; }
|
||||
async listRealms() { return this.state.policy ? this.state.policy.listRealms() : []; }
|
||||
async createRealm(realmData) { return this.state.policy ? this.state.policy.createRealm(realmData) : null; }
|
||||
async updateRealm(realmId, patch) { return this.state.policy ? this.state.policy.updateRealm(realmId, patch) : null; }
|
||||
async deleteRealm(realmId) { return this.state.policy ? this.state.policy.deleteRealm(realmId) : null; }
|
||||
async listResources() { return this.state.policy ? this.state.policy.listResources() : []; }
|
||||
async createResource(resource) { return this.state.policy ? this.state.policy.registerResource(resource) : null; }
|
||||
async updateResource(path, patch) { return this.state.policy ? this.state.policy.updateResource(path, patch) : null; }
|
||||
async deleteResource(path) {
|
||||
return this.state.policy && typeof this.state.policy.deleteResource === 'function'
|
||||
? this.state.policy.deleteResource(path)
|
||||
: null;
|
||||
}
|
||||
async listPermits() { return this.state.policy ? this.state.policy.listPermits() : []; }
|
||||
async createPermit(permit) { return this.state.policy ? this.state.policy.grantPermit(permit) : null; }
|
||||
async updatePermit(permitId, patch) { return this.state.policy ? this.state.policy.updatePermit(permitId, patch) : null; }
|
||||
async deletePermit(permitId) { return this.state.policy ? this.state.policy.revokePermit(permitId) : null; }
|
||||
}
|
||||
|
||||
export const securityService = new SecurityService();
|
||||
|
||||
+48
-7
@@ -126,6 +126,21 @@ function App({
|
||||
setActiveStyleThemeName(styleThemeName);
|
||||
}, [styleThemeName]);
|
||||
|
||||
useEffect(() => {
|
||||
const resolvedDocumentBackground =
|
||||
styleTheme?.themes?.[activeTheme]?.bgPage
|
||||
|| styleTheme?.themes?.[activeTheme]?.background
|
||||
|| null;
|
||||
|
||||
if (resolvedDocumentBackground) {
|
||||
envModuleRef.setDocumentBackground(resolvedDocumentBackground);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.style.colorScheme = activeTheme === THEME_MODES.DARK ? 'dark' : 'light';
|
||||
}
|
||||
}, [activeTheme, styleTheme]);
|
||||
|
||||
// Load theme preferences from storage on mount
|
||||
useEffect(() => {
|
||||
if (initialThemePreferencesLoaded) {
|
||||
@@ -232,11 +247,18 @@ function App({
|
||||
const servicesTrace = startTrace('App', 'getPlatformServices');
|
||||
const services = await getPlatformServices();
|
||||
servicesTrace.end();
|
||||
securityService.installAPIClient(services.api_client);
|
||||
const initialSecurityConfig = initialProfile?.security || {};
|
||||
const securityTrace = startTrace('Security', 'init', { provider: initialSecurityConfig.provider ?? 'basic' });
|
||||
await securityService.init(initialSecurityConfig);
|
||||
const initialSecurityInitPromise = securityService.init(initialSecurityConfig)
|
||||
.then((state) => {
|
||||
securityTrace.end({ enabled: initialSecurityConfig.enabled === true });
|
||||
securityService.installAPIClient(services.api_client);
|
||||
return state;
|
||||
})
|
||||
.catch((error) => {
|
||||
securityTrace.fail(error);
|
||||
throw error;
|
||||
});
|
||||
|
||||
// Call onInit callback if provided (handles profile, env, modules, SW)
|
||||
let selectedProfile = null;
|
||||
@@ -247,11 +269,14 @@ function App({
|
||||
}
|
||||
|
||||
const selectedSecurityConfig = selectedProfile?.security || initialSecurityConfig;
|
||||
const finalizeSecurityInit = async () => {
|
||||
await initialSecurityInitPromise;
|
||||
if (JSON.stringify(selectedSecurityConfig) !== JSON.stringify(initialSecurityConfig)) {
|
||||
const selectedSecurityTrace = startTrace('Security', 're-init from selected profile', { provider: selectedSecurityConfig.provider ?? 'basic' });
|
||||
await securityService.init(selectedSecurityConfig);
|
||||
selectedSecurityTrace.end({ enabled: selectedSecurityConfig.enabled === true });
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedProfile?.__boot) {
|
||||
setBootResult(selectedProfile.__boot);
|
||||
@@ -294,6 +319,9 @@ function App({
|
||||
setSwStatus(await sw.getServiceWorkerStatus());
|
||||
|
||||
setInitialized(true);
|
||||
finalizeSecurityInit().catch((error) => {
|
||||
console.error('[Security] Background initialization failed:', error);
|
||||
});
|
||||
initTrace.end({
|
||||
shell: shellName,
|
||||
bootMode: selectedProfile?.__boot?.uiMode ?? 'runtime'
|
||||
@@ -350,10 +378,26 @@ function App({
|
||||
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(),
|
||||
listPermits: () => securityService.listPermits()
|
||||
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: {
|
||||
locale: envModuleRef.getLocaleSync(),
|
||||
@@ -366,7 +410,6 @@ function App({
|
||||
const effectiveBootMode = bootModeOverride ?? bootResult?.uiMode ?? 'runtime';
|
||||
const shouldRenderBootScreen = effectiveBootMode !== 'runtime' || (!initialized && showInitialBootSplash);
|
||||
const shouldHoldDuringInit = !initialized && !showInitialBootSplash;
|
||||
const shouldRenderLoginGate = initialized && !shouldRenderBootScreen && securityState.enabled && securityState.requireLogin && !securityState.isAuthenticated;
|
||||
let bootScreenContent = null;
|
||||
let appContent = null;
|
||||
|
||||
@@ -395,9 +438,7 @@ function App({
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRenderLoginGate) {
|
||||
appContent = <LoginPage />;
|
||||
} else if (!shouldRenderBootScreen && !shouldHoldDuringInit) {
|
||||
if (!shouldRenderBootScreen && !shouldHoldDuringInit) {
|
||||
appContent = (
|
||||
<Router initialPath={initialRoute}>
|
||||
{/* Declarative route registration (commented out - routes now registered programmatically via modules)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { Checkbox, Text, XStack } from 'tamagui';
|
||||
import { getIcon } from './IconMapper.jsx';
|
||||
|
||||
function toEntries(codeMap = {}, labels = null) {
|
||||
return Object.entries(codeMap).map(([key, code]) => ({
|
||||
key,
|
||||
code,
|
||||
label: labels?.[key] || key
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeCodedValue(codeMap = {}, value = 0) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.reduce((mask, key) => mask | (codeMap[key] || 0), 0);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.reduce((mask, key) => mask | (codeMap[key] || 0), 0);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.keys(codeMap).reduce((mask, key) => (value[key] ? mask | codeMap[key] : mask), 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function codedValueToArray(codeMap = {}, value = 0) {
|
||||
const mask = normalizeCodedValue(codeMap, value);
|
||||
return toEntries(codeMap)
|
||||
.filter((entry) => (mask & entry.code) !== 0)
|
||||
.map((entry) => entry.key);
|
||||
}
|
||||
|
||||
export function formatCodedValue(codeMap = {}, value = 0, labels = null) {
|
||||
const selected = new Set(codedValueToArray(codeMap, value));
|
||||
return toEntries(codeMap, labels)
|
||||
.filter((entry) => selected.has(entry.key))
|
||||
.map((entry) => entry.label);
|
||||
}
|
||||
|
||||
export function CodedCheckboxGroup({
|
||||
codeMap = {},
|
||||
labels = null,
|
||||
value = 0,
|
||||
onChange,
|
||||
disabled = false
|
||||
}) {
|
||||
const mask = normalizeCodedValue(codeMap, value);
|
||||
const entries = toEntries(codeMap, labels);
|
||||
const CheckIcon = getIcon('check');
|
||||
return (
|
||||
<XStack flexWrap="wrap" gap="$3">
|
||||
{entries.map((entry) => {
|
||||
const checked = (mask & entry.code) !== 0;
|
||||
return (
|
||||
<XStack key={entry.key} gap="$2" alignItems="center">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
borderColor="$lineStrong"
|
||||
backgroundColor="$bgPanel"
|
||||
focusStyle={{ borderColor: '$accent' }}
|
||||
onCheckedChange={(nextChecked) => {
|
||||
const nextMask = nextChecked === true ? (mask | entry.code) : (mask & ~entry.code);
|
||||
onChange?.(nextMask);
|
||||
}}
|
||||
>
|
||||
<Checkbox.Indicator>
|
||||
{CheckIcon ? <CheckIcon size="sm" color="$accent" /> : null}
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox>
|
||||
<Text>{entry.label}</Text>
|
||||
</XStack>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodedValueBadges({
|
||||
codeMap = {},
|
||||
labels = null,
|
||||
value = 0,
|
||||
compact = false
|
||||
}) {
|
||||
const items = formatCodedValue(codeMap, value, labels);
|
||||
if (compact) {
|
||||
return (
|
||||
<XStack flexWrap="nowrap" gap="$1.5" alignItems="center">
|
||||
{items.map((item) => (
|
||||
<XStack
|
||||
key={item}
|
||||
minWidth={20}
|
||||
justifyContent="center"
|
||||
paddingHorizontal="$1.5"
|
||||
paddingVertical="$0.5"
|
||||
borderRadius="$radiusMd"
|
||||
borderWidth={1}
|
||||
borderColor="$lineSubtle"
|
||||
backgroundColor="$bgPage"
|
||||
>
|
||||
<Text fontSize="$1" fontWeight="700" color="$textPrimary">{item}</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{items.map((item) => (
|
||||
<XStack
|
||||
key={item}
|
||||
paddingHorizontal="$2.5"
|
||||
paddingVertical="$1"
|
||||
borderRadius="$radiusLg"
|
||||
borderWidth={1}
|
||||
borderColor="$lineSubtle"
|
||||
backgroundColor="$bgPage"
|
||||
>
|
||||
<Text fontSize="$2" color="$textPrimary">{item}</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Input, Paragraph, ScrollView, Separator, Spinner, Text, XStack, YStack } from 'tamagui';
|
||||
import { getIcon } from './IconMapper.jsx';
|
||||
import { PageNavBar } from './PageNavBar.jsx';
|
||||
import { normalizeColumnsArray } from './grid/utils.js';
|
||||
import { getTypographyRoleProps } from '../styles/index.js';
|
||||
|
||||
@@ -67,6 +68,9 @@ function HeaderCell({ column, orderBy, order, onSort }) {
|
||||
const CaretUp = getIcon('caret-up');
|
||||
const CaretDown = getIcon('caret-down');
|
||||
const justifyContent = getColumnJustify(column.align);
|
||||
const nextSortDirection = !isActive ? 'ascending' : order === 'asc' ? 'descending' : 'ascending';
|
||||
const ChevronIcon = isActive ? (order === 'asc' ? CaretUp : CaretDown) : CaretDown;
|
||||
const iconColor = isActive ? '$textSecondary' : '$textMuted';
|
||||
|
||||
return (
|
||||
<XStack
|
||||
@@ -75,33 +79,25 @@ function HeaderCell({ column, orderBy, order, onSort }) {
|
||||
minWidth={column.minWidth || 120}
|
||||
alignItems="center"
|
||||
justifyContent={justifyContent}
|
||||
gap="$2"
|
||||
>
|
||||
<Button
|
||||
chromeless
|
||||
disabled={!sortable}
|
||||
onPress={() => sortable && onSort(column.id)}
|
||||
padding={0}
|
||||
justifyContent={justifyContent}
|
||||
width="100%"
|
||||
hoverStyle={sortable ? { backgroundColor: '$bgPage' } : undefined}
|
||||
pressStyle={sortable ? { backgroundColor: '$bgPanelElev' } : undefined}
|
||||
>
|
||||
<XStack width="100%" alignItems="center" justifyContent={justifyContent} gap="$2">
|
||||
<Text {...getTypographyRoleProps('tableHeader')} textAlign={column.align || 'left'} numberOfLines={1}>
|
||||
<Text flex={1} minWidth={0} {...getTypographyRoleProps('tableHeader')} textAlign={column.align || 'left'} numberOfLines={1}>
|
||||
{column.label}
|
||||
</Text>
|
||||
{sortable ? (
|
||||
isActive ? (
|
||||
order === 'asc'
|
||||
? (CaretUp ? <CaretUp size="xs" color="$textSecondary" /> : null)
|
||||
: (CaretDown ? <CaretDown size="xs" color="$textSecondary" /> : null)
|
||||
) : (
|
||||
CaretDown ? <CaretDown size="xs" color="$textMuted" style={{ opacity: 0.6 }} /> : null
|
||||
)
|
||||
<Button
|
||||
chromeless
|
||||
circular
|
||||
size="$2"
|
||||
flexShrink={0}
|
||||
aria-label={`Sort ${column.label} ${nextSortDirection}`}
|
||||
onPress={() => onSort(column.id)}
|
||||
hoverStyle={{ backgroundColor: '$bgPage' }}
|
||||
pressStyle={{ backgroundColor: '$bgPanelElev' }}
|
||||
icon={ChevronIcon ? <ChevronIcon size="xs" color={iconColor} /> : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</XStack>
|
||||
</Button>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,10 +166,6 @@ export function DirView({
|
||||
const [totalRecords, setTotalRecords] = useState(0);
|
||||
const resolvedSummaryDefinitions = summaryDefinitions || EMPTY_SUMMARY_DEFINITIONS;
|
||||
const RefreshIcon = getIcon('refresh');
|
||||
const FirstPageIcon = getIcon('first-page');
|
||||
const PreviousPageIcon = getIcon('chevron-left');
|
||||
const NextPageIcon = getIcon('chevron-right');
|
||||
const LastPageIcon = getIcon('last-page');
|
||||
const paddingRow = density === 'compact' ? '$2' : density === 'spacious' ? '$4' : '$3';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -209,15 +201,18 @@ export function DirView({
|
||||
filter_by: filterBy
|
||||
};
|
||||
|
||||
const [recordResult, summaryResult] = await Promise.all([
|
||||
dataModel.queryRecords(query),
|
||||
dataModel.querySummary(query, resolvedSummaryDefinitions)
|
||||
]);
|
||||
const recordPromise = dataModel.queryRecords(query);
|
||||
const summaryPromise = (
|
||||
showSummary && typeof dataModel.querySummary === 'function'
|
||||
? dataModel.querySummary(query, resolvedSummaryDefinitions)
|
||||
: Promise.resolve(null)
|
||||
);
|
||||
const [recordResult, summaryResult] = await Promise.all([recordPromise, summaryPromise]);
|
||||
|
||||
if (!cancelled) {
|
||||
setRecords(recordResult.rows || []);
|
||||
setTotalRecords(recordResult.total || 0);
|
||||
setSummary(summaryResult || null);
|
||||
setSummary(showSummary ? (summaryResult || null) : null);
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (!cancelled) {
|
||||
@@ -280,12 +275,13 @@ export function DirView({
|
||||
}
|
||||
|
||||
const IconComponent = action?.icon ? getIcon(action.icon) : null;
|
||||
const useChromeless = Boolean(action?.chromeless && !action?.label);
|
||||
return (
|
||||
<Button
|
||||
key={action?.id || action?.label || index}
|
||||
size="$3"
|
||||
theme={action?.theme}
|
||||
chromeless={action?.chromeless}
|
||||
chromeless={useChromeless}
|
||||
disabled={loading || action?.disabled}
|
||||
icon={IconComponent ? <IconComponent size={16} /> : undefined}
|
||||
onPress={action?.onPress}
|
||||
@@ -401,47 +397,21 @@ export function DirView({
|
||||
|
||||
{bodyFooterContent}
|
||||
|
||||
<XStack justifyContent="space-between" alignItems="center" gap="$3" flexWrap="wrap">
|
||||
<XStack justifyContent="space-between" alignItems="center" gap="$1" flexWrap="wrap">
|
||||
<Text color="$textMuted">
|
||||
Rows: {totalRecords}
|
||||
</Text>
|
||||
<XStack alignItems="center" gap="$2" flexWrap="wrap" padding="$1" borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusMd" backgroundColor="$bgPanel">
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
aria-label="First page"
|
||||
icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
<PageNavBar
|
||||
label={`Page ${currentPage} of ${totalPages}`}
|
||||
onFirstPage={() => setCurrentPage(1)}
|
||||
onPreviousPage={() => setCurrentPage((value) => Math.max(1, value - 1))}
|
||||
onNextPage={() => setCurrentPage((value) => Math.min(totalPages, value + 1))}
|
||||
onLastPage={() => setCurrentPage(totalPages)}
|
||||
firstDisabled={currentPage === 1 || loading}
|
||||
previousDisabled={currentPage === 1 || loading}
|
||||
nextDisabled={currentPage >= totalPages || loading}
|
||||
lastDisabled={currentPage >= totalPages || loading}
|
||||
/>
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
aria-label="Previous page"
|
||||
icon={PreviousPageIcon ? <PreviousPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={() => setCurrentPage((value) => Math.max(1, value - 1))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
/>
|
||||
<Text color="$textSecondary">
|
||||
Page {currentPage} of {totalPages}
|
||||
</Text>
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
aria-label="Next page"
|
||||
icon={NextPageIcon ? <NextPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={() => setCurrentPage((value) => Math.min(totalPages, value + 1))}
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
/>
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
aria-label="Last page"
|
||||
icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
/>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</YStack>
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ function fieldChrome(orientation, error, border) {
|
||||
position: embedded ? 'relative' : 'static',
|
||||
borderWidth: embedded && !borderless ? 1 : 0,
|
||||
borderColor: error ? '$danger' : '$lineSubtle',
|
||||
borderRadius: embedded ? '$4' : '$0',
|
||||
borderRadius: embedded ? '$radiusMd' : '$0',
|
||||
backgroundColor: embedded && !borderless ? '$bgPanel' : 'transparent',
|
||||
paddingTop: embedded && !borderless ? '$3.5' : '$0',
|
||||
paddingRight: embedded && !borderless ? '$3' : '$0',
|
||||
@@ -197,7 +197,7 @@ function ColorSwatch({ value }) {
|
||||
<YStack
|
||||
width={28}
|
||||
height={28}
|
||||
borderRadius="$3"
|
||||
borderRadius="$radiusSm"
|
||||
borderWidth={1}
|
||||
borderColor="$lineSubtle"
|
||||
backgroundColor={isHex ? normalized : '$bgPanelElev'}
|
||||
@@ -541,7 +541,7 @@ export function FieldControl({
|
||||
backgroundColor="$bgPanel"
|
||||
borderWidth={1}
|
||||
borderColor="$lineSubtle"
|
||||
borderRadius="$4"
|
||||
borderRadius="$radiusMd"
|
||||
elevation="$2"
|
||||
shadowColor="$shadowColor"
|
||||
>
|
||||
|
||||
@@ -236,7 +236,7 @@ function ColorField({ color, onPick }) {
|
||||
minWidth={0}
|
||||
minHeight={192}
|
||||
overflow="hidden"
|
||||
borderRadius="$3"
|
||||
borderRadius="$radiusMd"
|
||||
borderWidth={1}
|
||||
borderColor="$lineSubtle"
|
||||
backgroundColor={color}
|
||||
@@ -260,7 +260,7 @@ function PickerFrame({ width = 320, children }) {
|
||||
backgroundColor="$bgPanel"
|
||||
borderWidth={1}
|
||||
borderColor="$lineSubtle"
|
||||
borderRadius="$4"
|
||||
borderRadius="$radiusMd"
|
||||
shadowColor="$shadowColor"
|
||||
elevation="$3"
|
||||
>
|
||||
@@ -299,7 +299,7 @@ export function ColorPickerPopup({ value, onChange, onClose }) {
|
||||
<YStack
|
||||
marginTop="$1"
|
||||
height={44}
|
||||
borderRadius="$3"
|
||||
borderRadius="$radiusSm"
|
||||
borderWidth={1}
|
||||
borderColor="$lineSubtle"
|
||||
backgroundColor={value || '#000000'}
|
||||
@@ -308,7 +308,7 @@ export function ColorPickerPopup({ value, onChange, onClose }) {
|
||||
</XStack>
|
||||
<XStack justifyContent="space-between" alignItems="center">
|
||||
<Text fontSize="$2" color="$textSecondary">{String(value || '#000000').toUpperCase()}</Text>
|
||||
<Button size="$2" chromeless onPress={onClose}>Close</Button>
|
||||
<Button size="$2" onPress={onClose}>Close</Button>
|
||||
</XStack>
|
||||
</PickerFrame>
|
||||
);
|
||||
@@ -334,7 +334,7 @@ function DateSection({ value, onChange }) {
|
||||
<YStack
|
||||
gap="$2"
|
||||
minHeight={184}
|
||||
borderRadius="$3"
|
||||
borderRadius="$radiusMd"
|
||||
borderWidth={1}
|
||||
borderColor="$lineSubtle"
|
||||
padding="$4"
|
||||
@@ -381,7 +381,7 @@ function DateSection({ value, onChange }) {
|
||||
minWidth={0}
|
||||
height={34}
|
||||
paddingHorizontal="$0"
|
||||
borderRadius="$3"
|
||||
borderRadius="$radiusSm"
|
||||
borderWidth={isSelected ? 1 : (isToday ? 1 : 0)}
|
||||
borderColor={isSelected ? '$accent' : (isToday ? '$lineStrong' : 'transparent')}
|
||||
backgroundColor={isSelected ? '$accentBg' : 'transparent'}
|
||||
@@ -472,7 +472,7 @@ export function DateTimePickerPopup({ type = 'date-time', value, onChange, onClo
|
||||
<Text fontSize="$2" color="$textSecondary">
|
||||
{String(composeDateTimeValue(type, parts) || value || '').toUpperCase() || '—'}
|
||||
</Text>
|
||||
<Button size="$2" chromeless onPress={onClose}>Close</Button>
|
||||
<Button size="$2" onPress={onClose}>Close</Button>
|
||||
</XStack>
|
||||
</PickerFrame>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
YStack,
|
||||
} from 'tamagui';
|
||||
import { getIcon } from './IconMapper.jsx';
|
||||
import { CodedCheckboxGroup } from './CodedValues.jsx';
|
||||
import { pickFile } from '../../platform/compat.js';
|
||||
import { getTypographyRoleProps } from '../styles/index.js';
|
||||
|
||||
@@ -224,6 +225,21 @@ export function FormField({
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'coded-checkboxes') {
|
||||
return (
|
||||
<FieldShell label={label} error={error} helperText={helperText}>
|
||||
<CodedCheckboxGroup
|
||||
key={`${id}:${String(fieldValue)}`}
|
||||
codeMap={props.codeMap || {}}
|
||||
labels={props.labels || null}
|
||||
value={fieldValue}
|
||||
disabled={disabled}
|
||||
onChange={(nextValue) => onChange?.(id, nextValue)}
|
||||
/>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
// True boolean control instead of a Button-pretending-to-be-a-checkbox.
|
||||
// Switch is the right semantic for an on/off setting.
|
||||
if (type === 'checkbox') {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { SizableText } from 'tamagui';
|
||||
import { Avatar, SizableText } from 'tamagui';
|
||||
import {
|
||||
// navigation & shell
|
||||
House,
|
||||
@@ -152,6 +152,7 @@ import {
|
||||
Printer,
|
||||
Circle,
|
||||
Lightning,
|
||||
Gift,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
import { themeManager } from '../theme-controller.js';
|
||||
@@ -375,6 +376,7 @@ const iconMap = {
|
||||
'star': wrap(Star, 'Star'),
|
||||
'star-border': wrap(Star, 'Star'),
|
||||
'bookmark': wrap(BookmarkSimple, 'BookmarkSimple'),
|
||||
'gift': wrap(Gift, 'Gift'),
|
||||
|
||||
// ── Time & Calendar ────────────────────────────────────────────────────
|
||||
'calendar': wrap(Calendar, 'Calendar'),
|
||||
@@ -467,6 +469,55 @@ export function IconMapper({ iconName, size = DEFAULT_SIZE, color = '$textPrimar
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pictogram-style icon that renders either an image or a compact fallback glyph
|
||||
* while honoring the same semantic icon sizes used elsewhere in the shell.
|
||||
*/
|
||||
export function PictIcon({
|
||||
size = DEFAULT_SIZE,
|
||||
color = '$accentColor',
|
||||
image_url = '',
|
||||
label = '',
|
||||
fallback = '',
|
||||
...props
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const pixelSize = resolveSize(size);
|
||||
const backgroundColor = resolveColor(color, theme) || resolveColor('$accentColor', theme) || 'currentColor';
|
||||
const source = String(label || fallback || '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() || '')
|
||||
.join('') || 'A';
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
circular
|
||||
size={pixelSize}
|
||||
width={pixelSize}
|
||||
height={pixelSize}
|
||||
backgroundColor={backgroundColor}
|
||||
overflow="hidden"
|
||||
{...props}
|
||||
>
|
||||
{image_url ? (
|
||||
<Avatar.Image
|
||||
src={image_url}
|
||||
width={pixelSize}
|
||||
height={pixelSize}
|
||||
style={{ width: pixelSize, height: pixelSize, objectFit: 'cover' }}
|
||||
/>
|
||||
) : null}
|
||||
<Avatar.Fallback backgroundColor={backgroundColor}>
|
||||
<SizableText size={Math.max(Math.round(pixelSize * 0.48), 10)} color="white" fontWeight="700">
|
||||
{source}
|
||||
</SizableText>
|
||||
</Avatar.Fallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the px value for a semantic size token. Useful when laying out
|
||||
* non-icon children (avatars, badges) alongside icons.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Button, XStack, YStack, Text } from 'tamagui';
|
||||
import { Button, Separator, XStack, YStack, Text } from 'tamagui';
|
||||
import { getIcon } from './IconMapper.jsx';
|
||||
import { NotificationManager } from './Shell.jsx';
|
||||
import {
|
||||
@@ -90,6 +90,27 @@ export function MenuItemButton({
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSeparator = menuItem.type === 'separator' || menuItem.kind === 'separator';
|
||||
|
||||
if (isSeparator) {
|
||||
return (
|
||||
<YStack
|
||||
width={width !== undefined ? width : '100%'}
|
||||
paddingVertical={orientation === 'horizontal' ? '$1' : '$2'}
|
||||
paddingHorizontal={orientation === 'horizontal' ? '$1' : 0}
|
||||
style={style}
|
||||
testID={testID}
|
||||
{...otherProps}
|
||||
>
|
||||
<Separator
|
||||
vertical={orientation === 'horizontal'}
|
||||
borderColor="$lineSubtle"
|
||||
alignSelf={orientation === 'horizontal' ? 'stretch' : undefined}
|
||||
/>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine expand_mode: default based on orientation, but allow override
|
||||
// When collapsed, always use popup mode for groups (no room for inline expansion)
|
||||
const effectiveExpandMode = collapsed
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { Button, Text, XStack } from 'tamagui';
|
||||
import { getIcon } from './IconMapper.jsx';
|
||||
|
||||
export function PageNavBar({
|
||||
label = 'Page 1 of 1',
|
||||
labelControl = null,
|
||||
onFirstPage = undefined,
|
||||
onPreviousPage = undefined,
|
||||
onNextPage = undefined,
|
||||
onLastPage = undefined,
|
||||
firstDisabled = false,
|
||||
previousDisabled = false,
|
||||
nextDisabled = false,
|
||||
lastDisabled = false,
|
||||
outlined = true,
|
||||
size = '$3'
|
||||
}) {
|
||||
const FirstPageIcon = getIcon('first-page');
|
||||
const PreviousPageIcon = getIcon('chevron-left');
|
||||
const NextPageIcon = getIcon('chevron-right');
|
||||
const LastPageIcon = getIcon('last-page');
|
||||
|
||||
return (
|
||||
<XStack
|
||||
gap="$1"
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
padding="$1"
|
||||
borderWidth={outlined ? 1 : 0}
|
||||
borderColor={outlined ? '$lineSubtle' : 'transparent'}
|
||||
borderRadius="$radiusMd"
|
||||
backgroundColor="$bgPanel"
|
||||
>
|
||||
<Button
|
||||
size={size}
|
||||
chromeless
|
||||
aria-label="First page"
|
||||
disabled={firstDisabled}
|
||||
icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={onFirstPage}
|
||||
/>
|
||||
<Button
|
||||
size={size}
|
||||
chromeless
|
||||
aria-label="Previous page"
|
||||
disabled={previousDisabled}
|
||||
icon={PreviousPageIcon ? <PreviousPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={onPreviousPage}
|
||||
/>
|
||||
{labelControl || (
|
||||
<Text color="$textSecondary">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
size={size}
|
||||
chromeless
|
||||
aria-label="Next page"
|
||||
disabled={nextDisabled}
|
||||
icon={NextPageIcon ? <NextPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={onNextPage}
|
||||
/>
|
||||
<Button
|
||||
size={size}
|
||||
chromeless
|
||||
aria-label="Last page"
|
||||
disabled={lastDisabled}
|
||||
icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={onLastPage}
|
||||
/>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageNavBar;
|
||||
@@ -21,22 +21,18 @@ function getHeaderSizeStyles(headerSize) {
|
||||
const sizeMap = {
|
||||
1: {
|
||||
padding: '$3',
|
||||
borderRadius: '$4',
|
||||
minHeight: 64
|
||||
},
|
||||
2: {
|
||||
padding: '$2',
|
||||
borderRadius: '$3',
|
||||
minHeight: 48
|
||||
},
|
||||
3: {
|
||||
padding: '$1.5',
|
||||
borderRadius: '$2',
|
||||
minHeight: 44
|
||||
},
|
||||
4: {
|
||||
padding: '$1',
|
||||
borderRadius: '$2',
|
||||
minHeight: 36
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
|
||||
import { MenuItem } from '../../platform/menu.js';
|
||||
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
|
||||
import { MenuItemButton } from './MenuItemButton.jsx';
|
||||
import { PictIcon } from './IconMapper.jsx';
|
||||
|
||||
function createSecurityRenderContext(securityState) {
|
||||
return {
|
||||
@@ -49,16 +50,46 @@ export function PersonalMenuItem({
|
||||
}
|
||||
}
|
||||
|
||||
const displayLabel = String(
|
||||
securityState.profile?.display_name ||
|
||||
securityState.user?.display_name ||
|
||||
securityState.user?.username ||
|
||||
'Account'
|
||||
).trim() || 'Account';
|
||||
const imageUrl = securityState.profile?.image_url || securityState.user?.image_url || '';
|
||||
const avatarLabel = securityState.profile?.display_name || securityState.user?.display_name || securityState.user?.username || 'Account';
|
||||
const PersonalAvatarIcon = (props) => (
|
||||
<PictIcon
|
||||
{...props}
|
||||
color="$accentColor"
|
||||
image_url={imageUrl}
|
||||
label={avatarLabel}
|
||||
fallback="A"
|
||||
/>
|
||||
);
|
||||
PersonalAvatarIcon.displayName = 'PersonalAvatarIcon';
|
||||
|
||||
return new MenuItem({
|
||||
id: 'personal-smart',
|
||||
label: 'Account',
|
||||
icon: 'account',
|
||||
label: displayLabel,
|
||||
icon: PersonalAvatarIcon,
|
||||
style: 'both',
|
||||
invoke_type: 'page',
|
||||
invoke_target: '/account',
|
||||
items: visibleChildren
|
||||
});
|
||||
}, [personalRoot, security, securityState.config?.login_route, securityState.enabled, securityState.isAuthenticated]);
|
||||
}, [
|
||||
personalRoot,
|
||||
security,
|
||||
securityState.config?.login_route,
|
||||
securityState.enabled,
|
||||
securityState.isAuthenticated,
|
||||
securityState.profile?.display_name,
|
||||
securityState.profile?.image_url,
|
||||
securityState.user?.display_name,
|
||||
securityState.user?.image_url,
|
||||
securityState.user?.username
|
||||
]);
|
||||
|
||||
if (!resolvedMenuItem) {
|
||||
return null;
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ProgressBar({
|
||||
position="relative"
|
||||
width="100%"
|
||||
height={trackHeight}
|
||||
borderRadius="$1"
|
||||
borderRadius="$radiusSm"
|
||||
backgroundColor="$lineSubtle"
|
||||
overflow="hidden"
|
||||
>
|
||||
@@ -101,7 +101,7 @@ export function ProgressBar({
|
||||
left={0}
|
||||
height="100%"
|
||||
width={determinateWidth}
|
||||
borderRadius="$1"
|
||||
borderRadius="$radiusSm"
|
||||
backgroundColor="$accent"
|
||||
/>
|
||||
) : (
|
||||
@@ -110,7 +110,7 @@ export function ProgressBar({
|
||||
top={0}
|
||||
height="100%"
|
||||
width="42%"
|
||||
borderRadius="$1"
|
||||
borderRadius="$radiusSm"
|
||||
backgroundColor="$accent"
|
||||
left={`${offset}%`}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ import React, { Suspense, createContext, useContext, useState, useEffect, useCal
|
||||
import { InvokeHandlers } from '../../platform/menu.js';
|
||||
import { openExternalURL, getRouterPath, setRouterPath, subscribeToPathChanges } from '../../platform/compat.js';
|
||||
import { ErrorPage } from '../../security/pages/ErrorPage.jsx';
|
||||
import { LoginPage } from '../../security/pages/LoginPage.jsx';
|
||||
import { LoginDialog, LoginPage } from '../../security/pages/LoginPage.jsx';
|
||||
import { evaluateRouteAccess } from '../../security/runtime/route-guards.js';
|
||||
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
|
||||
|
||||
@@ -394,6 +394,7 @@ function SelectedComponent({ placement, fallback }) {
|
||||
evaluating: true,
|
||||
allowed: true,
|
||||
requires_login: false,
|
||||
pending: false,
|
||||
reason: ''
|
||||
});
|
||||
|
||||
@@ -413,6 +414,7 @@ function SelectedComponent({ placement, fallback }) {
|
||||
evaluating: false,
|
||||
allowed: true,
|
||||
requires_login: false,
|
||||
pending: false,
|
||||
reason: ''
|
||||
});
|
||||
return () => {
|
||||
@@ -425,6 +427,7 @@ function SelectedComponent({ placement, fallback }) {
|
||||
evaluating: false,
|
||||
allowed: true,
|
||||
requires_login: false,
|
||||
pending: false,
|
||||
reason: securityState.enabled ? 'Route has no security requirements' : 'Security disabled'
|
||||
});
|
||||
return () => {
|
||||
@@ -463,9 +466,13 @@ function SelectedComponent({ placement, fallback }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (guardState.pending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!guardState.allowed) {
|
||||
if (guardState.requires_login) {
|
||||
return <LoginPage compact subtitle="This route requires an authenticated user." />;
|
||||
return <LoginDialog subtitle="This route requires an authenticated user." />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -891,10 +898,8 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
const unsubscribe = subscribeToPathChanges((path, state) => {
|
||||
console.log('[Router] Browser URL changed (popstate):', path);
|
||||
|
||||
// Find matching route
|
||||
const match = findRoute(path);
|
||||
if (match) {
|
||||
// Update internal state to match browser URL
|
||||
// Always adopt the browser path first. Route registration can lag
|
||||
// behind URL changes during boot or auth redirects.
|
||||
setNavigationState((prevState) => {
|
||||
const existingIndex = prevState.history.findIndex(entry => entry.path === path);
|
||||
if (existingIndex >= 0) {
|
||||
@@ -904,7 +909,7 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
currentPath: path,
|
||||
routeState: state
|
||||
};
|
||||
} else {
|
||||
}
|
||||
const newHistory = [...prevState.history, { path, state, timestamp: Date.now() }];
|
||||
return {
|
||||
...prevState,
|
||||
@@ -913,15 +918,13 @@ export function Router({ initialPath = '/', children, onRouteChange }) {
|
||||
currentPath: path,
|
||||
routeState: state
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Call onRouteChange callback
|
||||
if (onRouteChangeRef.current) {
|
||||
const match = findRoute(path);
|
||||
if (match && onRouteChangeRef.current) {
|
||||
onRouteChangeRef.current(path, match.component, match.params, state);
|
||||
}
|
||||
} else {
|
||||
console.warn('[Router] Browser URL changed to unknown path:', path);
|
||||
} else if (!match) {
|
||||
console.warn('[Router] Browser URL changed to path pending route registration:', path);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||
import { XStack, YStack, Text, Button } from 'tamagui';
|
||||
import { XStack, YStack, Text, Button, Spinner } from 'tamagui';
|
||||
import RecordsModel from '../../data/RecordsModel.js';
|
||||
import { getIcon } from './IconMapper.jsx';
|
||||
import { SidePanelShell } from './SidePanelShell.jsx';
|
||||
import { networkActivityManager } from '../../platform/api.js';
|
||||
|
||||
// ============================================================================
|
||||
// Shell Context
|
||||
@@ -627,6 +628,7 @@ export function ShellProvider({
|
||||
// Toast state
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(notificationCenterManager.isOpen());
|
||||
const [networkActivity, setNetworkActivity] = useState(networkActivityManager.getState());
|
||||
|
||||
// Initialize shell manager with setters
|
||||
useEffect(() => {
|
||||
@@ -647,6 +649,8 @@ export function ShellProvider({
|
||||
notificationCenterManager._init(setNotificationsOpen);
|
||||
}, []);
|
||||
|
||||
useEffect(() => networkActivityManager.subscribe(setNetworkActivity), []);
|
||||
|
||||
// Update shell manager state when it changes
|
||||
useEffect(() => {
|
||||
shellManager._updateState({
|
||||
@@ -711,6 +715,7 @@ export function ShellProvider({
|
||||
return (
|
||||
<ShellContext.Provider value={value}>
|
||||
{children}
|
||||
<NetworkActivityOverlay visible={networkActivity.visible} />
|
||||
<NotificationCenterPanel open={notificationsOpen} />
|
||||
</ShellContext.Provider>
|
||||
);
|
||||
@@ -769,7 +774,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
|
||||
backgroundColor={style.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={style.borderColor}
|
||||
borderRadius="$4"
|
||||
borderRadius="$radiusMd"
|
||||
padding="$3"
|
||||
minWidth={300}
|
||||
maxWidth={400}
|
||||
@@ -833,7 +838,7 @@ function NotificationRecord({ record, onDismiss }) {
|
||||
backgroundColor={style.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={style.borderColor}
|
||||
borderRadius="$4"
|
||||
borderRadius="$radiusMd"
|
||||
padding="$3"
|
||||
gap="$2"
|
||||
alignItems="flex-start"
|
||||
@@ -1023,6 +1028,46 @@ export function ToastViewport() {
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkActivityOverlay({ visible = false }) {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack
|
||||
position="fixed"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
zIndex={21500}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
pointerEvents="none"
|
||||
>
|
||||
<YStack
|
||||
padding="$4"
|
||||
gap="$2"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="$bgPanel"
|
||||
borderWidth={1}
|
||||
borderColor="$lineSubtle"
|
||||
borderRadius="$radiusLg"
|
||||
shadowColor="$shadowColor"
|
||||
shadowOpacity={0.15}
|
||||
shadowRadius={12}
|
||||
elevation={6}
|
||||
>
|
||||
<Spinner size="large" color="$accentColor" />
|
||||
<Text color="$textSecondary" fontSize="$3">
|
||||
Working...
|
||||
</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Shell Placement
|
||||
// ============================================================================
|
||||
|
||||
@@ -9,11 +9,12 @@ import { getIcon } from './IconMapper.jsx';
|
||||
*/
|
||||
function ActionButton({ action }) {
|
||||
const IconComponent = action?.icon ? getIcon(action.icon) : null;
|
||||
const useChromeless = Boolean(action?.chromeless && !action?.label);
|
||||
return (
|
||||
<Button
|
||||
size="$3"
|
||||
theme={action?.theme}
|
||||
chromeless={action?.chromeless}
|
||||
chromeless={useChromeless}
|
||||
disabled={action?.disabled}
|
||||
onPress={action?.onPress}
|
||||
icon={IconComponent ? <IconComponent size="sm" /> : undefined}
|
||||
|
||||
@@ -242,7 +242,7 @@ function TopBarWide({
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{/* Right Side — secondary actions get a hairline separator + tighter gap */}
|
||||
{/* Right Side */}
|
||||
{(effectiveRightWidth > 0 || effectiveRightWidth === 'auto') && (
|
||||
<XStack
|
||||
width={effectiveRightWidth === 'auto' ? undefined : effectiveRightWidth}
|
||||
@@ -256,7 +256,38 @@ function TopBarWide({
|
||||
borderLeftColor="$lineSubtle"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{organizedChildren.sections.rightSide}
|
||||
{organizedChildren.secondaryMenuItems.length > 0 && (
|
||||
<XStack alignItems="center" gap="$1">
|
||||
{organizedChildren.secondaryMenuItems.map((item) => (
|
||||
<MenuItemButton
|
||||
key={item.id || item.path}
|
||||
menuItem={item}
|
||||
orientation="horizontal"
|
||||
displayStyle="icon_only"
|
||||
padding="$1"
|
||||
stateVersion={organizedChildren.menuVersion}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem && (
|
||||
<XStack
|
||||
alignItems="center"
|
||||
paddingLeft={organizedChildren.secondaryMenuItems.length > 0 ? "$2" : undefined}
|
||||
marginLeft={organizedChildren.secondaryMenuItems.length > 0 ? "$1" : undefined}
|
||||
borderLeftWidth={organizedChildren.secondaryMenuItems.length > 0 ? 1 : 0}
|
||||
borderLeftColor="$lineSubtle"
|
||||
>
|
||||
<PersonalMenuItem
|
||||
key="personal-menu"
|
||||
personalRoot={organizedChildren.personalRoot}
|
||||
orientation="horizontal"
|
||||
expand_mode="popup"
|
||||
stateVersion={organizedChildren.menuVersion}
|
||||
/>
|
||||
</XStack>
|
||||
)}
|
||||
</XStack>
|
||||
)}
|
||||
</XStack>
|
||||
|
||||
@@ -16,12 +16,13 @@ function renderToolbarItem(item) {
|
||||
|
||||
if (item.kind === 'button') {
|
||||
const IconComponent = item.icon ? getIcon(item.icon) : null;
|
||||
const useChromeless = Boolean(item.chromeless && !item.label);
|
||||
return (
|
||||
<Button
|
||||
key={item.key || item.label}
|
||||
size="$3"
|
||||
theme={item.theme}
|
||||
chromeless={item.chromeless}
|
||||
chromeless={useChromeless}
|
||||
disabled={item.disabled}
|
||||
icon={IconComponent ? <IconComponent size="sm" /> : undefined}
|
||||
onPress={item.onClick || item.onPress}
|
||||
|
||||
@@ -11,10 +11,9 @@ export function createPanelGridViewProps(overrides = {}) {
|
||||
export function createTableGridViewProps(overrides = {}) {
|
||||
return {
|
||||
direction: 'vertical',
|
||||
headerSize: 3.25,
|
||||
bodySize: 20,
|
||||
footerSize: 3.5,
|
||||
headerSize: 'auto',
|
||||
bodySize: 'auto',
|
||||
footerSize: 'auto',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Button, Checkbox, ScrollView, Separator, Text, XStack, YStack } from 'tamagui';
|
||||
import { getIcon } from '../IconMapper.jsx';
|
||||
import { PageNavBar } from '../PageNavBar.jsx';
|
||||
import { useGridView } from './context.js';
|
||||
import { getTypographyRoleProps } from '../../styles/index.js';
|
||||
import {
|
||||
@@ -98,6 +99,16 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
|
||||
const selectedCount = allIds.filter((id) => grid.selectedIds.has(id)).length;
|
||||
const allSelected = allIds.length > 0 && selectedCount === allIds.length;
|
||||
const someSelected = selectedCount > 0 && !allSelected;
|
||||
const getNextSortDirection = (column) => {
|
||||
const activeSort = grid.sortBy.find((entry) => entry.field === column.field);
|
||||
if (!activeSort) {
|
||||
return 'ascending';
|
||||
}
|
||||
if (activeSort.direction === 'asc') {
|
||||
return 'descending';
|
||||
}
|
||||
return 'none';
|
||||
};
|
||||
|
||||
return (
|
||||
<XStack
|
||||
@@ -105,8 +116,11 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
|
||||
borderTopWidth={showTopBorder ? 1 : 0}
|
||||
borderBottomWidth={1}
|
||||
borderColor="$lineSubtle"
|
||||
backgroundColor="transparent"
|
||||
backgroundColor="$bgPanel"
|
||||
paddingHorizontal="$2"
|
||||
paddingTop="$1"
|
||||
paddingBottom="$1"
|
||||
gap="$1"
|
||||
>
|
||||
{grid.selectable || grid.nested ? (
|
||||
<XStack width={36} alignItems="center" justifyContent="center">
|
||||
@@ -132,24 +146,26 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
|
||||
const activeSort = grid.sortBy.find((entry) => entry.field === column.field);
|
||||
const isActive = Boolean(activeSort);
|
||||
const direction = activeSort?.direction;
|
||||
const sortButtonLabel = `Sort ${column.label} ${getNextSortDirection(column)}`;
|
||||
const ChevronIcon = isActive
|
||||
? (direction === 'asc' ? CaretUp : CaretDown)
|
||||
: CaretDown;
|
||||
const iconColor = isActive ? '$textSecondary' : '$textMuted';
|
||||
|
||||
return (
|
||||
<Button
|
||||
<XStack
|
||||
key={column.field}
|
||||
chromeless
|
||||
disabled={!column.sortable}
|
||||
onPress={() => column.sortable && grid.toggleSort(column.field)}
|
||||
justifyContent={getColumnJustify(column.align)}
|
||||
alignItems="center"
|
||||
paddingVertical="$3"
|
||||
minHeight={44}
|
||||
paddingHorizontal="$2"
|
||||
paddingVertical="$2"
|
||||
gap="$2"
|
||||
{...getColumnLayoutStyle(column)}
|
||||
hoverStyle={column.sortable ? { backgroundColor: '$bgPage' } : undefined}
|
||||
pressStyle={column.sortable ? { backgroundColor: '$bgPanelElev' } : undefined}
|
||||
>
|
||||
<XStack width="100%" alignItems="center" justifyContent={getColumnJustify(column.align)} gap="$2">
|
||||
<Text
|
||||
width="auto"
|
||||
flex={1}
|
||||
minWidth={0}
|
||||
textAlign={column.align || 'left'}
|
||||
numberOfLines={1}
|
||||
{...getTypographyRoleProps('tableHeader')}
|
||||
@@ -157,16 +173,19 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
|
||||
{column.label}
|
||||
</Text>
|
||||
{column.sortable ? (
|
||||
isActive ? (
|
||||
direction === 'asc'
|
||||
? (CaretUp ? <CaretUp size="xs" color="$textSecondary" /> : null)
|
||||
: (CaretDown ? <CaretDown size="xs" color="$textSecondary" /> : null)
|
||||
) : (
|
||||
CaretDown ? <CaretDown size="xs" color="$textMuted" style={{ opacity: 0.6 }} /> : null
|
||||
)
|
||||
<Button
|
||||
chromeless
|
||||
circular
|
||||
size="$2"
|
||||
flexShrink={0}
|
||||
aria-label={sortButtonLabel}
|
||||
onPress={() => grid.toggleSort(column.field)}
|
||||
hoverStyle={{ backgroundColor: '$bgPage' }}
|
||||
pressStyle={{ backgroundColor: '$bgPanelElev' }}
|
||||
icon={ChevronIcon ? <ChevronIcon size="xs" color={iconColor} /> : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</XStack>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
@@ -247,10 +266,6 @@ export function TableBodyView({ visible = true }) {
|
||||
|
||||
export function TableFooter({ visible = true }) {
|
||||
const grid = useGridView();
|
||||
const FirstPageIcon = getIcon('first-page');
|
||||
const PreviousPageIcon = getIcon('chevron-left');
|
||||
const NextPageIcon = getIcon('chevron-right');
|
||||
const LastPageIcon = getIcon('last-page');
|
||||
|
||||
if (visible === false) {
|
||||
return null;
|
||||
@@ -261,8 +276,11 @@ export function TableFooter({ visible = true }) {
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap="$3"
|
||||
padding="$3"
|
||||
minHeight={56}
|
||||
paddingTop="$1"
|
||||
paddingRight="$3"
|
||||
paddingBottom="$1"
|
||||
paddingLeft="$3"
|
||||
minHeight={64}
|
||||
borderTopWidth={1}
|
||||
borderTopColor="$lineSubtle"
|
||||
backgroundColor="$bgPanel"
|
||||
@@ -272,43 +290,18 @@ export function TableFooter({ visible = true }) {
|
||||
{grid.total} records
|
||||
</Text>
|
||||
|
||||
<XStack gap="$1" alignItems="center" flexWrap="wrap" padding="$1" borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusMd" backgroundColor="$bgPanel">
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
circular
|
||||
disabled={grid.currentPage <= 1}
|
||||
icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={() => grid.setPage(1)}
|
||||
<PageNavBar
|
||||
outlined={false}
|
||||
label={`Page ${grid.currentPage} of ${grid.pageCount}`}
|
||||
onFirstPage={() => grid.setPage(1)}
|
||||
onPreviousPage={() => grid.setPage(grid.currentPage - 1)}
|
||||
onNextPage={() => grid.setPage(grid.currentPage + 1)}
|
||||
onLastPage={() => grid.setPage(grid.pageCount)}
|
||||
firstDisabled={grid.currentPage <= 1}
|
||||
previousDisabled={grid.currentPage <= 1}
|
||||
nextDisabled={grid.currentPage >= grid.pageCount}
|
||||
lastDisabled={grid.currentPage >= grid.pageCount}
|
||||
/>
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
circular
|
||||
disabled={grid.currentPage <= 1}
|
||||
icon={PreviousPageIcon ? <PreviousPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={() => grid.setPage(grid.currentPage - 1)}
|
||||
/>
|
||||
<Text color="$textSecondary">
|
||||
Page {grid.currentPage} of {grid.pageCount}
|
||||
</Text>
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
circular
|
||||
disabled={grid.currentPage >= grid.pageCount}
|
||||
icon={NextPageIcon ? <NextPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={() => grid.setPage(grid.currentPage + 1)}
|
||||
/>
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
circular
|
||||
disabled={grid.currentPage >= grid.pageCount}
|
||||
icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={() => grid.setPage(grid.pageCount)}
|
||||
/>
|
||||
</XStack>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,14 @@ export { ColorPickerPopup, DateTimePickerPopup } from './FieldControlPickers.jsx
|
||||
export { Router, useRouter, useRoute } from './Router.jsx';
|
||||
export { Page, default as PageDefault } from './Page.jsx';
|
||||
export { ProgressBar, default as ProgressBarDefault } from './ProgressBar.jsx';
|
||||
export { PageNavBar, default as PageNavBarDefault } from './PageNavBar.jsx';
|
||||
export { Panel, default as PanelDefault } from './Panel.jsx';
|
||||
export { SettingsPanel, default as SettingsPanelDefault } from './SettingsPanel.jsx';
|
||||
export { GeneralConfig, default as GeneralConfigDefault } from './GeneralConfig.jsx';
|
||||
export { IdentityConfig, default as IdentityConfigDefault } from './IdentityConfig.jsx';
|
||||
export { StorageAdapter } from './storage/StorageAdapter.js';
|
||||
export { OpfsStorageAdapter, default as OpfsStorageAdapterDefault } from './storage/OpfsStorageAdapter.js';
|
||||
export { registerStorageFileView, default as StorageBrowser, default as StorageBrowserDefault } from './storage/StorageBrowser.jsx';
|
||||
export { registerShell, unregisterShell, resolveRegisteredShell, listRegisteredShells, clearRegisteredShells } from './shell-registry.js';
|
||||
export * from './grid/index.js';
|
||||
export { getTypographyRoleProps, getStyleTypography, TYPOGRAPHY_ROLE_KEYS } from '../styles/index.js';
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { StorageAdapter } from './StorageAdapter.js';
|
||||
|
||||
function normalizePath(path = '/') {
|
||||
const trimmed = String(path || '/').trim();
|
||||
if (!trimmed || trimmed === '.') return '/';
|
||||
const withLeading = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
const collapsed = withLeading.replace(/\/+/g, '/');
|
||||
return collapsed.length > 1 ? collapsed.replace(/\/$/, '') : collapsed;
|
||||
}
|
||||
|
||||
function splitPath(path = '/') {
|
||||
return normalizePath(path).split('/').filter(Boolean);
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
const joined = parts
|
||||
.filter((part) => part != null && part !== '')
|
||||
.join('/');
|
||||
return normalizePath(joined.startsWith('/') ? joined : `/${joined}`);
|
||||
}
|
||||
|
||||
function entryName(path = '/') {
|
||||
const segments = splitPath(path);
|
||||
return segments[segments.length - 1] || '/';
|
||||
}
|
||||
|
||||
async function fileHandleToMetadata(path, handle) {
|
||||
const file = await handle.getFile();
|
||||
return {
|
||||
path,
|
||||
is_folder: false,
|
||||
mime_type: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
modified: Number.isFinite(file.lastModified) ? new Date(file.lastModified).toISOString() : null
|
||||
};
|
||||
}
|
||||
|
||||
function directoryMetadata(path) {
|
||||
return {
|
||||
path,
|
||||
is_folder: true,
|
||||
mime_type: 'application/folder',
|
||||
size: null,
|
||||
modified: null
|
||||
};
|
||||
}
|
||||
|
||||
async function readBlobAsText(blob) {
|
||||
return blob.text();
|
||||
}
|
||||
|
||||
export class OpfsStorageAdapter extends StorageAdapter {
|
||||
constructor({ basePath = '/' } = {}) {
|
||||
super();
|
||||
this.basePath = normalizePath(basePath);
|
||||
this.rootPromise = null;
|
||||
}
|
||||
|
||||
async getOpfsRoot() {
|
||||
if (!globalThis.navigator?.storage?.getDirectory) {
|
||||
throw new Error('Origin Private File System is not available in this browser.');
|
||||
}
|
||||
|
||||
if (!this.rootPromise) {
|
||||
this.rootPromise = (async () => {
|
||||
let directory = await globalThis.navigator.storage.getDirectory();
|
||||
for (const segment of splitPath(this.basePath)) {
|
||||
directory = await directory.getDirectoryHandle(segment, { create: true });
|
||||
}
|
||||
return directory;
|
||||
})();
|
||||
}
|
||||
|
||||
return this.rootPromise;
|
||||
}
|
||||
|
||||
async getDirectoryHandle(path = '/', { create = false } = {}) {
|
||||
let directory = await this.getOpfsRoot();
|
||||
for (const segment of splitPath(path)) {
|
||||
directory = await directory.getDirectoryHandle(segment, { create });
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
|
||||
async getParentDirectory(path, { create = false } = {}) {
|
||||
const normalized = normalizePath(path);
|
||||
if (normalized === '/') {
|
||||
throw new Error('The root path has no parent directory.');
|
||||
}
|
||||
|
||||
const segments = splitPath(normalized);
|
||||
const name = segments.pop();
|
||||
const parentPath = segments.length ? `/${segments.join('/')}` : '/';
|
||||
const directory = await this.getDirectoryHandle(parentPath, { create });
|
||||
return { directory, name, parentPath };
|
||||
}
|
||||
|
||||
async tryGetHandle(path) {
|
||||
const normalized = normalizePath(path);
|
||||
if (normalized === '/') {
|
||||
return { kind: 'directory', handle: await this.getOpfsRoot() };
|
||||
}
|
||||
|
||||
const { directory, name } = await this.getParentDirectory(normalized);
|
||||
try {
|
||||
const handle = await directory.getDirectoryHandle(name);
|
||||
return { kind: 'directory', handle };
|
||||
} catch {
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await directory.getFileHandle(name);
|
||||
return { kind: 'file', handle };
|
||||
} catch {
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async ensurePathMissing(path) {
|
||||
const existing = await this.tryGetHandle(path);
|
||||
if (existing) {
|
||||
throw new Error(`Path already exists: ${normalizePath(path)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async list(path = '/') {
|
||||
const directory = await this.getDirectoryHandle(path);
|
||||
const rows = [];
|
||||
|
||||
for await (const [name, handle] of directory.entries()) {
|
||||
const childPath = joinPath(path, name);
|
||||
if (handle.kind === 'directory') {
|
||||
rows.push(directoryMetadata(childPath));
|
||||
} else {
|
||||
rows.push(await fileHandleToMetadata(childPath, handle));
|
||||
}
|
||||
}
|
||||
|
||||
rows.sort((left, right) => {
|
||||
if (left.is_folder !== right.is_folder) {
|
||||
return left.is_folder ? -1 : 1;
|
||||
}
|
||||
return entryName(left.path).localeCompare(entryName(right.path), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
});
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async info(path = '/') {
|
||||
const normalized = normalizePath(path);
|
||||
const found = await this.tryGetHandle(normalized);
|
||||
if (!found) {
|
||||
throw new Error(`Path not found: ${normalized}`);
|
||||
}
|
||||
|
||||
if (found.kind === 'directory') {
|
||||
return directoryMetadata(normalized);
|
||||
}
|
||||
|
||||
return fileHandleToMetadata(normalized, found.handle);
|
||||
}
|
||||
|
||||
async remove(path) {
|
||||
const normalized = normalizePath(path);
|
||||
if (normalized === '/') {
|
||||
throw new Error('Removing the adapter root path is not supported.');
|
||||
}
|
||||
|
||||
const { directory, name } = await this.getParentDirectory(normalized);
|
||||
await directory.removeEntry(name, { recursive: true });
|
||||
}
|
||||
|
||||
async rename(oldPath, newPath) {
|
||||
const from = normalizePath(oldPath);
|
||||
const to = normalizePath(newPath);
|
||||
|
||||
if (from === '/' || to === '/') {
|
||||
throw new Error('Renaming the adapter root path is not supported.');
|
||||
}
|
||||
if (from === to) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = await this.tryGetHandle(from);
|
||||
if (!source) {
|
||||
throw new Error(`Path not found: ${from}`);
|
||||
}
|
||||
|
||||
await this.ensurePathMissing(to);
|
||||
const { directory: destinationParent, name: destinationName } = await this.getParentDirectory(to, { create: true });
|
||||
|
||||
if (source.kind === 'directory') {
|
||||
await this.copyDirectoryRecursive(source.handle, destinationParent, destinationName);
|
||||
} else {
|
||||
await this.copyFileHandle(source.handle, destinationParent, destinationName);
|
||||
}
|
||||
|
||||
await this.remove(from);
|
||||
}
|
||||
|
||||
async upload(pathScope, itemType, itemName, file) {
|
||||
const scope = normalizePath(pathScope || '/');
|
||||
const name = String(itemName || '').trim();
|
||||
if (!name) {
|
||||
throw new Error('A file or folder name is required.');
|
||||
}
|
||||
|
||||
const destinationPath = joinPath(scope, name);
|
||||
await this.ensurePathMissing(destinationPath);
|
||||
|
||||
if (itemType === 'Folder') {
|
||||
const { directory, name: folderName } = await this.getParentDirectory(destinationPath, { create: true });
|
||||
await directory.getDirectoryHandle(folderName, { create: true });
|
||||
return this.info(destinationPath);
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
throw new Error('A file payload is required for file uploads.');
|
||||
}
|
||||
|
||||
const { directory, name: fileName } = await this.getParentDirectory(destinationPath, { create: true });
|
||||
const fileHandle = await directory.getFileHandle(fileName, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
try {
|
||||
await writable.write(file);
|
||||
} finally {
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
return this.info(destinationPath);
|
||||
}
|
||||
|
||||
async downloadBlob(path) {
|
||||
const normalized = normalizePath(path);
|
||||
const found = await this.tryGetHandle(normalized);
|
||||
if (!found) {
|
||||
throw new Error(`Path not found: ${normalized}`);
|
||||
}
|
||||
|
||||
if (found.kind === 'directory') {
|
||||
const manifest = await this.describeTree(normalized);
|
||||
return new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/json' });
|
||||
}
|
||||
|
||||
return found.handle.getFile();
|
||||
}
|
||||
|
||||
async downloadText(path) {
|
||||
const blob = await this.downloadBlob(path);
|
||||
return readBlobAsText(blob);
|
||||
}
|
||||
|
||||
async copyFileHandle(sourceHandle, destinationDirectory, destinationName) {
|
||||
const file = await sourceHandle.getFile();
|
||||
const nextHandle = await destinationDirectory.getFileHandle(destinationName, { create: true });
|
||||
const writable = await nextHandle.createWritable();
|
||||
try {
|
||||
await writable.write(file);
|
||||
} finally {
|
||||
await writable.close();
|
||||
}
|
||||
}
|
||||
|
||||
async copyDirectoryRecursive(sourceDirectory, destinationParent, destinationName) {
|
||||
const nextDirectory = await destinationParent.getDirectoryHandle(destinationName, { create: true });
|
||||
for await (const [childName, childHandle] of sourceDirectory.entries()) {
|
||||
if (childHandle.kind === 'directory') {
|
||||
await this.copyDirectoryRecursive(childHandle, nextDirectory, childName);
|
||||
} else {
|
||||
await this.copyFileHandle(childHandle, nextDirectory, childName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async describeTree(path = '/') {
|
||||
const found = await this.tryGetHandle(path);
|
||||
if (!found) {
|
||||
throw new Error(`Path not found: ${normalizePath(path)}`);
|
||||
}
|
||||
|
||||
if (found.kind === 'file') {
|
||||
return await fileHandleToMetadata(normalizePath(path), found.handle);
|
||||
}
|
||||
|
||||
const children = await this.list(path);
|
||||
return {
|
||||
...directoryMetadata(normalizePath(path)),
|
||||
children: await Promise.all(children.map(async (child) => (
|
||||
child.is_folder ? this.describeTree(child.path) : child
|
||||
)))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default OpfsStorageAdapter;
|
||||
@@ -0,0 +1,29 @@
|
||||
export class StorageAdapter {
|
||||
async list(_path) {
|
||||
throw new Error('StorageAdapter.list() is not implemented');
|
||||
}
|
||||
|
||||
async info(_path) {
|
||||
throw new Error('StorageAdapter.info() is not implemented');
|
||||
}
|
||||
|
||||
async remove(_path) {
|
||||
throw new Error('StorageAdapter.remove() is not implemented');
|
||||
}
|
||||
|
||||
async rename(_oldPath, _newPath) {
|
||||
throw new Error('StorageAdapter.rename() is not implemented');
|
||||
}
|
||||
|
||||
async upload(_pathScope, _itemType, _itemName, _file) {
|
||||
throw new Error('StorageAdapter.upload() is not implemented');
|
||||
}
|
||||
|
||||
async downloadBlob(_path, _disposition = 'attachment') {
|
||||
throw new Error('StorageAdapter.downloadBlob() is not implemented');
|
||||
}
|
||||
|
||||
async downloadText(_path, _disposition = 'inline') {
|
||||
throw new Error('StorageAdapter.downloadText() is not implemented');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Fluent Flat Theme
|
||||
* Square, enterprise look inspired by modern Microsoft product surfaces.
|
||||
*
|
||||
* Aesthetic intent
|
||||
* ----------------
|
||||
* - Zero-radius surfaces for a crisp, grid-aligned shell
|
||||
* - Border-led separation with restrained shadow use
|
||||
* - Neutral page background with bright white working surfaces
|
||||
* - Azure-like accent reserved for focus, primary action, and selection
|
||||
*/
|
||||
|
||||
import { config as configBase } from '@tamagui/config/v3';
|
||||
|
||||
export const FluentFlatTheme = {
|
||||
...configBase,
|
||||
name: 'fluent-flat',
|
||||
displayName: 'Fluent Flat',
|
||||
iconWeight: 'regular',
|
||||
typography: {
|
||||
fieldLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
|
||||
detailLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
|
||||
pageTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
|
||||
panelTitle: { fontWeight: '600', color: '$textPrimary' },
|
||||
tableHeader: { fontSize: '$4', fontWeight: '600', color: '$textSecondary' },
|
||||
sectionTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
|
||||
},
|
||||
tokens: {
|
||||
...configBase.tokens,
|
||||
radius: {
|
||||
...configBase.tokens.radius,
|
||||
true: 0,
|
||||
0: 0,
|
||||
1: 0,
|
||||
2: 0,
|
||||
3: 0,
|
||||
4: 0,
|
||||
5: 0,
|
||||
6: 0,
|
||||
7: 0,
|
||||
8: 0,
|
||||
9: 0,
|
||||
10: 0,
|
||||
11: 0,
|
||||
12: 0,
|
||||
radiusSm: 0,
|
||||
radiusMd: 0,
|
||||
radiusLg: 0,
|
||||
},
|
||||
color: {
|
||||
...configBase.tokens.color,
|
||||
primary: '#0f6cbd',
|
||||
primaryLight: '#2886de',
|
||||
primaryDark: '#0c5ea6',
|
||||
secondary: '#5b6572',
|
||||
secondaryLight: '#7a8594',
|
||||
secondaryDark: '#404854',
|
||||
error: '#c42b1c',
|
||||
warning: '#986f0b',
|
||||
info: '#0f6cbd',
|
||||
success: '#107c10',
|
||||
},
|
||||
space: {
|
||||
...configBase.tokens.space,
|
||||
0: 0,
|
||||
1: 4,
|
||||
2: 8,
|
||||
3: 12,
|
||||
4: 16,
|
||||
5: 20,
|
||||
6: 24,
|
||||
7: 32,
|
||||
8: 40,
|
||||
},
|
||||
size: {
|
||||
...configBase.tokens.size,
|
||||
xs: 11,
|
||||
sm: 12,
|
||||
md: 14,
|
||||
base: 14,
|
||||
lg: 16,
|
||||
xl: 18,
|
||||
'2xl': 20,
|
||||
'3xl': 24,
|
||||
'4xl': 28,
|
||||
'5xl': 36,
|
||||
'6xl': 48,
|
||||
},
|
||||
shadowColor: {
|
||||
...configBase.tokens.shadowColor,
|
||||
elevation0: 'transparent',
|
||||
elevation1: 'rgba(15, 23, 42, 0.03)',
|
||||
elevation2: 'rgba(15, 23, 42, 0.05)',
|
||||
elevation4: 'rgba(15, 23, 42, 0.08)',
|
||||
elevation8: 'rgba(15, 23, 42, 0.10)',
|
||||
elevation12: 'rgba(15, 23, 42, 0.12)',
|
||||
elevation16: 'rgba(15, 23, 42, 0.14)',
|
||||
},
|
||||
},
|
||||
themes: {
|
||||
...configBase.themes,
|
||||
light: {
|
||||
...configBase.themes.light,
|
||||
background: '#ffffff',
|
||||
backgroundHover: '#f5f6f8',
|
||||
backgroundPress: '#eceef2',
|
||||
backgroundFocus: '#edf5fd',
|
||||
surface: '#ffffff',
|
||||
surfaceVariant: '#f7f8fa',
|
||||
accentBackground: '#dff0ff',
|
||||
accentSurface: '#eef6fd',
|
||||
accentColor: '#0f6cbd',
|
||||
accentBorder: 'rgba(15, 108, 189, 0.3)',
|
||||
accentHover: '#d3e8fb',
|
||||
accentPress: '#c3ddf6',
|
||||
color: '#1b1b1b',
|
||||
colorHover: '#1b1b1b',
|
||||
colorSecondary: '#454545',
|
||||
colorDisabled: '#8a8886',
|
||||
borderColor: '#d1d1d1',
|
||||
borderColorHover: '#bdbdbd',
|
||||
bgPage: '#f7f8fa',
|
||||
bgPanel: '#ffffff',
|
||||
bgPanelElev: '#ffffff',
|
||||
bgInverse: '#1b1b1b',
|
||||
scrim: 'rgba(0, 0, 0, 0.28)',
|
||||
lineSubtle: '#d1d1d1',
|
||||
lineStrong: '#b3b0ad',
|
||||
textPrimary: '#1b1b1b',
|
||||
textSecondary: '#454545',
|
||||
textMuted: '#605e5c',
|
||||
textOnAccent: '#ffffff',
|
||||
accent: '#0f6cbd',
|
||||
accentBg: '#eef6fd',
|
||||
accentBgHover: '#dff0ff',
|
||||
danger: '#c42b1c',
|
||||
dangerBg: '#fde7e9',
|
||||
warning: '#986f0b',
|
||||
warningBg: '#fff4ce',
|
||||
success: '#107c10',
|
||||
successBg: '#dff6dd',
|
||||
info: '#0f6cbd',
|
||||
infoBg: '#deecf9',
|
||||
},
|
||||
dark: {
|
||||
...configBase.themes.dark,
|
||||
background: '#11100f',
|
||||
backgroundHover: '#1b1a19',
|
||||
backgroundPress: '#252423',
|
||||
backgroundFocus: '#16324f',
|
||||
surface: '#1b1a19',
|
||||
surfaceVariant: '#252423',
|
||||
accentBackground: '#103a5f',
|
||||
accentSurface: '#16324f',
|
||||
accentColor: '#7dc3ff',
|
||||
accentBorder: 'rgba(125, 195, 255, 0.32)',
|
||||
accentHover: '#17446e',
|
||||
accentPress: '#1c4f80',
|
||||
color: '#f3f2f1',
|
||||
colorHover: '#f3f2f1',
|
||||
colorSecondary: '#d2d0ce',
|
||||
colorDisabled: '#8a8886',
|
||||
borderColor: '#3b3a39',
|
||||
borderColorHover: '#605e5c',
|
||||
bgPage: '#11100f',
|
||||
bgPanel: '#1b1a19',
|
||||
bgPanelElev: '#252423',
|
||||
bgInverse: '#f3f2f1',
|
||||
scrim: 'rgba(0, 0, 0, 0.6)',
|
||||
lineSubtle: '#3b3a39',
|
||||
lineStrong: '#605e5c',
|
||||
textPrimary: '#f3f2f1',
|
||||
textSecondary: '#d2d0ce',
|
||||
textMuted: '#a19f9d',
|
||||
textOnAccent: '#081f33',
|
||||
accent: '#7dc3ff',
|
||||
accentBg: '#16324f',
|
||||
accentBgHover: '#103a5f',
|
||||
danger: '#ff99a4',
|
||||
dangerBg: 'rgba(255, 153, 164, 0.14)',
|
||||
warning: '#fce100',
|
||||
warningBg: 'rgba(252, 225, 0, 0.14)',
|
||||
success: '#6ccb5f',
|
||||
successBg: 'rgba(108, 203, 95, 0.14)',
|
||||
info: '#7dc3ff',
|
||||
infoBg: 'rgba(125, 195, 255, 0.14)',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
...configBase.settings,
|
||||
styleCompat: 'web',
|
||||
},
|
||||
};
|
||||
|
||||
export default FluentFlatTheme;
|
||||
@@ -46,9 +46,9 @@
|
||||
*
|
||||
* Radii (preset overrides via tokens.radius)
|
||||
* ------------------------------------------
|
||||
* radiusSm tight controls (4-8)
|
||||
* radiusMd cards, inputs (6-12)
|
||||
* radiusLg large containers (8-16)
|
||||
* radiusSm tight controls (0-8)
|
||||
* radiusMd cards, inputs (0-12)
|
||||
* radiusLg large containers (0-16)
|
||||
*
|
||||
* Font weights (preset overrides via tokens.weight)
|
||||
* -------------------------------------------------
|
||||
@@ -78,6 +78,7 @@ import { MinimalTheme } from './MinimalTheme.js';
|
||||
import { ColorfulTheme } from './ColorfulTheme.js';
|
||||
import { AzureTheme } from './AzureTheme.js';
|
||||
import { AppleTheme } from './AppleTheme.js';
|
||||
import { FluentFlatTheme } from './FluentFlatTheme.js';
|
||||
|
||||
/**
|
||||
* Theme keys that every preset MUST define in both light and dark variants.
|
||||
@@ -108,6 +109,7 @@ export const ICON_WEIGHTS = Object.freeze([
|
||||
|
||||
export const STYLE_THEMES = {
|
||||
azure: AzureTheme,
|
||||
'fluent-flat': FluentFlatTheme,
|
||||
apple: AppleTheme,
|
||||
material: MaterialTheme,
|
||||
minimal: MinimalTheme,
|
||||
@@ -214,7 +216,7 @@ export function normalizeStyleThemeName(name) {
|
||||
|
||||
/**
|
||||
* Get a style theme by name.
|
||||
* @param {string} themeName - Theme name ('azure', 'apple', 'material', 'minimal', 'colorful')
|
||||
* @param {string} themeName - Theme name ('azure', 'fluent-flat', 'apple', 'material', 'minimal', 'colorful')
|
||||
* @returns {Object} Theme configuration
|
||||
*/
|
||||
export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
|
||||
@@ -270,4 +272,4 @@ export function getIconWeight(themeName = DEFAULT_STYLE_THEME) {
|
||||
return ICON_WEIGHTS.includes(weight) ? weight : 'regular';
|
||||
}
|
||||
|
||||
export { MaterialTheme, MinimalTheme, ColorfulTheme, AzureTheme, AppleTheme };
|
||||
export { MaterialTheme, MinimalTheme, ColorfulTheme, AzureTheme, AppleTheme, FluentFlatTheme };
|
||||
|
||||
Reference in New Issue
Block a user