1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 06:29:35 +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:
Sebastian Frick 2026-02-18 12:23:11 +01:00
parent 87123df2e4
commit bbe9e87c33
65 changed files with 6955 additions and 1 deletions

7
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
dist/
.turbo/
*.tsbuildinfo
coverage/
.env
.env.local

View file

@ -0,0 +1,47 @@
{
"name": "@effigenix/cli",
"version": "0.1.0",
"description": "Terminal User Interface for Effigenix ERP",
"type": "module",
"bin": {
"effigenix": "./dist/index.js"
},
"scripts": {
"build": "tsup",
"dev": "tsx src/index.tsx",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist .turbo"
},
"dependencies": {
"@effigenix/api-client": "workspace:*",
"@effigenix/config": "workspace:*",
"@effigenix/types": "workspace:*",
"@effigenix/validation": "workspace:*",
"ink": "^5.0.1",
"ink-text-input": "^6.0.0",
"react": "^18.2.0",
"yargs": "^17.7.2"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.0",
"@types/yargs": "^17.0.32",
"ink-testing-library": "^4.0.0",
"tsup": "^8.0.1",
"tsx": "^4.7.0",
"typescript": "^5.3.3",
"vitest": "^1.2.0"
},
"tsup": {
"entry": ["src/index.tsx"],
"format": ["esm"],
"dts": false,
"clean": true,
"sourcemap": true,
"splitting": false,
"banner": {
"js": "#!/usr/bin/env node"
}
}
}

View file

@ -0,0 +1,64 @@
import React, { useEffect } from 'react';
import { Box } from 'ink';
import { AuthProvider, useAuth } from './state/auth-context.js';
import { NavigationProvider, useNavigation } from './state/navigation-context.js';
import { LoginScreen } from './components/auth/LoginScreen.js';
import { MainMenu } from './components/MainMenu.js';
import { MainLayout } from './components/layout/MainLayout.js';
import { LoadingSpinner } from './components/shared/LoadingSpinner.js';
import { UserListScreen } from './components/users/UserListScreen.js';
import { UserCreateScreen } from './components/users/UserCreateScreen.js';
import { UserDetailScreen } from './components/users/UserDetailScreen.js';
import { ChangePasswordScreen } from './components/users/ChangePasswordScreen.js';
import { RoleListScreen } from './components/roles/RoleListScreen.js';
import { RoleDetailScreen } from './components/roles/RoleDetailScreen.js';
function ScreenRouter() {
const { isAuthenticated, loading } = useAuth();
const { current, navigate } = useNavigation();
// Redirect zu main-menu nach erfolgreichem Login
useEffect(() => {
if (isAuthenticated && current === 'login') {
navigate('main-menu');
}
}, [isAuthenticated, current, navigate]);
if (loading) {
return (
<Box alignItems="center" justifyContent="center" paddingY={4}>
<LoadingSpinner label="Initialisiere..." />
</Box>
);
}
if (!isAuthenticated) {
return (
<MainLayout showHeader={false}>
<LoginScreen />
</MainLayout>
);
}
return (
<MainLayout>
{current === 'main-menu' && <MainMenu />}
{current === 'user-list' && <UserListScreen />}
{current === 'user-create' && <UserCreateScreen />}
{current === 'user-detail' && <UserDetailScreen />}
{current === 'change-password' && <ChangePasswordScreen />}
{current === 'role-list' && <RoleListScreen />}
{current === 'role-detail' && <RoleDetailScreen />}
</MainLayout>
);
}
export function App() {
return (
<NavigationProvider initialScreen="login">
<AuthProvider>
<ScreenRouter />
</AuthProvider>
</NavigationProvider>
);
}

View file

@ -0,0 +1,46 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import type { RoleDTO } from '@effigenix/api-client';
vi.mock('../../utils/api-client.js', () => ({
client: {
roles: {
list: vi.fn(),
},
},
}));
const { client } = await import('../../utils/api-client.js');
const mockRoles: RoleDTO[] = [
{ id: '1', name: 'ADMIN', permissions: [] },
{ id: '2', name: 'USER', permissions: [] },
];
describe('useRoles api-client integration', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('client.roles.list resolves with roles', async () => {
vi.mocked(client.roles.list).mockResolvedValue(mockRoles);
const result = await client.roles.list();
expect(result).toEqual(mockRoles);
});
it('client.roles.list rejects with error', async () => {
vi.mocked(client.roles.list).mockRejectedValue(new Error('Netzwerkfehler'));
await expect(client.roles.list()).rejects.toThrow('Netzwerkfehler');
});
it('error message extraction from Error instance', () => {
const err: unknown = new Error('Verbindungsfehler');
const msg = err instanceof Error ? err.message : 'Unbekannter Fehler';
expect(msg).toBe('Verbindungsfehler');
});
it('error message fallback for non-Error', () => {
const err: unknown = 'string error';
const msg = err instanceof Error ? err.message : 'Unbekannter Fehler';
expect(msg).toBe('Unbekannter Fehler');
});
});

View file

@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'ink-testing-library';
import React from 'react';
import { ConfirmDialog } from '../../components/shared/ConfirmDialog.js';
describe('ConfirmDialog', () => {
it('renders the message', () => {
const { lastFrame } = render(
React.createElement(ConfirmDialog, {
message: 'Benutzer sperren?',
onConfirm: vi.fn(),
onCancel: vi.fn(),
}),
);
expect(lastFrame()).toContain('Benutzer sperren?');
});
it('renders J/N hint', () => {
const { lastFrame } = render(
React.createElement(ConfirmDialog, {
message: 'Test?',
onConfirm: vi.fn(),
onCancel: vi.fn(),
}),
);
expect(lastFrame()).toContain('[J]');
expect(lastFrame()).toContain('[N]');
});
it('renders Bestätigung label', () => {
const { lastFrame } = render(
React.createElement(ConfirmDialog, {
message: 'Aktion bestätigen?',
onConfirm: vi.fn(),
onCancel: vi.fn(),
}),
);
expect(lastFrame()).toContain('Bestätigung');
});
});

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { render } from 'ink-testing-library';
import React from 'react';
import { ErrorDisplay } from '../../components/shared/ErrorDisplay.js';
describe('ErrorDisplay', () => {
it('renders the error message', () => {
const { lastFrame } = render(React.createElement(ErrorDisplay, { message: 'Test-Fehler' }));
expect(lastFrame()).toContain('Test-Fehler');
});
it('renders without onDismiss without throwing', () => {
expect(() => render(React.createElement(ErrorDisplay, { message: 'Fehler' }))).not.toThrow();
});
it('does not render dismiss hint when onDismiss is absent', () => {
const { lastFrame } = render(React.createElement(ErrorDisplay, { message: 'Fehler' }));
expect(lastFrame()).not.toContain('Schließen');
});
it('renders dismiss hint when onDismiss is provided', () => {
const { lastFrame } = render(
React.createElement(ErrorDisplay, { message: 'Fehler', onDismiss: () => undefined }),
);
expect(lastFrame()).toContain('Schließen');
});
});

