fixed proper splitting sysadmin/platformadmin and proper logic for mandate name(slug) and label(user)
This commit is contained in:
parent
7ab2cdff2d
commit
2e00f3ac44
54 changed files with 1335 additions and 304 deletions
|
|
@ -101,6 +101,7 @@ export interface AuthUser {
|
||||||
roleLabels?: string[];
|
roleLabels?: string[];
|
||||||
authenticationAuthority: string;
|
authenticationAuthority: string;
|
||||||
isSysAdmin?: boolean;
|
isSysAdmin?: boolean;
|
||||||
|
isPlatformAdmin?: boolean;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
InstancePermissions,
|
InstancePermissions,
|
||||||
AccessLevel,
|
AccessLevel,
|
||||||
} from '../types/mandate';
|
} from '../types/mandate';
|
||||||
|
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MOCK DATA (Temporär bis Backend bereit)
|
// MOCK DATA (Temporär bis Backend bereit)
|
||||||
|
|
@ -71,7 +72,8 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
|
||||||
mandates: [
|
mandates: [
|
||||||
{
|
{
|
||||||
id: 'mand-soha',
|
id: 'mand-soha',
|
||||||
name: 'Soha Treuhand',
|
name: 'soha-treuhand',
|
||||||
|
label: 'Soha Treuhand',
|
||||||
code: 'soha',
|
code: 'soha',
|
||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
|
|
@ -119,7 +121,8 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'mand-swiss',
|
id: 'mand-swiss',
|
||||||
name: 'SwissTreu',
|
name: 'swisstreu',
|
||||||
|
label: 'SwissTreu',
|
||||||
code: 'swisstreu',
|
code: 'swisstreu',
|
||||||
features: [
|
features: [
|
||||||
{
|
{
|
||||||
|
|
@ -189,7 +192,7 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
|
||||||
if (feature.code === 'chatbot') {
|
if (feature.code === 'chatbot') {
|
||||||
console.log('🔍 [DEBUG] featuresApi: Found chatbot feature', {
|
console.log('🔍 [DEBUG] featuresApi: Found chatbot feature', {
|
||||||
mandateId: mandate.id,
|
mandateId: mandate.id,
|
||||||
mandateName: mandate.label || mandate.name,
|
mandateName: mandateDisplayLabel(mandate),
|
||||||
featureCode: feature.code,
|
featureCode: feature.code,
|
||||||
instanceCount: feature.instances.length,
|
instanceCount: feature.instances.length,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,40 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
// TYPES & INTERFACES
|
// TYPES & INTERFACES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mandate (Mandant) — represents one tenant in PowerOn PORTA.
|
||||||
|
*
|
||||||
|
* Field semantics (must stay in sync with the backend `Mandate` Pydantic model):
|
||||||
|
* - `id` — UUID, immutable.
|
||||||
|
* - `name` — Kurzzeichen / slug. Globally unique, lowercase [a-z0-9] with
|
||||||
|
* hyphen-separated segments (length 2–32). Used for audit/tracking
|
||||||
|
* and stable references. Only PlatformAdmin can change it after
|
||||||
|
* creation.
|
||||||
|
* - `label` — Voller Name. Mandatory, human-readable display name shown in the
|
||||||
|
* UI. Freely changeable by a Mandate-Admin.
|
||||||
|
*/
|
||||||
export interface Mandate {
|
export interface Mandate {
|
||||||
id: string;
|
id: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
isSystem?: boolean;
|
||||||
|
deletedAt?: number | null;
|
||||||
[key: string]: any; // Allow additional properties from backend
|
[key: string]: any; // Allow additional properties from backend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Payload for creating a mandate. `label` is required, `name` is optional. */
|
||||||
|
export interface MandateCreateData {
|
||||||
|
label: string;
|
||||||
|
name?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for updating a mandate. Only PlatformAdmin may change `name`;
|
||||||
|
* Mandate-Admin can update `label` and other UI fields.
|
||||||
|
*/
|
||||||
export type MandateUpdateData = Partial<Omit<Mandate, 'id'>>;
|
export type MandateUpdateData = Partial<Omit<Mandate, 'id'>>;
|
||||||
|
|
||||||
export interface PaginationParams {
|
export interface PaginationParams {
|
||||||
|
|
@ -112,7 +141,7 @@ export async function updateMandate(
|
||||||
*/
|
*/
|
||||||
export async function createMandate(
|
export async function createMandate(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
mandateData: Partial<Mandate>
|
mandateData: MandateCreateData | Partial<Mandate>
|
||||||
): Promise<Mandate> {
|
): Promise<Mandate> {
|
||||||
return await request({
|
return await request({
|
||||||
url: '/api/mandates/',
|
url: '/api/mandates/',
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ export interface User {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"])
|
roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"])
|
||||||
authenticationAuthority: string;
|
authenticationAuthority: string;
|
||||||
isSysAdmin?: boolean; // System-Administrator Flag
|
isSysAdmin?: boolean; // Infrastructure/System Operator (RBAC bypass)
|
||||||
|
isPlatformAdmin?: boolean; // Cross-Mandate Governance (no RBAC bypass)
|
||||||
// mandateId ist nicht mehr Teil des User-Objekts (Multi-Tenant-Konzept)
|
// mandateId ist nicht mehr Teil des User-Objekts (Multi-Tenant-Konzept)
|
||||||
// Der Mandant-Kontext wird über Feature-Instanzen bestimmt
|
// Der Mandant-Kontext wird über Feature-Instanzen bestimmt
|
||||||
[key: string]: any; // Allow additional properties
|
[key: string]: any; // Allow additional properties
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,21 @@ import {
|
||||||
getDefaultValueForType
|
getDefaultValueForType
|
||||||
} from '../../../utils/attributeTypeMapper';
|
} from '../../../utils/attributeTypeMapper';
|
||||||
import type { AttributeType } from '../../../utils/attributeTypeMapper';
|
import type { AttributeType } from '../../../utils/attributeTypeMapper';
|
||||||
|
import {
|
||||||
|
SLUG_HINT,
|
||||||
|
maskSlugInput,
|
||||||
|
slugify,
|
||||||
|
validateSlug,
|
||||||
|
} from '../../../utils/slugUtils';
|
||||||
|
|
||||||
|
const _isSlugType = (attrType: AttributeType | undefined): boolean => attrType === 'slug';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default source field used to auto-derive a slug in `create` mode. A specific
|
||||||
|
* attribute can override this by setting `slugSource` in its definition
|
||||||
|
* (json_schema_extra.slug_source on the backend).
|
||||||
|
*/
|
||||||
|
const _DEFAULT_SLUG_SOURCE_FIELD = 'label';
|
||||||
|
|
||||||
const isTextMultilingual = (value: any): boolean => {
|
const isTextMultilingual = (value: any): boolean => {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
|
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
|
||||||
|
|
@ -370,33 +385,58 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tracks slug fields that have been touched manually so we don't override them
|
||||||
|
// when the user keeps editing the source label afterwards.
|
||||||
|
const slugFieldsManuallyEdited = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
// Handle field value changes
|
// Handle field value changes
|
||||||
// For timestamp fields: Convert datetime-local string to Unix timestamp (float in seconds)
|
// For timestamp fields: Convert datetime-local string to Unix timestamp (float in seconds)
|
||||||
const handleFieldChange = (fieldName: string, value: any, fieldType?: AttributeType) => {
|
const handleFieldChange = (fieldName: string, value: any, fieldType?: AttributeType) => {
|
||||||
let processedValue = value;
|
let processedValue = value;
|
||||||
|
|
||||||
// If field type is timestamp, convert datetime-local string to Unix timestamp
|
|
||||||
if (fieldType === 'timestamp' && typeof value === 'string' && value) {
|
if (fieldType === 'timestamp' && typeof value === 'string' && value) {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (!isNaN(date.getTime())) {
|
if (!isNaN(date.getTime())) {
|
||||||
// Convert to Unix timestamp in seconds (float)
|
|
||||||
processedValue = date.getTime() / 1000;
|
processedValue = date.getTime() / 1000;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData(prev => ({
|
if (_isSlugType(fieldType)) {
|
||||||
...prev,
|
processedValue = maskSlugInput(String(value ?? ''));
|
||||||
[fieldName]: processedValue
|
slugFieldsManuallyEdited.current.add(fieldName);
|
||||||
}));
|
}
|
||||||
|
|
||||||
|
const autoFilledSlugFields = new Set<string>();
|
||||||
|
|
||||||
|
setFormData(prev => {
|
||||||
|
const next: any = { ...prev, [fieldName]: processedValue };
|
||||||
|
|
||||||
|
// Generic auto-suggest: any slug attribute can declare its source field
|
||||||
|
// via attr.slugSource (default: 'label'). When that source changes in
|
||||||
|
// create mode and the slug is still untouched, derive a suggestion.
|
||||||
|
if (mode === 'create' && !_isSlugType(fieldType)) {
|
||||||
|
const attrs = attributes ?? [];
|
||||||
|
for (const a of attrs) {
|
||||||
|
if (!_isSlugType(a.type as AttributeType)) continue;
|
||||||
|
const source = (a as any).slugSource || _DEFAULT_SLUG_SOURCE_FIELD;
|
||||||
|
if (source !== fieldName) continue;
|
||||||
|
if (slugFieldsManuallyEdited.current.has(a.name)) continue;
|
||||||
|
const sourceStr = typeof processedValue === 'string' ? processedValue : '';
|
||||||
|
if (sourceStr.trim().length === 0) continue;
|
||||||
|
next[a.name] = slugify(sourceStr);
|
||||||
|
autoFilledSlugFields.add(a.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Clear error for this field when user starts typing
|
|
||||||
if (errors[fieldName]) {
|
|
||||||
setErrors(prev => {
|
setErrors(prev => {
|
||||||
|
if (!prev[fieldName] && autoFilledSlugFields.size === 0) return prev;
|
||||||
const newErrors = { ...prev };
|
const newErrors = { ...prev };
|
||||||
delete newErrors[fieldName];
|
if (newErrors[fieldName]) delete newErrors[fieldName];
|
||||||
|
autoFilledSlugFields.forEach(n => delete newErrors[n]);
|
||||||
return newErrors;
|
return newErrors;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert Unix timestamp (seconds) to datetime-local input format
|
// Convert Unix timestamp (seconds) to datetime-local input format
|
||||||
|
|
@ -509,6 +549,14 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_isSlugType(attr.type as AttributeType)) {
|
||||||
|
const slugErr = validateSlug(String(value));
|
||||||
|
if (slugErr) {
|
||||||
|
newErrors[attr.name] = t(slugErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Select/Multiselect option validation
|
// Select/Multiselect option validation
|
||||||
if (isSelectType(attr.type)) {
|
if (isSelectType(attr.type)) {
|
||||||
const options = normalizeOptions(attr);
|
const options = normalizeOptions(attr);
|
||||||
|
|
@ -1019,6 +1067,38 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_isSlugType(attr.type as AttributeType)) {
|
||||||
|
const slugValue = typeof value === 'string' ? value : (value == null ? '' : String(value));
|
||||||
|
return (
|
||||||
|
<div className={styles.floatingLabelInput} key={attr.name}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={slugValue}
|
||||||
|
inputMode="text"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
pattern="^[a-z0-9]+(-[a-z0-9]+)*$"
|
||||||
|
onChange={(e) => handleFieldChange(attr.name, e.target.value, 'slug')}
|
||||||
|
onFocus={() => handleFieldFocus(attr.name, true)}
|
||||||
|
onBlur={() => handleFieldFocus(attr.name, false)}
|
||||||
|
className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`}
|
||||||
|
/>
|
||||||
|
<label className={getLabelClass(attr.name, slugValue)}>
|
||||||
|
{attr.label}
|
||||||
|
{attr.required && <span className={styles.required}>*</span>}
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
className={styles.helperText ?? ''}
|
||||||
|
style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: '4px', display: 'block' }}
|
||||||
|
>
|
||||||
|
{t(SLUG_HINT)}
|
||||||
|
</span>
|
||||||
|
{hasError && <span className={styles.errorText}>{hasError}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Default input field (text, email, date, time, url, password, number, integer, float, timestamp)
|
// Default input field (text, email, date, time, url, password, number, integer, float, timestamp)
|
||||||
const inputType = attributeTypeToInputType(attr.type);
|
const inputType = attributeTypeToInputType(attr.type);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1431,63 +1431,96 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
return isCheckboxType(column.type);
|
return isCheckboxType(column.type);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle inline toggle for boolean fields
|
// Always-current snapshot of `data` so a queued toggle reads the freshly
|
||||||
|
// refetched row (server truth from the previous PUT+refetch) instead of the
|
||||||
|
// stale `row` captured by React at render time.
|
||||||
|
const dataRef = useRef<T[]>(data);
|
||||||
|
useEffect(() => { dataRef.current = data; }, [data]);
|
||||||
|
|
||||||
|
// Per-row update queue: every toggle on the same row awaits the previous
|
||||||
|
// one so PUT + refetch are strictly serialized. Combined with a refetch
|
||||||
|
// after every PUT, this guarantees that the next queued PUT merges its
|
||||||
|
// payload from confirmed server state — never from an unconfirmed UI guess.
|
||||||
|
const inlineUpdateQueueRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||||
|
|
||||||
|
// Handle inline toggle for boolean fields.
|
||||||
|
//
|
||||||
|
// Design contract (no optimistic UI):
|
||||||
|
// 1. The cell shows a spinner immediately on click.
|
||||||
|
// 2. We send the PUT.
|
||||||
|
// 3. We always trigger a refetch — the table only ever displays values
|
||||||
|
// that the backend has returned.
|
||||||
|
// 4. The cell re-renders from the refetched server data.
|
||||||
|
//
|
||||||
|
// We deliberately do NOT call ``hookData.updateOptimistically`` here:
|
||||||
|
// flipping the cell client-side before the backend confirmed leads to
|
||||||
|
// (a) misleading UX (a click that silently reverts on error) and
|
||||||
|
// (b) clobber-PUTs when the user toggles a sibling cell while the previous
|
||||||
|
// change is still in flight (its payload would be merged from the
|
||||||
|
// unconfirmed local state).
|
||||||
const handleInlineToggle = useCallback(async (row: T, column: ColumnConfig, currentValue: boolean) => {
|
const handleInlineToggle = useCallback(async (row: T, column: ColumnConfig, currentValue: boolean) => {
|
||||||
if (!canInlineEdit || !isInlineEditableColumn(column)) return;
|
if (!canInlineEdit || !isInlineEditableColumn(column)) return;
|
||||||
|
|
||||||
const rowId = row[idField];
|
const rowId = row[idField];
|
||||||
const cellKey = `${rowId}-${column.key}`;
|
const cellKey = `${rowId}-${column.key}`;
|
||||||
|
|
||||||
// Check if update function is available (either from prop or hookData)
|
|
||||||
const updateFn = onInlineUpdate || hookData?.handleInlineUpdate;
|
const updateFn = onInlineUpdate || hookData?.handleInlineUpdate;
|
||||||
if (!updateFn) {
|
if (!updateFn) {
|
||||||
// Silent return - inline editing is optional, no warning needed
|
// Inline editing is optional — silently noop when no handler is wired.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark cell as updating
|
|
||||||
setUpdatingCells(prev => new Set(prev).add(cellKey));
|
setUpdatingCells(prev => new Set(prev).add(cellKey));
|
||||||
|
|
||||||
const newValue = !currentValue;
|
const newValue = !currentValue;
|
||||||
const hasOptimisticUpdate = !!hookData?.updateOptimistically;
|
|
||||||
|
|
||||||
// If updateOptimistically is available, use it for immediate UI feedback
|
const previous = inlineUpdateQueueRef.current.get(String(rowId)) || Promise.resolve();
|
||||||
if (hasOptimisticUpdate) {
|
|
||||||
hookData.updateOptimistically(rowId, { [column.key]: newValue });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const work: Promise<void> = previous
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
try {
|
try {
|
||||||
// Call the update function (generic - no entity-specific logic)
|
// Re-resolve the row from the latest refetched snapshot so the
|
||||||
|
// merged payload reflects every server-confirmed change made by
|
||||||
|
// earlier queued toggles on this row.
|
||||||
|
const latestRow = (dataRef.current.find(
|
||||||
|
(r: any) => String(r?.[idField]) === String(rowId),
|
||||||
|
) as T | undefined) ?? row;
|
||||||
|
|
||||||
if (onInlineUpdate) {
|
if (onInlineUpdate) {
|
||||||
await onInlineUpdate(row, column.key, newValue);
|
await onInlineUpdate(latestRow, column.key, newValue);
|
||||||
} else if (hookData?.handleInlineUpdate) {
|
} else if (hookData?.handleInlineUpdate) {
|
||||||
// Pass row as third parameter for hooks that need to merge changes with existing data
|
await hookData.handleInlineUpdate(rowId, { [column.key]: newValue }, latestRow);
|
||||||
await hookData.handleInlineUpdate(rowId, { [column.key]: newValue }, row);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only refetch if we DON'T have optimistic update (to get fresh data)
|
// Always refetch on success — the cell only ever shows backend truth.
|
||||||
// With optimistic update, local state is already correct
|
if (hookData?.refetch) {
|
||||||
if (!hasOptimisticUpdate && hookData?.refetch) {
|
|
||||||
await hookData.refetch();
|
await hookData.refetch();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('FormGeneratorTable: Inline update failed:', error);
|
console.error('FormGeneratorTable: Inline update failed:', error);
|
||||||
// Revert optimistic update on error
|
// Refetch on error too: restores the row to confirmed server state
|
||||||
if (hasOptimisticUpdate) {
|
// (the cell snaps back to the original value).
|
||||||
hookData.updateOptimistically(rowId, { [column.key]: currentValue });
|
|
||||||
}
|
|
||||||
// Refetch to restore consistent state on error
|
|
||||||
if (hookData?.refetch) {
|
if (hookData?.refetch) {
|
||||||
await hookData.refetch();
|
try { await hookData.refetch(); } catch { /* swallow */ }
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
// Remove cell from updating state
|
|
||||||
setUpdatingCells(prev => {
|
setUpdatingCells(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
newSet.delete(cellKey);
|
newSet.delete(cellKey);
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inlineUpdateQueueRef.current.set(String(rowId), work);
|
||||||
|
try {
|
||||||
|
await work;
|
||||||
|
} finally {
|
||||||
|
if (inlineUpdateQueueRef.current.get(String(rowId)) === work) {
|
||||||
|
inlineUpdateQueueRef.current.delete(String(rowId));
|
||||||
|
}
|
||||||
|
}
|
||||||
}, [canInlineEdit, isInlineEditableColumn, idField, onInlineUpdate, hookData]);
|
}, [canInlineEdit, isInlineEditableColumn, idField, onInlineUpdate, hookData]);
|
||||||
|
|
||||||
// Render inline-editable boolean cell
|
// Render inline-editable boolean cell
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,16 @@
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15));
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-surface, rgba(255, 255, 255, 0.6));
|
||||||
|
color: var(--color-text, #1f2937);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
opacity: 0.6;
|
opacity: 0.7;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,9 +23,9 @@
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.15rem 0.3rem;
|
padding: 0.05rem 0.5rem 0.05rem 0.15rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
opacity: 0.7;
|
opacity: 0.95;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,13 @@ import styles from './LanguageSelector.module.css';
|
||||||
export function LanguageSelector() {
|
export function LanguageSelector() {
|
||||||
const { currentLanguage, setLanguage, availableLanguages } = useLanguage();
|
const { currentLanguage, setLanguage, availableLanguages } = useLanguage();
|
||||||
|
|
||||||
if (availableLanguages.length <= 1) return null;
|
// Always show the selector. If the backend has not (yet) returned a list,
|
||||||
|
// fall back to a static option for the currently active language so the
|
||||||
|
// control is visible even on pre-login screens / before the codes endpoint
|
||||||
|
// resolves.
|
||||||
|
const optionList = availableLanguages.length > 0
|
||||||
|
? availableLanguages
|
||||||
|
: [{ code: currentLanguage, label: currentLanguage.toUpperCase() } as { code: string; label: string }];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
|
|
@ -14,8 +20,9 @@ export function LanguageSelector() {
|
||||||
className={styles.select}
|
className={styles.select}
|
||||||
value={currentLanguage}
|
value={currentLanguage}
|
||||||
onChange={(e) => setLanguage(e.target.value as typeof currentLanguage)}
|
onChange={(e) => setLanguage(e.target.value as typeof currentLanguage)}
|
||||||
|
aria-label="Sprache / Language"
|
||||||
>
|
>
|
||||||
{availableLanguages.map((lang) => (
|
{optionList.map((lang) => (
|
||||||
<option key={lang.code} value={lang.code}>
|
<option key={lang.code} value={lang.code}>
|
||||||
{lang.label || lang.code.toUpperCase()}
|
{lang.label || lang.code.toUpperCase()}
|
||||||
</option>
|
</option>
|
||||||
|
|
|
||||||
77
src/components/UiComponents/Modal/Modal.module.css
Normal file
77
src/components/UiComponents/Modal/Modal.module.css
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sizeSm { max-width: 420px; }
|
||||||
|
.sizeMd { max-width: 600px; }
|
||||||
|
.sizeLg { max-width: 880px; }
|
||||||
|
.sizeXl { max-width: 1200px; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
133
src/components/UiComponents/Modal/Modal.tsx
Normal file
133
src/components/UiComponents/Modal/Modal.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
/**
|
||||||
|
* Modal — central, consistent dialog component for the whole UI.
|
||||||
|
*
|
||||||
|
* Behavior contract (intentional, documented):
|
||||||
|
* - The dialog stays open until the user explicitly closes it via the X button
|
||||||
|
* (top-right) or an explicit Cancel/OK button rendered by the consumer.
|
||||||
|
* - Clicking on the dimmed overlay does NOT close the dialog (default: false).
|
||||||
|
* - Pressing Escape does NOT close the dialog (default: false).
|
||||||
|
* - Both behaviors can be opted-in via ``closeOnOverlayClick`` /
|
||||||
|
* ``closeOnEscape`` for the rare cases where this is desired.
|
||||||
|
*
|
||||||
|
* Layout: standard 3-row flex (header / scrollable content / optional footer).
|
||||||
|
* The component traps body scroll while open and is accessible via ``role=dialog``.
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import styles from './Modal.module.css';
|
||||||
|
|
||||||
|
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
|
export interface ModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
size?: ModalSize;
|
||||||
|
closeOnOverlayClick?: boolean;
|
||||||
|
closeOnEscape?: boolean;
|
||||||
|
hideCloseButton?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
testId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _SIZE_CLASS: Record<ModalSize, string> = {
|
||||||
|
sm: styles.sizeSm,
|
||||||
|
md: styles.sizeMd,
|
||||||
|
lg: styles.sizeLg,
|
||||||
|
xl: styles.sizeXl,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Modal: React.FC<ModalProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
size = 'md',
|
||||||
|
closeOnOverlayClick = false,
|
||||||
|
closeOnEscape = false,
|
||||||
|
hideCloseButton = false,
|
||||||
|
ariaLabel,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
testId,
|
||||||
|
}) => {
|
||||||
|
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !closeOnEscape) return;
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKey);
|
||||||
|
return () => window.removeEventListener('keydown', handleKey);
|
||||||
|
}, [open, closeOnEscape, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!closeOnOverlayClick) return;
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleId = title ? 'modal-title' : undefined;
|
||||||
|
|
||||||
|
const node = (
|
||||||
|
<div
|
||||||
|
className={styles.overlay}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
className={`${styles.modal} ${_SIZE_CLASS[size]} ${className ?? ''}`.trim()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-label={!title ? ariaLabel : undefined}
|
||||||
|
>
|
||||||
|
{(title || !hideCloseButton) && (
|
||||||
|
<div className={styles.header}>
|
||||||
|
{title ? (
|
||||||
|
<h2 id={titleId} className={styles.title}>{title}</h2>
|
||||||
|
) : <span aria-hidden="true" />}
|
||||||
|
{!hideCloseButton && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeButton}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`${styles.content} ${contentClassName ?? ''}`.trim()}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{footer && <div className={styles.footer}>{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(node, document.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
3
src/components/UiComponents/Modal/index.ts
Normal file
3
src/components/UiComponents/Modal/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { Modal } from './Modal';
|
||||||
|
export type { ModalProps, ModalSize } from './Modal';
|
||||||
|
export { default } from './Modal';
|
||||||
|
|
@ -21,3 +21,4 @@ export * from './Tabs';
|
||||||
export type { TabsProps, Tab } from './Tabs';
|
export type { TabsProps, Tab } from './Tabs';
|
||||||
export * from './Toast';
|
export * from './Toast';
|
||||||
export * from './VoiceLanguageSelect';
|
export * from './VoiceLanguageSelect';
|
||||||
|
export * from './Modal';
|
||||||
|
|
@ -17,14 +17,16 @@ import {
|
||||||
deleteMandate as deleteMandateApi,
|
deleteMandate as deleteMandateApi,
|
||||||
hardDeleteMandate as hardDeleteMandateApi,
|
hardDeleteMandate as hardDeleteMandateApi,
|
||||||
type Mandate,
|
type Mandate,
|
||||||
|
type MandateCreateData,
|
||||||
type MandateUpdateData,
|
type MandateUpdateData,
|
||||||
type PaginationParams
|
type PaginationParams
|
||||||
} from '../api/mandateApi';
|
} from '../api/mandateApi';
|
||||||
import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm';
|
import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm';
|
||||||
import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge';
|
import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge';
|
||||||
|
import { validateMandateName } from '../utils/mandateNameUtils';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type { Mandate, MandateUpdateData, PaginationParams };
|
export type { Mandate, MandateCreateData, MandateUpdateData, PaginationParams };
|
||||||
|
|
||||||
export interface AttributeDefinition {
|
export interface AttributeDefinition {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -169,7 +171,19 @@ export function useAdminMandates() {
|
||||||
// Create mandate
|
// Create mandate
|
||||||
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<Mandate | null> => {
|
const handleCreate = useCallback(async (mandateData: Partial<Mandate>): Promise<Mandate | null> => {
|
||||||
try {
|
try {
|
||||||
const created = await createMandateApi(request, mandateData);
|
const label = typeof mandateData.label === 'string' ? mandateData.label.trim() : '';
|
||||||
|
if (!label) {
|
||||||
|
console.error('createMandate: label (Voller Name) is required');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof mandateData.name === 'string' && mandateData.name.length > 0) {
|
||||||
|
const slugErr = validateMandateName(mandateData.name);
|
||||||
|
if (slugErr) {
|
||||||
|
console.error(`createMandate: invalid Kurzzeichen — ${slugErr}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const created = await createMandateApi(request, { ...mandateData, label } as MandateCreateData);
|
||||||
await fetchMandates();
|
await fetchMandates();
|
||||||
return created ?? null;
|
return created ?? null;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -181,6 +195,21 @@ export function useAdminMandates() {
|
||||||
// Update mandate
|
// Update mandate
|
||||||
const handleUpdate = useCallback(async (mandateId: string, updateData: MandateUpdateData): Promise<boolean> => {
|
const handleUpdate = useCallback(async (mandateId: string, updateData: MandateUpdateData): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
|
if ('label' in updateData) {
|
||||||
|
const lbl = typeof updateData.label === 'string' ? updateData.label.trim() : '';
|
||||||
|
if (!lbl) {
|
||||||
|
console.error('updateMandate: label (Voller Name) must not be empty');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
updateData = { ...updateData, label: lbl };
|
||||||
|
}
|
||||||
|
if ('name' in updateData && typeof updateData.name === 'string') {
|
||||||
|
const slugErr = validateMandateName(updateData.name);
|
||||||
|
if (slugErr) {
|
||||||
|
console.error(`updateMandate: invalid Kurzzeichen — ${slugErr}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
updateOptimistically(mandateId, updateData);
|
updateOptimistically(mandateId, updateData);
|
||||||
await updateMandateApi(request, mandateId, updateData);
|
await updateMandateApi(request, mandateId, updateData);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -295,9 +324,17 @@ export function useMandateFormAttributes() {
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const formAttributes: FormGenAttr[] = useMemo(() => {
|
const formAttributes: FormGenAttr[] = useMemo(() => {
|
||||||
return attributes
|
const list = attributes
|
||||||
.filter(attr => attr.name !== 'id')
|
.filter(attr => attr.name !== 'id')
|
||||||
.map(attr => ({ ...attr, type: attr.type })) as FormGenAttr[];
|
.map(attr => ({ ...attr, type: attr.type })) as FormGenAttr[];
|
||||||
|
|
||||||
|
const labelIdx = list.findIndex(a => a.name === 'label');
|
||||||
|
const nameIdx = list.findIndex(a => a.name === 'name');
|
||||||
|
if (labelIdx >= 0 && nameIdx >= 0 && nameIdx < labelIdx) {
|
||||||
|
const [labelAttr] = list.splice(labelIdx, 1);
|
||||||
|
list.splice(nameIdx, 0, labelAttr);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
const createFormAttributes: FormGenAttr[] = useMemo(
|
const createFormAttributes: FormGenAttr[] = useMemo(
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,23 @@ import {
|
||||||
} from '../api/storeApi';
|
} from '../api/storeApi';
|
||||||
import { useFeatureStore } from '../stores/featureStore';
|
import { useFeatureStore } from '../stores/featureStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a stable key identifying a single Store action button so the spinner
|
||||||
|
* can be scoped to exactly that button (one feature × one mandate / instance)
|
||||||
|
* instead of greying out every button of the feature.
|
||||||
|
*/
|
||||||
|
export const _storeActionKey = {
|
||||||
|
activate: (featureCode: string, mandateId?: string) => `activate:${featureCode}:${mandateId ?? ''}`,
|
||||||
|
deactivate: (featureCode: string, instanceId: string) => `deactivate:${featureCode}:${instanceId}`,
|
||||||
|
};
|
||||||
|
|
||||||
interface UseStoreReturn {
|
interface UseStoreReturn {
|
||||||
features: StoreFeature[];
|
features: StoreFeature[];
|
||||||
mandates: UserMandate[];
|
mandates: UserMandate[];
|
||||||
subscriptionInfo: SubscriptionInfo | null;
|
subscriptionInfo: SubscriptionInfo | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
actionLoading: string | null;
|
/** Set of in-flight action keys (see ``_storeActionKey``) — one entry per button currently processing. */
|
||||||
|
actionLoading: Set<string>;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
loadStore: () => Promise<void>;
|
loadStore: () => Promise<void>;
|
||||||
loadSubscriptionInfo: (mandateId?: string) => Promise<void>;
|
loadSubscriptionInfo: (mandateId?: string) => Promise<void>;
|
||||||
|
|
@ -37,10 +48,27 @@ export function useStore(): UseStoreReturn {
|
||||||
const [mandates, setMandates] = useState<UserMandate[]>([]);
|
const [mandates, setMandates] = useState<UserMandate[]>([]);
|
||||||
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(null);
|
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<Set<string>>(() => new Set());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const featureStore = useFeatureStore();
|
const featureStore = useFeatureStore();
|
||||||
|
|
||||||
|
const _markBusy = useCallback((key: string) => {
|
||||||
|
setActionLoading(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _markIdle = useCallback((key: string) => {
|
||||||
|
setActionLoading(prev => {
|
||||||
|
if (!prev.has(key)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadSubscriptionInfo = useCallback(async (mandateId?: string) => {
|
const loadSubscriptionInfo = useCallback(async (mandateId?: string) => {
|
||||||
try {
|
try {
|
||||||
const info = await fetchSubscriptionInfo(mandateId);
|
const info = await fetchSubscriptionInfo(mandateId);
|
||||||
|
|
@ -81,7 +109,8 @@ export function useStore(): UseStoreReturn {
|
||||||
}, [featureStore, loadStore]);
|
}, [featureStore, loadStore]);
|
||||||
|
|
||||||
const activate = useCallback(async (featureCode: string, mandateId?: string) => {
|
const activate = useCallback(async (featureCode: string, mandateId?: string) => {
|
||||||
setActionLoading(featureCode);
|
const key = _storeActionKey.activate(featureCode, mandateId);
|
||||||
|
_markBusy(key);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await activateStoreFeature(featureCode, mandateId);
|
await activateStoreFeature(featureCode, mandateId);
|
||||||
|
|
@ -90,12 +119,13 @@ export function useStore(): UseStoreReturn {
|
||||||
const msg = err instanceof Error ? err.message : 'Activation failed';
|
const msg = err instanceof Error ? err.message : 'Activation failed';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
_markIdle(key);
|
||||||
}
|
}
|
||||||
}, [_refreshAfterAction]);
|
}, [_refreshAfterAction, _markBusy, _markIdle]);
|
||||||
|
|
||||||
const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => {
|
const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => {
|
||||||
setActionLoading(featureCode);
|
const key = _storeActionKey.deactivate(featureCode, instanceId);
|
||||||
|
_markBusy(key);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await deactivateStoreFeature(featureCode, mandateId, instanceId);
|
await deactivateStoreFeature(featureCode, mandateId, instanceId);
|
||||||
|
|
@ -104,9 +134,9 @@ export function useStore(): UseStoreReturn {
|
||||||
const msg = err instanceof Error ? err.message : 'Deactivation failed';
|
const msg = err instanceof Error ? err.message : 'Deactivation failed';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
_markIdle(key);
|
||||||
}
|
}
|
||||||
}, [_refreshAfterAction]);
|
}, [_refreshAfterAction, _markBusy, _markIdle]);
|
||||||
|
|
||||||
return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate };
|
return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,12 @@ export function useCurrentUser() {
|
||||||
const cachedUser = getUserDataCache();
|
const cachedUser = getUserDataCache();
|
||||||
if (cachedUser && cachedUser.username) {
|
if (cachedUser && cachedUser.username) {
|
||||||
// Use cached user data - permissions are checked via RBAC API, not client-side
|
// Use cached user data - permissions are checked via RBAC API, not client-side
|
||||||
// Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin flag instead
|
// Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin/isPlatformAdmin flags instead
|
||||||
setUser(cachedUser);
|
setUser(cachedUser);
|
||||||
console.log('✅ Using cached user data from sessionStorage (persists during session):', {
|
console.log('✅ Using cached user data from sessionStorage (persists during session):', {
|
||||||
username: cachedUser.username,
|
username: cachedUser.username,
|
||||||
isSysAdmin: cachedUser.isSysAdmin
|
isSysAdmin: cachedUser.isSysAdmin,
|
||||||
|
isPlatformAdmin: cachedUser.isPlatformAdmin
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -72,6 +73,7 @@ export function useCurrentUser() {
|
||||||
console.log('📦 User data received from API:', {
|
console.log('📦 User data received from API:', {
|
||||||
username: data?.username,
|
username: data?.username,
|
||||||
isSysAdmin: data?.isSysAdmin,
|
isSysAdmin: data?.isSysAdmin,
|
||||||
|
isPlatformAdmin: data?.isPlatformAdmin,
|
||||||
allKeys: data ? Object.keys(data) : []
|
allKeys: data ? Object.keys(data) : []
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -85,11 +87,12 @@ export function useCurrentUser() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache user data (permissions are checked via RBAC API)
|
// Cache user data (permissions are checked via RBAC API)
|
||||||
// Note: roleLabels is deprecated - use isSysAdmin flag for admin checks
|
// Note: roleLabels is deprecated - use isSysAdmin/isPlatformAdmin flags for admin checks
|
||||||
setUserDataCache(data);
|
setUserDataCache(data);
|
||||||
console.log('✅ User data fetched from API and cached:', {
|
console.log('✅ User data fetched from API and cached:', {
|
||||||
username: data.username,
|
username: data.username,
|
||||||
isSysAdmin: data.isSysAdmin
|
isSysAdmin: data.isSysAdmin,
|
||||||
|
isPlatformAdmin: data.isPlatformAdmin
|
||||||
});
|
});
|
||||||
setUser(data);
|
setUser(data);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -215,11 +218,12 @@ export function useCurrentUser() {
|
||||||
const cachedUser = getUserDataCache();
|
const cachedUser = getUserDataCache();
|
||||||
if (cachedUser && cachedUser.username) {
|
if (cachedUser && cachedUser.username) {
|
||||||
// Use cached user data - permissions are checked via RBAC API
|
// Use cached user data - permissions are checked via RBAC API
|
||||||
// Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin flag instead
|
// Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin/isPlatformAdmin flags instead
|
||||||
setUser(cachedUser);
|
setUser(cachedUser);
|
||||||
console.log('✅ Using cached user data from sessionStorage on mount:', {
|
console.log('✅ Using cached user data from sessionStorage on mount:', {
|
||||||
username: cachedUser.username,
|
username: cachedUser.username,
|
||||||
isSysAdmin: cachedUser.isSysAdmin
|
isSysAdmin: cachedUser.isSysAdmin,
|
||||||
|
isPlatformAdmin: cachedUser.isPlatformAdmin
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -800,24 +804,26 @@ export function useUserOperations() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generic inline update handler for FormGeneratorTable
|
// Generic inline update handler for FormGeneratorTable.
|
||||||
// Must merge changes with existing row data because backend requires full object
|
//
|
||||||
// The existingRow parameter is passed from FormGeneratorTable which has access to row data
|
// The User PUT endpoint accepts PARTIAL payloads — only fields explicitly
|
||||||
const handleInlineUpdate = async (userId: string, changes: Partial<UserUpdateData>, existingRow?: any) => {
|
// present are applied; missing fields keep their stored value. We therefore
|
||||||
if (!existingRow) {
|
// forward ONLY the changed cells. This avoids two classes of bugs:
|
||||||
throw new Error(`Existing row data required for inline update`);
|
// 1. Stale snapshot: spreading ``existingRow`` onto the payload would
|
||||||
|
// overwrite fields with whatever the client last loaded, even if the
|
||||||
|
// backend has been updated since (e.g. by a parallel admin action).
|
||||||
|
// 2. Missing-field default-flip: previously, any non-listed field (e.g.
|
||||||
|
// ``isSysAdmin`` while toggling ``isPlatformAdmin``) was absent from
|
||||||
|
// the merged payload and the Pydantic ``User`` body on the backend
|
||||||
|
// filled it with ``False``, silently dropping the other privileged flag.
|
||||||
|
//
|
||||||
|
// ``existingRow`` is kept in the signature for forward-compat with table
|
||||||
|
// hooks but is no longer consulted to build the payload.
|
||||||
|
const handleInlineUpdate = async (userId: string, changes: Partial<UserUpdateData>, _existingRow?: any) => {
|
||||||
|
if (!changes || Object.keys(changes).length === 0) {
|
||||||
|
throw new Error('No fields to update');
|
||||||
}
|
}
|
||||||
|
const result = await handleUserUpdate(userId, changes);
|
||||||
// Merge changes with existing row data (backend requires full object with required fields)
|
|
||||||
const mergedData: UserUpdateData = {
|
|
||||||
username: existingRow.username,
|
|
||||||
email: existingRow.email,
|
|
||||||
enabled: existingRow.enabled,
|
|
||||||
roleLabels: existingRow.roleLabels,
|
|
||||||
...changes
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await handleUserUpdate(userId, mergedData);
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || 'Failed to update');
|
throw new Error(result.error || 'Failed to update');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
||||||
import { useFeaturesInitialized, useFeaturesLoading } from '../stores/featureStore';
|
import { useFeaturesInitialized, useFeaturesLoading } from '../stores/featureStore';
|
||||||
import useNavigation from '../hooks/useNavigation';
|
import useNavigation from '../hooks/useNavigation';
|
||||||
import styles from './FeatureLayout.module.css';
|
import styles from './FeatureLayout.module.css';
|
||||||
|
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
|
@ -115,7 +116,9 @@ export const FeatureLayout: React.FC = () => {
|
||||||
{/* Header mit Instanz-Info */}
|
{/* Header mit Instanz-Info */}
|
||||||
<header className={styles.featureHeader}>
|
<header className={styles.featureHeader}>
|
||||||
<div className={styles.breadcrumb}>
|
<div className={styles.breadcrumb}>
|
||||||
<span className={styles.mandateName}>{navLabels?.mandate || mandate?.label || mandate?.name}</span>
|
<span className={styles.mandateName}>
|
||||||
|
{navLabels?.mandate || (mandate ? mandateDisplayLabel(mandate) : '')}
|
||||||
|
</span>
|
||||||
<span className={styles.separator}>/</span>
|
<span className={styles.separator}>/</span>
|
||||||
<span className={styles.featureName}>{navLabels?.feature || feature?.code}</span>
|
<span className={styles.featureName}>{navLabels?.feature || feature?.code}</span>
|
||||||
<span className={styles.separator}>/</span>
|
<span className={styles.separator}>/</span>
|
||||||
|
|
|
||||||
|
|
@ -320,11 +320,10 @@ const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) =>
|
||||||
}, [steps]);
|
}, [steps]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.modalOverlay} onClick={onClose}>
|
<div className={styles.modalOverlay}>
|
||||||
<div
|
<div
|
||||||
className={styles.modal}
|
className={styles.modal}
|
||||||
style={{ maxWidth: 800, height: '80vh' }}
|
style={{ maxWidth: 800, height: '80vh' }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { useUserMandates } from '../hooks/useUserMandates';
|
||||||
import { useConfirm } from '../hooks/useConfirm';
|
import { useConfirm } from '../hooks/useConfirm';
|
||||||
import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
|
||||||
import styles from './ComplianceAuditPage.module.css';
|
import styles from './ComplianceAuditPage.module.css';
|
||||||
|
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828', '#2e7d32'];
|
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828', '#2e7d32'];
|
||||||
|
|
||||||
|
|
@ -110,7 +111,11 @@ interface AuditStats {
|
||||||
neutralizationPercent: number;
|
neutralizationPercent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Mandate { id: string; name?: string; label?: string; }
|
interface Mandate {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ContentModalData {
|
interface ContentModalData {
|
||||||
row: any;
|
row: any;
|
||||||
|
|
@ -554,7 +559,7 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
>
|
>
|
||||||
<option value="">{mandatesLoading ? t('Lade…') : t('— Mandant wählen —')}</option>
|
<option value="">{mandatesLoading ? t('Lade…') : t('— Mandant wählen —')}</option>
|
||||||
{mandates.map(m => (
|
{mandates.map(m => (
|
||||||
<option key={m.id} value={m.id}>{m.label || m.name || m.id}</option>
|
<option key={m.id} value={m.id}>{mandateDisplayLabel(m)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ function Login() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '-1.5rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ function PasswordResetRequest() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '-1.5rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ function Register() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '-1.5rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ function Reset() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '-1.5rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
|
|
@ -142,7 +142,7 @@ function Reset() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '-1.5rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,8 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.modalOverlay} onClick={onClose}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.modalContent}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2>{t('Profil bearbeiten')}</h2>
|
<h2>{t('Profil bearbeiten')}</h2>
|
||||||
<button className={styles.closeButton} onClick={onClose}>×</button>
|
<button className={styles.closeButton} onClick={onClose}>×</button>
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
.store {
|
.store {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1000px;
|
max-width: 1600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem clamp(1rem, 2vw, 2.5rem);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,8 +78,9 @@
|
||||||
/* Grid */
|
/* Grid */
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(clamp(260px, 22vw, 340px), 1fr));
|
||||||
gap: 1.25rem;
|
gap: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card */
|
/* Card */
|
||||||
|
|
@ -171,18 +172,47 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--surface-alt, rgba(0, 0, 0, 0.025));
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceRow {
|
.instanceRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.5rem;
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: var(--surface-color, #ffffff);
|
||||||
|
border: 1px solid var(--border-color, #e5e7eb);
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceInfo {
|
.instanceInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceMandate {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #555);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deactivateButtonSmall {
|
.deactivateButtonSmall {
|
||||||
|
|
@ -326,6 +356,23 @@
|
||||||
color: var(--success-color, #34d399);
|
color: var(--success-color, #34d399);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .instanceList {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .instanceRow {
|
||||||
|
background: var(--surface-dark, #1f1f1f);
|
||||||
|
border-color: var(--border-dark, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .instanceLabel {
|
||||||
|
color: var(--text-primary-dark, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .instanceMandate {
|
||||||
|
color: var(--text-secondary-dark, #aaa);
|
||||||
|
}
|
||||||
|
|
||||||
:global(.dark-theme) .statusInactive {
|
:global(.dark-theme) .statusInactive {
|
||||||
background: var(--surface-dark, #2a2a2a);
|
background: var(--surface-dark, #2a2a2a);
|
||||||
color: var(--text-secondary-dark, #aaa);
|
color: var(--text-secondary-dark, #aaa);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram, FaShieldAlt } from 'react-icons/fa';
|
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram, FaShieldAlt } from 'react-icons/fa';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useStore } from '../hooks/useStore';
|
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||||
|
import { useStore, _storeActionKey } from '../hooks/useStore';
|
||||||
import type { StoreFeature, UserMandate } from '../api/storeApi';
|
import type { StoreFeature, UserMandate } from '../api/storeApi';
|
||||||
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
|
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
|
||||||
import styles from './Store.module.css';
|
import styles from './Store.module.css';
|
||||||
|
|
@ -39,7 +40,7 @@ function _storeCardDescription(feature: StoreFeature): string {
|
||||||
interface FeatureCardProps {
|
interface FeatureCardProps {
|
||||||
feature: StoreFeature;
|
feature: StoreFeature;
|
||||||
mandates: UserMandate[];
|
mandates: UserMandate[];
|
||||||
actionLoading: string | null;
|
actionLoading: Set<string>;
|
||||||
onActivate: (code: string, mandateId?: string) => void;
|
onActivate: (code: string, mandateId?: string) => void;
|
||||||
onDeactivate: (code: string, mandateId: string, instanceId: string) => void;
|
onDeactivate: (code: string, mandateId: string, instanceId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +53,6 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||||
onDeactivate,
|
onDeactivate,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const isProcessing = actionLoading === feature.featureCode;
|
|
||||||
const icon = FEATURE_ICONS[feature.featureCode];
|
const icon = FEATURE_ICONS[feature.featureCode];
|
||||||
const activeInstances = feature.instances.filter(inst => inst.isActive);
|
const activeInstances = feature.instances.filter(inst => inst.isActive);
|
||||||
const hasActive = activeInstances.length > 0;
|
const hasActive = activeInstances.length > 0;
|
||||||
|
|
@ -74,23 +74,37 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||||
|
|
||||||
{activeInstances.length > 0 && (
|
{activeInstances.length > 0 && (
|
||||||
<div className={styles.instanceList}>
|
<div className={styles.instanceList}>
|
||||||
{activeInstances.map((inst) => (
|
{activeInstances.map((inst) => {
|
||||||
|
const instanceLabel = (inst.label && inst.label.trim()) || feature.label;
|
||||||
|
const mandateLabel = inst.mandateName || '';
|
||||||
|
const deactivateKey = _storeActionKey.deactivate(feature.featureCode, inst.instanceId);
|
||||||
|
const isDeactivating = actionLoading.has(deactivateKey);
|
||||||
|
return (
|
||||||
<div key={inst.instanceId} className={styles.instanceRow}>
|
<div key={inst.instanceId} className={styles.instanceRow}>
|
||||||
<div className={styles.instanceInfo}>
|
<div className={styles.instanceInfo}>
|
||||||
<span className={`${styles.statusBadge} ${styles.statusActive}`}>
|
<span className={styles.instanceLabel}>
|
||||||
<span className={styles.statusDot} />
|
<span
|
||||||
{inst.mandateName || inst.label}
|
className={`${styles.statusDot} ${styles.statusActive}`}
|
||||||
|
aria-label={t('Aktiv')}
|
||||||
|
/>
|
||||||
|
{instanceLabel}
|
||||||
</span>
|
</span>
|
||||||
|
{mandateLabel && (
|
||||||
|
<span className={styles.instanceMandate}>
|
||||||
|
{t('Mandant')}: {mandateLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={styles.deactivateButtonSmall}
|
className={styles.deactivateButtonSmall}
|
||||||
onClick={() => onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)}
|
onClick={() => onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)}
|
||||||
disabled={isProcessing}
|
disabled={isDeactivating}
|
||||||
>
|
>
|
||||||
{isProcessing ? '...' : t('Deaktivieren')}
|
{isDeactivating ? '…' : t('Deaktivieren')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -104,18 +118,22 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.cardActions}>
|
<div className={styles.cardActions}>
|
||||||
{feature.canActivate && mandates.map((m) => (
|
{feature.canActivate && mandates.map((m) => {
|
||||||
|
const activateKey = _storeActionKey.activate(feature.featureCode, m.id);
|
||||||
|
const isActivating = actionLoading.has(activateKey);
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className={styles.activateButton}
|
className={styles.activateButton}
|
||||||
onClick={() => onActivate(feature.featureCode, m.id)}
|
onClick={() => onActivate(feature.featureCode, m.id)}
|
||||||
disabled={isProcessing}
|
disabled={isActivating}
|
||||||
>
|
>
|
||||||
{isProcessing
|
{isActivating
|
||||||
? t('Wird aktiviert…')
|
? t('Wird aktiviert…')
|
||||||
: t('Aktivieren für {name}', { name: String(m.label || m.name) })}
|
: t('Aktivieren für {name}', { name: mandateDisplayLabel(m) })}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,10 @@ import { FeatureInstanceWizard } from './wizards/FeatureInstanceWizard';
|
||||||
import { InstanceHierarchyView } from './InstanceHierarchyView';
|
import { InstanceHierarchyView } from './InstanceHierarchyView';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
function getMandateName(mandate: Mandate): string {
|
function getMandateName(mandate: Mandate): string {
|
||||||
return mandate.label || mandate.name || mandate.id;
|
return mandateDisplayLabel(mandate);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFeatureLabel(feature: Feature): string {
|
function getFeatureLabel(feature: Feature): string {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { TextField } from '../../components/UiComponents/TextField';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
export const AdminFeatureAccessPage: React.FC = () => {
|
export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -336,11 +337,6 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get mandate name
|
|
||||||
const getMandateName = (mandate: Mandate) => {
|
|
||||||
return mandate.label || mandate.name || mandate.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get feature label
|
// Get feature label
|
||||||
const getFeatureLabel = (code: string) => {
|
const getFeatureLabel = (code: string) => {
|
||||||
const feature = features.find(f => f.code === code);
|
const feature = features.find(f => f.code === code);
|
||||||
|
|
@ -385,7 +381,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
<option value="">{t('Mandant wählen')}</option>
|
<option value="">{t('Mandant wählen')}</option>
|
||||||
{mandates.map(m => (
|
{mandates.map(m => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
{getMandateName(m)}
|
{mandateDisplayLabel(m)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -92,7 +93,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
allOptions.push({
|
allOptions.push({
|
||||||
mandateId: mandate.id,
|
mandateId: mandate.id,
|
||||||
instanceId: inst.id,
|
instanceId: inst.id,
|
||||||
mandateName: mandate.label || mandate.name || mandate.id,
|
mandateName: mandateDisplayLabel(mandate),
|
||||||
instanceLabel: inst.label || inst.id,
|
instanceLabel: inst.label || inst.id,
|
||||||
featureCode: inst.featureCode,
|
featureCode: inst.featureCode,
|
||||||
combinedKey: `${mandate.id}:${inst.id}`,
|
combinedKey: `${mandate.id}:${inst.id}`,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
export const AdminInvitationsPage: React.FC = () => {
|
export const AdminInvitationsPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -235,10 +236,6 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get mandate name
|
|
||||||
const getMandateName = (mandate: Mandate) => {
|
|
||||||
return mandate.label || mandate.name || mandate.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error && !selectedMandateId) {
|
if (error && !selectedMandateId) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -280,7 +277,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
<option value="">{t('Mandant wählen')}</option>
|
<option value="">{t('Mandant wählen')}</option>
|
||||||
{mandates.map(m => (
|
{mandates.map(m => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
{getMandateName(m)}
|
{mandateDisplayLabel(m)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
// Types for cleanup result
|
// Types for cleanup result
|
||||||
interface DuplicateGroup {
|
interface DuplicateGroup {
|
||||||
|
|
@ -279,7 +280,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
>
|
>
|
||||||
{mandates.map(mandate => (
|
{mandates.map(mandate => (
|
||||||
<option key={mandate.id} value={mandate.id}>
|
<option key={mandate.id} value={mandate.id}>
|
||||||
{mandate.label || getTextValue(mandate.name)}
|
{mandateDisplayLabel(mandate)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -388,8 +389,8 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Cleanup Duplicates Modal */}
|
{/* Cleanup Duplicates Modal */}
|
||||||
{showCleanupModal && (
|
{showCleanupModal && (
|
||||||
<div className={styles.modalOverlay} onClick={_closeCleanupModal}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} style={{ maxWidth: '750px' }} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.modal} style={{ maxWidth: '750px' }}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h3 className={styles.modalTitle}>
|
<h3 className={styles.modalTitle}>
|
||||||
<FaBroom style={{ marginRight: '0.5rem' }} />
|
<FaBroom style={{ marginRight: '0.5rem' }} />
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
export const AdminMandateRolesPage: React.FC = () => {
|
export const AdminMandateRolesPage: React.FC = () => {
|
||||||
const { t, currentLanguage } = useLanguage();
|
const { t, currentLanguage } = useLanguage();
|
||||||
|
|
@ -273,11 +274,6 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
setEditingRole(role);
|
setEditingRole(role);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get mandate name
|
|
||||||
const getMandateName = (mandate: Mandate) => {
|
|
||||||
return mandate.label || mandate.name || mandate.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error && !selectedMandateId) {
|
if (error && !selectedMandateId) {
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
|
|
@ -334,7 +330,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
<option value="">{t('Mandant wählen')}</option>
|
<option value="">{t('Mandant wählen')}</option>
|
||||||
{mandates.map(m => (
|
{mandates.map(m => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
{getMandateName(m)}
|
{mandateDisplayLabel(m)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -60,16 +60,16 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
const [editingFormData, setEditingFormData] = useState<Record<string, unknown> | null>(null);
|
const [editingFormData, setEditingFormData] = useState<Record<string, unknown> | null>(null);
|
||||||
const [editingBillingWarning, setEditingBillingWarning] = useState<string | null>(null);
|
const [editingBillingWarning, setEditingBillingWarning] = useState<string | null>(null);
|
||||||
|
|
||||||
const isSysAdmin = getUserDataCache()?.isSysAdmin === true;
|
const isPlatformAdmin = getUserDataCache()?.isPlatformAdmin === true;
|
||||||
|
|
||||||
// MandateAdmin: only label + billing fields editable; rest readonly
|
// MandateAdmin: only label + billing fields editable; rest readonly
|
||||||
const _MANDATE_ADMIN_EDITABLE = new Set(['label', 'warningThresholdPercent', 'notifyOnWarning', 'notifyEmails']);
|
const _MANDATE_ADMIN_EDITABLE = new Set(['label', 'warningThresholdPercent', 'notifyOnWarning', 'notifyEmails']);
|
||||||
const editFormAttrs: AttributeDefinition[] = useMemo(() => {
|
const editFormAttrs: AttributeDefinition[] = useMemo(() => {
|
||||||
if (isSysAdmin) return formAttributesWithBilling;
|
if (isPlatformAdmin) return formAttributesWithBilling;
|
||||||
return formAttributesWithBilling.map(attr =>
|
return formAttributesWithBilling.map(attr =>
|
||||||
_MANDATE_ADMIN_EDITABLE.has(attr.name) ? attr : { ...attr, editable: false, readonly: true }
|
_MANDATE_ADMIN_EDITABLE.has(attr.name) ? attr : { ...attr, editable: false, readonly: true }
|
||||||
);
|
);
|
||||||
}, [formAttributesWithBilling, isSysAdmin]);
|
}, [formAttributesWithBilling, isPlatformAdmin]);
|
||||||
|
|
||||||
// Check if user can create
|
// Check if user can create
|
||||||
const canCreate = permissions?.create !== 'n';
|
const canCreate = permissions?.create !== 'n';
|
||||||
|
|
@ -138,12 +138,15 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const entered = await prompt(
|
const entered = await prompt(
|
||||||
t('Um den Mandanten "{name}" zu deaktivieren (Soft-Delete), geben Sie den Namen ein:', { name: mandate.name }),
|
t(
|
||||||
|
'Um den Mandanten zu deaktivieren (Soft-Delete), geben Sie das Kurzzeichen «{slug}» exakt ein (Anzeigename: «{label}»).',
|
||||||
|
{ slug: mandate.name, label: mandate.label || mandate.name }
|
||||||
|
),
|
||||||
{ title: t('Mandat deaktivieren'), confirmLabel: t('Deaktivieren'), variant: 'danger', placeholder: mandate.name },
|
{ title: t('Mandat deaktivieren'), confirmLabel: t('Deaktivieren'), variant: 'danger', placeholder: mandate.name },
|
||||||
);
|
);
|
||||||
if (entered === null) return;
|
if (entered === null) return;
|
||||||
if (entered !== mandate.name) {
|
if (entered !== mandate.name) {
|
||||||
showWarning(t('Abgebrochen'), t('Der eingegebene Name stimmt nicht überein.'));
|
showWarning(t('Abgebrochen'), t('Das eingegebene Kurzzeichen stimmt nicht überein.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await handleDelete(mandate.id);
|
await handleDelete(mandate.id);
|
||||||
|
|
@ -155,17 +158,23 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const entered = await prompt(
|
const entered = await prompt(
|
||||||
t('ACHTUNG: Dies löscht den Mandanten "{name}" unwiderruflich inkl. aller Subscriptions, Features, Benutzer-Zuweisungen und Daten. Geben Sie den exakten Namen ein:', { name: mandate.name }),
|
t(
|
||||||
|
'ACHTUNG: Dies löscht den Mandanten unwiderruflich inkl. aller Subscriptions, Features, Benutzer-Zuweisungen und Daten. Geben Sie das Kurzzeichen «{slug}» exakt ein (Anzeigename: «{label}»).',
|
||||||
|
{ slug: mandate.name, label: mandate.label || mandate.name }
|
||||||
|
),
|
||||||
{ title: t('Unwiderrufliches Löschen'), confirmLabel: t('Dauerhaft löschen'), variant: 'danger', placeholder: mandate.name },
|
{ title: t('Unwiderrufliches Löschen'), confirmLabel: t('Dauerhaft löschen'), variant: 'danger', placeholder: mandate.name },
|
||||||
);
|
);
|
||||||
if (entered === null) return;
|
if (entered === null) return;
|
||||||
if (entered !== mandate.name) {
|
if (entered !== mandate.name) {
|
||||||
showWarning(t('Abgebrochen'), t('Der eingegebene Name stimmt nicht überein.'));
|
showWarning(t('Abgebrochen'), t('Das eingegebene Kurzzeichen stimmt nicht überein.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ok = await handleHardDelete(mandate.id, entered);
|
const ok = await handleHardDelete(mandate.id, entered);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
showSuccess(t('Gelöscht'), t('Mandant "{name}" wurde endgültig gelöscht.', { name: mandate.name }));
|
showSuccess(
|
||||||
|
t('Gelöscht'),
|
||||||
|
t('Mandant «{name}» wurde endgültig gelöscht.', { name: mandate.label || mandate.name })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -190,7 +199,13 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('Mandanten')}</h1>
|
<h1 className={styles.pageTitle}>{t('Mandanten')}</h1>
|
||||||
<p className={styles.pageSubtitle}>{t('Verwalten Sie alle Mandanten im')}</p>
|
<p className={styles.pageSubtitle}>
|
||||||
|
{t('Verwalten Sie alle Mandanten im')}
|
||||||
|
{' '}
|
||||||
|
{t(
|
||||||
|
'Der Volle Name erscheint in der Oberfläche; das Kurzzeichen ist systemweit eindeutig und dient Referenzierung und Bestätigungsabfragen.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -328,7 +343,9 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
||||||
<span>
|
<span>
|
||||||
{t('Dies ist ein')} <strong>{t('System-Mandant')}</strong>.{' '}
|
{t('Dies ist ein')} <strong>{t('System-Mandant')}</strong>.{' '}
|
||||||
{t('Er kann nicht gelöscht werden und der Name sollte nicht geändert werden.')}
|
{t(
|
||||||
|
'Er kann nicht gelöscht werden. Das Kurzzeichen (technischer Identifier) soll nicht geändert werden; der Volle Name kann bei Bedarf angepasst werden.'
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLineLabelThenSlug } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
interface UserOption {
|
interface UserOption {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -18,6 +19,7 @@ interface UserOption {
|
||||||
email: string;
|
email: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
isSysAdmin: boolean;
|
isSysAdmin: boolean;
|
||||||
|
isPlatformAdmin: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,14 +62,6 @@ interface MandateInfo {
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function _mandateNameLine(mandate: MandateInfo): string {
|
|
||||||
const label = mandate.label?.trim();
|
|
||||||
if (label) {
|
|
||||||
return `${mandate.name} (${label})`;
|
|
||||||
}
|
|
||||||
return mandate.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _roleDescriptionLine(role: RoleInfo): string {
|
function _roleDescriptionLine(role: RoleInfo): string {
|
||||||
return role.description?.trim() || '';
|
return role.description?.trim() || '';
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +69,7 @@ function _roleDescriptionLine(role: RoleInfo): string {
|
||||||
interface UserAccessOverview {
|
interface UserAccessOverview {
|
||||||
user: UserOption;
|
user: UserOption;
|
||||||
isSysAdmin: boolean;
|
isSysAdmin: boolean;
|
||||||
|
isPlatformAdmin: boolean;
|
||||||
sysAdminNote?: string;
|
sysAdminNote?: string;
|
||||||
roles: RoleInfo[];
|
roles: RoleInfo[];
|
||||||
mandates: MandateInfo[];
|
mandates: MandateInfo[];
|
||||||
|
|
@ -201,7 +196,13 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
|
||||||
{overview.isSysAdmin && (
|
{overview.isSysAdmin && (
|
||||||
<div className={styles.infoBox} style={{ background: '#fef3c7', borderColor: '#f59e0b' }}>
|
<div className={styles.infoBox} style={{ background: '#fef3c7', borderColor: '#f59e0b' }}>
|
||||||
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#f59e0b' }} />
|
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#f59e0b' }} />
|
||||||
<span>{overview.sysAdminNote || t('Dieser Benutzer ist SysAdmin und hat vollen Systemzugriff.')}</span>
|
<span>{overview.sysAdminNote || t('Dieser Benutzer ist Systemadmin (Infrastruktur-Operator) und hat vollen Datenzugriff (RBAC-Bypass).')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{overview.isPlatformAdmin && (
|
||||||
|
<div className={styles.infoBox} style={{ background: '#dbeafe', borderColor: '#3b82f6' }}>
|
||||||
|
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#3b82f6' }} />
|
||||||
|
<span>{t('Dieser Benutzer ist Plattformadmin und kann mandantsübergreifend User, Mandate und RBAC-Regeln verwalten (kein RBAC-Bypass).')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -223,7 +224,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
<FaChevronRight className={styles.expandIcon} />
|
<FaChevronRight className={styles.expandIcon} />
|
||||||
)}
|
)}
|
||||||
<span className={styles.roleLabel}>{_mandateNameLine(mandate)}</span>
|
<span className={styles.roleLabel}>{mandateDisplayLineLabelThenSlug(mandate)}</span>
|
||||||
<span className={styles.roleDescription}>
|
<span className={styles.roleDescription}>
|
||||||
{t('{r} Mandantenrolle(n) · {i} Feature-Instanz(en)', {
|
{t('{r} Mandantenrolle(n) · {i} Feature-Instanz(en)', {
|
||||||
r: mandateRoles.length,
|
r: mandateRoles.length,
|
||||||
|
|
@ -623,6 +624,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
|
||||||
<option key={user.id} value={user.id}>
|
<option key={user.id} value={user.id}>
|
||||||
{user.fullName || user.username} ({user.email})
|
{user.fullName || user.username} ({user.email})
|
||||||
{user.isSysAdmin && ` [${t('SysAdmin')}]`}
|
{user.isSysAdmin && ` [${t('SysAdmin')}]`}
|
||||||
|
{user.isPlatformAdmin && ` [${t('PlatformAdmin')}]`}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -668,6 +670,14 @@ export const AdminUserAccessOverviewPage: React.FC = () => {
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{overview.isPlatformAdmin && (
|
||||||
|
<>
|
||||||
|
<span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
|
||||||
|
<span className={styles.badge} style={{ background: '#3b82f6', color: 'white' }}>
|
||||||
|
{t('PlatformAdmin')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
export const AdminUserMandatesPage: React.FC = () => {
|
export const AdminUserMandatesPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -250,11 +251,6 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
setEditingUser(user);
|
setEditingUser(user);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get mandate name
|
|
||||||
const getMandateName = (mandate: Mandate) => {
|
|
||||||
return mandate.label || mandate.name || mandate.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error && !selectedMandateId) {
|
if (error && !selectedMandateId) {
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
|
|
@ -295,7 +291,7 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
<option value="">{t('Mandant wählen')}</option>
|
<option value="">{t('Mandant wählen')}</option>
|
||||||
{mandates.map(m => (
|
{mandates.map(m => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
{getMandateName(m)}
|
{mandateDisplayLabel(m)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,12 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaKey, FaEnvelopeOpenText, FaUserShield } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaKey, FaEnvelopeOpenText, FaUserShield } from 'react-icons/fa';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
import { getUserDataCache } from '../../utils/userCache';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
const _PRIVILEGED_FLAGS = ['isSysAdmin', 'isPlatformAdmin'] as const;
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|
@ -21,6 +24,7 @@ interface User {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
isSysAdmin?: boolean;
|
isSysAdmin?: boolean;
|
||||||
|
isPlatformAdmin?: boolean;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,17 +119,41 @@ export const AdminUsersPage: React.FC = () => {
|
||||||
await handleSendPasswordLink(user.id);
|
await handleSendPasswordLink(user.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Form attributes from backend - filter for create/edit forms
|
// Privileged-flag gating mirrors the backend rules in routeDataUsers.update_user
|
||||||
const formAttributes = useMemo(() => {
|
// and create_user: only a Platform-Admin may set isSysAdmin / isPlatformAdmin,
|
||||||
|
// and even then never on themselves (Self-Protection).
|
||||||
|
const currentUserCache = getUserDataCache();
|
||||||
|
const callerIsPlatformAdmin = currentUserCache?.isPlatformAdmin === true;
|
||||||
|
const callerId = currentUserCache?.id;
|
||||||
|
|
||||||
|
const _buildFormAttributes = (mode: 'create' | 'edit', targetUserId?: string) => {
|
||||||
const excludedFields = ['id', 'hashedPassword', 'authenticationAuthority'];
|
const excludedFields = ['id', 'hashedPassword', 'authenticationAuthority'];
|
||||||
|
const isSelfEdit = mode === 'edit' && targetUserId !== undefined && targetUserId === callerId;
|
||||||
|
// Caller may flip flags only when PlatformAdmin AND not editing themselves.
|
||||||
|
const flagsEditable = callerIsPlatformAdmin && !isSelfEdit;
|
||||||
|
|
||||||
return (attributes || [])
|
return (attributes || [])
|
||||||
.filter(attr => !excludedFields.includes(attr.name))
|
.filter(attr => !excludedFields.includes(attr.name))
|
||||||
.map(attr => ({
|
.map(attr => {
|
||||||
...attr,
|
if (_PRIVILEGED_FLAGS.includes(attr.name as any) && !flagsEditable) {
|
||||||
// Mark username as readonly for edit mode (will be handled by FormGeneratorForm)
|
return { ...attr, editable: false };
|
||||||
editable: attr.name === 'username' ? false : attr.editable,
|
}
|
||||||
}));
|
if (attr.name === 'username') {
|
||||||
}, [attributes]);
|
return { ...attr, editable: false };
|
||||||
|
}
|
||||||
|
return attr;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formAttributesCreate = useMemo(
|
||||||
|
() => _buildFormAttributes('create'),
|
||||||
|
[attributes, callerIsPlatformAdmin],
|
||||||
|
);
|
||||||
|
|
||||||
|
const formAttributesEdit = useMemo(
|
||||||
|
() => _buildFormAttributes('edit', editingUser?.id),
|
||||||
|
[attributes, callerIsPlatformAdmin, callerId, editingUser?.id],
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -242,14 +270,14 @@ export const AdminUsersPage: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
{formAttributes.length === 0 ? (
|
{formAttributesCreate.length === 0 ? (
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.spinner} />
|
<div className={styles.spinner} />
|
||||||
<span>{t('Lade Formular')}</span>
|
<span>{t('Lade Formular')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FormGeneratorForm
|
<FormGeneratorForm
|
||||||
attributes={formAttributes}
|
attributes={formAttributesCreate}
|
||||||
mode="create"
|
mode="create"
|
||||||
onSubmit={handleCreateSubmit}
|
onSubmit={handleCreateSubmit}
|
||||||
onCancel={() => setShowCreateModal(false)}
|
onCancel={() => setShowCreateModal(false)}
|
||||||
|
|
@ -276,14 +304,14 @@ export const AdminUsersPage: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
{formAttributes.length === 0 ? (
|
{formAttributesEdit.length === 0 ? (
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.spinner} />
|
<div className={styles.spinner} />
|
||||||
<span>{t('Lade Formular')}</span>
|
<span>{t('Lade Formular')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FormGeneratorForm
|
<FormGeneratorForm
|
||||||
attributes={formAttributes}
|
attributes={formAttributesEdit}
|
||||||
data={editingUser}
|
data={editingUser}
|
||||||
mode="edit"
|
mode="edit"
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmit}
|
||||||
|
|
|
||||||
|
|
@ -292,8 +292,8 @@ export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({ instan
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.modalOverlay} onClick={onClose}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={`${styles.modal} ${modalStyles.modal}`} onClick={(e) => e.stopPropagation()}>
|
<div className={`${styles.modal} ${modalStyles.modal}`}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className={styles.modalTitle}>{instance.label}</h2>
|
<h2 className={styles.modalTitle}>{instance.label}</h2>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { useToast } from '../../../contexts/ToastContext';
|
||||||
import styles from '../Admin.module.css';
|
import styles from '../Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
type InviteType = 'mandate' | 'featureInstance';
|
type InviteType = 'mandate' | 'featureInstance';
|
||||||
|
|
||||||
|
|
@ -114,9 +115,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
// HELPERS
|
// HELPERS
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
const getMandateName = (m: Mandate): string => {
|
const getMandateName = (m: Mandate): string => mandateDisplayLabel(m);
|
||||||
return m.label || m.name || m.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// DATA LOADING
|
// DATA LOADING
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,14 @@ import {
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useMandateFormAttributes } from '../../../hooks/useMandates';
|
import { useMandateFormAttributes } from '../../../hooks/useMandates';
|
||||||
import { createMandate } from '../../../api/mandateApi';
|
import { createMandate, type MandateCreateData } from '../../../api/mandateApi';
|
||||||
import { updateSettingsAdmin } from '../../../api/billingApi';
|
import { updateSettingsAdmin } from '../../../api/billingApi';
|
||||||
import { splitMandateAndBillingFromForm } from '../../../utils/mandateBillingFormMerge';
|
import { splitMandateAndBillingFromForm } from '../../../utils/mandateBillingFormMerge';
|
||||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||||
import styles from '../Admin.module.css';
|
import styles from '../Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
const TOTAL_STEPS = 4;
|
const TOTAL_STEPS = 4;
|
||||||
|
|
||||||
|
|
@ -103,9 +104,8 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
// HELPERS
|
// HELPERS
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const getMandateName = (m: Mandate | Record<string, any>): string => {
|
const getMandateName = (m: Mandate | Record<string, any>): string =>
|
||||||
return m.label || m.name || m.id;
|
mandateDisplayLabel(m as { label?: string | null; name?: string | null; id?: string });
|
||||||
};
|
|
||||||
|
|
||||||
const getFeatureLabel = (code: string): string => {
|
const getFeatureLabel = (code: string): string => {
|
||||||
const f = features.find(feat => feat.code === code);
|
const f = features.find(feat => feat.code === code);
|
||||||
|
|
@ -216,9 +216,10 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
||||||
const body = {
|
const body: MandateCreateData = {
|
||||||
...mandatePayload,
|
...(mandatePayload as Record<string, unknown>),
|
||||||
enabled: mandatePayload.enabled !== undefined ? mandatePayload.enabled : true,
|
label: String(mandatePayload.label ?? '').trim(),
|
||||||
|
enabled: typeof mandatePayload.enabled === 'boolean' ? mandatePayload.enabled : true,
|
||||||
};
|
};
|
||||||
const created = await createMandate(request, body);
|
const created = await createMandate(request, body);
|
||||||
let billingSaved = false;
|
let billingSaved = false;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,100 @@
|
||||||
.modal {
|
.modal {
|
||||||
max-width: 520px;
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step 1 — selectable cards (mandate / feature) */
|
||||||
|
|
||||||
|
.fieldLabel {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--primary-color, #f25843);
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary, rgba(255, 255, 255, 0.03));
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardButton:hover {
|
||||||
|
border-color: rgba(242, 88, 67, 0.5);
|
||||||
|
background: rgba(242, 88, 67, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardButton:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color, #f25843);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardButtonActive {
|
||||||
|
border-color: rgba(242, 88, 67, 0.7);
|
||||||
|
background: rgba(242, 88, 67, 0.18);
|
||||||
|
color: var(--primary-color, #f25843);
|
||||||
|
box-shadow: 0 0 0 1px rgba(242, 88, 67, 0.3) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardButton:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textInput:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color, #f25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldError {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--primary-color, #f25843);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldHint {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.steps {
|
.steps {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useFeatureAccess } from '../../../hooks/useFeatureAccess';
|
import { useFeatureAccess } from '../../../hooks/useFeatureAccess';
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../../components/FormGenerator/FormGeneratorForm';
|
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import type { Mandate } from '../../../hooks/useUserMandates';
|
import type { Mandate } from '../../../hooks/useUserMandates';
|
||||||
|
|
@ -15,9 +14,10 @@ import styles from '../Admin.module.css';
|
||||||
import wizardStyles from './FeatureInstanceWizard.module.css';
|
import wizardStyles from './FeatureInstanceWizard.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
function getMandateName(m: Mandate): string {
|
function getMandateName(m: Mandate): string {
|
||||||
return m.label || m.name || m.id;
|
return mandateDisplayLabel(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeatureInstanceWizardProps {
|
export interface FeatureInstanceWizardProps {
|
||||||
|
|
@ -55,45 +55,25 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
|
||||||
const [mandateUsers, setMandateUsers] = useState<Array<{ id: string; username: string; email?: string }>>([]);
|
const [mandateUsers, setMandateUsers] = useState<Array<{ id: string; username: string; email?: string }>>([]);
|
||||||
const [instanceRoles, setInstanceRoles] = useState<Array<{ id: string; roleLabel: string }>>([]);
|
const [instanceRoles, setInstanceRoles] = useState<Array<{ id: string; roleLabel: string }>>([]);
|
||||||
const [selectedUserRoles, setSelectedUserRoles] = useState<Array<{ userId: string; roleIds: string[] }>>([]);
|
const [selectedUserRoles, setSelectedUserRoles] = useState<Array<{ userId: string; roleIds: string[] }>>([]);
|
||||||
|
const [labelTouched, setLabelTouched] = useState(false);
|
||||||
|
|
||||||
const featureOptions = useMemo(
|
const trimmedLabel = label.trim();
|
||||||
() => features.map((f) => ({ value: f.code, label: f.label || f.code })),
|
const labelMissing = trimmedLabel.length === 0;
|
||||||
[features]
|
const canSubmitStep1 = !!mandateId && !!featureCode && !labelMissing && !submitting;
|
||||||
);
|
|
||||||
const mandateOptions = useMemo(
|
|
||||||
() => mandates.map((m) => ({ value: m.id, label: getMandateName(m) })),
|
|
||||||
[mandates]
|
|
||||||
);
|
|
||||||
|
|
||||||
const createFields: AttributeDefinition[] = useMemo(
|
const handleStep1Submit = async () => {
|
||||||
() => [
|
setLabelTouched(true);
|
||||||
{ name: 'mandateId', label: t('Mandant'), type: 'enum' as const, required: true, options: mandateOptions },
|
if (!canSubmitStep1) return;
|
||||||
{ name: 'featureCode', label: t('Feature'), type: 'enum' as const, required: true, options: featureOptions },
|
|
||||||
{ name: 'label', label: t('Bezeichnung'), type: 'string' as const, required: true, editable: true },
|
|
||||||
{ name: 'enabled', label: t('Aktiv'), type: 'boolean' as const, required: false, editable: true },
|
|
||||||
],
|
|
||||||
[mandateOptions, featureOptions]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleStep1Submit = async (data: {
|
|
||||||
mandateId: string;
|
|
||||||
featureCode: string;
|
|
||||||
label: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
}) => {
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const result = await createInstance(data.mandateId, {
|
const result = await createInstance(mandateId, {
|
||||||
featureCode: data.featureCode,
|
featureCode,
|
||||||
label: data.label,
|
label: trimmedLabel,
|
||||||
enabled: data.enabled !== false,
|
enabled,
|
||||||
copyTemplateRoles: copyTemplateRoles,
|
copyTemplateRoles,
|
||||||
});
|
});
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setMandateId(data.mandateId);
|
setLabel(trimmedLabel);
|
||||||
setFeatureCode(data.featureCode);
|
|
||||||
setLabel(data.label);
|
|
||||||
setEnabled(data.enabled !== false);
|
|
||||||
setCreatedInstanceId(result.data.id);
|
setCreatedInstanceId(result.data.id);
|
||||||
setStep(1);
|
setStep(1);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -165,8 +145,8 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
|
||||||
const currentStepId = steps[step]?.id;
|
const currentStepId = steps[step]?.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.modalOverlay} onClick={onClose}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={`${styles.modal} ${wizardStyles.modal}`} onClick={(e) => e.stopPropagation()}>
|
<div className={`${styles.modal} ${wizardStyles.modal}`}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Neue Feature-Instanz')}</h2>
|
<h2 className={styles.modalTitle}>{t('Neue Feature-Instanz')}</h2>
|
||||||
<button type="button" className={styles.modalClose} onClick={onClose} aria-label={t('Schließen')}>
|
<button type="button" className={styles.modalClose} onClick={onClose} aria-label={t('Schließen')}>
|
||||||
|
|
@ -189,20 +169,86 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
{currentStepId === 'create' && (
|
{currentStepId === 'create' && (
|
||||||
<div className={wizardStyles.stepContent}>
|
<div className={wizardStyles.stepContent}>
|
||||||
<FormGeneratorForm
|
<div className={wizardStyles.fieldGroup}>
|
||||||
attributes={createFields}
|
<span className={wizardStyles.fieldLabel}>
|
||||||
mode="create"
|
{t('Mandant')}<span className={wizardStyles.required}>*</span>
|
||||||
data={{
|
</span>
|
||||||
mandateId: mandateId || (mandates[0]?.id ?? ''),
|
{mandates.length === 0 ? (
|
||||||
featureCode: featureCode || (features[0]?.code ?? ''),
|
<p className={wizardStyles.fieldHint}>{t('Keine Mandanten verfügbar')}</p>
|
||||||
label,
|
) : (
|
||||||
enabled,
|
<div className={wizardStyles.cardGrid}>
|
||||||
}}
|
{mandates.map((m) => {
|
||||||
onSubmit={handleStep1Submit}
|
const isActive = mandateId === m.id;
|
||||||
onCancel={onClose}
|
return (
|
||||||
submitButtonText={t('Weiter')}
|
<button
|
||||||
cancelButtonText={t('Abbrechen')}
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
className={`${wizardStyles.cardButton} ${isActive ? wizardStyles.cardButtonActive : ''}`}
|
||||||
|
onClick={() => setMandateId(m.id)}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
>
|
||||||
|
{getMandateName(m)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={wizardStyles.fieldGroup}>
|
||||||
|
<span className={wizardStyles.fieldLabel}>
|
||||||
|
{t('Feature')}<span className={wizardStyles.required}>*</span>
|
||||||
|
</span>
|
||||||
|
{features.length === 0 ? (
|
||||||
|
<p className={wizardStyles.fieldHint}>{t('Keine Features verfügbar')}</p>
|
||||||
|
) : (
|
||||||
|
<div className={wizardStyles.cardGrid}>
|
||||||
|
{features.map((f) => {
|
||||||
|
const isActive = featureCode === f.code;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.code}
|
||||||
|
type="button"
|
||||||
|
className={`${wizardStyles.cardButton} ${isActive ? wizardStyles.cardButtonActive : ''}`}
|
||||||
|
onClick={() => setFeatureCode(f.code)}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
>
|
||||||
|
{f.label || f.code}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={wizardStyles.fieldGroup}>
|
||||||
|
<label className={wizardStyles.fieldLabel} htmlFor="featureInstanceLabel">
|
||||||
|
{t('Bezeichnung')}<span className={wizardStyles.required}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="featureInstanceLabel"
|
||||||
|
type="text"
|
||||||
|
className={wizardStyles.textInput}
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
onBlur={() => setLabelTouched(true)}
|
||||||
|
placeholder={t('z. B. Vertrieb DE')}
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
|
{labelTouched && labelMissing && (
|
||||||
|
<p className={wizardStyles.fieldError}>{t('Bezeichnung ist erforderlich.')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className={wizardStyles.checkLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('Aktiv')}
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className={wizardStyles.checkLabel}>
|
<label className={wizardStyles.checkLabel}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -211,6 +257,21 @@ export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({ ma
|
||||||
/>
|
/>
|
||||||
{t('Rollen von Feature-Vorlage übernehmen (empfohlen)')}
|
{t('Rollen von Feature-Vorlage übernehmen (empfohlen)')}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div className={wizardStyles.stepActions}>
|
||||||
|
<button type="button" className={styles.secondaryButton} onClick={onClose}>
|
||||||
|
{t('Abbrechen')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={handleStep1Submit}
|
||||||
|
disabled={!canSubmitStep1}
|
||||||
|
title={!canSubmitStep1 ? t('Bitte Mandant, Feature und Bezeichnung wählen.') : undefined}
|
||||||
|
>
|
||||||
|
{submitting ? t('Speichern…') : t('Weiter')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { SubscriptionTab } from './SubscriptionTab';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { getUserDataCache } from '../../utils/userCache';
|
import { getUserDataCache } from '../../utils/userCache';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
type AdminTabType = 'subscription' | 'settings' | 'credit';
|
type AdminTabType = 'subscription' | 'settings' | 'credit';
|
||||||
|
|
@ -28,9 +29,6 @@ const _formatCurrency = (amount: number) => {
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const _mandateDisplayLabel = (m: UserMandateRow): string => {
|
|
||||||
return m.label || m.name || m.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MANDATE SELECTOR
|
// MANDATE SELECTOR
|
||||||
|
|
@ -62,7 +60,7 @@ const MandateSelector: React.FC<MandateSelectorProps> = ({
|
||||||
<option value="">{t('Mandant wählen')}</option>
|
<option value="">{t('Mandant wählen')}</option>
|
||||||
{mandates.map(mandate => (
|
{mandates.map(mandate => (
|
||||||
<option key={mandate.id} value={mandate.id}>
|
<option key={mandate.id} value={mandate.id}>
|
||||||
{_mandateDisplayLabel(mandate)}
|
{mandateDisplayLabel(mandate)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -446,7 +444,7 @@ export const BillingAdmin: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { user: currentUser } = useCurrentUser();
|
const { user: currentUser } = useCurrentUser();
|
||||||
const isSysAdmin = currentUser?.isSysAdmin === true;
|
const isSysAdmin = currentUser?.isPlatformAdmin === true;
|
||||||
|
|
||||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(
|
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(
|
||||||
searchParams.get('mandate') || null
|
searchParams.get('mandate') || null
|
||||||
|
|
|
||||||
|
|
@ -646,8 +646,8 @@ const PlaygroundTab: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{browseTarget && (
|
{browseTarget && (
|
||||||
<div className={styles.modalOverlay} onClick={handleCloseBrowse}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.modalContent}>
|
||||||
<h3 className={styles.modalTitle}>
|
<h3 className={styles.modalTitle}>
|
||||||
{browseTarget === 'source'
|
{browseTarget === 'source'
|
||||||
? t('SharePoint-Quellordner durchsuchen')
|
? t('SharePoint-Quellordner durchsuchen')
|
||||||
|
|
|
||||||
|
|
@ -211,8 +211,8 @@ export const RealEstateParcelsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(editingParcel || isCreateMode) && (
|
{(editingParcel || isCreateMode) && (
|
||||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>
|
<h2 className={styles.modalTitle}>
|
||||||
{isCreateMode ? t('Neue Parzelle') : t('Parzelle bearbeiten')}
|
{isCreateMode ? t('Neue Parzelle') : t('Parzelle bearbeiten')}
|
||||||
|
|
|
||||||
|
|
@ -174,8 +174,8 @@ export const RealEstateProjectsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(editingProject || isCreateMode) && (
|
{(editingProject || isCreateMode) && (
|
||||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{isCreateMode ? t('Neues Projekt') : t('Projekt bearbeiten')}</h2>
|
<h2 className={styles.modalTitle}>{isCreateMode ? t('Neues Projekt') : t('Projekt bearbeiten')}</h2>
|
||||||
<button className={styles.modalClose} onClick={handleCloseModal}>✕</button>
|
<button className={styles.modalClose} onClick={handleCloseModal}>✕</button>
|
||||||
|
|
|
||||||
|
|
@ -266,8 +266,8 @@ export const TrusteeDocumentsView: React.FC = () => {
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
{(editingDocument || isCreateMode) && (
|
{(editingDocument || isCreateMode) && (
|
||||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>
|
<h2 className={styles.modalTitle}>
|
||||||
{isCreateMode ? t('Neues Dokument') : t('Dokument bearbeiten')}
|
{isCreateMode ? t('Neues Dokument') : t('Dokument bearbeiten')}
|
||||||
|
|
|
||||||
|
|
@ -213,8 +213,8 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
{isCreateMode && (
|
{isCreateMode && (
|
||||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>{t('Neue Verknüpfung erstellen')}</h2>
|
<h2 className={styles.modalTitle}>{t('Neue Verknüpfung erstellen')}</h2>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -484,8 +484,8 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
{(editingPosition || isCreateMode) && (
|
{(editingPosition || isCreateMode) && (
|
||||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
<div className={styles.modalOverlay}>
|
||||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
<div className={styles.modal}>
|
||||||
<div className={styles.modalHeader}>
|
<div className={styles.modalHeader}>
|
||||||
<h2 className={styles.modalTitle}>
|
<h2 className={styles.modalTitle}>
|
||||||
{isCreateMode ? t('Neue Position') : t('Position bearbeiten')}
|
{isCreateMode ? t('Neue Position') : t('Position bearbeiten')}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,26 @@ interface LanguageContextType {
|
||||||
|
|
||||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const _LANGUAGE_STORAGE_KEY = 'poweron.preLoginLanguage';
|
||||||
|
|
||||||
|
function _readStoredLanguage(): Language | null {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(_LANGUAGE_STORAGE_KEY);
|
||||||
|
if (raw && raw.trim()) return raw.trim() as Language;
|
||||||
|
} catch {
|
||||||
|
// localStorage may be disabled (private mode etc.) — ignore.
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _writeStoredLanguage(language: Language): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(_LANGUAGE_STORAGE_KEY, language);
|
||||||
|
} catch {
|
||||||
|
// ignore write failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface LanguageProviderProps {
|
interface LanguageProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +66,11 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
||||||
const initializeLanguage = async () => {
|
const initializeLanguage = async () => {
|
||||||
let initialLanguage: Language = 'de';
|
let initialLanguage: Language = 'de';
|
||||||
|
|
||||||
|
// Priority order:
|
||||||
|
// 1. Logged-in user preference (server truth)
|
||||||
|
// 2. Pre-login choice persisted in localStorage (LanguageSelector on login/register/reset pages)
|
||||||
|
// 3. Browser language (if available)
|
||||||
|
// 4. Hard default 'de'
|
||||||
const userData = getUserDataCache();
|
const userData = getUserDataCache();
|
||||||
if (userData?.language && String(userData.language).trim()) {
|
if (userData?.language && String(userData.language).trim()) {
|
||||||
initialLanguage = String(userData.language).trim() as Language;
|
initialLanguage = String(userData.language).trim() as Language;
|
||||||
|
|
@ -53,6 +78,12 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stored = _readStoredLanguage();
|
||||||
|
if (stored) {
|
||||||
|
await loadAndSetLanguage(stored);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const browserLang = navigator.language.split('-')[0] as Language;
|
const browserLang = navigator.language.split('-')[0] as Language;
|
||||||
try {
|
try {
|
||||||
const codes = await fetchAvailableLanguageCodes();
|
const codes = await fetchAvailableLanguageCodes();
|
||||||
|
|
@ -93,6 +124,7 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setLanguage = async (language: Language) => {
|
const setLanguage = async (language: Language) => {
|
||||||
|
_writeStoredLanguage(language);
|
||||||
await loadAndSetLanguage(language);
|
await loadAndSetLanguage(language);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,12 +102,21 @@ export interface MandateFeature {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Mandant (oberste Ebene)
|
* Ein Mandant (oberste Ebene)
|
||||||
* Enthält mehrere Features mit deren Instanzen
|
* Enthält mehrere Features mit deren Instanzen.
|
||||||
|
*
|
||||||
|
* Felder:
|
||||||
|
* - `id` — UUID des Mandanten.
|
||||||
|
* - `name` — Kurzzeichen / Slug (UNIQUE, audit-stable). Nur lowercase
|
||||||
|
* `a–z0–9` und `-`, Länge 2–32. Wird vom System aus `label`
|
||||||
|
* generiert und kann nur durch einen PlatformAdmin geändert
|
||||||
|
* werden (siehe `wiki/b-reference/platform/rbac.md`).
|
||||||
|
* - `label` — Voller Name (Pflichtfeld), wird im UI gerendert. Frei
|
||||||
|
* änderbar durch Mandate-Admin.
|
||||||
*/
|
*/
|
||||||
export interface Mandate {
|
export interface Mandate {
|
||||||
id: string; // mandateId
|
id: string; // mandateId
|
||||||
name: string; // Technischer Identifier
|
name: string; // Kurzzeichen (Slug, audit-stable)
|
||||||
label?: string; // Anzeige-Label (fuer FK-Referenzen und UI)
|
label: string; // Voller Name — Anzeige im UI (mandatory)
|
||||||
code?: string; // Optionaler Code
|
code?: string; // Optionaler Code
|
||||||
features: MandateFeature[];
|
features: MandateFeature[];
|
||||||
}
|
}
|
||||||
|
|
@ -140,8 +149,9 @@ export interface User {
|
||||||
language: string;
|
language: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
authenticationAuthority: string;
|
authenticationAuthority: string;
|
||||||
isSysAdmin: boolean;
|
isSysAdmin: boolean; // Infrastructure/System Operator (RBAC bypass)
|
||||||
roleLabels?: string[]; // System-weite Rollen (z.B. ["sysadmin"])
|
isPlatformAdmin: boolean; // Cross-Mandate Governance (no RBAC bypass)
|
||||||
|
roleLabels?: string[]; // Mandanten-scoped role labels
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export type AttributeType =
|
||||||
| 'file'
|
| 'file'
|
||||||
| 'string'
|
| 'string'
|
||||||
| 'enum'
|
| 'enum'
|
||||||
|
| 'slug'
|
||||||
| 'readonly';
|
| 'readonly';
|
||||||
|
|
||||||
export type InputComponentType =
|
export type InputComponentType =
|
||||||
|
|
@ -66,6 +67,7 @@ export function attributeTypeToInputType(attributeType: AttributeType): InputCom
|
||||||
switch (attributeType) {
|
switch (attributeType) {
|
||||||
case 'text':
|
case 'text':
|
||||||
case 'string':
|
case 'string':
|
||||||
|
case 'slug':
|
||||||
return 'text';
|
return 'text';
|
||||||
|
|
||||||
case 'textarea':
|
case 'textarea':
|
||||||
|
|
|
||||||
42
src/utils/mandateDisplayUtils.ts
Normal file
42
src/utils/mandateDisplayUtils.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* UI display helpers for Mandate `label` (Voller Name) vs `name` (Kurzzeichen / Slug).
|
||||||
|
* Mirrors semantics in `wiki/c-work/1-plan/2026-04-mandate-name-label-logic.md`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function _trimOrEmpty(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string') return value.trim();
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary string for lists, dropdowns, breadcrumbs: Voller Name, then Kurzzeichen,
|
||||||
|
* then id (defensive).
|
||||||
|
*/
|
||||||
|
export function mandateDisplayLabel(m: {
|
||||||
|
label?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
id?: string;
|
||||||
|
}): string {
|
||||||
|
const lab = _trimOrEmpty(m.label);
|
||||||
|
if (lab) return lab;
|
||||||
|
const slug = _trimOrEmpty(m.name);
|
||||||
|
if (slug) return slug;
|
||||||
|
return typeof m.id === 'string' ? m.id : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line: `"Voller Name (kurzzeichen)"` when both differ; otherwise the single value.
|
||||||
|
* Use where users should see the human name first and the technical slug second.
|
||||||
|
*/
|
||||||
|
export function mandateDisplayLineLabelThenSlug(m: {
|
||||||
|
label?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
}): string {
|
||||||
|
const lab = _trimOrEmpty(m.label);
|
||||||
|
const slug = _trimOrEmpty(m.name);
|
||||||
|
if (lab && slug && lab !== slug) {
|
||||||
|
return `${lab} (${slug})`;
|
||||||
|
}
|
||||||
|
return lab || slug;
|
||||||
|
}
|
||||||
77
src/utils/mandateNameUtils.ts
Normal file
77
src/utils/mandateNameUtils.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* Mandate slug helpers — domain-specific wrapper around the generic
|
||||||
|
* `slugUtils.ts`. Keeps mandate-localized error strings & the historical
|
||||||
|
* "mn" fallback while delegating all formatting logic to the generic layer.
|
||||||
|
*
|
||||||
|
* The rules MUST stay in sync with the gateway counterpart at
|
||||||
|
* `gateway/modules/shared/mandateNameUtils.py`.
|
||||||
|
*
|
||||||
|
* Format: lowercase [a-z0-9], hyphen-separated segments, length 2–32.
|
||||||
|
* German umlauts are transliterated (ä→ae, ö→oe, ü→ue, ß→ss) before slugging.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_SLUG_MIN_LEN,
|
||||||
|
DEFAULT_SLUG_MAX_LEN,
|
||||||
|
SLUG_PATTERN,
|
||||||
|
SLUG_HINT,
|
||||||
|
allocateUniqueSlug,
|
||||||
|
isValidSlug,
|
||||||
|
maskSlugInput,
|
||||||
|
slugify,
|
||||||
|
transliterateGerman as _transliterateGermanGeneric,
|
||||||
|
} from './slugUtils';
|
||||||
|
|
||||||
|
export const MANDATE_NAME_MIN_LEN = DEFAULT_SLUG_MIN_LEN;
|
||||||
|
export const MANDATE_NAME_MAX_LEN = DEFAULT_SLUG_MAX_LEN;
|
||||||
|
export const MANDATE_NAME_PATTERN = SLUG_PATTERN;
|
||||||
|
export const MANDATE_NAME_HINT = SLUG_HINT;
|
||||||
|
|
||||||
|
const _MANDATE_OPTS = {
|
||||||
|
minLen: MANDATE_NAME_MIN_LEN,
|
||||||
|
maxLen: MANDATE_NAME_MAX_LEN,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function transliterateGerman(text: string): string {
|
||||||
|
return _transliterateGermanGeneric(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a slug from a label. Falls back to "mn" if no valid slug can be derived. */
|
||||||
|
export function slugifyMandateName(label: string | null | undefined): string {
|
||||||
|
const trimmed = (label ?? '').toString().trim();
|
||||||
|
if (!trimmed) return 'mn';
|
||||||
|
const result = slugify(trimmed, _MANDATE_OPTS);
|
||||||
|
return isValidMandateName(result) ? result : 'mn';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskMandateNameInput(raw: string | null | undefined): string {
|
||||||
|
return maskSlugInput(raw, _MANDATE_OPTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidMandateName(name: unknown): name is string {
|
||||||
|
return isValidSlug(name, _MANDATE_OPTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a localized (mandate-flavored) error message, or null when valid. */
|
||||||
|
export function validateMandateName(name: unknown): string | null {
|
||||||
|
if (typeof name !== 'string' || name.length === 0) {
|
||||||
|
return 'Kurzzeichen ist erforderlich.';
|
||||||
|
}
|
||||||
|
if (name.length < MANDATE_NAME_MIN_LEN) {
|
||||||
|
return `Kurzzeichen muss mindestens ${MANDATE_NAME_MIN_LEN} Zeichen lang sein.`;
|
||||||
|
}
|
||||||
|
if (name.length > MANDATE_NAME_MAX_LEN) {
|
||||||
|
return `Kurzzeichen darf maximal ${MANDATE_NAME_MAX_LEN} Zeichen lang sein.`;
|
||||||
|
}
|
||||||
|
if (!MANDATE_NAME_PATTERN.test(name)) {
|
||||||
|
return MANDATE_NAME_HINT;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allocateUniqueMandateSlug(
|
||||||
|
base: string,
|
||||||
|
taken: Iterable<string>,
|
||||||
|
): string {
|
||||||
|
return allocateUniqueSlug(base, taken, _MANDATE_OPTS);
|
||||||
|
}
|
||||||
152
src/utils/slugUtils.ts
Normal file
152
src/utils/slugUtils.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
/**
|
||||||
|
* Generic slug helpers for FormGenerator inputs of type `slug`.
|
||||||
|
*
|
||||||
|
* Format: lowercase ASCII (`[a-z0-9]`), single-hyphen segments, configurable
|
||||||
|
* length range (defaults 2–32). German umlauts are transliterated
|
||||||
|
* (ä→ae, ö→oe, ü→ue, ß→ss) before slugging.
|
||||||
|
*
|
||||||
|
* Domain-specific helpers (e.g. `mandateNameUtils.ts`) MUST delegate here so
|
||||||
|
* that all slug inputs stay in sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_SLUG_MIN_LEN = 2;
|
||||||
|
export const DEFAULT_SLUG_MAX_LEN = 32;
|
||||||
|
export const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||||
|
export const SLUG_HINT =
|
||||||
|
'Nur Kleinbuchstaben (a–z), Ziffern (0–9) und Bindestriche. Ohne führende oder folgende Bindestriche.';
|
||||||
|
|
||||||
|
export interface SlugOptions {
|
||||||
|
minLen?: number;
|
||||||
|
maxLen?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _GERMAN_MAP: Record<string, string> = {
|
||||||
|
ä: 'ae', Ä: 'ae',
|
||||||
|
ö: 'oe', Ö: 'oe',
|
||||||
|
ü: 'ue', Ü: 'ue',
|
||||||
|
ß: 'ss',
|
||||||
|
};
|
||||||
|
|
||||||
|
function _transliterateGerman(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
let out = '';
|
||||||
|
for (const ch of text) {
|
||||||
|
out += _GERMAN_MAP[ch] ?? ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _collapseHyphensAndTrim(raw: string): string {
|
||||||
|
const lowered = raw.toLowerCase();
|
||||||
|
const replaced = lowered.replace(/[^a-z0-9]+/g, '-');
|
||||||
|
return replaced.replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ensureMinSlugLength(slug: string, minLen: number): string {
|
||||||
|
if (slug.length >= minLen) return slug;
|
||||||
|
if (slug.length === 1) return slug + slug;
|
||||||
|
return slug + 'x'.repeat(minLen - slug.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _truncateSlugToMaxLen(slug: string, minLen: number, maxLen: number): string {
|
||||||
|
if (slug.length <= maxLen) return slug;
|
||||||
|
let cut = slug.slice(0, maxLen).replace(/-+$/g, '');
|
||||||
|
const lastHyphen = cut.lastIndexOf('-');
|
||||||
|
if (lastHyphen > 0) {
|
||||||
|
cut = cut.slice(0, lastHyphen);
|
||||||
|
}
|
||||||
|
cut = cut.replace(/^-+|-+$/g, '');
|
||||||
|
if (cut.length < minLen) {
|
||||||
|
return cut + 'x'.repeat(minLen - cut.length);
|
||||||
|
}
|
||||||
|
return cut;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transliterateGerman(text: string): string {
|
||||||
|
return _transliterateGerman(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a slug from a free-text source. Falls back to "x".repeat(minLen)
|
||||||
|
* when no valid slug can be derived (callers can override the fallback).
|
||||||
|
*/
|
||||||
|
export function slugify(source: string | null | undefined, opts: SlugOptions = {}): string {
|
||||||
|
const minLen = opts.minLen ?? DEFAULT_SLUG_MIN_LEN;
|
||||||
|
const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN;
|
||||||
|
const fallback = 'x'.repeat(Math.max(minLen, 2));
|
||||||
|
|
||||||
|
const src = (source ?? '').toString().trim();
|
||||||
|
if (!src) return fallback;
|
||||||
|
const step1 = _transliterateGerman(src);
|
||||||
|
const step2 = _collapseHyphensAndTrim(step1);
|
||||||
|
if (!step2) return fallback;
|
||||||
|
const ensured = _ensureMinSlugLength(step2, minLen);
|
||||||
|
const truncated = _truncateSlugToMaxLen(ensured, minLen, maxLen);
|
||||||
|
return isValidSlug(truncated, opts) ? truncated : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-mask user input for a slug field. Does NOT enforce min length so users
|
||||||
|
* can keep typing; the format check happens on submit.
|
||||||
|
*/
|
||||||
|
export function maskSlugInput(raw: string | null | undefined, opts: SlugOptions = {}): string {
|
||||||
|
if (!raw) return '';
|
||||||
|
const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN;
|
||||||
|
const transliterated = _transliterateGerman(String(raw)).toLowerCase();
|
||||||
|
const cleaned = transliterated.replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-');
|
||||||
|
return cleaned.slice(0, maxLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidSlug(value: unknown, opts: SlugOptions = {}): value is string {
|
||||||
|
const minLen = opts.minLen ?? DEFAULT_SLUG_MIN_LEN;
|
||||||
|
const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN;
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
if (value.length < minLen || value.length > maxLen) return false;
|
||||||
|
return SLUG_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a localized error message for an invalid slug, or null when valid. */
|
||||||
|
export function validateSlug(value: unknown, opts: SlugOptions = {}): string | null {
|
||||||
|
const minLen = opts.minLen ?? DEFAULT_SLUG_MIN_LEN;
|
||||||
|
const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN;
|
||||||
|
if (typeof value !== 'string' || value.length === 0) {
|
||||||
|
return 'Wert ist erforderlich.';
|
||||||
|
}
|
||||||
|
if (value.length < minLen) {
|
||||||
|
return `Wert muss mindestens ${minLen} Zeichen lang sein.`;
|
||||||
|
}
|
||||||
|
if (value.length > maxLen) {
|
||||||
|
return `Wert darf maximal ${maxLen} Zeichen lang sein.`;
|
||||||
|
}
|
||||||
|
if (!SLUG_PATTERN.test(value)) {
|
||||||
|
return SLUG_HINT;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocate a slug not already present in *taken*, by appending -2, -3, ...
|
||||||
|
* Mirrors `allocateUniqueMandateSlug` in the gateway.
|
||||||
|
*/
|
||||||
|
export function allocateUniqueSlug(
|
||||||
|
base: string,
|
||||||
|
taken: Iterable<string>,
|
||||||
|
opts: SlugOptions = {},
|
||||||
|
): string {
|
||||||
|
const minLen = opts.minLen ?? DEFAULT_SLUG_MIN_LEN;
|
||||||
|
const maxLen = opts.maxLen ?? DEFAULT_SLUG_MAX_LEN;
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const x of taken) {
|
||||||
|
if (x) used.add(x);
|
||||||
|
}
|
||||||
|
if (!used.has(base)) return base;
|
||||||
|
for (let n = 2; n <= 100000; n += 1) {
|
||||||
|
const suffix = `-${n}`;
|
||||||
|
const room = Math.max(maxLen - suffix.length, minLen);
|
||||||
|
let root = base.slice(0, room).replace(/-+$/g, '');
|
||||||
|
if (root.length < minLen) root = 'x'.repeat(minLen);
|
||||||
|
const cand = (root + suffix).slice(0, maxLen).replace(/-+$/g, '');
|
||||||
|
if (isValidSlug(cand, opts) && !used.has(cand)) return cand;
|
||||||
|
}
|
||||||
|
throw new Error('allocateUniqueSlug: could not allocate a unique slug');
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,8 @@ export interface CachedUserData {
|
||||||
roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"])
|
roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"])
|
||||||
// mandateId entfernt - User gehört keinem Mandanten direkt an
|
// mandateId entfernt - User gehört keinem Mandanten direkt an
|
||||||
// Stattdessen hat er Zugriff auf Feature-Instanzen (siehe featureStore)
|
// Stattdessen hat er Zugriff auf Feature-Instanzen (siehe featureStore)
|
||||||
isSysAdmin?: boolean; // System-Administrator Flag
|
isSysAdmin?: boolean; // Infrastructure/System Operator (RBAC bypass)
|
||||||
|
isPlatformAdmin?: boolean; // Cross-Mandate Governance (no RBAC bypass)
|
||||||
language: string;
|
language: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
authenticationAuthority: string;
|
authenticationAuthority: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue