diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py
index 84607eb..0368285 100644
--- a/backend/app/api/v1/auth.py
+++ b/backend/app/api/v1/auth.py
@@ -3,12 +3,14 @@ from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from typing import List
+import uuid
from ...core.database import get_db
from ...core.security import verify_password, get_password_hash, create_access_token
-from ...models.models import User, UserRole
+from ...models.models import User, UserRole, PasswordResetToken
from ...schemas import (
- UserCreate, UserResponse, Token, LoginRequest, MessageResponse
+ UserCreate, UserResponse, Token, LoginRequest, MessageResponse,
+ ForgotPasswordRequest, ResetPasswordRequest
)
from ...services.email_service import email_service
@@ -126,3 +128,92 @@ async def login_json(
"access_token": access_token,
"token_type": "bearer"
}
+
+
+@router.post("/forgot-password", response_model=MessageResponse)
+async def forgot_password(
+ request: ForgotPasswordRequest,
+ db: Session = Depends(get_db)
+):
+ """Request password reset for a user"""
+ # Find user by email
+ user = db.query(User).filter(User.email == request.email).first()
+
+ if not user or not user.is_active:
+ # Don't reveal if email exists or not for security
+ return {"message": "If an account with this email exists, a password reset link has been sent."}
+
+ # Invalidate any existing reset tokens for this user
+ db.query(PasswordResetToken).filter(
+ PasswordResetToken.user_id == user.id,
+ PasswordResetToken.used == False,
+ PasswordResetToken.expires_at > datetime.utcnow()
+ ).update({"used": True})
+
+ # Generate new reset token
+ reset_token = str(uuid.uuid4())
+ expires_at = datetime.utcnow() + timedelta(hours=1) # Token expires in 1 hour
+
+ # Create password reset token
+ db_token = PasswordResetToken(
+ user_id=user.id,
+ token=reset_token,
+ expires_at=expires_at,
+ used=False
+ )
+
+ db.add(db_token)
+ db.commit()
+
+ # Send password reset email (non-blocking, ignore errors)
+ try:
+ await email_service.send_password_reset_email(
+ to_email=user.email,
+ first_name=user.first_name,
+ reset_token=reset_token
+ )
+ except Exception as e:
+ # Log error but don't fail the request
+ print(f"Failed to send password reset email: {e}")
+
+ return {"message": "If an account with this email exists, a password reset link has been sent."}
+
+
+@router.post("/reset-password", response_model=MessageResponse)
+async def reset_password(
+ request: ResetPasswordRequest,
+ db: Session = Depends(get_db)
+):
+ """Reset password using reset token"""
+ # Find valid reset token
+ reset_token = db.query(PasswordResetToken).filter(
+ PasswordResetToken.token == request.token,
+ PasswordResetToken.used == False,
+ PasswordResetToken.expires_at > datetime.utcnow()
+ ).first()
+
+ if not reset_token:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid or expired reset token"
+ )
+
+ # Get the user
+ user = db.query(User).filter(User.id == reset_token.user_id).first()
+ if not user or not user.is_active:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid reset token"
+ )
+
+ # Update password
+ hashed_password = get_password_hash(request.new_password)
+ user.hashed_password = hashed_password
+ user.updated_at = datetime.utcnow()
+
+ # Mark token as used
+ reset_token.used = True
+
+ db.commit()
+
+ return {"message": "Password has been reset successfully. You can now log in with your new password."}
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 832b783..7614fa1 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -37,6 +37,7 @@ class Settings(BaseSettings):
SMTP2GO_API_URL: str = "https://api.smtp2go.com/v3/email/send"
EMAIL_FROM: str
EMAIL_FROM_NAME: str
+ FRONTEND_URL: str = "http://localhost:3500"
# CORS
BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8080"]
diff --git a/backend/app/models/models.py b/backend/app/models/models.py
index 0e59f27..80fc24b 100644
--- a/backend/app/models/models.py
+++ b/backend/app/models/models.py
@@ -259,3 +259,17 @@ class Notification(Base):
sent_at = Column(DateTime, nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
+
+
+class PasswordResetToken(Base):
+ __tablename__ = "password_reset_tokens"
+
+ id = Column(Integer, primary_key=True, index=True)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ token = Column(String(255), unique=True, nullable=False, index=True)
+ expires_at = Column(DateTime, nullable=False)
+ used = Column(Boolean, default=False, nullable=False)
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
+
+ # Relationships
+ user = relationship("User", backref="password_reset_tokens")
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
index 796adc7..f738319 100644
--- a/backend/app/schemas/__init__.py
+++ b/backend/app/schemas/__init__.py
@@ -7,6 +7,8 @@ from .schemas import (
Token,
TokenData,
LoginRequest,
+ ForgotPasswordRequest,
+ ResetPasswordRequest,
MembershipTierBase,
MembershipTierCreate,
MembershipTierUpdate,
@@ -31,6 +33,8 @@ __all__ = [
"Token",
"TokenData",
"LoginRequest",
+ "ForgotPasswordRequest",
+ "ResetPasswordRequest",
"MembershipTierBase",
"MembershipTierCreate",
"MembershipTierUpdate",
diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py
index 9aaa528..13853ae 100644
--- a/backend/app/schemas/schemas.py
+++ b/backend/app/schemas/schemas.py
@@ -53,6 +53,16 @@ class LoginRequest(BaseModel):
password: str
+# Password Reset Schemas
+class ForgotPasswordRequest(BaseModel):
+ email: EmailStr
+
+
+class ResetPasswordRequest(BaseModel):
+ token: str = Field(..., min_length=1)
+ new_password: str = Field(..., min_length=8)
+
+
# Membership Tier Schemas
class MembershipTierBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py
index c083399..488aa6e 100644
--- a/backend/app/services/email_service.py
+++ b/backend/app/services/email_service.py
@@ -191,6 +191,59 @@ class EmailService:
"""
return await self.send_email(to_email, subject, html_body, text_body)
+
+ async def send_password_reset_email(
+ self,
+ to_email: str,
+ first_name: str,
+ reset_token: str
+ ) -> dict:
+ """Send password reset email with reset link"""
+ subject = f"Password Reset - {settings.APP_NAME}"
+
+ reset_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}"
+
+ html_body = f"""
+
+
+ Password Reset Request
+ Hello {first_name},
+ You have requested to reset your password for your {settings.APP_NAME} account.
+ Please click the button below to reset your password:
+
+ If the button doesn't work, you can copy and paste this link into your browser:
+ {reset_url}
+ This link will expire in 1 hour.
+ If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
+ For security reasons, please don't share this email with anyone.
+ Best regards,
+ {settings.APP_NAME}
+
+
+ """
+
+ text_body = f"""
+ Password Reset Request
+
+ Hello {first_name},
+
+ You have requested to reset your password for your {settings.APP_NAME} account.
+
+ Please use this link to reset your password: {reset_url}
+
+ This link will expire in 1 hour.
+
+ If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
+
+ For security reasons, please don't share this email with anyone.
+
+ Best regards,
+ {settings.APP_NAME}
+ """
+
+ return await self.send_email(to_email, subject, html_body, text_body)
# Create a singleton instance
diff --git a/docker-compose.yml b/docker-compose.yml
index 3a4684f..1b15f49 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -40,6 +40,25 @@ services:
networks:
- membership_private # Access to database on private network
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ container_name: membership_frontend
+ restart: unless-stopped
+ environment:
+ - VITE_HOST_CHECK=false
+ ports:
+ - "3500: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 # Access to backend on private network
+
networks:
membership_private:
driver: bridge
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..6e28e41
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,18 @@
+FROM node:18-alpine
+
+WORKDIR /app
+
+# Copy package files
+COPY package.json ./
+
+# Install dependencies
+RUN npm install
+
+# Copy application files
+COPY . .
+
+# Expose port
+EXPOSE 3000
+
+# Start development server
+CMD ["npm", "run", "dev"]
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..0d90de8
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ SASA Membership Portal
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..bb37a76
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "membership-frontend",
+ "version": "1.0.0",
+ "private": true,
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.20.0",
+ "axios": "^1.6.2",
+ "@types/react": "^18.2.42",
+ "@types/react-dom": "^18.2.17",
+ "typescript": "^5.3.2"
+ },
+ "scripts": {
+ "dev": "vite --host 0.0.0.0",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.2.0",
+ "vite": "^5.0.5"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..e99ebc2
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
\ No newline at end of file
diff --git a/frontend/src/App.css b/frontend/src/App.css
new file mode 100644
index 0000000..328e3a8
--- /dev/null
+++ b/frontend/src/App.css
@@ -0,0 +1,209 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ line-height: 1.6;
+ font-weight: 400;
+ color: #213547;
+ background-color: #ffffff;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ min-height: 100vh;
+}
+
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+.card {
+ background: white;
+ border-radius: 8px;
+ padding: 24px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ margin-bottom: 20px;
+}
+
+.btn {
+ padding: 10px 20px;
+ border: none;
+ border-radius: 4px;
+ font-size: 16px;
+ cursor: pointer;
+ transition: all 0.3s;
+}
+
+.btn-primary {
+ background-color: #0066cc;
+ color: white;
+}
+
+.btn-primary:hover {
+ background-color: #0052a3;
+}
+
+.btn-secondary {
+ background-color: #6c757d;
+ color: white;
+}
+
+.btn-secondary:hover {
+ background-color: #5a6268;
+}
+
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 6px;
+ font-weight: 500;
+}
+
+.form-group input,
+.form-group textarea {
+ width: 100%;
+ padding: 10px;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-size: 14px;
+}
+
+.form-group input:focus,
+.form-group textarea:focus {
+ outline: none;
+ border-color: #0066cc;
+}
+
+.alert {
+ padding: 12px 16px;
+ border-radius: 4px;
+ margin-bottom: 16px;
+}
+
+.alert-success {
+ background-color: #d4edda;
+ color: #155724;
+ border: 1px solid #c3e6cb;
+}
+
+.alert-error {
+ background-color: #f8d7da;
+ color: #721c24;
+ border: 1px solid #f5c6cb;
+}
+
+.alert-warning {
+ background-color: #fff3cd;
+ color: #856404;
+ border: 1px solid #ffeaa7;
+}
+
+.navbar {
+ background-color: #0066cc;
+ color: white;
+ padding: 16px 24px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.navbar h1 {
+ margin: 0;
+ font-size: 20px;
+}
+
+.navbar button {
+ background: rgba(255, 255, 255, 0.2);
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.navbar button:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+.auth-container {
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+}
+
+.auth-card {
+ background: white;
+ border-radius: 8px;
+ padding: 40px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
+ width: 100%;
+ max-width: 440px;
+}
+
+.auth-card h2 {
+ margin-bottom: 24px;
+ color: #213547;
+ text-align: center;
+}
+
+.auth-card .form-footer {
+ margin-top: 16px;
+ text-align: center;
+ color: #666;
+}
+
+.auth-card .form-footer a {
+ color: #0066cc;
+ text-decoration: none;
+}
+
+.auth-card .form-footer a:hover {
+ text-decoration: underline;
+}
+
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 20px;
+ margin-top: 20px;
+}
+
+.status-badge {
+ display: inline-block;
+ padding: 4px 12px;
+ border-radius: 12px;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.status-active {
+ background-color: #d4edda;
+ color: #155724;
+}
+
+.status-pending {
+ background-color: #fff3cd;
+ color: #856404;
+}
+
+.status-expired {
+ background-color: #f8d7da;
+ color: #721c24;
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..0036eac
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
+import Register from './pages/Register';
+import Login from './pages/Login';
+import ForgotPassword from './pages/ForgotPassword';
+import ResetPassword from './pages/ResetPassword';
+import Dashboard from './pages/Dashboard';
+import './App.css';
+
+const App: React.FC = () => {
+ return (
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+};
+
+export default App;
diff --git a/frontend/src/components/MembershipSetup.tsx b/frontend/src/components/MembershipSetup.tsx
new file mode 100644
index 0000000..b49262b
--- /dev/null
+++ b/frontend/src/components/MembershipSetup.tsx
@@ -0,0 +1,210 @@
+import React, { useState, useEffect } from 'react';
+import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
+
+interface MembershipSetupProps {
+ onMembershipCreated: () => void;
+ onCancel: () => void;
+}
+
+const MembershipSetup: React.FC = ({ onMembershipCreated, onCancel }) => {
+ const [tiers, setTiers] = useState([]);
+ const [selectedTier, setSelectedTier] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [step, setStep] = useState<'select' | 'payment' | 'confirm'>('select');
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ loadTiers();
+ }, []);
+
+ const loadTiers = async () => {
+ try {
+ const tierData = await membershipService.getTiers();
+ setTiers(tierData);
+ } catch (error) {
+ console.error('Failed to load tiers:', error);
+ setError('Failed to load membership tiers');
+ }
+ };
+
+ const handleTierSelect = (tier: MembershipTier) => {
+ setSelectedTier(tier);
+ setStep('payment');
+ };
+
+ const handlePayment = async () => {
+ if (!selectedTier) return;
+
+ setLoading(true);
+ setError('');
+
+ try {
+ // Calculate dates (start today, end one year from now)
+ const startDate = new Date().toISOString().split('T')[0];
+ const endDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
+
+ // Create membership
+ const membershipData: MembershipCreateData = {
+ tier_id: selectedTier.id,
+ start_date: startDate,
+ end_date: endDate,
+ auto_renew: false
+ };
+
+ const membership = await membershipService.createMembership(membershipData);
+
+ // Create fake payment
+ const paymentData: PaymentCreateData = {
+ amount: selectedTier.annual_fee,
+ payment_method: 'dummy',
+ membership_id: membership.id,
+ notes: `Fake payment for ${selectedTier.name} membership`
+ };
+
+ await paymentService.createPayment(paymentData);
+
+ setStep('confirm');
+ } catch (err: any) {
+ setError(err.response?.data?.detail || 'Failed to create membership');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleConfirm = () => {
+ onMembershipCreated();
+ };
+
+ if (step === 'select') {
+ return (
+
+
Choose Your Membership
+ {error &&
{error}
}
+
+
+ {tiers.map(tier => (
+
{
+ e.currentTarget.style.borderColor = '#0066cc';
+ e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 102, 204, 0.1)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.borderColor = '#ddd';
+ e.currentTarget.style.boxShadow = 'none';
+ }}
+ onClick={() => handleTierSelect(tier)}
+ >
+
+
{tier.name}
+
+ £{tier.annual_fee.toFixed(2)}/year
+
+
+
{tier.description}
+
+
Benefits:
+
{tier.benefits}
+
+
+ ))}
+
+
+
+
+
+
+ );
+ }
+
+ if (step === 'payment') {
+ return (
+
+
Complete Payment
+ {error &&
{error}
}
+
+ {selectedTier && (
+
+
Selected Membership: {selectedTier.name}
+
Annual Fee: £{selectedTier.annual_fee.toFixed(2)}
+
Benefits: {selectedTier.benefits}
+
+ )}
+
+
+
Demo Payment
+
+ This is a fake payment flow for demonstration purposes. In a real application, you would integrate with a payment processor like Stripe or Square.
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (step === 'confirm') {
+ return (
+
+
Membership Created Successfully!
+
+ {selectedTier && (
+
+
Your Membership Details:
+
Tier: {selectedTier.name}
+
Annual Fee: £{selectedTier.annual_fee.toFixed(2)}
+
Status: Pending
+
+ Your membership application has been submitted. An administrator will review and activate your membership shortly.
+
+
+ )}
+
+
+
+
+
+ );
+ }
+
+ return null;
+};
+
+export default MembershipSetup;
\ No newline at end of file
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..9707d82
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..7c20afb
--- /dev/null
+++ b/frontend/src/pages/Dashboard.tsx
@@ -0,0 +1,316 @@
+import React, { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { authService, userService, membershipService, paymentService, User, Membership, Payment } from '../services/membershipService';
+import MembershipSetup from '../components/MembershipSetup';
+
+const Dashboard: React.FC = () => {
+ const navigate = useNavigate();
+ const [user, setUser] = useState(null);
+ const [memberships, setMemberships] = useState([]);
+ const [payments, setPayments] = useState([]);
+ const [allPayments, setAllPayments] = useState([]);
+ const [allMemberships, setAllMemberships] = useState([]);
+ const [allUsers, setAllUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showMembershipSetup, setShowMembershipSetup] = useState(false);
+
+ useEffect(() => {
+ if (!authService.isAuthenticated()) {
+ navigate('/login');
+ return;
+ }
+
+ loadData();
+ }, []);
+
+ const loadData = async () => {
+ try {
+ const [userData, membershipData, paymentData] = await Promise.all([
+ userService.getCurrentUser(),
+ membershipService.getMyMemberships(),
+ paymentService.getMyPayments()
+ ]);
+
+ setUser(userData);
+ setMemberships(membershipData);
+ setPayments(paymentData);
+
+ // Load admin data if user is admin
+ if (userData.role === 'admin' || userData.role === 'super_admin') {
+ const [allPaymentsData, allMembershipsData, allUsersData] = await Promise.all([
+ paymentService.getAllPayments(),
+ membershipService.getAllMemberships(),
+ userService.getAllUsers()
+ ]);
+ setAllPayments(allPaymentsData);
+ setAllMemberships(allMembershipsData);
+ setAllUsers(allUsersData);
+ }
+ } catch (error) {
+ console.error('Failed to load data:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleLogout = () => {
+ authService.logout();
+ navigate('/login');
+ };
+
+ const handleMembershipSetup = () => {
+ setShowMembershipSetup(true);
+ };
+
+ const handleMembershipCreated = () => {
+ setShowMembershipSetup(false);
+ loadData(); // Reload data to show the new membership
+ };
+
+ const handleCancelMembershipSetup = () => {
+ setShowMembershipSetup(false);
+ };
+
+ const getUserName = (userId: number): string => {
+ const user = allUsers.find(u => u.id === userId);
+ return user ? `${user.first_name} ${user.last_name}` : `User #${userId}`;
+ };
+
+ const handleApprovePayment = async (paymentId: number, membershipId?: number) => {
+ try {
+ // Approve the payment
+ await paymentService.updatePayment(paymentId, { status: 'completed' });
+
+ // If there's an associated membership, activate it
+ if (membershipId) {
+ await membershipService.updateMembership(membershipId, { status: 'active' });
+ }
+
+ // Reload data
+ await loadData();
+ } catch (error) {
+ console.error('Failed to approve payment:', error);
+ alert('Failed to approve payment. Please try again.');
+ }
+ };
+
+ const getStatusClass = (status: string) => {
+ switch (status.toLowerCase()) {
+ case 'active':
+ return 'status-active';
+ case 'pending':
+ return 'status-pending';
+ case 'expired':
+ case 'cancelled':
+ return 'status-expired';
+ default:
+ return '';
+ }
+ };
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString('en-GB', {
+ day: 'numeric',
+ month: 'long',
+ year: 'numeric'
+ });
+ };
+
+ if (loading) {
+ return Loading...
;
+ }
+
+ if (showMembershipSetup) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ const activeMembership = memberships.find(m => m.status === 'active') || memberships[0];
+
+ return (
+ <>
+
+
+
+
Welcome, {user?.first_name}!
+
+
+ {/* Profile Card */}
+
+
Your Profile
+
Name: {user?.first_name} {user?.last_name}
+
Email: {user?.email}
+ {user?.phone &&
Phone: {user.phone}
}
+ {user?.address &&
Address: {user.address}
}
+
Member since: {user && formatDate(user.created_at)}
+
+
+ {/* Membership Card */}
+ {activeMembership ? (
+
+
Your Membership
+
{activeMembership.tier.name}
+
Status: {activeMembership.status.toUpperCase()}
+
Annual Fee: £{activeMembership.tier.annual_fee.toFixed(2)}
+
Start Date: {formatDate(activeMembership.start_date)}
+
Renewal Date: {formatDate(activeMembership.end_date)}
+
Auto Renew: {activeMembership.auto_renew ? 'Yes' : 'No'}
+
+
Benefits:
+
{activeMembership.tier.benefits}
+
+
+ ) : (
+
+
Set Up Your Membership
+
Choose from our membership tiers to get started with SASA benefits.
+
Available tiers include Personal, Aircraft Owners, and Corporate memberships.
+
+
+ )}
+
+
+ {/* Payment History */}
+
+
Payment History
+ {payments.length > 0 ? (
+
+
+
+ | Date |
+ Amount |
+ Method |
+ Status |
+
+
+
+ {payments.map(payment => (
+
+ | {payment.payment_date ? formatDate(payment.payment_date) : 'Pending'} |
+ £{payment.amount.toFixed(2)} |
+ {payment.payment_method} |
+
+
+ {payment.status.toUpperCase()}
+
+ |
+
+ ))}
+
+
+ ) : (
+
No payment history available.
+ )}
+
+
+ {/* Admin Section */}
+ {(user?.role === 'admin' || user?.role === 'super_admin') && (
+
+
Admin Panel - Pending Approvals
+
+ {/* Pending Payments */}
+ {allPayments.filter(p => p.status === 'pending').length > 0 && (
+
+
Pending Payments
+
+
+
+ | User |
+ Amount |
+ Method |
+ Membership |
+ Actions |
+
+
+
+ {allPayments.filter(p => p.status === 'pending').map(payment => {
+ const membership = allMemberships.find(m => m.id === payment.membership_id);
+ return (
+
+ | {getUserName(payment.user_id)} |
+ £{payment.amount.toFixed(2)} |
+ {payment.payment_method} |
+
+ {membership ? `${membership.tier.name} (${membership.status})` : 'N/A'}
+ |
+
+
+ |
+
+ );
+ })}
+
+
+
+ )}
+
+ {/* Pending Memberships */}
+ {allMemberships.filter(m => m.status === 'pending').length > 0 && (
+
+
Pending Memberships
+
+
+
+ | User |
+ Tier |
+ Start Date |
+ Status |
+
+
+
+ {allMemberships.filter(m => m.status === 'pending').map(membership => (
+
+ | {getUserName(membership.user_id)} |
+ {membership.tier.name} |
+ {formatDate(membership.start_date)} |
+
+
+ {membership.status.toUpperCase()}
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ {allPayments.filter(p => p.status === 'pending').length === 0 &&
+ allMemberships.filter(m => m.status === 'pending').length === 0 && (
+
No pending approvals at this time.
+ )}
+
+ )}
+
+ >
+ );
+};
+
+export default Dashboard;
diff --git a/frontend/src/pages/ForgotPassword.tsx b/frontend/src/pages/ForgotPassword.tsx
new file mode 100644
index 0000000..b5ae61a
--- /dev/null
+++ b/frontend/src/pages/ForgotPassword.tsx
@@ -0,0 +1,73 @@
+import React, { useState } from 'react';
+import { Link } from 'react-router-dom';
+import { authService, ForgotPasswordData } from '../services/membershipService';
+
+const ForgotPassword: React.FC = () => {
+ const [email, setEmail] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [message, setMessage] = useState('');
+ const [error, setError] = useState('');
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setError('');
+ setMessage('');
+
+ try {
+ const data: ForgotPasswordData = { email };
+ await authService.forgotPassword(data);
+ setMessage('If an account with this email exists, a password reset link has been sent.');
+ } catch (err: any) {
+ setError(err.response?.data?.detail || 'An error occurred. Please try again.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
Forgot Password
+
+ Enter your email address and we'll send you a link to reset your password.
+
+
+ {error &&
{error}
}
+ {message &&
{message}
}
+
+
+
+
+
+ Back to login
+
+
+
+
+ );
+};
+
+export default ForgotPassword;
\ No newline at end of file
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
new file mode 100644
index 0000000..66802c4
--- /dev/null
+++ b/frontend/src/pages/Login.tsx
@@ -0,0 +1,101 @@
+import React, { useState } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+import { authService, LoginData } from '../services/membershipService';
+
+const Login: React.FC = () => {
+ const navigate = useNavigate();
+ const [formData, setFormData] = useState({
+ email: '',
+ password: ''
+ });
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ setFormData({
+ ...formData,
+ [e.target.name]: e.target.value
+ });
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ await authService.login(formData);
+ navigate('/dashboard');
+ } catch (err: any) {
+ setError(err.response?.data?.detail || 'Login failed. Please check your credentials.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
Welcome Back
+
+ Log in to your membership account
+
+
+ {error &&
{error}
}
+
+
+
+
+
+
+ Forgot your password?
+
+
+
+
+
+
+ );
+};
+
+export default Login;
diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx
new file mode 100644
index 0000000..6f25eee
--- /dev/null
+++ b/frontend/src/pages/Register.tsx
@@ -0,0 +1,144 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { authService, RegisterData } from '../services/membershipService';
+
+const Register: React.FC = () => {
+ const navigate = useNavigate();
+ const [formData, setFormData] = useState({
+ email: '',
+ password: '',
+ first_name: '',
+ last_name: '',
+ phone: '',
+ address: ''
+ });
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ setFormData({
+ ...formData,
+ [e.target.name]: e.target.value
+ });
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+
+ try {
+ await authService.register(formData);
+ alert('Registration successful! Please check your email. You can now log in.');
+ navigate('/login');
+ } catch (err: any) {
+ setError(err.response?.data?.detail || 'Registration failed. Please try again.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
Create Your Account
+
+ Join Swansea Airport Stakeholders Alliance
+
+
+ {error &&
{error}
}
+
+
+
+
+ Already have an account?
Log in
+
+
+
+ );
+};
+
+export default Register;
diff --git a/frontend/src/pages/ResetPassword.tsx b/frontend/src/pages/ResetPassword.tsx
new file mode 100644
index 0000000..be911ac
--- /dev/null
+++ b/frontend/src/pages/ResetPassword.tsx
@@ -0,0 +1,128 @@
+import React, { useState, useEffect } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { authService, ResetPasswordData } from '../services/membershipService';
+
+const ResetPassword: React.FC = () => {
+ const [newPassword, setNewPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [message, setMessage] = useState('');
+ const [error, setError] = useState('');
+ const [token, setToken] = useState('');
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+
+ useEffect(() => {
+ const tokenParam = searchParams.get('token');
+ if (tokenParam) {
+ setToken(tokenParam);
+ } else {
+ setError('Invalid reset link. Please request a new password reset.');
+ }
+ }, [searchParams]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLoading(true);
+ setError('');
+ setMessage('');
+
+ if (newPassword !== confirmPassword) {
+ setError('Passwords do not match.');
+ setLoading(false);
+ return;
+ }
+
+ if (newPassword.length < 8) {
+ setError('Password must be at least 8 characters long.');
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const data: ResetPasswordData = { token, new_password: newPassword };
+ await authService.resetPassword(data);
+ setMessage('Password has been reset successfully. You can now log in with your new password.');
+ setTimeout(() => {
+ navigate('/login');
+ }, 3000);
+ } catch (err: any) {
+ setError(err.response?.data?.detail || 'An error occurred. Please try again.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (!token) {
+ return (
+
+
+
Invalid Reset Link
+
+ This password reset link is invalid or has expired. Please request a new password reset.
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
Reset Password
+
+ Enter your new password below. Make sure it's at least 8 characters long.
+
+
+ {error &&
{error}
}
+ {message &&
{message}
}
+
+
+
+
+ );
+};
+
+export default ResetPassword;
\ No newline at end of file
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
new file mode 100644
index 0000000..297cd15
--- /dev/null
+++ b/frontend/src/services/api.ts
@@ -0,0 +1,31 @@
+import axios from 'axios';
+
+const api = axios.create({
+ baseURL: '/api/v1',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+});
+
+// Add token to requests if available
+api.interceptors.request.use((config) => {
+ const token = localStorage.getItem('token');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+});
+
+// Handle 401 errors
+api.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (error.response?.status === 401) {
+ localStorage.removeItem('token');
+ window.location.href = '/login';
+ }
+ return Promise.reject(error);
+ }
+);
+
+export default api;
diff --git a/frontend/src/services/authService.ts b/frontend/src/services/authService.ts
new file mode 100644
index 0000000..afa3e40
--- /dev/null
+++ b/frontend/src/services/authService.ts
@@ -0,0 +1,66 @@
+import api from './api';
+
+export interface LoginData {
+ email: string;
+ password: string;
+}
+
+export interface RegisterData {
+ email: string;
+ first_name: string;
+ last_name: string;
+ phone?: string;
+ address?: string;
+ password: string;
+}
+
+export interface ForgotPasswordData {
+ email: string;
+}
+
+export interface ResetPasswordData {
+ token: string;
+ new_password: string;
+}
+
+class AuthService {
+ async login(data: LoginData) {
+ const response = await api.post('/auth/login-json', data);
+ const { access_token } = response.data;
+ this.setToken(access_token);
+ return response.data;
+ }
+
+ async register(data: RegisterData) {
+ const response = await api.post('/auth/register', data);
+ return response.data;
+ }
+
+ async forgotPassword(data: ForgotPasswordData) {
+ const response = await api.post('/auth/forgot-password', data);
+ return response.data;
+ }
+
+ async resetPassword(data: ResetPasswordData) {
+ const response = await api.post('/auth/reset-password', data);
+ return response.data;
+ }
+
+ logout() {
+ localStorage.removeItem('token');
+ }
+
+ getToken() {
+ return localStorage.getItem('token');
+ }
+
+ setToken(token: string) {
+ localStorage.setItem('token', token);
+ }
+
+ isAuthenticated() {
+ return !!this.getToken();
+ }
+}
+
+export const authService = new AuthService();
\ No newline at end of file
diff --git a/frontend/src/services/membershipService.ts b/frontend/src/services/membershipService.ts
new file mode 100644
index 0000000..160b557
--- /dev/null
+++ b/frontend/src/services/membershipService.ts
@@ -0,0 +1,197 @@
+import api from './api';
+
+export interface RegisterData {
+ email: string;
+ password: string;
+ first_name: string;
+ last_name: string;
+ phone?: string;
+ address?: string;
+}
+
+export interface LoginData {
+ email: string;
+ password: string;
+}
+
+export interface User {
+ id: number;
+ email: string;
+ first_name: string;
+ last_name: string;
+ phone: string | null;
+ address: string | null;
+ role: string;
+ is_active: boolean;
+ created_at: string;
+ last_login: string | null;
+}
+
+export interface MembershipTier {
+ id: number;
+ name: string;
+ description: string;
+ annual_fee: number;
+ benefits: string;
+ is_active: boolean;
+ created_at: string;
+}
+
+export interface Membership {
+ id: number;
+ user_id: number;
+ tier_id: number;
+ status: string;
+ start_date: string;
+ end_date: string;
+ auto_renew: boolean;
+ created_at: string;
+ tier: MembershipTier;
+}
+
+export interface Payment {
+ id: number;
+ user_id: number;
+ membership_id: number | null;
+ amount: number;
+ payment_method: string;
+ status: string;
+ transaction_id: string | null;
+ payment_date: string | null;
+ notes: string | null;
+ created_at: string;
+}
+
+export interface ForgotPasswordData {
+ email: string;
+}
+
+export interface ResetPasswordData {
+ token: string;
+ new_password: string;
+}
+
+export interface MembershipCreateData {
+ tier_id: number;
+ start_date: string;
+ end_date: string;
+ auto_renew: boolean;
+}
+
+export interface PaymentCreateData {
+ amount: number;
+ payment_method: string;
+ membership_id?: number;
+ notes?: string;
+}
+
+export interface PaymentUpdateData {
+ status?: string;
+ transaction_id?: string;
+ payment_date?: string;
+ notes?: string;
+}
+
+export interface MembershipUpdateData {
+ tier_id?: number;
+ status?: string;
+ end_date?: string;
+ auto_renew?: boolean;
+}
+
+export const authService = {
+ async register(data: RegisterData) {
+ const response = await api.post('/auth/register', data);
+ return response.data;
+ },
+
+ async login(data: LoginData) {
+ const response = await api.post('/auth/login-json', data);
+ localStorage.setItem('token', response.data.access_token);
+ return response.data;
+ },
+
+ async forgotPassword(data: ForgotPasswordData) {
+ const response = await api.post('/auth/forgot-password', data);
+ return response.data;
+ },
+
+ async resetPassword(data: ResetPasswordData) {
+ const response = await api.post('/auth/reset-password', data);
+ return response.data;
+ },
+
+ logout() {
+ localStorage.removeItem('token');
+ },
+
+ isAuthenticated() {
+ return !!localStorage.getItem('token');
+ }
+};
+
+export const userService = {
+ async getCurrentUser(): Promise {
+ const response = await api.get('/users/me');
+ return response.data;
+ },
+
+ async updateProfile(data: Partial) {
+ const response = await api.put('/users/me', data);
+ return response.data;
+ },
+
+ async getAllUsers(): Promise {
+ const response = await api.get('/users/');
+ return response.data;
+ }
+};
+
+export const membershipService = {
+ async getMyMemberships(): Promise {
+ const response = await api.get('/memberships/my-memberships');
+ return response.data;
+ },
+
+ async createMembership(data: MembershipCreateData): Promise {
+ const response = await api.post('/memberships/', data);
+ return response.data;
+ },
+
+ async updateMembership(membershipId: number, data: MembershipUpdateData): Promise {
+ const response = await api.put(`/memberships/${membershipId}`, data);
+ return response.data;
+ },
+
+ async getAllMemberships(): Promise {
+ const response = await api.get('/memberships/');
+ return response.data;
+ },
+
+ async getTiers(): Promise {
+ const response = await api.get('/tiers/');
+ return response.data;
+ }
+};
+
+export const paymentService = {
+ async getMyPayments(): Promise {
+ const response = await api.get('/payments/my-payments');
+ return response.data;
+ },
+
+ async createPayment(data: PaymentCreateData): Promise {
+ const response = await api.post('/payments/', data);
+ return response.data;
+ },
+
+ async updatePayment(paymentId: number, data: PaymentUpdateData): Promise {
+ const response = await api.put(`/payments/${paymentId}`, data);
+ return response.data;
+ },
+
+ async getAllPayments(): Promise {
+ const response = await api.get('/payments/');
+ return response.data;
+ }
+};
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..89a305e
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,11 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
\ No newline at end of file
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..3934b8f
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..42872c5
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..ef125d1
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,24 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ host: true,
+ port: 3000,
+ strictPort: true,
+ allowedHosts: ['sasaprod', 'localhost'],
+ watch: {
+ usePolling: true
+ },
+ hmr: {
+ clientPort: 3500
+ },
+ proxy: {
+ '/api': {
+ target: 'http://backend:8000',
+ changeOrigin: true
+ }
+ }
+ }
+})