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 (
-
+ Feature flags are loaded from environment variables. Changes require updating the .env file and reloading. +
+