int #6

Merged
p.motsch merged 6 commits from int into main 2026-06-12 12:08:34 +00:00
5 changed files with 48 additions and 195 deletions
Showing only changes of commit a01ebed7af - Show all commits

View file

@ -38,29 +38,6 @@ export interface BillingTransaction {
userName?: string; userName?: string;
} }
/** Pagination request for GET /api/billing/transactions with `pagination` JSON (table + grouping). */
export interface BillingTransactionsPaginationParams {
page?: number;
pageSize?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
}
export interface BillingTransactionsPaginatedResponse {
items: BillingTransaction[];
pagination?: {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
};
groupLayout?: import('./connectionApi').GroupLayout;
appliedView?: { viewKey?: string; displayName?: string };
}
export interface BillingSettings { export interface BillingSettings {
id: string; id: string;
mandateId: string; mandateId: string;
@ -159,46 +136,6 @@ export async function fetchBalanceForMandate(
}); });
} }
/**
* Fetch transaction history (table UI: pagination, filters, sort, saved views, grouping).
* Endpoint: GET /api/billing/transactions?pagination=...
*/
export async function fetchTransactionsPaginated(
request: ApiRequestFunction,
params?: BillingTransactionsPaginationParams
): Promise<BillingTransactionsPaginatedResponse> {
const paginationObj: Record<string, unknown> = {};
if (params?.page !== undefined) paginationObj.page = params.page;
if (params?.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params?.sort?.length) paginationObj.sort = params.sort;
if (params?.filters && Object.keys(params.filters).length > 0) paginationObj.filters = params.filters;
if (params?.search) paginationObj.search = params.search;
if (params?.viewKey) paginationObj.viewKey = params.viewKey;
if (params?.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
return await request({
url: '/api/billing/transactions',
method: 'get',
params: { pagination: JSON.stringify(paginationObj) },
});
}
/**
* Fetch transaction history (legacy array window)
* Endpoint: GET /api/billing/transactions
*/
export async function fetchTransactions(
request: ApiRequestFunction,
limit: number = 50,
offset: number = 0
): Promise<BillingTransaction[]> {
return await request({
url: '/api/billing/transactions',
method: 'get',
params: { limit, offset }
});
}
/** /**
* Fetch usage statistics for an explicit date range. * Fetch usage statistics for an explicit date range.
* Endpoint: GET /api/billing/statistics * Endpoint: GET /api/billing/statistics
@ -406,51 +343,7 @@ export async function fetchMandateViewTransactions(
}); });
} }
// ============================================================================
// USER VIEW TYPES & API FUNCTIONS
// ============================================================================
export interface UserBalance {
accountId: string;
mandateId: string;
mandateName: string;
userId: string;
userName: string;
balance: number;
warningThreshold: number;
isWarning: boolean;
enabled: boolean;
}
export interface UserTransaction extends BillingTransaction { export interface UserTransaction extends BillingTransaction {
userId?: string; userId?: string;
userName?: string; userName?: string;
} }
/**
* Fetch user-level balances (RBAC-based)
* Endpoint: GET /api/billing/view/users/balances
*/
export async function fetchUserViewBalances(
request: ApiRequestFunction
): Promise<UserBalance[]> {
return await request({
url: '/api/billing/view/users/balances',
method: 'get'
});
}
/**
* Fetch user-level transactions (RBAC-based)
* Endpoint: GET /api/billing/view/users/transactions
*/
export async function fetchUserViewTransactions(
request: ApiRequestFunction,
limit: number = 100
): Promise<UserTransaction[]> {
return await request({
url: '/api/billing/view/users/transactions',
method: 'get',
params: { limit }
});
}

View file

@ -11,7 +11,6 @@ import api from '../api';
import type { import type {
FeaturesMyResponse, FeaturesMyResponse,
Mandate, Mandate,
MandateFeature,
FeatureInstance, FeatureInstance,
InstancePermissions, InstancePermissions,
AccessLevel, AccessLevel,
@ -156,22 +155,6 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
} }
} }
/**
* Lädt die verfügbaren Features (für Admin - Feature-Instanz erstellen)
*
* Endpoint: GET /api/features/available
*/
export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
if (USE_MOCK) {
return [
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
];
}
const response = await api.get<MandateFeature[]>('/api/features/available');
return response.data;
}
// ============================================================================= // =============================================================================
// TYPE GUARDS // TYPE GUARDS
// ============================================================================= // =============================================================================

View file

