From c6f7240912bfda6c1aee090d0efcd14faf8ebf93 Mon Sep 17 00:00:00 2001 From: Amer Agovic Date: Sun, 31 May 2026 12:30:02 -0500 Subject: [PATCH] Update bface UI and security work --- src/platform/api.js | 293 +++-- src/platform/env.js | 28 +- src/platform/menu.js | 15 +- src/security/model/Permit.js | 8 +- src/security/model/Securable.js | 12 + src/security/model/Session.js | 1 + src/security/model/User.js | 2 + src/security/model/index.js | 1 + src/security/model/rights.js | 7 + src/security/pages/AccountProfilePage.jsx | 19 +- src/security/pages/ErrorPage.jsx | 4 +- src/security/pages/LoginPage.jsx | 504 ++++++-- src/security/pages/SecurityAdminPage.jsx | 820 +++++++++++-- src/security/policy/ApiSecurityPolicy.js | 558 +++++++++ src/security/policy/BstoreSecurityPolicy.js | 17 +- src/security/policy/SecurityPolicy.js | 5 + src/security/policy/index.js | 1 + src/security/runtime/access-rules.js | 81 ++ src/security/runtime/route-guards.js | 31 +- src/security/runtime/security-service.js | 238 ++-- src/ui/App.jsx | 67 +- src/ui/components/CodedValues.jsx | 131 +++ src/ui/components/DirView.jsx | 116 +- src/ui/components/FieldControl.jsx | 6 +- src/ui/components/FieldControlPickers.jsx | 14 +- src/ui/components/FormField.jsx | 16 + src/ui/components/IconMapper.jsx | 53 +- src/ui/components/MenuItemButton.jsx | 23 +- src/ui/components/PageNavBar.jsx | 76 ++ src/ui/components/Panel.jsx | 4 - src/ui/components/PersonalMenuItem.jsx | 37 +- src/ui/components/ProgressBar.jsx | 6 +- src/ui/components/Router.jsx | 69 +- src/ui/components/Shell.jsx | 51 +- src/ui/components/SidePanelShell.jsx | 3 +- src/ui/components/TopBar.jsx | 35 +- src/ui/components/grid/panel.jsx | 3 +- src/ui/components/grid/presets.js | 7 +- src/ui/components/grid/table.jsx | 135 +-- src/ui/components/index.js | 4 + .../components/storage/OpfsStorageAdapter.js | 299 +++++ src/ui/components/storage/StorageAdapter.js | 29 + src/ui/components/storage/StorageBrowser.jsx | 1048 +++++++++++++++++ src/ui/styles/FluentFlatTheme.js | 195 +++ src/ui/styles/index.js | 12 +- 45 files changed, 4531 insertions(+), 553 deletions(-) create mode 100644 src/security/model/Securable.js create mode 100644 src/security/policy/ApiSecurityPolicy.js create mode 100644 src/security/runtime/access-rules.js create mode 100644 src/ui/components/CodedValues.jsx create mode 100644 src/ui/components/PageNavBar.jsx create mode 100644 src/ui/components/storage/OpfsStorageAdapter.js create mode 100644 src/ui/components/storage/StorageAdapter.js create mode 100644 src/ui/components/storage/StorageBrowser.jsx create mode 100644 src/ui/styles/FluentFlatTheme.js diff --git a/src/platform/api.js b/src/platform/api.js index 6275be9..f44b2e2 100644 --- a/src/platform/api.js +++ b/src/platform/api.js @@ -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} - */ + 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} - */ + 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} - */ + 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} - */ + 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} - */ + 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(); diff --git a/src/platform/env.js b/src/platform/env.js index 60b1266..d5e955f 100644 --- a/src/platform/env.js +++ b/src/platform/env.js @@ -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' }; } diff --git a/src/platform/menu.js b/src/platform/menu.js index 5253128..fc638ed 100644 --- a/src/platform/menu.js +++ b/src/platform/menu.js @@ -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; diff --git a/src/security/model/Permit.js b/src/security/model/Permit.js index 3951a5f..d32f5eb 100644 --- a/src/security/model/Permit.js +++ b/src/security/model/Permit.js @@ -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(); diff --git a/src/security/model/Securable.js b/src/security/model/Securable.js new file mode 100644 index 0000000..ad22b59 --- /dev/null +++ b/src/security/model/Securable.js @@ -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; + } +} + diff --git a/src/security/model/Session.js b/src/security/model/Session.js index 46f001f..6968c5e 100644 --- a/src/security/model/Session.js +++ b/src/security/model/Session.js @@ -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'; } } diff --git a/src/security/model/User.js b/src/security/model/User.js index 8f106cd..5ac8aa2 100644 --- a/src/security/model/User.js +++ b/src/security/model/User.js @@ -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(); diff --git a/src/security/model/index.js b/src/security/model/index.js index e394525..8a80d19 100644 --- a/src/security/model/index.js +++ b/src/security/model/index.js @@ -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 { diff --git a/src/security/model/rights.js b/src/security/model/rights.js index 82eba40..aa138f4 100644 --- a/src/security/model/rights.js +++ b/src/security/model/rights.js @@ -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)) { diff --git a/src/security/pages/AccountProfilePage.jsx b/src/security/pages/AccountProfilePage.jsx index 3d7e4d3..9df740a 100644 --- a/src/security/pages/AccountProfilePage.jsx +++ b/src/security/pages/AccountProfilePage.jsx @@ -97,9 +97,18 @@ export function AccountProfilePage() { if (!selection?.file) { return; } - - setForm((state) => ({ ...state, image_url: selection.result || '' })); - setUploadMessage(`Selected ${selection.file.name}`); + 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 {form.image_url ? ( - ) : null} diff --git a/src/security/pages/ErrorPage.jsx b/src/security/pages/ErrorPage.jsx index 48791b4..5a4929e 100644 --- a/src/security/pages/ErrorPage.jsx +++ b/src/security/pages/ErrorPage.jsx @@ -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" > diff --git a/src/security/pages/LoginPage.jsx b/src/security/pages/LoginPage.jsx index fbe6c5d..b6b2caa 100644 --- a/src/security/pages/LoginPage.jsx +++ b/src/security/pages/LoginPage.jsx @@ -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,12 +121,307 @@ 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 = ( + return ( + + + + {brandLogo ? ( + + ) : null} + + {appName || 'Account'} + + + {subtitle ? ( + + {subtitle} + + ) : null} + + passwordInputRef.current?.focus?.()} + /> + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + {!security.enabled ? ( + + Identity is currently disabled in the active app profile. + + ) : null} + {security.enabled && !security.initialized ? ( + + Security is still initializing. + + ) : null} + + + Demo credentials: admin / admin or demo / demo + + + + + ); +} + +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 ( + + {effectiveSubtitle} + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + {feedbackMessage ? ( + + {feedbackMessage} + + ) : null} + + + + ); + } + + return ( + + {effectiveSubtitle} + + {errorMessage ? ( + + {errorMessage} + + ) : null} + {feedbackMessage ? ( + + + {feedbackMessage} + + {previewLink ? ( + + ) : null} + + ) : null} + + + + ); +} + +function LoginPanel({ + title = 'Login', + subtitle = '', + compact = false, + onComplete = null, + mode = 'login', + resetToken = '', + onRequestReset = null, + onCompleteReset = null, +}) { + const content = useMemo(() => { + if (mode === 'reset-request') { + return ; + } + if (mode === 'reset-complete') { + return ; + } + return ; + }, [compact, mode, onComplete, onCompleteReset, onRequestReset, resetToken, subtitle]); + + return ( - - - {subtitle} - - passwordInputRef.current?.focus?.()} - /> - - {errorMessage ? ( - - {errorMessage} - - ) : null} - - {!security.enabled ? ( - - Identity is currently disabled in the active app profile. - - ) : null} - {security.enabled && !security.initialized ? ( - - Security is still initializing. - - ) : null} - - Demo credentials: admin / admin or demo / demo - - + {content} ); +} - if (compact) { - return content; - } +export function LoginDialog({ open = true, title = 'Login', subtitle = 'This route requires an authenticated user.' }) { + return ( + + + + + + + + + + + + + {}} /> + + + + ); +} +export function LoginPage({ title = 'Login', subtitle = '' }) { + return ( + + ); +} + +export function LoginPageMode({ + title = 'Login', + subtitle = '', + mode = 'login', + resetToken = '', + onRequestReset = null, + onCompleteReset = null, +}) { return ( 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 ( - - {items.length > 0 ? items.map(renderItem) : ( - - {emptyText} - - )} + + {mode === 'edit' ? `Edit ${title}` : `Create ${title}`} + + {fields.map((field) => ( + + + {field.label} + + + + + + ))} + + + + + ); } +function ActionCell({ onDelete, busy = false }) { + return ( + + + + ); +} + export function SecurityAdminPage() { const { security } = useApp(); const [users, setUsers] = useState([]); 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); + + 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() { + setLoadingData(true); + const [nextUsers, nextRoles, nextRealms, nextResources, nextSubjects, nextPermits] = await Promise.all([ + security.listUsers(), + security.listRoles(), + security.listRealms(), + security.listResources(), + security.listSubjects(), + security.listPermits() + ]); + + setUsers(nextUsers); + setRoles(nextRoles); + setRealms(nextRealms); + setResources(nextResources); + setSubjects(nextSubjects); + setPermits(nextPermits); + setLoadingData(false); + } useEffect(() => { let active = true; - async function loadSecurityData() { + async function run() { try { - const [nextUsers, nextRoles, nextRealms, nextResources, nextPermits] = await Promise.all([ - security.listUsers(), - security.listRoles(), - security.listRealms(), - security.listResources(), - security.listPermits() - ]); - - if (!active) { - return; - } - - setUsers(nextUsers); - setRoles(nextRoles); - setRealms(nextRealms); - setResources(nextResources); - setPermits(nextPermits); + 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 ( + + {names.map((name) => ( + + {name} + + ))} + + ); + } + }, + { + id: 'actions', + label: 'Actions', + minWidth: 120, + flex: 0.9, + align: 'right', + sortable: false, + render: (_value, record) => ( + 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) => ( + 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) => ( + 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) => + }, + { + id: 'actions', + label: 'Actions', + minWidth: 120, + flex: 0.9, + align: 'right', + sortable: false, + render: (_value, record) => ( + 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) => ( - - - {user.display_name || user.username} - {user.email} - - {(user.role_ids || []).join(', ') || 'no roles'} - - ), 'No users available.') + content: ( + 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) => ( - - {role.name} - {role.description || role.id} - - ), 'No roles available.') + content: ( + 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) => ( - - {realm.name} - {realm.description || realm.id} - - ), 'No realms available.') + content: ( + + Realms are read only for now. We seed the local realm in backend code and keep it stable while the rest of security matures. + + )} + /> + ) }, { id: 'resources', label: 'Resources', icon: 'library', - content: renderSectionBody(resources, (resource) => ( - - {resource.path} - {resource.type} in realm {resource.realm_id} - - ), 'No resources registered.') + content: ( + 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) => ( - - - {permit.effect.toUpperCase()} {permit.principal_type}:{permit.principal_id} - - - {permit.resource_path} {'->'} {rightsToArray(permit.rights).join(', ') || 'none'} - - - ), 'No permits registered.') - }, + content: ( + 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 ? ( + + Loading security data... + + ) : null; + return ( Provider: {security.config.provider} + {loadingNotice} Security is {security.enabled ? 'enabled' : 'disabled'}. Authenticated user: {security.user?.display_name || security.user?.username || 'none'} + {message ? ( + + {message} + + ) : null} diff --git a/src/security/policy/ApiSecurityPolicy.js b/src/security/policy/ApiSecurityPolicy.js new file mode 100644 index 0000000..15afa9e --- /dev/null +++ b/src/security/policy/ApiSecurityPolicy.js @@ -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 || '/'); + } +} diff --git a/src/security/policy/BstoreSecurityPolicy.js b/src/security/policy/BstoreSecurityPolicy.js index 9c19bee..c7c27d3 100644 --- a/src/security/policy/BstoreSecurityPolicy.js +++ b/src/security/policy/BstoreSecurityPolicy.js @@ -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 {} diff --git a/src/security/policy/SecurityPolicy.js b/src/security/policy/SecurityPolicy.js index 7f6d69e..747d86a 100644 --- a/src/security/policy/SecurityPolicy.js +++ b/src/security/policy/SecurityPolicy.js @@ -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'); } diff --git a/src/security/policy/index.js b/src/security/policy/index.js index 40b4119..6d30e16 100644 --- a/src/security/policy/index.js +++ b/src/security/policy/index.js @@ -1,3 +1,4 @@ export { SecurityPolicy } from './SecurityPolicy.js'; +export { ApiSecurityPolicy } from './ApiSecurityPolicy.js'; export { BasicSecurityPolicy } from './BasicSecurityPolicy.js'; export { BstoreSecurityPolicy } from './BstoreSecurityPolicy.js'; diff --git a/src/security/runtime/access-rules.js b/src/security/runtime/access-rules.js new file mode 100644 index 0000000..45d2a07 --- /dev/null +++ b/src/security/runtime/access-rules.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' }; +} diff --git a/src/security/runtime/route-guards.js b/src/security/runtime/route-guards.js index 478493d..868eecd 100644 --- a/src/security/runtime/route-guards.js +++ b/src/security/runtime/route-guards.js @@ -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 }); } diff --git a/src/security/runtime/security-service.js b/src/security/runtime/security-service.js index 06fcfb8..f85db14 100644 --- a/src/security/runtime/security-service.js +++ b/src/security/runtime/security-service.js @@ -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,70 +87,87 @@ class SecurityService { async init(config = {}) { const normalizedConfig = normalizeSecurityConfig(config); - - if (!normalizedConfig.enabled) { - this.setState({ - ...createInitialState(), - initialized: true, - config: normalizedConfig, - enabled: false, - provider: normalizedConfig.provider, - requireLogin: normalizedConfig.require_login - }); - return this.state; - } - - this.setState({ - loading: true, - error: null, - enabled: true, - provider: normalizedConfig.provider, - requireLogin: normalizedConfig.require_login, - config: normalizedConfig - }); - - try { - const policy = this._resolvePolicy(normalizedConfig); - await policy.init(); - const session = await policy.getCurrentSession(); - const user = session?.user_id ? await policy.getUser(session.user_id) : null; - const realm = user?.realm_id ? await policy.getRealm(user.realm_id) : null; - const profile = user ? await policy.getAccountProfile(user.id) : null; + const initTask = (async () => { + if (!normalizedConfig.enabled) { + this.setState({ + ...createInitialState(), + initialized: true, + config: normalizedConfig, + enabled: false, + provider: normalizedConfig.provider, + requireLogin: normalizedConfig.require_login + }); + return this.state; + } this.setState({ - initialized: true, - loading: false, + loading: true, + error: null, enabled: true, provider: normalizedConfig.provider, requireLogin: normalizedConfig.require_login, config: normalizedConfig, - policy, - session, - user, - profile, - realm, - isAuthenticated: Boolean(session && user), - error: null - }); - } catch (error) { - console.error('[Security] Failed to initialize security service:', error); - this.setState({ - initialized: true, - loading: false, - enabled: normalizedConfig.enabled, - provider: normalizedConfig.provider, - requireLogin: normalizedConfig.require_login, - config: normalizedConfig, - policy: null, - session: null, - user: null, - profile: null, - realm: null, - isAuthenticated: false, - error }); + + try { + const policy = this._resolvePolicy(normalizedConfig); + await policy.init(); + const session = await policy.getCurrentSession(); + const user = session?.user_id ? await policy.getUser(session.user_id) : null; + const realm = user?.realm_id ? await policy.getRealm(user.realm_id) : null; + const profile = user ? await policy.getAccountProfile(user.id) : null; + + this.setState({ + initialized: true, + loading: false, + enabled: true, + provider: normalizedConfig.provider, + requireLogin: normalizedConfig.require_login, + config: normalizedConfig, + policy, + session, + user, + profile, + realm, + isAuthenticated: Boolean(session && user), + error: null + }); + } catch (error) { + console.error('[Security] Failed to initialize security service:', error); + this.setState({ + initialized: true, + loading: false, + enabled: normalizedConfig.enabled, + provider: normalizedConfig.provider, + requireLogin: normalizedConfig.require_login, + config: normalizedConfig, + policy: null, + session: null, + user: null, + profile: null, + realm: null, + isAuthenticated: false, + error + }); + } + + 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(); diff --git a/src/ui/App.jsx b/src/ui/App.jsx index da63645..e6cef1f 100644 --- a/src/ui/App.jsx +++ b/src/ui/App.jsx @@ -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); - securityTrace.end({ enabled: initialSecurityConfig.enabled === true }); - securityService.installAPIClient(services.api_client); + const initialSecurityInitPromise = securityService.init(initialSecurityConfig) + .then((state) => { + securityTrace.end({ enabled: initialSecurityConfig.enabled === true }); + 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; - 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 }); - } + 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 = ; - } else if (!shouldRenderBootScreen && !shouldHoldDuringInit) { + if (!shouldRenderBootScreen && !shouldHoldDuringInit) { appContent = ( {/* Declarative route registration (commented out - routes now registered programmatically via modules) diff --git a/src/ui/components/CodedValues.jsx b/src/ui/components/CodedValues.jsx new file mode 100644 index 0000000..2549109 --- /dev/null +++ b/src/ui/components/CodedValues.jsx @@ -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 ( + + {entries.map((entry) => { + const checked = (mask & entry.code) !== 0; + return ( + + { + const nextMask = nextChecked === true ? (mask | entry.code) : (mask & ~entry.code); + onChange?.(nextMask); + }} + > + + {CheckIcon ? : null} + + + {entry.label} + + ); + })} + + ); +} + +export function CodedValueBadges({ + codeMap = {}, + labels = null, + value = 0, + compact = false +}) { + const items = formatCodedValue(codeMap, value, labels); + if (compact) { + return ( + + {items.map((item) => ( + + {item} + + ))} + + ); + } + return ( + + {items.map((item) => ( + + {item} + + ))} + + ); +} diff --git a/src/ui/components/DirView.jsx b/src/ui/components/DirView.jsx index b7bd9f9..a14cedb 100644 --- a/src/ui/components/DirView.jsx +++ b/src/ui/components/DirView.jsx @@ -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 ( - + + {column.label} + + {sortable ? ( + + ); @@ -334,7 +334,7 @@ function DateSection({ value, onChange }) { {String(composeDateTimeValue(type, parts) || value || '').toUpperCase() || '—'} - + ); diff --git a/src/ui/components/FormField.jsx b/src/ui/components/FormField.jsx index 97a7d8b..f9c1ad1 100644 --- a/src/ui/components/FormField.jsx +++ b/src/ui/components/FormField.jsx @@ -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 ( + + onChange?.(id, nextValue)} + /> + + ); + } + // 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') { diff --git a/src/ui/components/IconMapper.jsx b/src/ui/components/IconMapper.jsx index 44c2e32..1fea51a 100644 --- a/src/ui/components/IconMapper.jsx +++ b/src/ui/components/IconMapper.jsx @@ -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 ( + + {image_url ? ( + + ) : null} + + + {source} + + + + ); +} + /** * Get the px value for a semantic size token. Useful when laying out * non-icon children (avatars, badges) alongside icons. diff --git a/src/ui/components/MenuItemButton.jsx b/src/ui/components/MenuItemButton.jsx index 5c7f604..8a12b79 100644 --- a/src/ui/components/MenuItemButton.jsx +++ b/src/ui/components/MenuItemButton.jsx @@ -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 ( + + + + ); + } + // 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 diff --git a/src/ui/components/PageNavBar.jsx b/src/ui/components/PageNavBar.jsx new file mode 100644 index 0000000..6670eb5 --- /dev/null +++ b/src/ui/components/PageNavBar.jsx @@ -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 ( + + + + {column.label} + + {column.sortable ? ( + + ); +} + +async function triggerAuthenticatedDownload(adapter, path, disposition = 'attachment') { + if (!path || typeof document === 'undefined') { + return; + } + + const blob = await adapter.downloadBlob(path, disposition); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.rel = 'noopener'; + link.download = entryName(path) || 'download'; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); +} + +function getFileViewComponent(entry) { + const found = STORAGE_FILE_VIEW_REGISTRY.find((item) => item.match(entry)); + return found ? found.component : DefaultStorageFileView; +} + +export function registerStorageFileView(match, component) { + STORAGE_FILE_VIEW_REGISTRY.push({ + id: `custom-${STORAGE_FILE_VIEW_REGISTRY.length}`, + match, + component + }); +} + +function ImageStorageFileView({ entry, adapter }) { + const [imageUrl, setImageUrl] = useState(''); + + useEffect(() => { + let cancelled = false; + let objectUrl = ''; + + async function loadImage() { + try { + const blob = await adapter.downloadBlob(entry?.path, 'inline'); + objectUrl = URL.createObjectURL(blob); + if (!cancelled) { + setImageUrl(objectUrl); + } + } catch { + if (!cancelled) { + setImageUrl(''); + } + } + } + + loadImage(); + return () => { + cancelled = true; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + }; + }, [adapter, entry?.path]); + + return ( + + + {entryName(entry?.path)} + + + ); +} + +function TextStorageFileView({ entry, adapter }) { + const [content, setContent] = useState(''); + const [error, setError] = useState(''); + + useEffect(() => { + let cancelled = false; + async function loadText() { + setError(''); + try { + const text = await adapter.downloadText(entry?.path, 'inline'); + if (!cancelled) { + setContent(text); + } + } catch (loadError) { + if (!cancelled) { + setError(loadError.message || 'Failed to load preview'); + setContent(''); + } + } + } + loadText(); + return () => { + cancelled = true; + }; + }, [adapter, entry?.path]); + + return ( + + {error ? {error} : null} +