View file

@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from 'ink-testing-library';
import React, { act } from 'react';
import { SuccessDisplay } from '../../components/shared/SuccessDisplay.js';
describe('SuccessDisplay', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('renders the success message', () => {
const onDismiss = vi.fn();
const { lastFrame } = render(
React.createElement(SuccessDisplay, { message: 'Aktion erfolgreich.', onDismiss }),
);
expect(lastFrame()).toContain('Aktion erfolgreich.');
});
it('renders with green Erfolg label', () => {
const onDismiss = vi.fn();
const { lastFrame } = render(
React.createElement(SuccessDisplay, { message: 'Ok', onDismiss }),
);
expect(lastFrame()).toContain('Erfolg');
});
it('calls onDismiss after 3 seconds', async () => {
const onDismiss = vi.fn();
await act(async () => {
render(React.createElement(SuccessDisplay, { message: 'Ok', onDismiss }));
});
expect(onDismiss).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(3000);
});
expect(onDismiss).toHaveBeenCalledOnce();
});
it('does not call onDismiss before 3 seconds', async () => {
const onDismiss = vi.fn();
await act(async () => {
render(React.createElement(SuccessDisplay, { message: 'Ok', onDismiss }));
});
await act(async () => {
vi.advanceTimersByTime(2999);
});
expect(onDismiss).not.toHaveBeenCalled();
});
});

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -0,0 +1,37 @@
import { useState, useCallback } from 'react';
import type { RoleDTO } from '@effigenix/api-client';
import { client } from '../utils/api-client.js';
interface RolesState {
roles: RoleDTO[];
loading: boolean;
error: string | null;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler';
}
export function useRoles() {
const [state, setState] = useState<RolesState>({
roles: [],
loading: false,
error: null,
});
const fetchRoles = useCallback(async () => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const roles = await client.roles.list();
setState({ roles, loading: false, error: null });
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
}
}, []);
const clearError = useCallback(() => {
setState((s) => ({ ...s, error: null }));
}, []);
return { ...state, fetchRoles, clearError };
}

View file

@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
import { useStdout } from 'ink';
interface TerminalSize {
columns: number;
rows: number;
}
export function useTerminalSize(): TerminalSize {
const { stdout } = useStdout();
const [size, setSize] = useState<TerminalSize>({
columns: stdout?.columns ?? 80,
rows: stdout?.rows ?? 24,
});
useEffect(() => {
if (!stdout) return;
const handleResize = () => {
setSize({ columns: stdout.columns, rows: stdout.rows });
};
stdout.on('resize', handleResize);
return () => {
stdout.off('resize', handleResize);
};
}, [stdout]);
return size;
}

View file

@ -0,0 +1,137 @@
import { useState, useCallback } from 'react';
import type { UserDTO } from '@effigenix/api-client';
import { client } from '../utils/api-client.js';
interface UsersState {
users: UserDTO[];
loading: boolean;
error: string | null;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler';
}
export function useUsers() {
const [state, setState] = useState<UsersState>({
users: [],
loading: false,
error: null,
});
const fetchUsers = useCallback(async () => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const users = await client.users.list();
setState({ users, loading: false, error: null });
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
}
}, []);
const createUser = useCallback(
async (username: string, email: string, password: string, roleName?: string) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const user = await client.users.create({
username,
email,
password,
roleNames: roleName ? [roleName] : [],
});
setState((s) => ({ users: [...s.users, user], loading: false, error: null }));
return user;
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
return null;
}
},
[],
);
const lockUser = useCallback(async (id: string) => {
try {
const user = await client.users.lock(id);
setState((s) => ({
...s,
users: s.users.map((u) => (u.id === id ? user : u)),
}));
return user;
} catch (err) {
setState((s) => ({ ...s, error: errorMessage(err) }));
return null;
}
}, []);
const unlockUser = useCallback(async (id: string) => {
try {
const user = await client.users.unlock(id);
setState((s) => ({
...s,
users: s.users.map((u) => (u.id === id ? user : u)),
}));
return user;
} catch (err) {
setState((s) => ({ ...s, error: errorMessage(err) }));
return null;
}
}, []);
const assignRole = useCallback(async (id: string, roleName: string) => {
try {
const user = await client.users.assignRole(id, { roleName });
setState((s) => ({
...s,
users: s.users.map((u) => (u.id === id ? user : u)),
}));
return user;
} catch (err) {
setState((s) => ({ ...s, error: errorMessage(err) }));
return null;
}
}, []);
const removeRole = useCallback(async (id: string, roleName: string) => {
try {
await client.users.removeRole(id, roleName);
const updated = await client.users.getById(id);
setState((s) => ({
...s,
users: s.users.map((u) => (u.id === id ? updated : u)),
}));
return true;
} catch (err) {
setState((s) => ({ ...s, error: errorMessage(err) }));
return false;
}
}, []);
const changePassword = useCallback(
async (id: string, currentPassword: string, newPassword: string) => {
try {
await client.users.changePassword(id, { currentPassword, newPassword });
return true;
} catch (err) {
setState((s) => ({ ...s, error: errorMessage(err) }));
return false;
}
},
[],
);
const clearError = useCallback(() => {
setState((s) => ({ ...s, error: null }));
}, []);
return {
...state,
fetchUsers,
createUser,
lockUser,
unlockUser,
assignRole,
removeRole,
changePassword,
clearError,
};
}

View file

