mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 15:29:34 +01:00
feat(frontend): TypeScript-Monorepo mit Terminal-UI für Effigenix ERP
Monorepo-Setup (pnpm workspaces) mit vier shared Packages und einer TUI-App: Shared Packages: - @effigenix/types: TypeScript-DTOs (UserDTO, RoleDTO, AuthDTO, Enums) - @effigenix/config: API-Konfiguration und Shared Constants - @effigenix/validation: Zod-Schemas für Username, E-Mail und Passwort - @effigenix/api-client: axios-Client mit JWT-Handling (proaktiver + reaktiver Token-Refresh), AuthInterceptor, ErrorInterceptor, Resources für auth/users/roles TUI (apps/cli, Ink 5 / React): - Authentication: Login/Logout, Session-Restore beim Start, JWT-Refresh - User Management: Liste, Anlage (Zod-Inline-Validation), Detailansicht, Passwort ändern, Sperren/Entsperren mit ConfirmDialog - Role Management: Liste, Detailansicht, Zuweisen/Entfernen per RoleSelectList (↑↓) - UX: SuccessDisplay (Auto-Dismiss 3 s), ConfirmDialog (J/N), FormInput mit Inline-Fehlern, StatusBar mit API-URL - Layout: Fullscreen-Modus (alternate screen buffer), Header mit eingeloggtem User - Tests: vitest + ink-testing-library (15 Tests)
This commit is contained in:
parent
87123df2e4
commit
bbe9e87c33
65 changed files with 6955 additions and 1 deletions
74
frontend/apps/cli/src/components/MainMenu.tsx
Normal file
74
frontend/apps/cli/src/components/MainMenu.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { useAuth } from '../state/auth-context.js';
|
||||
import { useNavigation } from '../state/navigation-context.js';
|
||||
import type { Screen } from '../state/navigation-context.js';
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
screen?: Screen;
|
||||
action?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function MainMenu() {
|
||||
const { user, logout } = useAuth();
|
||||
const { navigate } = useNavigation();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{ label: 'Benutzer verwalten', screen: 'user-list' },
|
||||
{ label: 'Rollen anzeigen', screen: 'role-list' },
|
||||
{ label: 'Abmelden', action: () => void logout() },
|
||||
];
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : items.length - 1));
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((i) => (i < items.length - 1 ? i + 1 : 0));
|
||||
}
|
||||
if (key.return) {
|
||||
const item = items[selectedIndex];
|
||||
if (item) {
|
||||
if (item.screen) {
|
||||
navigate(item.screen);
|
||||
} else if (item.action) {
|
||||
void item.action();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingY={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text color="green">Willkommen, </Text>
|
||||
<Text color="green" bold>
|
||||
{user?.username}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1} width={40}>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>Hauptmenü</Text>
|
||||
</Box>
|
||||
|
||||
{items.map((item, index) => (
|
||||
<Box key={item.label}>
|
||||
<Text color={index === selectedIndex ? 'cyan' : 'white'}>
|
||||
{index === selectedIndex ? '▶ ' : ' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
↑↓ navigieren · Enter auswählen
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
109
frontend/apps/cli/src/components/auth/LoginScreen.tsx
Normal file
109
frontend/apps/cli/src/components/auth/LoginScreen.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import { useAuth } from '../../state/auth-context.js';
|
||||
import { useNavigation } from '../../state/navigation-context.js';
|
||||
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
|
||||
|
||||
type Field = 'username' | 'password';
|
||||
|
||||
export function LoginScreen() {
|
||||
const { login, loading, error, clearError } = useAuth();
|
||||
const { navigate } = useNavigation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [activeField, setActiveField] = useState<Field>('username');
|
||||
|
||||
useInput((input, key) => {
|
||||
if (loading) return;
|
||||
|
||||
if (key.tab || key.downArrow) {
|
||||
setActiveField((f) => (f === 'username' ? 'password' : 'username'));
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setActiveField((f) => (f === 'password' ? 'username' : 'password'));
|
||||
}
|
||||
if (key.escape && error) {
|
||||
clearError();
|
||||
}
|
||||
void input; // suppress unused warning
|
||||
});
|
||||
|
||||
const handleUsernameSubmit = (value: string) => {
|
||||
setUsername(value);
|
||||
setActiveField('password');
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async (value: string) => {
|
||||
setPassword(value);
|
||||
if (!username.trim() || !value.trim()) return;
|
||||
const success = await login(username, value);
|
||||
if (success) {
|
||||
navigate('main-menu');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center" paddingY={4}>
|
||||
<LoadingSpinner label="Anmelden..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center" paddingY={2}>
|
||||
<Box flexDirection="column" width={50} borderStyle="round" borderColor="cyan" paddingX={2} paddingY={1}>
|
||||
<Box marginBottom={1} justifyContent="center">
|
||||
<Text color="cyan" bold>
|
||||
Effigenix ERP – Anmeldung
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Box marginBottom={1}>
|
||||
<ErrorDisplay message={error} onDismiss={clearError} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text color={activeField === 'username' ? 'cyan' : 'gray'}>Benutzername</Text>
|
||||
<Box>
|
||||
<Text color="gray"> › </Text>
|
||||
<TextInput
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
onSubmit={handleUsernameSubmit}
|
||||
focus={activeField === 'username'}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color={activeField === 'password' ? 'cyan' : 'gray'}>Passwort</Text>
|
||||
<Box>
|
||||
<Text color="gray"> › </Text>
|
||||
<TextInput
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
onSubmit={(v) => void handlePasswordSubmit(v)}
|
||||
focus={activeField === 'password'}
|
||||
mask="*"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
Tab/↑↓ wechseln · Enter bestätigen
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
26
frontend/apps/cli/src/components/layout/Header.tsx
Normal file
26
frontend/apps/cli/src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useAuth } from '../../state/auth-context.js';
|
||||
|
||||
export function Header() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="double"
|
||||
borderColor="cyan"
|
||||
paddingX={2}
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
>
|
||||
<Text color="cyan" bold>
|
||||
Effigenix ERP
|
||||
</Text>
|
||||
{user && (
|
||||
<Text color="gray">
|
||||
»{' '}<Text color="white">{user.username}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
35
frontend/apps/cli/src/components/layout/MainLayout.tsx
Normal file
35
frontend/apps/cli/src/components/layout/MainLayout.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { Header } from './Header.js';
|
||||
import { StatusBar } from './StatusBar.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
|
||||
// Header: double border (top + bottom) + 1 content line = 3 rows
|
||||
const HEADER_HEIGHT = 3;
|
||||
const STATUSBAR_HEIGHT = 1;
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode;
|
||||
showHeader?: boolean;
|
||||
}
|
||||
|
||||
export function MainLayout({ children, showHeader = true }: MainLayoutProps) {
|
||||
const { columns, rows } = useTerminalSize();
|
||||
const contentHeight = rows - (showHeader ? HEADER_HEIGHT : 0) - STATUSBAR_HEIGHT;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={columns} height={rows}>
|
||||
{showHeader && <Header />}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
height={contentHeight}
|
||||
overflow="hidden"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
<StatusBar />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
13
frontend/apps/cli/src/components/layout/StatusBar.tsx
Normal file
13
frontend/apps/cli/src/components/layout/StatusBar.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { DEFAULT_API_CONFIG } from '@effigenix/config';
|
||||
|
||||
export function StatusBar() {
|
||||
return (
|
||||
<Box paddingX={2}>
|
||||
<Text color="gray" dimColor>
|
||||
API: {DEFAULT_API_CONFIG.baseUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
114
frontend/apps/cli/src/components/roles/RoleDetailScreen.tsx
Normal file
114
frontend/apps/cli/src/components/roles/RoleDetailScreen.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import type { RoleDTO } from '@effigenix/api-client';
|
||||
import { useNavigation } from '../../state/navigation-context.js';
|
||||
import { client } from '../../utils/api-client.js';
|
||||
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
|
||||
|
||||
export function RoleDetailScreen() {
|
||||
const { params, back } = useNavigation();
|
||||
const roleId = params['roleId'] ?? '';
|
||||
|
||||
const [role, setRole] = useState<RoleDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roleId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
client.roles
|
||||
.list()
|
||||
.then((roles) => {
|
||||
const found = roles.find((r) => r.id === roleId) ?? null;
|
||||
setRole(found);
|
||||
if (!found) setError('Rolle nicht gefunden.');
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [roleId]);
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (loading) return;
|
||||
if (key.backspace || key.escape) {
|
||||
back();
|
||||
}
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner label="Lade Rollendetails..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorDisplay message={error} onDismiss={() => back()} />;
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
return <Text color="red">Rolle nicht gefunden.</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>
|
||||
Rollendetails
|
||||
</Text>
|
||||
|
||||
{/* Role Info */}
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1}>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">ID:</Text>
|
||||
<Text>{role.id}</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Name:</Text>
|
||||
<Text bold>{role.name}</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Berechtigungen:</Text>
|
||||
<Text>{role.permissions.length}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Permissions List */}
|
||||
{role.permissions.length > 0 && (
|
||||
<Box flexDirection="column" gap={0}>
|
||||
<Text color="gray" bold>
|
||||
Berechtigungen:
|
||||
</Text>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
>
|
||||
{role.permissions.map((perm) => (
|
||||
<Box key={perm}>
|
||||
<Text color="white">• {perm}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{role.permissions.length === 0 && (
|
||||
<Text color="gray" dimColor>
|
||||
Keine Berechtigungen zugewiesen.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
Backspace / Escape Zurück
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
66
frontend/apps/cli/src/components/roles/RoleListScreen.tsx
Normal file
66
frontend/apps/cli/src/components/roles/RoleListScreen.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { useNavigation } from '../../state/navigation-context.js';
|
||||
import { useRoles } from '../../hooks/useRoles.js';
|
||||
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
|
||||
import { RoleTable } from './RoleTable.js';
|
||||
|
||||
export function RoleListScreen() {
|
||||
const { navigate, back } = useNavigation();
|
||||
const { roles, loading, error, fetchRoles, clearError } = useRoles();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchRoles();
|
||||
}, [fetchRoles]);
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (loading) return;
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((i) => Math.min(roles.length - 1, i + 1));
|
||||
}
|
||||
if (key.return && roles.length > 0) {
|
||||
const role = roles[selectedIndex];
|
||||
if (role) {
|
||||
navigate('role-detail', { roleId: role.id });
|
||||
}
|
||||
}
|
||||
if (key.backspace || key.escape) {
|
||||
back();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text color="cyan" bold>
|
||||
Rollenverwaltung
|
||||
</Text>
|
||||
<Text color="gray" dimColor>
|
||||
{' '}– {roles.length} Rollen
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{loading && <LoadingSpinner label="Lade Rollen..." />}
|
||||
|
||||
{error && !loading && (
|
||||
<ErrorDisplay message={error} onDismiss={clearError} />
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<RoleTable roles={roles} selectedIndex={selectedIndex} />
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
↑↓ navigieren · Enter Details · Backspace Zurück
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
54
frontend/apps/cli/src/components/roles/RoleSelectList.tsx
Normal file
54
frontend/apps/cli/src/components/roles/RoleSelectList.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import type { RoleDTO } from '@effigenix/api-client';
|
||||
|
||||
interface RoleSelectListProps {
|
||||
roles: RoleDTO[];
|
||||
label: string;
|
||||
onSelect: (role: RoleDTO) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function RoleSelectList({ roles, label, onSelect, onCancel }: RoleSelectListProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (roles.length === 0) {
|
||||
if (key.escape) onCancel();
|
||||
return;
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedIndex((i) => Math.min(roles.length - 1, i + 1));
|
||||
} else if (key.return) {
|
||||
const role = roles[selectedIndex];
|
||||
if (role !== undefined) onSelect(role);
|
||||
} else if (key.escape) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
||||
<Text color="cyan" bold>
|
||||
{label}
|
||||
</Text>
|
||||
{roles.length === 0 ? (
|
||||
<Text color="gray" dimColor>
|
||||
Keine Rollen verfügbar.
|
||||
</Text>
|
||||
) : (
|
||||
roles.map((role, index) => (
|
||||
<Text key={role.id} color={index === selectedIndex ? 'cyan' : 'white'}>
|
||||
{index === selectedIndex ? '▶ ' : ' '}
|
||||
{role.name}
|
||||
</Text>
|
||||
))
|
||||
)}
|
||||
<Text color="gray" dimColor>
|
||||
↑↓ navigieren · Enter auswählen · Escape abbrechen
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
50
frontend/apps/cli/src/components/roles/RoleTable.tsx
Normal file
50
frontend/apps/cli/src/components/roles/RoleTable.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { RoleDTO } from '@effigenix/api-client';
|
||||
|
||||
interface RoleTableProps {
|
||||
roles: RoleDTO[];
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
export function RoleTable({ roles, selectedIndex }: RoleTableProps) {
|
||||
if (roles.length === 0) {
|
||||
return <Text color="gray">Keine Rollen vorhanden.</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Header */}
|
||||
<Box>
|
||||
<Text color="gray" bold>
|
||||
{' '}
|
||||
{'#'.padEnd(3)}
|
||||
{'Rollenname'.padEnd(25)}
|
||||
{'Berechtigungen'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="gray">{'─'.repeat(70)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Rows */}
|
||||
{roles.map((role, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const rowColor = isSelected ? 'cyan' : 'white';
|
||||
const prefix = isSelected ? '▶ ' : ' ';
|
||||
const permCount = `${role.permissions.length} Berecht.`;
|
||||
|
||||
return (
|
||||
<Box key={role.id}>
|
||||
<Text color={rowColor}>
|
||||
{prefix}
|
||||
{String(index + 1).padEnd(3)}
|
||||
{role.name.padEnd(25)}
|
||||
{permCount}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
30
frontend/apps/cli/src/components/shared/ConfirmDialog.tsx
Normal file
30
frontend/apps/cli/src/components/shared/ConfirmDialog.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ message, onConfirm, onCancel }: ConfirmDialogProps) {
|
||||
useInput((input, key) => {
|
||||
if (input === 'y' || input === 'Y' || input === 'j' || input === 'J') {
|
||||
onConfirm();
|
||||
} else if (input === 'n' || input === 'N' || key.escape) {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1} paddingY={0}>
|
||||
<Text color="yellow" bold>
|
||||
Bestätigung erforderlich
|
||||
</Text>
|
||||
<Text>{message}</Text>
|
||||
<Text color="gray" dimColor>
|
||||
[J] Ja [N] Nein
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
23
frontend/apps/cli/src/components/shared/ErrorDisplay.tsx
Normal file
23
frontend/apps/cli/src/components/shared/ErrorDisplay.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
message: string;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function ErrorDisplay({ message, onDismiss }: ErrorDisplayProps) {
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="red" paddingX={1}>
|
||||
<Text color="red" bold>
|
||||
Fehler
|
||||
</Text>
|
||||
<Text color="red">{message}</Text>
|
||||
{onDismiss && (
|
||||
<Text color="gray" dimColor>
|
||||
[Enter] Schließen
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
34
frontend/apps/cli/src/components/shared/FormInput.tsx
Normal file
34
frontend/apps/cli/src/components/shared/FormInput.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
|
||||
interface FormInputProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit?: (value: string) => void;
|
||||
focus: boolean;
|
||||
placeholder?: string;
|
||||
mask?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function FormInput({ label, value, onChange, onSubmit, focus, placeholder, mask, error }: FormInputProps) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={focus ? 'cyan' : 'gray'}>{label}</Text>
|
||||
<Box>
|
||||
<Text color="gray"> › </Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...(onSubmit !== undefined ? { onSubmit } : {})}
|
||||
focus={focus}
|
||||
{...(placeholder !== undefined ? { placeholder } : {})}
|
||||
{...(mask !== undefined ? { mask } : {})}
|
||||
/>
|
||||
</Box>
|
||||
{error !== undefined && <Text color="red">⚠ {error}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
25
frontend/apps/cli/src/components/shared/LoadingSpinner.tsx
Normal file
25
frontend/apps/cli/src/components/shared/LoadingSpinner.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
|
||||
export function LoadingSpinner({ label = 'Laden...' }: LoadingSpinnerProps) {
|
||||
const [frame, setFrame] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setFrame((f) => (f + 1) % FRAMES.length);
|
||||
}, 80);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Text color="cyan">
|
||||
{FRAMES[frame]} {label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
26
frontend/apps/cli/src/components/shared/SuccessDisplay.tsx
Normal file
26
frontend/apps/cli/src/components/shared/SuccessDisplay.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
interface SuccessDisplayProps {
|
||||
message: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function SuccessDisplay({ message, onDismiss }: SuccessDisplayProps) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onDismiss, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onDismiss]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
|
||||
<Text color="green" bold>
|
||||
Erfolg
|
||||
</Text>
|
||||
<Text color="green">{message}</Text>
|
||||
<Text color="gray" dimColor>
|
||||
[Enter] Schließen (auto-dismiss nach 3 s)
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
153
frontend/apps/cli/src/components/users/ChangePasswordScreen.tsx
Normal file
153
frontend/apps/cli/src/components/users/ChangePasswordScreen.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { useNavigation } from '../../state/navigation-context.js';
|
||||
import { client } from '../../utils/api-client.js';
|
||||
import { FormInput } from '../shared/FormInput.js';
|
||||
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
|
||||
import { SuccessDisplay } from '../shared/SuccessDisplay.js';
|
||||
import { passwordSchema } from '@effigenix/validation';
|
||||
|
||||
type Field = 'currentPassword' | 'newPassword' | 'confirmPassword';
|
||||
const FIELDS: Field[] = ['currentPassword', 'newPassword', 'confirmPassword'];
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
}
|
||||
|
||||
export function ChangePasswordScreen() {
|
||||
const { params, back } = useNavigation();
|
||||
const userId = params['userId'] ?? '';
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [activeField, setActiveField] = useState<Field>('currentPassword');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (loading) return;
|
||||
if (key.tab || key.downArrow) {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[(idx + 1) % FIELDS.length] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.escape) {
|
||||
back();
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!currentPassword.trim() || !newPassword.trim() || !confirmPassword.trim()) {
|
||||
setError('Alle Felder sind Pflichtfelder.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Das neue Passwort und die Bestätigung stimmen nicht überein.');
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordResult = passwordSchema.safeParse(newPassword.trim());
|
||||
if (!passwordResult.success) {
|
||||
setError(passwordResult.error.errors[0]?.message ?? 'Ungültiges Passwort');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await client.users.changePassword(userId, {
|
||||
currentPassword: currentPassword.trim(),
|
||||
newPassword: newPassword.trim(),
|
||||
});
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldSubmit = (field: Field) => (_value: string) => {
|
||||
const idx = FIELDS.indexOf(field);
|
||||
if (idx < FIELDS.length - 1) {
|
||||
setActiveField(FIELDS[idx + 1] ?? field);
|
||||
} else {
|
||||
void handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center" paddingY={2}>
|
||||
<LoadingSpinner label="Passwort wird geändert..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center" paddingY={2}>
|
||||
<SuccessDisplay message="Passwort erfolgreich geändert." onDismiss={() => back()} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>
|
||||
Passwort ändern
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<ErrorDisplay message={error} onDismiss={() => setError(null)} />
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" gap={1} width={60}>
|
||||
<FormInput
|
||||
label="Aktuelles Passwort *"
|
||||
value={currentPassword}
|
||||
onChange={setCurrentPassword}
|
||||
onSubmit={handleFieldSubmit('currentPassword')}
|
||||
focus={activeField === 'currentPassword'}
|
||||
mask="*"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<FormInput
|
||||
label="Neues Passwort * (min. 8 Zeichen)"
|
||||
value={newPassword}
|
||||
onChange={setNewPassword}
|
||||
onSubmit={handleFieldSubmit('newPassword')}
|
||||
focus={activeField === 'newPassword'}
|
||||
mask="*"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<FormInput
|
||||
label="Neues Passwort bestätigen *"
|
||||
value={confirmPassword}
|
||||
onChange={setConfirmPassword}
|
||||
onSubmit={handleFieldSubmit('confirmPassword')}
|
||||
focus={activeField === 'confirmPassword'}
|
||||
mask="*"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
Tab/↑↓ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
162
frontend/apps/cli/src/components/users/UserCreateScreen.tsx
Normal file
162
frontend/apps/cli/src/components/users/UserCreateScreen.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { useNavigation } from '../../state/navigation-context.js';
|
||||
import { useUsers } from '../../hooks/useUsers.js';
|
||||
import { FormInput } from '../shared/FormInput.js';
|
||||
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
|
||||
import { usernameSchema, emailSchema, passwordSchema } from '@effigenix/validation';
|
||||
|
||||
type Field = 'username' | 'email' | 'password' | 'roleName';
|
||||
const FIELDS: Field[] = ['username', 'email', 'password', 'roleName'];
|
||||
|
||||
export function UserCreateScreen() {
|
||||
const { navigate, back } = useNavigation();
|
||||
const { createUser, loading, error, clearError } = useUsers();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [roleName, setRoleName] = useState('');
|
||||
const [activeField, setActiveField] = useState<Field>('username');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [usernameError, setUsernameError] = useState<string | null>(null);
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (loading) return;
|
||||
if (key.tab || key.downArrow) {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[(idx + 1) % FIELDS.length] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.backspace && !key.meta) {
|
||||
// Only go back if no text input is focused and the fields are empty
|
||||
}
|
||||
if (key.escape) {
|
||||
back();
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setValidationError(null);
|
||||
setUsernameError(null);
|
||||
setEmailError(null);
|
||||
setPasswordError(null);
|
||||
|
||||
const usernameResult = usernameSchema.safeParse(username.trim());
|
||||
const emailResult = emailSchema.safeParse(email.trim());
|
||||
const passwordResult = passwordSchema.safeParse(password.trim());
|
||||
|
||||
let hasError = false;
|
||||
if (!usernameResult.success) {
|
||||
setUsernameError(usernameResult.error.errors[0]?.message ?? 'Ungültiger Benutzername');
|
||||
hasError = true;
|
||||
}
|
||||
if (!emailResult.success) {
|
||||
setEmailError(emailResult.error.errors[0]?.message ?? 'Ungültige E-Mail');
|
||||
hasError = true;
|
||||
}
|
||||
if (!passwordResult.success) {
|
||||
setPasswordError(passwordResult.error.errors[0]?.message ?? 'Ungültiges Passwort');
|
||||
hasError = true;
|
||||
}
|
||||
if (hasError) return;
|
||||
|
||||
const user = await createUser(username.trim(), email.trim(), password.trim(), roleName.trim() || undefined);
|
||||
if (user) {
|
||||
navigate('user-list');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldSubmit = (field: Field) => (value: string) => {
|
||||
const idx = FIELDS.indexOf(field);
|
||||
if (idx < FIELDS.length - 1) {
|
||||
setActiveField(FIELDS[idx + 1] ?? field);
|
||||
} else {
|
||||
void handleSubmit();
|
||||
}
|
||||
void value;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center" paddingY={2}>
|
||||
<LoadingSpinner label="Benutzer wird angelegt..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const displayError = validationError ?? error;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>
|
||||
Neuen Benutzer anlegen
|
||||
</Text>
|
||||
|
||||
{displayError && (
|
||||
<ErrorDisplay
|
||||
message={displayError}
|
||||
onDismiss={() => {
|
||||
setValidationError(null);
|
||||
clearError();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box flexDirection="column" gap={1} width={60}>
|
||||
<FormInput
|
||||
label="Benutzername *"
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
onSubmit={handleFieldSubmit('username')}
|
||||
focus={activeField === 'username'}
|
||||
placeholder="admin"
|
||||
{...(usernameError !== null ? { error: usernameError } : {})}
|
||||
/>
|
||||
<FormInput
|
||||
label="E-Mail *"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
onSubmit={handleFieldSubmit('email')}
|
||||
focus={activeField === 'email'}
|
||||
placeholder="admin@example.com"
|
||||
{...(emailError !== null ? { error: emailError } : {})}
|
||||
/>
|
||||
<FormInput
|
||||
label="Passwort *"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
onSubmit={handleFieldSubmit('password')}
|
||||
focus={activeField === 'password'}
|
||||
mask="*"
|
||||
placeholder="••••••••"
|
||||
{...(passwordError !== null ? { error: passwordError } : {})}
|
||||
/>
|
||||
<FormInput
|
||||
label="Rolle (optional)"
|
||||
value={roleName}
|
||||
onChange={setRoleName}
|
||||
onSubmit={handleFieldSubmit('roleName')}
|
||||
focus={activeField === 'roleName'}
|
||||
placeholder="ADMIN"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
Tab/↑↓ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
271
frontend/apps/cli/src/components/users/UserDetailScreen.tsx
Normal file
271
frontend/apps/cli/src/components/users/UserDetailScreen.tsx
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import type { RoleDTO, UserDTO } from '@effigenix/api-client';
|
||||
import { useNavigation } from '../../state/navigation-context.js';
|
||||
import { client } from '../../utils/api-client.js';
|
||||
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
|
||||
import { SuccessDisplay } from '../shared/SuccessDisplay.js';
|
||||
import { ConfirmDialog } from '../shared/ConfirmDialog.js';
|
||||
import { RoleSelectList } from '../roles/RoleSelectList.js';
|
||||
|
||||
type MenuAction = 'toggle-lock' | 'assign-role' | 'remove-role' | 'change-password' | 'back';
|
||||
type Mode = 'menu' | 'confirm-lock' | 'assign-role' | 'remove-role';
|
||||
|
||||
interface MenuItem {
|
||||
id: MenuAction;
|
||||
label: (user: UserDTO) => string;
|
||||
}
|
||||
|
||||
const MENU_ITEMS: MenuItem[] = [
|
||||
{ id: 'toggle-lock', label: (u) => (u.status === 'ACTIVE' ? '[Sperren]' : '[Entsperren]') },
|
||||
{ id: 'assign-role', label: () => '[Rolle zuweisen]' },
|
||||
{ id: 'remove-role', label: () => '[Rolle entfernen]' },
|
||||
{ id: 'change-password', label: () => '[Passwort ändern]' },
|
||||
{ id: 'back', label: () => '[Zurück]' },
|
||||
];
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
}
|
||||
|
||||
export function UserDetailScreen() {
|
||||
const { params, navigate, back } = useNavigation();
|
||||
const userId = params['userId'] ?? '';
|
||||
|
||||
const [user, setUser] = useState<UserDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedAction, setSelectedAction] = useState(0);
|
||||
const [mode, setMode] = useState<Mode>('menu');
|
||||
const [availableRoles, setAvailableRoles] = useState<RoleDTO[]>([]);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const loadUser = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const u = await client.users.getById(userId);
|
||||
setUser(u);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
void loadUser();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId]);
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (loading || actionLoading || mode !== 'menu') return;
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedAction((i) => Math.max(0, i - 1));
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedAction((i) => Math.min(MENU_ITEMS.length - 1, i + 1));
|
||||
}
|
||||
if (key.return) {
|
||||
void handleAction();
|
||||
}
|
||||
if (key.backspace || key.escape) {
|
||||
back();
|
||||
}
|
||||
});
|
||||
|
||||
const handleAction = async () => {
|
||||
if (!user) return;
|
||||
const item = MENU_ITEMS[selectedAction];
|
||||
if (!item) return;
|
||||
|
||||
switch (item.id) {
|
||||
case 'toggle-lock': {
|
||||
setMode('confirm-lock');
|
||||
break;
|
||||
}
|
||||
case 'assign-role': {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const allRoles = await client.roles.list();
|
||||
const userRoleIds = new Set(user.roles.map((r) => r.id));
|
||||
setAvailableRoles(allRoles.filter((r) => !userRoleIds.has(r.id)));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
setMode('assign-role');
|
||||
break;
|
||||
}
|
||||
case 'remove-role': {
|
||||
setAvailableRoles(user.roles);
|
||||
setMode('remove-role');
|
||||
break;
|
||||
}
|
||||
case 'change-password': {
|
||||
navigate('change-password', { userId: user.id });
|
||||
break;
|
||||
}
|
||||
case 'back': {
|
||||
back();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleLock = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setMode('menu');
|
||||
setActionLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = user.status === 'ACTIVE'
|
||||
? await client.users.lock(user.id)
|
||||
: await client.users.unlock(user.id);
|
||||
setUser(updated);
|
||||
setSuccessMessage(user.status === 'ACTIVE' ? 'Benutzer gesperrt.' : 'Benutzer entsperrt.');
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleRoleSelect = useCallback(async (role: RoleDTO) => {
|
||||
if (!user) return;
|
||||
const currentMode = mode;
|
||||
setMode('menu');
|
||||
setActionLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (currentMode === 'assign-role') {
|
||||
const updated = await client.users.assignRole(user.id, { roleName: role.name });
|
||||
setUser(updated);
|
||||
setSuccessMessage(`Rolle "${role.name}" zugewiesen.`);
|
||||
} else {
|
||||
await client.users.removeRole(user.id, role.name);
|
||||
const updated = await client.users.getById(user.id);
|
||||
setUser(updated);
|
||||
setSuccessMessage(`Rolle "${role.name}" entfernt.`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}, [user, mode]);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner label="Lade Benutzerdetails..." />;
|
||||
}
|
||||
|
||||
if (error && !user) {
|
||||
return <ErrorDisplay message={error} onDismiss={() => setError(null)} />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Text color="red">Benutzer nicht gefunden.</Text>;
|
||||
}
|
||||
|
||||
const roleNames = user.roles.map((r) => r.name).join(', ') || '–';
|
||||
const statusColor = user.status === 'ACTIVE' ? 'green' : 'red';
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>
|
||||
Benutzerdetails
|
||||
</Text>
|
||||
|
||||
{error && <ErrorDisplay message={error} onDismiss={() => setError(null)} />}
|
||||
{successMessage && (
|
||||
<SuccessDisplay message={successMessage} onDismiss={() => setSuccessMessage(null)} />
|
||||
)}
|
||||
|
||||
{/* User Info */}
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1}>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">ID:</Text>
|
||||
<Text>{user.id}</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Benutzername:</Text>
|
||||
<Text bold>{user.username}</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">E-Mail:</Text>
|
||||
<Text>{user.email}</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Status:</Text>
|
||||
<Text color={statusColor} bold>
|
||||
{user.status}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Rollen:</Text>
|
||||
<Text>{roleNames}</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Erstellt:</Text>
|
||||
<Text>{new Date(user.createdAt).toLocaleString('de-DE')}</Text>
|
||||
</Box>
|
||||
{user.lastLogin && (
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Letzter Login:</Text>
|
||||
<Text>{new Date(user.lastLogin).toLocaleString('de-DE')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Confirm Lock Dialog */}
|
||||
{mode === 'confirm-lock' && (
|
||||
<ConfirmDialog
|
||||
message={user.status === 'ACTIVE' ? 'Benutzer sperren?' : 'Benutzer entsperren?'}
|
||||
onConfirm={() => void handleToggleLock()}
|
||||
onCancel={() => setMode('menu')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Role Select */}
|
||||
{(mode === 'assign-role' || mode === 'remove-role') && (
|
||||
<RoleSelectList
|
||||
roles={availableRoles}
|
||||
label={mode === 'assign-role' ? 'Rolle zuweisen:' : 'Rolle entfernen:'}
|
||||
onSelect={(role) => void handleRoleSelect(role)}
|
||||
onCancel={() => setMode('menu')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action Menu */}
|
||||
{mode === 'menu' && (
|
||||
<Box flexDirection="column">
|
||||
<Text color="gray" bold>
|
||||
Aktionen:
|
||||
</Text>
|
||||
{actionLoading && <LoadingSpinner label="Aktion wird ausgeführt..." />}
|
||||
{!actionLoading &&
|
||||
MENU_ITEMS.map((item, index) => (
|
||||
<Box key={item.id}>
|
||||
<Text color={index === selectedAction ? 'cyan' : 'white'}>
|
||||
{index === selectedAction ? '▶ ' : ' '}
|
||||
{item.label(user)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
↑↓ navigieren · Enter ausführen · Backspace Zurück
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
69
frontend/apps/cli/src/components/users/UserListScreen.tsx
Normal file
69
frontend/apps/cli/src/components/users/UserListScreen.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { useNavigation } from '../../state/navigation-context.js';
|
||||
import { useUsers } from '../../hooks/useUsers.js';
|
||||
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
|
||||
import { UserTable } from './UserTable.js';
|
||||
|
||||
export function UserListScreen() {
|
||||
const { navigate, back } = useNavigation();
|
||||
const { users, loading, error, fetchUsers, clearError } = useUsers();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (loading) return;
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex((i) => Math.min(users.length - 1, i + 1));
|
||||
}
|
||||
if (key.return && users.length > 0) {
|
||||
const user = users[selectedIndex];
|
||||
if (user) {
|
||||
navigate('user-detail', { userId: user.id });
|
||||
}
|
||||
}
|
||||
if (input === 'n') {
|
||||
navigate('user-create');
|
||||
}
|
||||
if (key.backspace || key.escape) {
|
||||
back();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text color="cyan" bold>
|
||||
Benutzerverwaltung
|
||||
</Text>
|
||||
<Text color="gray" dimColor>
|
||||
{' '}– {users.length} Benutzer
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{loading && <LoadingSpinner label="Lade Benutzer..." />}
|
||||
|
||||
{error && !loading && (
|
||||
<ErrorDisplay message={error} onDismiss={clearError} />
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<UserTable users={users} selectedIndex={selectedIndex} />
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
↑↓ navigieren · Enter Details · [n] Neu · Backspace Zurück
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
55
frontend/apps/cli/src/components/users/UserTable.tsx
Normal file
55
frontend/apps/cli/src/components/users/UserTable.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { UserDTO } from '@effigenix/api-client';
|
||||
|
||||
interface UserTableProps {
|
||||
users: UserDTO[];
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
export function UserTable({ users, selectedIndex }: UserTableProps) {
|
||||
if (users.length === 0) {
|
||||
return <Text color="gray">Keine Benutzer vorhanden.</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Header */}
|
||||
<Box>
|
||||
<Text color="gray" bold>
|
||||
{' '}
|
||||
{'#'.padEnd(3)}
|
||||
{'Benutzername'.padEnd(20)}
|
||||
{'E-Mail'.padEnd(30)}
|
||||
{'Status'.padEnd(10)}
|
||||
{'Rollen'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="gray">{'─'.repeat(80)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Rows */}
|
||||
{users.map((user, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const statusColor = user.status === 'ACTIVE' ? 'green' : 'red';
|
||||
const rowColor = isSelected ? 'cyan' : 'white';
|
||||
const prefix = isSelected ? '▶ ' : ' ';
|
||||
const roleNames = user.roles.map((r) => r.name).join(', ') || '–';
|
||||
|
||||
return (
|
||||
<Box key={user.id}>
|
||||
<Text color={rowColor}>
|
||||
{prefix}
|
||||
{String(index + 1).padEnd(3)}
|
||||
{user.username.padEnd(20)}
|
||||
{user.email.padEnd(30)}
|
||||
</Text>
|
||||
<Text color={statusColor}>{user.status.padEnd(10)}</Text>
|
||||
<Text color={rowColor}>{roleNames}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue