streamlined bootstrap and initial config
This commit is contained in:
parent
15c93b3bf0
commit
75125e3f58
12 changed files with 231 additions and 173 deletions
|
|
@ -143,6 +143,9 @@
|
|||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 0.75rem 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sectionDescription {
|
||||
|
|
@ -199,8 +202,8 @@
|
|||
|
||||
.chartWrapperSmall {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
min-height: 220px;
|
||||
height: 250px;
|
||||
min-height: 250px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string):
|
|||
return (
|
||||
<div className={styles.chartWrapper}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
|
||||
<BarChart data={chartData} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
|
|
@ -198,7 +198,7 @@ const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string)
|
|||
return (
|
||||
<div className={styles.chartWrapper}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={section.data} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
|
||||
<LineChart data={section.data} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
|
|
@ -244,7 +244,7 @@ const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string)
|
|||
return (
|
||||
<div className={styles.chartWrapper}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={section.data} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
|
||||
<AreaChart data={section.data} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
|
|
@ -302,13 +302,13 @@ const _renderPieChart = (section: ReportSectionPieChart, currencyCode: string):
|
|||
return (
|
||||
<div className={styles.chartWrapperSmall}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<PieChart margin={{ top: 20, right: 10, left: 10, bottom: 5 }}>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={section.donut ? '55%' : 0}
|
||||
outerRadius="80%"
|
||||
innerRadius={section.donut ? '45%' : 0}
|
||||
outerRadius="65%"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={_renderLabel}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ interface ProviderMultiSelectProps {
|
|||
label?: string;
|
||||
showLabel?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
excludeByDefault?: string[];
|
||||
}
|
||||
|
||||
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||
|
|
@ -112,8 +113,10 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
|||
label = 'AI-Provider',
|
||||
showLabel = true,
|
||||
defaultExpanded = false,
|
||||
excludeByDefault = [],
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
|
||||
|
||||
|
|
@ -123,6 +126,25 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Apply default exclusions when providers first load
|
||||
useEffect(() => {
|
||||
if (
|
||||
!initialExcludeApplied &&
|
||||
allowedProviders.length > 0 &&
|
||||
excludeByDefault.length > 0 &&
|
||||
selectedProviders.length === 0
|
||||
) {
|
||||
const initialSelection = allowedProviders.filter(
|
||||
(p) => !excludeByDefault.includes(p)
|
||||
);
|
||||
// Only apply if there's actually something to exclude
|
||||
if (initialSelection.length < allowedProviders.length) {
|
||||
onChange(initialSelection);
|
||||
}
|
||||
setInitialExcludeApplied(true);
|
||||
}
|
||||
}, [allowedProviders, excludeByDefault, initialExcludeApplied, selectedProviders.length, onChange]);
|
||||
|
||||
// Click outside handler
|
||||
const handleClickOutside = useCallback((event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
|
|
@ -137,37 +159,49 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
|||
}
|
||||
}, [isExpanded, handleClickOutside]);
|
||||
|
||||
// Check if all providers are explicitly selected
|
||||
const isAllSelected = allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length;
|
||||
// Effective selection: empty array = all providers active (no restriction)
|
||||
const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders;
|
||||
|
||||
// Check if no providers are selected (= no restriction, all allowed by default)
|
||||
const isNoneSelected = selectedProviders.length === 0;
|
||||
// "Alle" is active when no restriction is set (empty array) OR all explicitly selected
|
||||
const isAllSelected = selectedProviders.length === 0 ||
|
||||
(allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length);
|
||||
|
||||
const handleToggle = (provider: string) => {
|
||||
if (selectedProviders.includes(provider)) {
|
||||
if (selectedProviders.length === 0) {
|
||||
// Currently "all active" (no restriction) -> make explicit: all except the toggled one
|
||||
onChange(allowedProviders.filter((p) => p !== provider));
|
||||
} else if (selectedProviders.includes(provider)) {
|
||||
// Deactivate: remove from selection
|
||||
onChange(selectedProviders.filter((p) => p !== provider));
|
||||
const remaining = selectedProviders.filter((p) => p !== provider);
|
||||
// If removing leaves all others selected, reset to [] (= all, no restriction)
|
||||
if (remaining.length === allowedProviders.length) {
|
||||
onChange([]);
|
||||
} else {
|
||||
onChange(remaining);
|
||||
}
|
||||
} else {
|
||||
// Activate: add to selection
|
||||
onChange([...selectedProviders, provider]);
|
||||
const updated = [...selectedProviders, provider];
|
||||
// If all are now selected, reset to [] (= all, no restriction)
|
||||
if (updated.length === allowedProviders.length) {
|
||||
onChange([]);
|
||||
} else {
|
||||
onChange(updated);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
onChange([...allowedProviders]);
|
||||
};
|
||||
|
||||
const handleSelectNone = () => {
|
||||
onChange([]);
|
||||
onChange([]); // Empty = all active, no restriction
|
||||
};
|
||||
|
||||
// Summary icon for button
|
||||
const summaryIcon = useMemo(() => {
|
||||
if (selectedProviders.length === 1) {
|
||||
return PROVIDER_ICONS[selectedProviders[0]] || '🔌';
|
||||
if (effectiveSelection.length === 1) {
|
||||
return PROVIDER_ICONS[effectiveSelection[0]] || '🔌';
|
||||
}
|
||||
return '🤖';
|
||||
}, [selectedProviders]);
|
||||
}, [effectiveSelection]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -199,14 +233,6 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
|||
>
|
||||
Alle
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectNone}
|
||||
disabled={disabled}
|
||||
className={`${styles.actionButton} ${isNoneSelected ? styles.active : ''}`}
|
||||
>
|
||||
Keine
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -220,7 +246,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
|||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProviders.includes(provider)}
|
||||
checked={effectiveSelection.includes(provider)}
|
||||
onChange={() => handleToggle(provider)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
|
@ -233,9 +259,9 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{selectedProviders.length === 0 && !loading && (
|
||||
{isAllSelected && !loading && (
|
||||
<div className={styles.hint}>
|
||||
Alle Provider aktiv
|
||||
Alle Provider aktiv (kein Filter)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import type { Workflow, WorkflowMessage } from '../../api/workflowApi';
|
|||
import { useWorkflowLifecycle } from './useWorkflowLifecycle';
|
||||
import { useWorkflows } from './useWorkflows';
|
||||
import { useDashboardLogTree } from './useDashboardLogTree';
|
||||
import { extractFileIdsFromMessage, convertFilesToDocuments, sortMessages } from './playgroundUtils';
|
||||
import { convertFilesToDocuments, sortMessages } from './playgroundUtils';
|
||||
import type { WorkflowLog as LogTypesWorkflowLog } from '../../components/UiComponents/Log/LogTypes';
|
||||
|
||||
export interface WorkflowFile {
|
||||
|
|
@ -279,44 +279,21 @@ export function useDashboardInputForm(instanceId: string) {
|
|||
|
||||
useEffect(() => {
|
||||
if (!messages || messages.length === 0) return;
|
||||
if (!optimisticMessage) return;
|
||||
|
||||
const messageTexts = new Set<string>();
|
||||
messages.forEach((message: WorkflowMessage) => {
|
||||
if (message.message) {
|
||||
messageTexts.add(message.message.trim());
|
||||
}
|
||||
});
|
||||
// Clear optimistic message when backend's "first" user message arrives via polling.
|
||||
// The backend message contains the normalizedRequest (which differs from the original prompt),
|
||||
// so we match by status="first" instead of content comparison.
|
||||
const hasFirstMessage = messages.some((msg: WorkflowMessage) =>
|
||||
(msg as any).status === 'first' && msg.role?.toLowerCase() === 'user'
|
||||
);
|
||||
|
||||
if (optimisticMessage && optimisticMessage.message) {
|
||||
const optimisticText = optimisticMessage.message.trim();
|
||||
const optimisticFileIds = extractFileIdsFromMessage(optimisticMessage);
|
||||
|
||||
const matchingMessage = Array.from(messages).find((msg: WorkflowMessage) =>
|
||||
msg.message && msg.message.trim() === optimisticText
|
||||
);
|
||||
|
||||
if (matchingMessage) {
|
||||
const matchingFileIds = extractFileIdsFromMessage(matchingMessage);
|
||||
|
||||
if (optimisticFileIds.size > 0) {
|
||||
const allFilesConfirmed = Array.from(optimisticFileIds).every(fileId =>
|
||||
matchingFileIds.has(fileId)
|
||||
);
|
||||
if (allFilesConfirmed && matchingFileIds.size > 0) {
|
||||
setOptimisticMessage(null);
|
||||
}
|
||||
} else {
|
||||
if (messageTexts.has(optimisticText)) {
|
||||
setOptimisticMessage(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasFirstMessage) {
|
||||
setOptimisticMessage(null);
|
||||
}
|
||||
}, [messages, optimisticMessage]);
|
||||
|
||||
const displayMessages = useMemo(() => {
|
||||
const optimisticText = optimisticMessage?.message?.trim();
|
||||
|
||||
const processedMessages = (messages || []).map((message: WorkflowMessage) => {
|
||||
const files = (message as any).files as any[] | undefined;
|
||||
const documents = (message as any).documents as MessageDocument[] | undefined;
|
||||
|
|
@ -331,37 +308,19 @@ export function useDashboardInputForm(instanceId: string) {
|
|||
return message;
|
||||
});
|
||||
|
||||
let replacedMessageTimestamp: number | undefined;
|
||||
const filteredMessages = processedMessages.filter((message: WorkflowMessage) => {
|
||||
const isUserMessage = message.role?.toLowerCase() === 'user';
|
||||
const messageText = message.message?.trim();
|
||||
|
||||
if (optimisticMessage && optimisticText && isUserMessage && messageText === optimisticText) {
|
||||
const documents = (message as any).documents as MessageDocument[] | undefined;
|
||||
const files = (message as any).files as any[] | undefined;
|
||||
const hasDocuments = documents && Array.isArray(documents) && documents.length > 0;
|
||||
const hasFiles = files && Array.isArray(files) && files.length > 0;
|
||||
|
||||
if (hasDocuments || hasFiles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.publishedAt !== undefined) {
|
||||
replacedMessageTimestamp = message.publishedAt;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const allMessages = [...filteredMessages];
|
||||
// If optimistic message is still active (backend "first" message not yet polled),
|
||||
// show the optimistic message instead of any backend user messages to avoid duplicates.
|
||||
const allMessages = [...processedMessages];
|
||||
if (optimisticMessage) {
|
||||
const optimisticWithTimestamp = replacedMessageTimestamp !== undefined
|
||||
? { ...optimisticMessage, publishedAt: replacedMessageTimestamp }
|
||||
: optimisticMessage;
|
||||
allMessages.push(optimisticWithTimestamp);
|
||||
// Find backend "first" user message to inherit its timestamp for correct ordering
|
||||
const firstBackendMsg = processedMessages.find((msg: WorkflowMessage) =>
|
||||
(msg as any).status === 'first' && msg.role?.toLowerCase() === 'user'
|
||||
);
|
||||
if (!firstBackendMsg) {
|
||||
// Backend "first" message not yet arrived - show optimistic message
|
||||
allMessages.push(optimisticMessage);
|
||||
}
|
||||
// If firstBackendMsg exists, the useEffect above will clear optimistic on next render
|
||||
}
|
||||
|
||||
return allMessages.sort(sortMessages);
|
||||
|
|
|
|||
|
|
@ -256,26 +256,12 @@ export function useChatbot(): ChatbotHookReturn {
|
|||
return prev;
|
||||
}
|
||||
|
||||
// For user messages, check if we already have a temporary one with same content
|
||||
// Only replace if it's the temporary message we just created (by ID match)
|
||||
if (message.role === 'user' && message.message === inputMessageContent) {
|
||||
// Check if we have the exact temporary message we created
|
||||
const hasTempMessage = prev.some(m => m.id === tempUserMessageId);
|
||||
if (hasTempMessage) {
|
||||
// Replace the temporary message with the real one from backend
|
||||
return prev.map(m =>
|
||||
m.id === tempUserMessageId ? message : m
|
||||
);
|
||||
}
|
||||
// If no temp message found, check if this is a duplicate of an existing real message
|
||||
const isDuplicate = prev.some(m =>
|
||||
m.role === 'user' &&
|
||||
m.message === inputMessageContent &&
|
||||
!m.id.startsWith('temp-')
|
||||
// Backend sends the "first" message with the transformed/normalized user prompt
|
||||
// Replace the temporary optimistic message with it
|
||||
if (message.status === 'first') {
|
||||
return prev.map(m =>
|
||||
m.id === tempUserMessageId ? message : m
|
||||
);
|
||||
if (isDuplicate) {
|
||||
return prev; // Don't add duplicate
|
||||
}
|
||||
}
|
||||
|
||||
// For other messages, check for duplicates by role and content (more lenient check)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export interface InvitationCreate {
|
|||
email?: string;
|
||||
roleIds: string[];
|
||||
featureInstanceId?: string;
|
||||
frontendUrl?: string;
|
||||
expiresInHours?: number;
|
||||
maxUses?: number;
|
||||
}
|
||||
|
|
@ -117,6 +118,7 @@ export function useInvitations() {
|
|||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append('frontendUrl', window.location.origin);
|
||||
if (fetchOptions?.includeUsed) params.append('includeUsed', 'true');
|
||||
if (fetchOptions?.includeExpired) params.append('includeExpired', 'true');
|
||||
if (Object.keys(paginationParams).length > 0) {
|
||||
|
|
@ -160,7 +162,11 @@ export function useInvitations() {
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post('/api/invitations/', data, {
|
||||
const payload = {
|
||||
...data,
|
||||
frontendUrl: data.frontendUrl || window.location.origin,
|
||||
};
|
||||
const response = await api.post('/api/invitations/', payload, {
|
||||
headers: { 'X-Mandate-Id': mandateId }
|
||||
});
|
||||
return { success: true, data: response.data };
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export interface Mandate {
|
|||
name: string | { [key: string]: string };
|
||||
code?: string;
|
||||
language?: string;
|
||||
isSystem?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -226,7 +227,10 @@ export function useUserMandates() {
|
|||
}, []);
|
||||
|
||||
/**
|
||||
* Fetch all available roles (global and mandate-specific, excluding feature-instance roles)
|
||||
* Fetch available roles for a mandate (mandate-instance roles only).
|
||||
* Each mandate has its own instances of system roles (admin, user, viewer)
|
||||
* copied from templates during mandate creation. Only these mandate-bound
|
||||
* roles should be offered for user assignment - NOT global templates.
|
||||
*/
|
||||
const fetchRoles = useCallback(async (mandateId?: string): Promise<Role[]> => {
|
||||
try {
|
||||
|
|
@ -238,15 +242,15 @@ export function useUserMandates() {
|
|||
roles = response.data;
|
||||
}
|
||||
|
||||
// Filter to global roles and mandate-specific roles only
|
||||
// Exclude feature-instance roles (they have featureInstanceId set)
|
||||
// Only mandate-instance roles (mandateId matches, no featureInstanceId)
|
||||
// Global templates (mandateId=null) are NOT assignable to users
|
||||
if (mandateId) {
|
||||
return roles.filter(r =>
|
||||
!r.featureInstanceId && (!r.mandateId || r.mandateId === mandateId)
|
||||
!r.featureInstanceId && r.mandateId === mandateId
|
||||
);
|
||||
}
|
||||
// Without mandateId, return only global roles (no mandateId and no featureInstanceId)
|
||||
return roles.filter(r => !r.mandateId && !r.featureInstanceId);
|
||||
// Without mandateId, no roles available (roles are always mandate-specific)
|
||||
return [];
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching roles:', err);
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export const InvitePage: React.FC = () => {
|
|||
const [accepting, setAccepting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userExists, setUserExists] = useState<boolean | null>(null);
|
||||
|
||||
// Validate token on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -56,7 +57,6 @@ export const InvitePage: React.FC = () => {
|
|||
|
||||
const result = await validateInvitation(token);
|
||||
setValidation(result);
|
||||
setValidating(false);
|
||||
|
||||
// If invitation is valid but user is not authenticated,
|
||||
// store the token for later use after login/registration
|
||||
|
|
@ -64,7 +64,24 @@ export const InvitePage: React.FC = () => {
|
|||
// (e.g., when user opens password reset email in a new tab)
|
||||
if (result.valid && !isAuthenticated) {
|
||||
localStorage.setItem(PENDING_INVITATION_KEY, token);
|
||||
|
||||
// Check if the target username already has an account
|
||||
if (result.targetUsername) {
|
||||
try {
|
||||
const resp = await fetch(`/api/local/available?username=${encodeURIComponent(result.targetUsername)}`);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
// available=true means username is free -> user does NOT exist
|
||||
setUserExists(!data.available);
|
||||
}
|
||||
} catch {
|
||||
// On error, default to showing both options
|
||||
setUserExists(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setValidating(false);
|
||||
};
|
||||
|
||||
validate();
|
||||
|
|
@ -222,7 +239,7 @@ export const InvitePage: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
// Not authenticated - show login/register options (NO inline registration form)
|
||||
// Not authenticated - show appropriate options based on whether user account exists
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
|
|
@ -254,9 +271,11 @@ export const InvitePage: React.FC = () => {
|
|||
|
||||
<div className={styles.authPrompt}>
|
||||
<p>
|
||||
{validation.targetUsername
|
||||
{userExists === true
|
||||
? `Bitte melden Sie sich als "${validation.targetUsername}" an, um die Einladung anzunehmen.`
|
||||
: 'Bitte melden Sie sich an, um die Einladung anzunehmen.'}
|
||||
: userExists === false
|
||||
? 'Bitte erstellen Sie ein Konto, um die Einladung anzunehmen.'
|
||||
: 'Bitte melden Sie sich an oder erstellen Sie ein Konto, um die Einladung anzunehmen.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -267,29 +286,48 @@ export const InvitePage: React.FC = () => {
|
|||
)}
|
||||
|
||||
<div className={styles.authActions}>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleLoginRedirect}
|
||||
>
|
||||
<FaSignInAlt /> Anmelden
|
||||
</button>
|
||||
|
||||
<div className={styles.divider}>
|
||||
<span>oder</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={handleRegisterRedirect}
|
||||
>
|
||||
<FaUserPlus /> Neues Konto erstellen
|
||||
</button>
|
||||
{userExists === true ? (
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleLoginRedirect}
|
||||
>
|
||||
<FaSignInAlt /> Anmelden
|
||||
</button>
|
||||
) : userExists === false ? (
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleRegisterRedirect}
|
||||
>
|
||||
<FaUserPlus /> Konto erstellen
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleLoginRedirect}
|
||||
>
|
||||
<FaSignInAlt /> Anmelden
|
||||
</button>
|
||||
<div className={styles.divider}>
|
||||
<span>oder</span>
|
||||
</div>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={handleRegisterRedirect}
|
||||
>
|
||||
<FaUserPlus /> Neues Konto erstellen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.authInfo}>
|
||||
<p>
|
||||
Sie können sich mit Ihrem bestehenden Konto anmelden oder ein neues erstellen.
|
||||
Die Einladung wird automatisch nach der Anmeldung akzeptiert.
|
||||
{userExists === true
|
||||
? 'Melden Sie sich mit Ihrem bestehenden Konto an. Die Einladung wird automatisch nach der Anmeldung akzeptiert.'
|
||||
: userExists === false
|
||||
? 'Erstellen Sie ein neues Konto. Die Einladung wird automatisch nach der Registrierung akzeptiert.'
|
||||
: 'Die Einladung wird automatisch nach der Anmeldung akzeptiert.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
|||
// State
|
||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||||
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all');
|
||||
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
|
||||
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
|
||||
|
||||
// Cleanup state
|
||||
|
|
@ -117,19 +117,24 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
|||
setExpandedRoleId(prev => prev === roleId ? null : roleId);
|
||||
};
|
||||
|
||||
// Check if a role is a template (not bound to a specific mandate)
|
||||
const _isTemplateRole = (role: Role): boolean => {
|
||||
return !!role.isSystemRole || !role.mandateId;
|
||||
};
|
||||
|
||||
// Get scope badge
|
||||
const getScopeBadge = (role: Role) => {
|
||||
if (role.isSystemRole) {
|
||||
return (
|
||||
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
|
||||
<FaUserShield style={{ marginRight: 4 }} /> System
|
||||
<FaUserShield style={{ marginRight: 4 }} /> System-Template
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!role.mandateId) {
|
||||
return (
|
||||
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
|
||||
<FaGlobe style={{ marginRight: 4 }} /> Global
|
||||
<FaGlobe style={{ marginRight: 4 }} /> Template
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -185,9 +190,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
|||
|
||||
// Filter options for scope
|
||||
const scopeOptions = useMemo(() => [
|
||||
{ value: 'all', label: 'Alle Rollen' },
|
||||
{ value: 'mandate', label: 'Nur Mandanten-Rollen' },
|
||||
{ value: 'global', label: 'Nur globale Rollen' },
|
||||
{ value: 'mandate', label: 'Mandanten-Rollen' },
|
||||
{ value: 'all', label: 'Alle (inkl. Templates)' },
|
||||
{ value: 'global', label: 'Nur Templates' },
|
||||
], []);
|
||||
|
||||
if (error) {
|
||||
|
|
@ -274,7 +279,8 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
|||
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
|
||||
<span>
|
||||
Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.
|
||||
Alle Rollen-Berechtigungen sind bearbeitbar (System-Rollen-Namen sind geschützt).
|
||||
<strong> Template-Rollen</strong> sind schreibgeschützt - Änderungen an Templates wirken sich nur auf neu erstellte Mandanten aus.
|
||||
<strong> Mandanten-Rollen</strong> sind direkt bearbeitbar.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -293,9 +299,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
|||
<p>Keine Rollen gefunden</p>
|
||||
<p className={styles.emptyHint}>
|
||||
{scopeFilter === 'mandate'
|
||||
? 'Es gibt noch keine mandantenspezifischen Rollen.'
|
||||
? 'Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.'
|
||||
: scopeFilter === 'global'
|
||||
? 'Es gibt noch keine globalen Rollen.'
|
||||
? 'Es gibt noch keine Rollen-Templates.'
|
||||
: 'Es gibt noch keine Rollen für diesen Mandanten.'}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -328,11 +334,20 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
|||
{/* Expanded Content - AccessRulesEditor */}
|
||||
{expandedRoleId === role.id && (
|
||||
<div className={styles.roleContent}>
|
||||
{_isTemplateRole(role) && (
|
||||
<div className={styles.infoBox} style={{ marginBottom: '0.75rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
||||
<FaUserShield style={{ marginRight: '0.5rem', color: 'var(--warning-color, #d69e2e)' }} />
|
||||
<span>
|
||||
Dies ist eine <strong>Template-Rolle</strong>. Änderungen an den Berechtigungen wirken sich nur auf neu erstellte Mandanten aus.
|
||||
Bestehende Mandanten-Instanzen werden nicht aktualisiert.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<AccessRulesEditor
|
||||
roleId={role.id}
|
||||
roleName={role.roleLabel}
|
||||
isTemplate={false}
|
||||
readOnly={false} // All AccessRules are editable (access controlled via RBAC)
|
||||
isTemplate={_isTemplateRole(role)}
|
||||
readOnly={false}
|
||||
apiBasePath="/api/rbac"
|
||||
mandateId={selectedMandateId}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all');
|
||||
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
// Store current filter state for refetch
|
||||
|
|
@ -126,14 +126,14 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
if (value === 'system') {
|
||||
return (
|
||||
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
|
||||
<FaUserShield style={{ marginRight: 4 }} /> System
|
||||
<FaUserShield style={{ marginRight: 4 }} /> System-Template
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (value === 'global') {
|
||||
return (
|
||||
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
|
||||
<FaGlobe style={{ marginRight: 4 }} /> Global
|
||||
<FaGlobe style={{ marginRight: 4 }} /> Template
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -164,7 +164,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
default: 'mandate',
|
||||
options: [
|
||||
{ value: 'mandate', label: 'Nur dieser Mandant' },
|
||||
{ value: 'global', label: 'Global (alle Mandanten)' },
|
||||
{ value: 'global', label: 'Template (wird bei neuen Mandanten kopiert)' },
|
||||
]
|
||||
});
|
||||
}
|
||||
|
|
@ -359,9 +359,9 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
|
||||
style={{ minWidth: 150 }}
|
||||
>
|
||||
<option value="all">Alle Rollen</option>
|
||||
<option value="mandate">Nur Mandanten-Rollen</option>
|
||||
<option value="global">Nur globale Rollen</option>
|
||||
<option value="mandate">Mandanten-Rollen</option>
|
||||
<option value="all">Alle (inkl. Templates)</option>
|
||||
<option value="global">Nur Templates</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -389,9 +389,9 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
<div className={styles.infoBox}>
|
||||
<FaUserShield style={{ marginRight: 8 }} />
|
||||
<span>
|
||||
<strong>System-Rollen</strong> (admin, user, viewer) können nicht bearbeitet oder gelöscht werden.
|
||||
<strong> Globale Rollen</strong> gelten für alle Mandanten.
|
||||
<strong> Mandanten-Rollen</strong> gelten nur für den ausgewählten Mandanten.
|
||||
<strong>System-Templates</strong> (admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert.
|
||||
Templates selbst können nicht gelöscht werden.
|
||||
<strong> Mandanten-Rollen</strong> gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -416,9 +416,9 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
<h3 className={styles.emptyTitle}>Keine Rollen</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
{scopeFilter === 'mandate'
|
||||
? 'Es gibt noch keine mandantenspezifischen Rollen.'
|
||||
? 'Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.'
|
||||
: scopeFilter === 'global'
|
||||
? 'Es gibt noch keine globalen Rollen.'
|
||||
? 'Es gibt noch keine Rollen-Templates.'
|
||||
: 'Es gibt noch keine Rollen für diesen Mandanten.'}
|
||||
</p>
|
||||
<button
|
||||
|
|
@ -522,7 +522,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
|
||||
<FaUserShield style={{ marginRight: 8 }} />
|
||||
<span>
|
||||
Geltungsbereich: <strong>{editingRole.mandateId ? 'Mandant-spezifisch' : 'Global'}</strong>
|
||||
Geltungsbereich: <strong>{editingRole.mandateId ? 'Mandanten-Instanz' : 'Template (global)'}</strong>
|
||||
{' '}(kann nicht geändert werden)
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { useAdminMandates, type Mandate } from '../../hooks/useMandates';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaBuilding, FaUsers } from 'react-icons/fa';
|
||||
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
export const AdminMandatesPage: React.FC = () => {
|
||||
|
|
@ -42,6 +42,11 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
})) as AttributeDefinition[];
|
||||
}, [attributes]);
|
||||
|
||||
// Create form attributes - exclude isSystem (only set by system, not user)
|
||||
const createFormAttributes: AttributeDefinition[] = useMemo(() => {
|
||||
return formAttributes.filter(attr => attr.name !== 'isSystem');
|
||||
}, [formAttributes]);
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingMandate, setEditingMandate] = useState<Mandate | null>(null);
|
||||
|
||||
|
|
@ -76,7 +81,11 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
};
|
||||
|
||||
// Handle delete (confirmation handled by DeleteActionButton)
|
||||
// System mandates (isSystem=true) are protected from deletion
|
||||
const handleDeleteMandate = async (mandate: Mandate) => {
|
||||
if (mandate.isSystem) {
|
||||
return; // Safety guard - should not be reachable due to disabled button
|
||||
}
|
||||
await handleDelete(mandate.id);
|
||||
};
|
||||
|
||||
|
|
@ -169,6 +178,9 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
...(canDelete ? [{
|
||||
type: 'delete' as const,
|
||||
title: 'Löschen',
|
||||
disabled: (row: Mandate) => row.isSystem
|
||||
? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' }
|
||||
: false
|
||||
}] : []),
|
||||
]}
|
||||
onDelete={handleDeleteMandate}
|
||||
|
|
@ -199,14 +211,14 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{formAttributes.length === 0 ? (
|
||||
{createFormAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={formAttributes}
|
||||
attributes={createFormAttributes}
|
||||
mode="create"
|
||||
onSubmit={handleCreateSubmit}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
|
|
@ -233,6 +245,14 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{editingMandate.isSystem && (
|
||||
<div className={styles.infoBox} style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
||||
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
||||
<span>
|
||||
Dies ist ein <strong>System-Mandant</strong>. Er kann nicht gelöscht werden und der Name sollte nicht geändert werden.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
|
|
|
|||
|
|
@ -774,6 +774,7 @@ export const PlaygroundPage: React.FC = () => {
|
|||
selectedProviders={selectedProviders}
|
||||
onChange={onProvidersChange}
|
||||
showLabel={false}
|
||||
excludeByDefault={['privatellm']}
|
||||
/>
|
||||
<VoiceLanguageSelect
|
||||
value={voiceLanguage}
|
||||
|
|
|
|||
Loading…
Reference in a new issue