@ -0,0 +1,24 @@
import React from 'react';
import { render } from 'ink';
import { App } from './App.js';
// Alternate screen buffer + clear + hide cursor
process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H\x1b[?25l');
const cleanup = () => {
process.stdout.write('\x1b[?25h\x1b[?1049l');
};
process.on('exit', cleanup);
process.on('SIGINT', () => {
cleanup();
process.exit(0);
});
process.on('SIGTERM', () => {
cleanup();
process.exit(0);
});
const { waitUntilExit } = render(<App />);
waitUntilExit().then(cleanup).catch(cleanup);

View file

@ -0,0 +1,148 @@
import React, { createContext, useCallback, useContext, useEffect, useReducer } from 'react';
import { RefreshTokenExpiredError } from '@effigenix/api-client';
import { tokenStorage } from '../utils/token-storage.js';
import { client } from '../utils/api-client.js';
export interface AuthUser {
username: string;
}
interface AuthState {
isAuthenticated: boolean;
user: AuthUser | null;
loading: boolean;
error: string | null;
}
type AuthAction =
| { type: 'SET_LOADING'; loading: boolean }
| { type: 'SET_USER'; user: AuthUser }
| { type: 'SET_ERROR'; error: string }
| { type: 'CLEAR_ERROR' }
| { type: 'LOGOUT' };
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case 'SET_LOADING':
return { ...state, loading: action.loading };
case 'SET_USER':
return { ...state, isAuthenticated: true, user: action.user, loading: false, error: null };
case 'SET_ERROR':
return { ...state, loading: false, error: action.error };
case 'CLEAR_ERROR':
return { ...state, error: null };
case 'LOGOUT':
return { isAuthenticated: false, user: null, loading: false, error: null };
}
}
interface AuthContextValue {
isAuthenticated: boolean;
user: AuthUser | null;
loading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
clearError: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
interface AuthProviderProps {
children: React.ReactNode;
onAuthRequired?: () => void;
onLogout?: () => void;
}
export function AuthProvider({ children, onLogout }: AuthProviderProps) {
const [state, dispatch] = useReducer(authReducer, {
isAuthenticated: false,
user: null,
loading: true,
error: null,
});
// Beim Start: prüfen ob bereits gültige Session vorhanden
useEffect(() => {
void (async () => {
try {
const token = await tokenStorage.getAccessToken();
const refreshToken = await tokenStorage.getRefreshToken();
if (token && refreshToken) {
// Token existiert und ist noch gültig
dispatch({ type: 'SET_USER', user: { username: 'Eingeloggt' } });
} else if (refreshToken) {
// Access Token abgelaufen, aber Refresh Token vorhanden → Refresh versuchen
try {
const refreshed = await client.auth.refresh({ refreshToken });
await tokenStorage.saveTokens(
refreshed.accessToken,
refreshed.refreshToken,
refreshed.expiresAt,
);
dispatch({ type: 'SET_USER', user: { username: 'Eingeloggt' } });
} catch {
await tokenStorage.clearTokens();
dispatch({ type: 'LOGOUT' });
}
} else {
dispatch({ type: 'SET_LOADING', loading: false });
}
} catch {
dispatch({ type: 'SET_LOADING', loading: false });
}
})();
}, []);
const login = useCallback(async (username: string, password: string): Promise<boolean> => {
dispatch({ type: 'SET_LOADING', loading: true });
dispatch({ type: 'CLEAR_ERROR' });
try {
const response = await client.auth.login({ username, password });
await tokenStorage.saveTokens(response.accessToken, response.refreshToken, response.expiresAt);
dispatch({ type: 'SET_USER', user: { username } });
return true;
} catch (err) {
if (err instanceof RefreshTokenExpiredError) {
dispatch({ type: 'SET_ERROR', error: 'Session abgelaufen. Bitte neu anmelden.' });
} else if (err instanceof Error) {
const message = err.message.includes('401')
? 'Ungültiger Benutzername oder Passwort.'
: err.message;
dispatch({ type: 'SET_ERROR', error: message });
} else {
dispatch({ type: 'SET_ERROR', error: 'Anmeldung fehlgeschlagen.' });
}
return false;
}
}, []);
const logout = useCallback(async (): Promise<void> => {
try {
await client.auth.logout();
} catch {
// Logout-Request kann fehlschlagen wenn Token abgelaufen trotzdem lokale Session löschen
}
await tokenStorage.clearTokens();
dispatch({ type: 'LOGOUT' });
onLogout?.();
}, [onLogout]);
const clearError = useCallback(() => dispatch({ type: 'CLEAR_ERROR' }), []);
const value: AuthContextValue = {
...state,
login,
logout,
clearError,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

View file

@ -0,0 +1,77 @@
import React, { createContext, useContext, useReducer } from 'react';
export type Screen =
| 'login'
| 'main-menu'
| 'user-list'
| 'user-create'
| 'user-detail'
| 'change-password'
| 'role-list'
| 'role-detail';
interface NavigationState {
current: Screen;
history: Screen[];
params: Record<string, string>;
}
type NavigationAction =
| { type: 'NAVIGATE'; screen: Screen; params?: Record<string, string> }
| { type: 'BACK' };
function navigationReducer(state: NavigationState, action: NavigationAction): NavigationState {
switch (action.type) {
case 'NAVIGATE':
return {
current: action.screen,
history: [...state.history, state.current],
params: action.params ?? {},
};
case 'BACK': {
const history = [...state.history];
const previous = history.pop();
if (!previous) return state;
return { current: previous, history, params: {} };
}
}
}
interface NavigationContextValue {
current: Screen;
params: Record<string, string>;
canGoBack: boolean;
navigate: (screen: Screen, params?: Record<string, string>) => void;
back: () => void;
}
const NavigationContext = createContext<NavigationContextValue | null>(null);
interface NavigationProviderProps {
children: React.ReactNode;
initialScreen?: Screen;
}
export function NavigationProvider({ children, initialScreen = 'login' }: NavigationProviderProps) {
const [state, dispatch] = useReducer(navigationReducer, {
current: initialScreen,
history: [],
params: {},
});
const value: NavigationContextValue = {
current: state.current,
params: state.params,
canGoBack: state.history.length > 0,
navigate: (screen, params) => dispatch({ type: 'NAVIGATE', screen, params: params ?? {} }),
back: () => dispatch({ type: 'BACK' }),
};
return <NavigationContext.Provider value={value}>{children}</NavigationContext.Provider>;
}
export function useNavigation(): NavigationContextValue {
const ctx = useContext(NavigationContext);
if (!ctx) throw new Error('useNavigation must be used within NavigationProvider');
return ctx;
}

View file

@ -0,0 +1,5 @@
import { createEffigenixClient } from '@effigenix/api-client';
import type { EffigenixClient } from '@effigenix/api-client';
import { tokenStorage } from './token-storage.js';
export const client: EffigenixClient = createEffigenixClient(tokenStorage);

View file

@ -0,0 +1,82 @@
/**
* Filesystem-based TokenProvider.
* Stores tokens in ~/.effigenix/config.json with file permissions 600.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import type { TokenProvider } from '@effigenix/api-client';
interface StoredAuth {
accessToken: string;
refreshToken: string;
expiresAt: string;
}
interface StoredConfig {
apiBaseUrl?: string;
auth?: StoredAuth;
}
const CONFIG_DIR = path.join(os.homedir(), '.effigenix');
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
async function loadConfig(): Promise<StoredConfig> {
try {
const raw = await fs.readFile(CONFIG_FILE, 'utf-8');
return JSON.parse(raw) as StoredConfig;
} catch {
return {};
}
}
async function saveConfig(config: StoredConfig): Promise<void> {
await fs.mkdir(CONFIG_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
}
export const tokenStorage: TokenProvider = {
async getAccessToken(): Promise<string | null> {
const config = await loadConfig();
if (!config.auth) return null;
// Proaktiver Refresh: wenn weniger als 5 Minuten bis Ablauf
const expiresAt = new Date(config.auth.expiresAt).getTime();
const timeUntilExpiry = expiresAt - Date.now();
if (timeUntilExpiry < 5 * 60 * 1000) {
// Token bald abgelaufen Aufrufer (RefreshInterceptor) übernimmt Refresh
return null;
}
return config.auth.accessToken;
},
async getRefreshToken(): Promise<string | null> {
const config = await loadConfig();
return config.auth?.refreshToken ?? null;
},
async saveTokens(accessToken: string, refreshToken: string, expiresAt: string): Promise<void> {
const config = await loadConfig();
config.auth = { accessToken, refreshToken, expiresAt };
await saveConfig(config);
},
async clearTokens(): Promise<void> {
const config = await loadConfig();
delete config.auth;
await saveConfig(config);
},
};
export async function getStoredApiBaseUrl(): Promise<string | undefined> {
const config = await loadConfig();
return config.apiBaseUrl;
}
export async function saveApiBaseUrl(url: string): Promise<void> {
const config = await loadConfig();
config.apiBaseUrl = url;
await saveConfig(config);
}

View file

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": false,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});

View file

@ -0,0 +1,42 @@
{
"name": "@effigenix/api-client",
"version": "0.1.0",
"description": "HTTP client for the Effigenix ERP API",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist .turbo",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@effigenix/config": "workspace:*",
"@effigenix/types": "workspace:*",
"axios": "^1.6.7"
},
"devDependencies": {
"@types/node": "^20.11.0",
"tsup": "^8.0.1",
"typescript": "^5.3.3",
"vitest": "^1.2.2",
"axios-mock-adapter": "^1.22.0"
},
"tsup": {
"entry": ["src/index.ts"],
"format": ["esm"],
"dts": true,
"clean": true,
"sourcemap": true,
"splitting": false
}
}

View file

@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import {
ApiError,
AuthenticationError,
NetworkError,
RefreshTokenExpiredError,
} from '../errors.js';
describe('ApiError', () => {
it('stores status code and message', () => {
const error = new ApiError('Not found', 404);
expect(error.message).toBe('Not found');
expect(error.status).toBe(404);
expect(error.name).toBe('ApiError');
});
it('stores optional validation errors', () => {
const validationErrors = [{ field: 'email', message: 'Invalid email' }];
const error = new ApiError('Validation failed', 400, undefined, validationErrors);
expect(error.validationErrors).toEqual(validationErrors);
});
it('stores optional code', () => {
const error = new ApiError('Conflict', 409, 'USERNAME_TAKEN');
expect(error.code).toBe('USERNAME_TAKEN');
});
});
describe('AuthenticationError', () => {
it('defaults to 401 status', () => {
const error = new AuthenticationError();
expect(error.status).toBe(401);
expect(error.name).toBe('AuthenticationError');
});
it('accepts custom status for 403', () => {
const error = new AuthenticationError('Forbidden', 403);
expect(error.status).toBe(403);
});
});
describe('NetworkError', () => {
it('defaults to non-timeout', () => {
const error = new NetworkError();
expect(error.isTimeout).toBe(false);
expect(error.name).toBe('NetworkError');
});
it('flags timeout errors', () => {
const error = new NetworkError('Request timed out', true);
expect(error.isTimeout).toBe(true);
});
});
describe('RefreshTokenExpiredError', () => {
it('is an AuthenticationError with correct message', () => {
const error = new RefreshTokenExpiredError();
expect(error).toBeInstanceOf(AuthenticationError);
expect(error.message).toBe('Session expired. Please log in again.');
expect(error.name).toBe('RefreshTokenExpiredError');
});
});

View file

@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { createApiClient } from '../client.js';
import { ApiError, AuthenticationError, NetworkError, RefreshTokenExpiredError } from '../errors.js';
import type { TokenProvider } from '../token-provider.js';
function makeTokenProvider(overrides: Partial<TokenProvider> = {}): TokenProvider {
return {
getAccessToken: vi.fn().mockResolvedValue('access-token'),
getRefreshToken: vi.fn().mockResolvedValue('refresh-token'),
saveTokens: vi.fn().mockResolvedValue(undefined),
clearTokens: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
describe('Auth interceptor', () => {
it('attaches Authorization header to requests', async () => {
const tokenProvider = makeTokenProvider();
const client = createApiClient({}, tokenProvider);
const mock = new MockAdapter(client);
mock.onGet('/test').reply(200, { ok: true });
await client.get('/test');
expect(mock.history['get']?.[0]?.headers?.['Authorization']).toBe('Bearer access-token');
});
it('does not attach header when no token is stored', async () => {
const tokenProvider = makeTokenProvider({
getAccessToken: vi.fn().mockResolvedValue(null),
});
const client = createApiClient({}, tokenProvider);
const mock = new MockAdapter(client);
mock.onGet('/test').reply(200, {});
await client.get('/test');
expect(mock.history['get']?.[0]?.headers?.['Authorization']).toBeUndefined();
});
});
describe('Error interceptor', () => {
it('converts 401 to AuthenticationError', async () => {
const client = createApiClient({}, makeTokenProvider({
getRefreshToken: vi.fn().mockResolvedValue(null),
}));
const mock = new MockAdapter(client);
mock.onGet('/secure').reply(401, { message: 'Unauthorized' });
await expect(client.get('/secure')).rejects.toBeInstanceOf(AuthenticationError);
});
it('converts 404 to ApiError with correct status', async () => {
const client = createApiClient({}, makeTokenProvider());
const mock = new MockAdapter(client);
mock.onGet('/missing').reply(404, { message: 'Not found' });
await expect(client.get('/missing')).rejects.toMatchObject({
status: 404,
message: 'Not found',
});
});
it('converts network timeout to NetworkError with isTimeout=true', async () => {
const client = createApiClient({ timeoutMs: 100 }, makeTokenProvider());
const mock = new MockAdapter(client);
mock.onGet('/slow').timeout();
const error = await client.get('/slow').catch((e: unknown) => e);
expect(error).toBeInstanceOf(NetworkError);
expect((error as NetworkError).isTimeout).toBe(true);
});
it('converts network error to NetworkError', async () => {
const client = createApiClient({}, makeTokenProvider());
const mock = new MockAdapter(client);
mock.onGet('/down').networkError();
await expect(client.get('/down')).rejects.toBeInstanceOf(NetworkError);
});
});
describe('Refresh interceptor', () => {
it('retries request with new token after successful refresh', async () => {
const tokenProvider = makeTokenProvider();
const client = createApiClient({}, tokenProvider);
const mock = new MockAdapter(client);
// First call returns 401, then after refresh returns 200
let callCount = 0;
mock.onGet('/api/data').reply(() => {
callCount++;
return callCount === 1 ? [401, { message: 'Unauthorized' }] : [200, { data: 'ok' }];
});
mock.onPost('/api/auth/refresh').reply(200, {
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
expiresAt: '2026-02-17T15:00:00Z',
});
const response = await client.get('/api/data');
expect(response.data).toEqual({ data: 'ok' });
expect(tokenProvider.saveTokens).toHaveBeenCalledWith(
'new-access-token',
'new-refresh-token',
'2026-02-17T15:00:00Z',
);
});
it('throws RefreshTokenExpiredError when no refresh token is stored', async () => {
const tokenProvider = makeTokenProvider({
getRefreshToken: vi.fn().mockResolvedValue(null),
});
const client = createApiClient({}, tokenProvider);
const mock = new MockAdapter(client);
mock.onGet('/api/data').reply(401, { message: 'Unauthorized' });
await expect(client.get('/api/data')).rejects.toBeInstanceOf(RefreshTokenExpiredError);
expect(tokenProvider.clearTokens).toHaveBeenCalled();
});
it('throws RefreshTokenExpiredError when refresh request fails', async () => {
const tokenProvider = makeTokenProvider();
const client = createApiClient({}, tokenProvider);
const mock = new MockAdapter(client);
mock.onGet('/api/data').reply(401, { message: 'Unauthorized' });
mock.onPost('/api/auth/refresh').reply(401, { message: 'Refresh token expired' });
await expect(client.get('/api/data')).rejects.toBeInstanceOf(RefreshTokenExpiredError);
expect(tokenProvider.clearTokens).toHaveBeenCalled();
});
it('does not retry login endpoint on 401', async () => {
const tokenProvider = makeTokenProvider();
const client = createApiClient({}, tokenProvider);
const mock = new MockAdapter(client);
mock.onPost('/api/auth/login').reply(401, { message: 'Invalid credentials' });
await expect(client.post('/api/auth/login', {})).rejects.toBeInstanceOf(AuthenticationError);
expect(tokenProvider.saveTokens).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,40 @@
/**
* Base axios client for the Effigenix API
*/
import axios, { type AxiosInstance } from 'axios';
import { type ApiConfig, DEFAULT_API_CONFIG } from '@effigenix/config';
import { setupAuthInterceptor } from './interceptors/auth-interceptor.js';
import { setupRefreshInterceptor } from './interceptors/refresh-interceptor.js';
import { setupErrorInterceptor } from './interceptors/error-interceptor.js';
import type { TokenProvider } from './token-provider.js';
export type { AxiosInstance };
/**
* Creates and configures an axios instance with all interceptors.
*
* @param config - Optional API configuration (defaults to localhost:8080)
* @param tokenProvider - Provides access/refresh tokens and handles token storage
*/
export function createApiClient(
config: Partial<ApiConfig> = {},
tokenProvider: TokenProvider,
): AxiosInstance {
const resolvedConfig: ApiConfig = { ...DEFAULT_API_CONFIG, ...config };
const client = axios.create({
baseURL: resolvedConfig.baseUrl,
timeout: resolvedConfig.timeoutMs,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
setupAuthInterceptor(client, tokenProvider);
setupRefreshInterceptor(client, tokenProvider);
setupErrorInterceptor(client);
return client;
}

View file

@ -0,0 +1,66 @@
/**
* Custom error classes for the Effigenix API client
*/
export interface ValidationErrorDetail {
field: string;
message: string;
code?: string;
}
/**
* Base error for all API errors.
* Wraps HTTP error responses from the backend.
*/
export class ApiError extends Error {
readonly status: number;
readonly code?: string;
readonly validationErrors?: ValidationErrorDetail[];
constructor(
message: string,
status: number,
code?: string,
validationErrors?: ValidationErrorDetail[],
) {
super(message);
this.name = 'ApiError';
this.status = status;
if (code !== undefined) this.code = code;
if (validationErrors !== undefined) this.validationErrors = validationErrors;
}
}
/**
* Thrown when the server returns 401 (Unauthorized) or 403 (Forbidden).
*/
export class AuthenticationError extends ApiError {
constructor(message: string = 'Authentication required', status: number = 401) {
super(message, status);
this.name = 'AuthenticationError';
}
}
/**
* Thrown when there is no network connection or the request times out.
*/
export class NetworkError extends Error {
readonly isTimeout: boolean;
constructor(message: string = 'Network error', isTimeout: boolean = false) {
super(message);
this.name = 'NetworkError';
this.isTimeout = isTimeout;
}
}
/**
* Thrown when the refresh token has expired or is invalid.
* The user must re-authenticate.
*/
export class RefreshTokenExpiredError extends AuthenticationError {
constructor() {
super('Session expired. Please log in again.', 401);
this.name = 'RefreshTokenExpiredError';
}
}

View file

@ -0,0 +1,69 @@
/**
* @effigenix/api-client
*
* Type-safe HTTP client for the Effigenix ERP API.
*
* Usage:
*
* ```ts
* import { createEffigenixClient } from '@effigenix/api-client';
*
* const client = createEffigenixClient(tokenProvider);
* const users = await client.users.list();
* ```
*/
export { createApiClient } from './client.js';
export type { TokenProvider } from './token-provider.js';
export { createAuthResource } from './resources/auth.js';
export { createUsersResource } from './resources/users.js';
export { createRolesResource } from './resources/roles.js';
export {
ApiError,
AuthenticationError,
NetworkError,
RefreshTokenExpiredError,
} from './errors.js';
export type { ValidationErrorDetail } from './errors.js';
export type {
LoginRequest,
LoginResponse,
RefreshTokenRequest,
AuthResource,
} from './resources/auth.js';
export type {
UserDTO,
RoleDTO,
CreateUserRequest,
UpdateUserRequest,
ChangePasswordRequest,
AssignRoleRequest,
UsersResource,
} from './resources/users.js';
export type { RolesResource } from './resources/roles.js';
import { createApiClient } from './client.js';
import { createAuthResource } from './resources/auth.js';
import { createUsersResource } from './resources/users.js';
import { createRolesResource } from './resources/roles.js';
import type { TokenProvider } from './token-provider.js';
import type { ApiConfig } from '@effigenix/config';
/**
* Convenience factory that creates a fully-configured Effigenix API client
* with all resource modules attached.
*/
export function createEffigenixClient(
tokenProvider: TokenProvider,
config: Partial<ApiConfig> = {},
) {
const axiosClient = createApiClient(config, tokenProvider);
return {
auth: createAuthResource(axiosClient),
users: createUsersResource(axiosClient),
roles: createRolesResource(axiosClient),
};
}
export type EffigenixClient = ReturnType<typeof createEffigenixClient>;

View file

@ -0,0 +1,16 @@
/**
* Auth interceptor: attaches the JWT access token to every outgoing request.
*/
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import type { TokenProvider } from '../token-provider.js';
export function setupAuthInterceptor(client: AxiosInstance, tokenProvider: TokenProvider): void {
client.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
const token = await tokenProvider.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
}

View file

@ -0,0 +1,48 @@
/**
* Error interceptor: converts backend ErrorResponse objects and network errors
* into typed ApiError / NetworkError instances.
*/
import type { AxiosInstance, AxiosError } from 'axios';
import { ApiError, AuthenticationError, NetworkError } from '../errors.js';
interface BackendErrorResponse {
message?: string;
error?: string;
status?: number;
errors?: Array<{ field: string; message: string; code?: string }>;
}
export function setupErrorInterceptor(client: AxiosInstance): void {
client.interceptors.response.use(
undefined,
(error: unknown) => {
// Pass through errors that aren't axios errors (e.g. RefreshTokenExpiredError)
const axiosError = error as AxiosError<BackendErrorResponse>;
if (!axiosError.isAxiosError) {
return Promise.reject(error);
}
// Network / timeout error (no response received)
if (!axiosError.response) {
const isTimeout = axiosError.code === 'ECONNABORTED';
return Promise.reject(
new NetworkError(
isTimeout ? 'Request timed out' : 'Network error is the backend running?',
isTimeout,
),
);
}
const { status, data } = axiosError.response;
const message = data?.message ?? data?.error ?? axiosError.message ?? 'Unknown error';
const validationErrors = data?.errors;
if (status === 401 || status === 403) {
return Promise.reject(new AuthenticationError(message, status));
}
return Promise.reject(new ApiError(message, status, undefined, validationErrors));
},
);
}

View file

@ -0,0 +1,69 @@
/**
* Refresh interceptor: on 401 errors, attempts a token refresh and retries the
* original request. If the refresh also fails, clears tokens and re-throws.
*/
import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
import { API_PATHS } from '@effigenix/config';
import type { TokenProvider } from '../token-provider.js';
import { RefreshTokenExpiredError } from '../errors.js';
/** Marker on request config to prevent infinite refresh loops */
interface RetryConfig extends InternalAxiosRequestConfig {
_retried?: boolean;
}
interface LoginResponse {
accessToken: string;
refreshToken: string;
expiresAt: string;
}
export function setupRefreshInterceptor(client: AxiosInstance, tokenProvider: TokenProvider): void {
client.interceptors.response.use(
undefined,
async (error: unknown) => {
// Only process AxiosErrors with a response
const axiosError = error as AxiosError;
if (!axiosError.isAxiosError) {
return Promise.reject(error);
}
const config = axiosError.config as RetryConfig | undefined;
// Only handle 401 errors that haven't been retried yet and have a config
if (axiosError.response?.status !== 401 || config?._retried || !config) {
return Promise.reject(error);
}
// Skip refresh for auth endpoints themselves (login/refresh)
const url = config.url ?? '';
if (url.includes(API_PATHS.auth.login) || url.includes(API_PATHS.auth.refresh)) {
return Promise.reject(error);
}
config._retried = true;
const refreshToken = await tokenProvider.getRefreshToken();
if (!refreshToken) {
await tokenProvider.clearTokens();
return Promise.reject(new RefreshTokenExpiredError());
}
try {
// Use the same client so MockAdapter (tests) and auth interceptor work,
// but mark _retried on a separate config so we don't loop.
const response = await client.post<LoginResponse>(API_PATHS.auth.refresh, { refreshToken });
const { accessToken, refreshToken: newRefreshToken, expiresAt } = response.data;
await tokenProvider.saveTokens(accessToken, newRefreshToken, expiresAt);
// Retry the original request with the new access token
config.headers.Authorization = `Bearer ${accessToken}`;
return await client.request(config);
} catch {
await tokenProvider.clearTokens();
return Promise.reject(new RefreshTokenExpiredError());
}
},
);
}

View file

@ -0,0 +1,43 @@
/**
* Auth resource: login, logout, refresh
*/
import type { AxiosInstance } from 'axios';
import { API_PATHS } from '@effigenix/config';
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
accessToken: string;
tokenType: string;
expiresIn: number;
expiresAt: string;
refreshToken: string;
}
export interface RefreshTokenRequest {
refreshToken: string;
}
export function createAuthResource(client: AxiosInstance) {
return {
async login(request: LoginRequest): Promise<LoginResponse> {
const response = await client.post<LoginResponse>(API_PATHS.auth.login, request);
return response.data;
},
async logout(): Promise<void> {
await client.post(API_PATHS.auth.logout);
},
async refresh(request: RefreshTokenRequest): Promise<LoginResponse> {
const response = await client.post<LoginResponse>(API_PATHS.auth.refresh, request);
return response.data;
},
};
}
export type AuthResource = ReturnType<typeof createAuthResource>;

View file

@ -0,0 +1,23 @@
/**
* Roles resource: list all roles
*/
import type { AxiosInstance } from 'axios';
import { API_PATHS } from '@effigenix/config';
export interface RoleDTO {
id: string;
name: string;
permissions: string[];
}
export function createRolesResource(client: AxiosInstance) {
return {
async list(): Promise<RoleDTO[]> {
const response = await client.get<RoleDTO[]>(API_PATHS.roles.base);
return response.data;
},
};
}
export type RolesResource = ReturnType<typeof createRolesResource>;

View file

@ -0,0 +1,94 @@
/**
* Users resource: CRUD, lock/unlock, roles, password
*/
import type { AxiosInstance } from 'axios';
import { API_PATHS } from '@effigenix/config';
export interface UserDTO {
id: string;
username: string;
email: string;
roles: RoleDTO[];
branchId?: string;
status: 'ACTIVE' | 'LOCKED';
createdAt: string;
lastLogin?: string;
}
export interface RoleDTO {
id: string;
name: string;
permissions: string[];
}
export interface CreateUserRequest {
username: string;
email: string;
password: string;
roleNames: string[];
branchId?: string;
}
export interface UpdateUserRequest {
email?: string;
branchId?: string;
}
export interface ChangePasswordRequest {
currentPassword: string;
newPassword: string;
}
export interface AssignRoleRequest {
roleName: string;
}
export function createUsersResource(client: AxiosInstance) {
return {
async list(): Promise<UserDTO[]> {
const response = await client.get<UserDTO[]>(API_PATHS.users.base);
return response.data;
},
async getById(id: string): Promise<UserDTO> {
const response = await client.get<UserDTO>(API_PATHS.users.byId(id));
return response.data;
},
async create(request: CreateUserRequest): Promise<UserDTO> {
const response = await client.post<UserDTO>(API_PATHS.users.base, request);
return response.data;
},
async update(id: string, request: UpdateUserRequest): Promise<UserDTO> {
const response = await client.put<UserDTO>(API_PATHS.users.byId(id), request);
return response.data;
},
async lock(id: string): Promise<UserDTO> {
const response = await client.post<UserDTO>(API_PATHS.users.lock(id));
return response.data;
},
async unlock(id: string): Promise<UserDTO> {
const response = await client.post<UserDTO>(API_PATHS.users.unlock(id));
return response.data;
},
async assignRole(id: string, request: AssignRoleRequest): Promise<UserDTO> {
const response = await client.post<UserDTO>(API_PATHS.users.roles(id), request);
return response.data;
},
async removeRole(id: string, roleName: string): Promise<void> {
await client.delete(`${API_PATHS.users.roles(id)}/${encodeURIComponent(roleName)}`);
},
async changePassword(id: string, request: ChangePasswordRequest): Promise<void> {
await client.put(API_PATHS.users.password(id), request);
},
};
}
export type UsersResource = ReturnType<typeof createUsersResource>;

View file

@ -0,0 +1,17 @@
/**
* Interface for token storage and retrieval.
* The CLI implementation stores tokens in ~/.effigenix/config.json.
*/
export interface TokenProvider {
/** Returns the current access token, or null if not authenticated */
getAccessToken(): Promise<string | null>;
/** Returns the current refresh token, or null if not authenticated */
getRefreshToken(): Promise<string | null>;
/** Persists new tokens after a successful login or refresh */
saveTokens(accessToken: string, refreshToken: string, expiresAt: string): Promise<void>;
/** Clears all stored tokens (called on logout or session expiry) */
clearTokens(): Promise<void>;
}

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": false,
"incremental": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

View file

@ -0,0 +1,33 @@
{
"name": "@effigenix/config",
"version": "0.1.0",
"description": "Shared configuration constants for Effigenix ERP",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist .turbo"
},
"devDependencies": {
"@types/node": "^20.11.0",
"tsup": "^8.0.1",
"typescript": "^5.3.3"
},
"tsup": {
"entry": ["src/index.ts"],
"format": ["esm"],
"dts": true,
"clean": true,
"sourcemap": true,
"splitting": false
}
}

View file

@ -0,0 +1,34 @@
/**
* API client configuration
*/
export interface ApiConfig {
baseUrl: string;
timeoutMs: number;
retries: number;
}
export const DEFAULT_API_CONFIG: ApiConfig = {
baseUrl: 'http://localhost:8080',
timeoutMs: 10_000,
retries: 1,
};
export const API_PATHS = {
auth: {
login: '/api/auth/login',
logout: '/api/auth/logout',
refresh: '/api/auth/refresh',
},
users: {
base: '/api/users',
byId: (id: string) => `/api/users/${id}`,
lock: (id: string) => `/api/users/${id}/lock`,
unlock: (id: string) => `/api/users/${id}/unlock`,
roles: (id: string) => `/api/users/${id}/roles`,
password: (id: string) => `/api/users/${id}/password`,
},
roles: {
base: '/api/roles',
},
} as const;

View file

@ -0,0 +1,26 @@
/**
* Shared application constants
*/
/** Milliseconds before token expiry at which a proactive refresh is triggered */
export const TOKEN_REFRESH_THRESHOLD_MS = 5 * 60 * 1_000; // 5 minutes
/** Default access token lifetime (matches backend default: 15 min) */
export const ACCESS_TOKEN_LIFETIME_MS = 15 * 60 * 1_000;
/** Default refresh token lifetime (matches backend default: 7 days) */
export const REFRESH_TOKEN_LIFETIME_MS = 7 * 24 * 60 * 60 * 1_000;
/** Path to the CLI config file */
export const CLI_CONFIG_PATH = '~/.effigenix/config.json';
/** File permission octal for the CLI config file (owner read/write only) */
export const CLI_CONFIG_FILE_MODE = 0o600;
/** Pagination defaults */
export const DEFAULT_PAGE_SIZE = 20;
export const MAX_PAGE_SIZE = 100;
/** Password constraints (must match backend validation) */
export const PASSWORD_MIN_LENGTH = 8;
export const PASSWORD_MAX_LENGTH = 128;

View file

@ -0,0 +1,2 @@
export * from './api-config.js';
export * from './constants.js';

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": false,
"incremental": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

View file

@ -2,6 +2,7 @@
"name": "@effigenix/types",
"version": "0.1.0",
"description": "TypeScript types for Effigenix ERP (auto-generated from OpenAPI)",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {

View file

@ -3,7 +3,8 @@
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
"composite": false,
"incremental": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]

View file

@ -0,0 +1,37 @@
{
"name": "@effigenix/validation",
"version": "0.1.0",
"description": "Zod validation schemas for Effigenix ERP",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist .turbo"
},
"dependencies": {
"@effigenix/config": "workspace:*",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.11.0",
"tsup": "^8.0.1",
"typescript": "^5.3.3"
},
"tsup": {
"entry": ["src/index.ts"],
"format": ["esm"],
"dts": true,
"clean": true,
"sourcemap": true,
"splitting": false
}
}

View file

@ -0,0 +1,3 @@
export * from './validators.js';
export * from './schemas/auth.js';
export * from './schemas/user.js';

View file

@ -0,0 +1,14 @@
import { z } from 'zod';
import { usernameSchema } from '../validators.js';
export const loginRequestSchema = z.object({
username: usernameSchema,
password: z.string().min(1, 'Passwort ist erforderlich'),
});
export const refreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1, 'Refresh Token ist erforderlich'),
});
export type LoginRequestInput = z.input<typeof loginRequestSchema>;
export type LoginRequestOutput = z.output<typeof loginRequestSchema>;

