fix(seller-team): guard owner-only routes + robust useMyStores

Review fixes:
- useMyStores throws on a failed response instead of resolving to [] — a
  transient 401/blip no longer looks like "no stores" and bounces an owner off
  their own dashboard (query stays in error/retry, keeps prior data).
- Add useRequireOwner() guard and apply it to the owner-only routes
  (financial-dashboard + its cash-funds/reports/transactions sub-routes and
  shipping-method) so staff who hit those URLs directly are redirected to the
  dashboard. (Financial data was already backend-protected; this is the matching
  client-side gate.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arda Samadi 2026-07-01 17:04:06 +03:30
parent a642fc0fae
commit bb44ebf0e5
7 changed files with 40 additions and 7 deletions

View File

@ -38,3 +38,21 @@ export function useStoreOwnership(storeId: string | undefined, mode?: string) {
stores: stores ?? [], stores: stores ?? [],
}; };
} }
/**
* Guard for OWNER-ONLY store pages (financial, shipping, edit, team). Redirects
* non-members (via useStoreOwnership) and staff members back to the dashboard.
* Returns { isLoading, isOwner } so callers can render a spinner meanwhile.
*/
export function useRequireOwner(storeId: string | undefined) {
const navigate = useNavigate();
const { isLoading, role } = useStoreOwnership(storeId);
useEffect(() => {
if (!isLoading && role && role !== "owner") {
navigate(`/store/${storeId}`);
}
}, [isLoading, role, storeId, navigate]);
return { isLoading, isOwner: role === "owner" };
}

View File

@ -45,10 +45,14 @@ export function useMyStores() {
const res = await fetch(`${getApiBaseUrl()}/api/seller/my-stores/`, { const res = await fetch(`${getApiBaseUrl()}/api/seller/my-stores/`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
if (!res.ok) return []; // Throw on failure so a transient error stays an ERROR state (data kept,
// retried) instead of resolving to [] — otherwise a blip would look like
// "no stores" and bounce an owner off their own dashboard.
if (!res.ok) throw new Error(`my-stores failed: ${res.status}`);
return res.json(); return res.json();
}, },
enabled: !!token, enabled: !!token,
retry: 1,
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
} }

View File

