added stepsetup for initial admin setup

This commit is contained in:
2025-10-30 15:26:25 +01:00
parent fbd0f03eb2
commit 5809bb8b09
7 changed files with 2450 additions and 419 deletions

View File

@@ -15,11 +15,17 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"@types/jest": "30.0.0",
"@testing-library/react": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/user-event": "14.6.1",
"@storybook/react": "10.0.1",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.0.7", "vite": "^6.0.7",
"esbuild": "^0.21.0", "esbuild": "^0.21.0",
"terser": "5.44.0", "terser": "5.44.0",
"babel-plugin-transform-remove-console": "6.9.4" "babel-plugin-transform-remove-console": "6.9.4",
"framer-motion": "12.23.24"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -0,0 +1,79 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import StepSetup from './StepSetup';
const meta: Meta<typeof StepSetup> = {
title: 'Components/StepSetup',
component: StepSetup,
parameters: {
layout: 'padded',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof StepSetup>;
const defaultSteps = [
{ id: 'step-1', title: 'Account Setup', subtitle: 'Create your account' },
{ id: 'step-2', title: 'Profile Information', subtitle: 'Add personal details' },
{ id: 'step-3', title: 'Preferences', subtitle: 'Customize your experience', optional: true },
{ id: 'step-4', title: 'Confirmation', subtitle: 'Review and confirm' },
];
export const Horizontal: Story = {
args: {
steps: defaultSteps,
defaultCurrent: 1,
orientation: 'horizontal',
},
};
export const Vertical: Story = {
args: {
steps: defaultSteps,
defaultCurrent: 1,
orientation: 'vertical',
},
parameters: {
layout: 'padded',
},
};
export const ClickableFalse: Story = {
args: {
steps: defaultSteps,
current: 2,
clickable: false,
},
name: 'Non-Clickable Steps',
};
export const AnimatedFalse: Story = {
args: {
steps: defaultSteps,
defaultCurrent: 1,
animated: false,
},
name: 'Without Animation',
};
export const DifferentSizes: Story = {
render: () => (
<div className="space-y-8">
<div>
<h3 className="text-sm font-medium mb-2">Small</h3>
<StepSetup steps={defaultSteps} size="sm" defaultCurrent={1} />
</div>
<div>
<h3 className="text-sm font-medium mb-2">Medium (default)</h3>
<StepSetup steps={defaultSteps} size="md" defaultCurrent={1} />
</div>
<div>
<h3 className="text-sm font-medium mb-2">Large</h3>
<StepSetup steps={defaultSteps} size="lg" defaultCurrent={1} />
</div>
</div>
),
name: 'Different Sizes',
};

View File

@@ -0,0 +1,127 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import StepSetup from './StepSetup';
const mockSteps = [
{ id: 'step-1', title: 'First Step', subtitle: 'Description 1' },
{ id: 'step-2', title: 'Second Step' },
{ id: 'step-3', title: 'Third Step', subtitle: 'Description 3', optional: true },
];
describe('StepSetup', () => {
// a) Test verschiedener Step-Counts
test('renders correct number of steps', () => {
render(<StepSetup steps={mockSteps} />);
expect(screen.getByText('First Step')).toBeInTheDocument();
expect(screen.getByText('Second Step')).toBeInTheDocument();
expect(screen.getByText('Third Step')).toBeInTheDocument();
});
test('renders empty state correctly', () => {
render(<StepSetup steps={[]} />);
expect(screen.getByText('No steps available')).toBeInTheDocument();
});
// b) Keyboard-Navigation und Klicks
test('handles click navigation when clickable', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<StepSetup steps={mockSteps} clickable={true} onChange={onChange} />);
const secondStep = screen.getByRole('tab', { name: /second step/i });
await user.click(secondStep);
expect(onChange).toHaveBeenCalledWith(1);
});
test('handles keyboard navigation', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<StepSetup
steps={mockSteps}
defaultCurrent={0}
onChange={onChange}
/>
);
const firstStep = screen.getByRole('tab', { name: /first step/i });
firstStep.focus();
// Right arrow to next step
await user.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenCalledWith(1);
// Home key to first step
await user.keyboard('{Home}');
expect(onChange).toHaveBeenCalledWith(0);
// End key to last step
await user.keyboard('{End}');
expect(onChange).toHaveBeenCalledWith(2);
});
// c) ARIA-Attribute Tests
test('has correct ARIA attributes', () => {
render(<StepSetup steps={mockSteps} current={1} />);
const tablist = screen.getByRole('tablist');
expect(tablist).toBeInTheDocument();
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(3);
// Second step should be selected
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
expect(tabs[1]).toHaveAttribute('aria-current', 'step');
});
// d) Controlled vs Uncontrolled Tests
test('works in controlled mode', () => {
const onChange = jest.fn();
const { rerender } = render(
<StepSetup steps={mockSteps} current={0} onChange={onChange} />
);
// Click should call onChange but not change internal state in controlled mode
const secondStep = screen.getByRole('tab', { name: /second step/i });
fireEvent.click(secondStep);
expect(onChange).toHaveBeenCalledWith(1);
// Current step should still be first (controlled by prop)
expect(screen.getByRole('tab', { name: /first step/i }))
.toHaveAttribute('aria-selected', 'true');
// Update prop should change current step
rerender(<StepSetup steps={mockSteps} current={1} onChange={onChange} />);
expect(screen.getByRole('tab', { name: /second step/i }))
.toHaveAttribute('aria-selected', 'true');
});
test('works in uncontrolled mode', () => {
const onChange = jest.fn();
render(<StepSetup steps={mockSteps} defaultCurrent={0} onChange={onChange} />);
const secondStep = screen.getByRole('tab', { name: /second step/i });
fireEvent.click(secondStep);
expect(onChange).toHaveBeenCalledWith(1);
expect(secondStep).toHaveAttribute('aria-selected', 'true');
});
test('clamps out-of-range current values', () => {
render(<StepSetup steps={mockSteps} current={10} />);
// Should clamp to last step
const lastStep = screen.getByRole('tab', { name: /third step/i });
expect(lastStep).toHaveAttribute('aria-selected', 'true');
});
});

