Initial commit: bface library, build fixes, and refreshed docs
- Externalize all @tamagui/* and tamagui subpaths so dist no longer vendors Tamagui. - Emit TypeScript declarations with vite-plugin-dts; fix package exports types for ui/*. - Align initEnv with profiles: displayName, brandLogo, api.baseURL, themeColor, uiShell. - Stabilize tests with Node localStorage file; env tests pass. - Update README and component docs for services, menus, API client, and development.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const accountTabs = [];
|
||||
const listeners = new Set();
|
||||
let cachedTabs = [];
|
||||
|
||||
function rebuildSnapshot() {
|
||||
cachedTabs = [...accountTabs].sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
}
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((listener) => {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
console.warn('[Security] Account tab listener failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function publishAccountTab(tab) {
|
||||
if (!tab || !tab.id || !tab.label || !tab.component) {
|
||||
console.warn('[Security] publishAccountTab() requires id, label, and component');
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIndex = accountTabs.findIndex((item) => item.id === tab.id);
|
||||
if (existingIndex >= 0) {
|
||||
accountTabs[existingIndex] = tab;
|
||||
} else {
|
||||
accountTabs.push(tab);
|
||||
}
|
||||
rebuildSnapshot();
|
||||
emit();
|
||||
}
|
||||
|
||||
export function retractAccountTab(tabId) {
|
||||
const nextTabs = accountTabs.filter((tab) => tab.id !== tabId);
|
||||
if (nextTabs.length !== accountTabs.length) {
|
||||
accountTabs.length = 0;
|
||||
accountTabs.push(...nextTabs);
|
||||
rebuildSnapshot();
|
||||
emit();
|
||||
}
|
||||
}
|
||||
|
||||
export function getAccountTabs() {
|
||||
return cachedTabs;
|
||||
}
|
||||
|
||||
export function subscribeToAccountTabs(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function useAccountTabs() {
|
||||
return useSyncExternalStore(subscribeToAccountTabs, getAccountTabs, getAccountTabs);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function createSecurityRequestInterceptor(securityService) {
|
||||
return (config = {}) => securityService.injectRequestConfig(config);
|
||||
}
|
||||
|
||||
export function createSecurityResponseInterceptor(securityService) {
|
||||
return (response) => {
|
||||
if (response && response.status === 401) {
|
||||
securityService.handleUnauthorizedResponse();
|
||||
}
|
||||
return response;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { normalizeRightsInput } from '../model/rights.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);
|
||||
|
||||
if (!securityState.enabled) {
|
||||
return {
|
||||
allowed: true,
|
||||
requires_login: false,
|
||||
reason: 'Security disabled'
|
||||
};
|
||||
}
|
||||
|
||||
if (!options.require_user && requestedRights === 0) {
|
||||
return {
|
||||
allowed: true,
|
||||
requires_login: false,
|
||||
reason: 'Route has no security requirements'
|
||||
};
|
||||
}
|
||||
|
||||
if (options.require_user && !securityState.isAuthenticated) {
|
||||
return {
|
||||
allowed: false,
|
||||
requires_login: true,
|
||||
reason: 'Login required for route'
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedRights !== 0) {
|
||||
return securityService.userPermitted(requestedRights, resourcePath, { redirectOnFail: false });
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
requires_login: false,
|
||||
reason: 'Authenticated route'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { setRouterPath } from '../../platform/compat.js';
|
||||
import { normalizeRightsInput } from '../model/rights.js';
|
||||
import { BasicSecurityPolicy, BstoreSecurityPolicy } from '../policy/index.js';
|
||||
import { createSecurityRequestInterceptor, createSecurityResponseInterceptor } from './api-auth.js';
|
||||
|
||||
const DEFAULT_SECURITY_CONFIG = {
|
||||
enabled: false,
|
||||
provider: 'basic',
|
||||
require_login: false,
|
||||
login_route: '/login',
|
||||
logout_route: '/login',
|
||||
default_realm: 'local',
|
||||
debug_errors: false
|
||||
};
|
||||
|
||||
function normalizeSecurityConfig(config = {}) {
|
||||
return {
|
||||
...DEFAULT_SECURITY_CONFIG,
|
||||
...(config || {})
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialState() {
|
||||
return {
|
||||
initialized: false,
|
||||
loading: false,
|
||||
enabled: false,
|
||||
provider: 'basic',
|
||||
requireLogin: false,
|
||||
config: normalizeSecurityConfig(),
|
||||
policy: null,
|
||||
session: null,
|
||||
user: null,
|
||||
profile: null,
|
||||
realm: null,
|
||||
isAuthenticated: false,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
class SecurityService {
|
||||
constructor() {
|
||||
this.state = createInitialState();
|
||||
this.listeners = new Set();
|
||||
this.apiHooksInstalled = false;
|
||||
}
|
||||
|
||||
subscribe(listener) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
emit() {
|
||||
this.listeners.forEach((listener) => {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
console.warn('[Security] Subscriber failed:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getSnapshot = () => this.state;
|
||||
getState() { return this.state; }
|
||||
|
||||
setState(patch) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
...patch
|
||||
};
|
||||
this.emit();
|
||||
}
|
||||
|
||||
_resolvePolicy(config) {
|
||||
if (config.provider === 'bstore') {
|
||||
return new BstoreSecurityPolicy(config);
|
||||
}
|
||||
return new BasicSecurityPolicy(config);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
installAPIClient(apiClient) {
|
||||
if (!apiClient || this.apiHooksInstalled) {
|
||||
return;
|
||||
}
|
||||
apiClient.addRequestInterceptor(createSecurityRequestInterceptor(this));
|
||||
apiClient.addResponseInterceptor(createSecurityResponseInterceptor(this));
|
||||
this.apiHooksInstalled = true;
|
||||
}
|
||||
|
||||
injectRequestConfig(config = {}) {
|
||||
if (!this.state.session?.jwt_token) {
|
||||
return config;
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
headers: {
|
||||
...(config.headers || {}),
|
||||
Authorization: `Bearer ${this.state.session.jwt_token}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async injectAuthHeaders(headers = {}) {
|
||||
return this.injectRequestConfig({ headers }).headers;
|
||||
}
|
||||
|
||||
async handleUnauthorizedResponse() {
|
||||
if (this.state.isAuthenticated) {
|
||||
await this.logout({ redirect: true });
|
||||
}
|
||||
}
|
||||
|
||||
async login(credentials = {}) {
|
||||
if (!this.state.policy) {
|
||||
throw new Error('Security policy is not initialized');
|
||||
}
|
||||
|
||||
this.setState({ loading: true, error: null });
|
||||
try {
|
||||
const result = await this.state.policy.authenticate(credentials);
|
||||
const realm = result.user?.realm_id ? await this.state.policy.getRealm(result.user.realm_id) : null;
|
||||
this.setState({
|
||||
loading: false,
|
||||
session: result.session,
|
||||
user: result.user,
|
||||
profile: result.profile || null,
|
||||
realm,
|
||||
isAuthenticated: true,
|
||||
error: null
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async logout(options = {}) {
|
||||
const { redirect = true } = options;
|
||||
|
||||
if (this.state.policy && this.state.session) {
|
||||
try {
|
||||
await this.state.policy.logout(this.state.session);
|
||||
} catch (error) {
|
||||
console.warn('[Security] Policy logout failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.policy) {
|
||||
await this.state.policy.clearSession();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
session: null,
|
||||
user: null,
|
||||
profile: null,
|
||||
realm: null,
|
||||
isAuthenticated: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
await setRouterPath(this.state.config.logout_route || this.state.config.login_route || '/login', true);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshSession() {
|
||||
if (!this.state.policy) {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
const session = await this.state.policy.getCurrentSession();
|
||||
const user = session?.user_id ? await this.state.policy.getUser(session.user_id) : null;
|
||||
const realm = user?.realm_id ? await this.state.policy.getRealm(user.realm_id) : null;
|
||||
const profile = user ? await this.state.policy.getAccountProfile(user.id) : null;
|
||||
|
||||
this.setState({
|
||||
session,
|
||||
user,
|
||||
profile,
|
||||
realm,
|
||||
isAuthenticated: Boolean(session && user)
|
||||
});
|
||||
|
||||
return this.state;
|
||||
}
|
||||
|
||||
async registerResource(resource) {
|
||||
if (!this.state.policy) {
|
||||
return null;
|
||||
}
|
||||
return this.state.policy.registerResource(resource);
|
||||
}
|
||||
|
||||
async userRequired(options = {}) {
|
||||
if (!this.state.enabled) {
|
||||
return { allowed: true, requires_login: false, reason: 'Security disabled' };
|
||||
}
|
||||
|
||||
if (this.state.isAuthenticated) {
|
||||
return { allowed: true, requires_login: false, reason: 'User authenticated' };
|
||||
}
|
||||
|
||||
if (options.redirect !== false) {
|
||||
await setRouterPath(this.state.config.login_route || '/login', true);
|
||||
}
|
||||
|
||||
return { allowed: false, requires_login: true, reason: 'User login required' };
|
||||
}
|
||||
|
||||
async userPermitted(rights, resourcePath, options = {}) {
|
||||
if (!this.state.enabled || !this.state.policy) {
|
||||
return { allowed: true, requires_login: false, reason: 'Security disabled', matched_permits: [] };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const result = await this.state.policy.evaluate(
|
||||
this.state.user.id,
|
||||
normalizeRightsInput(rights),
|
||||
resourcePath,
|
||||
options
|
||||
);
|
||||
|
||||
if (!result.allowed && result.requires_login && options.redirectOnFail) {
|
||||
await setRouterPath(this.state.config.login_route || '/login', true);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
isPermitted(rights, resourcePath, options = {}) {
|
||||
if (!this.state.enabled || !this.state.policy) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.state.isAuthenticated || !this.state.user?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof this.state.policy.evaluateSync !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = this.state.policy.evaluateSync(
|
||||
this.state.user.id,
|
||||
normalizeRightsInput(rights),
|
||||
resourcePath,
|
||||
options
|
||||
);
|
||||
|
||||
return result?.allowed === true;
|
||||
}
|
||||
|
||||
async updateAccountProfile(patch) {
|
||||
if (!this.state.policy || !this.state.user) {
|
||||
throw new Error('No authenticated user to update');
|
||||
}
|
||||
const profile = await this.state.policy.updateAccountProfile(this.state.user.id, patch);
|
||||
const user = await this.state.policy.getUser(this.state.user.id);
|
||||
this.setState({
|
||||
profile,
|
||||
user
|
||||
});
|
||||
return profile;
|
||||
}
|
||||
|
||||
async changePassword(passwordInput) {
|
||||
if (!this.state.policy || !this.state.user) {
|
||||
throw new Error('No authenticated user to update');
|
||||
}
|
||||
await this.state.policy.changePassword(this.state.user.id, passwordInput);
|
||||
}
|
||||
|
||||
async listUsers() { return this.state.policy ? this.state.policy.listUsers() : []; }
|
||||
async listRoles() { return this.state.policy ? this.state.policy.listRoles() : []; }
|
||||
async listRealms() { return this.state.policy ? this.state.policy.listRealms() : []; }
|
||||
async listResources() { return this.state.policy ? this.state.policy.listResources() : []; }
|
||||
async listPermits() { return this.state.policy ? this.state.policy.listPermits() : []; }
|
||||
}
|
||||
|
||||
export const securityService = new SecurityService();
|
||||
|
||||
export function useSecurityState() {
|
||||
return useSyncExternalStore(
|
||||
(listener) => securityService.subscribe(listener),
|
||||
securityService.getSnapshot,
|
||||
securityService.getSnapshot
|
||||
);
|
||||
}
|
||||
|
||||
export { normalizeSecurityConfig, DEFAULT_SECURITY_CONFIG };
|
||||
Reference in New Issue
Block a user