9 Commits

6 changed files with 18 additions and 2613 deletions

View File

@@ -1,3 +1,3 @@
REACT_APP_BACKEND_URL=http://localhost:8000
REACT_APP_BASENAME=/
PUBLIC_URL=/
REACT_APP_BASENAME=/membership
PUBLIC_URL=/membership

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,6 @@ const Login = () => {
const location = useLocation();
const [searchParams] = useSearchParams();
const { login } = useAuth();
const basename = process.env.REACT_APP_BASENAME || '';
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
email: '',
@@ -32,20 +31,20 @@ const Login = () => {
toast.info('Your session has expired. Please log in again.', {
duration: 5000,
});
// Clean up URL (respect basename for subpath deployments)
window.history.replaceState({}, '', `${basename}/login`);
// Clean up URL
window.history.replaceState({}, '', '/login');
} else if (sessionParam === 'idle') {
toast.info('You were logged out due to inactivity. Please log in again.', {
duration: 5000,
});
// Clean up URL (respect basename for subpath deployments)
window.history.replaceState({}, '', `${basename}/login`);
// Clean up URL
window.history.replaceState({}, '', '/login');
} else if (stateMessage) {
toast.info(stateMessage, {
duration: 5000,
});
}
}, [searchParams, location.state, basename]);
}, [searchParams, location.state]);
const handleInputChange = (e) => {
const { name, value } = e.target;

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import React, { useState } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api';
@@ -6,24 +6,19 @@ import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import { Checkbox } from '../../components/ui/checkbox';
import { Badge } from '../../components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '../../components/ui/dropdown-menu';
import { toast } from 'sonner';
import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown, CircleMinus, KeyRound, Loader2, X } from 'lucide-react';
import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown, CircleMinus } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import CreateMemberDialog from '../../components/CreateMemberDialog';
import InviteMemberDialog from '../../components/InviteMemberDialog';
import WordPressImportWizard from '../../components/WordPressImportWizard';
import ComprehensiveImportWizard from '../../components/ComprehensiveImportWizard';
import TemplateImportWizard from '../../components/TemplateImportWizard';
import StatusBadge from '../../components/StatusBadge';
import { StatCard } from '@/components/StatCard';
import { useMembers } from '../../hooks/use-users';
@@ -50,158 +45,8 @@ const AdminMembers = () => {
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [comprehensiveImportOpen, setComprehensiveImportOpen] = useState(false);
const [templateImportOpen, setTemplateImportOpen] = useState(false);
const [exporting, setExporting] = useState(false);
// Bulk selection state
const [selectedUsers, setSelectedUsers] = useState(new Set());
const [bulkActionLoading, setBulkActionLoading] = useState(false);
const [bulkPasswordResetOpen, setBulkPasswordResetOpen] = useState(false);
// Import job filter state
const [importJobs, setImportJobs] = useState([]);
const [selectedImportJob, setSelectedImportJob] = useState(null);
const [importJobLoading, setImportJobLoading] = useState(false);
// Fetch import jobs on mount
useEffect(() => {
const fetchImportJobs = async () => {
try {
const response = await api.get('/admin/users/import-jobs');
// Filter to only show completed/partial jobs that have users
const jobsWithUsers = response.data.filter(
(job) => job.successful_rows > 0 && ['completed', 'partial'].includes(job.status)
);
setImportJobs(jobsWithUsers);
} catch (error) {
console.error('Failed to fetch import jobs:', error);
}
};
if (hasPermission('users.import')) {
fetchImportJobs();
}
}, [hasPermission]);
// Select all users from an import job
const selectUsersFromImportJob = useCallback(async (jobId) => {
if (!jobId) {
setSelectedImportJob(null);
return;
}
setImportJobLoading(true);
setSelectedImportJob(jobId);
try {
// Get import job details to get the user IDs
const response = await api.get(`/admin/users/import-jobs/${jobId}`);
const importedUserIds = response.data.imported_user_ids || [];
if (importedUserIds.length === 0) {
toast.info('No users found in this import job');
setSelectedImportJob(null);
return;
}
// Filter to only select users that are currently visible in filteredUsers
const visibleImportedUsers = filteredUsers.filter((user) =>
importedUserIds.includes(user.id)
);
if (visibleImportedUsers.length === 0) {
// If no visible users match, select from all users
const allImportedUsers = users.filter((user) =>
importedUserIds.includes(user.id)
);
if (allImportedUsers.length > 0) {
setSelectedUsers(new Set(allImportedUsers.map((u) => u.id)));
toast.success(
`Selected ${allImportedUsers.length} users from import job (some may be hidden by current filters)`
);
} else {
toast.info('No users from this import job found');
}
} else {
setSelectedUsers(new Set(visibleImportedUsers.map((u) => u.id)));
toast.success(`Selected ${visibleImportedUsers.length} users from import job`);
}
} catch (error) {
const message = error.response?.data?.detail || 'Failed to load import job';
toast.error(message);
setSelectedImportJob(null);
} finally {
setImportJobLoading(false);
}
}, [filteredUsers, users]);
// Check if all visible users are selected
const allSelected = useMemo(() => {
if (!filteredUsers || filteredUsers.length === 0) return false;
return filteredUsers.every((user) => selectedUsers.has(user.id));
}, [filteredUsers, selectedUsers]);
// Toggle single user selection
const toggleUserSelection = (userId) => {
setSelectedUsers((prev) => {
const newSet = new Set(prev);
if (newSet.has(userId)) {
newSet.delete(userId);
} else {
newSet.add(userId);
}
return newSet;
});
};
// Toggle all visible users
const toggleAllUsers = () => {
if (allSelected) {
setSelectedUsers(new Set());
} else {
setSelectedUsers(new Set(filteredUsers.map((user) => user.id)));
}
};
// Clear selection
const clearSelection = () => {
setSelectedUsers(new Set());
};
// Handle bulk password reset
const handleBulkPasswordReset = async () => {
if (selectedUsers.size === 0) {
toast.error('No users selected');
return;
}
setBulkActionLoading(true);
try {
const response = await api.post('/admin/users/bulk-password-reset', {
user_ids: Array.from(selectedUsers),
send_email: true,
});
toast.success(
`Password reset emails sent to ${response.data.successful} users`
);
if (response.data.failed > 0) {
toast.warning(`${response.data.failed} emails failed to send`);
}
setBulkPasswordResetOpen(false);
clearSelection();
} catch (error) {
const message = error.response?.data?.detail || 'Failed to send password reset emails';
toast.error(message);
} finally {
setBulkActionLoading(false);
}
};
const handleActivatePayment = (user) => {
setSelectedUserForPayment(user);
setPaymentDialogOpen(true);
@@ -387,54 +232,13 @@ const AdminMembers = () => {
)}
{hasPermission('users.import') && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="btn-util-green">
<Upload className="h-5 w-5 mr-2" />
Import
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuItem
onClick={() => setTemplateImportOpen(true)}
className="cursor-pointer"
>
<FileDown className="h-4 w-4 mr-2" />
<div>
<span className="font-medium">Template Import (Recommended)</span>
<p className="text-xs text-muted-foreground">
Download templates, fill your data, upload
</p>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setComprehensiveImportOpen(true)}
className="cursor-pointer"
>
<Upload className="h-4 w-4 mr-2" />
<div>
<span className="font-medium">WordPress Import</span>
<p className="text-xs text-muted-foreground">
For WordPress/PMS exports
</p>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setImportDialogOpen(true)}
className="cursor-pointer"
>
<Users className="h-4 w-4 mr-2" />
<div>
<span className="font-medium">Basic User Import</span>
<p className="text-xs text-muted-foreground">
Simple WordPress users CSV
</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
onClick={() => setImportDialogOpen(true)}
className="btn-util-green "
>
<Upload className="h-5 w-5 mr-2" />
Import
</Button>
)}
{hasPermission('users.invite') && (
@@ -527,102 +331,6 @@ const AdminMembers = () => {
</div>
</Card>
{/* Import Job Quick Select */}
{hasPermission('users.import') && importJobs.length > 0 && (
<Card className="p-4 mb-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<Upload className="h-5 w-5 text-brand-purple" />
<span
className="font-semibold text-brand-purple"
style={{ fontFamily: "'Inter', sans-serif" }}
>
Quick Select from Import
</span>
</div>
<div className="flex items-center gap-3">
<Select
value={selectedImportJob || ''}
onValueChange={(value) => selectUsersFromImportJob(value || null)}
>
<SelectTrigger className="w-64 h-10 bg-white border-[var(--neutral-800)]">
<SelectValue placeholder="Select an import job..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="">-- None --</SelectItem>
{importJobs.map((job) => (
<SelectItem key={job.id} value={job.id}>
<div className="flex flex-col">
<span className="font-medium">
{job.filename || 'Import'} ({job.successful_rows} users)
</span>
<span className="text-xs text-muted-foreground">
{new Date(job.started_at).toLocaleDateString()}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{importJobLoading && (
<Loader2 className="h-5 w-5 animate-spin text-brand-purple" />
)}
</div>
</div>
</Card>
)}
{/* Bulk Action Bar */}
{selectedUsers.size > 0 && (
<Card className="p-4 mb-4 bg-brand-purple text-white rounded-xl sticky top-4 z-10 shadow-lg">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3">
<span className="font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
{selectedUsers.size} user{selectedUsers.size !== 1 ? 's' : ''} selected
{selectedImportJob && (
<span className="ml-2 text-sm font-normal opacity-80">
(from import job)
</span>
)}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
clearSelection();
setSelectedImportJob(null);
}}
className="text-white hover:bg-white/20"
>
<X className="h-4 w-4 mr-1" />
Clear
</Button>
</div>
<div className="flex items-center gap-2">
{hasPermission('users.reset_password') && (
<Button
variant="secondary"
size="sm"
onClick={() => setBulkPasswordResetOpen(true)}
disabled={bulkActionLoading}
className="bg-white text-brand-purple hover:bg-white/90"
>
{bulkActionLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<KeyRound className="h-4 w-4 mr-2" />
)}
Send Password Reset
</Button>
)}
</div>
</div>
</Card>
)}
{/* Members List */}
{loading ? (
<div className="text-center py-20">
@@ -630,50 +338,17 @@ const AdminMembers = () => {
</div>
) : filteredUsers.length > 0 ? (
<div className="space-y-4">
{/* Select All Row */}
{filteredUsers.length > 0 && hasPermission('users.reset_password') && (
<div className="flex items-center gap-3 px-2">
<Checkbox
id="select-all"
checked={allSelected}
onCheckedChange={toggleAllUsers}
/>
<label
htmlFor="select-all"
className="text-sm text-brand-purple cursor-pointer"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Select all ({filteredUsers.length} users)
</label>
</div>
)}
{filteredUsers.map((user) => {
const joinedDate = user.created_at;
const memberDate = user.member_since;
return (
<Card
key={user.id}
className={`p-6 bg-background rounded-2xl border hover:shadow-md transition-shadow ${
selectedUsers.has(user.id)
? 'border-brand-purple bg-[var(--lavender-500)]'
: 'border-[var(--neutral-800)]'
}`}
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-md transition-shadow"
data-testid={`member-card-${user.id}`}
>
<div className="flex justify-between items-start flex-wrap gap-4">
<div className="flex items-start gap-4 flex-1">
{/* Selection Checkbox */}
{hasPermission('users.reset_password') && (
<div className="flex items-center pt-1">
<Checkbox
checked={selectedUsers.has(user.id)}
onCheckedChange={() => toggleUserSelection(user.id)}
aria-label={`Select ${user.first_name} ${user.last_name}`}
/>
</div>
)}
{/* Avatar */}
<div className="h-14 w-14 rounded-full bg-[var(--neutral-800)] flex items-center justify-center text-[var(--purple-ink)] font-semibold text-lg flex-shrink-0">
{user.first_name?.[0]}{user.last_name?.[0]}
@@ -857,50 +532,6 @@ const AdminMembers = () => {
onOpenChange={setImportDialogOpen}
onSuccess={refetch}
/>
<ComprehensiveImportWizard
open={comprehensiveImportOpen}
onOpenChange={setComprehensiveImportOpen}
onSuccess={refetch}
/>
<TemplateImportWizard
open={templateImportOpen}
onOpenChange={setTemplateImportOpen}
onSuccess={refetch}
/>
{/* Bulk Password Reset Confirmation Dialog */}
<ConfirmationDialog
open={bulkPasswordResetOpen}
onOpenChange={setBulkPasswordResetOpen}
onConfirm={handleBulkPasswordReset}
title="Send Password Reset Emails"
description={
<div className="space-y-4">
<p>
You are about to send password reset emails to{' '}
<strong>{selectedUsers.size} user{selectedUsers.size !== 1 ? 's' : ''}</strong>.
</p>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>What happens:</strong>
</p>
<ul className="text-sm text-blue-700 mt-2 space-y-1 list-disc list-inside">
<li>Each user will receive an email with a password reset link</li>
<li>The <code className="bg-blue-100 px-1 rounded">force_password_change</code> flag will be set</li>
<li>Users must set a new password on their next login</li>
</ul>
</div>
<p className="text-sm text-muted-foreground">
This action cannot be undone. Continue?
</p>
</div>
}
confirmText={bulkActionLoading ? 'Sending...' : 'Send Emails'}
variant="info"
loading={bulkActionLoading}
/>
</>
);
};

View File

@@ -1,7 +1,6 @@
import axios from 'axios';
const API_URL = process.env.REACT_APP_BACKEND_URL;
const BASENAME = process.env.REACT_APP_BASENAME || '';
export const api = axios.create({
baseURL: `${API_URL}/api`,
@@ -49,11 +48,9 @@ api.interceptors.response.use(
// Redirect to login with session expired message
// Use replace to prevent back button issues
// Include BASENAME to support subpath deployments
const currentPath = window.location.pathname;
const loginPath = `${BASENAME}/login?session=expired`;
if (!currentPath.includes('/login')) {
window.location.replace(loginPath);
window.location.replace('/login?session=expired');
}
}