@ -12,8 +12,6 @@ import { useApiRequest } from './useApi';
import { import {
fetchBalances, fetchBalances,
fetchBalanceForMandate, fetchBalanceForMandate,
fetchTransactions,
fetchTransactionsPaginated,
fetchStatistics, fetchStatistics,
fetchAllowedProviders, fetchAllowedProviders,
fetchSettingsAdmin, fetchSettingsAdmin,
@ -34,9 +32,7 @@ import {
type MandateUserSummary, type MandateUserSummary,
type StatisticsRangeRequest, type StatisticsRangeRequest,
type BillingBucketSize, type BillingBucketSize,
type BillingTransactionsPaginationParams,
} from '../api/billingApi'; } from '../api/billingApi';
import type { GroupLayout } from '../api/connectionApi';
// Re-export types // Re-export types
export type { export type {
@ -52,25 +48,13 @@ export type {
BillingBucketSize, BillingBucketSize,
}; };
export type { TransactionType, ReferenceType, BillingTransactionsPaginationParams } from '../api/billingApi'; export type { TransactionType, ReferenceType } from '../api/billingApi';
/** /**
* Hook for user billing operations * Hook for user billing operations
*/ */
export function useBilling() { export function useBilling() {
const [balances, setBalances] = useState<BillingBalance[]>([]); const [balances, setBalances] = useState<BillingBalance[]>([]);
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
const [transactionsPagination, setTransactionsPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const [transactionsGroupLayout, setTransactionsGroupLayout] = useState<GroupLayout | null>(null);
const [transactionsAppliedView, setTransactionsAppliedView] = useState<{
viewKey?: string;
displayName?: string;
} | null>(null);
const [statistics, setStatistics] = useState<UsageReport | null>(null); const [statistics, setStatistics] = useState<UsageReport | null>(null);
const [allowedProviders, setAllowedProviders] = useState<string[]>([]); const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
const { request, isLoading: loading, error } = useApiRequest(); const { request, isLoading: loading, error } = useApiRequest();
@ -98,43 +82,6 @@ export function useBilling() {
} }
}, [request]); }, [request]);
// Fetch transactions
const loadTransactions = useCallback(async (limit: number = 50, offset: number = 0) => {
try {
const data = await fetchTransactions(request, limit, offset);
setTransactions(Array.isArray(data) ? data : []);
setTransactionsPagination(null);
setTransactionsGroupLayout(null);
setTransactionsAppliedView(null);
return data;
} catch (err) {
console.error('Error loading transactions:', err);
setTransactions([]);
setTransactionsPagination(null);
setTransactionsGroupLayout(null);
setTransactionsAppliedView(null);
return [];
}
}, [request]);
const refetchTransactions = useCallback(async (params?: BillingTransactionsPaginationParams) => {
try {
const data = await fetchTransactionsPaginated(request, params);
setTransactions(Array.isArray(data.items) ? data.items : []);
setTransactionsPagination(data.pagination ?? null);
setTransactionsGroupLayout(data.groupLayout ?? null);
setTransactionsAppliedView(data.appliedView ?? null);
return data;
} catch (err) {
console.error('Error loading transactions:', err);
setTransactions([]);
setTransactionsPagination(null);
setTransactionsGroupLayout(null);
setTransactionsAppliedView(null);
return null;
}
}, [request]);
const loadStatistics = useCallback(async (range: StatisticsRangeRequest) => { const loadStatistics = useCallback(async (range: StatisticsRangeRequest) => {
try { try {
const data = await fetchStatistics(request, range); const data = await fetchStatistics(request, range);
@ -168,18 +115,12 @@ export function useBilling() {
return { return {
balances, balances,
transactions,
transactionsPagination,
transactionsGroupLayout,
transactionsAppliedView,
statistics, statistics,
allowedProviders, allowedProviders,
loading, loading,
error, error,
loadBalances, loadBalances,
loadBalanceForMandate, loadBalanceForMandate,
loadTransactions,
refetchTransactions,
loadStatistics, loadStatistics,
loadAllowedProviders, loadAllowedProviders,
refetch: loadBalances, refetch: loadBalances,

View file

@ -6,7 +6,8 @@
* Admin page for viewing and managing active sessions and trusted devices per user. * Admin page for viewing and managing active sessions and trusted devices per user.
*/ */
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import api from '../../api'; import api from '../../api';
import { StackLayout } from '../../components/Layout/StackLayout'; import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel'; import { Panel } from '../../components/Layout/Panel';
@ -33,7 +34,8 @@ interface TrustedDeviceEntry {
export const AdminSessionsPage: React.FC = () => { export const AdminSessionsPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const [userId, setUserId] = useState(''); const [searchParams] = useSearchParams();
const [userId, setUserId] = useState(searchParams.get('userId') || '');
const [sessions, setSessions] = useState<SessionEntry[]>([]); const [sessions, setSessions] = useState<SessionEntry[]>([]);
const [trustedDevices, setTrustedDevices] = useState<TrustedDeviceEntry[]>([]); const [trustedDevices, setTrustedDevices] = useState<TrustedDeviceEntry[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -86,6 +88,21 @@ export const AdminSessionsPage: React.FC = () => {
} }
}, [userId]); }, [userId]);
const revokeTrustedDevice = useCallback(async (deviceId: string) => {
try {
await api.delete(`/api/admin/trusted-devices/${deviceId}`);
setTrustedDevices(prev => prev.filter(d => d.id !== deviceId));
} catch (e: any) {
setError(e.response?.data?.detail || 'Failed to revoke device');
}
}, []);
useEffect(() => {
if (searchParams.get('userId') && userId) {
loadData();
}
}, []);
const formatTimestamp = (ts: number) => { const formatTimestamp = (ts: number) => {
if (!ts) return '-'; if (!ts) return '-';
return new Date(ts * 1000).toLocaleString(); return new Date(ts * 1000).toLocaleString();
@ -175,21 +192,27 @@ export const AdminSessionsPage: React.FC = () => {
<th style={{ textAlign: 'left', padding: '0.5rem', borderBottom: '1px solid var(--border-color)' }}>IP</th> <th style={{ textAlign: 'left', padding: '0.5rem', borderBottom: '1px solid var(--border-color)' }}>IP</th>
<th style={{ textAlign: 'left', padding: '0.5rem', borderBottom: '1px solid var(--border-color)' }}>Trusted Until</th> <th style={{ textAlign: 'left', padding: '0.5rem', borderBottom: '1px solid var(--border-color)' }}>Trusted Until</th>
<th style={{ textAlign: 'left', padding: '0.5rem', borderBottom: '1px solid var(--border-color)' }}>Status</th> <th style={{ textAlign: 'left', padding: '0.5rem', borderBottom: '1px solid var(--border-color)' }}>Status</th>
<th style={{ padding: '0.5rem', borderBottom: '1px solid var(--border-color)' }}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{trustedDevices.map((d, i) => ( {trustedDevices.map((d) => (
<tr key={i}> <tr key={d.id}>
<td style={{ padding: '0.5rem', borderBottom: '1px solid var(--border-color-light)' }}> <td style={{ padding: '0.5rem', borderBottom: '1px solid var(--border-color-light)' }}>
<span title={d.userAgent || ''}>{d.id}</span> <span title={d.userAgent || ''}><code>{d.id.slice(0, 8)}...</code></span>
</td> </td>
<td style={{ padding: '0.5rem', borderBottom: '1px solid var(--border-color-light)' }}>{d.ipAddress || '-'}</td> <td style={{ padding: '0.5rem', borderBottom: '1px solid var(--border-color-light)' }}>{d.ipAddress || '-'}</td>
<td style={{ padding: '0.5rem', borderBottom: '1px solid var(--border-color-light)' }}>{formatTimestamp(d.trustedUntil)}</td> <td style={{ padding: '0.5rem', borderBottom: '1px solid var(--border-color-light)' }}>{formatTimestamp(d.trustedUntil)}</td>
<td style={{ padding: '0.5rem', borderBottom: '1px solid var(--border-color-light)' }}> <td style={{ padding: '0.5rem', borderBottom: '1px solid var(--border-color-light)' }}>
<span style={{ color: d.isExpired ? 'var(--color-error)' : 'var(--color-success)' }}> <span style={{ color: d.isExpired ? 'var(--color-error)' : 'var(--color-success)' }}>
{d.isExpired ? 'Expired' : 'Active'} {d.isExpired ? t('Abgelaufen') : t('Aktiv')}
</span> </span>
</td> </td>
<td style={{ padding: '0.5rem', borderBottom: '1px solid var(--border-color-light)', textAlign: 'right' }}>
<button onClick={() => revokeTrustedDevice(d.id)} className={styles.dangerButton} style={{ fontSize: '0.75rem' }}>
<FaTrash />
</button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View file

@ -11,7 +11,7 @@ import { useNavigate } from 'react-router-dom';
import { useOrgUsers, useUserOperations } from '../../hooks/useUsers'; import { useOrgUsers, useUserOperations } from '../../hooks/useUsers';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
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, FaDesktop } from 'react-icons/fa';
import { StackLayout } from '../../components/Layout/StackLayout'; import { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel'; import { Panel } from '../../components/Layout/Panel';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
@ -211,6 +211,13 @@ export const AdminUsersPage: React.FC = () => {
> >
<FaEnvelopeOpenText /> {t('Einladungen')} <FaEnvelopeOpenText /> {t('Einladungen')}
</button> </button>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/sessions')}
>
<FaDesktop /> {t('Sessions')}
</button>
<button <button
className={styles.secondaryButton} className={styles.secondaryButton}
onClick={() => _tableRefetch()} onClick={() => _tableRefetch()}
@ -252,15 +259,21 @@ export const AdminUsersPage: React.FC = () => {
title: t('Löschen'), title: t('Löschen'),
}] : []), }] : []),
]} ]}
customActions={canUpdate ? [ customActions={[
{ ...(canUpdate ? [{
id: 'sendPasswordLink', id: 'sendPasswordLink',
icon: <FaKey />, icon: <FaKey />,
onClick: handleSendPassword, onClick: handleSendPassword,
title: t('Passwort-Link senden'), title: t('Passwort-Link senden'),
loading: (row: User) => sendingPasswordLinkState.has(row.id), loading: (row: User) => sendingPasswordLinkState.has(row.id),
} }] : []),
] : []} {
id: 'viewSessions',
icon: <FaDesktop />,
onClick: (row: User) => navigate(`/admin/sessions?userId=${row.id}`),
title: t('Sessions anzeigen'),
},
]}
onDelete={handleDeleteUser} onDelete={handleDeleteUser}
hookData={{ hookData={{
refetch: _tableRefetch, refetch: _tableRefetch,