1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 18:49:59 +01:00

feat(frontend): TypeScript-Monorepo mit Terminal-UI für Effigenix ERP

Monorepo-Setup (pnpm workspaces) mit vier shared Packages und einer TUI-App:

Shared Packages:
- @effigenix/types: TypeScript-DTOs (UserDTO, RoleDTO, AuthDTO, Enums)
- @effigenix/config: API-Konfiguration und Shared Constants
- @effigenix/validation: Zod-Schemas für Username, E-Mail und Passwort
- @effigenix/api-client: axios-Client mit JWT-Handling (proaktiver + reaktiver
  Token-Refresh), AuthInterceptor, ErrorInterceptor, Resources für auth/users/roles

TUI (apps/cli, Ink 5 / React):
- Authentication: Login/Logout, Session-Restore beim Start, JWT-Refresh
- User Management: Liste, Anlage (Zod-Inline-Validation), Detailansicht,
  Passwort ändern, Sperren/Entsperren mit ConfirmDialog
- Role Management: Liste, Detailansicht, Zuweisen/Entfernen per RoleSelectList (↑↓)
- UX: SuccessDisplay (Auto-Dismiss 3 s), ConfirmDialog (J/N),
  FormInput mit Inline-Fehlern, StatusBar mit API-URL
- Layout: Fullscreen-Modus (alternate screen buffer), Header mit eingeloggtem User
- Tests: vitest + ink-testing-library (15 Tests)
This commit is contained in:
Sebastian Frick 2026-02-18 12:23:11 +01:00
parent 87123df2e4
commit bbe9e87c33
65 changed files with 6955 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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