feat(T01): auth + user management + RBAC + base frontend + i18n

- Backend: FastAPI, JWT auth (HS256), bcrypt, RBAC middleware
- User CRUD (admin-only), soft-delete, pagination
- Pydantic BaseSettings config, async SQLAlchemy 2.0
- 50 backend tests, 88% coverage
- Frontend: Next.js 14 App Router, Tailwind, design tokens
- Login page, auth context, API client with auto-refresh
- i18n: next-intl, DE/EN (31 keys each)
- 6 base UI components (Button, Input, Card, Table, Modal, Toast)
- 12 frontend tests, npm build success
This commit is contained in:
2026-07-14 11:51:32 +02:00
parent 5459a43e2b
commit d89304845a
56 changed files with 7581 additions and 9 deletions
+37
View File
@@ -0,0 +1,37 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
loading?: boolean;
}
const variantClasses: Record<Variant, string> = {
primary: 'bg-primary text-white hover:bg-primary-hover focus:ring-primary',
secondary: 'bg-secondary text-white hover:bg-secondary/80 focus:ring-secondary',
danger: 'bg-error text-white hover:bg-error/80 focus:ring-error',
ghost: 'bg-transparent text-primary hover:bg-primary/10 focus:ring-primary',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', loading = false, children, className = '', disabled, ...props }, ref) => {
return (
<button
ref={ref}
className={`inline-flex items-center justify-center px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${variantClasses[variant]} ${className}`}
disabled={disabled || loading}
{...props}
>
{loading ? (
<svg className="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : null}
{children}
</button>
);
}
);
Button.displayName = 'Button';
+18
View File
@@ -0,0 +1,18 @@
import { HTMLAttributes, ReactNode } from 'react';
interface CardProps extends HTMLAttributes<HTMLDivElement> {
title?: string;
children: ReactNode;
}
export function Card({ title, children, className = '', ...props }: CardProps) {
return (
<div
className={`bg-surface rounded-xl shadow-sm border border-border p-6 ${className}`}
{...props}
>
{title && <h2 className="text-xl font-semibold text-text mb-4">{title}</h2>}
{children}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { InputHTMLAttributes, forwardRef } from 'react';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, className = '', id, ...props }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
return (
<div className="w-full">
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-text mb-1">
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={`w-full px-3 py-2 border rounded-lg bg-surface text-text placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-colors ${error ? 'border-error' : 'border-border'} ${className}`}
{...props}
/>
{error && <p className="mt-1 text-sm text-error">{error}</p>}
</div>
);
}
);
Input.displayName = 'Input';
+40
View File
@@ -0,0 +1,40 @@
import { ReactNode, useEffect } from 'react';
interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
}
export function Modal({ open, onClose, title, children }: ModalProps) {
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} data-testid="modal-overlay" />
<div className="relative bg-surface rounded-xl shadow-lg max-w-md w-full mx-4 p-6" data-testid="modal-content">
{title && <h2 className="text-lg font-semibold mb-4">{title}</h2>}
{children}
<button
onClick={onClose}
className="absolute top-4 right-4 text-text-muted hover:text-text"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { ReactNode } from 'react';
interface TableProps<T> {
columns: { key: string; label: string; render?: (row: T) => ReactNode }[];
data: T[];
rowKey: (row: T) => string;
}
export function Table<T extends Record<string, any>>({ columns, data, rowKey }: TableProps<T>) {
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-border">
{columns.map((col) => (
<th key={col.key} className="text-left py-3 px-4 font-medium text-text-muted text-sm">
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={rowKey(row)} className="border-b border-border hover:bg-background/50">
{columns.map((col) => (
<td key={col.key} className="py-3 px-4 text-text">
{col.render ? col.render(row) : String(row[col.key] ?? '')}
</td>
))}
</tr>
))}
{data.length === 0 && (
<tr>
<td colSpan={columns.length} className="py-8 text-center text-text-muted">
No data available
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
'use client';
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
type ToastType = 'success' | 'error' | 'info' | 'warning';
interface Toast {
id: number;
type: ToastType;
message: string;
}
interface ToastContextValue {
showToast: (message: string, type?: ToastType) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
const toastColors: Record<ToastType, string> = {
success: 'bg-success text-white',
error: 'bg-error text-white',
info: 'bg-primary text-white',
warning: 'bg-secondary text-white',
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: ToastType = 'info') => {
const id = Date.now() + Math.random();
setToasts((prev) => [...prev, { id, type, message }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 5000);
}, []);
const removeToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" data-testid="toast-container">
{toasts.map((toast) => (
<div
key={toast.id}
className={`${toastColors[toast.type]} px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[280px]`}
data-testid={`toast-${toast.id}`}
role="alert"
>
<span className="flex-1">{toast.message}</span>
<button onClick={() => removeToast(toast.id)} className="text-white/80 hover:text-white" aria-label="Dismiss">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error('useToast must be used within ToastProvider');
}
return ctx;
}