Basic frontend

This commit is contained in:
James Pattinson
2025-11-10 14:51:15 +00:00
parent 3751ee0076
commit 93aeda8e83
27 changed files with 1828 additions and 2 deletions

View File

@@ -3,12 +3,14 @@ from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List from typing import List
import uuid
from ...core.database import get_db from ...core.database import get_db
from ...core.security import verify_password, get_password_hash, create_access_token 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 ( from ...schemas import (
UserCreate, UserResponse, Token, LoginRequest, MessageResponse UserCreate, UserResponse, Token, LoginRequest, MessageResponse,
ForgotPasswordRequest, ResetPasswordRequest
) )
from ...services.email_service import email_service from ...services.email_service import email_service
@@ -126,3 +128,92 @@ async def login_json(
"access_token": access_token, "access_token": access_token,
"token_type": "bearer" "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."}

View File

@@ -37,6 +37,7 @@ class Settings(BaseSettings):
SMTP2GO_API_URL: str = "https://api.smtp2go.com/v3/email/send" SMTP2GO_API_URL: str = "https://api.smtp2go.com/v3/email/send"
EMAIL_FROM: str EMAIL_FROM: str
EMAIL_FROM_NAME: str EMAIL_FROM_NAME: str
FRONTEND_URL: str = "http://localhost:3500"
# CORS # CORS
BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8080"] BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8080"]

View File

@@ -259,3 +259,17 @@ class Notification(Base):
sent_at = Column(DateTime, nullable=True) sent_at = Column(DateTime, nullable=True)
error_message = Column(Text, nullable=True) error_message = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) 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")

View File

