Skip to content

Commit f8dbcbc

Browse files
maskarbclaude
andauthored
feat: add Unleash feature flag integration to frontend (#655)
Adds feature flag UI and React Query hooks: - Unleash React SDK provider with proxy configuration - Feature flags settings UI in workspace settings - useWorkspaceFlag hook for workspace-scoped evaluation - API routes for flag evaluation and override management - Batch save pattern for admin UI Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c6a68c0 commit f8dbcbc

File tree

23 files changed

+1690
-6
lines changed

23 files changed

+1690
-6
lines changed

components/frontend/.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ MAX_UPLOAD_SIZE_IMAGES=3145728
3232
IMAGE_COMPRESSION_TARGET=358400
3333

3434

35+
# Unleash feature flags (optional)
36+
# When set, the frontend proxy /api/feature-flags will forward to Unleash.
37+
# UNLEASH_URL=https://unleash.example.com
38+
# UNLEASH_CLIENT_KEY=your-frontend-api-token
39+
# UNLEASH_APP_NAME=ambient-code-platform
40+
# NEXT_PUBLIC_UNLEASH_ENV_CONTEXT_FIELD: Environment value sent in SDK context (default: development)
41+
# Note: This does NOT select the Unleash environment - that's determined by the token scope.
42+
# This is only used for strategy constraints that check context.environment.
43+
# NEXT_PUBLIC_UNLEASH_ENV_CONTEXT_FIELD=development
44+
3545
# Langfuse Configuration for User Feedback
3646
# These are used by the /api/feedback route to submit user feedback scores
3747
# Get your keys from your Langfuse instance: Settings > API Keys

components/frontend/package-lock.json

Lines changed: 76 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/frontend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@
1818
"@radix-ui/react-progress": "^1.1.7",
1919
"@radix-ui/react-select": "^2.2.6",
2020
"@radix-ui/react-slot": "^1.2.3",
21+
"@radix-ui/react-switch": "^1.1.3",
2122
"@radix-ui/react-tabs": "^1.1.13",
2223
"@radix-ui/react-toast": "^1.2.15",
2324
"@radix-ui/react-tooltip": "^1.2.8",
2425
"@tanstack/react-query": "^5.90.2",
2526
"@tanstack/react-query-devtools": "^5.90.2",
27+
"@unleash/proxy-client-react": "^5.0.1",
28+
"unleash-proxy-client": "^3.6.1",
2629
"class-variance-authority": "^0.7.1",
2730
"clsx": "^2.1.1",
2831
"date-fns": "^4.1.0",
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { env } from '@/lib/env';
2+
import { NextRequest } from 'next/server';
3+
4+
/**
5+
* POST /api/feature-flags/client/metrics
6+
* Proxies usage metrics from the Unleash SDK to the Unleash server.
7+
* This enables impression data and usage tracking in Unleash.
8+
*/
9+
export async function POST(request: NextRequest) {
10+
const baseUrl = env.UNLEASH_URL?.replace(/\/$/, '');
11+
const clientKey = env.UNLEASH_CLIENT_KEY;
12+
13+
// If Unleash isn't configured, just acknowledge the request
14+
if (!baseUrl || !clientKey) {
15+
console.log('[Unleash Metrics] Unleash not configured, ignoring metrics');
16+
return new Response(null, { status: 202 });
17+
}
18+
19+
const url = new URL('/api/frontend/client/metrics', baseUrl);
20+
21+
try {
22+
const body = await request.json();
23+
console.log('[Unleash Metrics] Forwarding metrics to:', url.toString());
24+
console.log('[Unleash Metrics] Payload:', JSON.stringify(body, null, 2));
25+
26+
const res = await fetch(url.toString(), {
27+
method: 'POST',
28+
headers: {
29+
Authorization: clientKey,
30+
'Content-Type': 'application/json',
31+
},
32+
body: JSON.stringify(body),
33+
});
34+
35+
if (!res.ok) {
36+
const errorText = await res.text();
37+
console.error('[Unleash Metrics] Error:', res.status, errorText);
38+
// Still return 202 to not break the client
39+
return new Response(null, { status: 202 });
40+
}
41+
42+
console.log('[Unleash Metrics] Success:', res.status);
43+
return new Response(null, { status: 202 });
44+
} catch (error) {
45+
console.error('[Unleash Metrics] Fetch error:', error);
46+
return new Response(null, { status: 202 });
47+
}
48+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { env } from '@/lib/env';
2+
import { NextRequest } from 'next/server';
3+
4+
/**
5+
* POST /api/feature-flags/client/register
6+
* Proxies client registration from the Unleash SDK to the Unleash server.
7+
* This allows Unleash to track connected clients/applications.
8+
*/
9+
export async function POST(request: NextRequest) {
10+
const baseUrl = env.UNLEASH_URL?.replace(/\/$/, '');
11+
const clientKey = env.UNLEASH_CLIENT_KEY;
12+
13+
// If Unleash isn't configured, just acknowledge the request
14+
if (!baseUrl || !clientKey) {
15+
return new Response(null, { status: 202 });
16+
}
17+
18+
const url = new URL('/api/frontend/client/register', baseUrl);
19+
20+
try {
21+
const body = await request.json();
22+
23+
const res = await fetch(url.toString(), {
24+
method: 'POST',
25+
headers: {
26+
Authorization: clientKey,
27+
'Content-Type': 'application/json',
28+
},
29+
body: JSON.stringify(body),
30+
});
31+
32+
if (!res.ok) {
33+
console.error('Unleash register proxy error:', res.status, await res.text());
34+
// Still return 202 to not break the client
35+
return new Response(null, { status: 202 });
36+
}
37+
38+
return new Response(null, { status: 202 });
39+
} catch (error) {
40+
console.error('Unleash register proxy fetch error:', error);
41+
return new Response(null, { status: 202 });
42+
}
43+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { env } from '@/lib/env';
2+
import { NextRequest } from 'next/server';
3+
4+
/**
5+
* GET /api/feature-flags
6+
* Proxies to Unleash Frontend API when UNLEASH_URL and UNLEASH_CLIENT_KEY are set.
7+
* Returns empty toggles when Unleash is not configured (SDK still works, all flags off).
8+
* Used by @unleash/proxy-client-react so the client never sees the real Unleash URL or key.
9+
*/
10+
export async function GET(request: NextRequest) {
11+
const baseUrl = env.UNLEASH_URL?.replace(/\/$/, '');
12+
const clientKey = env.UNLEASH_CLIENT_KEY;
13+
14+
if (!baseUrl || !clientKey) {
15+
return Response.json({ toggles: [] });
16+
}
17+
18+
const url = new URL('/api/frontend', baseUrl);
19+
// Forward query params (e.g. projectId) if needed for strategies
20+
request.nextUrl.searchParams.forEach((value, key) => {
21+
url.searchParams.set(key, value);
22+
});
23+
24+
try {
25+
const res = await fetch(url.toString(), {
26+
method: 'GET',
27+
headers: {
28+
Authorization: clientKey,
29+
'Content-Type': 'application/json',
30+
},
31+
next: { revalidate: 15 },
32+
});
33+
34+
if (!res.ok) {
35+
console.error('Unleash proxy error:', res.status, await res.text());
36+
return Response.json({ toggles: [] });
37+
}
38+
39+
const data = await res.json();
40+
return Response.json(data);
41+
} catch (error) {
42+
console.error('Unleash proxy fetch error:', error);
43+
return Response.json({ toggles: [] });
44+
}
45+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { BACKEND_URL } from "@/lib/config";
2+
import { buildForwardHeadersAsync } from "@/lib/auth";
3+
4+
/**
5+
* POST /api/projects/:projectName/feature-flags/:flagName/disable
6+
* Proxies to backend to disable a feature flag in Unleash
7+
*/
8+
export async function POST(
9+
request: Request,
10+
{ params }: { params: Promise<{ name: string; flagName: string }> }
11+
) {
12+
try {
13+
const { name: projectName, flagName } = await params;
14+
const headers = await buildForwardHeadersAsync(request);
15+
16+
const response = await fetch(
17+
`${BACKEND_URL}/projects/${encodeURIComponent(projectName)}/feature-flags/${encodeURIComponent(flagName)}/disable`,
18+
{
19+
method: "POST",
20+
headers,
21+
}
22+
);
23+
24+
const data = await response.text();
25+
26+
return new Response(data, {
27+
status: response.status,
28+
headers: { "Content-Type": "application/json" },
29+
});
30+
} catch (error) {
31+
console.error("Failed to disable feature flag:", error);
32+
return Response.json(
33+
{ error: "Failed to disable feature flag" },
34+
{ status: 500 }
35+
);
36+
}
37+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { BACKEND_URL } from "@/lib/config";
2+
import { buildForwardHeadersAsync } from "@/lib/auth";
3+
4+
/**
5+
* POST /api/projects/:projectName/feature-flags/:flagName/enable
6+
* Proxies to backend to enable a feature flag in Unleash
7+
*/
8+
export async function POST(
9+
request: Request,
10+
{ params }: { params: Promise<{ name: string; flagName: string }> }
11+
) {
12+
try {
13+
const { name: projectName, flagName } = await params;
14+
const headers = await buildForwardHeadersAsync(request);
15+
16+
const response = await fetch(
17+
`${BACKEND_URL}/projects/${encodeURIComponent(projectName)}/feature-flags/${encodeURIComponent(flagName)}/enable`,
18+
{
19+
method: "POST",
20+
headers,
21+
}
22+
);
23+
24+
const data = await response.text();
25+
26+
return new Response(data, {
27+
status: response.status,
28+
headers: { "Content-Type": "application/json" },
29+
});
30+
} catch (error) {
31+
console.error("Failed to enable feature flag:", error);
32+
return Response.json(
33+
{ error: "Failed to enable feature flag" },
34+
{ status: 500 }
35+
);
36+
}
37+
}

0 commit comments

Comments
 (0)