mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:39:35 +01:00
feat(frontend): TypeScript-Monorepo mit Terminal-UI für Effigenix ERP
Monorepo-Setup (pnpm workspaces) mit vier shared Packages und einer TUI-App: Shared Packages: - @effigenix/types: TypeScript-DTOs (UserDTO, RoleDTO, AuthDTO, Enums) - @effigenix/config: API-Konfiguration und Shared Constants - @effigenix/validation: Zod-Schemas für Username, E-Mail und Passwort - @effigenix/api-client: axios-Client mit JWT-Handling (proaktiver + reaktiver Token-Refresh), AuthInterceptor, ErrorInterceptor, Resources für auth/users/roles TUI (apps/cli, Ink 5 / React): - Authentication: Login/Logout, Session-Restore beim Start, JWT-Refresh - User Management: Liste, Anlage (Zod-Inline-Validation), Detailansicht, Passwort ändern, Sperren/Entsperren mit ConfirmDialog - Role Management: Liste, Detailansicht, Zuweisen/Entfernen per RoleSelectList (↑↓) - UX: SuccessDisplay (Auto-Dismiss 3 s), ConfirmDialog (J/N), FormInput mit Inline-Fehlern, StatusBar mit API-URL - Layout: Fullscreen-Modus (alternate screen buffer), Header mit eingeloggtem User - Tests: vitest + ink-testing-library (15 Tests)
This commit is contained in:
parent
87123df2e4
commit
bbe9e87c33
65 changed files with 6955 additions and 1 deletions
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",
|
||||
"version": "0.1.0",
|
||||
"description": "TypeScript types for Effigenix ERP (auto-generated from OpenAPI)",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true
|
||||
"composite": false,
|
||||
"incremental": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
|
|
|
|||
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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue