Update bface UI and security work

This commit is contained in:
Amer Agovic
2026-05-31 12:30:02 -05:00
parent 6fe23fae86
commit c6f7240912
45 changed files with 4531 additions and 553 deletions
+7 -1
View File
@@ -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();
+12
View File
@@ -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;
}
}
+1
View File
@@ -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';
}
}
+2
View File
@@ -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();
+1
View File
@@ -3,6 +3,7 @@ export { Permit } from './Permit.js';
export { Realm } from './Realm.js';
export { Resource } from './Resource.js';
export { Role } from './Role.js';
export { Securable } from './Securable.js';
export { Session } from './Session.js';
export { User } from './User.js';
export {
+7
View File
@@ -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)) {
+14 -5
View File
@@ -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
</Button>
{form.image_url ? (
<Button size="$3" chromeless onPress={clearProfileImage}>
<Button size="$3" onPress={clearProfileImage}>
Remove
</Button>
) : null}
+2 -2
View File
@@ -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"
>
+421 -83
View File
@@ -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 (
<YStack
tag="form"
gap="$4"
onSubmit={handleFormSubmit}
autoComplete="on"
>
<YStack alignItems="center" gap="$3" paddingBottom="$2">
<XStack alignItems="center" justifyContent="center" gap="$3" width="100%">
{brandLogo ? (
<Image
source={{ uri: brandLogo }}
width={48}
height={48}
borderRadius="$radiusMd"
resizeMode="contain"
/>
) : null}
<Text fontSize="$8" fontWeight="700" color="$textPrimary" textAlign="center">
{appName || 'Account'}
</Text>
</XStack>
{subtitle ? (
<Paragraph color="$textMuted" textAlign="center">
{subtitle}
</Paragraph>
) : null}
</YStack>
<LoginField
id="login-identifier"
label="Username or email"
error={Boolean(errorMessage)}
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"
error={Boolean(errorMessage)}
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="$danger" fontSize="$4">
{errorMessage}
</Text>
) : null}
<Button
theme="accent"
onPress={handleSubmit}
disabled={security.loading || !security.enabled || !security.initialized}
>
{security.loading ? 'Signing In...' : 'Sign In'}
</Button>
{!security.enabled ? (
<Paragraph fontSize="$3" color="$textSecondary">
Identity is currently disabled in the active app profile.
</Paragraph>
) : null}
{security.enabled && !security.initialized ? (
<Paragraph fontSize="$3" color="$textSecondary">
Security is still initializing.
</Paragraph>
) : null}
<XStack alignItems="center" justifyContent="space-between" gap="$3">
<Paragraph fontSize="$3" color="$textMuted" flex={1}>
Demo credentials: admin / admin or demo / demo
</Paragraph>
<Button chromeless paddingHorizontal={0} minHeight={0} height="auto" onPress={handleForgotPassword}>
Forgot password?
</Button>
</XStack>
</YStack>
);
}
export function LoginResetForm({
mode = 'request',
token = '',
subtitle = '',
onRequestReset = null,
onCompleteReset = null,
}) {
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [feedbackMessage, setFeedbackMessage] = useState('');
const [previewLink, setPreviewLink] = useState('');
const [submitting, setSubmitting] = useState(false);
const effectiveSubtitle = subtitle || (mode === 'complete'
? 'Choose a new password for your account.'
: 'Enter your username or email to request a password reset link.');
const handleBackToLogin = async () => {
await setRouterPath('/login', true, { state: null });
};
const handleRequest = async () => {
setErrorMessage('');
setFeedbackMessage('');
setPreviewLink('');
if (!identifier.trim()) {
setErrorMessage('Please enter your username or email.');
return;
}
if (typeof onRequestReset !== 'function') {
setErrorMessage('Password reset is not configured for this application.');
return;
}
setSubmitting(true);
try {
const result = await onRequestReset({ identifier: identifier.trim() });
setFeedbackMessage(String(result?.message || 'If the account exists, reset instructions are ready.'));
setPreviewLink(String(result?.preview_reset_link || ''));
} catch (error) {
setErrorMessage(error?.message || 'Password reset request failed.');
} finally {
setSubmitting(false);
}
};
const handleComplete = async () => {
setErrorMessage('');
setFeedbackMessage('');
if (!token) {
setErrorMessage('This reset link is missing a token.');
return;
}
if (!password) {
setErrorMessage('Please enter a new password.');
return;
}
if (password !== confirmPassword) {
setErrorMessage('The passwords do not match.');
return;
}
if (typeof onCompleteReset !== 'function') {
setErrorMessage('Password reset completion is not configured for this application.');
return;
}
setSubmitting(true);
try {
const result = await onCompleteReset({ token, new_password: password });
setFeedbackMessage(String(result?.message || 'Your password has been updated. You can now sign in.'));
setPassword('');
setConfirmPassword('');
} catch (error) {
setErrorMessage(error?.message || 'Password reset failed.');
} finally {
setSubmitting(false);
}
};
if (mode === 'complete') {
return (
<YStack gap="$4">
<Paragraph color="$textMuted">{effectiveSubtitle}</Paragraph>
<LoginField
id="reset-password"
label="New password"
error={Boolean(errorMessage)}
placeholder="New password"
value={password}
onChangeText={setPassword}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
secureTextEntry
autoComplete="new-password"
textContentType="newPassword"
/>
<LoginField
id="reset-password-confirm"
label="Confirm password"
error={Boolean(errorMessage)}
placeholder="Confirm password"
value={confirmPassword}
onChangeText={setConfirmPassword}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
secureTextEntry
autoComplete="new-password"
textContentType="newPassword"
/>
{errorMessage ? (
<Text color="$danger" fontSize="$4">
{errorMessage}
</Text>
) : null}
{feedbackMessage ? (
<Text color="$success" fontSize="$4">
{feedbackMessage}
</Text>
) : null}
<Button theme="accent" onPress={handleComplete} disabled={submitting}>
{submitting ? 'Updating Password...' : 'Set New Password'}
</Button>
<Button chromeless alignSelf="flex-start" paddingHorizontal={0} onPress={handleBackToLogin}>
Back to login
</Button>
</YStack>
);
}
return (
<YStack gap="$4">
<Paragraph color="$textMuted">{effectiveSubtitle}</Paragraph>
<LoginField
id="reset-identifier"
label="Username or email"
error={Boolean(errorMessage)}
placeholder="Username or email"
value={identifier}
onChangeText={setIdentifier}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
autoComplete="username"
textContentType="username"
/>
{errorMessage ? (
<Text color="$danger" fontSize="$4">
{errorMessage}
</Text>
) : null}
{feedbackMessage ? (
<YStack gap="$2">
<Text color="$success" fontSize="$4">
{feedbackMessage}
</Text>
{previewLink ? (
<Button chromeless alignSelf="flex-start" paddingHorizontal={0} onPress={() => setRouterPath(normalizeInternalPath(previewLink), true, { state: null })}>
Open preview reset link
</Button>
) : null}
</YStack>
) : null}
<Button theme="accent" onPress={handleRequest} disabled={submitting}>
{submitting ? 'Requesting Reset...' : 'Request Password Reset'}
</Button>
<Button chromeless alignSelf="flex-start" paddingHorizontal={0} onPress={handleBackToLogin}>
Back to login
</Button>
</YStack>
);
}
function LoginPanel({
title = 'Login',
subtitle = '',
compact = false,
onComplete = null,
mode = 'login',
resetToken = '',
onRequestReset = null,
onCompleteReset = null,
}) {
const content = useMemo(() => {
if (mode === 'reset-request') {
return <LoginResetForm mode="request" subtitle={subtitle} onRequestReset={onRequestReset} />;
}
if (mode === 'reset-complete') {
return <LoginResetForm mode="complete" token={resetToken} subtitle={subtitle} onCompleteReset={onCompleteReset} />;
}
return <LoginForm compact={compact} subtitle={subtitle} onComplete={onComplete} />;
}, [compact, mode, onComplete, onCompleteReset, onRequestReset, resetToken, subtitle]);
return (
<Panel
icon="login"
title={title}
@@ -74,84 +429,60 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
headerFront={{ color: '$textPrimary' }}
headerBack={{ backgroundColor: '$bgPanel' }}
>
<YStack
tag="form"
gap="$4"
onSubmit={handleFormSubmit}
autoComplete="on"
>
<Paragraph color="$textMuted">
{subtitle}
</Paragraph>
<LoginField
id="login-identifier"
label="Username or email"
error={Boolean(errorMessage)}
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"
error={Boolean(errorMessage)}
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="$danger" fontSize="$4">
{errorMessage}
</Text>
) : null}
<Button
theme="accent"
onPress={handleSubmit}
disabled={security.loading || !security.enabled || !security.initialized}
>
{security.loading ? 'Signing In...' : 'Sign In'}
</Button>
{!security.enabled ? (
<Paragraph fontSize="$3" color="$textSecondary">
Identity is currently disabled in the active app profile.
</Paragraph>
) : null}
{security.enabled && !security.initialized ? (
<Paragraph fontSize="$3" color="$textSecondary">
Security is still initializing.
</Paragraph>
) : null}
<Paragraph fontSize="$3" color="$textMuted">
Demo credentials: admin / admin or demo / demo
</Paragraph>
</YStack>
{content}
</Panel>
);
}
if (compact) {
return content;
}
export function LoginDialog({ open = true, title = 'Login', subtitle = 'This route requires an authenticated user.' }) {
return (
<Dialog modal open={open}>
<Adapt when="sm" platform="touch">
<Sheet modal dismissOnSnapToBottom={false} snapPoints={[85]} zIndex={22000}>
<Sheet.Frame backgroundColor="$bgPage" padding="$4">
<Adapt.Contents />
</Sheet.Frame>
<Sheet.Overlay backgroundColor="$scrim" />
</Sheet>
</Adapt>
<Dialog.Portal>
<Dialog.Overlay
key="overlay"
backgroundColor="$scrim"
opacity={0.7}
/>
<Dialog.Content
key="content"
bordered
elevate
backgroundColor="transparent"
borderWidth={0}
shadowOpacity={0}
padding={0}
width="100%"
maxWidth={520}
>
<LoginPanel title={title} subtitle={subtitle} compact onComplete={() => {}} />
</Dialog.Content>
</Dialog.Portal>
</Dialog>
);
}
export function LoginPage({ title = 'Login', subtitle = '' }) {
return (
<LoginPageMode title={title} subtitle={subtitle} mode="login" />
);
}
export function LoginPageMode({
title = 'Login',
subtitle = '',
mode = 'login',
resetToken = '',
onRequestReset = null,
onCompleteReset = null,
}) {
return (
<YStack
flex={1}
@@ -163,7 +494,14 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
backgroundColor="$bgPage"
>
<YStack width="100%" maxWidth={520}>
{content}
<LoginPanel
title={title}
subtitle={subtitle}
mode={mode}
resetToken={resetToken}
onRequestReset={onRequestReset}
onCompleteReset={onCompleteReset}
/>
</YStack>
</YStack>
);
+751 -69
View File
@@ -1,58 +1,225 @@
import React, { useEffect, useState } from 'react';
import { Paragraph, Text, XStack, YStack } from 'tamagui';
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Paragraph, Text, XStack, YStack } from 'tamagui';
import { DirView } from '../../ui/components/DirView.jsx';
import { FormField } from '../../ui/components/FormField.jsx';
import { SettingsPanel } from '../../ui/components/SettingsPanel.jsx';
import { rightsToArray } from '../model/rights.js';
import { SECURITY_RIGHTS, SECURITY_RIGHT_SHORT_LABELS, normalizeRightsInput } from '../model/rights.js';
import { CodedValueBadges } from '../../ui/components/CodedValues.jsx';
import { useApp } from '../../ui/App.jsx';
function renderSectionBody(items, renderItem, emptyText) {
function createLocalDataModel(rows, idField, searchableFields) {
const normalizedRows = Array.isArray(rows) ? rows : [];
return {
getIdField() {
return idField;
},
async queryRecords(query = {}) {
const search = String(query?.filter_by?.search || '').trim().toLowerCase();
const pageSize = Number(query?.page_size || 10);
const offset = Number(query?.offset || 0);
const sortRule = Array.isArray(query?.sort_by) ? query.sort_by[0] : null;
let filtered = normalizedRows;
if (search) {
filtered = normalizedRows.filter((row) => searchableFields.some((field) => {
const value = row?.[field];
if (Array.isArray(value)) {
return value.join(', ').toLowerCase().includes(search);
}
if (value && typeof value === 'object') {
return JSON.stringify(value).toLowerCase().includes(search);
}
return String(value ?? '').toLowerCase().includes(search);
}));
}
if (sortRule?.field) {
const direction = sortRule.direction === 'desc' || sortRule.direction === 'descending' ? -1 : 1;
filtered = [...filtered].sort((left, right) => {
const leftValue = left?.[sortRule.field];
const rightValue = right?.[sortRule.field];
const leftText = Array.isArray(leftValue) ? leftValue.join(', ') : String(leftValue ?? '');
const rightText = Array.isArray(rightValue) ? rightValue.join(', ') : String(rightValue ?? '');
return leftText.localeCompare(rightText, undefined, { numeric: true, sensitivity: 'base' }) * direction;
});
}
return {
total: filtered.length,
rows: filtered.slice(offset, offset + pageSize)
};
},
async querySummary() {
return { items: [] };
}
};
}
function SectionForm({
title,
mode,
visible,
fields,
onChange,
onSubmit,
onCancel,
busy = false
}) {
if (!visible) {
return null;
}
return (
<YStack gap="$3">
{items.length > 0 ? items.map(renderItem) : (
<Paragraph color="$color" opacity={0.7}>
{emptyText}
</Paragraph>
)}
<YStack
gap="$3"
padding="$4"
borderWidth={1}
borderColor="$lineSubtle"
borderRadius="$radiusLg"
backgroundColor="$bgPanel"
>
<Text fontWeight="700">{mode === 'edit' ? `Edit ${title}` : `Create ${title}`}</Text>
<YStack gap="$3">
{fields.map((field) => (
<XStack key={field.id} gap="$4" alignItems="flex-start" flexWrap="wrap">
<Text width={140} flexShrink={0} fontSize="$4" fontWeight="600" paddingTop="$2">
{field.label}
</Text>
<YStack flex={1} minWidth={280}>
<FormField
id={field.id}
type={field.type || 'text'}
value={field.value}
options={field.options}
codeMap={field.codeMap}
labels={field.labels}
placeholder={field.placeholder}
helperText={field.helperText}
readOnly={field.readOnly}
onChange={onChange}
/>
</YStack>
</XStack>
))}
</YStack>
<XStack gap="$3">
<Button onPress={onSubmit} disabled={busy}>
{busy ? 'Working...' : mode === 'edit' ? 'Save Changes' : 'Create'}
</Button>
<Button chromeless onPress={onCancel} disabled={busy}>
Cancel
</Button>
</XStack>
</YStack>
);
}
function ActionCell({ onDelete, busy = false }) {
return (
<XStack justifyContent="flex-end" width="100%">
<Button
size="$2"
theme="red"
onMouseDown={(event) => event?.stopPropagation?.()}
onClick={(event) => {
event?.preventDefault?.();
event?.stopPropagation?.();
onDelete?.();
}}
onPress={(event) => {
event?.stopPropagation?.();
onDelete?.();
}}
disabled={busy}
>
Delete
</Button>
</XStack>
);
}
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 (
<XStack flexWrap="wrap" gap="$2">
{names.map((name) => (
<XStack
key={name}
paddingHorizontal="$2.5"
paddingVertical="$1"
borderRadius="$radiusLg"
borderWidth={1}
borderColor="$lineSubtle"
backgroundColor="$bgPage"
>
<Text fontSize="$2" color="$textPrimary">{name}</Text>
</XStack>
))}
</XStack>
);
}
},
{
id: 'actions',
label: 'Actions',
minWidth: 120,
flex: 0.9,
align: 'right',
sortable: false,
render: (_value, record) => (
<ActionCell
busy={busy === `delete-user-${record.id}`}
onDelete={() => execute(`delete-user-${record.id}`, () => security.deleteUser(record.id))}
/>
)
}
]), [busy, roleNameById, security]);
const roleColumns = useMemo(() => ([
{ id: 'name', label: 'Role', minWidth: 220, flex: 1.4, render: (value) => `role:${value || ''}` },
{ id: 'description', label: 'Description', minWidth: 260, flex: 2 },
{ id: 'realm_id', label: 'Realm', minWidth: 140, flex: 1 },
{
id: 'actions',
label: 'Actions',
minWidth: 120,
flex: 0.9,
align: 'right',
sortable: false,
render: (_value, record) => (
<ActionCell
busy={busy === `delete-role-${record.id}`}
onDelete={() => execute(`delete-role-${record.id}`, () => security.deleteRole(record.id))}
/>
)
}
]), [busy, security]);
const realmColumns = useMemo(() => ([
{ id: 'name', label: 'Name', minWidth: 220, flex: 1.3 },
{ id: 'id', label: 'Realm ID', minWidth: 160, flex: 1 },
{ id: 'description', label: 'Description', minWidth: 320, flex: 2 }
]), []);
const resourceColumns = useMemo(() => ([
{ id: 'path', label: 'Path', minWidth: 240, flex: 1.8 },
{ id: 'realm_id', label: 'Realm', minWidth: 140, flex: 1 },
{ id: 'type', label: 'Type', minWidth: 140, flex: 1 },
{
id: 'actions',
label: 'Actions',
minWidth: 120,
flex: 0.9,
align: 'right',
sortable: false,
render: (_value, record) => (
<ActionCell
busy={busy === `delete-resource-${record.path}`}
onDelete={() => execute(`delete-resource-${record.path}`, () => security.deleteResource(record.path))}
/>
)
}
]), [busy, security]);
const permitColumns = useMemo(() => ([
{
id: 'principal_display',
label: 'Principal',
minWidth: 220,
flex: 1.6,
render: (_value, record) => {
if (record?.principal_display) {
return record.principal_display;
}
const type = record?.principal_type || 'role';
const key = String(record?.principal_id ?? '');
const name = type === 'user'
? (userNameById[key] || record?.principal_name || key)
: (roleNameById[key] || record?.principal_name || key);
return `${type}:${name}`;
}
},
{ id: 'subject_display', label: 'Subject', minWidth: 240, flex: 1.8 },
{
id: 'rights',
label: 'Rights',
minWidth: 110,
flex: 0.9,
render: (value) => <CodedValueBadges codeMap={SECURITY_RIGHTS} labels={SECURITY_RIGHT_SHORT_LABELS} value={value} compact />
},
{
id: 'actions',
label: 'Actions',
minWidth: 120,
flex: 0.9,
align: 'right',
sortable: false,
render: (_value, record) => (
<ActionCell
busy={busy === `delete-permit-${record.id}`}
onDelete={() => execute(`delete-permit-${record.id}`, () => security.deletePermit(record.id))}
/>
)
}
]), [busy, roleNameById, security, userNameById]);
function openCreateUser() {
setUserEditor({
open: true,
mode: 'create',
selectedId: null,
values: {
username: '',
display_name: '',
email: '',
realm_id: realmOptions[0]?.value || 'local',
role_ids: [],
password: '',
status: 'active'
}
});
}
function openEditUser(record) {
setUserEditor({
open: true,
mode: 'edit',
selectedId: record.id,
values: {
username: record.username || '',
display_name: record.display_name || '',
email: record.email || '',
realm_id: record.realm_id || (realmOptions[0]?.value || 'local'),
role_ids: Array.isArray(record.role_ids) ? record.role_ids : [],
password: '',
status: record.status || 'active'
}
});
}
function openCreateRole() {
setRoleEditor({
open: true,
mode: 'create',
selectedId: null,
values: { name: '', description: '', realm_id: realmOptions[0]?.value || 'local' }
});
}
function openEditRole(record) {
setRoleEditor({
open: true,
mode: 'edit',
selectedId: record.id,
values: { name: record.name || '', description: record.description || '', realm_id: record.realm_id || (realmOptions[0]?.value || 'local') }
});
}
function openCreateResource() {
setResourceEditor({
open: true,
mode: 'create',
selectedId: null,
values: { path: '', realm_id: realmOptions[0]?.value || 'local', type: 'ui-route', metadata_json: '{}' }
});
}
function openEditResource(record) {
setResourceEditor({
open: true,
mode: 'edit',
selectedId: record.path,
values: {
path: record.path || '',
realm_id: record.realm_id || (realmOptions[0]?.value || 'local'),
type: record.type || 'ui-route',
metadata_json: JSON.stringify(record.metadata || {}, null, 2)
}
});
}
function openCreatePermit() {
setPermitEditor({
open: true,
mode: 'create',
selectedId: null,
values: {
principal_key: principalOptions[0]?.value || '',
subject_id: subjectOptions[0]?.value || '',
rights: normalizeRightsInput(['read']),
effect: 'allow'
}
});
}
function openEditPermit(record) {
setPermitEditor({
open: true,
mode: 'edit',
selectedId: record.id,
values: {
principal_key: `${record.principal_type || 'role'}:${record.principal_id || ''}`,
subject_id: record.subject_id != null ? String(record.subject_id) : '',
rights: normalizeRightsInput(record.rights),
effect: record.effect || 'allow'
}
});
}
const userFormFields = [
{ id: 'username', label: 'Username', value: userEditor.values.username },
{ id: 'display_name', label: 'Display Name', value: userEditor.values.display_name },
{ id: 'email', label: 'Email', value: userEditor.values.email },
{ id: 'realm_id', label: 'Realm', type: 'select', value: userEditor.values.realm_id, options: realmOptions },
{ id: 'role_ids', label: 'Roles', type: 'multiselect', value: userEditor.values.role_ids, options: roleOptions, helperText: 'Select one or more roles.' },
{ id: 'status', label: 'Status', type: 'select', value: userEditor.values.status, options: [{ value: 'active', label: 'active' }, { value: 'disabled', label: 'disabled' }] },
{ id: 'password', label: userEditor.mode === 'edit' ? 'New Password' : 'Initial Password', value: userEditor.values.password, helperText: userEditor.mode === 'edit' ? 'Leave blank to keep current password.' : 'Used for the first login.' }
];
const roleFormFields = [
{ id: 'name', label: 'Role Name', value: roleEditor.values.name },
{ id: 'description', label: 'Description', value: roleEditor.values.description },
{ id: 'realm_id', label: 'Realm', type: 'select', value: roleEditor.values.realm_id, options: realmOptions }
];
const resourceFormFields = [
{ id: 'path', label: 'Path', value: resourceEditor.values.path, readOnly: resourceEditor.mode === 'edit', helperText: resourceEditor.mode === 'edit' ? 'Path is the resource identity and is read only while editing.' : '' },
{ id: 'realm_id', label: 'Realm', type: 'select', value: resourceEditor.values.realm_id, options: realmOptions },
{ id: 'type', label: 'Type', value: resourceEditor.values.type },
{ id: 'metadata_json', label: 'Metadata JSON', type: 'textarea', value: resourceEditor.values.metadata_json }
];
const permitFormFields = [
{ id: 'principal_key', label: 'Principal', type: 'select', value: permitEditor.values.principal_key, options: principalOptions },
{ id: 'subject_id', label: 'Subject', type: 'select', value: permitEditor.values.subject_id, options: subjectOptions },
{ id: 'effect', label: 'Effect', type: 'select', value: permitEditor.values.effect, options: [{ value: 'allow', label: 'allow' }, { value: 'deny', label: 'deny' }] },
{ id: 'rights', label: 'Rights', type: 'coded-checkboxes', value: permitEditor.values.rights, codeMap: SECURITY_RIGHTS }
];
const content = [
{
id: 'users',
label: 'Users',
icon: 'group',
content: renderSectionBody(users, (user) => (
<XStack key={user.id} justifyContent="space-between" flexWrap="wrap" gap="$2">
<YStack>
<Text fontWeight="700">{user.display_name || user.username}</Text>
<Paragraph color="$color" opacity={0.7}>{user.email}</Paragraph>
</YStack>
<Text color="$accentColor">{(user.role_ids || []).join(', ') || 'no roles'}</Text>
</XStack>
), 'No users available.')
content: (
<DirView
title="Users"
dataModel={userDataModel}
columns={userColumns}
pageSize={8}
showSummary={false}
searchConfig={{ enabled: true, placeholder: 'Search users...' }}
toolbarActions={[
{
id: 'create-user',
label: userEditor.open && userEditor.mode === 'create' ? 'Creating' : 'Create',
onPress: openCreateUser
}
]}
bodyHeaderContent={(
<SectionForm
title="User"
mode={userEditor.mode}
visible={userEditor.open}
fields={userFormFields}
busy={busy === 'save-user'}
onChange={(fieldId, value) => setUserEditor((current) => ({
...current,
values: { ...current.values, [fieldId]: value }
}))}
onCancel={() => setUserEditor((current) => ({ ...current, open: false, selectedId: null }))}
onSubmit={() => execute('save-user', async () => {
const payload = {
...userEditor.values,
role_ids: Array.isArray(userEditor.values.role_ids) ? userEditor.values.role_ids : []
};
if (userEditor.mode === 'edit') {
await security.updateUser(userEditor.selectedId, payload);
setMessage('User updated');
} else {
await security.createUser(payload);
setMessage('User created');
}
setUserEditor((current) => ({ ...current, open: false, selectedId: null }));
})}
/>
)}
onRowClick={openEditUser}
/>
)
},
{
id: 'roles',
label: 'Roles',
icon: 'lock',
content: renderSectionBody(roles, (role) => (
<YStack key={role.id} gap="$1">
<Text fontWeight="700">{role.name}</Text>
<Paragraph color="$color" opacity={0.7}>{role.description || role.id}</Paragraph>
</YStack>
), 'No roles available.')
content: (
<DirView
title="Roles"
dataModel={roleDataModel}
columns={roleColumns}
pageSize={8}
showSummary={false}
searchConfig={{ enabled: true, placeholder: 'Search roles...' }}
toolbarActions={[
{
id: 'create-role',
label: roleEditor.open && roleEditor.mode === 'create' ? 'Creating' : 'Create',
onPress: openCreateRole
}
]}
bodyHeaderContent={(
<SectionForm
title="Role"
mode={roleEditor.mode}
visible={roleEditor.open}
fields={roleFormFields}
busy={busy === 'save-role'}
onChange={(fieldId, value) => setRoleEditor((current) => ({
...current,
values: { ...current.values, [fieldId]: value }
}))}
onCancel={() => setRoleEditor((current) => ({ ...current, open: false, selectedId: null }))}
onSubmit={() => execute('save-role', async () => {
if (roleEditor.mode === 'edit') {
await security.updateRole(roleEditor.selectedId, roleEditor.values);
setMessage('Role updated');
} else {
await security.createRole(roleEditor.values);
setMessage('Role created');
}
setRoleEditor((current) => ({ ...current, open: false, selectedId: null }));
})}
/>
)}
onRowClick={openEditRole}
/>
)
},
{
id: 'realms',
label: 'Realms',
icon: 'network',
content: renderSectionBody(realms, (realm) => (
<YStack key={realm.id} gap="$1">
<Text fontWeight="700">{realm.name}</Text>
<Paragraph color="$color" opacity={0.7}>{realm.description || realm.id}</Paragraph>
</YStack>
), 'No realms available.')
content: (
<DirView
title="Realms"
dataModel={realmDataModel}
columns={realmColumns}
pageSize={8}
showSummary={false}
searchConfig={{ enabled: true, placeholder: 'Search realms...' }}
bodyHeaderContent={(
<Paragraph color="$textSecondary">
Realms are read only for now. We seed the local realm in backend code and keep it stable while the rest of security matures.
</Paragraph>
)}
/>
)
},
{
id: 'resources',
label: 'Resources',
icon: 'library',
content: renderSectionBody(resources, (resource) => (
<YStack key={resource.path} gap="$1">
<Text fontWeight="700">{resource.path}</Text>
<Paragraph color="$color" opacity={0.7}>{resource.type} in realm {resource.realm_id}</Paragraph>
</YStack>
), 'No resources registered.')
content: (
<DirView
title="Resources"
dataModel={resourceDataModel}
columns={resourceColumns}
pageSize={8}
showSummary={false}
searchConfig={{ enabled: true, placeholder: 'Search resources...' }}
toolbarActions={[
{
id: 'create-resource',
label: resourceEditor.open && resourceEditor.mode === 'create' ? 'Creating' : 'Create',
onPress: openCreateResource
}
]}
bodyHeaderContent={(
<SectionForm
title="Resource"
mode={resourceEditor.mode}
visible={resourceEditor.open}
fields={resourceFormFields}
busy={busy === 'save-resource'}
onChange={(fieldId, value) => setResourceEditor((current) => ({
...current,
values: { ...current.values, [fieldId]: value }
}))}
onCancel={() => setResourceEditor((current) => ({ ...current, open: false, selectedId: null }))}
onSubmit={() => execute('save-resource', async () => {
const payload = {
path: resourceEditor.values.path,
realm_id: resourceEditor.values.realm_id,
type: resourceEditor.values.type,
metadata: JSON.parse(resourceEditor.values.metadata_json || '{}')
};
if (resourceEditor.mode === 'edit') {
await security.updateResource(resourceEditor.selectedId, payload);
setMessage('Resource updated');
} else {
await security.createResource(payload);
setMessage('Resource created');
}
setResourceEditor((current) => ({ ...current, open: false, selectedId: null }));
})}
/>
)}
onRowClick={openEditResource}
/>
)
},
{
id: 'permits',
label: 'Permits',
icon: 'lock',
content: renderSectionBody(permits, (permit) => (
<YStack key={permit.id} gap="$1">
<Text fontWeight="700">
{permit.effect.toUpperCase()} {permit.principal_type}:{permit.principal_id}
</Text>
<Paragraph color="$color" opacity={0.7}>
{permit.resource_path} {'->'} {rightsToArray(permit.rights).join(', ') || 'none'}
</Paragraph>
</YStack>
), 'No permits registered.')
},
content: (
<DirView
title="Permits"
dataModel={permitDataModel}
columns={permitColumns}
pageSize={8}
showSummary={false}
searchConfig={{ enabled: true, placeholder: 'Search permits...' }}
toolbarActions={[
{
id: 'create-permit',
label: permitEditor.open && permitEditor.mode === 'create' ? 'Creating' : 'Create',
onPress: openCreatePermit
}
]}
bodyHeaderContent={(
<SectionForm
title="Permit"
mode={permitEditor.mode}
visible={permitEditor.open}
fields={permitFormFields}
busy={busy === 'save-permit'}
onChange={(fieldId, value) => setPermitEditor((current) => ({
...current,
values: { ...current.values, [fieldId]: value }
}))}
onCancel={() => setPermitEditor((current) => ({ ...current, open: false, selectedId: null }))}
onSubmit={() => execute('save-permit', async () => {
const payload = { ...permitEditor.values };
const [principalType, principalId] = String(payload.principal_key || '').split(':');
payload.principal_type = principalType || 'role';
payload.principal_id = principalId ? Number(principalId) : null;
payload.subject_id = payload.subject_id ? Number(payload.subject_id) : null;
payload.resource_path = null;
delete payload.principal_key;
if (permitEditor.mode === 'edit') {
await security.updatePermit(permitEditor.selectedId, payload);
setMessage('Permit updated');
} else {
await security.createPermit(payload);
setMessage('Permit created');
}
setPermitEditor((current) => ({ ...current, open: false, selectedId: null }));
})}
/>
)}
onRowClick={openEditPermit}
/>
)
}
];
const loadingNotice = loadingData ? (
<Paragraph color="$color" opacity={0.75}>
Loading security data...
</Paragraph>
) : null;
return (
<SettingsPanel
icon="lock"
title="Security"
description="Security provider details, authenticated state, and the currently loaded policy dataset."
description="Directory-style security administration for users, roles, realms, resources, and permits."
defaultExpanded={false}
persistenceKey="settings.security"
content={content}
@@ -140,12 +816,18 @@ export function SecurityAdminPage() {
<Text color="$accentColor" fontWeight="700">
Provider: {security.config.provider}
</Text>
{loadingNotice}
<Paragraph color="$color" opacity={0.75}>
Security is {security.enabled ? 'enabled' : 'disabled'}.
</Paragraph>
<Paragraph color="$color" opacity={0.75}>
Authenticated user: {security.user?.display_name || security.user?.username || 'none'}
</Paragraph>
{message ? (
<Paragraph color="$accentColor">
{message}
</Paragraph>
) : null}
</YStack>
</YStack>
</SettingsPanel>
+558
View File
@@ -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 || '/');
}
}
+2 -15
View File
@@ -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 {}
+5
View File
@@ -15,6 +15,7 @@ export class SecurityPolicy {
async updateUser(_userId, _patch) { throw new Error('updateUser() not implemented'); }
async deleteUser(_userId) { throw new Error('deleteUser() not implemented'); }
async listRoles(_realmId = null) { return []; }
async listSubjects() { return []; }
async getRole(_roleId) { return null; }
async createRole(_roleData) { throw new Error('createRole() not implemented'); }
async updateRole(_roleId, _patch) { throw new Error('updateRole() not implemented'); }
@@ -23,10 +24,14 @@ export class SecurityPolicy {
async getRealm(_realmId) { return null; }
async createRealm(_realmData) { throw new Error('createRealm() not implemented'); }
async updateRealm(_realmId, _patch) { throw new Error('updateRealm() not implemented'); }
async deleteRealm(_realmId) { throw new Error('deleteRealm() not implemented'); }
async registerResource(_resource) { throw new Error('registerResource() not implemented'); }
async updateResource(_path, _patch) { throw new Error('updateResource() not implemented'); }
async deleteResource(_path) { throw new Error('deleteResource() not implemented'); }
async listResources(_realmId = null) { return []; }
async listPermits(_filters = {}) { return []; }
async grantPermit(_permit) { throw new Error('grantPermit() not implemented'); }
async updatePermit(_permitId, _patch) { throw new Error('updatePermit() not implemented'); }
async revokePermit(_permitId) { throw new Error('revokePermit() not implemented'); }
async getAccountProfile(_userId) { return null; }
async updateAccountProfile(_userId, _patch) { throw new Error('updateAccountProfile() not implemented'); }
+1
View File
@@ -1,3 +1,4 @@
export { SecurityPolicy } from './SecurityPolicy.js';
export { ApiSecurityPolicy } from './ApiSecurityPolicy.js';
export { BasicSecurityPolicy } from './BasicSecurityPolicy.js';
export { BstoreSecurityPolicy } from './BstoreSecurityPolicy.js';
+81
View File
@@ -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' };
}
+27 -4
View File
@@ -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 });
}
+170 -68
View File
@@ -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();