View file

@ -0,0 +1,37 @@
import { z } from 'zod';
import { emailSchema, passwordSchema, usernameSchema, uuidSchema } from '../validators.js';
export const createUserRequestSchema = z.object({
username: usernameSchema,
email: emailSchema,
password: passwordSchema,
roleIds: z.array(uuidSchema).min(1, 'Mindestens eine Rolle muss zugewiesen werden'),
branchId: uuidSchema.optional(),
});
export const updateUserRequestSchema = z.object({
email: emailSchema.optional(),
branchId: uuidSchema.optional(),
});
export const changePasswordRequestSchema = z
.object({
currentPassword: z.string().min(1, 'Aktuelles Passwort ist erforderlich'),
newPassword: passwordSchema,
confirmPassword: z.string().min(1, 'Passwortbestätigung ist erforderlich'),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: 'Passwörter stimmen nicht überein',
path: ['confirmPassword'],
});
export const assignRoleRequestSchema = z.object({
roleId: uuidSchema,
});
export type CreateUserRequestInput = z.input<typeof createUserRequestSchema>;
export type CreateUserRequestOutput = z.output<typeof createUserRequestSchema>;
export type UpdateUserRequestInput = z.input<typeof updateUserRequestSchema>;
export type UpdateUserRequestOutput = z.output<typeof updateUserRequestSchema>;
export type ChangePasswordRequestInput = z.input<typeof changePasswordRequestSchema>;
export type AssignRoleRequestInput = z.input<typeof assignRoleRequestSchema>;

