diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index ca6c3c7..887da42 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -8,3 +8,4 @@ JWT_ACCESS_EXPIRES_IN="15m" JWT_REFRESH_EXPIRES_IN="7d" PORT=3001 +FRONTEND_ORIGIN="http://localhost:3000" diff --git a/ApiCore-Backend/src/app.config.ts b/ApiCore-Backend/src/app.config.ts index 33bc44b..b9895a1 100644 --- a/ApiCore-Backend/src/app.config.ts +++ b/ApiCore-Backend/src/app.config.ts @@ -11,6 +11,11 @@ export function configureApp(app: INestApplication): void { app.use(helmet()); app.use(cookieParser()); + app.enableCors({ + origin: process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000', + credentials: true, + }); + app.setGlobalPrefix('api', { exclude: [ { path: 'health', method: RequestMethod.GET }, diff --git a/ApiCore-Frontend/proxy.ts b/ApiCore-Frontend/proxy.ts new file mode 100644 index 0000000..60d2189 --- /dev/null +++ b/ApiCore-Frontend/proxy.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +const SESSION_COOKIE = "refreshToken"; + +export function proxy(request: NextRequest) { + const hasSession = request.cookies.has(SESSION_COOKIE); + const { pathname } = request.nextUrl; + + if (pathname.startsWith("/login")) { + if (hasSession) { + return NextResponse.redirect(new URL("/settings/users", request.url)); + } + return NextResponse.next(); + } + + if (!hasSession) { + return NextResponse.redirect(new URL("/login", request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/login", "/settings/:path*"], +}; diff --git a/ApiCore-Frontend/public/icon.svg b/ApiCore-Frontend/public/icon.svg new file mode 100644 index 0000000..7593cc6 --- /dev/null +++ b/ApiCore-Frontend/public/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ApiCore-Frontend/src/app/(app)/layout.tsx b/ApiCore-Frontend/src/app/(app)/layout.tsx new file mode 100644 index 0000000..bdbd915 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/layout.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { ReactNode, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth/auth-context"; +import { Button } from "@/components/ui/button"; + +export default function AppLayout({ children }: { children: ReactNode }) { + const router = useRouter(); + const { user, isLoading, logout } = useAuth(); + + useEffect(() => { + if (!isLoading && !user) { + router.replace("/login"); + } + }, [isLoading, user, router]); + + if (isLoading || !user) { + return null; + } + + return ( +
+
+ ApiCore +
+ {user.email} + +
+
+
{children}
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/layout.tsx b/ApiCore-Frontend/src/app/(app)/settings/layout.tsx new file mode 100644 index 0000000..85a0ee3 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/layout.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { ReactNode } from "react"; +import { useAuth } from "@/lib/auth/auth-context"; + +export default function SettingsLayout({ children }: { children: ReactNode }) { + const { user } = useAuth(); + + if (user?.role !== "ADMIN") { + return ( +
+

+ Diese Seite ist nur für Administratoren verfügbar. +

+
+ ); + } + + return <>{children}; +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/users/create-user-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/users/create-user-dialog.tsx new file mode 100644 index 0000000..44ed10e --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/users/create-user-dialog.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createUser } from "@/lib/users/api"; +import { Role } from "@/lib/users/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +const ROLES: Role[] = ["ADMIN", "BEEKEEPER", "STAFF", "READONLY", "API_CLIENT"]; + +export function CreateUserDialog() { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [name, setName] = useState(""); + const [role, setRole] = useState("STAFF"); + const [error, setError] = useState(null); + + const mutation = useMutation({ + mutationFn: createUser, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["users"] }); + setOpen(false); + setEmail(""); + setPassword(""); + setName(""); + setRole("STAFF"); + setError(null); + }, + onError: () => { + setError("Benutzer konnte nicht angelegt werden."); + }, + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + mutation.mutate({ email, password, name, role }); + } + + return ( + + + Neuer Benutzer} /> + + + Neuen Benutzer anlegen + +
+
+ + setName(event.target.value)} + /> +
+
+ + setEmail(event.target.value)} + /> +
+
+ + setPassword(event.target.value)} + /> +
+
+ + +
+ {error ? ( +

+ {error} +

+ ) : null} + + + +
+
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/users/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/users/page.spec.tsx new file mode 100644 index 0000000..f6d2472 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/users/page.spec.tsx @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; +import UsersPage from "./page"; +import { deactivateUser, fetchUsers } from "@/lib/users/api"; + +vi.mock("@/lib/users/api", () => ({ + fetchUsers: vi.fn(), + deactivateUser: vi.fn(), + createUser: vi.fn(), +})); + +function renderWithClient(ui: ReactElement) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +describe("UsersPage", () => { + beforeEach(() => { + vi.mocked(fetchUsers).mockReset(); + vi.mocked(deactivateUser).mockReset(); + }); + + it("renders the fetched users", async () => { + vi.mocked(fetchUsers).mockResolvedValueOnce([ + { + id: "1", + email: "a@b.com", + name: "Admin", + role: "ADMIN", + isActive: true, + createdAt: "", + deactivatedAt: null, + }, + ]); + + renderWithClient(); + + expect(await screen.findByText("a@b.com")).toBeInTheDocument(); + expect(screen.getByText("Aktiv")).toBeInTheDocument(); + }); + + it("shows an error message when loading users fails", async () => { + vi.mocked(fetchUsers).mockRejectedValueOnce(new Error("boom")); + + renderWithClient(); + + expect(await screen.findByRole("alert")).toHaveTextContent(/nicht geladen/i); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/users/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/users/page.tsx new file mode 100644 index 0000000..f0ae041 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/users/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { deactivateUser, fetchUsers } from "@/lib/users/api"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { CreateUserDialog } from "./create-user-dialog"; + +export default function UsersPage() { + const queryClient = useQueryClient(); + const { data: users, isLoading, isError } = useQuery({ + queryKey: ["users"], + queryFn: fetchUsers, + }); + + const deactivateMutation = useMutation({ + mutationFn: deactivateUser, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["users"] }); + }, + }); + + return ( +
+
+

Benutzerverwaltung

+ +
+ + {isLoading ?

Lädt…

: null} + {isError ? ( +

+ Benutzer konnten nicht geladen werden. +

+ ) : null} + + {users ? ( + + + + Name + E-Mail + Rolle + Status + Aktion + + + + {users.map((user) => ( + + {user.name} + {user.email} + {user.role} + + + {user.isActive ? "Aktiv" : "Deaktiviert"} + + + + + + + ))} + +
+ ) : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx b/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx new file mode 100644 index 0000000..e925bf3 --- /dev/null +++ b/ApiCore-Frontend/src/app/(auth)/login/page.spec.tsx @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import LoginPage from "./page"; + +const pushMock = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock }), +})); + +const loginMock = vi.fn(); +vi.mock("@/lib/auth/auth-context", () => ({ + useAuth: () => ({ login: loginMock, user: null, isLoading: false, logout: vi.fn() }), +})); + +describe("LoginPage", () => { + beforeEach(() => { + pushMock.mockReset(); + loginMock.mockReset(); + }); + + it("submits credentials and redirects on success", async () => { + loginMock.mockResolvedValueOnce(undefined); + render(); + + await userEvent.type(screen.getByLabelText("E-Mail"), "admin@apicore.local"); + await userEvent.type(screen.getByLabelText("Passwort"), "ChangeMe123!"); + await userEvent.click(screen.getByRole("button", { name: /anmelden/i })); + + expect(loginMock).toHaveBeenCalledWith("admin@apicore.local", "ChangeMe123!"); + expect(pushMock).toHaveBeenCalledWith("/settings/users"); + }); + + it("shows an error message when login fails", async () => { + loginMock.mockRejectedValueOnce(new Error("nope")); + render(); + + await userEvent.type(screen.getByLabelText("E-Mail"), "admin@apicore.local"); + await userEvent.type(screen.getByLabelText("Passwort"), "wrong-password"); + await userEvent.click(screen.getByRole("button", { name: /anmelden/i })); + + expect(await screen.findByRole("alert")).toHaveTextContent(/fehlgeschlagen/i); + }); +}); diff --git a/ApiCore-Frontend/src/app/(auth)/login/page.tsx b/ApiCore-Frontend/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..97fcd1b --- /dev/null +++ b/ApiCore-Frontend/src/app/(auth)/login/page.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth/auth-context"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export default function LoginPage() { + const router = useRouter(); + const { login } = useAuth(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setIsSubmitting(true); + try { + await login(email, password); + router.push("/settings/users"); + } catch { + setError("Login fehlgeschlagen. Bitte E-Mail und Passwort prüfen."); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+ + + ApiCore Login + Digitale Imkereiverwaltung + + +
+
+ + setEmail(event.target.value)} + /> +
+
+ + setPassword(event.target.value)} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+
+
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/manifest.ts b/ApiCore-Frontend/src/app/manifest.ts index 606578b..8d13d24 100644 --- a/ApiCore-Frontend/src/app/manifest.ts +++ b/ApiCore-Frontend/src/app/manifest.ts @@ -11,14 +11,9 @@ export default function manifest(): MetadataRoute.Manifest { theme_color: "#0a0a0a", icons: [ { - src: "/icon-192x192.png", - sizes: "192x192", - type: "image/png", - }, - { - src: "/icon-512x512.png", - sizes: "512x512", - type: "image/png", + src: "/icon.svg", + sizes: "any", + type: "image/svg+xml", }, ], }; diff --git a/ApiCore-Frontend/src/app/providers.tsx b/ApiCore-Frontend/src/app/providers.tsx index b7a738a..1f56b8a 100644 --- a/ApiCore-Frontend/src/app/providers.tsx +++ b/ApiCore-Frontend/src/app/providers.tsx @@ -2,9 +2,14 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState } from "react"; +import { AuthProvider } from "@/lib/auth/auth-context"; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient()); - return {children}; + return ( + + {children} + + ); } diff --git a/ApiCore-Frontend/src/lib/auth/api-client.ts b/ApiCore-Frontend/src/lib/auth/api-client.ts new file mode 100644 index 0000000..f017c94 --- /dev/null +++ b/ApiCore-Frontend/src/lib/auth/api-client.ts @@ -0,0 +1,53 @@ +import { getAccessToken, setAccessToken } from './token-store'; +import { refreshRequest } from './api'; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1'; + +export class ApiError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +async function rawFetch(path: string, init: RequestInit): Promise { + const token = getAccessToken(); + return fetch(`${API_URL}${path}`, { + ...init, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...init.headers, + }, + }); +} + +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + let response = await rawFetch(path, init); + + if (response.status === 401) { + const refreshed = await refreshRequest(); + if (refreshed) { + setAccessToken(refreshed.accessToken); + response = await rawFetch(path, init); + } + } + + if (!response.ok) { + const body: unknown = await response.json().catch(() => ({})); + const message = + typeof body === 'object' && body !== null && 'message' in body + ? String((body as { message: unknown }).message) + : 'Request failed'; + throw new ApiError(response.status, message); + } + + if (response.status === 204) { + return undefined as T; + } + + return response.json() as Promise; +} diff --git a/ApiCore-Frontend/src/lib/auth/api.ts b/ApiCore-Frontend/src/lib/auth/api.ts new file mode 100644 index 0000000..be022ab --- /dev/null +++ b/ApiCore-Frontend/src/lib/auth/api.ts @@ -0,0 +1,34 @@ +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1'; + +export interface AccessTokenResponse { + accessToken: string; +} + +export async function loginRequest(email: string, password: string): Promise { + const response = await fetch(`${API_URL}/auth/login`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + if (!response.ok) { + throw new Error('Login fehlgeschlagen. Bitte E-Mail und Passwort prüfen.'); + } + return response.json() as Promise; +} + +export async function refreshRequest(signal?: AbortSignal): Promise { + const response = await fetch(`${API_URL}/auth/refresh`, { + method: 'POST', + credentials: 'include', + signal, + }); + if (!response.ok) { + return null; + } + return response.json() as Promise; +} + +export async function logoutRequest(): Promise { + await fetch(`${API_URL}/auth/logout`, { method: 'POST', credentials: 'include' }); +} diff --git a/ApiCore-Frontend/src/lib/auth/auth-context.tsx b/ApiCore-Frontend/src/lib/auth/auth-context.tsx new file mode 100644 index 0000000..d0717e5 --- /dev/null +++ b/ApiCore-Frontend/src/lib/auth/auth-context.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { createContext, useCallback, useContext, useEffect, useState, ReactNode } from 'react'; +import { loginRequest, logoutRequest, refreshRequest } from './api'; +import { decodeAccessToken } from './jwt'; +import { setAccessToken as storeAccessToken } from './token-store'; + +export interface AuthUser { + id: string; + email: string; + role: string; +} + +interface AuthContextValue { + user: AuthUser | null; + isLoading: boolean; + login: (email: string, password: string) => Promise; + logout: () => Promise; +} + +const AuthContext = createContext(undefined); + +function applyAccessToken(token: string): AuthUser | null { + storeAccessToken(token); + const claims = decodeAccessToken(token); + if (!claims) { + return null; + } + return { id: claims.sub, email: claims.email, role: claims.role }; +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const controller = new AbortController(); + refreshRequest(controller.signal) + .then((result) => { + if (!result) { + return; + } + setUser(applyAccessToken(result.accessToken)); + }) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + throw error; + }) + .finally(() => { + if (!controller.signal.aborted) { + setIsLoading(false); + } + }); + return () => { + controller.abort(); + }; + }, []); + + const login = useCallback(async (email: string, password: string) => { + const result = await loginRequest(email, password); + setUser(applyAccessToken(result.accessToken)); + }, []); + + const logout = useCallback(async () => { + await logoutRequest(); + storeAccessToken(null); + setUser(null); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/ApiCore-Frontend/src/lib/auth/jwt.spec.ts b/ApiCore-Frontend/src/lib/auth/jwt.spec.ts new file mode 100644 index 0000000..d3a0a9e --- /dev/null +++ b/ApiCore-Frontend/src/lib/auth/jwt.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { decodeAccessToken } from "./jwt"; + +function makeToken(payload: object): string { + const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const body = btoa(JSON.stringify(payload)); + return `${header}.${body}.signature`; +} + +describe("decodeAccessToken", () => { + it("decodes a valid JWT payload", () => { + const token = makeToken({ sub: "user-1", email: "a@b.com", role: "ADMIN", exp: 123 }); + expect(decodeAccessToken(token)).toEqual({ + sub: "user-1", + email: "a@b.com", + role: "ADMIN", + exp: 123, + }); + }); + + it("returns null for a malformed token", () => { + expect(decodeAccessToken("not-a-jwt")).toBeNull(); + }); +}); diff --git a/ApiCore-Frontend/src/lib/auth/jwt.ts b/ApiCore-Frontend/src/lib/auth/jwt.ts new file mode 100644 index 0000000..723edd8 --- /dev/null +++ b/ApiCore-Frontend/src/lib/auth/jwt.ts @@ -0,0 +1,17 @@ +export interface AccessTokenClaims { + sub: string; + email: string; + role: string; + exp: number; +} + +export function decodeAccessToken(token: string): AccessTokenClaims | null { + try { + const payload = token.split('.')[1]; + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + const json = atob(base64); + return JSON.parse(json) as AccessTokenClaims; + } catch { + return null; + } +} diff --git a/ApiCore-Frontend/src/lib/auth/token-store.ts b/ApiCore-Frontend/src/lib/auth/token-store.ts new file mode 100644 index 0000000..42a9d37 --- /dev/null +++ b/ApiCore-Frontend/src/lib/auth/token-store.ts @@ -0,0 +1,9 @@ +let currentAccessToken: string | null = null; + +export function getAccessToken(): string | null { + return currentAccessToken; +} + +export function setAccessToken(token: string | null): void { + currentAccessToken = token; +} diff --git a/ApiCore-Frontend/src/lib/users/api.ts b/ApiCore-Frontend/src/lib/users/api.ts new file mode 100644 index 0000000..3b71b73 --- /dev/null +++ b/ApiCore-Frontend/src/lib/users/api.ts @@ -0,0 +1,17 @@ +import { apiFetch } from "@/lib/auth/api-client"; +import { CreateUserInput, User } from "./types"; + +export function fetchUsers(): Promise { + return apiFetch("/users"); +} + +export function createUser(input: CreateUserInput): Promise { + return apiFetch("/users", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function deactivateUser(id: string): Promise { + return apiFetch(`/users/${id}/deactivate`, { method: "POST" }); +} diff --git a/ApiCore-Frontend/src/lib/users/types.ts b/ApiCore-Frontend/src/lib/users/types.ts new file mode 100644 index 0000000..c93730d --- /dev/null +++ b/ApiCore-Frontend/src/lib/users/types.ts @@ -0,0 +1,18 @@ +export type Role = "ADMIN" | "BEEKEEPER" | "STAFF" | "READONLY" | "API_CLIENT"; + +export interface User { + id: string; + email: string; + name: string; + role: Role; + isActive: boolean; + createdAt: string; + deactivatedAt: string | null; +} + +export interface CreateUserInput { + email: string; + password: string; + name: string; + role: Role; +} diff --git a/TASKS.md b/TASKS.md index aef344b..9154810 100644 --- a/TASKS.md +++ b/TASKS.md @@ -19,7 +19,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Frontend: `/login`, `/settings/users` (nur Administrator) - [x] Rollen-Enum + Guards (`JwtAuthGuard`, `RolesGuard`, `@Roles()`) implementiert und in jedem künftigen Modul verpflichtend - [x] Backend-Modul fertig, getestet, grün, committet -- [ ] Frontend-Views fertig, getestet, grün, committet +- [x] Frontend-Views fertig, getestet, grün, committet ### 2. Bienenstände diff --git a/deploy/k8s/backend/configmap.example.yaml b/deploy/k8s/backend/configmap.example.yaml index 23ea7d8..bbf7cd3 100644 --- a/deploy/k8s/backend/configmap.example.yaml +++ b/deploy/k8s/backend/configmap.example.yaml @@ -6,3 +6,4 @@ data: PORT: "3001" JWT_ACCESS_EXPIRES_IN: "15m" JWT_REFRESH_EXPIRES_IN: "7d" + FRONTEND_ORIGIN: "https://apicore.example.com"