mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 18:23:42 +00:00
feat: auth context, login/register pages, app layout with routing
- Task 19: TypeScript types for all API entities, axios client with JWT refresh interceptor, auth service, AuthContext provider, ProtectedRoute - Task 20: Login page with MFA support, Register page with invitation token support, German labels, zod validation - Task 21: Responsive sidebar with role-aware navigation, header with user dropdown, AppLayout with Sheet for mobile, full React Router setup with placeholder pages for all routes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
aea967acf8
commit
0767e1ed18
21 changed files with 1766 additions and 4 deletions
|
|
@ -10,6 +10,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -17,9 +18,11 @@
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.71.2",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"tailwind-merge": "^3.5.0"
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@hookform/resolvers':
|
||||||
|
specifier: ^5.2.2
|
||||||
|
version: 5.2.2(react-hook-form@7.71.2(react@19.2.4))
|
||||||
axios:
|
axios:
|
||||||
specifier: ^1.13.5
|
specifier: ^1.13.5
|
||||||
version: 1.13.5
|
version: 1.13.5
|
||||||
|
|
@ -29,6 +32,9 @@ importers:
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.4(react@19.2.4)
|
version: 19.2.4(react@19.2.4)
|
||||||
|
react-hook-form:
|
||||||
|
specifier: ^7.71.2
|
||||||
|
version: 7.71.2(react@19.2.4)
|
||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^7.13.1
|
specifier: ^7.13.1
|
||||||
version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
|
@ -38,6 +44,9 @@ importers:
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
|
zod:
|
||||||
|
specifier: ^4.3.6
|
||||||
|
version: 4.3.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.39.1
|
specifier: ^9.39.1
|
||||||
|
|
@ -460,6 +469,11 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
hono: ^4
|
hono: ^4
|
||||||
|
|
||||||
|
'@hookform/resolvers@5.2.2':
|
||||||
|
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
|
||||||
|
peerDependencies:
|
||||||
|
react-hook-form: ^7.55.0
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
|
|
@ -2905,6 +2919,12 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.4
|
react: ^19.2.4
|
||||||
|
|
||||||
|
react-hook-form@7.71.2:
|
||||||
|
resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
react-is@19.2.4:
|
react-is@19.2.4:
|
||||||
resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==}
|
resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==}
|
||||||
|
|
||||||
|
|
@ -3799,6 +3819,11 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
hono: 4.12.2
|
hono: 4.12.2
|
||||||
|
|
||||||
|
'@hookform/resolvers@5.2.2(react-hook-form@7.71.2(react@19.2.4))':
|
||||||
|
dependencies:
|
||||||
|
'@standard-schema/utils': 0.3.0
|
||||||
|
react-hook-form: 7.71.2(react@19.2.4)
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.7':
|
'@humanfs/node@0.16.7':
|
||||||
|
|
@ -6224,6 +6249,10 @@ snapshots:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
|
react-hook-form@7.71.2(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
react-is@19.2.4: {}
|
react-is@19.2.4: {}
|
||||||
|
|
||||||
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
|
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,43 @@
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from '@/context/AuthContext'
|
||||||
|
import { ProtectedRoute } from '@/components/layout/ProtectedRoute'
|
||||||
|
import { AppLayout } from '@/components/layout/AppLayout'
|
||||||
|
import { LoginPage } from '@/pages/LoginPage'
|
||||||
|
import { RegisterPage } from '@/pages/RegisterPage'
|
||||||
|
|
||||||
|
// Placeholder pages for now
|
||||||
|
function DashboardPage() { return <div className="p-6"><h1 className="text-2xl font-bold">Dashboard</h1></div> }
|
||||||
|
function CasesPage() { return <div className="p-6"><h1 className="text-2xl font-bold">Faelle</h1></div> }
|
||||||
|
function ImportPage() { return <div className="p-6"><h1 className="text-2xl font-bold">Import</h1></div> }
|
||||||
|
function IcdPage() { return <div className="p-6"><h1 className="text-2xl font-bold">ICD-Eingabe</h1></div> }
|
||||||
|
function CodingPage() { return <div className="p-6"><h1 className="text-2xl font-bold">Coding</h1></div> }
|
||||||
|
function ReportsPage() { return <div className="p-6"><h1 className="text-2xl font-bold">Berichte</h1></div> }
|
||||||
|
function AdminUsersPage() { return <div className="p-6"><h1 className="text-2xl font-bold">Benutzer</h1></div> }
|
||||||
|
function AdminInvitationsPage() { return <div className="p-6"><h1 className="text-2xl font-bold">Einladungen</h1></div> }
|
||||||
|
function AdminAuditPage() { return <div className="p-6"><h1 className="text-2xl font-bold">Audit-Log</h1></div> }
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<BrowserRouter>
|
||||||
<h1 className="text-2xl font-bold p-8">DAK Zweitmeinungs-Portal</h1>
|
<AuthProvider>
|
||||||
</div>
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/" element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
|
||||||
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="cases" element={<CasesPage />} />
|
||||||
|
<Route path="import" element={<ProtectedRoute requireAdmin><ImportPage /></ProtectedRoute>} />
|
||||||
|
<Route path="icd" element={<IcdPage />} />
|
||||||
|
<Route path="coding" element={<ProtectedRoute requireAdmin><CodingPage /></ProtectedRoute>} />
|
||||||
|
<Route path="reports" element={<ReportsPage />} />
|
||||||
|
<Route path="admin/users" element={<ProtectedRoute requireAdmin><AdminUsersPage /></ProtectedRoute>} />
|
||||||
|
<Route path="admin/invitations" element={<ProtectedRoute requireAdmin><AdminInvitationsPage /></ProtectedRoute>} />
|
||||||
|
<Route path="admin/audit" element={<ProtectedRoute requireAdmin><AdminAuditPage /></ProtectedRoute>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
34
frontend/src/components/layout/AppLayout.tsx
Normal file
34
frontend/src/components/layout/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Outlet } from 'react-router-dom'
|
||||||
|
import { Sidebar } from './Sidebar'
|
||||||
|
import { Header } from './Header'
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
|
||||||
|
|
||||||
|
export function AppLayout() {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
{/* Desktop sidebar */}
|
||||||
|
<div className="hidden lg:flex lg:w-64 lg:flex-col lg:border-r">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile sidebar */}
|
||||||
|
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||||
|
<SheetContent side="left" className="w-64 p-0" showCloseButton={false}>
|
||||||
|
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||||
|
<Sidebar className="h-full" />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<Header onToggleSidebar={() => setSidebarOpen(true)} />
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
frontend/src/components/layout/Header.tsx
Normal file
85
frontend/src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Bell, LogOut, Menu } from 'lucide-react'
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
onToggleSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ onToggleSidebar }: HeaderProps) {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
|
const initials = user?.username
|
||||||
|
? user.username
|
||||||
|
.split(/[\s._-]/)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
: '??'
|
||||||
|
|
||||||
|
const roleBadgeVariant = user?.role === 'admin' ? 'default' as const : 'secondary' as const
|
||||||
|
const roleLabel = user?.role === 'admin' ? 'Admin' : 'DAK Mitarbeiter'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center gap-4 border-b bg-background px-4 lg:px-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="lg:hidden"
|
||||||
|
onClick={onToggleSidebar}
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Navigation umschalten</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Notification bell placeholder (Task 27) */}
|
||||||
|
<Button variant="ghost" size="icon" className="relative">
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Benachrichtigungen</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* User menu */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="flex items-center gap-2 px-2">
|
||||||
|
<Avatar size="sm">
|
||||||
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="hidden text-sm font-medium md:inline-block">
|
||||||
|
{user?.username}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">{user?.username}</p>
|
||||||
|
<p className="text-xs leading-none text-muted-foreground">{user?.email}</p>
|
||||||
|
<Badge variant={roleBadgeVariant} className="mt-1 w-fit">
|
||||||
|
{roleLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => logout()} className="cursor-pointer">
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
Abmelden
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
frontend/src/components/layout/ProtectedRoute.tsx
Normal file
15
frontend/src/components/layout/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children, requireAdmin = false }: {
|
||||||
|
children: React.ReactNode
|
||||||
|
requireAdmin?: boolean
|
||||||
|
}) {
|
||||||
|
const { user, isLoading } = useAuth()
|
||||||
|
|
||||||
|
if (isLoading) return <div className="flex items-center justify-center h-screen">Laden...</div>
|
||||||
|
if (!user) return <Navigate to="/login" replace />
|
||||||
|
if (requireAdmin && user.role !== 'admin') return <div className="p-8">Zugriff verweigert</div>
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
101
frontend/src/components/layout/Sidebar.tsx
Normal file
101
frontend/src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
FolderOpen,
|
||||||
|
Upload,
|
||||||
|
FileEdit,
|
||||||
|
ClipboardCheck,
|
||||||
|
FileBarChart,
|
||||||
|
Users,
|
||||||
|
Mail,
|
||||||
|
History,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
label: string
|
||||||
|
to: string
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
adminOnly?: boolean
|
||||||
|
mitarbeiterOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainNavItems: NavItem[] = [
|
||||||
|
{ label: 'Dashboard', to: '/dashboard', icon: LayoutDashboard },
|
||||||
|
{ label: 'Faelle', to: '/cases', icon: FolderOpen },
|
||||||
|
{ label: 'Import', to: '/import', icon: Upload, adminOnly: true },
|
||||||
|
{ label: 'ICD-Eingabe', to: '/icd', icon: FileEdit, mitarbeiterOnly: true },
|
||||||
|
{ label: 'Coding', to: '/coding', icon: ClipboardCheck, adminOnly: true },
|
||||||
|
{ label: 'Berichte', to: '/reports', icon: FileBarChart },
|
||||||
|
]
|
||||||
|
|
||||||
|
const adminNavItems: NavItem[] = [
|
||||||
|
{ label: 'Benutzer', to: '/admin/users', icon: Users },
|
||||||
|
{ label: 'Einladungen', to: '/admin/invitations', icon: Mail },
|
||||||
|
{ label: 'Audit-Log', to: '/admin/audit', icon: History },
|
||||||
|
]
|
||||||
|
|
||||||
|
function NavItemLink({ item }: { item: NavItem }) {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ className }: { className?: string }) {
|
||||||
|
const { user, isAdmin } = useAuth()
|
||||||
|
|
||||||
|
const visibleMainItems = mainNavItems.filter((item) => {
|
||||||
|
if (item.adminOnly && !isAdmin) return false
|
||||||
|
if (item.mitarbeiterOnly && user?.role !== 'dak_mitarbeiter') return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={cn('flex h-full flex-col gap-4 p-4', className)}>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground font-bold text-sm">
|
||||||
|
D
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-lg">DAK Portal</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-1">
|
||||||
|
{visibleMainItems.map((item) => (
|
||||||
|
<NavItemLink key={item.to} item={item} />
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="px-3 py-1">
|
||||||
|
<span className="text-xs font-semibold uppercase text-muted-foreground tracking-wider">
|
||||||
|
Administration
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<nav className="flex flex-col gap-1">
|
||||||
|
{adminNavItems.map((item) => (
|
||||||
|
<NavItemLink key={item.to} item={item} />
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
frontend/src/components/ui/alert.tsx
Normal file
66
frontend/src/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
107
frontend/src/components/ui/avatar.tsx
Normal file
107
frontend/src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||||
|
size?: "default" | "sm" | "lg"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarBadge,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
}
|
||||||
48
frontend/src/components/ui/badge.tsx
Normal file
48
frontend/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
257
frontend/src/components/ui/dropdown-menu.tsx
Normal file
257
frontend/src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
167
frontend/src/components/ui/form.tsx
Normal file
167
frontend/src/components/ui/form.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import type { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
useFormState,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState } = useFormContext()
|
||||||
|
const formState = useFormState({ name: fieldContext.name })
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="form-label"
|
||||||
|
data-error={!!error}
|
||||||
|
className={cn("data-[error=true]:text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot.Root>) {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot.Root
|
||||||
|
data-slot="form-control"
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-description"
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message ?? "") : props.children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-message"
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-destructive text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
26
frontend/src/components/ui/separator.tsx
Normal file
26
frontend/src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
143
frontend/src/components/ui/sheet.tsx
Normal file
143
frontend/src/components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
55
frontend/src/components/ui/tooltip.tsx
Normal file
55
frontend/src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
67
frontend/src/context/AuthContext.tsx
Normal file
67
frontend/src/context/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
||||||
|
import type { User, LoginRequest, RegisterRequest } from '@/types'
|
||||||
|
import * as authService from '@/services/authService'
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null
|
||||||
|
isLoading: boolean
|
||||||
|
isAdmin: boolean
|
||||||
|
login: (data: LoginRequest) => Promise<void>
|
||||||
|
register: (data: RegisterRequest) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) {
|
||||||
|
authService.getMe()
|
||||||
|
.then(setUser)
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false))
|
||||||
|
} else {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loginFn = async (data: LoginRequest) => {
|
||||||
|
const result = await authService.login(data)
|
||||||
|
setUser(result.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerFn = async (data: RegisterRequest) => {
|
||||||
|
await authService.register(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoutFn = async () => {
|
||||||
|
await authService.logout()
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
isAdmin: user?.role === 'admin',
|
||||||
|
login: loginFn,
|
||||||
|
register: registerFn,
|
||||||
|
logout: logoutFn,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (!context) throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
return context
|
||||||
|
}
|
||||||
150
frontend/src/pages/LoginPage.tsx
Normal file
150
frontend/src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Bitte geben Sie eine gueltige E-Mail-Adresse ein'),
|
||||||
|
password: z.string().min(1, 'Passwort ist erforderlich'),
|
||||||
|
mfa_code: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type LoginFormValues = z.infer<typeof loginSchema>
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showMfa, setShowMfa] = useState(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginFormValues>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
mfa_code: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginFormValues) => {
|
||||||
|
setError(null)
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await login({
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
mfa_code: data.mfa_code || undefined,
|
||||||
|
})
|
||||||
|
navigate('/dashboard')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const axiosError = err as { response?: { data?: { detail?: string }; status?: number } }
|
||||||
|
const detail = axiosError.response?.data?.detail || ''
|
||||||
|
|
||||||
|
if (detail.toLowerCase().includes('mfa') || detail.toLowerCase().includes('2fa')) {
|
||||||
|
setShowMfa(true)
|
||||||
|
setError('Bitte geben Sie Ihren MFA-Code ein')
|
||||||
|
} else if (axiosError.response?.status === 401) {
|
||||||
|
setError('Ungueltige Anmeldedaten')
|
||||||
|
} else if (axiosError.response?.status === 423) {
|
||||||
|
setError('Konto gesperrt. Bitte kontaktieren Sie den Administrator.')
|
||||||
|
} else {
|
||||||
|
setError(detail || 'Ein Fehler ist aufgetreten')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">DAK Portal</CardTitle>
|
||||||
|
<CardDescription>Melden Sie sich mit Ihrem Konto an</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">E-Mail</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="name@dak.de"
|
||||||
|
autoComplete="email"
|
||||||
|
{...register('email')}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showMfa && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="mfa_code">MFA-Code</Label>
|
||||||
|
<Input
|
||||||
|
id="mfa_code"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="123456"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
{...register('mfa_code')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Wird angemeldet...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Anmelden'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Noch kein Konto?{' '}
|
||||||
|
<Link to="/register" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Registrieren
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
frontend/src/pages/RegisterPage.tsx
Normal file
166
frontend/src/pages/RegisterPage.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
username: z.string().min(3, 'Benutzername muss mindestens 3 Zeichen lang sein'),
|
||||||
|
email: z.string().email('Bitte geben Sie eine gueltige E-Mail-Adresse ein'),
|
||||||
|
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein'),
|
||||||
|
})
|
||||||
|
|
||||||
|
type RegisterFormValues = z.infer<typeof registerSchema>
|
||||||
|
|
||||||
|
export function RegisterPage() {
|
||||||
|
const { register: registerUser } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const invitationToken = searchParams.get('token') || undefined
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<RegisterFormValues>({
|
||||||
|
resolver: zodResolver(registerSchema),
|
||||||
|
defaultValues: {
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = async (data: RegisterFormValues) => {
|
||||||
|
setError(null)
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await registerUser({
|
||||||
|
username: data.username,
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
invitation_token: invitationToken,
|
||||||
|
})
|
||||||
|
setSuccess(true)
|
||||||
|
setTimeout(() => navigate('/login'), 2000)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const axiosError = err as { response?: { data?: { detail?: string } } }
|
||||||
|
const detail = axiosError.response?.data?.detail || ''
|
||||||
|
setError(detail || 'Registrierung fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Konto erstellen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Registrieren Sie sich fuer das DAK Portal
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{success ? (
|
||||||
|
<Alert>
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Registrierung erfolgreich! Sie werden zur Anmeldung weitergeleitet...
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Benutzername</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
{...register('username')}
|
||||||
|
/>
|
||||||
|
{errors.username && (
|
||||||
|
<p className="text-sm text-destructive">{errors.username.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">E-Mail</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="name@dak.de"
|
||||||
|
autoComplete="email"
|
||||||
|
{...register('email')}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!invitationToken && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Hinweis: Ohne Einladungstoken ist die Registrierung auf @dak.de E-Mail-Adressen beschraenkt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{invitationToken && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Sie registrieren sich mit einem Einladungstoken.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Wird registriert...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Registrieren'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Bereits ein Konto?{' '}
|
||||||
|
<Link to="/login" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Anmelden
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
frontend/src/services/api.ts
Normal file
44
frontend/src/services/api.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request interceptor: attach access token
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// Response interceptor: handle 401 with token refresh
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true
|
||||||
|
try {
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token')
|
||||||
|
if (!refreshToken) throw new Error('No refresh token')
|
||||||
|
|
||||||
|
const { data } = await axios.post('/api/auth/refresh', {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
localStorage.setItem('access_token', data.access_token)
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${data.access_token}`
|
||||||
|
return api(originalRequest)
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
31
frontend/src/services/authService.ts
Normal file
31
frontend/src/services/authService.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import api from './api'
|
||||||
|
import type { LoginRequest, RegisterRequest, TokenResponse, User } from '@/types'
|
||||||
|
|
||||||
|
export async function login(data: LoginRequest): Promise<TokenResponse> {
|
||||||
|
const response = await api.post<TokenResponse>('/auth/login', data)
|
||||||
|
localStorage.setItem('access_token', response.data.access_token)
|
||||||
|
localStorage.setItem('refresh_token', response.data.refresh_token)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function register(data: RegisterRequest): Promise<{ user: User }> {
|
||||||
|
const response = await api.post<{ user: User }>('/auth/register', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token')
|
||||||
|
if (refreshToken) {
|
||||||
|
await api.post('/auth/logout', { refresh_token: refreshToken })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe(): Promise<User> {
|
||||||
|
const response = await api.get<User>('/auth/me')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
133
frontend/src/types/index.ts
Normal file
133
frontend/src/types/index.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
role: 'admin' | 'dak_mitarbeiter'
|
||||||
|
mfa_enabled: boolean
|
||||||
|
is_active: boolean
|
||||||
|
last_login: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
mfa_code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
invitation_token?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
token_type: string
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Case {
|
||||||
|
id: number
|
||||||
|
fall_id: string | null
|
||||||
|
crm_ticket_id: string | null
|
||||||
|
jahr: number
|
||||||
|
kw: number
|
||||||
|
datum: string
|
||||||
|
anrede: string | null
|
||||||
|
vorname: string | null
|
||||||
|
nachname: string
|
||||||
|
geburtsdatum: string | null
|
||||||
|
kvnr: string | null
|
||||||
|
versicherung: string
|
||||||
|
icd: string | null
|
||||||
|
fallgruppe: string
|
||||||
|
unterlagen: boolean
|
||||||
|
gutachten: boolean
|
||||||
|
gutachter: string | null
|
||||||
|
gutachten_typ: string | null
|
||||||
|
therapieaenderung: string | null
|
||||||
|
ablehnung: boolean
|
||||||
|
abbruch: boolean
|
||||||
|
kurzbeschreibung: string | null
|
||||||
|
fragestellung: string | null
|
||||||
|
kommentar: string | null
|
||||||
|
abgerechnet: boolean
|
||||||
|
import_source: string | null
|
||||||
|
imported_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaseListResponse {
|
||||||
|
items: Case[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
per_page: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardKPIs {
|
||||||
|
total_cases: number
|
||||||
|
pending_icd: number
|
||||||
|
pending_coding: number
|
||||||
|
total_gutachten: number
|
||||||
|
fallgruppen: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyDataPoint {
|
||||||
|
kw: number
|
||||||
|
erstberatungen: number
|
||||||
|
unterlagen: number
|
||||||
|
ablehnungen: number
|
||||||
|
keine_rm: number
|
||||||
|
gutachten: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardResponse {
|
||||||
|
kpis: DashboardKPIs
|
||||||
|
weekly: WeeklyDataPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: number
|
||||||
|
notification_type: string
|
||||||
|
title: string
|
||||||
|
message: string | null
|
||||||
|
is_read: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportPreview {
|
||||||
|
filename: string
|
||||||
|
total_rows: number
|
||||||
|
new_cases: number
|
||||||
|
duplicates: number
|
||||||
|
errors: string[]
|
||||||
|
rows: ImportRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportRow {
|
||||||
|
row_number: number
|
||||||
|
nachname: string
|
||||||
|
vorname: string | null
|
||||||
|
fallgruppe: string
|
||||||
|
datum: string
|
||||||
|
is_duplicate: boolean
|
||||||
|
fall_id: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
imported: number
|
||||||
|
skipped: number
|
||||||
|
updated: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportMeta {
|
||||||
|
id: number
|
||||||
|
jahr: number
|
||||||
|
kw: number
|
||||||
|
report_date: string
|
||||||
|
generated_at: string
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue