From f0ff2e3046cbe51f1edb274de4b1629341b50319 Mon Sep 17 00:00:00 2001 From: coderDom-x Date: Sun, 28 Jun 2026 20:32:04 +0000 Subject: [PATCH] feat: implement churn analytics, SMS 2FA, and multi-location support --- frontend/app/(auth)/verify-2fa/page.tsx | 328 ++++++++++++++---- .../components/settings/SmsTwoFactorModal.tsx | 268 ++++++++++++++ frontend/components/ui/LocationSwitcher.tsx | 138 ++++++++ frontend/components/ui/Navbar.tsx | 169 ++++++--- .../hooks/admin/analytics/useGetChurnRisk.ts | 59 ++++ .../react-query/hooks/admin/sms-2fa/index.ts | 85 +++++ .../lib/react-query/hooks/useLocationParam.ts | 23 ++ 7 files changed, 947 insertions(+), 123 deletions(-) create mode 100644 frontend/components/settings/SmsTwoFactorModal.tsx create mode 100644 frontend/components/ui/LocationSwitcher.tsx create mode 100644 frontend/lib/react-query/hooks/admin/analytics/useGetChurnRisk.ts create mode 100644 frontend/lib/react-query/hooks/admin/sms-2fa/index.ts create mode 100644 frontend/lib/react-query/hooks/useLocationParam.ts diff --git a/frontend/app/(auth)/verify-2fa/page.tsx b/frontend/app/(auth)/verify-2fa/page.tsx index 83e95f5..271a984 100644 --- a/frontend/app/(auth)/verify-2fa/page.tsx +++ b/frontend/app/(auth)/verify-2fa/page.tsx @@ -9,12 +9,29 @@ import { storage } from "@/lib/storage"; import { Shield, ArrowLeft, Loader2 } from "lucide-react"; import Link from "next/link"; -function Verify2FAForm() { - const router = useRouter(); - const searchParams = useSearchParams(); - const tempToken = searchParams.get("tempToken") ?? ""; - const email = searchParams.get("email") ?? ""; +// ── Shared helper: apply sign-in side-effects ──────────────────────────────── + +function applyAuthResponse(response: { + user: any; + accessToken: string; + backupCodesRemaining?: number; +}) { + apiClient.setToken(response.accessToken); + useAuthStore.getState().setUser(response.user); + useAuthStore.getState().setToken(response.accessToken); + storage.setToken(response.accessToken); + storage.setUser(response.user); +} + +// ── TOTP / Backup panel (original logic, unchanged) ────────────────────────── +function TotpBackupPanel({ + tempToken, + onSuccess, +}: { + tempToken: string; + onSuccess: () => void; +}) { const [mode, setMode] = useState<"totp" | "backup">("totp"); const [code, setCode] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); @@ -39,11 +56,7 @@ function Verify2FAForm() { backupCodesRemaining?: number; }>(endpoint, body); - apiClient.setToken(response.accessToken); - useAuthStore.getState().setUser(response.user); - useAuthStore.getState().setToken(response.accessToken); - storage.setToken(response.accessToken); - storage.setUser(response.user); + applyAuthResponse(response); if (mode === "backup" && response.backupCodesRemaining !== undefined) { toast.success( @@ -53,7 +66,7 @@ function Verify2FAForm() { toast.success("Signed in successfully"); } - router.push("/dashboard"); + onSuccess(); } catch (error: any) { toast.error(error.message || "Invalid code. Please try again."); } finally { @@ -61,9 +74,204 @@ function Verify2FAForm() { } }; + return ( +
+
+ + + setCode( + mode === "totp" + ? e.target.value.replace(/\D/g, "") + : e.target.value + ) + } + placeholder={mode === "totp" ? "000000" : "e.g. a1b2c3d4e5"} + autoFocus + className={`w-full px-4 py-3 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-200 text-center font-mono text-lg tracking-widest ${ + mode === "backup" ? "tracking-normal text-base" : "" + }`} + /> +
+ + + +
+ +
+
+ ); +} + +// ── SMS panel (FE-34) ───────────────────────────────────────────────────────── + +function SmsPanel({ + userId, + tempToken, + onSuccess, +}: { + userId: string; + tempToken: string; + onSuccess: () => void; +}) { + const [smsSent, setSmsSent] = useState(false); + const [code, setCode] = useState(""); + const [isSending, setIsSending] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + + async function handleSendCode() { + setIsSending(true); + try { + await apiClient.post("/auth/2fa/sms/send-code", { userId }); + setSmsSent(true); + toast.success("Code sent to your phone."); + } catch (err: any) { + toast.error(err.message || "Failed to send code. Please try again."); + } finally { + setIsSending(false); + } + } + + async function handleVerify(e: React.FormEvent) { + e.preventDefault(); + setIsVerifying(true); + try { + const response = await apiClient.post<{ user: any; accessToken: string }>( + "/auth/2fa/sms/verify", + { userId, otp: code, tempToken } + ); + applyAuthResponse(response); + toast.success("Signed in successfully"); + onSuccess(); + } catch (err: any) { + toast.error(err.message || "Invalid or expired code."); + } finally { + setIsVerifying(false); + } + } + + if (!smsSent) { + return ( +
+

