int #6
5 changed files with 48 additions and 195 deletions
|
|
@ -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 }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue