3 Коміти c36089675f ... 411747da16

Автор SHA1 Опис Дата
  Code_Uwe 411747da16 RHL-029 feat(profile): add email display to ProfilePage and remove deprecated email card 4 днів тому
  Code_Uwe 44f4f6227f RHL-029 feat(auth): include email in session management and update related tests 4 днів тому
  Code_Uwe 35880c4252 RHL-029 Implement feature X to enhance user experience and optimize performance 4 днів тому

+ 3 - 2
app/api/auth/login/route.js

@@ -68,7 +68,7 @@ export const POST = withErrorHandling(
 				"Missing username or password",
 				{
 					fields: ["username", "password"],
-				}
+				},
 			);
 		}
 
@@ -104,10 +104,11 @@ export const POST = withErrorHandling(
 			userId: user._id.toString(),
 			role: user.role,
 			branchId: user.branchId ?? null,
+			email: user.email ?? null,
 		});
 
 		// Happy path response stays unchanged:
 		return json({ ok: true }, 200);
 	},
-	{ logPrefix: "[api/auth/login]" }
+	{ logPrefix: "[api/auth/login]" },
 );

+ 7 - 1
app/api/auth/login/route.test.js

@@ -1,3 +1,5 @@
+/* @vitest-environment node */
+
 import { describe, it, expect, vi, beforeEach } from "vitest";
 
 // 1) Mocks