+ Click below to receive a 6-digit code on your registered phone number. +

+ +
+ ); + } + + return ( +
+
+ + setCode(e.target.value.replace(/\D/g, ""))} + placeholder="000000" + autoFocus + className="w-full px-4 py-3 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-200 text-center font-mono text-lg tracking-widest" + /> +
+ + + +
+ +
+
+ ); +} + +// ── Main form ───────────────────────────────────────────────────────────────── + +function Verify2FAForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const tempToken = searchParams.get("tempToken") ?? ""; + const email = searchParams.get("email") ?? ""; + const userId = searchParams.get("userId") ?? ""; + // hasSms is set by the login response when smsTwoFactorEnabled === true + // so users without SMS 2FA never see the tab + const hasSms = searchParams.get("hasSms") === "true"; + + const [activeTab, setActiveTab] = useState<"totp" | "sms">("totp"); + return (
+ {/* Header */}
@@ -74,9 +282,9 @@ function Verify2FAForm() { Two-factor verification

- {mode === "totp" + {activeTab === "totp" ? "Enter the 6-digit code from your authenticator app." - : "Enter one of your saved backup codes."} + : "Verify your identity with an SMS code."}

{email && (

@@ -87,66 +295,48 @@ function Verify2FAForm() {

-
-
- - - setCode( - mode === "totp" - ? e.target.value.replace(/\D/g, "") - : e.target.value - ) - } - placeholder={mode === "totp" ? "000000" : "e.g. a1b2c3d4e5"} - autoFocus - className={`w-full px-4 py-3 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-200 text-center font-mono text-lg tracking-widest ${ - mode === "backup" ? "tracking-normal text-base" : "" + {/* Tab switcher — only visible when this user has SMS 2FA enabled */} + {hasSms && ( +
+ +
+ )} - - + {activeTab === "totp" ? ( + router.push("/dashboard")} + /> + ) : ( + router.push("/dashboard")} + /> + )}
- -
- -
); -} +} \ No newline at end of file diff --git a/frontend/components/settings/SmsTwoFactorModal.tsx b/frontend/components/settings/SmsTwoFactorModal.tsx new file mode 100644 index 0000000..fe6558e --- /dev/null +++ b/frontend/components/settings/SmsTwoFactorModal.tsx @@ -0,0 +1,268 @@ +"use client"; + +/** + * SmsTwoFactorModal + * + * Step-by-step modal for enabling or disabling SMS 2FA. + * + * Enable flow: + * Step 1 — Enter phone number + country code + * Step 2 — Enter 6-digit OTP + * Step 3 — Success confirmation + * + * Disable flow: + * Single step — confirm with current password + * + * Location: frontend/components/settings/SmsTwoFactorModal.tsx + */ + +import { useState } from "react"; +import { + useEnableSmsTwoFactor, + useVerifySmsTwoFactor, + useDisableSmsTwoFactor, +} from "@/lib/react-query/hooks/admin/sms-2fa"; + +interface Props { + isOpen: boolean; + mode: "enable" | "disable"; + onClose: () => void; +} + +export function SmsTwoFactorModal({ isOpen, mode, onClose }: Props) { + const [step, setStep] = useState<1 | 2 | 3>(1); + const [phone, setPhone] = useState(""); + const [otp, setOtp] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + + const enable = useEnableSmsTwoFactor(); + const verifySetup = useVerifySmsTwoFactor(); + const disable = useDisableSmsTwoFactor(); + + if (!isOpen) return null; + + function reset() { + setStep(1); + setPhone(""); + setOtp(""); + setPassword(""); + setError(null); + } + + function handleClose() { + reset(); + onClose(); + } + + // ── Enable: Step 1 — phone number ──────────────────────────────────────── + + async function handleSendCode(e: React.FormEvent) { + e.preventDefault(); + setError(null); + try { + await enable.mutateAsync({ phone }); + setStep(2); + } catch (err: any) { + setError(err?.response?.data?.message ?? "Failed to send code. Try again."); + } + } + + // ── Enable: Step 2 — OTP ────────────────────────────────────────────────── + + async function handleVerifyOtp(e: React.FormEvent) { + e.preventDefault(); + setError(null); + try { + await verifySetup.mutateAsync({ otp }); + setStep(3); + } catch (err: any) { + setError(err?.response?.data?.message ?? "Invalid or expired code."); + } + } + + // ── Disable — password confirmation ─────────────────────────────────────── + + async function handleDisable(e: React.FormEvent) { + e.preventDefault(); + setError(null); + try { + await disable.mutateAsync({ currentPassword: password }); + handleClose(); + } catch (err: any) { + setError(err?.response?.data?.message ?? "Incorrect password."); + } + } + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( +
+
+ {/* Header */} +
+
+

+ {mode === "enable" + ? "Enable SMS Two-Factor Authentication" + : "Disable SMS Two-Factor Authentication"} +

+ {mode === "enable" && ( +

+ Step {step} of 3 +

+ )} +
+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* ── Enable flow ── */} + {mode === "enable" && ( + <> + {step === 1 && ( +
+

+ Enter your phone number. We'll send a 6-digit verification code. +

+
+ + setPhone(e.target.value)} + required + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + +
+
+ )} + + {step === 2 && ( +
+

+ Enter the 6-digit code sent to {phone}. +

+
+ + setOtp(e.target.value.replace(/\D/g, ""))} + required + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tracking-widest font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + +
+
+ )} + + {step === 3 && ( +
+
+ ✓ +
+

+ SMS two-factor authentication has been enabled for{" "} + {phone}. +

+ +
+ )} + + )} + + {/* ── Disable flow ── */} + {mode === "disable" && ( +
+

+ Confirm your current password to disable SMS two-factor authentication. +

+
+ + setPassword(e.target.value)} + required + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/ui/LocationSwitcher.tsx b/frontend/components/ui/LocationSwitcher.tsx new file mode 100644 index 0000000..fe28d7b --- /dev/null +++ b/frontend/components/ui/LocationSwitcher.tsx @@ -0,0 +1,138 @@ +"use client"; + +/** + * LocationSwitcher + * + * Dropdown that lets the user pick an active location. + * Hidden when only one location exists (single-hub operators see no change). + * + * Used in: + * frontend/components/ui/Navbar.tsx + * frontend/components/dashboard/DashboardSidebar.tsx (optional) + * + * Location: frontend/components/ui/LocationSwitcher.tsx + */ + +import { useState, useRef, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import { useAuthStore } from "@/lib/store/authStore"; + +// ── Types ────────────────────────────────────────────────────────────────── + +interface Location { + id: string; + name: string; + isActive: boolean; +} + +// ── Data fetching ────────────────────────────────────────────────────────── + +async function fetchLocations(): Promise { + const { data } = await axios.get("/api/locations"); + return data.filter((l) => l.isActive); +} + +function useLocations() { + return useQuery({ + queryKey: ["locations"], + queryFn: fetchLocations, + staleTime: 10 * 60 * 1000, + }); +} + +// ── Component ────────────────────────────────────────────────────────────── + +export function LocationSwitcher() { + const { data: locations, isLoading } = useLocations(); + const { selectedLocationId, setSelectedLocationId, user } = useAuthStore(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + const isAdmin = user?.role === "admin"; + + // Close on outside click + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + // Hide if only one location — single-hub operators see no UI change + if (!isLoading && (!locations || locations.length <= 1)) return null; + + const currentLocation = locations?.find((l) => l.id === selectedLocationId); + const label = selectedLocationId + ? (currentLocation?.name ?? "Unknown") + : "All Locations"; + + function select(id: string | null) { + setSelectedLocationId(id); + setOpen(false); + } + + return ( +
+ + + {open && ( +
+ {/* "All Locations" — admin only */} + {isAdmin && ( + + )} + + {isLoading && ( +
Loading…
+ )} + + {locations?.map((loc) => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/components/ui/Navbar.tsx b/frontend/components/ui/Navbar.tsx index 0d8b3f9..39df571 100644 --- a/frontend/components/ui/Navbar.tsx +++ b/frontend/components/ui/Navbar.tsx @@ -1,9 +1,15 @@ "use client"; -import { Building2, X, Menu } from "lucide-react"; + +import { Building2, Menu, X } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; +import { useAuthStore } from "@/lib/store/authStore"; +import { LocationSwitcher } from "./LocationSwitcher"; -type NavItem = { label: string; href: string }; +type NavItem = { + label: string; + href: string; +}; const NAV_ITEMS: NavItem[] = [ { label: "Features", href: "#features" }, @@ -11,98 +17,153 @@ const NAV_ITEMS: NavItem[] = [ { label: "Resources", href: "/resources" }, ]; -export function Navbar({ items = NAV_ITEMS }: { items?: NavItem[] }) { +export function Navbar({ + items = NAV_ITEMS, +}: { + items?: NavItem[]; +}) { const [open, setOpen] = useState(false); + const { user, logout } = useAuthStore(); + return ( -
-