Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
"web-vitals": "^4.2.4",
"workbox-webpack-plugin": "^7.0.0",
"zod": "^3.25.75",
"i18next": "^24.0.0",
"react-i18next": "^15.0.0",
"zustand": "^5.0.10"
},
"devDependencies": {
Expand Down
84 changes: 84 additions & 0 deletions src/app/api/admin/feature-flags/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
import {
flagStore,
createAuditEntry,
} from '@/lib/feature-flags/store';
import type { FeatureFlag, TargetingRule } from '@/lib/feature-flags/store';
import { withRateLimit } from '@/lib/ratelimit';

// ─── GET /api/admin/feature-flags/[id] ───────────────────────────────────────

export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { addHeaders, rateLimitResponse } = withRateLimit(req, 'API');
if (rateLimitResponse) return rateLimitResponse;

const { id } = await params;
const flag = flagStore.get(id);
if (!flag) return addHeaders(NextResponse.json({ message: 'Not found' }, { status: 404 }));

return addHeaders(NextResponse.json({ flag }));
}

// ─── PUT /api/admin/feature-flags/[id] ───────────────────────────────────────
// Full or partial update. Also handles toggle via { enabled: boolean }.

export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { addHeaders, rateLimitResponse } = withRateLimit(req, 'AUTH');
if (rateLimitResponse) return rateLimitResponse;

const { id } = await params;
const existing = flagStore.get(id);
if (!existing) return addHeaders(NextResponse.json({ message: 'Not found' }, { status: 404 }));

const body = await req.json().catch(() => null);
if (!body) return addHeaders(NextResponse.json({ message: 'Invalid JSON' }, { status: 400 }));

const actor = req.headers.get('x-admin-user') ?? 'anonymous';

const updated: FeatureFlag = {
...existing,
...(typeof body.name === 'string' && body.name.trim() ? { name: body.name.trim() } : {}),
...(typeof body.description === 'string' ? { description: body.description.trim() } : {}),
...(typeof body.enabled === 'boolean' ? { enabled: body.enabled } : {}),
...(['all', 'percentage', 'targeting'].includes(body.strategy) ? { strategy: body.strategy } : {}),
...(typeof body.percentage === 'number' ? { percentage: Math.max(0, Math.min(100, body.percentage)) } : {}),
...(Array.isArray(body.rules) ? { rules: body.rules as TargetingRule[] } : {}),
...(Array.isArray(body.tags) ? { tags: body.tags.map(String) } : {}),
updatedAt: new Date().toISOString(),
};

flagStore.set(id, updated);

const action = typeof body.enabled === 'boolean' && body.enabled !== existing.enabled
? 'toggled'
: 'updated';
createAuditEntry(action, actor, existing, updated);

return addHeaders(NextResponse.json({ flag: updated }));
}

// ─── DELETE /api/admin/feature-flags/[id] ────────────────────────────────────

export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { addHeaders, rateLimitResponse } = withRateLimit(req, 'AUTH');
if (rateLimitResponse) return rateLimitResponse;

const { id } = await params;
const existing = flagStore.get(id);
if (!existing) return addHeaders(NextResponse.json({ message: 'Not found' }, { status: 404 }));

const actor = req.headers.get('x-admin-user') ?? 'anonymous';
flagStore.delete(id);
createAuditEntry('deleted', actor, existing, null);

return addHeaders(NextResponse.json({ message: 'Deleted' }));
}
21 changes: 21 additions & 0 deletions src/app/api/admin/feature-flags/audit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditLog } from '@/lib/feature-flags/store';
import { withRateLimit } from '@/lib/ratelimit';

/**
* GET /api/admin/feature-flags/audit?flagId=<id>&limit=50&offset=0
*/
export async function GET(req: NextRequest) {
const { addHeaders, rateLimitResponse } = withRateLimit(req, 'API');
if (rateLimitResponse) return rateLimitResponse;

const { searchParams } = new URL(req.url);
const flagId = searchParams.get('flagId');
const limit = Math.min(200, Math.max(1, parseInt(searchParams.get('limit') ?? '50', 10)));
const offset = Math.max(0, parseInt(searchParams.get('offset') ?? '0', 10));

const filtered = flagId ? auditLog.filter((e) => e.flagId === flagId) : auditLog;
const page = filtered.slice(offset, offset + limit);

return addHeaders(NextResponse.json({ entries: page, total: filtered.length }));
}
34 changes: 34 additions & 0 deletions src/app/api/admin/feature-flags/evaluate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { flagStore, evaluateFlag } from '@/lib/feature-flags/store';
import { withRateLimit } from '@/lib/ratelimit';

