v0.0.10: Server/client architecture with enhanced security and UX improvements

- Split feature flags view into server/client components for better performance
- Added comprehensive security checks for authentication and authorization
- Implemented read-only mode for users without update permissions
- Fixed checkbox state synchronization issues with server updates
- Improved UX: rollout percentage editable regardless of flag enabled state
- Added DefaultTemplate integration with proper PayloadCMS admin layout
- Enhanced error handling with specific messages for auth/permissions
- Removed debug logging for production readiness

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-03 17:12:16 +02:00
parent bca558fad3
commit 3696ff7641
4 changed files with 774 additions and 623 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/payload-feature-flags", "name": "@xtr-dev/payload-feature-flags",
"version": "0.0.9", "version": "0.0.10",
"description": "Feature flags plugin for Payload CMS - manage feature toggles, A/B tests, and gradual rollouts", "description": "Feature flags plugin for Payload CMS - manage feature toggles, A/B tests, and gradual rollouts",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -1,2 +1,2 @@
// Custom admin views // Custom admin views
export { FeatureFlagsView } from '../views/FeatureFlagsView.js' export { default as FeatureFlagsView } from '../views/FeatureFlagsView.js'

View File

@@ -0,0 +1,634 @@
'use client'
import { useState, useEffect, useCallback, useMemo, memo } from 'react'
import {
useConfig,
useTheme
} from '@payloadcms/ui'
interface FeatureFlag {
id: string
name: string
description?: string
enabled: boolean
rolloutPercentage?: number
variants?: Array<{
name: string
weight: number
metadata?: any
}>
environment?: 'development' | 'staging' | 'production'
tags?: Array<{ tag: string }>
metadata?: any
createdAt: string
updatedAt: string
}
interface FeatureFlagsClientProps {
initialFlags?: FeatureFlag[]
canUpdate?: boolean
}
const FeatureFlagsClientComponent = ({ initialFlags = [], canUpdate = true }: FeatureFlagsClientProps) => {
const { config } = useConfig()
const { theme } = useTheme()
const [flags, setFlags] = useState<FeatureFlag[]>(initialFlags)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [search, setSearch] = useState('')
const [sortField, setSortField] = useState<'name' | 'enabled' | 'rolloutPercentage' | 'updatedAt'>('name')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
const [saving, setSaving] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState('')
const fetchFlags = async (signal?: AbortSignal) => {
try {
setLoading(true)
setError('')
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags?limit=1000`, {
credentials: 'include',
signal,
})
if (!response.ok) {
throw new Error(`Failed to fetch feature flags: ${response.statusText}`)
}
const result = await response.json()
// Extract docs array from Payload API response and filter out null/invalid entries
const flagsArray = (result.docs || []).filter((flag: any) => flag && flag.id && flag.name)
// Only update state if the component is still mounted (signal not aborted)
if (!signal?.aborted) {
setFlags(flagsArray as FeatureFlag[])
}
} catch (err) {
// Don't show error if request was aborted (component unmounting)
if (err instanceof Error && err.name === 'AbortError') {
return
}
console.error('Error fetching feature flags:', err)
if (!signal?.aborted) {
setError(err instanceof Error ? err.message : 'Failed to fetch feature flags')
}
} finally {
if (!signal?.aborted) {
setLoading(false)
}
}
}
const updateFlag = useCallback(async (flagId: string, updates: Partial<FeatureFlag>) => {
// Security check: Don't allow updates if user doesn't have permission
if (!canUpdate) {
setError('You do not have permission to update feature flags')
setTimeout(() => setError(''), 5000)
return
}
setSaving(flagId)
setError('')
setSuccessMessage('')
try {
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags/${flagId}`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates),
})
if (!response.ok) {
if (response.status === 403) {
throw new Error('Access denied: You do not have permission to update this feature flag')
}
if (response.status === 401) {
throw new Error('Authentication required: Please log in again')
}
throw new Error(`Failed to update feature flag: ${response.statusText}`)
}
const updatedFlag = await response.json()
// Update local state - merge only the specific updates we sent
setFlags(prev => prev.map(flag =>
flag.id === flagId ? { ...flag, ...updates, updatedAt: updatedFlag.updatedAt || new Date().toISOString() } : flag
))
setSuccessMessage('✓ Saved')
setTimeout(() => setSuccessMessage(''), 2000)
} catch (err) {
console.error('Error updating feature flag:', err)
setError(err instanceof Error ? err.message : 'Failed to update feature flag')
setTimeout(() => setError(''), 5000)
} finally {
setSaving(null)
}
}, [config.serverURL, config.routes.api, canUpdate])
const handleSort = useCallback((field: typeof sortField) => {
if (sortField === field) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDirection('asc')
}
}, [sortField])
const filteredAndSortedFlags = useMemo(() => {
// Filter out null/undefined entries first
let filtered = flags.filter(flag => flag && flag.name)
// Filter by search
if (search) {
filtered = filtered.filter(flag =>
flag.name?.toLowerCase().includes(search.toLowerCase()) ||
flag.description?.toLowerCase().includes(search.toLowerCase()) ||
flag.tags?.some(t => t.tag?.toLowerCase().includes(search.toLowerCase()))
)
}
// Sort
filtered.sort((a, b) => {
let aVal: any = a[sortField]
let bVal: any = b[sortField]
if (sortField === 'updatedAt') {
aVal = new Date(aVal || 0).getTime()
bVal = new Date(bVal || 0).getTime()
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1
return 0
})
return filtered
}, [flags, search, sortField, sortDirection])
const SortIcon = ({ field }: { field: typeof sortField }) => {
if (sortField !== field) {
return <span style={{ opacity: 0.3 }}></span>
}
return <span>{sortDirection === 'asc' ? '↑' : '↓'}</span>
}
// Theme-aware styles
const getThemeStyles = () => ({
background: 'var(--theme-bg)',
surface: 'var(--theme-elevation-50)',
surfaceHover: 'var(--theme-elevation-100)',
border: 'var(--theme-elevation-150)',
text: 'var(--theme-text)',
textMuted: 'var(--theme-text-400)',
textSubdued: 'var(--theme-text-600)',
primary: 'var(--theme-success-500)',
warning: 'var(--theme-warning-500)',
error: 'var(--theme-error-500)',
info: 'var(--theme-info-500)',
inputBg: 'var(--theme-elevation-0)',
inputBorder: 'var(--theme-elevation-250)',
headerBg: 'var(--theme-elevation-100)',
})
const styles = getThemeStyles()
return (
<div style={{
padding: '2rem',
maxWidth: '100%'
}}>
{/* Header */}
<div style={{ marginBottom: '2rem' }}>
<h1 style={{
fontSize: '2rem',
fontWeight: '700',
color: styles.text,
margin: '0 0 0.5rem 0'
}}>
Feature Flags Dashboard
</h1>
<p style={{
color: styles.textMuted,
fontSize: '1rem',
margin: '0 0 1rem 0'
}}>
Manage all feature flags in a spreadsheet view with inline editing capabilities
</p>
{!canUpdate && (
<div style={{
backgroundColor: styles.info + '20',
border: `1px solid ${styles.info}`,
borderRadius: '0.5rem',
padding: '0.75rem 1rem',
marginBottom: '1rem',
color: styles.info,
fontSize: '0.875rem'
}}>
<strong>Read-Only Access:</strong> You can view feature flags but cannot edit them. Contact your administrator to request update permissions.
</div>
)}
</div>
{loading ? (
<div style={{
padding: '2rem',
textAlign: 'center',
minHeight: '400px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{ fontSize: '1.125rem', color: styles.textMuted }}>Loading feature flags...</div>
</div>
) : (
<>
{/* Success/Error Messages */}
{successMessage && (
<div style={{
position: 'fixed',
top: '20px',
right: '20px',
backgroundColor: styles.primary,
color: 'white',
padding: '0.75rem 1.5rem',
borderRadius: '0.5rem',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
zIndex: 1000,
}}>
{successMessage}
</div>
)}
{error && (
<div style={{
marginBottom: '1rem',
backgroundColor: styles.error + '20',
border: `1px solid ${styles.error}`,
borderRadius: '0.5rem',
padding: '1rem',
color: styles.error
}}>
<strong>Error:</strong> {error}
</div>
)}
{/* Controls */}
<div style={{
display: 'flex',
gap: '1rem',
marginBottom: '2rem',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<input
type="text"
placeholder="Search flags by name, description, or tags..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
padding: '0.5rem 1rem',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '0.5rem',
fontSize: '0.875rem',
width: '300px',
backgroundColor: styles.inputBg,
color: styles.text
}}
/>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<div style={{ fontSize: '0.875rem', color: styles.textMuted }}>
{filteredAndSortedFlags.length} of {flags.filter(f => f && f.name).length} flags
</div>
<button
onClick={() => fetchFlags()}
style={{
padding: '0.5rem 1rem',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '0.5rem',
backgroundColor: styles.surface,
color: styles.text,
fontSize: '0.875rem',
cursor: 'pointer'
}}
>
🔄 Refresh
</button>
</div>
</div>
{/* Spreadsheet Table */}
<div style={{
backgroundColor: styles.surface,
border: `1px solid ${styles.border}`,
borderRadius: '0.75rem',
overflow: 'hidden',
marginBottom: '2rem'
}}>
<div style={{ overflowX: 'auto' }}>
<table style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.875rem'
}}>
<thead>
<tr style={{ backgroundColor: styles.headerBg }}>
<th style={{
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
position: 'sticky',
left: 0,
backgroundColor: styles.headerBg,
minWidth: '50px'
}}>
Status
</th>
<th
onClick={() => handleSort('name')}
style={{
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
cursor: 'pointer',
minWidth: '200px',
userSelect: 'none'
}}
>
Name <SortIcon field="name" />
</th>
<th style={{
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
minWidth: '300px'
}}>
Description
</th>
<th
onClick={() => handleSort('enabled')}
style={{
padding: '0.75rem 1rem',
textAlign: 'center',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
cursor: 'pointer',
minWidth: '80px',
userSelect: 'none'
}}
>
Enabled <SortIcon field="enabled" />
</th>
<th
onClick={() => handleSort('rolloutPercentage')}
style={{
padding: '0.75rem 1rem',
textAlign: 'center',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
cursor: 'pointer',
minWidth: '120px',
userSelect: 'none'
}}
>
Rollout % <SortIcon field="rolloutPercentage" />
</th>
<th style={{
padding: '0.75rem 1rem',
textAlign: 'center',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
minWidth: '100px'
}}>
Variants
</th>
<th style={{
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
minWidth: '150px'
}}>
Tags
</th>
<th
onClick={() => handleSort('updatedAt')}
style={{
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
cursor: 'pointer',
minWidth: '150px',
userSelect: 'none'
}}
>
Last Updated <SortIcon field="updatedAt" />
</th>
</tr>
</thead>
<tbody>
{filteredAndSortedFlags.length === 0 ? (
<tr>
<td colSpan={8} style={{
padding: '2rem',
textAlign: 'center',
color: styles.textMuted
}}>
{search ? 'No flags match your search' : 'No feature flags yet'}
</td>
</tr>
) : (
filteredAndSortedFlags.map(flag => (
<tr key={flag.id} style={{
borderBottom: `1px solid ${styles.border}`,
transition: 'background-color 0.15s',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = styles.surfaceHover}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ''}
>
<td style={{
padding: '0.75rem 1rem',
position: 'sticky',
left: 0,
backgroundColor: 'inherit'
}}>
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: flag.enabled ?
(flag.rolloutPercentage && flag.rolloutPercentage < 100 ? styles.warning : styles.primary)
: styles.error
}} />
</td>
<td style={{
padding: '0.75rem 1rem',
fontWeight: '500',
color: styles.text
}}>
<a
href={`/admin/collections/feature-flags/${flag.id}`}
style={{
color: styles.info,
textDecoration: 'none',
cursor: 'pointer'
}}
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
>
{flag.name}
</a>
</td>
<td style={{
padding: '0.75rem 1rem',
color: styles.textMuted
}}>
{flag.description || '-'}
</td>
<td style={{
padding: '0.75rem 1rem',
textAlign: 'center'
}}>
<input
type="checkbox"
checked={flag.enabled}
onChange={(e) => updateFlag(flag.id, { enabled: e.target.checked })}
disabled={!canUpdate || saving === flag.id}
style={{
width: '18px',
height: '18px',
cursor: (!canUpdate || saving === flag.id) ? 'not-allowed' : 'pointer',
accentColor: styles.primary,
opacity: canUpdate ? 1 : 0.6
}}
/>
</td>
<td style={{
padding: '0.75rem 1rem',
textAlign: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem' }}>
<input
type="number"
value={flag.rolloutPercentage || 100}
onChange={(e) => {
const value = Math.min(100, Math.max(0, parseInt(e.target.value) || 0))
updateFlag(flag.id, { rolloutPercentage: value })
}}
disabled={!canUpdate || saving === flag.id}
min="0"
max="100"
style={{
width: '60px',
padding: '0.25rem 0.5rem',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '0.25rem',
fontSize: '0.875rem',
textAlign: 'center',
cursor: (!canUpdate || saving === flag.id) ? 'not-allowed' : 'text',
opacity: canUpdate ? 1 : 0.5,
backgroundColor: styles.inputBg,
color: styles.text
}}
/>
<span style={{ color: styles.textMuted }}>%</span>
</div>
</td>
<td style={{
padding: '0.75rem 1rem',
textAlign: 'center',
color: styles.textMuted
}}>
{flag.variants && flag.variants.length > 0 ? (
<span style={{
backgroundColor: styles.surface,
padding: '0.25rem 0.5rem',
borderRadius: '0.25rem',
fontSize: '0.75rem'
}}>
{flag.variants.length} variants
</span>
) : '-'}
</td>
<td style={{
padding: '0.75rem 1rem'
}}>
{flag.tags && flag.tags.length > 0 ? (
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
{flag.tags.map((t, i) => (
<span key={i} style={{
backgroundColor: styles.info + '20',
color: styles.info,
padding: '0.125rem 0.5rem',
borderRadius: '0.25rem',
fontSize: '0.75rem'
}}>
{t.tag}
</span>
))}
</div>
) : '-'}
</td>
<td style={{
padding: '0.75rem 1rem',
color: styles.textMuted,
fontSize: '0.75rem'
}}>
{new Date(flag.updatedAt).toLocaleDateString()} {new Date(flag.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Summary Stats */}
<div style={{
marginTop: '2rem',
display: 'flex',
gap: '2rem',
fontSize: '0.875rem',
color: styles.textMuted
}}>
<div>
<span style={{ fontWeight: '600' }}>Total:</span> {flags.filter(f => f && f.name).length} flags
</div>
<div>
<span style={{ fontWeight: '600' }}>Enabled:</span> {flags.filter(f => f && f.enabled).length}
</div>
<div>
<span style={{ fontWeight: '600' }}>Disabled:</span> {flags.filter(f => f && !f.enabled).length}
</div>
<div>
<span style={{ fontWeight: '600' }}>Rolling out:</span> {flags.filter(f => f && f.enabled && f.rolloutPercentage && f.rolloutPercentage < 100).length}
</div>
<div>
<span style={{ fontWeight: '600' }}>A/B Tests:</span> {flags.filter(f => f && f.variants && f.variants.length > 0).length}
</div>
</div>
</>
)}
</div>
)
}
export const FeatureFlagsClient = memo(FeatureFlagsClientComponent)
export default FeatureFlagsClient

View File

@@ -1,11 +1,7 @@
'use client' import type { AdminViewServerProps } from 'payload'
import { useState, useEffect, useCallback, useMemo, memo } from 'react'
import {
useConfig,
useTheme
} from '@payloadcms/ui'
import { DefaultTemplate } from '@payloadcms/next/templates' import { DefaultTemplate } from '@payloadcms/next/templates'
import type { Locale } from 'payload' import { Gutter } from '@payloadcms/ui'
import FeatureFlagsClient from './FeatureFlagsClient.js'
interface FeatureFlag { interface FeatureFlag {
id: string id: string
@@ -25,622 +21,143 @@ interface FeatureFlag {
updatedAt: string updatedAt: string
} }
interface FeatureFlagsViewProps { async function fetchInitialFlags(payload: any): Promise<FeatureFlag[]> {
i18n?: any try {
locale?: Locale const result = await payload.find({
params?: Record<string, any> collection: 'feature-flags',
payload?: any limit: 1000,
permissions?: any sort: 'name',
searchParams?: Record<string, any>
user?: any
visibleEntities?: any
[key: string]: any
}
const FeatureFlagsViewComponent = (props: FeatureFlagsViewProps = {}) => {
const { config } = useConfig()
const { theme } = useTheme()
const [flags, setFlags] = useState<FeatureFlag[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [search, setSearch] = useState('')
const [sortField, setSortField] = useState<'name' | 'enabled' | 'rolloutPercentage' | 'updatedAt'>('name')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
const [saving, setSaving] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState('')
useEffect(() => {
const abortController = new AbortController()
const loadFlags = async () => {
await fetchFlags(abortController.signal)
}
loadFlags()
return () => {
abortController.abort()
}
}, [config.serverURL])
const fetchFlags = async (signal?: AbortSignal) => {
try {
setLoading(true)
setError('')
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags?limit=1000`, {
credentials: 'include',
signal,
})
if (!response.ok) {
throw new Error(`Failed to fetch feature flags: ${response.statusText}`)
}
const result = await response.json()
// Extract docs array from Payload API response and filter out null/invalid entries
const flagsArray = (result.docs || []).filter((flag: any) => flag && flag.id && flag.name)
// Only update state if the component is still mounted (signal not aborted)
if (!signal?.aborted) {
setFlags(flagsArray as FeatureFlag[])
}
} catch (err) {
// Don't show error if request was aborted (component unmounting)
if (err instanceof Error && err.name === 'AbortError') {
return
}
console.error('Error fetching feature flags:', err)
if (!signal?.aborted) {
setError(err instanceof Error ? err.message : 'Failed to fetch feature flags')
}
} finally {
if (!signal?.aborted) {
setLoading(false)
}
}
}
const updateFlag = useCallback(async (flagId: string, updates: Partial<FeatureFlag>) => {
setSaving(flagId)
setError('')
setSuccessMessage('')
try {
const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags/${flagId}`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates),
})
if (!response.ok) {
throw new Error(`Failed to update feature flag: ${response.statusText}`)
}
const updatedFlag = await response.json()
// Update local state
setFlags(prev => prev.map(flag =>
flag.id === flagId ? { ...flag, ...updatedFlag } : flag
))
setSuccessMessage('✓ Saved')
setTimeout(() => setSuccessMessage(''), 2000)
} catch (err) {
console.error('Error updating feature flag:', err)
setError(err instanceof Error ? err.message : 'Failed to update feature flag')
setTimeout(() => setError(''), 5000)
} finally {
setSaving(null)
}
}, [config.serverURL, config.routes.api])
const handleSort = useCallback((field: typeof sortField) => {
if (sortField === field) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDirection('asc')
}
}, [sortField])
const filteredAndSortedFlags = useMemo(() => {
// Filter out null/undefined entries first
let filtered = flags.filter(flag => flag && flag.name)
// Filter by search
if (search) {
filtered = filtered.filter(flag =>
flag.name?.toLowerCase().includes(search.toLowerCase()) ||
flag.description?.toLowerCase().includes(search.toLowerCase()) ||
flag.tags?.some(t => t.tag?.toLowerCase().includes(search.toLowerCase()))
)
}
// Sort
filtered.sort((a, b) => {
let aVal: any = a[sortField]
let bVal: any = b[sortField]
if (sortField === 'updatedAt') {
aVal = new Date(aVal || 0).getTime()
bVal = new Date(bVal || 0).getTime()
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1
return 0
}) })
return filtered return (result.docs || []).filter((flag: any) => flag && flag.id && flag.name)
}, [flags, search, sortField, sortDirection]) } catch (error) {
console.error('Error fetching initial feature flags:', error)
return []
}
}
const SortIcon = ({ field }: { field: typeof sortField }) => { export default async function FeatureFlagsView({
if (sortField !== field) { initPageResult,
return <span style={{ opacity: 0.3 }}></span> params,
} searchParams,
return <span>{sortDirection === 'asc' ? '↑' : '↓'}</span> }: AdminViewServerProps) {
const {
req: { user },
permissions,
} = initPageResult
// Security check: User must be logged in
if (!user) {
return (
<DefaultTemplate
i18n={initPageResult.req.i18n}
locale={initPageResult.locale}
params={params}
payload={initPageResult.req.payload}
permissions={initPageResult.permissions}
searchParams={searchParams}
user={undefined}
visibleEntities={initPageResult.visibleEntities}
>
<Gutter>
<div style={{
padding: '2rem',
textAlign: 'center',
color: 'var(--theme-error-500)',
backgroundColor: 'var(--theme-error-50)',
border: '1px solid var(--theme-error-200)',
borderRadius: '0.5rem',
margin: '2rem 0'
}}>
<h2 style={{ marginBottom: '1rem', color: 'var(--theme-error-600)' }}>
Authentication Required
</h2>
<p style={{ marginBottom: '1rem' }}>
You must be logged in to view the Feature Flags Dashboard.
</p>
<a
href="/admin/login"
style={{
display: 'inline-block',
padding: '0.75rem 1.5rem',
backgroundColor: 'var(--theme-error-500)',
color: 'white',
textDecoration: 'none',
borderRadius: '0.375rem',
fontWeight: '500'
}}
>
Go to Login
</a>
</div>
</Gutter>
</DefaultTemplate>
)
} }
// Theme-aware styles // Security check: User must have permissions to access feature-flags collection
const getThemeStyles = () => ({ const canReadFeatureFlags = permissions?.collections?.['feature-flags']?.read
background: 'var(--theme-bg)', if (!canReadFeatureFlags) {
surface: 'var(--theme-elevation-50)', return (
surfaceHover: 'var(--theme-elevation-100)', <DefaultTemplate
border: 'var(--theme-elevation-150)', i18n={initPageResult.req.i18n}
text: 'var(--theme-text)', locale={initPageResult.locale}
textMuted: 'var(--theme-text-400)', params={params}
textSubdued: 'var(--theme-text-600)', payload={initPageResult.req.payload}
primary: 'var(--theme-success-500)', permissions={initPageResult.permissions}
warning: 'var(--theme-warning-500)', searchParams={searchParams}
error: 'var(--theme-error-500)', user={initPageResult.req.user || undefined}
info: 'var(--theme-info-500)', visibleEntities={initPageResult.visibleEntities}
inputBg: 'var(--theme-elevation-0)', >
inputBorder: 'var(--theme-elevation-250)', <Gutter>
headerBg: 'var(--theme-elevation-100)', <div style={{
}) padding: '2rem',
textAlign: 'center',
const styles = getThemeStyles() color: 'var(--theme-warning-600)',
backgroundColor: 'var(--theme-warning-50)',
const FeatureFlagsContent = () => ( border: '1px solid var(--theme-warning-200)',
<div style={{
padding: '2rem',
maxWidth: '100%'
}}>
{/* Header */}
<div style={{ marginBottom: '2rem' }}>
<h1 style={{
fontSize: '2rem',
fontWeight: '700',
color: styles.text,
margin: '0 0 0.5rem 0'
}}>
Feature Flags Dashboard
</h1>
<p style={{
color: styles.textMuted,
fontSize: '1rem',
margin: '0 0 2rem 0'
}}>
Manage all feature flags in a spreadsheet view with inline editing capabilities
</p>
</div>
{loading ? (
<div style={{
padding: '2rem',
textAlign: 'center',
minHeight: '400px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{ fontSize: '1.125rem', color: styles.textMuted }}>Loading feature flags...</div>
</div>
) : (
<>
{/* Success/Error Messages */}
{successMessage && (
<div style={{
position: 'fixed',
top: '20px',
right: '20px',
backgroundColor: styles.primary,
color: 'white',
padding: '0.75rem 1.5rem',
borderRadius: '0.5rem',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
zIndex: 1000,
}}>
{successMessage}
</div>
)}
{error && (
<div style={{
marginBottom: '1rem',
backgroundColor: styles.error + '20',
border: `1px solid ${styles.error}`,
borderRadius: '0.5rem',
padding: '1rem',
color: styles.error
}}>
<strong>Error:</strong> {error}
</div>
)}
{/* Controls */}
<div style={{
display: 'flex',
gap: '1rem',
marginBottom: '2rem',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<input
type="text"
placeholder="Search flags by name, description, or tags..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
padding: '0.5rem 1rem',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '0.5rem', borderRadius: '0.5rem',
fontSize: '0.875rem', margin: '2rem 0'
width: '300px',
backgroundColor: styles.inputBg,
color: styles.text
}}
/>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<div style={{ fontSize: '0.875rem', color: styles.textMuted }}>
{filteredAndSortedFlags.length} of {flags.filter(f => f && f.name).length} flags
</div>
<button
onClick={() => fetchFlags()}
style={{
padding: '0.5rem 1rem',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '0.5rem',
backgroundColor: styles.surface,
color: styles.text,
fontSize: '0.875rem',
cursor: 'pointer'
}}
>
🔄 Refresh
</button>
</div>
</div>
{/* Spreadsheet Table */}
<div style={{
backgroundColor: styles.surface,
border: `1px solid ${styles.border}`,
borderRadius: '0.75rem',
overflow: 'hidden',
marginBottom: '2rem'
}}>
<div style={{ overflowX: 'auto' }}>
<table style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.875rem'
}}> }}>
<thead> <h2 style={{ marginBottom: '1rem', color: 'var(--theme-warning-700)' }}>
<tr style={{ backgroundColor: styles.headerBg }}> Access Denied
<th style={{ </h2>
padding: '0.75rem 1rem', <p style={{ marginBottom: '1rem' }}>
textAlign: 'left', You don't have permission to access the Feature Flags Dashboard.
fontWeight: '600', </p>
color: styles.text, <p style={{ fontSize: '0.875rem', color: 'var(--theme-warning-600)' }}>
borderBottom: `1px solid ${styles.border}`, Contact your administrator to request access to the feature-flags collection.
position: 'sticky', </p>
left: 0, </div>
backgroundColor: styles.headerBg, </Gutter>
minWidth: '50px' </DefaultTemplate>
}}> )
Status }
</th>
<th
onClick={() => handleSort('name')}
style={{
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
cursor: 'pointer',
minWidth: '200px',
userSelect: 'none'
}}
>
Name <SortIcon field="name" />
</th>
<th style={{
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
minWidth: '300px'
}}>
Description
</th>
<th
onClick={() => handleSort('enabled')}
style={{
padding: '0.75rem 1rem',
textAlign: 'center',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
cursor: 'pointer',
minWidth: '80px',
userSelect: 'none'
}}
>
Enabled <SortIcon field="enabled" />
</th>
<th
onClick={() => handleSort('rolloutPercentage')}
style={{
padding: '0.75rem 1rem',
textAlign: 'center',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
cursor: 'pointer',
minWidth: '120px',
userSelect: 'none'
}}
>
Rollout % <SortIcon field="rolloutPercentage" />
</th>
<th style={{
padding: '0.75rem 1rem',
textAlign: 'center',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
minWidth: '100px'
}}>
Variants
</th>
<th style={{
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
minWidth: '150px'
}}>
Tags
</th>
<th
onClick={() => handleSort('updatedAt')}
style={{
padding: '0.75rem 1rem',
textAlign: 'left',
fontWeight: '600',
color: styles.text,
borderBottom: `1px solid ${styles.border}`,
cursor: 'pointer',
minWidth: '150px',
userSelect: 'none'
}}
>
Last Updated <SortIcon field="updatedAt" />
</th>
</tr>
</thead>
<tbody>
{filteredAndSortedFlags.length === 0 ? (
<tr>
<td colSpan={8} style={{
padding: '2rem',
textAlign: 'center',
color: styles.textMuted
}}>
{search ? 'No flags match your search' : 'No feature flags yet'}
</td>
</tr>
) : (
filteredAndSortedFlags.map(flag => (
<tr key={flag.id} style={{
borderBottom: `1px solid ${styles.border}`,
transition: 'background-color 0.15s',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = styles.surfaceHover}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = ''}
>
<td style={{
padding: '0.75rem 1rem',
position: 'sticky',
left: 0,
backgroundColor: 'inherit'
}}>
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: flag.enabled ?
(flag.rolloutPercentage && flag.rolloutPercentage < 100 ? styles.warning : styles.primary)
: styles.error
}} />
</td>
<td style={{
padding: '0.75rem 1rem',
fontWeight: '500',
color: styles.text
}}>
<a
href={`/admin/collections/feature-flags/${flag.id}`}
style={{
color: styles.info,
textDecoration: 'none',
cursor: 'pointer'
}}
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
>
{flag.name}
</a>
</td>
<td style={{
padding: '0.75rem 1rem',
color: styles.textMuted
}}>
{flag.description || '-'}
</td>
<td style={{
padding: '0.75rem 1rem',
textAlign: 'center'
}}>
<input
type="checkbox"
checked={flag.enabled}
onChange={(e) => updateFlag(flag.id, { enabled: e.target.checked })}
disabled={saving === flag.id}
style={{
width: '18px',
height: '18px',
cursor: saving === flag.id ? 'wait' : 'pointer',
accentColor: styles.primary
}}
/>
</td>
<td style={{
padding: '0.75rem 1rem',
textAlign: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem' }}>
<input
type="number"
value={flag.rolloutPercentage || 100}
onChange={(e) => {
const value = Math.min(100, Math.max(0, parseInt(e.target.value) || 0))
updateFlag(flag.id, { rolloutPercentage: value })
}}
disabled={!flag.enabled || saving === flag.id}
min="0"
max="100"
style={{
width: '60px',
padding: '0.25rem 0.5rem',
border: `1px solid ${styles.inputBorder}`,
borderRadius: '0.25rem',
fontSize: '0.875rem',
textAlign: 'center',
cursor: saving === flag.id ? 'wait' : 'text',
opacity: flag.enabled ? 1 : 0.5,
backgroundColor: styles.inputBg,
color: styles.text
}}
/>
<span style={{ color: styles.textMuted }}>%</span>
</div>
</td>
<td style={{
padding: '0.75rem 1rem',
textAlign: 'center',
color: styles.textMuted
}}>
{flag.variants && flag.variants.length > 0 ? (
<span style={{
backgroundColor: styles.surface,
padding: '0.25rem 0.5rem',
borderRadius: '0.25rem',
fontSize: '0.75rem'
}}>
{flag.variants.length} variants
</span>
) : '-'}
</td>
<td style={{
padding: '0.75rem 1rem'
}}>
{flag.tags && flag.tags.length > 0 ? (
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
{flag.tags.map((t, i) => (
<span key={i} style={{
backgroundColor: styles.info + '20',
color: styles.info,
padding: '0.125rem 0.5rem',
borderRadius: '0.25rem',
fontSize: '0.75rem'
}}>
{t.tag}
</span>
))}
</div>
) : '-'}
</td>
<td style={{
padding: '0.75rem 1rem',
color: styles.textMuted,
fontSize: '0.75rem'
}}>
{new Date(flag.updatedAt).toLocaleDateString()} {new Date(flag.updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Summary Stats */} // Fetch initial data server-side (only if user has access)
<div style={{ const initialFlags = await fetchInitialFlags(initPageResult.req.payload)
marginTop: '2rem',
display: 'flex',
gap: '2rem',
fontSize: '0.875rem',
color: styles.textMuted
}}>
<div>
<span style={{ fontWeight: '600' }}>Total:</span> {flags.filter(f => f && f.name).length} flags
</div>
<div>
<span style={{ fontWeight: '600' }}>Enabled:</span> {flags.filter(f => f && f.enabled).length}
</div>
<div>
<span style={{ fontWeight: '600' }}>Disabled:</span> {flags.filter(f => f && !f.enabled).length}
</div>
<div>
<span style={{ fontWeight: '600' }}>Rolling out:</span> {flags.filter(f => f && f.enabled && f.rolloutPercentage && f.rolloutPercentage < 100).length}
</div>
<div>
<span style={{ fontWeight: '600' }}>A/B Tests:</span> {flags.filter(f => f && f.variants && f.variants.length > 0).length}
</div>
</div>
</>
)}
</div>
)
// Use DefaultTemplate with proper props structure // Check if user can update feature flags
const canUpdateFeatureFlags = permissions?.collections?.['feature-flags']?.update || false
// Use DefaultTemplate with proper props structure from initPageResult
return ( return (
<DefaultTemplate <DefaultTemplate
i18n={props.i18n} i18n={initPageResult.req.i18n}
locale={props.locale} locale={initPageResult.locale}
params={props.params} params={params}
payload={props.payload} payload={initPageResult.req.payload}
permissions={props.permissions} permissions={initPageResult.permissions}
searchParams={props.searchParams} searchParams={searchParams}
user={props.user} user={initPageResult.req.user || undefined}
visibleEntities={props.visibleEntities} visibleEntities={initPageResult.visibleEntities}
> >
<FeatureFlagsContent /> <Gutter>
<FeatureFlagsClient
initialFlags={initialFlags}
canUpdate={canUpdateFeatureFlags}
/>
</Gutter>
</DefaultTemplate> </DefaultTemplate>
) )
} }
export const FeatureFlagsView = memo(FeatureFlagsViewComponent)
export default FeatureFlagsView