diff --git a/package.json b/package.json index ee1ef820..f942d1ea 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app/api/admin/feature-flags/[id]/route.ts b/src/app/api/admin/feature-flags/[id]/route.ts new file mode 100644 index 00000000..a2a2fb95 --- /dev/null +++ b/src/app/api/admin/feature-flags/[id]/route.ts @@ -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' })); +} diff --git a/src/app/api/admin/feature-flags/audit/route.ts b/src/app/api/admin/feature-flags/audit/route.ts new file mode 100644 index 00000000..1614bdf6 --- /dev/null +++ b/src/app/api/admin/feature-flags/audit/route.ts @@ -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=&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 })); +} diff --git a/src/app/api/admin/feature-flags/evaluate/route.ts b/src/app/api/admin/feature-flags/evaluate/route.ts new file mode 100644 index 00000000..830c789e --- /dev/null +++ b/src/app/api/admin/feature-flags/evaluate/route.ts @@ -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=&userId=&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 = {}; + searchParams.forEach((value, key) => { + if (key !== 'id') context[key] = value; + }); + + const isEnabled = evaluateFlag(flag, context); + return addHeaders(NextResponse.json({ flag, isEnabled, context })); +} diff --git a/src/app/api/admin/feature-flags/route.ts b/src/app/api/admin/feature-flags/route.ts new file mode 100644 index 00000000..f1ee6491 --- /dev/null +++ b/src/app/api/admin/feature-flags/route.ts @@ -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 })); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fbb3b17a..1b9a9701 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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', @@ -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 — + // 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 { @@ -49,14 +59,16 @@ export default async function RootLayout({ `; return ( - +