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:
@@ -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}>
|
||||
|
||||
25
frontend/src/components/auth/AdminGuard.tsx
Normal file
25
frontend/src/components/auth/AdminGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user