/**
* GET /api/admin/feature-flags/evaluate?id=<flagId>&userId=<uid>&plan=<plan>…
*
* All query params beyond `id` are passed as the evaluation context.
*/
export async function GET(req: NextRequest) {
const { addHeaders, rateLimitResponse } = withRateLimit(req, 'API');
if (rateLimitResponse) return rateLimitResponse;

const { searchParams } = new URL(req.url);
const id = searchParams.get('id');

if (!id) {
return addHeaders(NextResponse.json({ message: 'id param required' }, { status: 400 }));
}

const flag = flagStore.get(id);
if (!flag) {
return addHeaders(NextResponse.json({ message: 'Not found' }, { status: 404 }));
}

// Build context from remaining search params
const context: Record<string, string> = {};
searchParams.forEach((value, key) => {
if (key !== 'id') context[key] = value;
});

const isEnabled = evaluateFlag(flag, context);
return addHeaders(NextResponse.json({ flag, isEnabled, context }));
}
61 changes: 61 additions & 0 deletions src/app/api/admin/feature-flags/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server';
import {
flagStore,
auditLog,
createAuditEntry,
generateId,
evaluateFlag,
} from '@/lib/feature-flags/store';
import type { FeatureFlag, TargetingRule } from '@/lib/feature-flags/store';
import { withRateLimit } from '@/lib/ratelimit';

// ─── GET /api/admin/feature-flags ─────────────────────────────────────────────
// Returns the full flag list sorted by updatedAt desc.

export async function GET(req: NextRequest) {
const { addHeaders, rateLimitResponse } = withRateLimit(req, 'API');
if (rateLimitResponse) return rateLimitResponse;

const flags = Array.from(flagStore.values()).sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
);

return addHeaders(NextResponse.json({ flags }));
}

// ─── POST /api/admin/feature-flags ───────────────────────────────────────────
// Creates a new flag.

export async function POST(req: NextRequest) {
const { addHeaders, rateLimitResponse } = withRateLimit(req, 'AUTH');
if (rateLimitResponse) return rateLimitResponse;

const body = await req.json().catch(() => null);
if (!body || typeof body.name !== 'string' || !body.name.trim()) {
return addHeaders(NextResponse.json({ message: 'name is required' }, { status: 400 }));
}

const actor = req.headers.get('x-admin-user') ?? 'anonymous';
const now = new Date().toISOString();

const flag: FeatureFlag = {
id: generateId('flag'),
name: body.name.trim(),
description: typeof body.description === 'string' ? body.description.trim() : '',
enabled: false,
strategy: ['all', 'percentage', 'targeting'].includes(body.strategy)
? body.strategy
: 'all',
percentage: typeof body.percentage === 'number' ? Math.max(0, Math.min(100, body.percentage)) : 0,
rules: Array.isArray(body.rules) ? (body.rules as TargetingRule[]) : [],
tags: Array.isArray(body.tags) ? body.tags.map(String) : [],
createdAt: now,
updatedAt: now,
createdBy: actor,
};

flagStore.set(flag.id, flag);
createAuditEntry('created', actor, null, flag);

return addHeaders(NextResponse.json({ flag }, { status: 201 }));
}
16 changes: 14 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { Geist, Geist_Mono } from 'next/font/google';
import Script from 'next/script';
import './globals.css';
import { RootProviders } from '@/providers/RootProviders';
import { getHtmlDir } from '@/lib/i18n/config';

// Languages supported at startup — extend as new locale files are added.
const VALID_LOCALES = new Set(['en', 'es', 'ar', 'fr', 'de', 'he', 'ja', 'zh', 'pt', 'ru', 'it', 'ko']);

const geistSans = Geist({
variable: '--font-geist-sans',
Expand All @@ -30,6 +34,12 @@ export default async function RootLayout({
const themeCookie = cookieStore.get('theme');
const defaultTheme = themeCookie ? themeCookie.value : 'system';

// Read persisted locale to server-render the correct lang/dir on <html> —
// avoids a hydration flash for RTL users.
const rawLocale = cookieStore.get('i18n:language')?.value ?? 'en';
const locale = VALID_LOCALES.has(rawLocale) ? rawLocale : 'en';
const dir = getHtmlDir(locale);

const themeScript = `
(function() {
try {
Expand All @@ -49,14 +59,16 @@ export default async function RootLayout({
`;

return (
<html lang="en" suppressHydrationWarning>
<html lang={locale} dir={dir} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-white text-gray-900 transition-colors duration-200 dark:bg-gray-950 dark:text-gray-50`}
>
<RootProviders defaultTheme={defaultTheme}>{children}</RootProviders>
<RootProviders defaultTheme={defaultTheme} defaultLocale={locale}>
{children}
</RootProviders>

{/* Non-essential analytics — loaded after page is interactive */}
{process.env.NEXT_PUBLIC_ANALYTICS_ID && (
Expand Down
Loading
Loading