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:
CCS Admin 2026-02-24 08:21:00 +00:00
parent aea967acf8
commit 0767e1ed18
21 changed files with 1766 additions and 4 deletions

View file

@ -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": [

View file

@ -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):

View file

@ -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>
) )
} }

View 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>
)
}

View 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>
)
}

View 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}</>
}

View 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>
)
}

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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,
}

View 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 }

View 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,
}

View 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 }

View 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
}

View 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>
)
}

View 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>
)
}

View 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

View 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
View 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
}