View file

@ -0,0 +1,28 @@
import { z } from 'zod';
import { PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH } from '@effigenix/config';
/**
* Reusable field validators
*/
export const emailSchema = z
.string()
.min(1, 'E-Mail ist erforderlich')
.email('Ungültige E-Mail-Adresse')
.max(254, 'E-Mail-Adresse zu lang');
export const passwordSchema = z
.string()
.min(PASSWORD_MIN_LENGTH, `Passwort muss mindestens ${String(PASSWORD_MIN_LENGTH)} Zeichen lang sein`)
.max(PASSWORD_MAX_LENGTH, `Passwort darf maximal ${String(PASSWORD_MAX_LENGTH)} Zeichen lang sein`)
.refine((val) => /[A-Z]/.test(val), 'Passwort muss mindestens einen Großbuchstaben enthalten')
.refine((val) => /[a-z]/.test(val), 'Passwort muss mindestens einen Kleinbuchstaben enthalten')
.refine((val) => /[0-9]/.test(val), 'Passwort muss mindestens eine Ziffer enthalten');
export const usernameSchema = z
.string()
.min(3, 'Benutzername muss mindestens 3 Zeichen lang sein')
.max(50, 'Benutzername darf maximal 50 Zeichen lang sein')
.regex(/^[a-zA-Z0-9_.-]+$/, 'Benutzername darf nur Buchstaben, Ziffern, _, . und - enthalten');
export const uuidSchema = z.string().uuid('Ungültige UUID');

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": false,
"incremental": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

3708
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

24
shell.nix Normal file
View file

@ -0,0 +1,24 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
name = "effigenix-frontend";
buildInputs = with pkgs; [
nodejs_22
nodePackages.pnpm
# Optional: useful CLI tools during development
jq # JSON processing for manual API testing
curl # Manual HTTP requests
];
shellHook = ''
echo "Node $(node --version)"
echo "npm $(npm --version)"
echo "pnpm $(pnpm --version)"
echo ""
echo "Run 'pnpm install' to install dependencies."
echo "Run 'pnpm run build' to build all packages."
echo "Run 'pnpm run dev' to start the TUI in watch mode."
'';
}