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:
Amer Agovic
2026-04-18 10:43:52 -05:00
commit 94a9f32969
87 changed files with 19750 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
export * from './model/index.js';
export * from './policy/index.js';
export * from './runtime/security-service.js';
export * from './runtime/route-guards.js';
export * from './runtime/account-tabs.js';
export * from './pages/index.js';
+10
View File
@@ -0,0 +1,10 @@
export class AccountProfile {
constructor(data = {}) {
this.user_id = data.user_id || null;
this.display_name = data.display_name || '';
this.email = data.email || '';
this.image_url = data.image_url || '';
this.preferences = data.preferences || {};
this.updated_on = data.updated_on || new Date().toISOString();
}
}
+14
View File
@@ -0,0 +1,14 @@
import { normalizeRightsInput } from './rights.js';
export class Permit {
constructor(data = {}) {
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.rights = normalizeRightsInput(data.rights || 0);
this.effect = data.effect || 'allow';
this.created_on = data.created_on || new Date().toISOString();
this.updated_on = data.updated_on || this.created_on;
}
}
+9
View File
@@ -0,0 +1,9 @@
export class Realm {
constructor(data = {}) {
this.id = data.id || null;
this.name = data.name || '';
this.description = data.description || '';
this.created_on = data.created_on || new Date().toISOString();
this.updated_on = data.updated_on || this.created_on;
}
}
+10
View File
@@ -0,0 +1,10 @@
export class Resource {
constructor(data = {}) {
this.path = data.path || '/';
this.realm_id = data.realm_id || 'local';
this.type = data.type || 'ui-route';
this.metadata = data.metadata || {};
this.created_on = data.created_on || new Date().toISOString();
this.updated_on = data.updated_on || this.created_on;
}
}
+10
View File
@@ -0,0 +1,10 @@
export class Role {
constructor(data = {}) {
this.id = data.id || null;
this.name = data.name || '';
this.realm_id = data.realm_id || 'local';
this.description = data.description || '';
this.created_on = data.created_on || new Date().toISOString();
this.updated_on = data.updated_on || this.created_on;
}
}
+12
View File
@@ -0,0 +1,12 @@
export class Session {
constructor(data = {}) {
this.user_id = data.user_id || null;
this.realm_id = data.realm_id || 'local';
this.jwt_token = data.jwt_token || '';
this.refresh_token = data.refresh_token || '';
this.issued_on = data.issued_on || new Date().toISOString();
this.expires_on = data.expires_on || null;
this.auth_provider = data.auth_provider || 'basic';
this.status = data.status || 'active';
}
}
+15
View File
@@ -0,0 +1,15 @@
export class User {
constructor(data = {}) {
this.id = data.id || null;
this.username = data.username || '';
this.display_name = data.display_name || data.displayName || '';
this.email = data.email || '';
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.status = data.status || 'active';
this.password_hash = data.password_hash || '';
this.created_on = data.created_on || new Date().toISOString();
this.updated_on = data.updated_on || this.created_on;
}
}
+15
View File
@@ -0,0 +1,15 @@
export { AccountProfile } from './AccountProfile.js';
export { Permit } from './Permit.js';
export { Realm } from './Realm.js';
export { Resource } from './Resource.js';
export { Role } from './Role.js';
export { Session } from './Session.js';
export { User } from './User.js';
export {
SECURITY_RIGHTS,
SECURITY_RIGHT_NAMES,
normalizeRightsInput,
rightsToArray,
rightsToObject,
hasRequiredRights
} from './rights.js';
+57
View File
@@ -0,0 +1,57 @@
export const SECURITY_RIGHTS = {
read: 1 << 0,
write: 1 << 1,
delete: 1 << 2,
execute: 1 << 3,
secure: 1 << 4
};
export const SECURITY_RIGHT_NAMES = Object.keys(SECURITY_RIGHTS);
export function normalizeRightsInput(rights = 0) {
if (typeof rights === 'number' && Number.isFinite(rights)) {
return rights;
}
if (typeof rights === 'string') {
return rights
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.reduce((mask, right) => mask | (SECURITY_RIGHTS[right] || 0), 0);
}
if (Array.isArray(rights)) {
return rights.reduce((mask, right) => mask | (SECURITY_RIGHTS[right] || 0), 0);
}
if (rights && typeof rights === 'object') {
return SECURITY_RIGHT_NAMES.reduce((mask, right) => {
return rights[right] ? mask | SECURITY_RIGHTS[right] : mask;
}, 0);
}
return 0;
}
export function rightsToObject(rights = 0) {
const mask = normalizeRightsInput(rights);
return SECURITY_RIGHT_NAMES.reduce((result, right) => {
result[right] = (mask & SECURITY_RIGHTS[right]) !== 0;
return result;
}, {});
}
export function rightsToArray(rights = 0) {
const mask = normalizeRightsInput(rights);
return SECURITY_RIGHT_NAMES.filter((right) => (mask & SECURITY_RIGHTS[right]) !== 0);
}
export function hasRequiredRights(grantedRights = 0, requestedRights = 0) {
const grantedMask = normalizeRightsInput(grantedRights);
const requestedMask = normalizeRightsInput(requestedRights);
if (requestedMask === 0) {
return true;
}
return (grantedMask & requestedMask) === requestedMask;
}
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import { Page } from '../../ui/components/Page.jsx';
import { AccountProfilePage } from './AccountProfilePage.jsx';
export function AccountHomePage({ title = 'Account', icon = 'account' }) {
return (
<Page icon={icon} title={title}>
<AccountProfilePage />
</Page>
);
}
export function ProfileHomePage() {
return <AccountHomePage title="Profile" icon="account" />;
}
export default AccountHomePage;
+270
View File
@@ -0,0 +1,270 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Avatar, Button, Input, Label, Paragraph, ScrollView, Text, XStack, YStack } from 'tamagui';
import { useApp } from '../../ui/App.jsx';
import { Panel } from '../../ui/components/Panel.jsx';
import { pickImage } from '../../platform/compat.js';
import { useAccountTabs } from '../runtime/account-tabs.js';
function AccountExtensionTabs() {
const tabs = useAccountTabs();
if (tabs.length === 0) {
return (
<Paragraph color="$color" opacity={0.7}>
No additional account extensions are registered yet.
</Paragraph>
);
}
return (
<YStack gap="$3">
{tabs.map((tab) => {
const TabComponent = tab.component;
return (
<Panel key={tab.id} icon={tab.icon || 'folder'} title={tab.label} headerSize={3}>
<TabComponent />
</Panel>
);
})}
</YStack>
);
}
export function AccountProfilePage() {
const { security } = useApp();
const profile = security.profile || {};
const user = security.user || {};
const [form, setForm] = useState({
display_name: '',
email: '',
image_url: ''
});
const [passwords, setPasswords] = useState({
currentPassword: '',
newPassword: ''
});
const [message, setMessage] = useState('');
const [uploadMessage, setUploadMessage] = useState('');
const initials = useMemo(() => {
const source = form.display_name || user.username || 'U';
return source
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() || '')
.join('');
}, [form.display_name, user.username]);
useEffect(() => {
setForm({
display_name: profile.display_name || user.display_name || '',
email: profile.email || user.email || '',
image_url: profile.image_url || user.image_url || ''
});
}, [profile, user]);
const saveProfile = async () => {
setMessage('');
try {
await security.updateAccountProfile(form);
setMessage('Profile updated');
} catch (error) {
setMessage(error.message || 'Failed to update profile');
}
};
const savePassword = async () => {
setMessage('');
try {
await security.changePassword(passwords);
setPasswords({
currentPassword: '',
newPassword: ''
});
setMessage('Password updated');
} catch (error) {
setMessage(error.message || 'Failed to update password');
}
};
const handlePasswordSubmit = (event) => {
event?.preventDefault?.();
savePassword();
};
const pickProfileImage = async () => {
const selection = await pickImage();
if (!selection?.file) {
return;
}
setForm((state) => ({ ...state, image_url: selection.result || '' }));
setUploadMessage(`Selected ${selection.file.name}`);
};
const clearProfileImage = () => {
setUploadMessage('');
setForm((state) => ({ ...state, image_url: '' }));
};
return (
<YStack gap="$4">
<Panel icon="account" title="Account Profile">
<YStack gap="$4">
<XStack gap="$5" flexWrap="wrap" alignItems="flex-start">
<YStack flex={1} minWidth={260} gap="$4">
<YStack gap="$2">
<Label htmlFor="profile-display-name" color="$color" fontWeight="600">
Display Name
</Label>
<Input
id="profile-display-name"
placeholder="Display name"
value={form.display_name}
onChangeText={(value) => setForm((state) => ({ ...state, display_name: value }))}
/>
</YStack>
<YStack gap="$2">
<Label htmlFor="profile-email" color="$color" fontWeight="600">
Email
</Label>
<Input
id="profile-email"
placeholder="Email"
value={form.email}
onChangeText={(value) => setForm((state) => ({ ...state, email: value }))}
/>
</YStack>
<Button themeInverse backgroundColor="$accentColor" color="white" onPress={saveProfile} alignSelf="flex-start">
Save Profile
</Button>
</YStack>
<YStack
width={220}
minWidth={220}
gap="$3"
padding="$4"
backgroundColor="$accentSurface"
borderRadius="$5"
borderWidth={1}
borderColor="$accentBorder"
alignItems="center"
>
<YStack
cursor="pointer"
alignItems="center"
gap="$2"
onPress={pickProfileImage}
>
<Avatar circular size="$8" backgroundColor="$accentColor">
{form.image_url ? <Avatar.Image src={form.image_url} /> : null}
<Avatar.Fallback backgroundColor="$accentColor">
<Text color="white" fontWeight="700">{initials}</Text>
</Avatar.Fallback>
</Avatar>
<Text color="$accentColor" fontSize="$3" fontWeight="600">
Change photo
</Text>
</YStack>
<YStack gap="$1.5" alignItems="center">
<Text fontSize="$6" fontWeight="700" color="$accentColor" textAlign="center">
{form.display_name || user.username || 'Anonymous'}
</Text>
<Paragraph color="$color" opacity={0.75} textAlign="center">
{user.username ? `Username: ${user.username}` : 'Not authenticated'}
</Paragraph>
<Paragraph color="$color" opacity={0.65} textAlign="center">
Realm: {security.realm?.name || security.realm?.id || 'local'}
</Paragraph>
</YStack>
<XStack gap="$2" flexWrap="wrap" justifyContent="center">
<Button size="$3" onPress={pickProfileImage}>
Select Image
</Button>
{form.image_url ? (
<Button size="$3" chromeless onPress={clearProfileImage}>
Remove
</Button>
) : null}
</XStack>
<Paragraph color="$color" opacity={0.6} fontSize="$3" textAlign="center">
JPG, PNG, GIF, or WebP. The selected image is stored with your account profile.
</Paragraph>
{uploadMessage ? (
<Text color="$accentColor" fontSize="$3" textAlign="center">
{uploadMessage}
</Text>
) : null}
</YStack>
</XStack>
</YStack>
</Panel>
<Panel icon="lock" title="Password">
<YStack tag="form" gap="$4" onSubmit={handlePasswordSubmit} autoComplete="on">
<Input
aria-hidden="true"
tabIndex={-1}
value={user.username || form.email || ''}
readOnly
autoComplete="username"
textContentType="username"
opacity={0}
position="absolute"
pointerEvents="none"
height={1}
width={1}
overflow="hidden"
/>
<Input
placeholder="Current password"
value={passwords.currentPassword}
onChangeText={(value) => setPasswords((state) => ({ ...state, currentPassword: value }))}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
autoComplete="current-password"
textContentType="password"
secureTextEntry
/>
<Input
placeholder="New password"
value={passwords.newPassword}
onChangeText={(value) => setPasswords((state) => ({ ...state, newPassword: value }))}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="go"
onSubmitEditing={savePassword}
secureTextEntry
/>
<Button onPress={savePassword} type="submit">
Change Password
</Button>
{message ? (
<Text color="$accentColor" fontSize="$4">
{message}
</Text>
) : null}
</YStack>
</Panel>
<Panel icon="library" title="Connected Account Panels">
<ScrollView maxHeight={320}>
<AccountExtensionTabs />
</ScrollView>
</Panel>
</YStack>
);
}
export default AccountProfilePage;
+66
View File
@@ -0,0 +1,66 @@
import React from 'react';
import { ScrollView, Text, YStack } from 'tamagui';
import { getIcon } from '../../ui/components/IconMapper.jsx';
export function ErrorPage({
title = 'Something went wrong',
message = 'The application could not complete that request.',
icon = 'error',
error = null,
debug = false
}) {
const IconComponent = getIcon(icon) || getIcon('error');
return (
<YStack
flex={1}
minHeight="100vh"
width="100%"
alignItems="center"
justifyContent="center"
padding="$6"
backgroundColor="$background"
>
<YStack
width="100%"
maxWidth={680}
padding="$6"
gap="$4"
borderWidth={1}
borderColor="$accentBorder"
borderRadius="$6"
backgroundColor="$accentSurface"
shadowColor="$shadowColor"
shadowOpacity={0.18}
shadowRadius={18}
>
<YStack gap="$3" alignItems="center">
{IconComponent ? <IconComponent size={36} color="$accentColor" /> : null}
<Text fontSize="$8" fontWeight="700" color="$accentColor" textAlign="center">
{title}
</Text>
<Text fontSize="$5" color="$color" textAlign="center" opacity={0.88}>
{message}
</Text>
</YStack>
{debug && error ? (
<ScrollView
maxHeight={260}
borderWidth={1}
borderColor="$borderColor"
borderRadius="$4"
backgroundColor="$background"
padding="$4"
>
<Text fontFamily="$mono" fontSize="$3" color="$color">
{error.stack || error.message || String(error)}
</Text>
</ScrollView>
) : null}
</YStack>
</YStack>
);
}
export default ErrorPage;
+165
View File
@@ -0,0 +1,165 @@
import React, { forwardRef, useRef, useState } from 'react';
import { Button, Input, Label, Paragraph, Text, YStack } from 'tamagui';
import { getRouterPath, setRouterPath } from '../../platform/compat.js';
import { securityService, useSecurityState } from '../runtime/security-service.js';
import { Panel } from '../../ui/components/Panel.jsx';
const LoginField = forwardRef(function LoginField({ id, label, ...props }, ref) {
return (
<YStack gap="$2">
<Label htmlFor={id} size="$3" color="$color" fontWeight="600">
{label}
</Label>
<Input ref={ref} id={id} {...props} />
</YStack>
);
});
export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign in to continue' }) {
const security = useSecurityState();
const passwordInputRef = useRef(null);
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const handleSubmit = async () => {
setErrorMessage('');
if (!security.enabled) {
setErrorMessage('Identity is disabled in the app profile.');
return;
}
if (!security.initialized || !security.policy) {
setErrorMessage('Security is still initializing. Reload once if this persists.');
return;
}
try {
await securityService.login({
username: identifier,
password
});
const currentPath = await getRouterPath('/home');
if (currentPath === (security.config.login_route || '/login')) {
await setRouterPath('/home', true);
}
} catch (error) {
setErrorMessage(error.message || 'Login failed');
}
};
const handlePasswordKeyDown = (event) => {
if (event?.key !== 'Enter') {
return;
}
event.preventDefault?.();
handleSubmit();
};
const handleFormSubmit = (event) => {
event?.preventDefault?.();
handleSubmit();
};
const content = (
<Panel
icon="login"
title={title}
width="100%"
headerFront={{ color: '$accentColor' }}
headerBack={{ backgroundColor: '$accentSurface' }}
>
<YStack
tag="form"
gap="$4"
onSubmit={handleFormSubmit}
autoComplete="on"
>
<Paragraph color="$color" opacity={0.78}>
{subtitle}
</Paragraph>
<LoginField
id="login-identifier"
label="Username or email"
placeholder="Username or email"
value={identifier}
onChangeText={setIdentifier}
autoFocus
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
autoComplete="username"
textContentType="username"
keyboardType="email-address"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => passwordInputRef.current?.focus?.()}
/>
<LoginField
id="login-password"
label="Password"
ref={passwordInputRef}
placeholder="Password"
value={password}
onChangeText={setPassword}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
autoComplete="current-password"
textContentType="password"
secureTextEntry
returnKeyType="go"
onSubmitEditing={handleSubmit}
onKeyDown={handlePasswordKeyDown}
/>
{errorMessage ? (
<Text color="#ef4444" fontSize="$4">
{errorMessage}
</Text>
) : null}
<Button
themeInverse
backgroundColor="$accentColor"
color="white"
onPress={handleSubmit}
disabled={security.loading || !security.enabled || !security.initialized}
>
{security.loading ? 'Signing In...' : 'Sign In'}
</Button>
{!security.enabled ? (
<Paragraph fontSize="$3" color="#8b5e3c">
Identity is currently disabled in the active app profile.
</Paragraph>
) : null}
{security.enabled && !security.initialized ? (
<Paragraph fontSize="$3" color="#8b5e3c">
Security is still initializing.
</Paragraph>
) : null}
<Paragraph fontSize="$3" color="$color" opacity={0.65}>
Demo credentials: admin / admin or demo / demo
</Paragraph>
</YStack>
</Panel>
);
if (compact) {
return content;
}
return (
<YStack
flex={1}
minHeight="100vh"
width="100%"
alignItems="center"
justifyContent="center"
padding="$6"
backgroundColor="$background"
>
<YStack width="100%" maxWidth={520}>
{content}
</YStack>
</YStack>
);
}
export default LoginPage;
+155
View File
@@ -0,0 +1,155 @@
import React, { useEffect, useState } from 'react';
import { Paragraph, Text, XStack, YStack } from 'tamagui';
import { SettingsPanel } from '../../ui/components/SettingsPanel.jsx';
import { rightsToArray } from '../model/rights.js';
import { useApp } from '../../ui/App.jsx';
function renderSectionBody(items, renderItem, emptyText) {
return (
<YStack gap="$3">
{items.length > 0 ? items.map(renderItem) : (
<Paragraph color="$color" opacity={0.7}>
{emptyText}
</Paragraph>
)}
</YStack>
);
}
export function SecurityAdminPage() {
const { security } = useApp();
const [users, setUsers] = useState([]);
const [roles, setRoles] = useState([]);
const [realms, setRealms] = useState([]);
const [resources, setResources] = useState([]);
const [permits, setPermits] = useState([]);
useEffect(() => {
let active = true;
async function loadSecurityData() {
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);
} catch (error) {
console.warn('[SecurityAdminPage] Failed to load security data:', error);
}
}
if (security.enabled && security.isAuthenticated) {
loadSecurityData();
}
return () => {
active = false;
};
}, [security.enabled, security.isAuthenticated, security.user?.id]);
const content = [
{
id: 'users',
label: 'Users',
icon: 'group',
content: renderSectionBody(users, (user) => (
<XStack key={user.id} justifyContent="space-between" flexWrap="wrap" gap="$2">
<YStack>
<Text fontWeight="700">{user.display_name || user.username}</Text>
<Paragraph color="$color" opacity={0.7}>{user.email}</Paragraph>
</YStack>
<Text color="$accentColor">{(user.role_ids || []).join(', ') || 'no roles'}</Text>
</XStack>
), 'No users available.')
},
{
id: 'roles',
label: 'Roles',
icon: 'lock',
content: renderSectionBody(roles, (role) => (
<YStack key={role.id} gap="$1">
<Text fontWeight="700">{role.name}</Text>
<Paragraph color="$color" opacity={0.7}>{role.description || role.id}</Paragraph>
</YStack>
), 'No roles available.')
},
{
id: 'realms',
label: 'Realms',
icon: 'network',
content: renderSectionBody(realms, (realm) => (
<YStack key={realm.id} gap="$1">
<Text fontWeight="700">{realm.name}</Text>
<Paragraph color="$color" opacity={0.7}>{realm.description || realm.id}</Paragraph>
</YStack>
), 'No realms available.')
},
{
id: 'resources',
label: 'Resources',
icon: 'library',
content: renderSectionBody(resources, (resource) => (
<YStack key={resource.path} gap="$1">
<Text fontWeight="700">{resource.path}</Text>
<Paragraph color="$color" opacity={0.7}>{resource.type} in realm {resource.realm_id}</Paragraph>
</YStack>
), 'No resources registered.')
},
{
id: 'permits',
label: 'Permits',
icon: 'lock',
content: renderSectionBody(permits, (permit) => (
<YStack key={permit.id} gap="$1">
<Text fontWeight="700">
{permit.effect.toUpperCase()} {permit.principal_type}:{permit.principal_id}
</Text>
<Paragraph color="$color" opacity={0.7}>
{permit.resource_path} {'->'} {rightsToArray(permit.rights).join(', ') || 'none'}
</Paragraph>
</YStack>
), 'No permits registered.')
}
];
return (
<SettingsPanel
icon="lock"
title="Security"
description="Security provider details, authenticated state, and the currently loaded policy dataset."
defaultExpanded={false}
persistenceKey="settings.security"
content={content}
contentStyle="tabs"
>
<YStack gap="$4">
<YStack gap="$2">
<Text color="$accentColor" fontWeight="700">
Provider: {security.config.provider}
</Text>
<Paragraph color="$color" opacity={0.75}>
Security is {security.enabled ? 'enabled' : 'disabled'}.
</Paragraph>
<Paragraph color="$color" opacity={0.75}>
Authenticated user: {security.user?.display_name || security.user?.username || 'none'}
</Paragraph>
</YStack>
</YStack>
</SettingsPanel>
);
}
export default SecurityAdminPage;
+5
View File
@@ -0,0 +1,5 @@
export { LoginPage } from './LoginPage.jsx';
export { AccountProfilePage } from './AccountProfilePage.jsx';
export { AccountHomePage, ProfileHomePage } from './AccountHomePage.jsx';
export { SecurityAdminPage } from './SecurityAdminPage.jsx';
export { ErrorPage } from './ErrorPage.jsx';
+533
View File
@@ -0,0 +1,533 @@
import { getProvider } from '../../platform/storage.js';
import { SecurityPolicy } from './SecurityPolicy.js';
import { AccountProfile, Permit, Realm, Resource, Role, Session, User, SECURITY_RIGHTS, hasRequiredRights, normalizeRightsInput } from '../model/index.js';
const STORAGE_KEY = 'security.basic.dataset';
const SESSION_KEY = 'security.basic.session';
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function nowISO() {
return new Date().toISOString();
}
function createId(prefix) {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
function pathMatches(resourcePath, targetPath) {
if (!resourcePath || resourcePath === '*') {
return true;
}
if (resourcePath.endsWith('*')) {
const prefix = resourcePath.slice(0, -1);
return targetPath.startsWith(prefix);
}
return targetPath === resourcePath || targetPath.startsWith(`${resourcePath}/`);
}
async function hashPassword(password) {
if (typeof crypto === 'undefined' || !crypto.subtle) {
return `plain:${password}`;
}
const encoded = new TextEncoder().encode(password);
const digest = await crypto.subtle.digest('SHA-256', encoded);
const hashArray = Array.from(new Uint8Array(digest));
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join('');
return `sha256:${hashHex}`;
}
async function verifyPassword(password, storedHash) {
if (!storedHash) {
return false;
}
if (storedHash.startsWith('plain:')) {
return storedHash === `plain:${password}`;
}
return (await hashPassword(password)) === storedHash;
}
function evaluateAgainstDataset(dataset, userId, rights, resourcePath, context = {}) {
const targetRights = normalizeRightsInput(rights);
const targetPath = resourcePath || context.resource_path || '/';
if (!userId) {
return {
allowed: false,
requires_login: true,
reason: 'Authentication required',
matched_permits: []
};
}
const user = dataset.users.find((item) => item.id === userId);
if (!user) {
return {
allowed: false,
requires_login: true,
reason: 'Session user not found',
matched_permits: []
};
}
const principals = [
{ principal_type: 'user', principal_id: user.id }
];
user.role_ids.forEach((roleId) => principals.push({ principal_type: 'role', principal_id: roleId }));
const matchingPermits = dataset.permits.filter((permit) => {
const principalMatch = principals.some((principal) => {
return principal.principal_type === permit.principal_type && principal.principal_id === permit.principal_id;
});
return principalMatch && pathMatches(permit.resource_path, targetPath);
});
const denyMatch = matchingPermits.find((permit) => permit.effect === 'deny' && hasRequiredRights(permit.rights, targetRights));
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, targetRights));
if (allowMatches.length > 0 || targetRights === 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)
};
}
export class BasicSecurityPolicy extends SecurityPolicy {
constructor(config = {}) {
super(config);
this.storage = getProvider('kv', 'security.basic');
this.dataset = null;
}
async init() {
const dataset = await this.storage.get(STORAGE_KEY, null);
if (dataset) {
this.dataset = dataset;
return;
}
const adminRole = new Role({
id: 'role_admin',
name: 'Administrators',
description: 'Full access to all registered resources'
});
const memberRole = new Role({
id: 'role_member',
name: 'Members',
description: 'Standard authenticated user role'
});
const realm = new Realm({
id: 'local',
name: 'Local Realm',
description: 'Local skeleton application realm'
});
const adminUser = new User({
id: 'user_admin',
username: 'admin',
display_name: 'Local Administrator',
email: 'admin@local.realm',
realm_id: realm.id,
role_ids: [adminRole.id],
password_hash: await hashPassword('admin')
});
const demoUser = new User({
id: 'user_demo',
username: 'demo',
display_name: 'Demo User',
email: 'demo@local.realm',
realm_id: realm.id,
role_ids: [memberRole.id],
password_hash: await hashPassword('demo')
});
const adminProfile = new AccountProfile({
user_id: adminUser.id,
display_name: adminUser.display_name,
email: adminUser.email
});
const demoProfile = new AccountProfile({
user_id: demoUser.id,
display_name: demoUser.display_name,
email: demoUser.email
});
this.dataset = {
version: 1,
realms: [realm],
roles: [adminRole, memberRole],
users: [adminUser, demoUser],
resources: [
new Resource({ path: '/login', realm_id: realm.id, type: 'ui-route' }),
new Resource({ path: '/settings/account', realm_id: realm.id, type: 'ui-route' }),
new Resource({ path: '/settings/security', realm_id: realm.id, type: 'ui-route' })
],
permits: [
new Permit({
id: 'permit_admin_all',
principal_type: 'role',
principal_id: adminRole.id,
resource_path: '*',
rights: Object.values(SECURITY_RIGHTS).reduce((mask, value) => mask | value, 0),
effect: 'allow'
}),
new Permit({
id: 'permit_member_account',
principal_type: 'role',
principal_id: memberRole.id,
resource_path: '/settings/account',
rights: SECURITY_RIGHTS.read | SECURITY_RIGHTS.write,
effect: 'allow'
}),
new Permit({
id: 'permit_member_home',
principal_type: 'role',
principal_id: memberRole.id,
resource_path: '/home',
rights: SECURITY_RIGHTS.read | SECURITY_RIGHTS.execute,
effect: 'allow'
})
],
profiles: [adminProfile, demoProfile]
};
await this._persistDataset();
}
async _persistDataset() {
await this.storage.set(STORAGE_KEY, clone(this.dataset));
}
async _loadDataset() {
if (!this.dataset) {
await this.init();
}
return this.dataset;
}
async authenticate(credentials = {}) {
const dataset = await this._loadDataset();
const identifier = (credentials.username || credentials.email || '').trim().toLowerCase();
const password = credentials.password || '';
const user = dataset.users.find((candidate) => {
return candidate.username.toLowerCase() === identifier || candidate.email.toLowerCase() === identifier;
});
if (!user) {
throw new Error('Unknown user');
}
const valid = await verifyPassword(password, user.password_hash);
if (!valid) {
throw new Error('Invalid password');
}
const session = new Session({
user_id: user.id,
realm_id: user.realm_id,
jwt_token: `local.${user.id}.${Date.now()}`,
issued_on: nowISO(),
auth_provider: 'basic',
status: 'active'
});
await this.saveSession(session);
return {
session,
user: clone(user),
profile: await this.getAccountProfile(user.id)
};
}
async logout() {
await this.clearSession();
}
async getCurrentSession() {
const session = await this.storage.get(SESSION_KEY, null);
return session ? new Session(session) : null;
}
async saveSession(session) {
await this.storage.set(SESSION_KEY, clone(session));
}
async clearSession() {
await this.storage.remove(SESSION_KEY);
}
async listUsers() {
const dataset = await this._loadDataset();
return clone(dataset.users);
}
async getUser(userId) {
const dataset = await this._loadDataset();
const user = dataset.users.find((item) => item.id === userId);
return user ? clone(user) : null;
}
async createUser(userData) {
const dataset = await this._loadDataset();
const createdOn = nowISO();
const user = new User({
...userData,
id: userData.id || createId('user'),
created_on: createdOn,
updated_on: createdOn,
password_hash: await hashPassword(userData.password || 'changeme')
});
dataset.users.push(user);
dataset.profiles.push(new AccountProfile({
user_id: user.id,
display_name: user.display_name,
email: user.email,
image_url: user.image_url || ''
}));
await this._persistDataset();
return clone(user);
}
async updateUser(userId, patch) {
const dataset = await this._loadDataset();
const user = dataset.users.find((item) => item.id === userId);
if (!user) {
throw new Error(`User not found: ${userId}`);
}
Object.assign(user, patch, { updated_on: nowISO() });
await this._persistDataset();
return clone(user);
}
async deleteUser(userId) {
const dataset = await this._loadDataset();
dataset.users = dataset.users.filter((item) => item.id !== userId);
dataset.profiles = dataset.profiles.filter((item) => item.user_id !== userId);
await this._persistDataset();
}
async listRoles(realmId = null) {
const dataset = await this._loadDataset();
return clone(realmId ? dataset.roles.filter((role) => role.realm_id === realmId) : dataset.roles);
}
async getRole(roleId) {
const dataset = await this._loadDataset();
const role = dataset.roles.find((item) => item.id === roleId);
return role ? clone(role) : null;
}
async createRole(roleData) {
const dataset = await this._loadDataset();
const role = new Role({
...roleData,
id: roleData.id || createId('role'),
created_on: nowISO(),
updated_on: nowISO()
});
dataset.roles.push(role);
await this._persistDataset();
return clone(role);
}
async updateRole(roleId, patch) {
const dataset = await this._loadDataset();
const role = dataset.roles.find((item) => item.id === roleId);
if (!role) {
throw new Error(`Role not found: ${roleId}`);
}
Object.assign(role, patch, { updated_on: nowISO() });
await this._persistDataset();
return clone(role);
}
async deleteRole(roleId) {
const dataset = await this._loadDataset();
dataset.roles = dataset.roles.filter((item) => item.id !== roleId);
dataset.users.forEach((user) => {
user.role_ids = user.role_ids.filter((item) => item !== roleId);
});
dataset.permits = dataset.permits.filter((permit) => !(permit.principal_type === 'role' && permit.principal_id === roleId));
await this._persistDataset();
}
async listRealms() {
const dataset = await this._loadDataset();
return clone(dataset.realms);
}
async getRealm(realmId) {
const dataset = await this._loadDataset();
const realm = dataset.realms.find((item) => item.id === realmId);
return realm ? clone(realm) : null;
}
async createRealm(realmData) {
const dataset = await this._loadDataset();
const realm = new Realm({
...realmData,
id: realmData.id || createId('realm'),
created_on: nowISO(),
updated_on: nowISO()
});
dataset.realms.push(realm);
await this._persistDataset();
return clone(realm);
}
async updateRealm(realmId, patch) {
const dataset = await this._loadDataset();
const realm = dataset.realms.find((item) => item.id === realmId);
if (!realm) {
throw new Error(`Realm not found: ${realmId}`);
}
Object.assign(realm, patch, { updated_on: nowISO() });
await this._persistDataset();
return clone(realm);
}
async registerResource(resourceData) {
const dataset = await this._loadDataset();
const existing = dataset.resources.find((item) => item.path === resourceData.path);
if (existing) {
Object.assign(existing, resourceData, { updated_on: nowISO() });
await this._persistDataset();
return clone(existing);
}
const resource = new Resource({
...resourceData,
created_on: nowISO(),
updated_on: nowISO()
});
dataset.resources.push(resource);
await this._persistDataset();
return clone(resource);
}
async listResources(realmId = null) {
const dataset = await this._loadDataset();
return clone(realmId ? dataset.resources.filter((item) => item.realm_id === realmId) : dataset.resources);
}
async listPermits(filters = {}) {
const dataset = await this._loadDataset();
let items = dataset.permits;
if (filters.principal_type) {
items = items.filter((permit) => permit.principal_type === filters.principal_type);
}
if (filters.principal_id) {
items = items.filter((permit) => permit.principal_id === filters.principal_id);
}
return clone(items);
}
async grantPermit(permitData) {
const dataset = await this._loadDataset();
const permit = new Permit({
...permitData,
id: permitData.id || createId('permit'),
created_on: nowISO(),
updated_on: nowISO()
});
dataset.permits.push(permit);
await this._persistDataset();
return clone(permit);
}
async revokePermit(permitId) {
const dataset = await this._loadDataset();
dataset.permits = dataset.permits.filter((item) => item.id !== permitId);
await this._persistDataset();
}
async getAccountProfile(userId) {
const dataset = await this._loadDataset();
const profile = dataset.profiles.find((item) => item.user_id === userId);
return profile ? clone(profile) : null;
}
async updateAccountProfile(userId, patch) {
const dataset = await this._loadDataset();
const profile = dataset.profiles.find((item) => item.user_id === userId);
const user = dataset.users.find((item) => item.id === userId);
if (!profile || !user) {
throw new Error(`Profile not found for user: ${userId}`);
}
Object.assign(profile, patch, { updated_on: nowISO() });
if (patch.display_name !== undefined) user.display_name = patch.display_name;
if (patch.email !== undefined) user.email = patch.email;
if (patch.image_url !== undefined) user.image_url = patch.image_url;
user.updated_on = nowISO();
await this._persistDataset();
return clone(profile);
}
async changePassword(userId, passwordInput = {}) {
const dataset = await this._loadDataset();
const user = dataset.users.find((item) => item.id === userId);
if (!user) {
throw new Error(`User not found: ${userId}`);
}
if (passwordInput.currentPassword) {
const valid = await verifyPassword(passwordInput.currentPassword, user.password_hash);
if (!valid) {
throw new Error('Current password is invalid');
}
}
if (!passwordInput.newPassword) {
throw new Error('New password is required');
}
user.password_hash = await hashPassword(passwordInput.newPassword);
user.updated_on = nowISO();
await this._persistDataset();
}
async evaluate(userId, rights, resourcePath, context = {}) {
const dataset = await this._loadDataset();
return evaluateAgainstDataset(dataset, userId, rights, resourcePath, context);
}
evaluateSync(userId, rights, resourcePath, context = {}) {
if (!this.dataset) {
return {
allowed: false,
requires_login: false,
reason: 'Security dataset is not initialized',
matched_permits: []
};
}
return evaluateAgainstDataset(this.dataset, userId, rights, resourcePath, context);
}
}
@@ -0,0 +1,16 @@
import { SecurityPolicy } from './SecurityPolicy.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: []
};
}
}
+51
View File
@@ -0,0 +1,51 @@
export class SecurityPolicy {
constructor(config = {}) {
this.config = config;
}
async init() {}
async authenticate(_credentials) { throw new Error('authenticate() not implemented'); }
async logout(_session) {}
async getCurrentSession() { return null; }
async saveSession(_session) {}
async clearSession() {}
async listUsers(_filters = {}) { return []; }
async getUser(_userId) { return null; }
async createUser(_userData) { throw new Error('createUser() not implemented'); }
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 getRole(_roleId) { return null; }
async createRole(_roleData) { throw new Error('createRole() not implemented'); }
async updateRole(_roleId, _patch) { throw new Error('updateRole() not implemented'); }
async deleteRole(_roleId) { throw new Error('deleteRole() not implemented'); }
async listRealms() { return []; }
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 registerResource(_resource) { throw new Error('registerResource() not implemented'); }
async listResources(_realmId = null) { return []; }
async listPermits(_filters = {}) { return []; }
async grantPermit(_permit) { throw new Error('grantPermit() 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'); }
async changePassword(_userId, _passwordInput) { throw new Error('changePassword() not implemented'); }
async evaluate(_userId, _rights, _resourcePath, _context = {}) {
return {
allowed: true,
requires_login: false,
reason: 'Security policy not enforced',
matched_permits: []
};
}
evaluateSync(_userId, _rights, _resourcePath, _context = {}) {
return {
allowed: true,
requires_login: false,
reason: 'Security policy not enforced',
matched_permits: []
};
}
}
+3
View File
@@ -0,0 +1,3 @@
export { SecurityPolicy } from './SecurityPolicy.js';
export { BasicSecurityPolicy } from './BasicSecurityPolicy.js';
export { BstoreSecurityPolicy } from './BstoreSecurityPolicy.js';
+58
View File
@@ -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);
}
+12
View File
@@ -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;
};
}
+42
View File
@@ -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'
};
}
+371
View File
@@ -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 };