@@ -7,6 +7,8 @@ from .schemas import (
Token, Token,
TokenData, TokenData,
LoginRequest, LoginRequest,
ForgotPasswordRequest,
ResetPasswordRequest,
MembershipTierBase, MembershipTierBase,
MembershipTierCreate, MembershipTierCreate,
MembershipTierUpdate, MembershipTierUpdate,
@@ -31,6 +33,8 @@ __all__ = [
"Token", "Token",
"TokenData", "TokenData",
"LoginRequest", "LoginRequest",
"ForgotPasswordRequest",
"ResetPasswordRequest",
"MembershipTierBase", "MembershipTierBase",
"MembershipTierCreate", "MembershipTierCreate",
"MembershipTierUpdate", "MembershipTierUpdate",

View File

@@ -53,6 +53,16 @@ class LoginRequest(BaseModel):
password: str 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 # Membership Tier Schemas
class MembershipTierBase(BaseModel): class MembershipTierBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=100)

View File

@@ -192,6 +192,59 @@ class EmailService:
return await self.send_email(to_email, subject, html_body, text_body) 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"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<h2 style="color: #0066cc;">Password Reset Request</h2>
<p>Hello {first_name},</p>
<p>You have requested to reset your password for your {settings.APP_NAME} account.</p>
<p>Please click the button below to reset your password:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{reset_url}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">Reset Password</a>
</div>
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
<p style="word-break: break-all; background-color: #f5f5f5; padding: 10px; border-radius: 3px;">{reset_url}</p>
<p><strong>This link will expire in 1 hour.</strong></p>
<p>If you didn't request this password reset, please ignore this email. Your password will remain unchanged.</p>
<p>For security reasons, please don't share this email with anyone.</p>
<p>Best regards,<br>
<strong>{settings.APP_NAME}</strong></p>
</body>
</html>
"""
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 # Create a singleton instance
email_service = EmailService() email_service = EmailService()

View File

@@ -40,6 +40,25 @@ services:
networks: networks:
- membership_private # Access to database on private network - 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: networks:
membership_private: membership_private:
driver: bridge driver: bridge

18
frontend/Dockerfile Normal file
View File

@@ -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"]

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SASA Membership Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

23
frontend/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

209
frontend/src/App.css Normal file
View File

@@ -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;
}

25
frontend/src/App.tsx Normal file
View File

@@ -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 (
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/login" />} />
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</BrowserRouter>
);
};
export default App;

View File

@@ -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<MembershipSetupProps> = ({ onMembershipCreated, onCancel }) => {
const [tiers, setTiers] = useState<MembershipTier[]>([]);
const [selectedTier, setSelectedTier] = useState<MembershipTier | null>(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 (
<div className="card">
<h3 style={{ marginBottom: '16px' }}>Choose Your Membership</h3>
{error && <div className="alert alert-error">{error}</div>}
<div style={{ display: 'grid', gap: '16px' }}>
{tiers.map(tier => (
<div
key={tier.id}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '16px',
cursor: 'pointer',
transition: 'all 0.3s'
}}
onMouseEnter={(e) => {
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)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h4 style={{ margin: 0, color: '#0066cc' }}>{tier.name}</h4>
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#0066cc' }}>
£{tier.annual_fee.toFixed(2)}/year
</span>
</div>
<p style={{ margin: '8px 0', color: '#666', fontSize: '14px' }}>{tier.description}</p>
<div style={{ backgroundColor: '#f5f5f5', padding: '12px', borderRadius: '4px' }}>
<strong>Benefits:</strong>
<p style={{ marginTop: '4px', fontSize: '14px' }}>{tier.benefits}</p>
</div>
</div>
))}
</div>
<div style={{ marginTop: '20px', textAlign: 'center' }}>
<button
type="button"
className="btn btn-secondary"
onClick={onCancel}
>
Cancel
</button>
</div>
</div>
);
}
if (step === 'payment') {
return (
<div className="card">
<h3 style={{ marginBottom: '16px' }}>Complete Payment</h3>
{error && <div className="alert alert-error">{error}</div>}
{selectedTier && (
<div style={{ marginBottom: '20px' }}>
<h4>Selected Membership: {selectedTier.name}</h4>
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
<p><strong>Benefits:</strong> {selectedTier.benefits}</p>
</div>
)}
<div style={{ backgroundColor: '#fff3cd', border: '1px solid #ffeaa7', borderRadius: '4px', padding: '16px', marginBottom: '20px' }}>
<strong>Demo Payment</strong>
<p style={{ marginTop: '8px', marginBottom: 0 }}>
This is a fake payment flow for demonstration purposes. In a real application, you would integrate with a payment processor like Stripe or Square.
</p>
</div>
<div style={{ textAlign: 'center' }}>
<button
type="button"
className="btn btn-primary"
onClick={handlePayment}
disabled={loading}
style={{ marginRight: '10px' }}
>
{loading ? 'Processing...' : 'Complete Fake Payment'}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setStep('select')}
disabled={loading}
>
Back
</button>
</div>
</div>
);
}
if (step === 'confirm') {
return (
<div className="card">
<h3 style={{ marginBottom: '16px' }}>Membership Created Successfully!</h3>
{selectedTier && (
<div style={{ marginBottom: '20px' }}>
<h4>Your Membership Details:</h4>
<p><strong>Tier:</strong> {selectedTier.name}</p>
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
<p><strong>Status:</strong> <span className="status-badge status-pending">Pending</span></p>
<p style={{ fontSize: '14px', color: '#666', marginTop: '12px' }}>
Your membership application has been submitted. An administrator will review and activate your membership shortly.
</p>
</div>
)}
<div style={{ textAlign: 'center' }}>
<button
type="button"
className="btn btn-primary"
onClick={handleConfirm}
>
Return to Dashboard
</button>
</div>
</div>
);
}
return null;
};
export default MembershipSetup;

9
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -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<User | null>(null);
const [memberships, setMemberships] = useState<Membership[]>([]);
const [payments, setPayments] = useState<Payment[]>([]);
const [allPayments, setAllPayments] = useState<Payment[]>([]);
const [allMemberships, setAllMemberships] = useState<Membership[]>([]);
const [allUsers, setAllUsers] = useState<User[]>([]);
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 <div className="container">Loading...</div>;
}
if (showMembershipSetup) {
return (
<>
<nav className="navbar">
<h1>SASA Membership Portal</h1>
<button onClick={handleLogout}>Log Out</button>
</nav>
<div className="container">
<MembershipSetup
onMembershipCreated={handleMembershipCreated}
onCancel={handleCancelMembershipSetup}
/>
</div>
</>
);
}
const activeMembership = memberships.find(m => m.status === 'active') || memberships[0];
return (
<>
<nav className="navbar">
<h1>SASA Membership Portal</h1>
<button onClick={handleLogout}>Log Out</button>
</nav>
<div className="container">
<h2 style={{ marginTop: '20px', marginBottom: '20px' }}>Welcome, {user?.first_name}!</h2>
<div className="dashboard-grid">
{/* Profile Card */}
<div className="card">
<h3 style={{ marginBottom: '16px' }}>Your Profile</h3>
<p><strong>Name:</strong> {user?.first_name} {user?.last_name}</p>
<p><strong>Email:</strong> {user?.email}</p>
{user?.phone && <p><strong>Phone:</strong> {user.phone}</p>}
{user?.address && <p><strong>Address:</strong> {user.address}</p>}
<p><strong>Member since:</strong> {user && formatDate(user.created_at)}</p>
</div>
{/* Membership Card */}
{activeMembership ? (
<div className="card">
<h3 style={{ marginBottom: '16px' }}>Your Membership</h3>
<h4 style={{ color: '#0066cc', marginBottom: '8px' }}>{activeMembership.tier.name}</h4>
<p><strong>Status:</strong> <span className={`status-badge ${getStatusClass(activeMembership.status)}`}>{activeMembership.status.toUpperCase()}</span></p>
<p><strong>Annual Fee:</strong> £{activeMembership.tier.annual_fee.toFixed(2)}</p>
<p><strong>Start Date:</strong> {formatDate(activeMembership.start_date)}</p>
<p><strong>Renewal Date:</strong> {formatDate(activeMembership.end_date)}</p>
<p><strong>Auto Renew:</strong> {activeMembership.auto_renew ? 'Yes' : 'No'}</p>
<div style={{ marginTop: '12px', padding: '12px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
<strong>Benefits:</strong>
<p style={{ marginTop: '4px', fontSize: '14px' }}>{activeMembership.tier.benefits}</p>
</div>
</div>
) : (
<div className="card">
<h3 style={{ marginBottom: '16px' }}>Set Up Your Membership</h3>
<p>Choose from our membership tiers to get started with SASA benefits.</p>
<p style={{ marginTop: '12px', color: '#666' }}>Available tiers include Personal, Aircraft Owners, and Corporate memberships.</p>
<button
className="btn btn-primary"
onClick={handleMembershipSetup}
style={{ marginTop: '16px' }}
>
Set Up Membership
</button>
</div>
)}
</div>
{/* Payment History */}
<div className="card" style={{ marginTop: '20px' }}>
<h3 style={{ marginBottom: '16px' }}>Payment History</h3>
{payments.length > 0 ? (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '12px', textAlign: 'left' }}>Date</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Amount</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Method</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Status</th>
</tr>
</thead>
<tbody>
{payments.map(payment => (
<tr key={payment.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '12px' }}>{payment.payment_date ? formatDate(payment.payment_date) : 'Pending'}</td>
<td style={{ padding: '12px' }}>£{payment.amount.toFixed(2)}</td>
<td style={{ padding: '12px' }}>{payment.payment_method}</td>
<td style={{ padding: '12px' }}>
<span className={`status-badge ${getStatusClass(payment.status)}`}>
{payment.status.toUpperCase()}
</span>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p style={{ color: '#666' }}>No payment history available.</p>
)}
</div>
{/* Admin Section */}
{(user?.role === 'admin' || user?.role === 'super_admin') && (
<div className="card" style={{ marginTop: '20px' }}>
<h3 style={{ marginBottom: '16px' }}>Admin Panel - Pending Approvals</h3>
{/* Pending Payments */}
{allPayments.filter(p => p.status === 'pending').length > 0 && (
<div style={{ marginBottom: '20px' }}>
<h4 style={{ marginBottom: '12px' }}>Pending Payments</h4>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '12px', textAlign: 'left' }}>User</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Amount</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Method</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Membership</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Actions</th>
</tr>
</thead>
<tbody>
{allPayments.filter(p => p.status === 'pending').map(payment => {
const membership = allMemberships.find(m => m.id === payment.membership_id);
return (
<tr key={payment.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '12px' }}>{getUserName(payment.user_id)}</td>
<td style={{ padding: '12px' }}>£{payment.amount.toFixed(2)}</td>
<td style={{ padding: '12px' }}>{payment.payment_method}</td>
<td style={{ padding: '12px' }}>
{membership ? `${membership.tier.name} (${membership.status})` : 'N/A'}
</td>
<td style={{ padding: '12px' }}>
<button
className="btn btn-primary"
onClick={() => handleApprovePayment(payment.id, payment.membership_id || undefined)}
style={{ fontSize: '12px', padding: '6px 12px' }}
>
Approve
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Pending Memberships */}
{allMemberships.filter(m => m.status === 'pending').length > 0 && (
<div>
<h4 style={{ marginBottom: '12px' }}>Pending Memberships</h4>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '12px', textAlign: 'left' }}>User</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Tier</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Start Date</th>
<th style={{ padding: '12px', textAlign: 'left' }}>Status</th>
</tr>
</thead>
<tbody>
{allMemberships.filter(m => m.status === 'pending').map(membership => (
<tr key={membership.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '12px' }}>{getUserName(membership.user_id)}</td>
<td style={{ padding: '12px' }}>{membership.tier.name}</td>
<td style={{ padding: '12px' }}>{formatDate(membership.start_date)}</td>
<td style={{ padding: '12px' }}>
<span className={`status-badge ${getStatusClass(membership.status)}`}>
{membership.status.toUpperCase()}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{allPayments.filter(p => p.status === 'pending').length === 0 &&
allMemberships.filter(m => m.status === 'pending').length === 0 && (
<p style={{ color: '#666' }}>No pending approvals at this time.</p>
)}
</div>
)}
</div>
</>
);
};
export default Dashboard;

View File

@@ -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 (
<div className="auth-container">
<div className="auth-card">
<h2>Forgot Password</h2>
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
Enter your email address and we'll send you a link to reset your password.
</p>
{error && <div className="alert alert-error">{error}</div>}
{message && <div className="alert alert-success">{message}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email Address</label>
<input
type="email"
id="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="Enter your email address"
/>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
style={{ width: '100%', marginTop: '16px' }}
>
{loading ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
<div className="form-footer">
<Link to="/login" style={{ color: '#0066cc', textDecoration: 'none' }}>
Back to login
</Link>
</div>
</div>
</div>
);
};
export default ForgotPassword;

View File

@@ -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<LoginData>({
email: '',
password: ''
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="auth-container">
<div className="auth-card">
<h2>Welcome Back</h2>
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
Log in to your membership account
</p>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email Address</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
/>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
style={{ width: '100%', marginTop: '16px' }}
>
{loading ? 'Logging in...' : 'Log In'}
</button>
</form>
<div className="form-footer">
<div style={{ marginBottom: '16px' }}>
<Link to="/forgot-password" style={{ color: '#0066cc', textDecoration: 'none' }}>
Forgot your password?
</Link>
</div>
<button
type="button"
className="btn btn-secondary"
onClick={() => navigate('/register')}
style={{ width: '100%' }}
>
Join as New User
</button>
</div>
</div>
</div>
);
};
export default Login;

View File

@@ -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<RegisterData>({
email: '',
password: '',
first_name: '',
last_name: '',
phone: '',
address: ''
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
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 (
<div className="auth-container">
<div className="auth-card">
<h2>Create Your Account</h2>
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
Join Swansea Airport Stakeholders Alliance
</p>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="first_name">First Name *</label>
<input
type="text"
id="first_name"
name="first_name"
value={formData.first_name}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="last_name">Last Name *</label>
<input
type="text"
id="last_name"
name="last_name"
value={formData.last_name}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="email">Email Address *</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password *</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
minLength={8}
required
/>
<small style={{ color: '#666', fontSize: '12px' }}>
Minimum 8 characters
</small>
</div>
<div className="form-group">
<label htmlFor="phone">Phone (optional)</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
/>
</div>
<div className="form-group">
<label htmlFor="address">Address (optional)</label>
<textarea
id="address"
name="address"
value={formData.address}
onChange={handleChange}
rows={3}
/>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
style={{ width: '100%', marginTop: '16px' }}
>
{loading ? 'Creating Account...' : 'Create Account'}
</button>
</form>
<div className="form-footer">
Already have an account? <a href="/login">Log in</a>
</div>
</div>
</div>
);
};
export default Register;

View File

@@ -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 (
<div className="auth-container">
<div className="auth-card">
<h2>Invalid Reset Link</h2>
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
This password reset link is invalid or has expired. Please request a new password reset.
</p>
<button
onClick={() => navigate('/forgot-password')}
className="btn btn-primary"
style={{ width: '100%' }}
>
Request New Reset Link
</button>
</div>
</div>
);
}
return (
<div className="auth-container">
<div className="auth-card">
<h2>Reset Password</h2>
<p style={{ textAlign: 'center', marginBottom: '24px', color: '#666' }}>
Enter your new password below. Make sure it's at least 8 characters long.
</p>
{error && <div className="alert alert-error">{error}</div>}
{message && <div className="alert alert-success">{message}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="newPassword">New Password</label>
<input
type="password"
id="newPassword"
name="newPassword"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
placeholder="Enter new password"
/>
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm New Password</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
placeholder="Confirm new password"
/>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={loading}
style={{ width: '100%', marginTop: '16px' }}
>
{loading ? 'Resetting...' : 'Reset Password'}
</button>
</form>
</div>
</div>
);
};
export default ResetPassword;

View File

@@ -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;

View File

@@ -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();

View File

@@ -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<User> {
const response = await api.get('/users/me');
return response.data;
},
async updateProfile(data: Partial<User>) {
const response = await api.put('/users/me', data);
return response.data;
},
async getAllUsers(): Promise<User[]> {
const response = await api.get('/users/');
return response.data;
}
};
export const membershipService = {
async getMyMemberships(): Promise<Membership[]> {
const response = await api.get('/memberships/my-memberships');
return response.data;
},
async createMembership(data: MembershipCreateData): Promise<Membership> {
const response = await api.post('/memberships/', data);
return response.data;
},
async updateMembership(membershipId: number, data: MembershipUpdateData): Promise<Membership> {
const response = await api.put(`/memberships/${membershipId}`, data);
return response.data;
},
async getAllMemberships(): Promise<Membership[]> {
const response = await api.get('/memberships/');
return response.data;
},
async getTiers(): Promise<MembershipTier[]> {
const response = await api.get('/tiers/');
return response.data;
}
};
export const paymentService = {
async getMyPayments(): Promise<Payment[]> {
const response = await api.get('/payments/my-payments');
return response.data;
},
async createPayment(data: PaymentCreateData): Promise<Payment> {
const response = await api.post('/payments/', data);
return response.data;
},
async updatePayment(paymentId: number, data: PaymentUpdateData): Promise<Payment> {
const response = await api.put(`/payments/${paymentId}`, data);
return response.data;
},
async getAllPayments(): Promise<Payment[]> {
const response = await api.get('/payments/');
return response.data;
}
};

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

21
frontend/tsconfig.json Normal file
View File

@@ -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" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

24
frontend/vite.config.ts Normal file
View File

@@ -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
}
}
}
})