View File

@@ -0,0 +1,516 @@
import React, {
useState,
useEffect,
useId,
useCallback,
KeyboardEvent
} from 'react';
import { motion, MotionConfig, SpringOptions } from 'framer-motion';
// ===== TYP-DEFINITIONEN =====
export interface Step {
id: string;
title: string;
subtitle?: string;
optional?: boolean;
}
export interface StepSetupProps {
/** Array der Schritte mit ID, Titel und optionalen Eigenschaften */
steps: Step[];
/** Kontrollierter aktueller Schritt-Index */
current?: number;
/** Unkontrollierter Standard-Schritt-Index */
defaultCurrent?: number;
/** Callback bei Schrittänderung */
onChange?: (index: number) => void;
/** Ausrichtung des Steppers */
orientation?: 'horizontal' | 'vertical';
/** Ob Steps anklickbar sind */
clickable?: boolean;
/** Größe der Step-Komponente */
size?: 'sm' | 'md' | 'lg';
/** Animation aktivieren/deaktivieren */
animated?: boolean;
/** Zusätzliche CSS-Klassen */
className?: string;
}
export interface StepState {
currentStep: number;
isControlled: boolean;
}
// ===== HOOK FÜR ZUSTANDSVERWALTUNG =====
export const useStepSetup = (props: StepSetupProps) => {
const {
steps,
current,
defaultCurrent = 0,
onChange,
clickable = true
} = props;
const [internalStep, setInternalStep] = useState(defaultCurrent);
const isControlled = current !== undefined;
const currentStep = isControlled ? current : internalStep;
// Clamp den Schritt-Index auf gültigen Bereich
const clampedStep = Math.max(0, Math.min(currentStep, steps.length - 1));
const setStep = useCallback((newStep: number) => {
const clampedNewStep = Math.max(0, Math.min(newStep, steps.length - 1));
if (!isControlled) {
setInternalStep(clampedNewStep);
}
onChange?.(clampedNewStep);
}, [isControlled, onChange, steps.length]);
const goToNext = useCallback(() => {
if (clampedStep < steps.length - 1) {
setStep(clampedStep + 1);
}
}, [clampedStep, steps.length, setStep]);
const goToPrev = useCallback(() => {
if (clampedStep > 0) {
setStep(clampedStep - 1);
}
}, [clampedStep, setStep]);
const goToStep = useCallback((index: number) => {
if (clickable) {
setStep(index);
}
}, [clickable, setStep]);
// Warnung bei Duplicate IDs (nur in Development)
useEffect(() => {
if (process.env.NODE_ENV !== 'production') {
const stepIds = steps.map(step => step.id);
const duplicateIds = stepIds.filter((id, index) => stepIds.indexOf(id) !== index);
if (duplicateIds.length > 0) {
console.warn(
`StepSetup: Duplicate step IDs found: ${duplicateIds.join(', ')}. ` +
`Step IDs should be unique.`
);
}
}
}, [steps]);
return {
currentStep: clampedStep,
setStep,
goToNext,
goToPrev,
goToStep,
isControlled
};
};
// ===== ANIMATIONS-KONFIGURATION =====
const getAnimationConfig = (animated: boolean): { reduced: { duration: number }; normal: SpringOptions } => ({
reduced: { duration: 0 }, // Keine Animation
normal: {
stiffness: 500,
damping: 30,
mass: 0.5
}
});
// ===== HILFSFUNKTIONEN =====
/**
* Berechnet die Step-Größenklassen basierend auf der size-Prop
*/
const getSizeClasses = (size: StepSetupProps['size'] = 'md') => {
const sizes = {
sm: {
step: 'w-8 h-8 text-sm',
icon: 'w-4 h-4',
title: 'text-sm',
subtitle: 'text-xs'
},
md: {
step: 'w-10 h-10 text-base',
icon: 'w-5 h-5',
title: 'text-base',
subtitle: 'text-sm'
},
lg: {
step: 'w-12 h-12 text-lg',
icon: 'w-6 h-6',
title: 'text-lg',
subtitle: 'text-base'
}
};
return sizes[size];
};
/**
* Prüft ob prefers-reduced-motion aktiv ist
*/
const prefersReducedMotion = (): boolean => {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
};
// ===== STEP ICON KOMPONENTE =====
interface StepIconProps {
stepIndex: number;
currentStep: number;
size: StepSetupProps['size'];
animated: boolean;
}
const StepNumber: React.FC<{ number: number; isCurrent?: boolean; isCompleted?: boolean }> = ({
number,
isCurrent = false,
isCompleted = false
}) => {
const baseClasses = `
w-6 h-6
flex items-center justify-center
rounded-full text-xs font-medium
transition-all duration-200
`;
if (isCompleted) {
return (
<div className={`${baseClasses} bg-blue-500 text-white`}>
</div>
);
}
if (isCurrent) {
return (
<div className={`${baseClasses} bg-blue-500 text-white ring-2 ring-blue-500 ring-opacity-20`}>
{number}
</div>
);
}
return (
<div className={`${baseClasses} bg-gray-200 text-gray-600`}>
{number}
</div>
);
};
const StepIcon: React.FC<StepIconProps> = ({
stepIndex,
currentStep,
size,
animated
}) => {
const isCompleted = stepIndex < currentStep;
const isCurrent = stepIndex === currentStep;
const sizeClasses = getSizeClasses(size);
const animationConfig = getAnimationConfig(animated);
const shouldAnimate = animated && !prefersReducedMotion();
const baseClasses = `
${sizeClasses.step}
flex items-center justify-center
rounded-full border-2 font-medium
transition-all duration-200
`;
if (isCompleted) {
const completedClasses = `
${baseClasses}
border-blue-500 bg-white
`;
const iconContent = shouldAnimate ? (
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={animationConfig.normal}
>
<StepNumber number={stepIndex + 1} isCompleted />
</motion.div>
) : (
<StepNumber number={stepIndex + 1} isCompleted />
);
return (
<div className={completedClasses}>
{iconContent}
</div>
);
}
if (isCurrent) {
const currentClasses = `
${baseClasses}
border-blue-500 bg-white
`;
const stepNumber = shouldAnimate ? (
<motion.div
key={stepIndex}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={animationConfig.normal}
>
<StepNumber number={stepIndex + 1} isCurrent />
</motion.div>
) : (
<StepNumber number={stepIndex + 1} isCurrent />
);
return <div className={currentClasses}>{stepNumber}</div>;
}
const upcomingClasses = `
${baseClasses}
border-gray-300 bg-white
`;
return (
<div className={upcomingClasses}>
<StepNumber number={stepIndex + 1} />
</div>
);
};
// ===== HAUPTKOMPONENTE =====
/**
* Eine zugängliche, animierte Step/Progress-Komponente mit Unterstützung für
* kontrollierte und unkontrollierte Verwendung.
*
* @example
* ```tsx
* <StepSetup
* steps={[
* { id: 's1', title: 'First Step', subtitle: 'Optional description' },
* { id: 's2', title: 'Second Step' }
* ]}
* defaultCurrent={0}
* onChange={(index) => console.log('Step changed:', index)}
* />
* ```
*/
const StepSetup: React.FC<StepSetupProps> = (props) => {
const {
steps,
orientation = 'horizontal',
clickable = true,
size = 'md',
animated = true,
className = ''
} = props;
const {
currentStep,
goToStep
} = useStepSetup(props);
const listId = useId();
const shouldAnimate = animated && !prefersReducedMotion();
const sizeClasses = getSizeClasses(size);
// Fallback für leere Steps
if (!steps || steps.length === 0) {
return (
<div
className={`flex items-center justify-center p-4 text-gray-500 ${className}`}
role="status"
aria-live="polite"
>
No steps available
</div>
);
}
// Tastatur-Navigation Handler
const handleKeyDown = (
event: KeyboardEvent<HTMLButtonElement>,
stepIndex: number
) => {
if (!clickable) return;
const isHorizontal = orientation === 'horizontal';
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
goToStep(stepIndex);
break;
case 'ArrowRight':
case 'ArrowDown':
if ((isHorizontal && event.key === 'ArrowRight') ||
(!isHorizontal && event.key === 'ArrowDown')) {
event.preventDefault();
const nextStep = Math.min(stepIndex + 1, steps.length - 1);
goToStep(nextStep);
}
break;
case 'ArrowLeft':
case 'ArrowUp':
if ((isHorizontal && event.key === 'ArrowLeft') ||
(!isHorizontal && event.key === 'ArrowUp')) {
event.preventDefault();
const prevStep = Math.max(stepIndex - 1, 0);
goToStep(prevStep);
}
break;
case 'Home':
event.preventDefault();
goToStep(0);
break;
case 'End':
event.preventDefault();
goToStep(steps.length - 1);
break;
}
};
// Container-Klassen basierend auf Ausrichtung
const containerClasses = `
flex ${orientation === 'vertical' ? 'flex-col' : 'flex-row'}
${orientation === 'horizontal' ? 'items-center justify-center gap-8' : 'gap-6'}
${className}
`;
const StepContent = shouldAnimate ? motion.div : 'div';
return (
<MotionConfig reducedMotion={prefersReducedMotion() ? "always" : "user"}>
<nav
className={containerClasses.trim()}
aria-label="Progress steps"
role="tablist"
>
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
const isClickable = clickable && (isCompleted || isCurrent || step.optional);
// Für horizontale Ausrichtung: flex-col mit zentrierten Items
const stepClasses = `
flex flex-col items-center
gap-3
${isClickable ? 'cursor-pointer' : 'cursor-not-allowed'}
transition-colors duration-200
${orientation === 'horizontal' ? 'flex-1' : ''}
`;
const contentClasses = `
flex flex-col items-center text-center
${orientation === 'vertical' ? 'flex-1' : ''}
`;
// Verbindungslinie nur für horizontale Ausrichtung
const connectorClasses = `
flex-1 h-0.5 mt-5
${isCompleted ? 'bg-blue-500' : 'bg-gray-200'}
transition-colors duration-200
${orientation === 'horizontal' ? '' : 'hidden'}
`;
return (
<React.Fragment key={step.id}>
<StepContent
className={stepClasses}
layout={shouldAnimate ? "position" : false}
transition={shouldAnimate ? getAnimationConfig(animated).normal : undefined}
>
<div className="flex items-center w-full">
{/* Verbindungslinie vor dem Step (außer beim ersten) */}
{index > 0 && orientation === 'horizontal' && (
<div
className={connectorClasses}
aria-hidden="true"
/>
)}
<button
type="button"
onClick={() => isClickable && goToStep(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
disabled={!isClickable}
className={`
flex items-center justify-center
focus:outline-none focus:ring-2
focus:ring-blue-500 focus:ring-offset-2 rounded-full
${!isClickable ? 'opacity-50' : ''}
${orientation === 'horizontal' ? 'mx-2' : ''}
`}
role="tab"
aria-selected={isCurrent}
aria-controls={`${listId}-${step.id}`}
id={`${listId}-tab-${step.id}`}
tabIndex={isCurrent ? 0 : -1}
aria-current={isCurrent ? 'step' : undefined}
aria-disabled={!isClickable}
>
<StepIcon
stepIndex={index}
currentStep={currentStep}
size={size}
animated={animated}
/>
</button>
{/* Verbindungslinie nach dem Step (außer beim letzten) */}
{index < steps.length - 1 && orientation === 'horizontal' && (
<div
className={connectorClasses}
aria-hidden="true"
/>
)}
</div>
{/* Step Titel und Untertitel */}
<div className={contentClasses}>
<span
className={`
font-medium
${isCurrent ? 'text-blue-600' : isCompleted ? 'text-gray-900' : 'text-gray-500'}
${sizeClasses.title}
`}
id={`${listId}-title-${step.id}`}
>
{step.title}
{step.optional && (
<span className="text-gray-400 text-sm ml-1">(Optional)</span>
)}
</span>
{step.subtitle && (
<span
className={`
${isCurrent ? 'text-gray-700' : 'text-gray-500'}
${sizeClasses.subtitle}
`}
id={`${listId}-subtitle-${step.id}`}
>
{step.subtitle}
</span>
)}
</div>
</StepContent>
</React.Fragment>
);
})}
</nav>
</MotionConfig>
);
};
export default StepSetup;

