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:
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user