1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:10:22 +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,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());
}
},
);
}