Quellcode durchsuchen

RHL-020 feat(auth): enhance TopNav and UserStatus with session management and functional LogoutButton

Code_Uwe vor 1 Monat
Ursprung
Commit
22517bc3e4

+ 15 - 16
components/app-shell/TopNav.jsx

@@ -1,19 +1,26 @@
+// ---------------------------------------------------------------------------
+// Folder: components/app-shell
+// File: TopNav.jsx
+// Relative Path: components/app-shell/TopNav.jsx
+// ---------------------------------------------------------------------------
+
 import React from "react";
 import Link from "next/link";
 import { Button } from "@/components/ui/button";
 import UserStatus from "@/components/app-shell/UserStatus";
+import LogoutButton from "@/components/auth/LogoutButton";
 
 /**
  * TopNav
  *
- * RHL-019:
- * - App branding
- * - User status placeholder (later: getMe + role/branch info)
- * - Logout button placeholder (later: wired to apiClient.logout)
- * - Theme toggle placeholder (optional; can be replaced by the real ModeToggle)
+ * RHL-020:
+ * - UserStatus now displays real session info (via AuthContext).
+ * - Logout button is now functional (calls apiClient.logout + redirects to /login).
  *
- * Test/runtime note:
- * - See AppShell.jsx for details why we import React explicitly.
+ * Notes:
+ * - Theme toggle is still a placeholder in this ticket.
+ * - We keep this component server-renderable for stability and SSR tests.
+ *   LogoutButton is a client component, but it does not require Next router hooks.
  */
 export default function TopNav() {
 	return (
@@ -41,15 +48,7 @@ export default function TopNav() {
 						Theme
 					</Button>
 
-					<Button
-						variant="outline"
-						size="sm"
-						disabled
-						aria-disabled="true"
-						title="Logout wiring will be added later"
-					>
-						Logout
-					</Button>
+					<LogoutButton />
 				</div>
 			</div>
 		</header>

+ 34 - 11
components/app-shell/UserStatus.jsx

@@ -1,25 +1,48 @@
+// ---------------------------------------------------------------------------
+// Folder: components/app-shell
+// File: UserStatus.jsx
+// Relative Path: components/app-shell/UserStatus.jsx
+// ---------------------------------------------------------------------------
+
+"use client";
+
 import React from "react";
+import { useAuth } from "@/components/auth/authContext";
 
 /**
- * UserStatus
+ * UserStatus (RHL-020)
  *
- * RHL-019:
- * - Placeholder only.
+ * Responsibilities:
+ * - Display minimal session info in the TopNav.
  *
- * Later:
- * - This component will read session state (via apiClient.getMe()) and display:
- *   - username or userId
- *   - role
- *   - branchId (for branch users)
+ * Data source:
+ * - AuthContext (provided by components/auth/AuthProvider.jsx)
  *
- * Test/runtime note:
- * - See AppShell.jsx for details why we import React explicitly.
+ * Behavior:
+ * - unknown (no provider): "Not loaded" (keeps SSR tests stable)
+ * - loading: "Loading..."
+ * - authenticated: show role + optional branchId
+ * - unauthenticated: "Signed out" (should be rare because we redirect)
+ * - error: "Error"
  */
 export default function UserStatus() {
+	const { status, user } = useAuth();
+
+	let text = "Not loaded";
+
+	if (status === "loading") text = "Loading...";
+	if (status === "authenticated" && user) {
+		// We only have userId/role/branchId from /api/auth/me.
+		// Keep this minimal and non-personal.
+		text = user.branchId ? `${user.role} (${user.branchId})` : `${user.role}`;
+	}
+	if (status === "unauthenticated") text = "Signed out";
+	if (status === "error") text = "Error";
+
 	return (
 		<div className="hidden items-center gap-2 md:flex">
 			<span className="text-xs text-muted-foreground">User:</span>
-			<span className="text-xs">Not loaded</span>
+			<span className="text-xs">{text}</span>
 		</div>
 	);
 }

+ 21 - 22
components/auth/LoginForm.jsx

@@ -33,44 +33,39 @@ import {
 /**
  * LoginForm (RHL-020)
  *
- * Responsibilities:
- * - Render login UI using shadcn/ui primitives.
- * - Show a reason banner (session expired / logged out) if present.
- * - Submit credentials via apiClient.login().
- * - On success: redirect to `nextPath` (if safe) or "/" (default).
- * - On failure: show a safe, user-friendly error message.
+ * Username casing policy:
+ * - Our backend stores usernames in lowercase and performs lowercase normalization.
+ * - To make UX consistent (and avoid "it works with BranchUser" confusion),
+ *   we normalize the username input to lowercase in the UI as well.
  *
- * Clean code notes:
- * - "reason" and "nextPath" parsing/sanitization is centralized in helpers.
- * - Error message mapping is centralized in lib/frontend/authMessages.js.
+ * NOTE:
+ * - Password is NOT normalized. It remains case-sensitive.
  *
  * @param {{ reason: string|null, nextPath: string|null }} props
  */
 export default function LoginForm({ reason, nextPath }) {
 	const router = useRouter();
 
-	// Controlled inputs: keep state explicit and predictable.
+	// Controlled inputs.
 	const [username, setUsername] = React.useState("");
 	const [password, setPassword] = React.useState("");
 
-	// UX state: disable submit while in-flight.
+	// UX state.
 	const [isSubmitting, setIsSubmitting] = React.useState(false);
-
-	// UI-safe error message shown above the form.
 	const [errorMessage, setErrorMessage] = React.useState("");
 
-	// Informational banner (optional).
+	// Optional informational banner (session expired / logged-out).
 	const reasonAlert = getLoginReasonAlert(reason);
 
-	// Defensive: sanitize again on the client (even though server parsing already does it).
+	// Defensive: sanitize nextPath again on the client.
 	const safeNext = sanitizeNext(nextPath) || homePath();
 
 	async function onSubmit(e) {
 		e.preventDefault();
 
-		// Minimal validation to avoid unnecessary network calls.
-		const u = username.trim();
-		const p = password;
+		// Enforce our username policy at submit time as well (defense-in-depth).
+		const u = username.trim().toLowerCase();
+		const p = password; // do NOT normalize password
 
 		if (!u || !p) {
 			setErrorMessage("Please enter username and password.");
@@ -81,10 +76,7 @@ export default function LoginForm({ reason, nextPath }) {
 		setErrorMessage("");
 
 		try {
-			// Backend sets an HTTP-only cookie on success.
 			await login({ username: u, password: p });
-
-			// Replace history entry so "Back" does not return to login.
 			router.replace(safeNext);
 		} catch (err) {
 			setErrorMessage(getLoginErrorMessage(err));
@@ -123,8 +115,15 @@ export default function LoginForm({ reason, nextPath }) {
 							id="username"
 							name="username"
 							autoComplete="username"
+							// Prevent mobile keyboards from auto-capitalizing the first character.
+							autoCapitalize="none"
+							autoCorrect="off"
+							spellCheck={false}
 							value={username}
-							onChange={(e) => setUsername(e.target.value)}
+							onChange={(e) => {
+								// Normalize to lowercase as the user types (consistent UX).
+								setUsername(e.target.value.toLowerCase());
+							}}
 							disabled={isSubmitting}
 							placeholder="e.g. branchuser"
 						/>

+ 64 - 0
components/auth/LogoutButton.jsx

@@ -0,0 +1,64 @@
+// ---------------------------------------------------------------------------
+// Folder: components/auth
+// File: LogoutButton.jsx
+// Relative Path: components/auth/LogoutButton.jsx
+// ---------------------------------------------------------------------------
+
+"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).
+ */
+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 ? "Logging out..." : "Logout"}
+		>
+			{isLoggingOut ? "Logging out..." : "Logout"}
+		</Button>
+	);
+}