From 93aeda8e83aeda0bd166591de5d901dccf7e64f2 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Mon, 10 Nov 2025 14:51:15 +0000 Subject: [PATCH] Basic frontend --- backend/app/api/v1/auth.py | 95 +++++- backend/app/core/config.py | 1 + backend/app/models/models.py | 14 + backend/app/schemas/__init__.py | 4 + backend/app/schemas/schemas.py | 10 + backend/app/services/email_service.py | 53 ++++ docker-compose.yml | 19 ++ frontend/Dockerfile | 18 ++ frontend/index.html | 12 + frontend/package.json | 23 ++ frontend/postcss.config.js | 6 + frontend/src/App.css | 209 +++++++++++++ frontend/src/App.tsx | 25 ++ frontend/src/components/MembershipSetup.tsx | 210 +++++++++++++ frontend/src/main.tsx | 9 + frontend/src/pages/Dashboard.tsx | 316 ++++++++++++++++++++ frontend/src/pages/ForgotPassword.tsx | 73 +++++ frontend/src/pages/Login.tsx | 101 +++++++ frontend/src/pages/Register.tsx | 144 +++++++++ frontend/src/pages/ResetPassword.tsx | 128 ++++++++ frontend/src/services/api.ts | 31 ++ frontend/src/services/authService.ts | 66 ++++ frontend/src/services/membershipService.ts | 197 ++++++++++++ frontend/tailwind.config.js | 11 + frontend/tsconfig.json | 21 ++ frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 24 ++ 27 files changed, 1828 insertions(+), 2 deletions(-) create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/MembershipSetup.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/ForgotPassword.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Register.tsx create mode 100644 frontend/src/pages/ResetPassword.tsx create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/services/authService.ts create mode 100644 frontend/src/services/membershipService.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts 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:

+
+ Reset 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 ? ( + + + + + + + + + + + {payments.map(payment => ( + + + + + + + ))} + +
DateAmountMethodStatus
{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

+ + + + + + + + + + + + {allPayments.filter(p => p.status === 'pending').map(payment => { + const membership = allMemberships.find(m => m.id === payment.membership_id); + return ( + + + + + + + + ); + })} + +
UserAmountMethodMembershipActions
{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

+ + + + + + + + + + + {allMemberships.filter(m => m.status === 'pending').map(membership => ( + + + + + + + ))} + +
UserTierStart DateStatus
{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}
} + +
+
+ + setEmail(e.target.value)} + required + placeholder="Enter your email address" + /> +
+ + +
+ +
+ + 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}
} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + Minimum 8 characters + +
+ +
+ + +
+ +
+ +