Merge branch 'dev' into features
This commit is contained in:
241
src/pages/admin/AdminDirectorySettings.js
Normal file
241
src/pages/admin/AdminDirectorySettings.js
Normal file
@@ -0,0 +1,241 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Switch } from '../../components/ui/switch';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { toast } from 'sonner';
|
||||
import { BookUser, Save, RotateCcw, Loader2, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
} from '../../components/ui/alert';
|
||||
|
||||
const AdminDirectorySettings = () => {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [initialConfig, setInitialConfig] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialConfig && config) {
|
||||
const changed = JSON.stringify(config) !== JSON.stringify(initialConfig);
|
||||
setHasChanges(changed);
|
||||
}
|
||||
}, [config, initialConfig]);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/admin/directory/config');
|
||||
setConfig(response.data.config);
|
||||
setInitialConfig(response.data.config);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch directory config:', error);
|
||||
toast.error('Failed to load directory configuration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleField = (fieldId) => {
|
||||
// Don't allow disabling show_in_directory - it's required
|
||||
if (fieldId === 'show_in_directory') {
|
||||
toast.error('The "Show in Directory" field cannot be disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
fields: {
|
||||
...prev.fields,
|
||||
[fieldId]: {
|
||||
...prev.fields[fieldId],
|
||||
enabled: !prev.fields[fieldId].enabled
|
||||
}
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await api.put('/admin/directory/config', { config });
|
||||
setInitialConfig(config);
|
||||
setHasChanges(false);
|
||||
toast.success('Directory configuration saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to save directory config:', error);
|
||||
toast.error('Failed to save directory configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!window.confirm('Are you sure you want to reset to default settings? This will enable all directory fields.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await api.post('/admin/directory/config/reset');
|
||||
setConfig(response.data.config);
|
||||
setInitialConfig(response.data.config);
|
||||
setHasChanges(false);
|
||||
toast.success('Directory configuration reset to defaults');
|
||||
} catch (error) {
|
||||
console.error('Failed to reset directory config:', error);
|
||||
toast.error('Failed to reset directory configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setConfig(initialConfig);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
// Field descriptions for better UX
|
||||
const fieldDescriptions = {
|
||||
show_in_directory: 'Master toggle for members to opt-in to the directory (always enabled)',
|
||||
directory_email: 'Email address visible to other members in the directory',
|
||||
directory_bio: 'Short biography shown in directory profile and member cards',
|
||||
directory_address: 'Physical address visible to other members',
|
||||
directory_phone: 'Phone number visible to other members',
|
||||
directory_dob: 'Birthday shown in directory profiles',
|
||||
directory_partner_name: 'Partner name displayed in directory',
|
||||
volunteer_interests: 'Volunteer interest badges shown in profiles',
|
||||
social_media: 'Social media links (Facebook, Instagram, Twitter, LinkedIn)',
|
||||
};
|
||||
|
||||
// Field icons for better UX
|
||||
const fieldLabels = {
|
||||
show_in_directory: 'Show in Directory Toggle',
|
||||
directory_email: 'Directory Email',
|
||||
directory_bio: 'Bio / About',
|
||||
directory_address: 'Address',
|
||||
directory_phone: 'Phone Number',
|
||||
directory_dob: 'Birthday',
|
||||
directory_partner_name: 'Partner Name',
|
||||
volunteer_interests: 'Volunteer Interests',
|
||||
social_media: 'Social Media Links',
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-brand-purple" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<BookUser className="h-6 w-6 text-brand-purple" />
|
||||
Directory Field Settings
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Configure which fields are available in member profiles and the directory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Alert */}
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
These settings control which fields appear in the <strong>Profile page</strong> and <strong>Member Directory</strong>.
|
||||
Disabling a field will hide it from both locations. Existing data will be preserved but not displayed.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Fields Configuration */}
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
{config && Object.entries(config.fields).map(([fieldId, fieldData]) => (
|
||||
<div
|
||||
key={fieldId}
|
||||
className={`flex items-center justify-between p-4 rounded-lg border ${
|
||||
fieldData.enabled ? 'bg-background border-border' : 'bg-muted/50 border-muted'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Label className="text-base font-medium text-foreground">
|
||||
{fieldLabels[fieldId] || fieldData.label || fieldId}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{fieldDescriptions[fieldId] || fieldData.description}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={fieldData.enabled}
|
||||
onCheckedChange={() => handleToggleField(fieldId)}
|
||||
disabled={fieldId === 'show_in_directory'}
|
||||
className="data-[state=checked]:bg-brand-purple"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-orange-500"></span>
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={!hasChanges || saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
className="bg-brand-purple hover:bg-brand-purple/90 text-white"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDirectorySettings;
|
||||
@@ -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') && (
|
||||
<Button
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
className="btn-util-green "
|
||||
>
|
||||
<Upload className="h-5 w-5 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{hasPermission('users.invite') && (
|
||||
@@ -331,6 +527,102 @@ 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">
|
||||
@@ -338,17 +630,50 @@ 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 border-[var(--neutral-800)] hover:shadow-md transition-shadow"
|
||||
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)]'
|
||||
}`}
|
||||
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]}
|
||||
@@ -532,6 +857,50 @@ 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -954,45 +954,213 @@ const AdminRegistrationBuilder = () => {
|
||||
|
||||
{/* Conditional Rules Dialog */}
|
||||
<Dialog open={conditionalDialogOpen} onOpenChange={setConditionalDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Conditional Rules</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4 max-h-96 overflow-y-auto">
|
||||
{(schema?.conditional_rules || []).map((rule, index) => (
|
||||
<div key={rule.id} className="p-4 border rounded-lg space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">Rule {index + 1}</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 text-red-500 hover-text-background"
|
||||
onClick={() => {
|
||||
updateSchema((prev) => ({
|
||||
...prev,
|
||||
conditional_rules: prev.conditional_rules.filter((r) => r.id !== rule.id),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Rules allow you to show or hide fields based on other field values. For example, show a "Scholarship Reason" field only when "Request Scholarship" is checked.
|
||||
</p>
|
||||
|
||||
{(schema?.conditional_rules || []).map((rule, index) => {
|
||||
// Get all available fields for dropdowns
|
||||
const allFields = schema?.steps?.flatMap(step =>
|
||||
step.sections?.flatMap(section =>
|
||||
section.fields?.map(field => ({
|
||||
id: field.id,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
stepTitle: step.title,
|
||||
sectionTitle: section.title,
|
||||
})) || []
|
||||
) || []
|
||||
) || [];
|
||||
|
||||
// Filter trigger fields (checkbox, dropdown, radio work best)
|
||||
const triggerFields = allFields.filter(f =>
|
||||
['checkbox', 'dropdown', 'radio', 'text'].includes(f.type)
|
||||
);
|
||||
|
||||
// Update a specific rule
|
||||
const updateRule = (ruleId, updates) => {
|
||||
updateSchema((prev) => ({
|
||||
...prev,
|
||||
conditional_rules: prev.conditional_rules.map((r) =>
|
||||
r.id === ruleId ? { ...r, ...updates } : r
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
// Get the trigger field's type to determine value input
|
||||
const triggerFieldData = allFields.find(f => f.id === rule.trigger_field);
|
||||
|
||||
return (
|
||||
<div key={rule.id} className="p-4 border rounded-lg space-y-4 bg-gray-50">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium text-sm">Rule {index + 1}</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 text-red-500 hover-text-background hover:text-red-700"
|
||||
onClick={() => {
|
||||
updateSchema((prev) => ({
|
||||
...prev,
|
||||
conditional_rules: prev.conditional_rules.filter((r) => r.id !== rule.id),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Trigger Field Selection */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">When this field...</Label>
|
||||
<Select
|
||||
value={rule.trigger_field || ''}
|
||||
onValueChange={(value) => updateRule(rule.id, { trigger_field: value })}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select field" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{triggerFields.map((field) => (
|
||||
<SelectItem key={field.id} value={field.id}>
|
||||
{field.label}
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
({field.type})
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">Operator</Label>
|
||||
<Select
|
||||
value={rule.trigger_operator || 'equals'}
|
||||
onValueChange={(value) => updateRule(rule.id, { trigger_operator: value })}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="equals">equals</SelectItem>
|
||||
<SelectItem value="not_equals">not equals</SelectItem>
|
||||
<SelectItem value="contains">contains</SelectItem>
|
||||
<SelectItem value="not_empty">is not empty</SelectItem>
|
||||
<SelectItem value="empty">is empty</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">Value</Label>
|
||||
{triggerFieldData?.type === 'checkbox' ? (
|
||||
<Select
|
||||
value={String(rule.trigger_value)}
|
||||
onValueChange={(value) =>
|
||||
updateRule(rule.id, { trigger_value: value === 'true' })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Checked (true)</SelectItem>
|
||||
<SelectItem value="false">Unchecked (false)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : ['empty', 'not_empty'].includes(rule.trigger_operator) ? (
|
||||
<Input
|
||||
className="mt-1"
|
||||
value="(no value needed)"
|
||||
disabled
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={rule.trigger_value || ''}
|
||||
onChange={(e) => updateRule(rule.id, { trigger_value: e.target.value })}
|
||||
placeholder="Enter value"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Selection */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">Action</Label>
|
||||
<Select
|
||||
value={rule.action || 'show'}
|
||||
onValueChange={(value) => updateRule(rule.id, { action: value })}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="show">Show fields</SelectItem>
|
||||
<SelectItem value="hide">Hide fields</SelectItem>
|
||||
<SelectItem value="require">Make required</SelectItem>
|
||||
<SelectItem value="optional">Make optional</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">Target Fields</Label>
|
||||
<div className="mt-1 border rounded-md p-2 bg-white max-h-32 overflow-y-auto">
|
||||
{allFields.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No fields available</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{allFields
|
||||
.filter(f => f.id !== rule.trigger_field) // Don't show trigger field as target
|
||||
.map((field) => (
|
||||
<div key={field.id} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`${rule.id}-${field.id}`}
|
||||
checked={(rule.target_fields || []).includes(field.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentTargets = rule.target_fields || [];
|
||||
const newTargets = checked
|
||||
? [...currentTargets, field.id]
|
||||
: currentTargets.filter((id) => id !== field.id);
|
||||
updateRule(rule.id, { target_fields: newTargets });
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${rule.id}-${field.id}`}
|
||||
className="text-xs font-normal cursor-pointer"
|
||||
>
|
||||
{field.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rule Summary */}
|
||||
{rule.trigger_field && (rule.target_fields?.length || 0) > 0 && (
|
||||
<div className="text-xs bg-blue-50 border border-blue-200 rounded p-2 text-blue-800">
|
||||
<strong>Summary:</strong> When "{triggerFieldData?.label || rule.trigger_field}" {rule.trigger_operator} "{String(rule.trigger_value)}", {rule.action} the following fields: {rule.target_fields?.map(id => allFields.find(f => f.id === id)?.label || id).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
When <span className="font-mono bg-gray-100 px-1">{rule.trigger_field}</span>{' '}
|
||||
{rule.trigger_operator}{' '}
|
||||
<span className="font-mono bg-gray-100 px-1">{String(rule.trigger_value)}</span>,{' '}
|
||||
{rule.action} fields:{' '}
|
||||
<span className="font-mono bg-gray-100 px-1">
|
||||
{rule.target_fields?.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{(schema?.conditional_rules || []).length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No conditional rules configured. Rules allow you to show or hide fields based on
|
||||
other field values.
|
||||
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
|
||||
No conditional rules configured yet.<br />
|
||||
Click "Add Rule" to create your first rule.
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,11 +16,13 @@ export default function AdminSettings() {
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
publishable_key: '',
|
||||
secret_key: '',
|
||||
webhook_secret: ''
|
||||
});
|
||||
|
||||
// Show/hide sensitive values
|
||||
const [showPublishableKey, setShowPublishableKey] = useState(false);
|
||||
const [showSecretKey, setShowSecretKey] = useState(false);
|
||||
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
||||
|
||||
@@ -57,6 +59,7 @@ export default function AdminSettings() {
|
||||
const handleEditClick = () => {
|
||||
setIsEditing(true);
|
||||
setFormData({
|
||||
publishable_key: '',
|
||||
secret_key: '',
|
||||
webhook_secret: ''
|
||||
});
|
||||
@@ -65,17 +68,24 @@ export default function AdminSettings() {
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
setFormData({
|
||||
publishable_key: '',
|
||||
secret_key: '',
|
||||
webhook_secret: ''
|
||||
});
|
||||
setShowPublishableKey(false);
|
||||
setShowSecretKey(false);
|
||||
setShowWebhookSecret(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate inputs
|
||||
if (!formData.secret_key || !formData.webhook_secret) {
|
||||
toast.error('Both Secret Key and Webhook Secret are required');
|
||||
if (!formData.publishable_key || !formData.secret_key || !formData.webhook_secret) {
|
||||
toast.error('All three keys are required: Publishable Key, Secret Key, and Webhook Secret');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.publishable_key.startsWith('pk_test_') && !formData.publishable_key.startsWith('pk_live_')) {
|
||||
toast.error('Invalid Publishable Key format. Must start with pk_test_ or pk_live_');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,15 +99,25 @@ export default function AdminSettings() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check environment consistency
|
||||
const pkIsLive = formData.publishable_key.startsWith('pk_live_');
|
||||
const skIsLive = formData.secret_key.startsWith('sk_live_');
|
||||
if (pkIsLive !== skIsLive) {
|
||||
toast.error('Publishable Key and Secret Key must be from the same environment (both test or both live)');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put('/admin/settings/stripe', formData);
|
||||
toast.success('Stripe settings updated successfully');
|
||||
setIsEditing(false);
|
||||
setFormData({
|
||||
publishable_key: '',
|
||||
secret_key: '',
|
||||
webhook_secret: ''
|
||||
});
|
||||
setShowPublishableKey(false);
|
||||
setShowSecretKey(false);
|
||||
setShowWebhookSecret(false);
|
||||
// Refresh status
|
||||
@@ -157,6 +177,31 @@ export default function AdminSettings() {
|
||||
{isEditing ? (
|
||||
/* Edit Mode */
|
||||
<div className="space-y-6">
|
||||
{/* Publishable Key Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="publishable_key">Stripe Publishable Key</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="publishable_key"
|
||||
type={showPublishableKey ? 'text' : 'password'}
|
||||
value={formData.publishable_key}
|
||||
onChange={(e) => setFormData({ ...formData, publishable_key: e.target.value })}
|
||||
placeholder="pk_test_... or pk_live_..."
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPublishableKey(!showPublishableKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showPublishableKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Get this from your Stripe Dashboard → Developers → API keys (Publishable key)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Secret Key Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="secret_key">Stripe Secret Key</Label>
|
||||
@@ -178,7 +223,7 @@ export default function AdminSettings() {
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Get this from your Stripe Dashboard → Developers → API keys
|
||||
Get this from your Stripe Dashboard → Developers → API keys (Secret key)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -267,7 +312,7 @@ export default function AdminSettings() {
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Environment</p>
|
||||
<p className="text-sm text-gray-600">Detected from secret key prefix</p>
|
||||
<p className="text-sm text-gray-600">Detected from key prefixes</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${
|
||||
stripeStatus.environment === 'live'
|
||||
@@ -278,6 +323,20 @@ export default function AdminSettings() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Publishable Key</p>
|
||||
<p className="text-sm text-gray-600 font-mono">
|
||||
{stripeStatus.publishable_key_prefix}...
|
||||
</p>
|
||||
</div>
|
||||
{stripeStatus.publishable_key_set ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-amber-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Secret Key</p>
|
||||
|
||||
Reference in New Issue
Block a user