@ -8,6 +8,7 @@ import {
HandCoins, HandCoins,
} from "lucide-react"; } from "lucide-react";
import HeaderWithSupport from "../components/HeaderWithSupport"; import HeaderWithSupport from "../components/HeaderWithSupport";
import { useRequireOwner } from "../hooks/useStoreOwnership";
export const meta: MetaFunction = () => { export const meta: MetaFunction = () => {
return [ return [
@ -41,6 +42,7 @@ export const meta: MetaFunction = () => {
export default function FinancialDashboard() { export default function FinancialDashboard() {
const { storeId } = useParams(); const { storeId } = useParams();
useRequireOwner(storeId); // financial is owner-only
const navigate = useNavigate(); const navigate = useNavigate();
const handleBack = () => { const handleBack = () => {
safeGoBack(navigate); safeGoBack(navigate);

View File

@ -16,8 +16,9 @@ import {
CarouselItem, CarouselItem,
} from "../components/ui/carousel"; } from "../components/ui/carousel";
import { useCallback, useState, useRef, useEffect } from "react"; import { useCallback, useState, useRef, useEffect } from "react";
import { useNavigate } from "@remix-run/react"; import { useNavigate, useParams } from "@remix-run/react";
import { useCarousel } from "../hooks/useCarousel"; import { useCarousel } from "../hooks/useCarousel";
import { useRequireOwner } from "../hooks/useStoreOwnership";
import { import {
Drawer, Drawer,
DrawerTitle, DrawerTitle,
@ -50,6 +51,8 @@ import { BankLogo } from "~/components/BankLogo";
export default function CashingFunds() { export default function CashingFunds() {
const navigate = useNavigate(); const navigate = useNavigate();
const { storeId } = useParams();
useRequireOwner(storeId); // financial is owner-only
const handleBack = () => { const handleBack = () => {
safeGoBack(navigate); safeGoBack(navigate);
}; };

View File

@ -2,14 +2,17 @@ import { Sun, Waves, Loader2 } from "lucide-react";
import HeaderWithSupport from "../components/HeaderWithSupport"; import HeaderWithSupport from "../components/HeaderWithSupport";
import PersianDatePicker from "../components/PersianDatePicker"; import PersianDatePicker from "../components/PersianDatePicker";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom"; import { useSearchParams, useNavigate, useParams } from "react-router-dom";
import { formatPrice, safeGoBack } from "../utils/helpers"; import { formatPrice, safeGoBack } from "../utils/helpers";
import { useServiceGetReport } from "../requestHandler/use-wallet-hooks"; import { useServiceGetReport } from "../requestHandler/use-wallet-hooks";
import { useRequireOwner } from "../hooks/useStoreOwnership";
import jMoment from "moment-jalaali"; import jMoment from "moment-jalaali";
import { MetaFunction } from "@remix-run/node"; import { MetaFunction } from "@remix-run/node";
export default function Reports() { export default function Reports() {
const navigate = useNavigate(); const navigate = useNavigate();
const { storeId } = useParams();
useRequireOwner(storeId); // financial is owner-only
const handleBack = () => { const handleBack = () => {
safeGoBack(navigate); safeGoBack(navigate);
}; };

View File

@ -12,8 +12,9 @@ import {
} from "../components/ui/drawer"; } from "../components/ui/drawer";
import { RadioGroup, RadioGroupItem } from "../components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "../components/ui/radio-group";
import PersianDatePicker from "../components/PersianDatePicker"; import PersianDatePicker from "../components/PersianDatePicker";
import { useSearchParams } from "react-router-dom"; import { useSearchParams, useParams } from "react-router-dom";
import { useServiceGetPayoutRequests } from "../requestHandler/use-wallet-hooks"; import { useServiceGetPayoutRequests } from "../requestHandler/use-wallet-hooks";
import { useRequireOwner } from "../hooks/useStoreOwnership";
import jMoment from "moment-jalaali"; import jMoment from "moment-jalaali";
import filterIcon from "~/assets/icons/filter.png"; import filterIcon from "~/assets/icons/filter.png";
import filterIconDark from "~/assets/icons/filter-dark.png"; import filterIconDark from "~/assets/icons/filter-dark.png";
@ -51,6 +52,8 @@ export const meta: MetaFunction = () => {
export default function Transactions() { export default function Transactions() {
const navigate = useNavigate(); const navigate = useNavigate();
const { storeId } = useParams();
useRequireOwner(storeId); // financial is owner-only
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false); const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { theme } = useRootData(); const { theme } = useRootData();

View File

@ -15,7 +15,7 @@ import {
import { SellerCourierCreate, SellerCourierUpdate } from "../../src/api/types"; import { SellerCourierCreate, SellerCourierUpdate } from "../../src/api/types";
import { useToast } from "../hooks/use-toast"; import { useToast } from "../hooks/use-toast";
import { useParams, useNavigate } from "@remix-run/react"; import { useParams, useNavigate } from "@remix-run/react";
import { useStoreOwnership } from "../hooks/useStoreOwnership"; import { useRequireOwner } from "../hooks/useStoreOwnership";
import { MetaFunction } from "@remix-run/node"; import { MetaFunction } from "@remix-run/node";
import { getPersianTextOfPrice, safeGoBack } from "~/utils/helpers"; import { getPersianTextOfPrice, safeGoBack } from "~/utils/helpers";
@ -37,8 +37,8 @@ export default function ShippingMethod() {
const navigate = useNavigate(); const navigate = useNavigate();
const [savingCourierId, setSavingCourierId] = useState<string | null>(null); const [savingCourierId, setSavingCourierId] = useState<string | null>(null);
// Check store ownership - this will redirect if user doesn't own the store // Shipping/courier config is owner-only — redirect staff back to the dashboard.
const { isLoading: isOwnershipLoading } = useStoreOwnership(storeId); const { isLoading: isOwnershipLoading } = useRequireOwner(storeId);
// Fetch all available couriers // Fetch all available couriers
const { const {