@@ -52,6 +54,7 @@ describe("POST /api/auth/login", () => {
 		const user = {
 			_id: "507f1f77bcf86cd799439011",
 			username: "branchuser",
+			email: "nl01@example.com",
 			passwordHash: "hashed-password",
 			role: "branch",
 			branchId: "NL01",
@@ -79,13 +82,14 @@ describe("POST /api/auth/login", () => {
 
 		expect(bcryptCompare).toHaveBeenCalledWith(
 			"secret-password",
-			"hashed-password"
+			"hashed-password",
 		);
 
 		expect(createSession).toHaveBeenCalledWith({
 			userId: "507f1f77bcf86cd799439011",
 			role: "branch",
 			branchId: "NL01",
+			email: "nl01@example.com",
 		});
 	});
 
@@ -138,6 +142,7 @@ describe("POST /api/auth/login", () => {
 			exec: vi.fn().mockResolvedValue({
 				_id: "507f1f77bcf86cd799439099",
 				username: "branchuser",
+				email: "nl01@example.com",
 				// passwordHash missing on purpose
 				role: "branch",
 				branchId: "NL01",
@@ -168,6 +173,7 @@ describe("POST /api/auth/login", () => {
 		const user = {
 			_id: "507f1f77bcf86cd799439012",
 			username: "branchuser",
+			email: "nl02@example.com",
 			passwordHash: "hashed-password",
 			role: "branch",
 			branchId: "NL02",

+ 3 - 2
app/api/auth/me/route.js

@@ -35,10 +35,11 @@ export const GET = withErrorHandling(
 					userId: session.userId,
 					role: session.role,
 					branchId: session.branchId ?? null,
+					email: session.email ?? null,
 				},
 			},
-			200
+			200,
 		);
 	},
-	{ logPrefix: "[api/auth/me]" }
+	{ logPrefix: "[api/auth/me]" },
 );

+ 28 - 2
app/api/auth/me/route.test.js

@@ -26,17 +26,43 @@ describe("GET /api/auth/me", () => {
 		expect(await res.json()).toEqual({ user: null });
 	});
 
-	it("returns user payload when authenticated", async () => {
+	it("returns user payload when authenticated (includes email)", async () => {
 		getSession.mockResolvedValue({
 			userId: "u1",
 			role: "branch",
 			branchId: "NL01",
+			email: "nl01@example.com",
 		});
 
 		const res = await GET();
 		expect(res.status).toBe(200);
 		expect(await res.json()).toEqual({
-			user: { userId: "u1", role: "branch", branchId: "NL01" },
+			user: {
+				userId: "u1",
+				role: "branch",
+				branchId: "NL01",
+				email: "nl01@example.com",
+			},
+		});
+	});
+
+	it("returns email=null when session has no email", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "admin",
+			branchId: null,
+			email: null,
+		});
+
+		const res = await GET();
+		expect(res.status).toBe(200);
+		expect(await res.json()).toEqual({
+			user: {
+				userId: "u2",
+				role: "admin",
+				branchId: null,
+				email: null,
+			},
 		});
 	});
 });

+ 0 - 61
components/auth/LogoutButton.jsx

@@ -1,61 +0,0 @@
-"use client";
-
-import React from "react";
-
-import { logout } from "@/lib/frontend/apiClient";
-import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
-import { Button } from "@/components/ui/button";
-
-/**
- * LogoutButton (RHL-020)
- *
- * Responsibilities:
- * - Call apiClient.logout() to clear the HTTP-only session cookie.
- * - Then redirect to /login?reason=logged-out.
- *
- * Important test/runtime note:
- * - We intentionally avoid next/navigation hooks here.
- * - Some unit tests render AppShell via react-dom/server without Next.js runtime.
- * - Using window.location inside the click handler avoids needing router context
- *   during server rendering (handler is not invoked in SSR tests).
- *
- * UX rule:
- * - All user-facing text must be German.
- */
-export default function LogoutButton() {
-	const [isLoggingOut, setIsLoggingOut] = React.useState(false);
-
-	async function handleLogout() {
-		if (isLoggingOut) return;
-
-		setIsLoggingOut(true);
-
-		try {
-			// Backend endpoint is idempotent; even if no cookie exists it returns ok.
-			await logout();
-		} catch (err) {
-			// If logout fails due to network issues, we still redirect to login.
-			// This keeps UX predictable; user can log in again if needed.
-			console.error("[LogoutButton] logout failed:", err);
-		}
-
-		const loginUrl = buildLoginUrl({ reason: LOGIN_REASONS.LOGGED_OUT });
-
-		// Replace so "Back" won't bring the user into a protected page.
-		window.location.replace(loginUrl);
-	}
-
-	return (
-		<Button
-			variant="outline"
-			size="sm"
-			type="button"
-			disabled={isLoggingOut}
-			aria-disabled={isLoggingOut ? "true" : "false"}
-			onClick={handleLogout}
-			title={isLoggingOut ? "Abmeldung läuft…" : "Abmelden"}
-		>
-			{isLoggingOut ? "Abmeldung…" : "Abmelden"}
-		</Button>
-	);
-}

+ 1 - 0
components/auth/authContext.jsx

@@ -23,6 +23,7 @@ import React from "react";
  * @property {string} userId
  * @property {string} role
  * @property {string|null} branchId
+ * @property {string|null} email
  */
 
 /**

+ 10 - 35
components/profile/ProfilePage.jsx

@@ -5,16 +5,12 @@ import { useAuth } from "@/components/auth/authContext";
 
 import ChangePasswordCard from "@/components/profile/ChangePasswordCard";
 
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
 import {
 	Card,
 	CardHeader,
 	CardTitle,
 	CardDescription,
 	CardContent,
-	CardFooter,
 } from "@/components/ui/card";
 
 function formatRole(role) {
@@ -31,6 +27,7 @@ export default function ProfilePage() {
 
 	const roleLabel = isAuthenticated ? formatRole(user.role) : "—";
 	const branchLabel = isAuthenticated ? user.branchId || "—" : "—";
+	const emailLabel = isAuthenticated ? user.email || "—" : "—";
 	const userIdLabel = isAuthenticated ? user.userId || "—" : "—";
 
 	return (
@@ -59,43 +56,21 @@ export default function ProfilePage() {
 						<span>{branchLabel}</span>
 					</div>
 
+					<div className="flex items-center justify-between gap-4">
+						<span className="text-muted-foreground">E-Mail</span>
+						<span className="truncate">{emailLabel}</span>
+					</div>
+
 					<div className="flex items-center justify-between gap-4">
 						<span className="text-muted-foreground">User ID</span>
 						<span className="truncate">{userIdLabel}</span>
 					</div>
-				</CardContent>
-			</Card>
 
-			<Card>
-				<CardHeader>
-					<CardTitle>E-Mail</CardTitle>
-					<CardDescription>
-						Die Änderung der E-Mail-Adresse wird in einem späteren Ticket
-						aktiviert.
-					</CardDescription>
-				</CardHeader>
-
-				<CardContent className="grid gap-2">
-					<Label htmlFor="email">E-Mail-Adresse</Label>
-					<Input
-						id="email"
-						type="email"
-						placeholder="name@firma.de"
-						disabled
-						aria-disabled="true"
-					/>
+					<p className="pt-1 text-xs text-muted-foreground">
+						Die E-Mail wird zentral verwaltet. Für Änderungen wenden Sie sich an
+						die IT.
+					</p>
 				</CardContent>
-
-				<CardFooter className="flex justify-end">
-					<Button
-						type="button"
-						disabled
-						aria-disabled="true"
-						title="Kommt später"
-					>
-						Speichern
-					</Button>
-				</CardFooter>
 			</Card>
 
 			<ChangePasswordCard />

+ 12 - 17
lib/auth/session.js

@@ -1,4 +1,3 @@
-// lib/auth/session.js
 import { cookies } from "next/headers";
 import { SignJWT, jwtVerify } from "jose";
 
@@ -15,16 +14,6 @@ function getSessionSecretKey() {
 	return new TextEncoder().encode(secret);
 }
 
-/**
- * Resolve whether the session cookie should be marked as "Secure".
- *
- * Default:
- * - Secure in production (`NODE_ENV=production`)
- *
- * Override (useful for local HTTP testing):
- * - SESSION_COOKIE_SECURE=false
- * - SESSION_COOKIE_SECURE=true
- */
 function resolveCookieSecureFlag() {
 	const raw = (process.env.SESSION_COOKIE_SECURE || "").trim().toLowerCase();
 	if (raw === "true") return true;
@@ -33,10 +22,18 @@ function resolveCookieSecureFlag() {
 	return process.env.NODE_ENV === "production";
 }
 
+function normalizeEmailOrNull(value) {
+	if (typeof value !== "string") return null;
+	const s = value.trim();
+	if (!s) return null;
+	// Email is not case-sensitive; keep it normalized for UI consistency.
+	return s.toLowerCase();
+}
+
 /**
  * Create a signed session JWT and store it in a HTTP-only cookie.
  */
-export async function createSession({ userId, role, branchId }) {
+export async function createSession({ userId, role, branchId, email }) {
 	if (!userId || !role) {
 		throw new Error("createSession requires userId and role");
 	}
@@ -45,6 +42,7 @@ export async function createSession({ userId, role, branchId }) {
 		userId,
 		role,
 		branchId: branchId ?? null,
+		email: normalizeEmailOrNull(email),
 	};
 
 	const jwt = await new SignJWT(payload)
@@ -82,7 +80,7 @@ export async function getSession() {
 	try {
 		const { payload } = await jwtVerify(cookie.value, secretKey);
 
-		const { userId, role, branchId } = payload;
+		const { userId, role, branchId, email } = payload;
 
 		if (typeof userId !== "string" || typeof role !== "string") {
 			return null;
@@ -92,9 +90,9 @@ export async function getSession() {
 			userId,
 			role,
 			branchId: typeof branchId === "string" ? branchId : null,
+			email: typeof email === "string" ? email : null,
 		};
 	} catch {
-		// Invalid or expired token: clear cookie and return null
 		const store = await cookies();
 		store.set(SESSION_COOKIE_NAME, "", {
 			httpOnly: true,
@@ -108,9 +106,6 @@ export async function getSession() {
 	}
 }
 
-/**
- * Destroy the current session by clearing the session cookie.
- */
 export async function destroySession() {
 	const cookieStore = await cookies();
 

+ 53 - 1
lib/auth/session.test.js

@@ -1,3 +1,5 @@
+/* @vitest-environment node */
+
 import { describe, it, expect, vi, beforeEach } from "vitest";
 
 // Mock next/headers to provide a simple in-memory cookie store
@@ -43,6 +45,7 @@ describe("auth session utilities", () => {
 		__cookieStore.clear();
 		process.env.SESSION_SECRET = "x".repeat(64);
 		process.env.NODE_ENV = "test";
+		delete process.env.SESSION_COOKIE_SECURE;
 	});
 
 	it("creates a session cookie with a signed JWT", async () => {
@@ -70,7 +73,7 @@ describe("auth session utilities", () => {
 		});
 	});
 
-	it("reads a valid session from cookie", async () => {
+	it("reads a valid session from cookie (email defaults to null)", async () => {
 		await createSession({
 			userId: "user456",
 			role: "admin",
@@ -83,6 +86,25 @@ describe("auth session utilities", () => {
 			userId: "user456",
 			role: "admin",
 			branchId: null,
+			email: null,
+		});
+	});
+
+	it("includes email in the session when provided (normalized to lowercase)", async () => {
+		await createSession({
+			userId: "user999",
+			role: "branch",
+			branchId: "NL01",
+			email: "User@Example.COM",
+		});
+
+		const session = await getSession();
+
+		expect(session).toEqual({
+			userId: "user999",
+			role: "branch",
+			branchId: "NL01",
+			email: "user@example.com",
 		});
 	});
 
@@ -143,4 +165,34 @@ describe("auth session utilities", () => {
 		expect(cookie.value).toBe("");
 		expect(cookie.options.maxAge).toBe(0);
 	});
+
+	it('respects SESSION_COOKIE_SECURE override when set to "true"', async () => {
+		process.env.SESSION_COOKIE_SECURE = "true";
+
+		await createSession({
+			userId: "user-secure",
+			role: "admin",
+			branchId: null,
+		});
+
+		const store = __cookieStore.dump();
+		const cookie = store.get(SESSION_COOKIE_NAME);
+
+		expect(cookie.options.secure).toBe(true);
+	});
+
+	it('respects SESSION_COOKIE_SECURE override when set to "false"', async () => {
+		process.env.SESSION_COOKIE_SECURE = "false";
+
+		await createSession({
+			userId: "user-insecure",
+			role: "admin",
+			branchId: null,
+		});
+
+		const store = __cookieStore.dump();
+		const cookie = store.get(SESSION_COOKIE_NAME);
+
+		expect(cookie.options.secure).toBe(false);
+	});
 });

Різницю між файлами не показано, бо вона завелика
+ 274 - 232
package-lock.json


Деякі файли не було показано, через те що забагато файлів було змінено