Просмотр исходного кода

RHL-044 feat(frontend): add central must-change-password gate

codeUWE 1 месяц назад
Родитель
Сommit
9521a0fa09

+ 50 - 1
components/auth/AuthGate.jsx

@@ -2,8 +2,14 @@
 
 import React from "react";
 import { RefreshCw } from "lucide-react";
+import { usePathname, useRouter } from "next/navigation";
 
 import { useAuth } from "@/components/auth/authContext";
+import {
+	shouldRedirectToProfileForPasswordChange,
+	buildMustChangePasswordRedirectUrl,
+	resolveMustChangePasswordResumePath,
+} from "@/lib/frontend/auth/mustChangePasswordGate";
 import { Button } from "@/components/ui/button";
 import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
 import {
@@ -16,11 +22,54 @@ import {
 } from "@/components/ui/card";
 
 export default function AuthGate({ children }) {
-	const { status, error, retry } = useAuth();
+	const router = useRouter();
+	const pathname = usePathname() || "/";
+
+	const { status, user, error, retry } = useAuth();
 
 	const canRetry = typeof retry === "function";
+	const isAuthenticated = status === "authenticated" && user;
+	const mustChangePassword = isAuthenticated && user.mustChangePassword === true;
+
+	const currentSearch =
+		typeof window !== "undefined" ? window.location.search || "" : "";
+	const currentPathWithSearch = `${pathname}${currentSearch}`;
+	const mustChangePasswordRedirectUrl = buildMustChangePasswordRedirectUrl(
+		currentPathWithSearch,
+	);
+
+	const shouldForceProfileRedirect = isAuthenticated
+		? shouldRedirectToProfileForPasswordChange({
+				pathname,
+				mustChangePassword,
+			})
+		: false;
+
+	const resumePathAfterPasswordChange = isAuthenticated
+		? resolveMustChangePasswordResumePath({
+				pathname,
+				searchParams:
+					typeof window !== "undefined"
+						? new URLSearchParams(window.location.search || "")
+						: null,
+				mustChangePassword,
+			})
+		: null;
+
+	React.useEffect(() => {
+		if (!shouldForceProfileRedirect) return;
+		router.replace(mustChangePasswordRedirectUrl);
+	}, [shouldForceProfileRedirect, mustChangePasswordRedirectUrl, router]);
+
+	React.useEffect(() => {
+		if (!resumePathAfterPasswordChange) return;
+		router.replace(resumePathAfterPasswordChange);
+	}, [resumePathAfterPasswordChange, router]);
 
 	if (status === "authenticated") {
+		if (shouldForceProfileRedirect) return null;
+		if (resumePathAfterPasswordChange) return null;
+
 		return children;
 	}
 

+ 2 - 1
components/auth/authContext.jsx

@@ -8,7 +8,7 @@ import React from "react";
  * Purpose:
  * - Provide a tiny, app-wide session state for the UI:
  *   - status: "unknown" | "loading" | "authenticated" | "unauthenticated" | "error"
- *   - user: { userId, role, branchId } | null
+ *   - user: { userId, role, branchId, email, mustChangePassword } | null
  *   - error: string | null
  *   - isValidating: boolean (true while a background session re-check runs)
  *   - retry: () => void | null (re-run the session check)
@@ -24,6 +24,7 @@ import React from "react";
  * @property {string} role
  * @property {string|null} branchId
  * @property {string|null} email
+ * @property {boolean} mustChangePassword
  */
 
 /**

+ 12 - 0
lib/frontend/apiClient.js

@@ -159,6 +159,15 @@ export async function apiFetch(path, options = {}) {
 /* Domain helpers                                                              */
 /* -------------------------------------------------------------------------- */
 
+/**
+ * @typedef {Object} MeUser
+ * @property {string} userId
+ * @property {string} role
+ * @property {string|null} branchId
+ * @property {string|null} email
+ * @property {boolean} mustChangePassword
+ */
+
 export function login(input, options) {
 	return apiFetch("/api/auth/login", {
 		method: "POST",
@@ -171,6 +180,9 @@ export function logout(options) {
 	return apiFetch("/api/auth/logout", { method: "GET", ...options });
 }
 
+/**
+ * @returns {Promise<{ user: MeUser|null }>}
+ */
 export function getMe(options) {
 	return apiFetch("/api/auth/me", { method: "GET", ...options });
 }

+ 106 - 0
lib/frontend/auth/mustChangePasswordGate.js

@@ -0,0 +1,106 @@
+import { sanitizeNext } from "@/lib/frontend/authRedirect";
+
+export const MUST_CHANGE_PASSWORD_GATE_PARAM = "mustChangePasswordGate";
+export const MUST_CHANGE_PASSWORD_GATE_VALUE = "1";
+export const MUST_CHANGE_PASSWORD_NEXT_PARAM = "next";
+export const PROFILE_PATH = "/profile";
+
+function normalizePathname(pathname) {
+	if (typeof pathname !== "string") return "/";
+
+	const trimmed = pathname.trim();
+	if (!trimmed) return "/";
+
+	return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
+}
+
+function readParam(searchParams, key) {
+	if (!searchParams) return null;
+
+	if (typeof searchParams.get === "function") {
+		const value = searchParams.get(key);
+		return typeof value === "string" ? value : null;
+	}
+
+	const raw = searchParams[key];
+
+	if (Array.isArray(raw)) {
+		return typeof raw[0] === "string" ? raw[0] : null;
+	}
+
+	return typeof raw === "string" ? raw : null;
+}
+
+function extractPathname(path) {
+	if (typeof path !== "string" || !path.trim()) return null;
+
+	try {
+		return new URL(path, "http://localhost").pathname;
+	} catch {
+		return null;
+	}
+}
+
+export function isProfilePath(pathname) {
+	const normalizedPathname = normalizePathname(pathname);
+	return (
+		normalizedPathname === PROFILE_PATH ||
+		normalizedPathname.startsWith(`${PROFILE_PATH}/`)
+	);
+}
+
+export function sanitizeMustChangePasswordNext(nextValue) {
+	const safeNext = sanitizeNext(nextValue);
+	if (!safeNext) return null;
+
+	const nextPathname = extractPathname(safeNext);
+	if (!nextPathname) return null;
+
+	if (isProfilePath(nextPathname)) return null;
+
+	return safeNext;
+}
+
+export function shouldRedirectToProfileForPasswordChange({
+	pathname,
+	mustChangePassword,
+}) {
+	return mustChangePassword === true && !isProfilePath(pathname);
+}
+
+export function buildMustChangePasswordRedirectUrl(nextValue) {
+	const params = new URLSearchParams();
+	params.set(MUST_CHANGE_PASSWORD_GATE_PARAM, MUST_CHANGE_PASSWORD_GATE_VALUE);
+
+	const safeNext = sanitizeMustChangePasswordNext(nextValue);
+	if (safeNext) {
+		params.set(MUST_CHANGE_PASSWORD_NEXT_PARAM, safeNext);
+	}
+
+	return `${PROFILE_PATH}?${params.toString()}`;
+}
+
+export function parseMustChangePasswordGateParams(searchParams) {
+	const gateMarker = readParam(searchParams, MUST_CHANGE_PASSWORD_GATE_PARAM);
+	const rawNext = readParam(searchParams, MUST_CHANGE_PASSWORD_NEXT_PARAM);
+
+	return {
+		isGateMarker: gateMarker === MUST_CHANGE_PASSWORD_GATE_VALUE,
+		next: sanitizeMustChangePasswordNext(rawNext),
+	};
+}
+
+export function resolveMustChangePasswordResumePath({
+	pathname,
+	searchParams,
+	mustChangePassword,
+}) {
+	if (mustChangePassword === true) return null;
+	if (!isProfilePath(pathname)) return null;
+
+	const parsed = parseMustChangePasswordGateParams(searchParams);
+	if (!parsed.isGateMarker) return null;
+	if (!parsed.next) return null;
+
+	return parsed.next;
+}

+ 163 - 0
lib/frontend/auth/mustChangePasswordGate.test.js

@@ -0,0 +1,163 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+
+import {
+	MUST_CHANGE_PASSWORD_GATE_PARAM,
+	MUST_CHANGE_PASSWORD_GATE_VALUE,
+	isProfilePath,
+	sanitizeMustChangePasswordNext,
+	shouldRedirectToProfileForPasswordChange,
+	buildMustChangePasswordRedirectUrl,
+	parseMustChangePasswordGateParams,
+	resolveMustChangePasswordResumePath,
+} from "./mustChangePasswordGate.js";
+
+describe("lib/frontend/auth/mustChangePasswordGate", () => {
+	describe("isProfilePath", () => {
+		it("returns true for /profile and profile subpaths", () => {
+			expect(isProfilePath("/profile")).toBe(true);
+			expect(isProfilePath("/profile/security")).toBe(true);
+		});
+
+		it("returns false for non-profile routes", () => {
+			expect(isProfilePath("/")).toBe(false);
+			expect(isProfilePath("/NL01")).toBe(false);
+			expect(isProfilePath("/NL01/search")).toBe(false);
+		});
+	});
+
+	describe("sanitizeMustChangePasswordNext", () => {
+		it("accepts safe internal non-profile targets", () => {
+			expect(sanitizeMustChangePasswordNext("/NL01")).toBe("/NL01");
+			expect(sanitizeMustChangePasswordNext("/NL01/search?scope=all")).toBe(
+				"/NL01/search?scope=all"
+			);
+		});
+
+		it("rejects unsafe or profile targets", () => {
+			expect(sanitizeMustChangePasswordNext("//evil.com")).toBe(null);
+			expect(sanitizeMustChangePasswordNext("https://evil.com")).toBe(null);
+			expect(sanitizeMustChangePasswordNext("/profile")).toBe(null);
+			expect(sanitizeMustChangePasswordNext("/profile?x=1")).toBe(null);
+		});
+	});
+
+	describe("shouldRedirectToProfileForPasswordChange", () => {
+		it("forces redirect for non-profile routes while flag is true", () => {
+			expect(
+				shouldRedirectToProfileForPasswordChange({
+					pathname: "/NL01",
+					mustChangePassword: true,
+				})
+			).toBe(true);
+		});
+
+		it("does not force redirect when already on profile or flag is false", () => {
+			expect(
+				shouldRedirectToProfileForPasswordChange({
+					pathname: "/profile",
+					mustChangePassword: true,
+				})
+			).toBe(false);
+
+			expect(
+				shouldRedirectToProfileForPasswordChange({
+					pathname: "/NL01",
+					mustChangePassword: false,
+				})
+			).toBe(false);
+		});
+	});
+
+	describe("buildMustChangePasswordRedirectUrl", () => {
+		it("builds profile URL with marker and safe next", () => {
+			expect(buildMustChangePasswordRedirectUrl("/NL01/search")).toBe(
+				"/profile?mustChangePasswordGate=1&next=%2FNL01%2Fsearch"
+			);
+		});
+
+		it("always includes gate marker and drops invalid next", () => {
+			expect(buildMustChangePasswordRedirectUrl("//evil.com")).toBe(
+				`/profile?${MUST_CHANGE_PASSWORD_GATE_PARAM}=${MUST_CHANGE_PASSWORD_GATE_VALUE}`
+			);
+		});
+	});
+
+	describe("parseMustChangePasswordGateParams", () => {
+		it("parses marker and next from URLSearchParams", () => {
+			const sp = new URLSearchParams({
+				mustChangePasswordGate: "1",
+				next: "/NL01",
+			});
+
+			expect(parseMustChangePasswordGateParams(sp)).toEqual({
+				isGateMarker: true,
+				next: "/NL01",
+			});
+		});
+
+		it("normalizes invalid input", () => {
+			const sp = new URLSearchParams({
+				mustChangePasswordGate: "yes",
+				next: "/profile",
+			});
+
+			expect(parseMustChangePasswordGateParams(sp)).toEqual({
+				isGateMarker: false,
+				next: null,
+			});
+		});
+	});
+
+	describe("resolveMustChangePasswordResumePath", () => {
+		it("returns next only when marker is set, route is profile, and flag is false", () => {
+			const sp = new URLSearchParams({
+				mustChangePasswordGate: "1",
+				next: "/NL01/search",
+			});
+
+			expect(
+				resolveMustChangePasswordResumePath({
+					pathname: "/profile",
+					searchParams: sp,
+					mustChangePassword: false,
+				})
+			).toBe("/NL01/search");
+		});
+
+		it("returns null when any resume condition is not satisfied", () => {
+			const sp = new URLSearchParams({
+				mustChangePasswordGate: "1",
+				next: "/NL01/search",
+			});
+
+			expect(
+				resolveMustChangePasswordResumePath({
+					pathname: "/profile",
+					searchParams: sp,
+					mustChangePassword: true,
+				})
+			).toBe(null);
+
+			expect(
+				resolveMustChangePasswordResumePath({
+					pathname: "/NL01",
+					searchParams: sp,
+					mustChangePassword: false,
+				})
+			).toBe(null);
+
+			expect(
+				resolveMustChangePasswordResumePath({
+					pathname: "/profile",
+					searchParams: new URLSearchParams({
+						mustChangePasswordGate: "0",
+						next: "/NL01/search",
+					}),
+					mustChangePassword: false,
+				})
+			).toBe(null);
+		});
+	});
+});