Skip to content

Commit 0471dab

Browse files
committed
feat: implement feature flag management and cleanup
- Added feature flag handling in various components and API routes. - Introduced cookies for managing feature flags and updated authentication flows. - Removed deprecated feature flag provider and related components. - Enhanced sidebar and assessment page to utilize feature flags for conditional rendering. - Updated middleware to enforce feature-based access control.
1 parent 5d8d9bc commit 0471dab

16 files changed

Lines changed: 296 additions & 289 deletions

File tree

app/api/auth/logout/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { apiClient } from "@/app/lib/apiClient";
3-
import { clearRoleCookie } from "@/app/lib/authCookie";
3+
import { clearFeaturesCookie, clearRoleCookie } from "@/app/lib/authCookie";
44

55
export async function POST(request: NextRequest) {
66
const { status, data, headers } = await apiClient(
@@ -17,6 +17,7 @@ export async function POST(request: NextRequest) {
1717
}
1818

1919
clearRoleCookie(res);
20+
clearFeaturesCookie(res);
2021

2122
return res;
2223
}

app/api/features/route.ts

Lines changed: 0 additions & 39 deletions
This file was deleted.

app/api/users/me/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { apiClient } from "@/app/lib/apiClient";
3-
import { setRoleCookieFromBody } from "@/app/lib/authCookie";
3+
import {
4+
setFeaturesCookieFromBody,
5+
setRoleCookieFromBody,
6+
} from "@/app/lib/authCookie";
47

58
export async function GET(request: NextRequest) {
69
try {
@@ -9,6 +12,7 @@ export async function GET(request: NextRequest) {
912

1013
if (status >= 200 && status < 300) {
1114
setRoleCookieFromBody(res, data);
15+
setFeaturesCookieFromBody(res, data);
1216
}
1317

1418
return res;

app/assessment/AssessmentPageClient.tsx

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"use client";
22

33
import { useState, useEffect, useCallback, useRef, Suspense } from "react";
4+
import { useRouter } from "next/navigation";
45
import { colors } from "@/app/lib/colors";
56
import { STORAGE_KEY } from "@/app/lib/constants/keystore";
7+
import { FeatureFlag } from "@/app/lib/constants/featureFlags";
8+
import { removeFeatureFromClient } from "@/app/lib/featureState";
69
import { APIKey } from "@/app/lib/types/credentials";
710
import Sidebar from "@/app/components/Sidebar";
811
import Loader from "@/app/components/Loader";
@@ -50,6 +53,7 @@ function ShimmerDot({ color }: { color: string }) {
5053
type IndicatorState = "none" | "processing" | "failed" | "success";
5154

5255
function AssessmentContent() {
56+
const router = useRouter();
5357
const toast = useToast();
5458
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
5559
const [activeTab, setActiveTab] = useState<TabId>("datasets");
@@ -62,6 +66,7 @@ function AssessmentContent() {
6266
const [selectedKeyId, setSelectedKeyId] = useState("");
6367
const [evalIndicator, setEvalIndicator] = useState<IndicatorState>("none");
6468
const dismissedRef = useRef(false);
69+
const featureRedirectingRef = useRef(false);
6570
const [assessmentRefreshToken, setAssessmentRefreshToken] = useState(0);
6671
const [experimentName, setExperimentName] = useState("");
6772
const {
@@ -92,12 +97,107 @@ function AssessmentContent() {
9297

9398
const selectedKey = apiKeys.find((k) => k.id === selectedKeyId);
9499

100+
const redirectIfFeatureDisabled = useCallback(
101+
async (
102+
response: Response,
103+
options?: { notify?: boolean },
104+
): Promise<boolean> => {
105+
if (response.status !== 403) return false;
106+
107+
const errorData = await response
108+
.clone()
109+
.json()
110+
.catch(() => ({}));
111+
const message = String(
112+
errorData?.error ?? errorData?.message ?? errorData?.detail ?? "",
113+
).toLowerCase();
114+
115+
if (
116+
message.includes("feature") &&
117+
message.includes("assessment") &&
118+
message.includes("not enabled")
119+
) {
120+
if (!featureRedirectingRef.current) {
121+
featureRedirectingRef.current = true;
122+
if (options?.notify) {
123+
toast.error(
124+
"Assessment feature is disabled for this organization/project.",
125+
);
126+
}
127+
removeFeatureFromClient(FeatureFlag.ASSESSMENT);
128+
// Trigger middleware redirect by navigating to the gated route.
129+
window.setTimeout(
130+
() => {
131+
router.replace("/assessment");
132+
},
133+
options?.notify ? 300 : 0,
134+
);
135+
}
136+
return true;
137+
}
138+
139+
return false;
140+
},
141+
[router, toast],
142+
);
143+
144+
useEffect(() => {
145+
const originalFetch = window.fetch.bind(window);
146+
147+
window.fetch = async (...args: Parameters<typeof fetch>) => {
148+
const response = await originalFetch(...args);
149+
try {
150+
const input = args[0];
151+
const requestUrl =
152+
typeof input === "string"
153+
? input
154+
: input instanceof URL
155+
? input.toString()
156+
: input.url;
157+
158+
if (requestUrl.includes("/api/assessment/")) {
159+
await redirectIfFeatureDisabled(response);
160+
}
161+
} catch {
162+
// silently ignore
163+
}
164+
return response;
165+
};
166+
167+
return () => {
168+
window.fetch = originalFetch;
169+
};
170+
}, [redirectIfFeatureDisabled]);
171+
172+
useEffect(() => {
173+
if (!selectedKey?.key) return;
174+
175+
let cancelled = false;
176+
177+
(async () => {
178+
try {
179+
const response = await fetch("/api/assessment/evaluations?limit=1", {
180+
headers: { "X-API-KEY": selectedKey.key },
181+
});
182+
if (cancelled) return;
183+
await redirectIfFeatureDisabled(response, { notify: true });
184+
} catch {
185+
// silently ignore
186+
}
187+
})();
188+
189+
return () => {
190+
cancelled = true;
191+
};
192+
}, [redirectIfFeatureDisabled, selectedKey?.key]);
193+
95194
const pollEvalStatus = useCallback(async () => {
96195
if (!selectedKey) return;
97196
try {
98-
const response = await fetch("/api/assessment/assessments?limit=10", {
197+
const response = await fetch("/api/assessment/evaluations?limit=10", {
99198
headers: { "X-API-KEY": selectedKey.key },
100199
});
200+
if (await redirectIfFeatureDisabled(response)) return;
101201
if (!response.ok) return;
102202
const data = await response.json();
103203
const runs = Array.isArray(data) ? data : data.data || [];
@@ -142,7 +242,7 @@ function AssessmentContent() {
142242
} catch {
143243
// silently fail
144244
}
145-
}, [selectedKey]);
245+
}, [redirectIfFeatureDisabled, selectedKey]);
146246

147247
useEffect(() => {
148248
if (!selectedKey) return;
@@ -223,6 +323,8 @@ function AssessmentContent() {
223323
body: JSON.stringify(payload),
224324
});
225325

326+
if (await redirectIfFeatureDisabled(response)) return;
327+
226328
if (!response.ok) {
227329
const errorData = await response.json().catch(() => ({}));
228330
throw new Error(

app/assessment/page.tsx

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,5 @@
1-
import { notFound } from "next/navigation";
21
import AssessmentPageClient from "@/app/assessment/AssessmentPageClient";
3-
import FeatureRouteGuard from "@/app/components/FeatureRouteGuard";
4-
import { FeatureFlag } from "@/app/lib/constants/featureFlags";
5-
import { getServerFeatureFlags } from "@/app/lib/featureFlags.server";
62

7-
export default async function AssessmentPage() {
8-
const initialFlags = await getServerFeatureFlags();
9-
10-
if (!initialFlags[FeatureFlag.ASSESSMENT]) {
11-
notFound();
12-
}
13-
14-
return (
15-
<FeatureRouteGuard featureFlag={FeatureFlag.ASSESSMENT}>
16-
<AssessmentPageClient />
17-
</FeatureRouteGuard>
18-
);
3+
export default function AssessmentPage() {
4+
return <AssessmentPageClient />;
195
}

app/components/FeatureRouteGuard.tsx

Lines changed: 0 additions & 35 deletions
This file was deleted.

app/components/Sidebar.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
ShieldCheckIcon,
2020
SlidersIcon,
2121
} from "@/app/components/icons";
22-
import { useFeatureFlags } from "@/app/lib/FeatureFlagProvider";
2322
import { MenuItem, SidebarProps } from "@/app/lib/types/nav";
2423
import { LoginModal } from "@/app/components/auth";
2524
import { Branding, UserMenuPopover } from "@/app/components/user-menu";
@@ -34,9 +33,9 @@ export default function Sidebar({
3433
activeRoute = "/evaluations",
3534
}: SidebarProps) {
3635
const router = useRouter();
37-
const { isEnabled, isLoaded: flagsLoaded } = useFeatureFlags();
3836
const [hasMounted, setHasMounted] = useState(false);
39-
const { currentUser, googleProfile, isAuthenticated, logout } = useAuth();
37+
const { currentUser, googleProfile, isAuthenticated, logout, hasFeature } =
38+
useAuth();
4039
const [expandedMenus, setExpandedMenus] = useState<Record<string, boolean>>({
4140
Evaluations: true,
4241
Configurations: false,
@@ -132,7 +131,7 @@ export default function Sidebar({
132131
if (item.superuserOnly && !currentUser?.is_superuser) return false;
133132
if (item.featureFlag) {
134133
if (!hasMounted) return false;
135-
if (flagsLoaded && !isEnabled(item.featureFlag)) return false;
134+
if (!hasFeature(item.featureFlag)) return false;
136135
}
137136
return true;
138137
}).map((item) => ({

app/layout.tsx

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
import type { Metadata } from "next";
22
import { Inter, JetBrains_Mono } from "next/font/google";
33
import "./globals.css";
4-
import { ToastProvider } from "@/app/components/Toast";
5-
import { AuthProvider } from "@/app/lib/context/AuthContext";
6-
import { AppProvider } from "@/app/lib/context/AppContext";
7-
import { FeatureFlagProvider } from "./lib/FeatureFlagProvider";
8-
import { getServerFeatureFlags } from "./lib/featureFlags.server";
94
import { Providers } from "@/app/components/providers";
105
import { APP_NAME } from "@/app/lib/constants";
116

@@ -26,25 +21,16 @@ export const metadata: Metadata = {
2621
description: "",
2722
};
2823

29-
export default async function RootLayout({
24+
export default function RootLayout({
3025
children,
3126
}: Readonly<{
3227
children: React.ReactNode;
3328
}>) {
34-
const initialFlags = await getServerFeatureFlags();
35-
3629
return (
3730
<html lang="en">
3831
<body
3932
className={`${inter.variable} ${jetbrainsMono.variable} antialiased`}
4033
>
41-
<FeatureFlagProvider initialFlags={initialFlags}>
42-
<ToastProvider>
43-
<AuthProvider>
44-
<AppProvider>{children}</AppProvider>
45-
</AuthProvider>
46-
</ToastProvider>
47-
</FeatureFlagProvider>
4834
<Providers>{children}</Providers>
4935
</body>
5036
</html>

0 commit comments

Comments
 (0)