diff --git a/.env.development b/.env.development
index 6ec835a..3886372 100644
--- a/.env.development
+++ b/.env.development
@@ -1,3 +1,3 @@
REACT_APP_BACKEND_URL=http://localhost:8000
-REACT_APP_BASENAME=/membership
-PUBLIC_URL=/membership
+REACT_APP_BASENAME=/
+PUBLIC_URL=/
diff --git a/src/components/ComprehensiveImportWizard.js b/src/components/ComprehensiveImportWizard.js
new file mode 100644
index 0000000..d1b985e
--- /dev/null
+++ b/src/components/ComprehensiveImportWizard.js
@@ -0,0 +1,1138 @@
+import React, { useState, useCallback } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from './ui/dialog';
+import { Button } from './ui/button';
+import { Card } from './ui/card';
+import { Label } from './ui/label';
+import { Checkbox } from './ui/checkbox';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from './ui/select';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from './ui/table';
+import {
+ Upload,
+ FileSpreadsheet,
+ Users,
+ CreditCard,
+ Receipt,
+ CheckCircle2,
+ XCircle,
+ AlertTriangle,
+ Loader2,
+ ChevronLeft,
+ ChevronRight,
+ Download,
+ RotateCcw,
+ Mail,
+ MailX,
+ Info,
+} from 'lucide-react';
+import { toast } from 'sonner';
+import api from '../utils/api';
+
+const STEPS = [
+ { id: 'upload', title: 'Upload Files', icon: Upload },
+ { id: 'preview', title: 'Preview Data', icon: FileSpreadsheet },
+ { id: 'options', title: 'Import Options', icon: Users },
+ { id: 'execute', title: 'Execute Import', icon: CheckCircle2 },
+];
+
+const STATUS_COLORS = {
+ active: 'bg-green-100 text-green-800',
+ pre_validated: 'bg-blue-100 text-blue-800',
+ payment_pending: 'bg-yellow-100 text-yellow-800',
+ pending_validation: 'bg-purple-100 text-purple-800',
+ inactive: 'bg-gray-100 text-gray-800',
+ expired: 'bg-red-100 text-red-800',
+};
+
+const ROLE_COLORS = {
+ superadmin: 'bg-red-100 text-red-800',
+ admin: 'bg-orange-100 text-orange-800',
+ finance: 'bg-blue-100 text-blue-800',
+ member: 'bg-green-100 text-green-800',
+ guest: 'bg-gray-100 text-gray-800',
+};
+
+/**
+ * ComprehensiveImportWizard - Multi-file WordPress import wizard
+ *
+ * Supports importing:
+ * - WordPress Users CSV (required)
+ * - PMS Members CSV (optional) - subscription data
+ * - PMS Payments CSV (optional) - payment history
+ */
+const ComprehensiveImportWizard = ({ open, onOpenChange, onSuccess }) => {
+ const [currentStep, setCurrentStep] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [importJobId, setImportJobId] = useState(null);
+
+ // File upload state
+ const [usersFile, setUsersFile] = useState(null);
+ const [membersFile, setMembersFile] = useState(null);
+ const [paymentsFile, setPaymentsFile] = useState(null);
+
+ // Preview state
+ const [uploadSummary, setUploadSummary] = useState(null);
+ const [previewData, setPreviewData] = useState([]);
+ const [previewPage, setPreviewPage] = useState(1);
+ const [previewTotalPages, setPreviewTotalPages] = useState(1);
+ const [filterErrors, setFilterErrors] = useState(false);
+ const [filterWarnings, setFilterWarnings] = useState(false);
+
+ // Override state
+ const [overrides, setOverrides] = useState({});
+
+ // Import options
+ const [options, setOptions] = useState({
+ skip_notifications: true,
+ send_welcome_emails: false,
+ send_password_emails: false,
+ import_subscriptions: true,
+ import_payment_history: true,
+ skip_errors: true,
+ });
+
+ // Results state
+ const [importResults, setImportResults] = useState(null);
+
+ // Reset wizard state
+ const resetWizard = useCallback(() => {
+ setCurrentStep(0);
+ setLoading(false);
+ setImportJobId(null);
+ setUsersFile(null);
+ setMembersFile(null);
+ setPaymentsFile(null);
+ setUploadSummary(null);
+ setPreviewData([]);
+ setPreviewPage(1);
+ setPreviewTotalPages(1);
+ setFilterErrors(false);
+ setFilterWarnings(false);
+ setOverrides({});
+ setOptions({
+ skip_notifications: true,
+ send_welcome_emails: false,
+ send_password_emails: false,
+ import_subscriptions: true,
+ import_payment_history: true,
+ skip_errors: true,
+ });
+ setImportResults(null);
+ }, []);
+
+ // Handle dialog close
+ const handleClose = () => {
+ if (loading) return;
+ resetWizard();
+ onOpenChange(false);
+ };
+
+ // Step 1: Upload files
+ const handleUpload = async () => {
+ if (!usersFile) {
+ toast.error('Please select a WordPress Users CSV file');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const formData = new FormData();
+ formData.append('users_file', usersFile);
+ if (membersFile) {
+ formData.append('members_file', membersFile);
+ }
+ if (paymentsFile) {
+ formData.append('payments_file', paymentsFile);
+ }
+
+ const response = await api.post('/admin/import/comprehensive/upload', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ timeout: 120000, // 2 minutes for large file uploads + R2 storage
+ });
+
+ setImportJobId(response.data.import_job_id);
+ setUploadSummary(response.data);
+
+ // Fetch preview data
+ await fetchPreview(response.data.import_job_id, 1);
+
+ toast.success('Files uploaded and analyzed successfully');
+ setCurrentStep(1);
+ } catch (error) {
+ console.error('[ComprehensiveImport] Upload failed:', error);
+ const message = error.response?.data?.detail ||
+ error.message ||
+ 'Failed to upload files';
+ toast.error(message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Fetch preview data
+ const fetchPreview = async (jobId, page) => {
+ try {
+ const response = await api.get(`/admin/import/comprehensive/${jobId}/preview`, {
+ params: {
+ page,
+ page_size: 20,
+ filter_errors: filterErrors,
+ filter_warnings: filterWarnings,
+ },
+ });
+
+ setPreviewData(response.data.rows);
+ setPreviewPage(response.data.page);
+ setPreviewTotalPages(response.data.total_pages);
+ } catch (error) {
+ toast.error('Failed to load preview data');
+ }
+ };
+
+ // Handle page change
+ const handlePageChange = async (newPage) => {
+ if (newPage < 1 || newPage > previewTotalPages) return;
+ setLoading(true);
+ await fetchPreview(importJobId, newPage);
+ setLoading(false);
+ };
+
+ // Handle filter change
+ const handleFilterChange = async () => {
+ if (!importJobId) return;
+ setLoading(true);
+ await fetchPreview(importJobId, 1);
+ setLoading(false);
+ };
+
+ // Handle override change
+ const handleOverride = (rowNum, field, value) => {
+ setOverrides((prev) => ({
+ ...prev,
+ [rowNum]: {
+ ...prev[rowNum],
+ [field]: value,
+ },
+ }));
+ };
+
+ // Execute import
+ const handleExecute = async () => {
+ if (!importJobId) return;
+
+ setLoading(true);
+ try {
+ const response = await api.post(`/admin/import/comprehensive/${importJobId}/execute`, {
+ overrides,
+ options,
+ });
+
+ setImportResults(response.data);
+ toast.success(
+ `Import completed: ${response.data.successful_rows} users imported`
+ );
+ setCurrentStep(3);
+ } catch (error) {
+ const message = error.response?.data?.detail || 'Import failed';
+ toast.error(message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Render step content
+ const renderStepContent = () => {
+ switch (currentStep) {
+ case 0:
+ return renderUploadStep();
+ case 1:
+ return renderPreviewStep();
+ case 2:
+ return renderOptionsStep();
+ case 3:
+ return renderResultsStep();
+ default:
+ return null;
+ }
+ };
+
+ // Step 1: Upload
+ const renderUploadStep = () => (
+
+
+
+ Upload your WordPress export CSV files. The Users file is required,
+ while Members and Payments files are optional but recommended for
+ complete data migration.
+
+
+
+ {/* Users File (Required) */}
+
+
+
+
+
+
+
+ WordPress Users CSV{' '}
+ *Required
+
+
+ User profiles, contact info, directory settings, newsletter
+ preferences
+
+
setUsersFile(e.target.files[0])}
+ className="block w-full text-sm text-brand-purple file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-purple file:text-white hover:file:bg-[var(--purple-ink)] cursor-pointer"
+ />
+ {usersFile && (
+
+
+ {usersFile.name}
+
+ )}
+
+
+
+
+ {/* Members File (Optional) */}
+
+
+
+
+
+
+
+ PMS Members CSV{' '}
+ Optional
+
+
+ Subscription plans, start/end dates, membership status
+
+
setMembersFile(e.target.files[0])}
+ className="block w-full text-sm text-brand-purple file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-[var(--lavender-500)] file:text-brand-purple hover:file:bg-[var(--lavender-300)] cursor-pointer"
+ />
+ {membersFile && (
+
+
+ {membersFile.name}
+
+ )}
+
+
+
+
+ {/* Payments File (Optional) */}
+
+
+
+
+
+
+
+ PMS Payments CSV{' '}
+ Optional
+
+
+ Payment history, transaction records, amounts paid
+
+
setPaymentsFile(e.target.files[0])}
+ className="block w-full text-sm text-brand-purple file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-[var(--lavender-500)] file:text-brand-purple hover:file:bg-[var(--lavender-300)] cursor-pointer"
+ />
+ {paymentsFile && (
+
+
+ {paymentsFile.name}
+
+ )}
+
+
+
+
+ );
+
+ // Step 2: Preview
+ const renderPreviewStep = () => (
+
+ {/* Summary Cards */}
+ {uploadSummary && (
+
+
+
+
+
+ Users
+
+
+
+ {uploadSummary.users?.valid || 0}
+
+ {' '}
+ / {uploadSummary.users?.total || 0}
+
+
+
+ {uploadSummary.users?.warnings || 0} warnings,{' '}
+ {uploadSummary.users?.errors || 0} errors
+
+
+
+
+
+
+
+ Subscriptions
+
+
+
+ {uploadSummary.members?.matched || 0}
+
+ {' '}
+ / {uploadSummary.members?.total || 0}
+
+
+
+ {uploadSummary.members?.unmatched || 0} unmatched
+
+
+
+
+
+
+
+ Payments
+
+
+
+ {uploadSummary.payments?.matched || 0}
+
+
+ $
+ {(
+ (uploadSummary.payments?.total_amount_cents || 0) / 100
+ ).toLocaleString()}{' '}
+ total
+
+
+
+ )}
+
+ {/* Filters */}
+
+
+ {
+ setFilterErrors(checked);
+ setTimeout(handleFilterChange, 0);
+ }}
+ />
+
+ Show only errors
+
+
+
+ {
+ setFilterWarnings(checked);
+ setTimeout(handleFilterChange, 0);
+ }}
+ />
+
+ Show only warnings
+
+
+
+
+ {/* Preview Table */}
+
+
+
+
+
+ Row
+
+
+ Email
+
+
+ Name
+
+
+ Role
+
+
+ Status
+
+
+ Subscription
+
+
+ Issues
+
+
+
+
+ {previewData.map((row) => (
+ 0
+ ? 'bg-red-50'
+ : row.warnings?.length > 0
+ ? 'bg-yellow-50'
+ : ''
+ }
+ >
+
+ {row.row_number}
+
+ {row.email}
+
+ {row.first_name} {row.last_name}
+
+
+
+ handleOverride(row.row_number, 'role', value)
+ }
+ >
+
+
+
+
+ Superadmin
+ Admin
+ Finance
+ Member
+ Guest
+
+
+
+
+
+ handleOverride(row.row_number, 'status', value)
+ }
+ >
+
+
+
+
+ Active
+ Pre-validated
+ Payment Pending
+
+ Pending Validation
+
+ Inactive
+
+
+
+
+ {row.has_subscription ? (
+
+ Yes
+
+ ) : (
+
+ No
+
+ )}
+
+
+
+ {row.errors?.length > 0 && (
+
+
+
+ )}
+ {row.warnings?.length > 0 && (
+
+
+
+ )}
+ {!row.errors?.length && !row.warnings?.length && (
+
+ )}
+
+
+
+ ))}
+
+
+
+
+ {/* Pagination */}
+
+
+ Page {previewPage} of {previewTotalPages}
+
+
+ handlePageChange(previewPage - 1)}
+ disabled={previewPage <= 1 || loading}
+ >
+
+
+ handlePageChange(previewPage + 1)}
+ disabled={previewPage >= previewTotalPages || loading}
+ >
+
+
+
+
+
+ );
+
+ // Step 3: Options
+ const renderOptionsStep = () => (
+
+ {/* Notification Options */}
+
+
+
+ Email Notifications
+
+
+
+
+
+ setOptions((prev) => ({
+ ...prev,
+ skip_notifications: checked,
+ send_welcome_emails: checked ? false : prev.send_welcome_emails,
+ send_password_emails: checked
+ ? false
+ : prev.send_password_emails,
+ }))
+ }
+ />
+
+
+
+ Skip all email notifications (Recommended)
+
+
+ No emails will be sent to imported users. You can send a bulk
+ password reset later from the admin panel.
+
+
+
+
+ {!options.skip_notifications && (
+ <>
+
+
+ setOptions((prev) => ({
+ ...prev,
+ send_welcome_emails: checked,
+ }))
+ }
+ />
+
+ Send welcome emails to imported users
+
+
+
+
+
+ setOptions((prev) => ({
+ ...prev,
+ send_password_emails: checked,
+ }))
+ }
+ />
+
+ Send password reset emails to imported users
+
+
+ >
+ )}
+
+
+
+ {/* Data Import Options */}
+
+
+
+ Data Import Options
+
+
+
+
+
+ setOptions((prev) => ({
+ ...prev,
+ import_subscriptions: checked,
+ }))
+ }
+ />
+
+
+ Import subscription records
+
+
+ Create subscription records from Members CSV data
+
+
+
+
+
+
+ setOptions((prev) => ({
+ ...prev,
+ import_payment_history: checked,
+ }))
+ }
+ />
+
+
+ Import payment history
+
+
+ Include payment history from Payments CSV in subscription notes
+
+
+
+
+
+
+ setOptions((prev) => ({
+ ...prev,
+ skip_errors: checked,
+ }))
+ }
+ />
+
+
+ Continue on errors
+
+
+ Skip rows with errors and continue importing valid rows
+
+
+
+
+
+
+ {/* Summary */}
+
+
+
+
+
Import Summary
+
+ Ready to import {uploadSummary?.users?.valid || 0} users
+ {options.import_subscriptions &&
+ uploadSummary?.members?.matched > 0 &&
+ ` with ${uploadSummary.members.matched} subscriptions`}
+ . All imported users will have{' '}
+
+ force_password_change
+ {' '}
+ enabled.
+
+
+
+
+
+ );
+
+ // Step 4: Results
+ const renderResultsStep = () => (
+
+ {importResults && (
+ <>
+ {/* Success Banner */}
+
+ {importResults.failed_rows === 0 ? (
+
+ ) : (
+
+ )}
+
+ {importResults.failed_rows === 0
+ ? 'Import Completed Successfully!'
+ : 'Import Completed with Some Errors'}
+
+
+
+ {/* Results Grid */}
+
+
+
+ {importResults.successful_rows}
+
+ Users Imported
+
+
+
+ {importResults.failed_rows}
+
+ Failed
+
+
+
+ {importResults.created_subscriptions}
+
+ Subscriptions
+
+
+
+ {importResults.emails_sent}
+
+ Emails Sent
+
+
+
+ {/* Error List */}
+ {importResults.errors?.length > 0 && (
+
+
+ Import Errors ({importResults.errors.length})
+
+
+ {importResults.errors.slice(0, 20).map((error, idx) => (
+
+
+ Row {error.row}:
+ {' '}
+ {error.error}
+ {error.email && (
+ ({error.email})
+ )}
+
+ ))}
+ {importResults.errors.length > 20 && (
+
+ ...and {importResults.errors.length - 20} more errors
+
+ )}
+
+
+ )}
+
+ {/* Next Steps */}
+
+
+ Next Steps
+
+
+
+ Review imported users in the Members management page
+
+ {options.skip_notifications && (
+
+ Send bulk password reset emails when ready
+
+ )}
+
+ Use the Import Jobs page to rollback if needed
+
+
+
+ >
+ )}
+
+ );
+
+ return (
+
+
+
+
+ WordPress Import Wizard
+
+
+ Import users, subscriptions, and payment history from WordPress
+
+
+
+ {/* Step Indicator */}
+
+ {STEPS.map((step, idx) => {
+ const StepIcon = step.icon;
+ const isActive = idx === currentStep;
+ const isCompleted = idx < currentStep;
+
+ return (
+
+
+
+ {isCompleted ? (
+
+ ) : (
+
+ )}
+
+
+ {step.title}
+
+
+ {idx < STEPS.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+
+ {/* Step Content */}
+ {renderStepContent()}
+
+
+ {/* Back Button */}
+ {currentStep > 0 && currentStep < 3 && (
+ setCurrentStep((prev) => prev - 1)}
+ disabled={loading}
+ className="mr-auto"
+ >
+
+ Back
+
+ )}
+
+ {/* Cancel Button */}
+ {currentStep < 3 && (
+
+ Cancel
+
+ )}
+
+ {/* Action Buttons */}
+ {currentStep === 0 && (
+
+ {loading ? (
+ <>
+
+ Analyzing...
+ >
+ ) : (
+ <>
+ Upload & Analyze
+
+ >
+ )}
+
+ )}
+
+ {currentStep === 1 && (
+ setCurrentStep(2)}
+ disabled={loading}
+ className="bg-brand-purple text-white hover:bg-[var(--purple-ink)]"
+ >
+ Continue to Options
+
+
+ )}
+
+ {currentStep === 2 && (
+
+ {loading ? (
+ <>
+
+ Importing...
+ >
+ ) : (
+ <>
+ Execute Import
+
+ >
+ )}
+
+ )}
+
+ {currentStep === 3 && (
+ {
+ handleClose();
+ onSuccess?.();
+ }}
+ className="bg-brand-purple text-white hover:bg-[var(--purple-ink)]"
+ >
+ Done
+
+ )}
+
+
+
+ );
+};
+
+export default ComprehensiveImportWizard;
diff --git a/src/components/TemplateImportWizard.js b/src/components/TemplateImportWizard.js
new file mode 100644
index 0000000..c468456
--- /dev/null
+++ b/src/components/TemplateImportWizard.js
@@ -0,0 +1,1084 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from './ui/dialog';
+import { Button } from './ui/button';
+import { Card } from './ui/card';
+import { Label } from './ui/label';
+import { Checkbox } from './ui/checkbox';
+import { Badge } from './ui/badge';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from './ui/select';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from './ui/table';
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from './ui/tabs';
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from './ui/accordion';
+import {
+ Upload,
+ Download,
+ FileSpreadsheet,
+ Users,
+ CreditCard,
+ Heart,
+ Receipt,
+ ClipboardList,
+ CheckCircle2,
+ XCircle,
+ AlertTriangle,
+ Loader2,
+ ChevronLeft,
+ ChevronRight,
+ Info,
+ FileDown,
+ ExternalLink,
+} from 'lucide-react';
+import { toast } from 'sonner';
+import api from '../utils/api';
+
+const STEPS = [
+ { id: 'templates', title: 'Download Templates', icon: Download },
+ { id: 'upload', title: 'Upload Files', icon: Upload },
+ { id: 'preview', title: 'Preview Data', icon: FileSpreadsheet },
+ { id: 'options', title: 'Import Options', icon: Users },
+ { id: 'execute', title: 'Execute Import', icon: CheckCircle2 },
+];
+
+const FILE_TYPES = {
+ users: { name: 'Users', icon: Users, color: 'text-blue-600', required: true },
+ subscriptions: { name: 'Subscriptions', icon: CreditCard, color: 'text-green-600', required: false },
+ donations: { name: 'Donations', icon: Heart, color: 'text-pink-600', required: false },
+ payments: { name: 'Payments', icon: Receipt, color: 'text-purple-600', required: false },
+ registration_data: { name: 'Custom Fields', icon: ClipboardList, color: 'text-orange-600', required: false },
+};
+
+const STATUS_COLORS = {
+ active: 'bg-green-100 text-green-800',
+ inactive: 'bg-gray-100 text-gray-800',
+ pending_email: 'bg-yellow-100 text-yellow-800',
+ pending_validation: 'bg-purple-100 text-purple-800',
+ pre_validated: 'bg-blue-100 text-blue-800',
+ payment_pending: 'bg-orange-100 text-orange-800',
+ canceled: 'bg-red-100 text-red-800',
+ expired: 'bg-red-100 text-red-800',
+};
+
+/**
+ * TemplateImportWizard - Template-based CSV import wizard
+ *
+ * Provides standardized CSV templates for importing:
+ * - Users (required)
+ * - Subscriptions (optional)
+ * - Donations (optional)
+ * - Payments (optional)
+ * - Custom Registration Fields (optional)
+ */
+const TemplateImportWizard = ({ open, onOpenChange, onSuccess }) => {
+ const [currentStep, setCurrentStep] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [importJobId, setImportJobId] = useState(null);
+
+ // Templates state
+ const [templates, setTemplates] = useState([]);
+ const [templatesLoading, setTemplatesLoading] = useState(false);
+
+ // File upload state
+ const [files, setFiles] = useState({
+ users: null,
+ subscriptions: null,
+ donations: null,
+ payments: null,
+ registration_data: null,
+ });
+
+ // Upload/validation results
+ const [uploadSummary, setUploadSummary] = useState(null);
+
+ // Preview state
+ const [previewTab, setPreviewTab] = useState('users');
+ const [previewData, setPreviewData] = useState({});
+ const [previewPage, setPreviewPage] = useState(1);
+ const [previewTotalPages, setPreviewTotalPages] = useState(1);
+
+ // Import options
+ const [options, setOptions] = useState({
+ skip_notifications: true,
+ update_existing: false,
+ import_subscriptions: true,
+ import_donations: true,
+ import_payments: true,
+ import_registration_data: true,
+ skip_errors: true,
+ });
+
+ // Results state
+ const [importResults, setImportResults] = useState(null);
+
+ // Fetch templates on mount
+ useEffect(() => {
+ if (open && templates.length === 0) {
+ fetchTemplates();
+ }
+ }, [open]);
+
+ const fetchTemplates = async () => {
+ setTemplatesLoading(true);
+ try {
+ const response = await api.get('/admin/import/templates');
+ setTemplates(response.data);
+ } catch (error) {
+ toast.error('Failed to load templates');
+ } finally {
+ setTemplatesLoading(false);
+ }
+ };
+
+ // Reset wizard state
+ const resetWizard = useCallback(() => {
+ setCurrentStep(0);
+ setLoading(false);
+ setImportJobId(null);
+ setFiles({
+ users: null,
+ subscriptions: null,
+ donations: null,
+ payments: null,
+ registration_data: null,
+ });
+ setUploadSummary(null);
+ setPreviewData({});
+ setPreviewTab('users');
+ setPreviewPage(1);
+ setPreviewTotalPages(1);
+ setOptions({
+ skip_notifications: true,
+ update_existing: false,
+ import_subscriptions: true,
+ import_donations: true,
+ import_payments: true,
+ import_registration_data: true,
+ skip_errors: true,
+ });
+ setImportResults(null);
+ }, []);
+
+ // Handle dialog close
+ const handleClose = () => {
+ if (loading) return;
+ resetWizard();
+ onOpenChange(false);
+ };
+
+ // Download template
+ const downloadTemplate = async (templateType) => {
+ try {
+ const response = await api.get(`/admin/import/templates/${templateType}/download`, {
+ responseType: 'blob',
+ });
+
+ const blob = new Blob([response.data], { type: 'text/csv' });
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `${templateType}_template.csv`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+
+ toast.success(`Downloaded ${templateType} template`);
+ } catch (error) {
+ toast.error(`Failed to download ${templateType} template`);
+ }
+ };
+
+ // Handle file selection
+ const handleFileChange = (fileType, file) => {
+ setFiles((prev) => ({
+ ...prev,
+ [fileType]: file,
+ }));
+ };
+
+ // Step 2: Upload files
+ const handleUpload = async () => {
+ if (!files.users) {
+ toast.error('Please select a Users CSV file (required)');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const formData = new FormData();
+ formData.append('users_file', files.users);
+
+ if (files.subscriptions) {
+ formData.append('subscriptions_file', files.subscriptions);
+ }
+ if (files.donations) {
+ formData.append('donations_file', files.donations);
+ }
+ if (files.payments) {
+ formData.append('payments_file', files.payments);
+ }
+ if (files.registration_data) {
+ formData.append('registration_data_file', files.registration_data);
+ }
+
+ const response = await api.post('/admin/import/template/upload', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ });
+
+ setImportJobId(response.data.import_job_id);
+ setUploadSummary(response.data);
+
+ // Fetch preview for users
+ await fetchPreview(response.data.import_job_id, 'users', 1);
+
+ toast.success('Files uploaded and validated successfully');
+ setCurrentStep(2);
+ } catch (error) {
+ const message = error.response?.data?.detail?.message || error.response?.data?.detail || 'Failed to upload files';
+ toast.error(message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Fetch preview data
+ const fetchPreview = async (jobId, fileType, page) => {
+ try {
+ const response = await api.get(`/admin/import/template/${jobId}/preview`, {
+ params: {
+ file_type: fileType,
+ page,
+ page_size: 10,
+ },
+ });
+
+ setPreviewData((prev) => ({
+ ...prev,
+ [fileType]: response.data,
+ }));
+ setPreviewTotalPages(response.data.total_pages);
+ setPreviewPage(page);
+ } catch (error) {
+ toast.error('Failed to fetch preview');
+ }
+ };
+
+ // Handle preview tab change
+ const handlePreviewTabChange = async (tab) => {
+ setPreviewTab(tab);
+ setPreviewPage(1);
+
+ if (!previewData[tab] && importJobId) {
+ await fetchPreview(importJobId, tab, 1);
+ }
+ };
+
+ // Execute import
+ const handleExecute = async () => {
+ if (!importJobId) return;
+
+ setLoading(true);
+ try {
+ const response = await api.post(`/admin/import/template/${importJobId}/execute`, {
+ options,
+ });
+
+ setImportResults(response.data);
+ setCurrentStep(4);
+ toast.success('Import completed successfully');
+
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ const message = error.response?.data?.detail || 'Failed to execute import';
+ toast.error(message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Render step indicator
+ const renderStepIndicator = () => (
+
+ {STEPS.map((step, index) => {
+ const Icon = step.icon;
+ const isActive = index === currentStep;
+ const isCompleted = index < currentStep;
+
+ return (
+
+
+
+ {isCompleted ? (
+
+ ) : (
+
+ )}
+
+
+ {step.title}
+
+
+ {index < STEPS.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+ );
+
+ // Step 1: Download Templates
+ const renderTemplatesStep = () => (
+
+
+
+
+
+
How Template Import Works
+
+ 1. Download the CSV templates below
+ 2. Fill in your data following the template format
+ 3. Upload your completed files in the next step
+ 4. Review and import your data
+
+
+
+
+
+
+ {templatesLoading ? (
+
+
+
+ ) : (
+ templates.map((template) => {
+ const fileType = FILE_TYPES[template.type];
+ const Icon = fileType?.icon || FileSpreadsheet;
+
+ return (
+
+
+
+
+
+
+
+
+
{template.name}
+ {template.required ? (
+ Required
+ ) : (
+ Optional
+ )}
+
+
{template.description}
+
+ {template.field_count} fields | Required: {template.required_fields.join(', ')}
+
+
+
+
downloadTemplate(template.type)}
+ >
+
+ Download
+
+
+
+ );
+ })
+ )}
+
+
+
+ setCurrentStep(1)}>
+ Continue to Upload
+
+
+
+
+ );
+
+ // Step 2: Upload Files
+ const renderUploadStep = () => (
+
+
+
+
+
+
Important
+
+ The Users CSV is required. Other files are optional and will be linked by email address.
+
+
+
+
+
+
+ {Object.entries(FILE_TYPES).map(([key, config]) => {
+ const Icon = config.icon;
+ const file = files[key];
+
+ return (
+
+
+
+
+
+
+
+
+
{config.name}
+ {config.required ? (
+ Required
+ ) : (
+ Optional
+ )}
+
+ {file ? (
+
{file.name}
+ ) : (
+
No file selected
+ )}
+
+
+
+ {file && (
+
+ )}
+
+ handleFileChange(key, e.target.files[0])}
+ />
+
+
+
+ {file ? 'Change' : 'Select'}
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ setCurrentStep(0)}>
+
+ Back
+
+
+ {loading ? (
+ <>
+
+ Validating...
+ >
+ ) : (
+ <>
+ Upload & Validate
+
+ >
+ )}
+
+
+
+ );
+
+ // Step 3: Preview Data
+ const renderPreviewStep = () => {
+ const currentPreview = previewData[previewTab];
+ const validation = uploadSummary?.validation?.[previewTab];
+ const crossValidation = uploadSummary?.cross_validation;
+
+ return (
+
+ {/* Summary Cards */}
+ {crossValidation && (
+
+
+
+ {crossValidation.summary.total_users}
+
+ Total Users
+
+
+
+ {crossValidation.summary.new_users}
+
+ New Users
+
+
+
+ {crossValidation.summary.total_subscriptions}
+
+ Subscriptions
+
+
+
+ {crossValidation.summary.total_donations}
+
+ Donations
+
+
+ )}
+
+ {/* Warnings */}
+ {crossValidation?.warnings?.length > 0 && (
+
+
+
+ Warnings
+
+
+ {crossValidation.warnings.map((warning, idx) => (
+ - {warning}
+ ))}
+
+
+ )}
+
+ {/* Preview Tabs */}
+
+
+ {Object.entries(FILE_TYPES).map(([key, config]) => {
+ const Icon = config.icon;
+ const hasData = uploadSummary?.files_uploaded?.[key];
+
+ return (
+
+
+ {config.name}
+
+ );
+ })}
+
+
+
+ {/* Validation Status */}
+ {validation && (
+
+
+
+ {validation.valid_rows} valid
+
+ {validation.invalid_rows > 0 && (
+
+
+ {validation.invalid_rows} invalid
+
+ )}
+
+ )}
+
+ {/* Data Table */}
+ {currentPreview?.rows?.length > 0 ? (
+
+
+
+
+
+ #
+ {previewTab === 'users' && (
+ <>
+ Email
+ Name
+ Status
+ Role
+ >
+ )}
+ {previewTab === 'subscriptions' && (
+ <>
+ Email
+ Plan
+ Status
+ Amount
+ >
+ )}
+ {previewTab === 'donations' && (
+ <>
+ Email/Donor
+ Amount
+ Date
+ Type
+ >
+ )}
+ {previewTab === 'payments' && (
+ <>
+ Email
+ Amount
+ Date
+ Type
+ >
+ )}
+ {previewTab === 'registration_data' && (
+ <>
+ Email
+ Field Name
+ Value
+ >
+ )}
+
+
+
+ {currentPreview.rows.map((row, idx) => (
+
+
+ {row._row_number || idx + 1}
+
+ {previewTab === 'users' && (
+ <>
+ {row.email}
+ {row.first_name} {row.last_name}
+
+
+ {row.status || 'active'}
+
+
+ {row.role || 'member'}
+ >
+ )}
+ {previewTab === 'subscriptions' && (
+ <>
+ {row.email}
+ {row.plan_name}
+
+
+ {row.status || 'active'}
+
+
+ ${row.amount}
+ >
+ )}
+ {previewTab === 'donations' && (
+ <>
+
+ {row.email || row.donor_name || 'Anonymous'}
+
+ ${row.amount}
+ {row.date?.toString()}
+ {row.type || 'member'}
+ >
+ )}
+ {previewTab === 'payments' && (
+ <>
+ {row.email}
+ ${row.amount}
+ {row.date?.toString()}
+ {row.type || 'subscription'}
+ >
+ )}
+ {previewTab === 'registration_data' && (
+ <>
+ {row.email}
+ {row.field_name}
+ {row.field_value}
+ >
+ )}
+
+ ))}
+
+
+
+
+ {/* Pagination */}
+ {currentPreview.total_pages > 1 && (
+
+
+ Page {previewPage} of {currentPreview.total_pages}
+
+
+ fetchPreview(importJobId, previewTab, previewPage - 1)}
+ >
+ Previous
+
+ = currentPreview.total_pages}
+ onClick={() => fetchPreview(importJobId, previewTab, previewPage + 1)}
+ >
+ Next
+
+
+
+ )}
+
+ ) : (
+
+ No data available for this file type
+
+ )}
+
+ {/* Errors */}
+ {validation?.errors_preview?.length > 0 && (
+
+
+
+
+ {validation.invalid_rows} Validation Errors
+
+
+
+ {validation.errors_preview.map((error, idx) => (
+
+ Row {error.row}: {' '}
+ {error.errors.join(', ')}
+
+ ))}
+
+
+
+
+ )}
+
+
+
+
+ setCurrentStep(1)}>
+
+ Back
+
+ setCurrentStep(3)} disabled={!uploadSummary?.can_proceed}>
+ Continue to Options
+
+
+
+
+ );
+ };
+
+ // Step 4: Import Options
+ const renderOptionsStep = () => (
+
+
+ Notification Settings
+
+
+
+
Skip all notifications
+
+ Don't send welcome or password emails during import
+
+
+
+ setOptions((prev) => ({ ...prev, skip_notifications: checked }))
+ }
+ />
+
+
+
+
+
+ User Import Settings
+
+
+
+
Update existing users
+
+ If email already exists, update the user instead of skipping
+
+
+
+ setOptions((prev) => ({ ...prev, update_existing: checked }))
+ }
+ />
+
+
+
+
Skip errors and continue
+
+ Continue importing even if some rows have errors
+
+
+
+ setOptions((prev) => ({ ...prev, skip_errors: checked }))
+ }
+ />
+
+
+
+
+
+ Data Import Settings
+
+ {uploadSummary?.files_uploaded?.subscriptions && (
+
+
+
Import subscriptions
+
+ Create subscription records from subscriptions.csv
+
+
+
+ setOptions((prev) => ({ ...prev, import_subscriptions: checked }))
+ }
+ />
+
+ )}
+ {uploadSummary?.files_uploaded?.donations && (
+
+
+
Import donations
+
+ Create donation records from donations.csv
+
+
+
+ setOptions((prev) => ({ ...prev, import_donations: checked }))
+ }
+ />
+
+ )}
+ {uploadSummary?.files_uploaded?.payments && (
+
+
+
Import payments
+
+ Create payment records from payments.csv
+
+
+
+ setOptions((prev) => ({ ...prev, import_payments: checked }))
+ }
+ />
+
+ )}
+ {uploadSummary?.files_uploaded?.registration_data && (
+
+
+
Import custom fields
+
+ Save custom registration data from registration_data.csv
+
+
+
+ setOptions((prev) => ({ ...prev, import_registration_data: checked }))
+ }
+ />
+
+ )}
+
+
+
+
+
After Import
+
+ All imported users will have force_password_change enabled.
+ You can use the Bulk Password Reset feature to send login instructions after import.
+
+
+
+
+ setCurrentStep(2)}>
+
+ Back
+
+
+ {loading ? (
+ <>
+
+ Importing...
+ >
+ ) : (
+ <>
+ Execute Import
+
+ >
+ )}
+
+
+
+ );
+
+ // Step 5: Results
+ const renderResultsStep = () => {
+ if (!importResults) return null;
+
+ const { results, errors, error_count } = importResults;
+
+ return (
+
+
+
+
Import Complete
+
+
+
+
+
+ {results.users.imported}
+
+ Users Imported
+
+
+
+ {results.users.updated}
+
+ Users Updated
+
+
+
+ {results.subscriptions.imported}
+
+ Subscriptions
+
+
+
+ {results.donations.imported}
+
+ Donations
+
+
+
+ {results.users.failed > 0 && (
+
+
+
+ {results.users.failed} users failed to import
+
+
+ )}
+
+ {error_count > 0 && (
+
+
+
+
+ {error_count} Errors
+
+
+
+ {errors.map((error, idx) => (
+
+ {error.type && {error.type} }
+ Row {error.row}: {' '}
+ {error.error}
+
+ ))}
+
+
+
+
+ )}
+
+
+
+ Close
+
+
+
+ );
+ };
+
+ // Render current step content
+ const renderStepContent = () => {
+ switch (currentStep) {
+ case 0:
+ return renderTemplatesStep();
+ case 1:
+ return renderUploadStep();
+ case 2:
+ return renderPreviewStep();
+ case 3:
+ return renderOptionsStep();
+ case 4:
+ return renderResultsStep();
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+
+
+
+
+ Template Import Wizard
+
+
+ Import users and related data using standardized CSV templates
+
+
+
+ {renderStepIndicator()}
+ {renderStepContent()}
+
+
+ );
+};
+
+export default TemplateImportWizard;
diff --git a/src/pages/Login.js b/src/pages/Login.js
index efd100b..73ac36e 100644
--- a/src/pages/Login.js
+++ b/src/pages/Login.js
@@ -16,6 +16,7 @@ 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: '',
@@ -31,20 +32,20 @@ const Login = () => {
toast.info('Your session has expired. Please log in again.', {
duration: 5000,
});
- // Clean up URL
- window.history.replaceState({}, '', '/login');
+ // Clean up URL (respect basename for subpath deployments)
+ window.history.replaceState({}, '', `${basename}/login`);
} else if (sessionParam === 'idle') {
toast.info('You were logged out due to inactivity. Please log in again.', {
duration: 5000,
});
- // Clean up URL
- window.history.replaceState({}, '', '/login');
+ // Clean up URL (respect basename for subpath deployments)
+ window.history.replaceState({}, '', `${basename}/login`);
} else if (stateMessage) {
toast.info(stateMessage, {
duration: 5000,
});
}
- }, [searchParams, location.state]);
+ }, [searchParams, location.state, basename]);
const handleInputChange = (e) => {
const { name, value } = e.target;
diff --git a/src/pages/admin/AdminMembers.js b/src/pages/admin/AdminMembers.js
index 71455cf..f9e4905 100644
--- a/src/pages/admin/AdminMembers.js
+++ b/src/pages/admin/AdminMembers.js
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api';
@@ -6,19 +6,24 @@ 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 } from 'lucide-react';
+import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown, CircleMinus, KeyRound, Loader2, X } 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';
@@ -45,8 +50,158 @@ 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);
@@ -232,13 +387,54 @@ const AdminMembers = () => {
)}
{hasPermission('users.import') && (
- setImportDialogOpen(true)}
- className="btn-util-green "
- >
-
- Import
-
+
+
+
+
+ Import
+
+
+
+
+ setTemplateImportOpen(true)}
+ className="cursor-pointer"
+ >
+
+
+
Template Import (Recommended)
+
+ Download templates, fill your data, upload
+
+
+
+
+ setComprehensiveImportOpen(true)}
+ className="cursor-pointer"
+ >
+
+
+
WordPress Import
+
+ For WordPress/PMS exports
+
+
+
+ setImportDialogOpen(true)}
+ className="cursor-pointer"
+ >
+
+
+
Basic User Import
+
+ Simple WordPress users CSV
+
+
+
+
+
)}
{hasPermission('users.invite') && (
@@ -331,6 +527,102 @@ const AdminMembers = () => {
+ {/* Import Job Quick Select */}
+ {hasPermission('users.import') && importJobs.length > 0 && (
+
+
+
+
+
+ Quick Select from Import
+
+
+
+
+
selectUsersFromImportJob(value || null)}
+ >
+
+
+
+
+ -- None --
+ {importJobs.map((job) => (
+
+
+
+ {job.filename || 'Import'} ({job.successful_rows} users)
+
+
+ {new Date(job.started_at).toLocaleDateString()}
+
+
+
+ ))}
+
+
+
+ {importJobLoading && (
+
+ )}
+
+
+
+ )}
+
+ {/* Bulk Action Bar */}
+ {selectedUsers.size > 0 && (
+
+
+
+
+ {selectedUsers.size} user{selectedUsers.size !== 1 ? 's' : ''} selected
+ {selectedImportJob && (
+
+ (from import job)
+
+ )}
+
+ {
+ clearSelection();
+ setSelectedImportJob(null);
+ }}
+ className="text-white hover:bg-white/20"
+ >
+
+ Clear
+
+
+
+
+ {hasPermission('users.reset_password') && (
+ setBulkPasswordResetOpen(true)}
+ disabled={bulkActionLoading}
+ className="bg-white text-brand-purple hover:bg-white/90"
+ >
+ {bulkActionLoading ? (
+
+ ) : (
+
+ )}
+ Send Password Reset
+
+ )}
+
+
+
+ )}
+
{/* Members List */}
{loading ? (
@@ -338,17 +630,50 @@ const AdminMembers = () => {
) : filteredUsers.length > 0 ? (
+ {/* Select All Row */}
+ {filteredUsers.length > 0 && hasPermission('users.reset_password') && (
+
+
+
+ Select all ({filteredUsers.length} users)
+
+
+ )}
+
{filteredUsers.map((user) => {
const joinedDate = user.created_at;
const memberDate = user.member_since;
return (
+ {/* Selection Checkbox */}
+ {hasPermission('users.reset_password') && (
+
+ toggleUserSelection(user.id)}
+ aria-label={`Select ${user.first_name} ${user.last_name}`}
+ />
+
+ )}
+
{/* Avatar */}
{user.first_name?.[0]}{user.last_name?.[0]}
@@ -532,6 +857,50 @@ const AdminMembers = () => {
onOpenChange={setImportDialogOpen}
onSuccess={refetch}
/>
+
+
+
+
+
+ {/* Bulk Password Reset Confirmation Dialog */}
+
+
+ You are about to send password reset emails to{' '}
+ {selectedUsers.size} user{selectedUsers.size !== 1 ? 's' : ''} .
+
+
+
+ What happens:
+
+
+ Each user will receive an email with a password reset link
+ The force_password_change flag will be set
+ Users must set a new password on their next login
+
+
+
+ This action cannot be undone. Continue?
+
+
+ }
+ confirmText={bulkActionLoading ? 'Sending...' : 'Send Emails'}
+ variant="info"
+ loading={bulkActionLoading}
+ />
>
);
};
diff --git a/src/utils/api.js b/src/utils/api.js
index fcf140a..f4ed2d0 100644
--- a/src/utils/api.js
+++ b/src/utils/api.js
@@ -1,6 +1,7 @@
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`,
@@ -48,9 +49,11 @@ 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('/login?session=expired');
+ window.location.replace(loginPath);
}
}