Compare commits
3 Commits
731c615d07
...
d42b7cb307
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d42b7cb307 | ||
|
|
d173b13bb9 | ||
|
|
ba21262854 |
65
README.md
65
README.md
@@ -68,9 +68,13 @@ membership/
|
|||||||
- SMTP2GO API key (for email notifications)
|
- SMTP2GO API key (for email notifications)
|
||||||
- Database password (if desired)
|
- Database password (if desired)
|
||||||
|
|
||||||
3. **Start the services**:
|
3. **Start the services in your preferred mode**:
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
# For development (with hot reloading)
|
||||||
|
docker-compose --profile dev up -d
|
||||||
|
|
||||||
|
# For production (optimized static files)
|
||||||
|
docker-compose --profile prod up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Wait for services to be ready** (about 30 seconds for MySQL initialization):
|
4. **Wait for services to be ready** (about 30 seconds for MySQL initialization):
|
||||||
@@ -79,10 +83,63 @@ membership/
|
|||||||
```
|
```
|
||||||
Press Ctrl+C when you see "Application startup complete"
|
Press Ctrl+C when you see "Application startup complete"
|
||||||
|
|
||||||
5. **Access the API**:
|
5. **Access the application**:
|
||||||
|
- Frontend: http://localhost:3500 (development) or http://localhost:8080 (production)
|
||||||
- API: http://localhost:8000
|
- API: http://localhost:8000
|
||||||
- API Documentation: http://localhost:8000/docs
|
- API Documentation: http://localhost:8000/docs
|
||||||
- Alternative Docs: http://localhost:8000/redoc
|
|
||||||
|
## Frontend Development vs Production
|
||||||
|
|
||||||
|
The frontend supports both development and production modes using Docker Compose profiles:
|
||||||
|
|
||||||
|
### Development Mode (Vite)
|
||||||
|
- Hot reloading and development features
|
||||||
|
- Access at: http://localhost:3500
|
||||||
|
- Start with: `docker-compose --profile dev up -d`
|
||||||
|
|
||||||
|
### Production Mode (Nginx)
|
||||||
|
- Optimized static files served by Nginx
|
||||||
|
- Access at: http://localhost:8080
|
||||||
|
- Start with: `docker-compose --profile prod up -d`
|
||||||
|
|
||||||
|
### Usage Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services in development mode
|
||||||
|
docker-compose --profile dev up -d
|
||||||
|
|
||||||
|
# Start all services in production mode
|
||||||
|
docker-compose --profile prod up -d
|
||||||
|
|
||||||
|
# Start only the frontend in development mode
|
||||||
|
docker-compose --profile dev up -d frontend
|
||||||
|
|
||||||
|
# Start only the frontend in production mode
|
||||||
|
docker-compose --profile prod up -d frontend-prod
|
||||||
|
|
||||||
|
# Stop all services
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Check which services are running
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Profile Details
|
||||||
|
|
||||||
|
- **`dev` profile**: Includes the `frontend` service (Vite dev server)
|
||||||
|
- **`prod` profile**: Includes the `frontend-prod` service (Nginx)
|
||||||
|
- **Default**: No frontend service runs unless you specify a profile
|
||||||
|
|
||||||
|
### For Production Deployment
|
||||||
|
|
||||||
|
When deploying to production with Caddy:
|
||||||
|
|
||||||
|
1. Start services in production mode:
|
||||||
|
```bash
|
||||||
|
docker-compose --profile prod up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure Caddy to proxy to `localhost:8080` for the frontend and `localhost:6000` for the API
|
||||||
|
|
||||||
## Default Credentials
|
## Default Credentials
|
||||||
|
|
||||||
|
|||||||
@@ -44,12 +44,13 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: development # Default to development
|
||||||
container_name: membership_frontend
|
container_name: membership_frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- VITE_HOST_CHECK=false
|
- VITE_HOST_CHECK=false
|
||||||
ports:
|
ports:
|
||||||
- "3500:3000" # Expose frontend to host
|
- "8050:3000" # Expose frontend to host
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/src:/app/src
|
- ./frontend/src:/app/src
|
||||||
- ./frontend/public:/app/public
|
- ./frontend/public:/app/public
|
||||||
@@ -58,6 +59,24 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
- membership_private # Access to backend on private network
|
- membership_private # Access to backend on private network
|
||||||
|
profiles:
|
||||||
|
- dev # Only run in development
|
||||||
|
|
||||||
|
frontend-prod:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: production
|
||||||
|
container_name: membership_frontend_prod
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8050:80" # Nginx default port
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- membership_private
|
||||||
|
profiles:
|
||||||
|
- prod # Only run in production
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
membership_private:
|
membership_private:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
FROM node:18-alpine
|
# Multi-stage Dockerfile for development and production
|
||||||
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -11,8 +12,24 @@ RUN npm install
|
|||||||
# Copy application files
|
# Copy application files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose port
|
# Development stage
|
||||||
|
FROM base AS development
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Start development server
|
|
||||||
CMD ["npm", "run", "dev"]
|
CMD ["npm", "run", "dev"]
|
||||||
|
|
||||||
|
# Production build stage
|
||||||
|
FROM base AS build
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage with Nginx
|
||||||
|
FROM nginx:alpine AS production
|
||||||
|
|
||||||
|
# Copy built files from build stage
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy custom nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
36
frontend/nginx.conf
Normal file
36
frontend/nginx.conf
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Handle client-side routing - serve index.html for all non-file requests
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/xml+rss
|
||||||
|
application/json;
|
||||||
|
}
|
||||||
@@ -154,7 +154,7 @@ body {
|
|||||||
padding: 40px;
|
padding: 40px;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 440px;
|
max-width: 900px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card h2 {
|
.auth-card h2 {
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ const Dashboard: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showMembershipSetup, setShowMembershipSetup] = useState(false);
|
const [showMembershipSetup, setShowMembershipSetup] = useState(false);
|
||||||
const [showProfileEdit, setShowProfileEdit] = useState(false);
|
const [showProfileEdit, setShowProfileEdit] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
|
const [showUserDetails, setShowUserDetails] = useState(false);
|
||||||
|
const [isEditingUser, setIsEditingUser] = useState(false);
|
||||||
|
const [editFormData, setEditFormData] = useState<Partial<User>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authService.isAuthenticated()) {
|
if (!authService.isAuthenticated()) {
|
||||||
@@ -107,7 +112,7 @@ const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
const handleUpdateUserRole = async (userId: number, newRole: string) => {
|
const handleUpdateUserRole = async (userId: number, newRole: string) => {
|
||||||
try {
|
try {
|
||||||
await userService.updateUserRole(userId, newRole);
|
await userService.updateUser(userId, { role: newRole });
|
||||||
// Reload data to reflect changes
|
// Reload data to reflect changes
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -130,6 +135,76 @@ const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredUsers = allUsers.filter(user => {
|
||||||
|
const fullName = `${user.first_name} ${user.last_name}`.toLowerCase();
|
||||||
|
const email = user.email.toLowerCase();
|
||||||
|
const search = searchTerm.toLowerCase();
|
||||||
|
return fullName.includes(search) || email.includes(search);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUserClick = (user: User) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setEditFormData({
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
email: user.email,
|
||||||
|
phone: user.phone || '',
|
||||||
|
address: user.address || ''
|
||||||
|
});
|
||||||
|
setShowUserDetails(true);
|
||||||
|
setIsEditingUser(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseUserDetails = () => {
|
||||||
|
setSelectedUser(null);
|
||||||
|
setShowUserDetails(false);
|
||||||
|
setIsEditingUser(false);
|
||||||
|
setEditFormData({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditUser = () => {
|
||||||
|
setIsEditingUser(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setIsEditingUser(false);
|
||||||
|
if (selectedUser) {
|
||||||
|
setEditFormData({
|
||||||
|
first_name: selectedUser.first_name,
|
||||||
|
last_name: selectedUser.last_name,
|
||||||
|
email: selectedUser.email,
|
||||||
|
phone: selectedUser.phone || '',
|
||||||
|
address: selectedUser.address || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveUser = async () => {
|
||||||
|
if (!selectedUser) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userService.updateUser(selectedUser.id, editFormData);
|
||||||
|
// Refresh data
|
||||||
|
await loadData();
|
||||||
|
setIsEditingUser(false);
|
||||||
|
// Update selected user with new data
|
||||||
|
const updatedUser = allUsers.find(u => u.id === selectedUser.id);
|
||||||
|
if (updatedUser) {
|
||||||
|
setSelectedUser(updatedUser);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update user:', error);
|
||||||
|
alert('Failed to update user. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormChange = (field: keyof User, value: string) => {
|
||||||
|
setEditFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString('en-GB', {
|
return new Date(dateString).toLocaleDateString('en-GB', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -345,6 +420,23 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="card" style={{ marginTop: '20px' }}>
|
<div className="card" style={{ marginTop: '20px' }}>
|
||||||
<h3 style={{ marginBottom: '16px' }}>User Management</h3>
|
<h3 style={{ marginBottom: '16px' }}>User Management</h3>
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search users by name or email..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||||
@@ -357,8 +449,12 @@ const Dashboard: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{allUsers.map(u => (
|
{filteredUsers.map(u => (
|
||||||
<tr key={u.id} style={{ borderBottom: '1px solid #eee' }}>
|
<tr
|
||||||
|
key={u.id}
|
||||||
|
style={{ borderBottom: '1px solid #eee', cursor: 'pointer' }}
|
||||||
|
onClick={() => handleUserClick(u)}
|
||||||
|
>
|
||||||
<td style={{ padding: '12px' }}>{u.first_name} {u.last_name}</td>
|
<td style={{ padding: '12px' }}>{u.first_name} {u.last_name}</td>
|
||||||
<td style={{ padding: '12px' }}>{u.email}</td>
|
<td style={{ padding: '12px' }}>{u.email}</td>
|
||||||
<td style={{ padding: '12px' }}>
|
<td style={{ padding: '12px' }}>
|
||||||
@@ -384,7 +480,10 @@ const Dashboard: React.FC = () => {
|
|||||||
{u.role === 'member' && (
|
{u.role === 'member' && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => handleUpdateUserRole(u.id, 'admin')}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Prevent row click
|
||||||
|
handleUpdateUserRole(u.id, 'admin');
|
||||||
|
}}
|
||||||
style={{ fontSize: '12px', padding: '4px 8px', marginRight: '4px' }}
|
style={{ fontSize: '12px', padding: '4px 8px', marginRight: '4px' }}
|
||||||
>
|
>
|
||||||
Make Admin
|
Make Admin
|
||||||
@@ -393,7 +492,10 @@ const Dashboard: React.FC = () => {
|
|||||||
{u.role === 'admin' && u.id !== user?.id && (
|
{u.role === 'admin' && u.id !== user?.id && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => handleUpdateUserRole(u.id, 'member')}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Prevent row click
|
||||||
|
handleUpdateUserRole(u.id, 'member');
|
||||||
|
}}
|
||||||
style={{ fontSize: '12px', padding: '4px 8px' }}
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||||
>
|
>
|
||||||
Remove Admin
|
Remove Admin
|
||||||
@@ -418,6 +520,251 @@ const Dashboard: React.FC = () => {
|
|||||||
onCancel={handleProfileCancel}
|
onCancel={handleProfileCancel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* User Details Modal */}
|
||||||
|
{showUserDetails && selectedUser && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 1000
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '24px',
|
||||||
|
maxWidth: '600px',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
width: '90%'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<h3 style={{ margin: 0 }}>User Details</h3>
|
||||||
|
<div>
|
||||||
|
{!isEditingUser && (user?.role === 'admin' || user?.role === 'super_admin') && (
|
||||||
|
<button
|
||||||
|
onClick={handleEditUser}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#007bff',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginRight: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleCloseUserDetails}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#666'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Profile */}
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<h4>Profile Information</h4>
|
||||||
|
{isEditingUser ? (
|
||||||
|
<div style={{ display: 'grid', gap: '12px' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<label style={{ fontWeight: 'bold' }}>First Name:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editFormData.first_name || ''}
|
||||||
|
onChange={(e) => handleFormChange('first_name', e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<label style={{ fontWeight: 'bold' }}>Last Name:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editFormData.last_name || ''}
|
||||||
|
onChange={(e) => handleFormChange('last_name', e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<label style={{ fontWeight: 'bold' }}>Email:</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={editFormData.email || ''}
|
||||||
|
onChange={(e) => handleFormChange('email', e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<label style={{ fontWeight: 'bold' }}>Phone:</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={editFormData.phone || ''}
|
||||||
|
onChange={(e) => handleFormChange('phone', e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<label style={{ fontWeight: 'bold' }}>Address:</label>
|
||||||
|
<textarea
|
||||||
|
value={editFormData.address || ''}
|
||||||
|
onChange={(e) => handleFormChange('address', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
padding: '6px 8px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px',
|
||||||
|
resize: 'vertical'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveUser}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#28a745',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p><strong>Name:</strong> {selectedUser.first_name} {selectedUser.last_name}</p>
|
||||||
|
<p><strong>Email:</strong> {selectedUser.email}</p>
|
||||||
|
{selectedUser.phone && <p><strong>Phone:</strong> {selectedUser.phone}</p>}
|
||||||
|
{selectedUser.address && <p><strong>Address:</strong> {selectedUser.address}</p>}
|
||||||
|
<p><strong>Role:</strong> {selectedUser.role.toUpperCase()}</p>
|
||||||
|
<p><strong>Status:</strong> {selectedUser.is_active ? 'Active' : 'Inactive'}</p>
|
||||||
|
<p><strong>Joined:</strong> {formatDate(selectedUser.created_at)}</p>
|
||||||
|
{selectedUser.last_login && <p><strong>Last Login:</strong> {formatDate(selectedUser.last_login)}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Memberships */}
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<h4>Memberships</h4>
|
||||||
|
{allMemberships.filter(m => m.user_id === selectedUser.id).length > 0 ? (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid #ddd' }}>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'left' }}>Tier</th>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'left' }}>Status</th>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'left' }}>Start Date</th>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'left' }}>End Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{allMemberships.filter(m => m.user_id === selectedUser.id).map(membership => (
|
||||||
|
<tr key={membership.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||||
|
<td style={{ padding: '8px' }}>{membership.tier.name}</td>
|
||||||
|
<td style={{ padding: '8px' }}>
|
||||||
|
<span className={`status-badge ${getStatusClass(membership.status)}`}>
|
||||||
|
{membership.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px' }}>{formatDate(membership.start_date)}</td>
|
||||||
|
<td style={{ padding: '8px' }}>{formatDate(membership.end_date)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: '#666' }}>No memberships found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Payments */}
|
||||||
|
<div>
|
||||||
|
<h4>Payment History</h4>
|
||||||
|
{allPayments.filter(p => p.user_id === selectedUser.id).length > 0 ? (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid #ddd' }}>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'left' }}>Date</th>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'left' }}>Amount</th>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'left' }}>Method</th>
|
||||||
|
<th style={{ padding: '8px', textAlign: 'left' }}>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{allPayments.filter(p => p.user_id === selectedUser.id).map(payment => (
|
||||||
|
<tr key={payment.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||||
|
<td style={{ padding: '8px' }}>{payment.payment_date ? formatDate(payment.payment_date) : 'Pending'}</td>
|
||||||
|
<td style={{ padding: '8px' }}>£{payment.amount.toFixed(2)}</td>
|
||||||
|
<td style={{ padding: '8px' }}>{payment.payment_method}</td>
|
||||||
|
<td style={{ padding: '8px' }}>
|
||||||
|
<span className={`status-badge ${getStatusClass(payment.status)}`}>
|
||||||
|
{payment.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: '#666' }}>No payment history found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const Register: React.FC = () => {
|
|||||||
phone: '',
|
phone: '',
|
||||||
address: ''
|
address: ''
|
||||||
});
|
});
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [passwordsMatch, setPasswordsMatch] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -22,15 +24,41 @@ const Register: React.FC = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
if (name === 'password') {
|
||||||
|
setFormData(prev => ({ ...prev, password: value }));
|
||||||
|
setPasswordsMatch(value === confirmPassword);
|
||||||
|
} else if (name === 'confirmPassword') {
|
||||||
|
setConfirmPassword(value);
|
||||||
|
setPasswordsMatch(formData.password === value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
|
// Validate password confirmation
|
||||||
|
if (formData.password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Register the user
|
||||||
await authService.register(formData);
|
await authService.register(formData);
|
||||||
alert('Registration successful! Please check your email. You can now log in.');
|
|
||||||
navigate('/login');
|
// Automatically log in the user
|
||||||
|
await authService.login({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to dashboard
|
||||||
|
navigate('/dashboard');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || 'Registration failed. Please try again.');
|
setError(err.response?.data?.detail || 'Registration failed. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -48,89 +76,127 @@ const Register: React.FC = () => {
|
|||||||
|
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '40px', maxWidth: '900px', margin: '0 auto' }}>
|
||||||
<div className="form-group">
|
{/* Left Column - Personal Information */}
|
||||||
<label htmlFor="first_name">First Name *</label>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
<input
|
<div className="form-group">
|
||||||
type="text"
|
<label htmlFor="first_name">First Name *</label>
|
||||||
id="first_name"
|
<input
|
||||||
name="first_name"
|
type="text"
|
||||||
value={formData.first_name}
|
id="first_name"
|
||||||
onChange={handleChange}
|
name="first_name"
|
||||||
required
|
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={handlePasswordChange}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small style={{ color: '#666', fontSize: '12px' }}>
|
||||||
|
Minimum 8 characters
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="confirmPassword">Confirm Password *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
borderColor: confirmPassword && !passwordsMatch ? '#dc3545' : confirmPassword && passwordsMatch ? '#28a745' : undefined
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{confirmPassword && (
|
||||||
|
<small style={{
|
||||||
|
color: passwordsMatch ? '#28a745' : '#dc3545',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
{passwordsMatch ? '✓ Passwords match' : '✗ Passwords do not match'}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
{!confirmPassword && (
|
||||||
|
<small style={{ color: '#666', fontSize: '12px' }}>
|
||||||
|
Re-enter your password
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
{/* Right Column - Contact Information */}
|
||||||
<label htmlFor="last_name">Last Name *</label>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
<input
|
<div className="form-group">
|
||||||
type="text"
|
<label htmlFor="phone">Phone (optional)</label>
|
||||||
id="last_name"
|
<input
|
||||||
name="last_name"
|
type="tel"
|
||||||
value={formData.last_name}
|
id="phone"
|
||||||
onChange={handleChange}
|
name="phone"
|
||||||
required
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
{/* Submit Button - Full Width */}
|
||||||
<label htmlFor="email">Email Address *</label>
|
<div style={{ gridColumn: '1 / -1', marginTop: '8px' }}>
|
||||||
<input
|
<button
|
||||||
type="email"
|
type="submit"
|
||||||
id="email"
|
className="btn btn-primary"
|
||||||
name="email"
|
disabled={loading}
|
||||||
value={formData.email}
|
style={{ width: '100%' }}
|
||||||
onChange={handleChange}
|
>
|
||||||
required
|
{loading ? 'Creating Account...' : 'Create Account & Sign In'}
|
||||||
/>
|
</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
|
|
||||||
<div className="form-footer">
|
<div className="form-footer">
|
||||||
|
|||||||
@@ -171,10 +171,10 @@ export const userService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateUserRole(userId: number, role: string): Promise<User> {
|
async updateUser(userId: number, data: Partial<User>): Promise<User> {
|
||||||
const response = await api.put(`/users/${userId}`, { role });
|
const response = await api.put(`/users/${userId}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const membershipService = {
|
export const membershipService = {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default defineConfig({
|
|||||||
host: true,
|
host: true,
|
||||||
port: 3000,
|
port: 3000,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
allowedHosts: ['sasaprod', 'localhost'],
|
allowedHosts: ['sasaprod', 'localhost', 'members.sasalliance.org'],
|
||||||
watch: {
|
watch: {
|
||||||
usePolling: true
|
usePolling: true
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user