Refactor API permissions and throttling: Updated default permission classes to enforce authentication and tenant access. Introduced new permission for system accounts and developers. Enhanced throttling rates for various operations to reduce false 429 errors. Improved API key loading logic to prioritize account-specific settings, with fallbacks to system accounts and Django settings. Updated integration views and sidebar to reflect new permission structure.

This commit is contained in:
IGNY8 VPS (Salman)
2025-12-07 17:23:42 +00:00
parent 3cbed65601
commit 65fea95d33
15 changed files with 374 additions and 71 deletions

View File

@@ -5,6 +5,7 @@ import AppLayout from "./layout/AppLayout";
import { ScrollToTop } from "./components/common/ScrollToTop";
import ProtectedRoute from "./components/auth/ProtectedRoute";
import ModuleGuard from "./components/common/ModuleGuard";
import AdminGuard from "./components/auth/AdminGuard";
import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay";
import LoadingStateMonitor from "./components/common/LoadingStateMonitor";
import { useAuthStore } from "./store/authStore";
@@ -595,8 +596,10 @@ export default function App() {
} />
<Route path="/settings/integration" element={
<Suspense fallback={null}>
<AdminGuard>
<Integration />
</Suspense>
</AdminGuard>
</Suspense>
} />
<Route path="/settings/publishing" element={
<Suspense fallback={null}>

View File

@@ -0,0 +1,25 @@
import { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuthStore } from "../../store/authStore";
interface AdminGuardProps {
children: ReactNode;
}
/**
* AdminGuard - restricts access to system account (aws-admin/default) or developer
*/
export default function AdminGuard({ children }: AdminGuardProps) {
const { user } = useAuthStore();
const role = user?.role;
const accountSlug = user?.account?.slug;
const isSystemAccount = accountSlug === 'aws-admin' || accountSlug === 'default-account' || accountSlug === 'default';
const allowed = role === 'developer' || isSystemAccount;
if (!allowed) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons";
import Label from "../form/Label";
@@ -6,6 +6,15 @@ import Input from "../form/input/InputField";
import Checkbox from "../form/input/Checkbox";
import { useAuthStore } from "../../store/authStore";
type Plan = {
id: number;
name: string;
price?: number;
billing_cycle?: string;
is_active?: boolean;
included_credits?: number;
};
export default function SignUpForm() {
const [showPassword, setShowPassword] = useState(false);
const [isChecked, setIsChecked] = useState(false);
@@ -15,11 +24,45 @@ export default function SignUpForm() {
email: "",
password: "",
username: "",
accountName: "",
});
const [plans, setPlans] = useState<Plan[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<number | null>(null);
const [plansLoading, setPlansLoading] = useState(true);
const [error, setError] = useState("");
const navigate = useNavigate();
const { register, loading } = useAuthStore();
const apiBaseUrl = useMemo(
() => import.meta.env.VITE_BACKEND_URL || "https://api.igny8.com/api",
[]
);
useEffect(() => {
const loadPlans = async () => {
setPlansLoading(true);
try {
const res = await fetch(`${apiBaseUrl}/v1/auth/plans/`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
const data = await res.json();
const list: Plan[] = data?.results || data || [];
const activePlans = list.filter((p) => p.is_active !== false);
setPlans(activePlans);
if (activePlans.length > 0) {
setSelectedPlanId(activePlans[0].id);
}
} catch (e) {
// keep empty list; surface error on submit if no plan
console.error("Failed to load plans", e);
} finally {
setPlansLoading(false);
}
};
loadPlans();
}, [apiBaseUrl]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
@@ -39,6 +82,11 @@ export default function SignUpForm() {
return;
}
if (!selectedPlanId) {
setError("Please select a plan to continue");
return;
}
try {
// Generate username from email if not provided
const username = formData.username || formData.email.split("@")[0];
@@ -49,6 +97,8 @@ export default function SignUpForm() {
username: username,
first_name: formData.firstName,
last_name: formData.lastName,
account_name: formData.accountName,
plan_id: selectedPlanId,
});
// Redirect to plan selection after successful registration
@@ -191,6 +241,43 @@ export default function SignUpForm() {
required
/>
</div>
{/* <!-- Account Name --> */}
<div>
<Label>Account Name (optional)</Label>
<Input
type="text"
id="accountName"
name="accountName"
value={formData.accountName}
onChange={handleChange}
placeholder="Workspace / Company name"
/>
</div>
{/* <!-- Plan Selection --> */}
<div>
<Label>
Select Plan<span className="text-error-500">*</span>
</Label>
<select
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-sm"
value={selectedPlanId ?? ""}
onChange={(e) => setSelectedPlanId(Number(e.target.value))}
disabled={plansLoading || plans.length === 0}
>
{plansLoading && <option>Loading plans...</option>}
{!plansLoading && plans.length === 0 && (
<option value="">No plans available</option>
)}
{plans.map((plan) => (
<option key={plan.id} value={plan.id}>
{plan.name}
{plan.price ? ` - $${plan.price}/${plan.billing_cycle || "month"}` : ""}
</option>
))}
</select>
</div>
{/* <!-- Password --> */}
<div>
<Label>

View File

@@ -49,7 +49,7 @@ export default function WorkflowGuide({ onSiteAdded }: WorkflowGuideProps) {
const [siteName, setSiteName] = useState('');
const [websiteAddress, setWebsiteAddress] = useState('');
// Load dismissal state from backend on mount
// Load dismissal state from backend on mount (guarded in store)
useEffect(() => {
if (isAuthenticated) {
loadFromBackend().catch(() => {
@@ -67,8 +67,11 @@ export default function WorkflowGuide({ onSiteAdded }: WorkflowGuideProps) {
setLoadingIndustries(true);
const response = await fetchIndustries();
setIndustries(response.industries || []);
} catch (error) {
console.error('Failed to load industries:', error);
} catch (error: any) {
// Swallow 429 to avoid noisy console; user can retry via toggle
if (error?.status !== 429) {
console.error('Failed to load industries:', error);
}
} finally {
setLoadingIndustries(false);
}
@@ -108,8 +111,10 @@ export default function WorkflowGuide({ onSiteAdded }: WorkflowGuideProps) {
setLoadingIndustries(true);
const response = await fetchIndustries();
setIndustries(response.industries || []);
} catch (error) {
console.error('Failed to load industries:', error);
} catch (error: any) {
if (error?.status !== 429) {
console.error('Failed to load industries:', error);
}
} finally {
setLoadingIndustries(false);
}

View File

@@ -43,10 +43,12 @@ const AppSidebar: React.FC = () => {
const { user, isAuthenticated } = useAuthStore();
const { moduleEnableSettings, isModuleEnabled: checkModuleEnabled, loadModuleEnableSettings, loading: settingsLoading } = useSettingsStore();
// Show admin menu only for users in aws-admin account
// Show admin menu only for system account (aws-admin/default) or developer
const isAwsAdminAccount = Boolean(
user?.account?.slug === 'aws-admin' ||
user?.role === 'developer' // Also show for developers as fallback
user?.account?.slug === 'aws-admin' ||
user?.account?.slug === 'default-account' ||
user?.account?.slug === 'default' ||
user?.role === 'developer'
);
// Helper to check if module is enabled - memoized to prevent infinite loops
@@ -226,10 +228,12 @@ const AppSidebar: React.FC = () => {
name: "Profile Settings",
path: "/settings/profile",
},
// Integration is admin-only; hide for non-privileged users (handled in render)
{
icon: <PlugInIcon />,
name: "Integration",
path: "/settings/integration",
adminOnly: true,
},
{
icon: <PageIcon />,
@@ -327,9 +331,17 @@ const AppSidebar: React.FC = () => {
// Combine all sections, including admin if user is in aws-admin account
const allSections = useMemo(() => {
const baseSections = menuSections.map(section => {
// Filter adminOnly items for non-system users
const filteredItems = section.items.filter((item: any) => {
if ((item as any).adminOnly && !isAwsAdminAccount) return false;
return true;
});
return { ...section, items: filteredItems };
});
return isAwsAdminAccount
? [...menuSections, adminSection]
: menuSections;
? [...baseSections, adminSection]
: baseSections;
}, [isAwsAdminAccount, menuSections, adminSection]);
useEffect(() => {

View File

@@ -432,18 +432,23 @@ export default function Home() {
const fetchAppInsights = async () => {
try {
setLoading(true);
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
// Determine site_id based on filter
const siteId = siteFilter === 'all' ? undefined : siteFilter;
const [keywordsRes, clustersRes, ideasRes, tasksRes, contentRes, imagesRes] = await Promise.all([
fetchKeywords({ page_size: 1, site_id: siteId }),
fetchClusters({ page_size: 1, site_id: siteId }),
fetchContentIdeas({ page_size: 1, site_id: siteId }),
fetchTasks({ page_size: 1, site_id: siteId }),
fetchContent({ page_size: 1, site_id: siteId }),
fetchContentImages({ page_size: 1, site_id: siteId })
]);
// Fetch sequentially with small delays to avoid burst throttling
const keywordsRes = await fetchKeywords({ page_size: 1, site_id: siteId });
await delay(120);
const clustersRes = await fetchClusters({ page_size: 1, site_id: siteId });
await delay(120);
const ideasRes = await fetchContentIdeas({ page_size: 1, site_id: siteId });
await delay(120);
const tasksRes = await fetchTasks({ page_size: 1, site_id: siteId });
await delay(120);
const contentRes = await fetchContent({ page_size: 1, site_id: siteId });
await delay(120);
const imagesRes = await fetchContentImages({ page_size: 1, site_id: siteId });
const totalKeywords = keywordsRes.count || 0;
const totalClusters = clustersRes.count || 0;
@@ -500,8 +505,15 @@ export default function Home() {
setLastUpdated(new Date());
} catch (error: any) {
console.error('Error fetching insights:', error);
toast.error(`Failed to load insights: ${error.message}`);
if (error?.status === 429) {
// Back off and retry once after a short delay
setTimeout(() => {
fetchAppInsights();
}, 2000);
} else {
console.error('Error fetching insights:', error);
toast.error(`Failed to load insights: ${error.message}`);
}
} finally {
setLoading(false);
}

View File

@@ -88,7 +88,7 @@ const getAuthToken = (): string | null => {
}
// Fallback to localStorage (for cases where store hasn't initialized yet)
const authStorage = localStorage.getItem('auth-storage');
const authStorage = localStorage.getItem('auth-store');
if (authStorage) {
const parsed = JSON.parse(authStorage);
return parsed?.state?.token || null;
@@ -109,7 +109,7 @@ const getRefreshToken = (): string | null => {
}
// Fallback to localStorage (for cases where store hasn't initialized yet)
const authStorage = localStorage.getItem('auth-storage');
const authStorage = localStorage.getItem('auth-store');
if (authStorage) {
const parsed = JSON.parse(authStorage);
return parsed?.state?.refreshToken || null;

View File

@@ -32,6 +32,16 @@ export const useOnboardingStore = create<OnboardingState>()(
lastSyncedAt: null,
loadFromBackend: async () => {
const state = get();
// Avoid hammering the endpoint; re-fetch at most every 5 minutes
if (state.lastSyncedAt) {
const elapsedMs = Date.now() - state.lastSyncedAt.getTime();
if (elapsedMs < 5 * 60 * 1000) {
return;
}
}
if (state.isLoading) return;
set({ isLoading: true });
try {
const setting = await fetchUserSetting(GUIDE_SETTING_KEY);
@@ -44,7 +54,9 @@ export const useOnboardingStore = create<OnboardingState>()(
});
} catch (error: any) {
// 404 means setting doesn't exist yet - that's fine, use local state
if (error.status !== 404) {
if (error?.status === 429) {
// Throttled: back off and don't spam warnings
} else if (error?.status !== 404) {
console.warn('Failed to load guide dismissal from backend:', error);
}
set({ isLoading: false });