From f1c4ff19d63b1055052c32f058cdb46d0eb932cc Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Mon, 10 Nov 2025 15:20:11 +0000 Subject: [PATCH] More iteration --- backend/app/api/v1/auth.py | 27 ++- backend/app/api/v1/users.py | 26 +++ backend/app/schemas/__init__.py | 2 + backend/app/schemas/schemas.py | 6 + frontend/src/App.css | 102 ++++++++++ frontend/src/components/ProfileMenu.tsx | 226 +++++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 95 ++++++++- frontend/src/pages/Login.tsx | 4 +- frontend/src/services/membershipService.ts | 15 ++ 9 files changed, 491 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/ProfileMenu.tsx diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 0368285..1a016e3 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -10,9 +10,10 @@ from ...core.security import verify_password, get_password_hash, create_access_t from ...models.models import User, UserRole, PasswordResetToken from ...schemas import ( UserCreate, UserResponse, Token, LoginRequest, MessageResponse, - ForgotPasswordRequest, ResetPasswordRequest + ForgotPasswordRequest, ResetPasswordRequest, ChangePasswordRequest ) from ...services.email_service import email_service +from ...api.dependencies import get_current_active_user router = APIRouter() @@ -217,3 +218,27 @@ async def reset_password( db.commit() return {"message": "Password has been reset successfully. You can now log in with your new password."} + + +@router.post("/change-password", response_model=MessageResponse) +async def change_password( + request: ChangePasswordRequest, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + """Change password for authenticated user""" + # Verify current password + if not verify_password(request.current_password, current_user.hashed_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is incorrect" + ) + + # Update password + hashed_password = get_password_hash(request.new_password) + current_user.hashed_password = hashed_password + current_user.updated_at = datetime.utcnow() + + db.commit() + + return {"message": "Password has been changed successfully."} diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 021de45..f4d6e30 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -65,6 +65,32 @@ async def get_user( return user +@router.put("/{user_id}", response_model=UserResponse) +async def update_user( + user_id: int, + user_update: UserUpdate, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + """Update user by ID (admin only)""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + update_data = user_update.model_dump(exclude_unset=True) + + for field, value in update_data.items(): + setattr(user, field, value) + + db.commit() + db.refresh(user) + + return user + + @router.delete("/{user_id}", response_model=MessageResponse) async def delete_user( user_id: int, diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index f738319..bcbd7af 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -9,6 +9,7 @@ from .schemas import ( LoginRequest, ForgotPasswordRequest, ResetPasswordRequest, + ChangePasswordRequest, MembershipTierBase, MembershipTierCreate, MembershipTierUpdate, @@ -35,6 +36,7 @@ __all__ = [ "LoginRequest", "ForgotPasswordRequest", "ResetPasswordRequest", + "ChangePasswordRequest", "MembershipTierBase", "MembershipTierCreate", "MembershipTierUpdate", diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 13853ae..48ec51b 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -22,6 +22,7 @@ class UserUpdate(BaseModel): last_name: Optional[str] = Field(None, min_length=1, max_length=100) phone: Optional[str] = None address: Optional[str] = None + role: Optional[UserRole] = None class UserResponse(UserBase): @@ -63,6 +64,11 @@ class ResetPasswordRequest(BaseModel): new_password: str = Field(..., min_length=8) +class ChangePasswordRequest(BaseModel): + current_password: str = Field(..., min_length=1) + new_password: str = Field(..., min_length=8) + + # Membership Tier Schemas class MembershipTierBase(BaseModel): name: str = Field(..., min_length=1, max_length=100) diff --git a/frontend/src/App.css b/frontend/src/App.css index 328e3a8..e35f5b1 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -207,3 +207,105 @@ body { background-color: #f8d7da; color: #721c24; } + +/* Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; +} + +.modal-content { + background: white; + padding: 24px; + border-radius: 8px; + width: 100%; + max-width: 400px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.modal-content h3 { + margin: 0 0 20px 0; + color: #333; + font-size: 18px; + font-weight: bold; +} + +.modal-form-group { + margin-bottom: 16px; +} + +.modal-form-group label { + display: block; + margin-bottom: 4px; + font-weight: bold; + color: #333; + font-size: 14px; +} + +.modal-form-group input { + width: 100%; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + color: #333; + background-color: #fff; +} + +.modal-form-group input:focus { + outline: none; + border-color: #0066cc; +} + +.modal-error { + color: #dc3545; + margin-bottom: 16px; + font-size: 14px; +} + +.modal-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.modal-btn-cancel { + padding: 8px 16px; + border: 1px solid #ddd; + border-radius: 4px; + background: white; + cursor: pointer; + color: #333; + font-size: 14px; +} + +.modal-btn-cancel:hover { + background: #f8f9fa; +} + +.modal-btn-primary { + padding: 8px 16px; + border: none; + border-radius: 4px; + background: #007bff; + color: white; + cursor: pointer; + font-size: 14px; +} + +.modal-btn-primary:hover:not(:disabled) { + background: #0056b3; +} + +.modal-btn-primary:disabled { + background: #6c757d; + cursor: not-allowed; +} diff --git a/frontend/src/components/ProfileMenu.tsx b/frontend/src/components/ProfileMenu.tsx new file mode 100644 index 0000000..77f0e14 --- /dev/null +++ b/frontend/src/components/ProfileMenu.tsx @@ -0,0 +1,226 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { authService } from '../services/membershipService'; + +interface ProfileMenuProps { + userName: string; +} + +const ProfileMenu: React.FC = ({ userName }) => { + const [isOpen, setIsOpen] = useState(false); + const [showChangePassword, setShowChangePassword] = useState(false); + const menuRef = useRef(null); + const navigate = useNavigate(); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const handleLogout = () => { + authService.logout(); + navigate('/login'); + }; + + const handleChangePassword = () => { + setShowChangePassword(true); + setIsOpen(false); + }; + + const handleCloseChangePassword = () => { + setShowChangePassword(false); + }; + + const dropdownStyle: React.CSSProperties = { + position: 'absolute', + top: '100%', + right: 0, + background: 'white', + border: '1px solid #ddd', + borderRadius: '4px', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + minWidth: '160px', + zIndex: 1000, + }; + + const menuItemStyle: React.CSSProperties = { + display: 'block', + width: '100%', + padding: '12px 16px', + background: 'none', + border: 'none', + textAlign: 'left', + cursor: 'pointer', + color: '#333', + fontSize: '14px', + }; + + return ( + <> +
+ + + {isOpen && ( +
+ + +
+ )} +
+ + {showChangePassword && ( + + )} + + ); +}; + +interface ChangePasswordModalProps { + onClose: () => void; +} + +const ChangePasswordModal: React.FC = ({ onClose }) => { + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (newPassword !== confirmPassword) { + setError('New passwords do not match'); + return; + } + + if (newPassword.length < 8) { + setError('New password must be at least 8 characters long'); + return; + } + + setLoading(true); + setError(''); + + try { + await authService.changePassword({ + current_password: currentPassword, + new_password: newPassword + }); + + alert('Password changed successfully!'); + onClose(); + } catch (error: any) { + setError(error.response?.data?.detail || 'Failed to change password'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Change Password

+ +
+
+ + setCurrentPassword(e.target.value)} + required + /> +
+ +
+ + setNewPassword(e.target.value)} + required + minLength={8} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+ ); +}; + +export default ProfileMenu; \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 7c20afb..b3ebdb1 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -2,6 +2,7 @@ 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'; +import ProfileMenu from '../components/ProfileMenu'; const Dashboard: React.FC = () => { const navigate = useNavigate(); @@ -53,11 +54,6 @@ const Dashboard: React.FC = () => { } }; - const handleLogout = () => { - authService.logout(); - navigate('/login'); - }; - const handleMembershipSetup = () => { setShowMembershipSetup(true); }; @@ -94,6 +90,17 @@ const Dashboard: React.FC = () => { } }; + const handleUpdateUserRole = async (userId: number, newRole: string) => { + try { + await userService.updateUserRole(userId, newRole); + // Reload data to reflect changes + await loadData(); + } catch (error) { + console.error('Failed to update user role:', error); + alert('Failed to update user role. Please try again.'); + } + }; + const getStatusClass = (status: string) => { switch (status.toLowerCase()) { case 'active': @@ -125,7 +132,7 @@ const Dashboard: React.FC = () => { <>
{ <>
@@ -157,7 +164,7 @@ const Dashboard: React.FC = () => {

Email: {user?.email}

{user?.phone &&

Phone: {user.phone}

} {user?.address &&

Address: {user.address}

} -

Member since: {user && formatDate(user.created_at)}

+

Registered since: {user && formatDate(user.created_at)}

{/* Membership Card */} @@ -167,7 +174,7 @@ const Dashboard: React.FC = () => {

{activeMembership.tier.name}

Status: {activeMembership.status.toUpperCase()}

Annual Fee: £{activeMembership.tier.annual_fee.toFixed(2)}

-

Start Date: {formatDate(activeMembership.start_date)}

+

Member since: {formatDate(activeMembership.start_date)}

Renewal Date: {formatDate(activeMembership.end_date)}

Auto Renew: {activeMembership.auto_renew ? 'Yes' : 'No'}

@@ -308,6 +315,76 @@ const Dashboard: React.FC = () => { )}
)} + + {/* User Management Section */} + {(user?.role === 'admin' || user?.role === 'super_admin') && ( +
+

User Management

+ + + + + + + + + + + + + + {allUsers.map(u => ( + + + + + + + + + ))} + +
NameEmailRoleStatusJoinedActions
{u.first_name} {u.last_name}{u.email} + + {u.role.toUpperCase()} + + + + {u.is_active ? 'ACTIVE' : 'INACTIVE'} + + {formatDate(u.created_at)} + {u.role === 'member' && ( + + )} + {u.role === 'admin' && u.id !== user?.id && ( + + )} + {u.role === 'super_admin' && ( + Super Admin + )} +
+
+ )}
); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 66802c4..a70c179 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -36,7 +36,7 @@ const Login: React.FC = () => { return (
-

Welcome Back

+

SASA Member Portal

Log in to your membership account

@@ -90,7 +90,7 @@ const Login: React.FC = () => { onClick={() => navigate('/register')} style={{ width: '100%' }} > - Join as New User + Join SASA
diff --git a/frontend/src/services/membershipService.ts b/frontend/src/services/membershipService.ts index 160b557..3641c4f 100644 --- a/frontend/src/services/membershipService.ts +++ b/frontend/src/services/membershipService.ts @@ -71,6 +71,11 @@ export interface ResetPasswordData { new_password: string; } +export interface ChangePasswordData { + current_password: string; + new_password: string; +} + export interface MembershipCreateData { tier_id: number; start_date: string; @@ -121,6 +126,11 @@ export const authService = { return response.data; }, + async changePassword(data: ChangePasswordData) { + const response = await api.post('/auth/change-password', data); + return response.data; + }, + logout() { localStorage.removeItem('token'); }, @@ -144,6 +154,11 @@ export const userService = { async getAllUsers(): Promise { const response = await api.get('/users/'); return response.data; + }, + + async updateUserRole(userId: number, role: string): Promise { + const response = await api.put(`/users/${userId}`, { role }); + return response.data; } };