mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 08:29:36 +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
7
frontend/.gitignore
vendored
Normal file
7
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.turbo/
|
||||||
|
*.tsbuildinfo
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
47
frontend/apps/cli/package.json
Normal file
47
frontend/apps/cli/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
frontend/apps/cli/src/App.tsx
Normal file
64
frontend/apps/cli/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/apps/cli/src/__tests__/hooks/useRoles.test.ts
Normal file
46
frontend/apps/cli/src/__tests__/hooks/useRoles.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
27
frontend/apps/cli/src/__tests__/shared/ErrorDisplay.test.tsx
Normal file
27
frontend/apps/cli/src/__tests__/shared/ErrorDisplay.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/apps/cli/src/hooks/useRoles.ts
Normal file
37
frontend/apps/cli/src/hooks/useRoles.ts
Normal 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 };
|
||||||
|
}
|
||||||
28
frontend/apps/cli/src/hooks/useTerminalSize.ts
Normal file
28
frontend/apps/cli/src/hooks/useTerminalSize.ts
Normal 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;
|
||||||
|
}
|
||||||
137
frontend/apps/cli/src/hooks/useUsers.ts
Normal file
137
frontend/apps/cli/src/hooks/useUsers.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
24
frontend/apps/cli/src/index.tsx
Normal file
24
frontend/apps/cli/src/index.tsx
Normal 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);
|
||||||
148
frontend/apps/cli/src/state/auth-context.tsx
Normal file
148
frontend/apps/cli/src/state/auth-context.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
77
frontend/apps/cli/src/state/navigation-context.tsx
Normal file
77
frontend/apps/cli/src/state/navigation-context.tsx
Normal 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;
|
||||||
|
}
|
||||||
5
frontend/apps/cli/src/utils/api-client.ts
Normal file
5
frontend/apps/cli/src/utils/api-client.ts
Normal 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);
|
||||||
82
frontend/apps/cli/src/utils/token-storage.ts
Normal file
82
frontend/apps/cli/src/utils/token-storage.ts
Normal 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);
|
||||||
|
}
|
||||||
12
frontend/apps/cli/tsconfig.json
Normal file
12
frontend/apps/cli/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
7
frontend/apps/cli/vitest.config.ts
Normal file
7
frontend/apps/cli/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
42
frontend/packages/api-client/package.json
Normal file
42
frontend/packages/api-client/package.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
62
frontend/packages/api-client/src/__tests__/errors.test.ts
Normal file
62
frontend/packages/api-client/src/__tests__/errors.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
154
frontend/packages/api-client/src/__tests__/interceptors.test.ts
Normal file
154
frontend/packages/api-client/src/__tests__/interceptors.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
frontend/packages/api-client/src/client.ts
Normal file
40
frontend/packages/api-client/src/client.ts
Normal 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;
|
||||||
|
}
|
||||||
66
frontend/packages/api-client/src/errors.ts
Normal file
66
frontend/packages/api-client/src/errors.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
69
frontend/packages/api-client/src/index.ts
Normal file
69
frontend/packages/api-client/src/index.ts
Normal 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>;
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
43
frontend/packages/api-client/src/resources/auth.ts
Normal file
43
frontend/packages/api-client/src/resources/auth.ts
Normal 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>;
|
||||||
23
frontend/packages/api-client/src/resources/roles.ts
Normal file
23
frontend/packages/api-client/src/resources/roles.ts
Normal 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>;
|
||||||
94
frontend/packages/api-client/src/resources/users.ts
Normal file
94
frontend/packages/api-client/src/resources/users.ts
Normal 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>;
|
||||||
17
frontend/packages/api-client/src/token-provider.ts
Normal file
17
frontend/packages/api-client/src/token-provider.ts
Normal 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>;
|
||||||
|
}
|
||||||
11
frontend/packages/api-client/tsconfig.json
Normal file
11
frontend/packages/api-client/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
33
frontend/packages/config/package.json
Normal file
33
frontend/packages/config/package.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
34
frontend/packages/config/src/api-config.ts
Normal file
34
frontend/packages/config/src/api-config.ts
Normal 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;
|
||||||
26
frontend/packages/config/src/constants.ts
Normal file
26
frontend/packages/config/src/constants.ts
Normal 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;
|
||||||
2
frontend/packages/config/src/index.ts
Normal file
2
frontend/packages/config/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './api-config.js';
|
||||||
|
export * from './constants.js';
|
||||||
11
frontend/packages/config/tsconfig.json
Normal file
11
frontend/packages/config/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"name": "@effigenix/types",
|
"name": "@effigenix/types",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "TypeScript types for Effigenix ERP (auto-generated from OpenAPI)",
|
"description": "TypeScript types for Effigenix ERP (auto-generated from OpenAPI)",
|
||||||
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"composite": true
|
"composite": false,
|
||||||
|
"incremental": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||||
|
|
|
||||||
37
frontend/packages/validation/package.json
Normal file
37
frontend/packages/validation/package.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/packages/validation/src/index.ts
Normal file
3
frontend/packages/validation/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './validators.js';
|
||||||
|
export * from './schemas/auth.js';
|
||||||
|
export * from './schemas/user.js';
|
||||||
14
frontend/packages/validation/src/schemas/auth.ts
Normal file
14
frontend/packages/validation/src/schemas/auth.ts
Normal 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>;
|
||||||
37
frontend/packages/validation/src/schemas/user.ts
Normal file
37
frontend/packages/validation/src/schemas/user.ts
Normal 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>;
|
||||||
28
frontend/packages/validation/src/validators.ts
Normal file
28
frontend/packages/validation/src/validators.ts
Normal 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');
|
||||||
11
frontend/packages/validation/tsconfig.json
Normal file
11
frontend/packages/validation/tsconfig.json
Normal 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
3708
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
24
shell.nix
Normal file
24
shell.nix
Normal 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."
|
||||||
|
'';
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue