From e1659c07ea0042baca92297991aa4657251f0e5a Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Sun, 23 Nov 2025 15:46:51 +0000 Subject: [PATCH] Arch changes and feature flags --- PROJECT_STRUCTURE.md | 2 +- QUICKSTART.md | 6 +- README.md | 8 +- SQUARE_CHECKLIST.md | 2 +- backend/alembic.ini | 4 +- backend/alembic/env.py | 22 ++++- backend/app/api/v1/__init__.py | 3 +- backend/app/api/v1/feature_flags.py | 47 ++++++++++ backend/app/schemas/feature_flags.py | 15 +++ backend/app/services/feature_flag_service.py | 80 ++++++++++++++++ docker-compose.dev.yml | 21 ----- docker-compose.prod.yml | 14 --- docker-compose.yml | 76 +++++++-------- frontend/src/App.css | 9 ++ frontend/src/App.tsx | 29 +++--- frontend/src/components/FeatureFlagStatus.tsx | 80 ++++++++++++++++ frontend/src/components/MembershipSetup.tsx | 43 +++++---- frontend/src/components/ProfileMenu.tsx | 20 +++- frontend/src/contexts/FeatureFlagContext.tsx | 94 +++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 87 +++++++++++++++++ frontend/src/services/featureFlagService.ts | 29 ++++++ frontend/src/services/membershipService.ts | 5 + 22 files changed, 577 insertions(+), 119 deletions(-) create mode 100644 backend/app/api/v1/feature_flags.py create mode 100644 backend/app/schemas/feature_flags.py create mode 100644 backend/app/services/feature_flag_service.py delete mode 100644 docker-compose.dev.yml delete mode 100644 docker-compose.prod.yml create mode 100644 frontend/src/components/FeatureFlagStatus.tsx create mode 100644 frontend/src/contexts/FeatureFlagContext.tsx create mode 100644 frontend/src/services/featureFlagService.ts diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index e478788..8523436 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -104,7 +104,7 @@ docker-compose logs -f **Admin**: admin@swanseaairport.org / admin123 -**Database**: membership_user / SecureMembershipPass2024! +**Database**: Configured via environment variables (see .env file) ## What's Next diff --git a/QUICKSTART.md b/QUICKSTART.md index 96eeb61..f2fdf36 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -78,11 +78,11 @@ docker-compose up -d --build # Check status docker-compose ps -# Access MySQL CLI -docker exec -it membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db +# Access MySQL CLI (using environment variables) +docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" # Create database backup -docker exec membership_mysql mysqldump -u membership_user -pSecureMembershipPass2024! membership_db > backup_$(date +%Y%m%d_%H%M%S).sql +docker exec membership_mysql mysqldump -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" > backup_$(date +%Y%m%d_%H%M%S).sql ``` ## Default Admin Access diff --git a/README.md b/README.md index b2a14bf..9089123 100644 --- a/README.md +++ b/README.md @@ -210,14 +210,14 @@ docker-compose ps ### Database Operations ```bash -# Access MySQL CLI -docker exec -it membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db +# Access MySQL CLI (using environment variables) +docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" # Create backup -docker exec membership_mysql mysqldump -u membership_user -pSecureMembershipPass2024! membership_db > backup_$(date +%Y%m%d_%H%M%S).sql +docker exec membership_mysql mysqldump -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" > backup_$(date +%Y%m%d_%H%M%S).sql # Restore database -docker exec -i membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db < backup.sql +docker exec -i membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" < backup.sql ``` ### Database Migrations diff --git a/SQUARE_CHECKLIST.md b/SQUARE_CHECKLIST.md index 07ac3f4..24cd111 100644 --- a/SQUARE_CHECKLIST.md +++ b/SQUARE_CHECKLIST.md @@ -141,7 +141,7 @@ docker-compose exec backend pip list | grep square docker-compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)" # Check database has payments -docker-compose exec mysql mysql -u membership_user -p -e "SELECT * FROM membership_db.payments LIMIT 5;" +docker-compose exec mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;" # Check frontend files ls -la frontend/src/components/SquarePayment.tsx diff --git a/backend/alembic.ini b/backend/alembic.ini index 05d2626..eaeaab3 100644 --- a/backend/alembic.ini +++ b/backend/alembic.ini @@ -60,7 +60,9 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne # are written from script.py.mako # output_encoding = utf-8 -sqlalchemy.url = mysql+pymysql://membership_user:SecureMembershipPass2024!@mysql:3306/membership_db +# Database URL - will be overridden by environment variables in production +# sqlalchemy.url = mysql+pymysql://username:password@host:port/database +sqlalchemy.url = driver://user:pass@localhost/dbname [post_write_hooks] diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 7caf0a1..9258b2c 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -1,4 +1,5 @@ from logging.config import fileConfig +import os from sqlalchemy import engine_from_config from sqlalchemy import pool @@ -19,6 +20,25 @@ if config.config_file_name is not None: from app.models.models import Base target_metadata = Base.metadata +# Set database URL from environment variables if available +def get_database_url(): + """Get database URL from environment variables or config""" + # Try to get from environment variables first + db_host = os.getenv("DATABASE_HOST") + db_port = os.getenv("DATABASE_PORT", "3306") + db_user = os.getenv("DATABASE_USER") + db_password = os.getenv("DATABASE_PASSWORD") + db_name = os.getenv("DATABASE_NAME") + + if all([db_host, db_user, db_password, db_name]): + return f"mysql+pymysql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}" + + # Fallback to config file + return config.get_main_option("sqlalchemy.url") + +# Set the database URL +config.set_main_option("sqlalchemy.url", get_database_url()) + # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") @@ -37,7 +57,7 @@ def run_migrations_offline() -> None: script output. """ - url = config.get_main_option("sqlalchemy.url") + url = get_database_url() context.configure( url=url, target_metadata=target_metadata, diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index b28aa14..61e573b 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from . import auth, users, tiers, memberships, payments, email, email_templates, events +from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags api_router = APIRouter() @@ -11,3 +11,4 @@ api_router.include_router(payments.router, prefix="/payments", tags=["payments"] api_router.include_router(email.router, prefix="/email", tags=["email"]) api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"]) api_router.include_router(events.router, prefix="/events", tags=["events"]) +api_router.include_router(feature_flags.router, prefix="/feature-flags", tags=["feature-flags"]) diff --git a/backend/app/api/v1/feature_flags.py b/backend/app/api/v1/feature_flags.py new file mode 100644 index 0000000..696481e --- /dev/null +++ b/backend/app/api/v1/feature_flags.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, Depends +from typing import Dict, Any +from app.services.feature_flag_service import feature_flags +from app.schemas.feature_flags import FeatureFlagsResponse, FeatureFlagResponse + +router = APIRouter() + + +@router.get("/flags", response_model=FeatureFlagsResponse) +async def get_all_feature_flags() -> FeatureFlagsResponse: + """ + Get all feature flags for the frontend + This endpoint is public as it only returns feature configuration + """ + all_flags = feature_flags.get_all_flags() + enabled_flags = feature_flags.get_enabled_flags() + + return FeatureFlagsResponse( + flags=all_flags, + enabled_flags=enabled_flags + ) + + +@router.get("/flags/{flag_name}", response_model=FeatureFlagResponse) +async def get_feature_flag(flag_name: str) -> FeatureFlagResponse: + """ + Get a specific feature flag value + """ + flag_name_upper = flag_name.upper() + enabled = feature_flags.is_enabled(flag_name_upper) + value = feature_flags.get_flag_value(flag_name_upper) + + return FeatureFlagResponse( + name=flag_name_upper, + enabled=enabled, + value=value + ) + + +@router.post("/flags/reload") +async def reload_feature_flags(): + """ + Reload feature flags from environment variables + This could be protected with admin permissions in production + """ + feature_flags.reload_flags() + return {"message": "Feature flags reloaded successfully"} \ No newline at end of file diff --git a/backend/app/schemas/feature_flags.py b/backend/app/schemas/feature_flags.py new file mode 100644 index 0000000..490e1e0 --- /dev/null +++ b/backend/app/schemas/feature_flags.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel +from typing import Dict, Any, List + + +class FeatureFlagsResponse(BaseModel): + """Response model for feature flags""" + flags: Dict[str, Any] + enabled_flags: List[str] + + +class FeatureFlagResponse(BaseModel): + """Response model for a single feature flag""" + name: str + enabled: bool + value: Any \ No newline at end of file diff --git a/backend/app/services/feature_flag_service.py b/backend/app/services/feature_flag_service.py new file mode 100644 index 0000000..bcc70fa --- /dev/null +++ b/backend/app/services/feature_flag_service.py @@ -0,0 +1,80 @@ +""" +Feature Flag Service for managing application features +""" +import os +from typing import Dict, List, Any +from app.core.config import settings + + +class FeatureFlagService: + """Service for managing feature flags""" + + def __init__(self): + """Initialize feature flags from environment variables""" + self._flags = self._load_flags_from_env() + + def _load_flags_from_env(self) -> Dict[str, Any]: + """Load feature flags from environment variables""" + # Get the FEATURE_FLAGS environment variable (comma-separated list) + feature_flags_env = os.getenv("FEATURE_FLAGS", "") + + # Default feature flags - these can be overridden by environment + default_flags = { + "CASH_PAYMENT_ENABLED": True, + "EMAIL_NOTIFICATIONS_ENABLED": True, + "EVENT_MANAGEMENT_ENABLED": True, + "AUTO_RENEWAL_ENABLED": False, + "MEMBERSHIP_TRANSFERS_ENABLED": False, + "BULK_OPERATIONS_ENABLED": False, + "ADVANCED_REPORTING_ENABLED": False, + "API_RATE_LIMITING_ENABLED": True, + } + + # Parse environment variable + flags = default_flags.copy() + + if feature_flags_env: + # Parse comma-separated key=value pairs + for flag_pair in feature_flags_env.split(","): + flag_pair = flag_pair.strip() + if "=" in flag_pair: + key, value = flag_pair.split("=", 1) + key = key.strip().upper() + value = value.strip().lower() + + # Convert string to boolean + if value in ("true", "1", "yes", "on"): + flags[key] = True + elif value in ("false", "0", "no", "off"): + flags[key] = False + else: + # For non-boolean values, keep as string + flags[key] = value + + return flags + + def is_enabled(self, flag_name: str) -> bool: + """Check if a feature flag is enabled""" + flag_name = flag_name.upper() + return bool(self._flags.get(flag_name, False)) + + def get_flag_value(self, flag_name: str, default: Any = None) -> Any: + """Get the value of a feature flag""" + flag_name = flag_name.upper() + return self._flags.get(flag_name, default) + + def get_all_flags(self) -> Dict[str, Any]: + """Get all feature flags""" + return self._flags.copy() + + def get_enabled_flags(self) -> List[str]: + """Get list of enabled feature flag names""" + return [name for name, value in self._flags.items() if value is True] + + def reload_flags(self) -> None: + """Reload feature flags from environment (useful for runtime updates)""" + self._flags = self._load_flags_from_env() + + +# Global instance +feature_flags = FeatureFlagService() \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index c6aa723..0000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,21 +0,0 @@ -services: - frontend: - build: - context: ./frontend - dockerfile: Dockerfile - target: development - container_name: membership_frontend - restart: unless-stopped - environment: - - VITE_HOST_CHECK=false - - VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS} - ports: - - "8050:3000" # Expose frontend to host - volumes: - - ./frontend/src:/app/src - - ./frontend/public:/app/public - - ./frontend/vite.config.ts:/app/vite.config.ts - depends_on: - - backend - networks: - - membership_private diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index dba26e8..0000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,14 +0,0 @@ -services: - frontend-prod: - build: - context: ./frontend - dockerfile: Dockerfile - target: production - container_name: membership_frontend_prod - restart: unless-stopped - ports: - - "8050:80" # Nginx default port - depends_on: - - backend - networks: - - membership_private diff --git a/docker-compose.yml b/docker-compose.yml index 4bd856e..c8e440e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,35 +1,47 @@ services: - mysql: - image: mysql:8.0 - container_name: membership_mysql - restart: unless-stopped - environment: - MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD:-rootpassword} - MYSQL_DATABASE: ${DATABASE_NAME:-membership_db} - MYSQL_USER: ${DATABASE_USER:-membership_user} - MYSQL_PASSWORD: ${DATABASE_PASSWORD:-change_this_password} - # No external port exposure - database only accessible on private network - expose: - - "3306" - volumes: - - mysql_data:/var/lib/mysql - networks: - - membership_private - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - start_period: 10s - interval: 5s - timeout: 5s - retries: 10 + # mysql: + # image: mysql:8.0 + # container_name: membership_mysql + # restart: unless-stopped + # environment: + # MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD:-secure_root_password_change_this} + # MYSQL_DATABASE: ${DATABASE_NAME:-membership_db} + # MYSQL_USER: ${DATABASE_USER:-membership_user} + # MYSQL_PASSWORD: ${DATABASE_PASSWORD:-secure_password_change_this} + # # No external port exposure - database only accessible on private network + # expose: + # - "3306" + # volumes: + # - mysql_data:/var/lib/mysql + # networks: + # - membership_private + # healthcheck: + # test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + # start_period: 10s + # interval: 5s + # timeout: 5s + # retries: 10 backend: build: context: ./backend dockerfile: Dockerfile - container_name: membership_backend restart: unless-stopped env_file: - .env + environment: + # Database configuration + - DATABASE_HOST=${DATABASE_HOST} + - DATABASE_PORT=${DATABASE_PORT} + - DATABASE_USER=${DATABASE_USER} + - DATABASE_PASSWORD=${DATABASE_PASSWORD} + - DATABASE_NAME=${DATABASE_NAME} + # Application configuration + - SECRET_KEY=${SECRET_KEY} + - ALGORITHM=${ALGORITHM} + - ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES} + extra_hosts: + - "host.docker.internal:host-gateway" ports: - "6000:8000" # Only expose backend API to host volumes: @@ -39,18 +51,15 @@ services: - uploads_data:/app/uploads command: > sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload" - depends_on: - mysql: - condition: service_healthy - networks: - - membership_private # Access to database on private network + # depends_on: + # mysql: + # condition: service_healthy frontend: build: context: ./frontend dockerfile: Dockerfile target: development - container_name: membership_frontend restart: unless-stopped environment: - VITE_HOST_CHECK=false @@ -63,8 +72,6 @@ services: - ./frontend/vite.config.ts:/app/vite.config.ts depends_on: - backend - networks: - - membership_private #frontend-prod: # build: @@ -80,12 +87,7 @@ services: # networks: # - membership_private -networks: - membership_private: - driver: bridge - internal: false # Allow outbound internet access for backend - # Database is not exposed to host - only accessible within this network volumes: - mysql_data: + # mysql_data: uploads_data: diff --git a/frontend/src/App.css b/frontend/src/App.css index 38b6a5a..2454953 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -63,6 +63,15 @@ body { background-color: #5a6268; } +.btn-danger { + background-color: #dc3545; + color: white; +} + +.btn-danger:hover { + background-color: #c82333; +} + .form-group { margin-bottom: 16px; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9969b8c..6b7eb4b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { FeatureFlagProvider } from './contexts/FeatureFlagContext'; import Register from './pages/Register'; import Login from './pages/Login'; import ForgotPassword from './pages/ForgotPassword'; @@ -12,19 +13,21 @@ import './App.css'; const App: React.FC = () => { return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); }; diff --git a/frontend/src/components/FeatureFlagStatus.tsx b/frontend/src/components/FeatureFlagStatus.tsx new file mode 100644 index 0000000..0f01275 --- /dev/null +++ b/frontend/src/components/FeatureFlagStatus.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { useFeatureFlags } from '../contexts/FeatureFlagContext'; + +const FeatureFlagStatus: React.FC = () => { + const { flags, loading, error, reloadFlags } = useFeatureFlags(); + + if (loading) { + return
Loading feature flags...
; + } + + if (error) { + return
Error loading feature flags
; + } + + if (!flags) { + return null; + } + + const handleReload = async () => { + try { + await reloadFlags(); + console.log('Feature flags reloaded'); + } catch (error) { + console.error('Failed to reload feature flags:', error); + } + }; + + return ( +
+

Feature Flags Status

+ +
+ {Object.entries(flags.flags).map(([name, value]) => ( +
+ + {name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())} + + + {String(value)} + +
+ ))} +
+ + + +

+ Feature flags are loaded from environment variables. Changes require updating the .env file and reloading. +

+
+ ); +}; + +export default FeatureFlagStatus; \ No newline at end of file diff --git a/frontend/src/components/MembershipSetup.tsx b/frontend/src/components/MembershipSetup.tsx index fd1328d..8ff884a 100644 --- a/frontend/src/components/MembershipSetup.tsx +++ b/frontend/src/components/MembershipSetup.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService'; +import { useFeatureFlags } from '../contexts/FeatureFlagContext'; import SquarePayment from './SquarePayment'; interface MembershipSetupProps { @@ -15,6 +16,8 @@ const MembershipSetup: React.FC = ({ onMembershipCreated, const [paymentMethod, setPaymentMethod] = useState<'square' | 'cash' | null>(null); const [error, setError] = useState(''); const [createdMembershipId, setCreatedMembershipId] = useState(null); + + const { isEnabled } = useFeatureFlags(); useEffect(() => { loadTiers(); @@ -202,26 +205,28 @@ const MembershipSetup: React.FC = ({ onMembershipCreated, - + + + )}
diff --git a/frontend/src/components/ProfileMenu.tsx b/frontend/src/components/ProfileMenu.tsx index 708e64b..12dd966 100644 --- a/frontend/src/components/ProfileMenu.tsx +++ b/frontend/src/components/ProfileMenu.tsx @@ -285,19 +285,33 @@ const ChangePasswordModal: React.FC = ({ onClose }) =>
)} -
+
diff --git a/frontend/src/contexts/FeatureFlagContext.tsx b/frontend/src/contexts/FeatureFlagContext.tsx new file mode 100644 index 0000000..fe7b0d2 --- /dev/null +++ b/frontend/src/contexts/FeatureFlagContext.tsx @@ -0,0 +1,94 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import { featureFlagService, FeatureFlags } from '../services/featureFlagService'; + +interface FeatureFlagContextType { + flags: FeatureFlags | null; + loading: boolean; + error: string | null; + isEnabled: (flagName: string) => boolean; + getFlagValue: (flagName: string, defaultValue?: any) => any; + reloadFlags: () => Promise; +} + +const FeatureFlagContext = createContext(undefined); + +export const useFeatureFlags = (): FeatureFlagContextType => { + const context = useContext(FeatureFlagContext); + if (context === undefined) { + throw new Error('useFeatureFlags must be used within a FeatureFlagProvider'); + } + return context; +}; + +interface FeatureFlagProviderProps { + children: React.ReactNode; +} + +export const FeatureFlagProvider: React.FC = ({ children }) => { + const [flags, setFlags] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadFlags = async () => { + try { + setLoading(true); + setError(null); + const flagsData = await featureFlagService.getAllFlags(); + setFlags(flagsData); + } catch (err: any) { + console.error('Failed to load feature flags:', err); + setError('Failed to load feature flags'); + // Set default flags on error + setFlags({ + flags: { + CASH_PAYMENT_ENABLED: true, + EMAIL_NOTIFICATIONS_ENABLED: true, + EVENT_MANAGEMENT_ENABLED: true, + AUTO_RENEWAL_ENABLED: false, + MEMBERSHIP_TRANSFERS_ENABLED: false, + BULK_OPERATIONS_ENABLED: false, + ADVANCED_REPORTING_ENABLED: false, + API_RATE_LIMITING_ENABLED: true, + }, + enabled_flags: ['CASH_PAYMENT_ENABLED', 'EMAIL_NOTIFICATIONS_ENABLED', 'EVENT_MANAGEMENT_ENABLED', 'API_RATE_LIMITING_ENABLED'] + }); + } finally { + setLoading(false); + } + }; + + const reloadFlags = async () => { + await loadFlags(); + }; + + const isEnabled = (flagName: string): boolean => { + if (!flags) return false; + const upperFlagName = flagName.toUpperCase(); + return Boolean(flags.flags[upperFlagName]); + }; + + const getFlagValue = (flagName: string, defaultValue: any = null): any => { + if (!flags) return defaultValue; + const upperFlagName = flagName.toUpperCase(); + return flags.flags[upperFlagName] ?? defaultValue; + }; + + useEffect(() => { + loadFlags(); + }, []); + + const contextValue: FeatureFlagContextType = { + flags, + loading, + error, + isEnabled, + getFlagValue, + reloadFlags, + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f50624c..fac59b5 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -4,6 +4,7 @@ import { authService, userService, membershipService, paymentService, eventServi import MembershipSetup from '../components/MembershipSetup'; import ProfileMenu from '../components/ProfileMenu'; import ProfileEdit from '../components/ProfileEdit'; +import FeatureFlagStatus from '../components/FeatureFlagStatus'; const Dashboard: React.FC = () => { const navigate = useNavigate(); @@ -38,6 +39,8 @@ const Dashboard: React.FC = () => { const [showRSVPModal, setShowRSVPModal] = useState(false); const [selectedEventForRSVP, setSelectedEventForRSVP] = useState(null); const [eventRSVPList, setEventRSVPList] = useState([]); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [userToDelete, setUserToDelete] = useState(null); useEffect(() => { if (!authService.isAuthenticated()) { @@ -183,6 +186,26 @@ const Dashboard: React.FC = () => { } }; + const handleDeleteUser = async () => { + if (!userToDelete) return; + + try { + await userService.deleteUser(userToDelete.id); + await loadData(); // Reload data to reflect changes + setShowDeleteConfirm(false); + setUserToDelete(null); + alert('User deleted successfully'); + } catch (error: any) { + console.error('Failed to delete user:', error); + alert(`Failed to delete user: ${error.response?.data?.detail || error.message}`); + } + }; + + const confirmDeleteUser = (u: User) => { + setUserToDelete(u); + setShowDeleteConfirm(true); + }; + const getStatusClass = (status: string) => { switch (status.toLowerCase()) { case 'active': @@ -694,6 +717,11 @@ const Dashboard: React.FC = () => {
)} + {/* Feature Flag Management - Super Admin Only */} + {user?.role === 'super_admin' && ( + + )} + {/* User Management Section */} {(user?.role === 'admin' || user?.role === 'super_admin') && (
@@ -788,6 +816,18 @@ const Dashboard: React.FC = () => { {u.role === 'super_admin' && ( Super Admin )} + {user?.role === 'super_admin' && u.id !== user?.id && ( + + )} ); @@ -1363,6 +1403,53 @@ const Dashboard: React.FC = () => {
)} + + {/* Delete User Confirmation Modal */} + {showDeleteConfirm && userToDelete && ( +
+
+

Delete User

+

Are you sure you want to delete the user {userToDelete.first_name} {userToDelete.last_name} ({userToDelete.email})?

+

+ ⚠️ This action cannot be undone. All associated memberships and payments will also be deleted. +

+ +
+ + +
+
+
+ )} ); }; diff --git a/frontend/src/services/featureFlagService.ts b/frontend/src/services/featureFlagService.ts new file mode 100644 index 0000000..0f276f4 --- /dev/null +++ b/frontend/src/services/featureFlagService.ts @@ -0,0 +1,29 @@ +import api from './api'; + +export interface FeatureFlag { + name: string; + enabled: boolean; + value: any; +} + +export interface FeatureFlags { + flags: { [key: string]: any }; + enabled_flags: string[]; +} + +export const featureFlagService = { + async getAllFlags(): Promise { + const response = await api.get('/feature-flags/flags'); + return response.data; + }, + + async getFlag(flagName: string): Promise { + const response = await api.get(`/feature-flags/flags/${flagName}`); + return response.data; + }, + + async reloadFlags(): Promise<{ message: string }> { + const response = await api.post('/feature-flags/flags/reload'); + return response.data; + } +}; \ No newline at end of file diff --git a/frontend/src/services/membershipService.ts b/frontend/src/services/membershipService.ts index 640bdc6..ed3ccd0 100644 --- a/frontend/src/services/membershipService.ts +++ b/frontend/src/services/membershipService.ts @@ -225,6 +225,11 @@ export const userService = { const response = await api.put(`/users/${userId}`, data); return response.data; }, + + async deleteUser(userId: number): Promise<{ message: string }> { + const response = await api.delete(`/users/${userId}`); + return response.data; + }, }; export const membershipService = {