Compare commits

..

3 Commits

Author SHA1 Message Date
James Pattinson
d42b7cb307 Prod/Dev mode 2025-11-11 16:59:58 +00:00
James Pattinson
d173b13bb9 reg workflow improvements 2025-11-10 19:55:29 +00:00
James Pattinson
ba21262854 User search and edit 2025-11-10 19:35:37 +00:00
9 changed files with 641 additions and 99 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,7 +76,9 @@ 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' }}>
{/* Left Column - Personal Information */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="form-group"> <div className="form-group">
<label htmlFor="first_name">First Name *</label> <label htmlFor="first_name">First Name *</label>
<input <input
@@ -92,7 +122,7 @@ const Register: React.FC = () => {
id="password" id="password"
name="password" name="password"
value={formData.password} value={formData.password}
onChange={handleChange} onChange={handlePasswordChange}
minLength={8} minLength={8}
required required
/> />
@@ -101,6 +131,38 @@ const Register: React.FC = () => {
</small> </small>
</div> </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>
{/* Right Column - Contact Information */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div className="form-group"> <div className="form-group">
<label htmlFor="phone">Phone (optional)</label> <label htmlFor="phone">Phone (optional)</label>
<input <input
@@ -122,15 +184,19 @@ const Register: React.FC = () => {
rows={3} rows={3}
/> />
</div> </div>
</div>
{/* Submit Button - Full Width */}
<div style={{ gridColumn: '1 / -1', marginTop: '8px' }}>
<button <button
type="submit" type="submit"
className="btn btn-primary" className="btn btn-primary"
disabled={loading} disabled={loading}
style={{ width: '100%', marginTop: '16px' }} style={{ width: '100%' }}
> >
{loading ? 'Creating Account...' : 'Create Account'} {loading ? 'Creating Account...' : 'Create Account & Sign In'}
</button> </button>
</div>
</form> </form>
<div className="form-footer"> <div className="form-footer">

View File

@@ -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 = {

View File

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