View File

@@ -1,12 +1,27 @@
// frontend/src/pages/Setup/Setup.tsx - UPDATED
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import StepSetup, { Step } from '../../components/StepSetup/StepSetup';
const API_BASE_URL = '/api'; const API_BASE_URL = '/api';
const Setup: React.FC = () => { // ===== TYP-DEFINITIONEN =====
const [step, setStep] = useState(1); interface SetupFormData {
const [formData, setFormData] = useState({ password: string;
confirmPassword: string;
firstname: string;
lastname: string;
}
interface SetupStep {
id: string;
title: string;
subtitle?: string;
}
// ===== HOOK FÜR SETUP-LOGIK =====
const useSetup = () => {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState<SetupFormData>({
password: '', password: '',
confirmPassword: '', confirmPassword: '',
firstname: '', firstname: '',
@@ -16,15 +31,26 @@ const Setup: React.FC = () => {
const [error, setError] = useState(''); const [error, setError] = useState('');
const { checkSetupStatus } = useAuth(); const { checkSetupStatus } = useAuth();
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const steps: SetupStep[] = [
const { name, value } = e.target; {
setFormData(prev => ({ id: 'password-setup',
...prev, title: 'Passwort erstellen',
[name]: value subtitle: 'Legen Sie ein sicheres Passwort fest'
})); },
}; {
id: 'profile-setup',
title: 'Profilinformationen',
subtitle: 'Geben Sie Ihre persönlichen Daten ein'
},
{
id: 'confirmation',
title: 'Bestätigung',
subtitle: 'Setup abschließen'
}
];
const validateStep1 = () => { // ===== VALIDIERUNGS-FUNKTIONEN =====
const validateStep1 = (): boolean => {
if (formData.password.length < 6) { if (formData.password.length < 6) {
setError('Das Passwort muss mindestens 6 Zeichen lang sein.'); setError('Das Passwort muss mindestens 6 Zeichen lang sein.');
return false; return false;
@@ -36,7 +62,7 @@ const Setup: React.FC = () => {
return true; return true;
}; };
const validateStep2 = () => { const validateStep2 = (): boolean => {
if (!formData.firstname.trim()) { if (!formData.firstname.trim()) {
setError('Bitte geben Sie einen Vornamen ein.'); setError('Bitte geben Sie einen Vornamen ein.');
return false; return false;
@@ -48,21 +74,66 @@ const Setup: React.FC = () => {
return true; return true;
}; };
const handleNext = () => { const validateCurrentStep = (stepIndex: number): boolean => {
setError(''); switch (stepIndex) {
if (step === 1 && validateStep1()) { case 0:
setStep(2); return validateStep1();
} else if (step === 2 && validateStep2()) { case 1:
handleSubmit(); return validateStep2();
default:
return true;
} }
}; };
const handleBack = () => { // ===== NAVIGATIONS-FUNKTIONEN =====
const goToNextStep = async (): Promise<void> => {
setError(''); setError('');
setStep(1);
if (!validateCurrentStep(currentStep)) {
return;
}
// Wenn wir beim letzten Schritt sind, Submit ausführen
if (currentStep === steps.length - 1) {
await handleSubmit();
return;
}
// Ansonsten zum nächsten Schritt gehen
setCurrentStep(prev => prev + 1);
}; };
const handleSubmit = async () => { const goToPrevStep = (): void => {
setError('');
if (currentStep > 0) {
setCurrentStep(prev => prev - 1);
}
};
const handleStepChange = (stepIndex: number): void => {
setError('');
// Nur erlauben, zu bereits validierten Schritten zu springen
// oder zum nächsten Schritt nach dem aktuellen
if (stepIndex <= currentStep + 1) {
// Vor dem Wechsel validieren
if (stepIndex > currentStep && !validateCurrentStep(currentStep)) {
return;
}
setCurrentStep(stepIndex);
}
};
// ===== FORM HANDLER =====
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (): Promise<void> => {
try { try {
setLoading(true); setLoading(true);
setError(''); setError('');
@@ -102,8 +173,8 @@ const Setup: React.FC = () => {
} }
}; };
// Helper to display generated email preview // ===== HELPER FUNCTIONS =====
const getEmailPreview = () => { const getEmailPreview = (): string => {
if (!formData.firstname.trim() || !formData.lastname.trim()) { if (!formData.firstname.trim() || !formData.lastname.trim()) {
return 'vorname.nachname@sp.de'; return 'vorname.nachname@sp.de';
} }
@@ -113,55 +184,50 @@ const Setup: React.FC = () => {
return `${cleanFirstname}.${cleanLastname}@sp.de`; return `${cleanFirstname}.${cleanLastname}@sp.de`;
}; };
return ( const isStepCompleted = (stepIndex: number): boolean => {
<div style={{ switch (stepIndex) {
minHeight: '100vh', case 0:
backgroundColor: '#f8f9fa', return formData.password.length >= 6 &&
display: 'flex', formData.password === formData.confirmPassword;
alignItems: 'center', case 1:
justifyContent: 'center', return !!formData.firstname.trim() && !!formData.lastname.trim();
padding: '2rem' default:
}}> return false;
<div style={{ }
backgroundColor: 'white', };
padding: '3rem',
borderRadius: '12px',
boxShadow: '0 10px 30px rgba(0,0,0,0.1)',
width: '100%',
maxWidth: '500px',
border: '1px solid #e9ecef'
}}>
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<h1 style={{
fontSize: '2rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
color: '#2c3e50'
}}>
🚀 Erstkonfiguration
</h1>
<p style={{
color: '#6c757d',
fontSize: '1.1rem'
}}>
Richten Sie Ihren Administrator-Account ein
</p>
</div>
{error && ( return {
<div style={{ // State
backgroundColor: '#f8d7da', currentStep,
border: '1px solid #f5c6cb', formData,
color: '#721c24', loading,
padding: '1rem', error,
borderRadius: '6px', steps,
marginBottom: '1.5rem'
}}>
{error}
</div>
)}
{step === 1 && ( // Actions
goToNextStep,
goToPrevStep,
handleStepChange,
handleInputChange,
// Helpers
getEmailPreview,
isStepCompleted
};
};
// ===== STEP-INHALTS-KOMPONENTEN =====
interface StepContentProps {
formData: SetupFormData;
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
getEmailPreview: () => string;
currentStep: number;
}
const Step1Content: React.FC<StepContentProps> = ({
formData,
onInputChange
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div> <div>
<label style={{ <label style={{
@@ -176,7 +242,7 @@ const Setup: React.FC = () => {
type="password" type="password"
name="password" name="password"
value={formData.password} value={formData.password}
onChange={handleInputChange} onChange={onInputChange}
style={{ style={{
width: '100%', width: '100%',
padding: '0.75rem', padding: '0.75rem',
@@ -187,6 +253,7 @@ const Setup: React.FC = () => {
}} }}
placeholder="Mindestens 6 Zeichen" placeholder="Mindestens 6 Zeichen"
required required
autoComplete="new-password"
/> />
</div> </div>
@@ -203,7 +270,7 @@ const Setup: React.FC = () => {
type="password" type="password"
name="confirmPassword" name="confirmPassword"
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={handleInputChange} onChange={onInputChange}
style={{ style={{
width: '100%', width: '100%',
padding: '0.75rem', padding: '0.75rem',
@@ -214,12 +281,17 @@ const Setup: React.FC = () => {
}} }}
placeholder="Passwort wiederholen" placeholder="Passwort wiederholen"
required required
autoComplete="new-password"
/> />
</div> </div>
</div> </div>
)} );
{step === 2 && ( const Step2Content: React.FC<StepContentProps> = ({
formData,
onInputChange,
getEmailPreview
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div> <div>
<label style={{ <label style={{
@@ -234,7 +306,7 @@ const Setup: React.FC = () => {
type="text" type="text"
name="firstname" name="firstname"
value={formData.firstname} value={formData.firstname}
onChange={handleInputChange} onChange={onInputChange}
style={{ style={{
width: '100%', width: '100%',
padding: '0.75rem', padding: '0.75rem',
@@ -244,6 +316,7 @@ const Setup: React.FC = () => {
}} }}
placeholder="Max" placeholder="Max"
required required
autoComplete="given-name"
/> />
</div> </div>
@@ -260,7 +333,7 @@ const Setup: React.FC = () => {
type="text" type="text"
name="lastname" name="lastname"
value={formData.lastname} value={formData.lastname}
onChange={handleInputChange} onChange={onInputChange}
style={{ style={{
width: '100%', width: '100%',
padding: '0.75rem', padding: '0.75rem',
@@ -270,6 +343,7 @@ const Setup: React.FC = () => {
}} }}
placeholder="Mustermann" placeholder="Mustermann"
required required
autoComplete="family-name"
/> />
</div> </div>
@@ -302,34 +376,221 @@ const Setup: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
);
const Step3Content: React.FC<StepContentProps> = ({
formData,
getEmailPreview
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div style={{
backgroundColor: '#f8f9fa',
padding: '1.5rem',
borderRadius: '8px',
border: '1px solid #e9ecef'
}}>
<h3 style={{
marginBottom: '1rem',
color: '#2c3e50',
fontSize: '1.1rem',
fontWeight: '600'
}}>
Zusammenfassung
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#6c757d' }}>E-Mail:</span>
<span style={{ fontWeight: '500' }}>{getEmailPreview()}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#6c757d' }}>Vorname:</span>
<span style={{ fontWeight: '500' }}>{formData.firstname || '-'}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#6c757d' }}>Nachname:</span>
<span style={{ fontWeight: '500' }}>{formData.lastname || '-'}</span>
</div>
</div>
</div>
<div style={{
padding: '1rem',
backgroundColor: '#e7f3ff',
borderRadius: '6px',
border: '1px solid #b6d7e8',
color: '#2c3e50'
}}>
<strong>💡 Wichtig:</strong> Nach dem Setup können Sie sich mit Ihrer
automatisch generierten E-Mail anmelden.
</div>
</div>
);
// ===== HAUPTKOMPONENTE =====
const Setup: React.FC = () => {
const {
currentStep,
formData,
loading,
error,
steps,
goToNextStep,
goToPrevStep,
handleStepChange,
handleInputChange,
getEmailPreview
} = useSetup();
const renderStepContent = (): React.ReactNode => {
const stepProps = {
formData,
onInputChange: handleInputChange,
getEmailPreview,
currentStep
};
switch (currentStep) {
case 0:
return <Step1Content {...stepProps} />;
case 1:
return <Step2Content {...stepProps} />;
case 2:
return <Step3Content {...stepProps} />;
default:
return null;
}
};
const getNextButtonText = (): string => {
if (loading) return '⏳ Wird verarbeitet...';
switch (currentStep) {
case 0:
return 'Weiter →';
case 1:
return 'Weiter →';
case 2:
return 'Setup abschließen';
default:
return 'Weiter →';
}
};
return (
<div style={{
minHeight: '100vh',
backgroundColor: '#f8f9fa',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem'
}}>
<div style={{
backgroundColor: 'white',
padding: '3rem',
borderRadius: '12px',
boxShadow: '0 10px 30px rgba(0,0,0,0.1)',
width: '100%',
maxWidth: '600px',
border: '1px solid #e9ecef'
}}>
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
<h1 style={{
fontSize: '2rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
color: '#2c3e50'
}}>
🚀 Erstkonfiguration
</h1>
<p style={{
color: '#6c757d',
fontSize: '1.1rem',
marginBottom: '2rem'
}}>
Richten Sie Ihren Administrator-Account ein
</p>
</div>
{/* Aktueller Schritt Titel und Beschreibung */}
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
<h2 style={{
fontSize: '1.5rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
color: '#2c3e50'
}}>
{steps[currentStep].title}
</h2>
{steps[currentStep].subtitle && (
<p style={{
color: '#6c757d',
fontSize: '1rem'
}}>
{steps[currentStep].subtitle}
</p>
)}
</div>
{/* StepSetup Komponente - nur die Steps ohne Beschreibung */}
<div style={{ marginBottom: '2.5rem' }}>
<StepSetup
steps={steps.map(step => ({ ...step, subtitle: undefined }))}
current={currentStep}
onChange={handleStepChange}
orientation="horizontal"
clickable={true}
size="md"
animated={true}
/>
</div>
{/* Fehleranzeige */}
{error && (
<div style={{
backgroundColor: '#f8d7da',
border: '1px solid #f5c6cb',
color: '#721c24',
padding: '1rem',
borderRadius: '6px',
marginBottom: '1.5rem'
}}>
{error}
</div>
)} )}
{/* Schritt-Inhalt */}
<div style={{ minHeight: '200px' }}>
{renderStepContent()}
</div>
{/* Navigations-Buttons */}
<div style={{ <div style={{
marginTop: '2rem', marginTop: '2rem',
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center' alignItems: 'center'
}}> }}>
{step === 2 && (
<button <button
onClick={handleBack} onClick={goToPrevStep}
disabled={loading || currentStep === 0}
style={{ style={{
padding: '0.75rem 1.5rem', padding: '0.75rem 1.5rem',
color: '#6c757d', color: loading || currentStep === 0 ? '#adb5bd' : '#6c757d',
border: '1px solid #6c757d', border: `1px solid ${loading || currentStep === 0 ? '#adb5bd' : '#6c757d'}`,
background: 'none', background: 'none',
borderRadius: '6px', borderRadius: '6px',
cursor: 'pointer', cursor: loading || currentStep === 0 ? 'not-allowed' : 'pointer',
fontWeight: '500' fontWeight: '500',
opacity: loading || currentStep === 0 ? 0.6 : 1
}} }}
disabled={loading}
> >
Zurück Zurück
</button> </button>
)}
<button <button
onClick={handleNext} onClick={goToNextStep}
disabled={loading} disabled={loading}
style={{ style={{
padding: '0.75rem 2rem', padding: '0.75rem 2rem',
@@ -340,28 +601,25 @@ const Setup: React.FC = () => {
cursor: loading ? 'not-allowed' : 'pointer', cursor: loading ? 'not-allowed' : 'pointer',
fontWeight: '600', fontWeight: '600',
fontSize: '1rem', fontSize: '1rem',
marginLeft: step === 1 ? 'auto' : '0',
transition: 'background-color 0.3s ease' transition: 'background-color 0.3s ease'
}} }}
> >
{loading ? '⏳ Wird verarbeitet...' : {getNextButtonText()}
step === 1 ? 'Weiter →' : 'Setup abschließen'}
</button> </button>
</div> </div>
{step === 2 && ( {/* Zusätzliche Informationen */}
{currentStep === 2 && !loading && (
<div style={{ <div style={{
marginTop: '1.5rem', marginTop: '1.5rem',
textAlign: 'center', textAlign: 'center',
color: '#6c757d', color: '#6c757d',
fontSize: '0.9rem', fontSize: '0.9rem',
padding: '1rem', padding: '1rem',
backgroundColor: '#e7f3ff', backgroundColor: '#f8f9fa',
borderRadius: '6px', borderRadius: '6px'
border: '1px solid #b6d7e8'
}}> }}>
💡 Nach dem erfolgreichen Setup werden Sie zur Anmeldeseite weitergeleitet, Überprüfen Sie Ihre Daten, bevor Sie das Setup abschließen
wo Sie sich mit Ihrer automatisch generierten E-Mail anmelden können.
</div> </div>
)} )}
</div> </div>

View File

@@ -23,7 +23,7 @@ export default defineConfig(({ mode }) => {
server: { server: {
port: 3003, port: 3003,
host: true, host: true,
open: isDevelopment, //open: isDevelopment,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3002', target: 'http://localhost:3002',

1487
package-lock.json generated

File diff suppressed because it is too large Load Diff