1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 13:49: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:
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,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;
}