Basic frontend
This commit is contained in:
@@ -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."}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
18
frontend/Dockerfile
Normal 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
12
frontend/index.html
Normal 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
23
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
209
frontend/src/App.css
Normal file
209
frontend/src/App.css
Normal 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
25
frontend/src/App.tsx
Normal 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;
|
||||||
210
frontend/src/components/MembershipSetup.tsx
Normal file
210
frontend/src/components/MembershipSetup.tsx
Normal 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
9
frontend/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
316
frontend/src/pages/Dashboard.tsx
Normal file
316
frontend/src/pages/Dashboard.tsx
Normal 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;
|
||||||
73
frontend/src/pages/ForgotPassword.tsx
Normal file
73
frontend/src/pages/ForgotPassword.tsx
Normal 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;
|
||||||
101
frontend/src/pages/Login.tsx
Normal file
101
frontend/src/pages/Login.tsx
Normal 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;
|
||||||
144
frontend/src/pages/Register.tsx
Normal file
144
frontend/src/pages/Register.tsx
Normal 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;
|
||||||
128
frontend/src/pages/ResetPassword.tsx
Normal file
128
frontend/src/pages/ResetPassword.tsx
Normal 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;
|
||||||
31
frontend/src/services/api.ts
Normal file
31
frontend/src/services/api.ts
Normal 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;
|
||||||
66
frontend/src/services/authService.ts
Normal file
66
frontend/src/services/authService.ts
Normal 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();
|
||||||
197
frontend/src/services/membershipService.ts
Normal file
197
frontend/src/services/membershipService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal 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
21
frontend/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
24
frontend/vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user