RBAC, Permissions, and Export/Import

This commit is contained in:
Koncept Kit
2025-12-16 20:04:00 +07:00
parent 02e38e1050
commit 9ed778db1c
30 changed files with 4579 additions and 487 deletions

View File

@@ -39,7 +39,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
try {
const response = await api.get('/admin/users');
const pending = response.data.filter(u =>
['pending_approval', 'pre_approved'].includes(u.status)
['pending_validation', 'pre_validated'].includes(u.status)
);
setPendingCount(pending.length);
} catch (error) {
@@ -105,9 +105,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
disabled: false
},
{
name: 'Approvals',
name: 'Validations',
icon: CheckCircle,
path: '/admin/approvals',
path: '/admin/validations',
disabled: false,
badge: pendingCount
},
@@ -154,9 +154,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
disabled: false
},
{
name: 'Roles',
name: 'Permissions',
icon: Shield,
path: '/admin/roles',
path: '/admin/permissions',
disabled: false,
superadminOnly: true
}

View File

@@ -0,0 +1,111 @@
import React from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from './ui/alert-dialog';
import { AlertTriangle, Info, CheckCircle } from 'lucide-react';
/**
* Reusable confirmation dialog component following the design system
*
* @param {boolean} open - Controls dialog visibility
* @param {function} onOpenChange - Callback when dialog open state changes
* @param {function} onConfirm - Callback when user confirms
* @param {string} title - Dialog title
* @param {string} description - Dialog description/message
* @param {string} confirmText - Confirm button text (default: "Confirm")
* @param {string} cancelText - Cancel button text (default: "Cancel")
* @param {string} variant - Visual variant: 'warning', 'danger', 'info', 'success' (default: 'warning')
* @param {boolean} loading - Show loading state on confirm button
*/
const ConfirmationDialog = ({
open,
onOpenChange,
onConfirm,
title,
description,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'warning',
loading = false,
}) => {
const variants = {
warning: {
icon: AlertTriangle,
iconColor: 'text-[#ff9e77]',
confirmButtonClass: 'bg-[#ff9e77] text-white hover:bg-[#e88d66] rounded-full px-6',
},
danger: {
icon: AlertTriangle,
iconColor: 'text-red-600',
confirmButtonClass: 'bg-red-600 text-white hover:bg-red-700 rounded-full px-6',
},
info: {
icon: Info,
iconColor: 'text-[#664fa3]',
confirmButtonClass: 'bg-[#664fa3] text-white hover:bg-[#553d8a] rounded-full px-6',
},
success: {
icon: CheckCircle,
iconColor: 'text-[#81B29A]',
confirmButtonClass: 'bg-[#81B29A] text-white hover:bg-[#6fa188] rounded-full px-6',
},
};
const config = variants[variant] || variants.warning;
const Icon = config.icon;
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="bg-white rounded-2xl border border-[#ddd8eb] p-0 overflow-hidden max-w-md">
<AlertDialogHeader className="p-6 pb-4">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-full bg-[#F8F7FB] ${config.iconColor}`}>
<Icon className="h-6 w-6" />
</div>
<div className="flex-1">
<AlertDialogTitle
className="text-xl font-semibold text-[#422268] mb-2"
style={{ fontFamily: "'Inter', sans-serif" }}
>
{title}
</AlertDialogTitle>
<AlertDialogDescription
className="text-[#664fa3] text-sm leading-relaxed"
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
{description}
</AlertDialogDescription>
</div>
</div>
</AlertDialogHeader>
<AlertDialogFooter className="p-6 pt-4 bg-[#F8F7FB] flex-row gap-3 justify-end">
<AlertDialogCancel
className="border-2 border-[#ddd8eb] text-[#664fa3] hover:bg-white rounded-full px-6"
disabled={loading}
>
{cancelText}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
className={config.confirmButtonClass}
disabled={loading}
>
{loading ? 'Processing...' : confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default ConfirmationDialog;

View File

@@ -0,0 +1,336 @@
import React, { useState } from 'react';
import api from '../utils/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { toast } from 'sonner';
import { Loader2, UserPlus } from 'lucide-react';
const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
const [formData, setFormData] = useState({
email: '',
password: '',
first_name: '',
last_name: '',
phone: '',
address: '',
city: '',
state: '',
zipcode: '',
date_of_birth: '',
member_since: '',
role: 'member'
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: null }));
}
};
const validate = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Invalid email format';
}
if (!formData.password || formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
if (!formData.first_name) {
newErrors.first_name = 'First name is required';
}
if (!formData.last_name) {
newErrors.last_name = 'Last name is required';
}
if (!formData.phone) {
newErrors.phone = 'Phone is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) {
return;
}
setLoading(true);
try {
// Format dates for backend
const payload = { ...formData };
if (payload.date_of_birth === '') {
delete payload.date_of_birth;
}
if (payload.member_since === '') {
delete payload.member_since;
}
await api.post('/admin/users/create', payload);
toast.success('Member created successfully');
// Reset form
setFormData({
email: '',
password: '',
first_name: '',
last_name: '',
phone: '',
address: '',
city: '',
state: '',
zipcode: '',
date_of_birth: '',
member_since: '',
role: 'member'
});
onOpenChange(false);
if (onSuccess) onSuccess();
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to create member';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] rounded-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl text-[#422268] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<UserPlus className="h-6 w-6" />
Create Member
</DialogTitle>
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Create a new member account with direct login access. Member will be created immediately.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-6 py-4">
{/* Email & Password Row */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="email" className="text-[#422268]">
Email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="member@example.com"
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="password" className="text-[#422268]">
Password <span className="text-red-500">*</span>
</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => handleChange('password', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Minimum 8 characters"
/>
{errors.password && (
<p className="text-sm text-red-500">{errors.password}</p>
)}
</div>
</div>
{/* Name Row */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="first_name" className="text-[#422268]">
First Name <span className="text-red-500">*</span>
</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="John"
/>
{errors.first_name && (
<p className="text-sm text-red-500">{errors.first_name}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="last_name" className="text-[#422268]">
Last Name <span className="text-red-500">*</span>
</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Doe"
/>
{errors.last_name && (
<p className="text-sm text-red-500">{errors.last_name}</p>
)}
</div>
</div>
{/* Phone */}
<div className="grid gap-2">
<Label htmlFor="phone" className="text-[#422268]">
Phone <span className="text-red-500">*</span>
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="(555) 123-4567"
/>
{errors.phone && (
<p className="text-sm text-red-500">{errors.phone}</p>
)}
</div>
{/* Address */}
<div className="grid gap-2">
<Label htmlFor="address" className="text-[#422268]">
Address
</Label>
<Input
id="address"
value={formData.address}
onChange={(e) => handleChange('address', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="123 Main St"
/>
</div>
{/* City, State, Zipcode Row */}
<div className="grid md:grid-cols-3 gap-4">
<div className="grid gap-2">
<Label htmlFor="city" className="text-[#422268]">City</Label>
<Input
id="city"
value={formData.city}
onChange={(e) => handleChange('city', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="San Francisco"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="state" className="text-[#422268]">State</Label>
<Input
id="state"
value={formData.state}
onChange={(e) => handleChange('state', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="CA"
maxLength={2}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="zipcode" className="text-[#422268]">Zipcode</Label>
<Input
id="zipcode"
value={formData.zipcode}
onChange={(e) => handleChange('zipcode', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="94102"
/>
</div>
</div>
{/* Dates Row */}
<div className="grid md:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="date_of_birth" className="text-[#422268]">Date of Birth</Label>
<Input
id="date_of_birth"
type="date"
value={formData.date_of_birth}
onChange={(e) => handleChange('date_of_birth', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="member_since" className="text-[#422268]">Member Since</Label>
<Input
id="member_since"
type="date"
value={formData.member_since}
onChange={(e) => handleChange('member_since', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
/>
</div>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="rounded-xl"
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<UserPlus className="h-4 w-4 mr-2" />
Create Member
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default CreateMemberDialog;

View File

@@ -0,0 +1,254 @@
import React, { useState } from 'react';
import api from '../utils/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { toast } from 'sonner';
import { Loader2, UserPlus } from 'lucide-react';
const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
const [formData, setFormData] = useState({
email: '',
password: '',
first_name: '',
last_name: '',
phone: '',
role: 'admin'
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: null }));
}
};
const validate = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Invalid email format';
}
if (!formData.password || formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
if (!formData.first_name) {
newErrors.first_name = 'First name is required';
}
if (!formData.last_name) {
newErrors.last_name = 'Last name is required';
}
if (!formData.phone) {
newErrors.phone = 'Phone is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) {
return;
}
setLoading(true);
try {
await api.post('/admin/users/create', formData);
toast.success('Staff member created successfully');
// Reset form
setFormData({
email: '',
password: '',
first_name: '',
last_name: '',
phone: '',
role: 'admin'
});
onOpenChange(false);
if (onSuccess) onSuccess();
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to create staff member';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] rounded-2xl">
<DialogHeader>
<DialogTitle className="text-2xl text-[#422268] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<UserPlus className="h-6 w-6" />
Create Staff Member
</DialogTitle>
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Create a new staff account with direct login access. User will be created immediately.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-6 py-4">
{/* Email */}
<div className="grid gap-2">
<Label htmlFor="email" className="text-[#422268]">
Email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="staff@example.com"
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
{/* Password */}
<div className="grid gap-2">
<Label htmlFor="password" className="text-[#422268]">
Password <span className="text-red-500">*</span>
</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => handleChange('password', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Minimum 8 characters"
/>
{errors.password && (
<p className="text-sm text-red-500">{errors.password}</p>
)}
</div>
{/* First Name */}
<div className="grid gap-2">
<Label htmlFor="first_name" className="text-[#422268]">
First Name <span className="text-red-500">*</span>
</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="John"
/>
{errors.first_name && (
<p className="text-sm text-red-500">{errors.first_name}</p>
)}
</div>
{/* Last Name */}
<div className="grid gap-2">
<Label htmlFor="last_name" className="text-[#422268]">
Last Name <span className="text-red-500">*</span>
</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Doe"
/>
{errors.last_name && (
<p className="text-sm text-red-500">{errors.last_name}</p>
)}
</div>
{/* Phone */}
<div className="grid gap-2">
<Label htmlFor="phone" className="text-[#422268]">
Phone <span className="text-red-500">*</span>
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="(555) 123-4567"
/>
{errors.phone && (
<p className="text-sm text-red-500">{errors.phone}</p>
)}
</div>
{/* Role */}
<div className="grid gap-2">
<Label htmlFor="role" className="text-[#422268]">
Role <span className="text-red-500">*</span>
</Label>
<Select value={formData.role} onValueChange={(value) => handleChange('role', value)}>
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="superadmin">Superadmin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="rounded-xl"
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<UserPlus className="h-4 w-4 mr-2" />
Create Staff
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default CreateStaffDialog;

View File

@@ -0,0 +1,335 @@
import React, { useState } from 'react';
import api from '../utils/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Label } from './ui/label';
import { Checkbox } from './ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from './ui/table';
import { Alert, AlertDescription } from './ui/alert';
import { Badge } from './ui/badge';
import { toast } from 'sonner';
import { Loader2, Upload, FileUp, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
const [file, setFile] = useState(null);
const [updateExisting, setUpdateExisting] = useState(false);
const [loading, setLoading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const [importResult, setImportResult] = useState(null);
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFileSelect(e.dataTransfer.files[0]);
}
};
const handleFileSelect = (selectedFile) => {
if (!selectedFile.name.endsWith('.csv')) {
toast.error('Please select a CSV file');
return;
}
setFile(selectedFile);
setImportResult(null);
};
const handleFileInput = (e) => {
if (e.target.files && e.target.files[0]) {
handleFileSelect(e.target.files[0]);
}
};
const handleSubmit = async () => {
if (!file) {
toast.error('Please select a file');
return;
}
setLoading(true);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('update_existing', updateExisting);
const response = await api.post('/admin/users/import', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
setImportResult(response.data);
if (response.data.status === 'completed') {
toast.success('All members imported successfully!');
} else if (response.data.status === 'partial') {
toast.warning('Import partially successful. Check errors below.');
} else {
toast.error('Import failed. Check errors below.');
}
if (onSuccess) onSuccess();
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to import members';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
const handleClose = () => {
setFile(null);
setUpdateExisting(false);
setImportResult(null);
onOpenChange(false);
};
const getStatusIcon = (status) => {
if (status === 'completed') {
return <CheckCircle className="h-6 w-6 text-green-500" />;
} else if (status === 'partial') {
return <AlertCircle className="h-6 w-6 text-orange-500" />;
} else {
return <XCircle className="h-6 w-6 text-red-500" />;
}
};
const getStatusBadge = (status) => {
const config = {
completed: { label: 'Completed', className: 'bg-green-500 text-white' },
partial: { label: 'Partial Success', className: 'bg-orange-500 text-white' },
failed: { label: 'Failed', className: 'bg-red-500 text-white' },
};
const statusConfig = config[status] || config.failed;
return (
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full`}>
{statusConfig.label}
</Badge>
);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[800px] rounded-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl text-[#422268] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Upload className="h-6 w-6" />
{importResult ? 'Import Results' : 'Import Members from CSV'}
</DialogTitle>
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{importResult
? 'Review the import results below'
: 'Upload a CSV file to bulk import members. Ensure the CSV has the required columns.'}
</DialogDescription>
</DialogHeader>
{!importResult ? (
// Upload Form
<div className="grid gap-6 py-4">
{/* CSV Format Instructions */}
<Alert className="border-[#664fa3] bg-[#F9F8FB]">
<AlertDescription className="text-sm text-[#422268]">
<strong>Required columns:</strong> Email, First Name, Last Name, Phone, Role
<br />
<strong>Optional columns:</strong> Status, Address, City, State, Zipcode, Date of Birth, Member Since
<br />
<strong>Date format:</strong> YYYY-MM-DD (e.g., 2024-01-15)
</AlertDescription>
</Alert>
{/* File Upload Area */}
<div
className={`border-2 border-dashed rounded-2xl p-12 text-center transition-colors ${
dragActive
? 'border-[#664fa3] bg-[#F9F8FB]'
: 'border-[#ddd8eb] hover:border-[#664fa3] hover:bg-[#F9F8FB]'
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{file ? (
<div className="flex flex-col items-center gap-4">
<FileUp className="h-16 w-16 text-[#81B29A]" />
<div>
<p className="text-lg font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{file.name}
</p>
<p className="text-sm text-[#664fa3]">
{(file.size / 1024).toFixed(2)} KB
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setFile(null)}
className="rounded-xl"
>
Remove File
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-4">
<Upload className="h-16 w-16 text-[#ddd8eb]" />
<div>
<p className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
Drag and drop your CSV file here
</p>
<p className="text-sm text-[#664fa3] mb-4">or</p>
<Label htmlFor="file-upload">
<Button variant="outline" className="rounded-xl cursor-pointer" asChild>
<span>Browse Files</span>
</Button>
</Label>
<input
id="file-upload"
type="file"
accept=".csv"
onChange={handleFileInput}
className="hidden"
/>
</div>
</div>
)}
</div>
{/* Options */}
<div className="flex items-center gap-3 p-4 bg-[#F9F8FB] rounded-xl">
<Checkbox
checked={updateExisting}
onCheckedChange={setUpdateExisting}
id="update-existing"
className="h-5 w-5 border-2 border-[#664fa3] data-[state=checked]:bg-[#664fa3]"
/>
<Label htmlFor="update-existing" className="text-[#422268] cursor-pointer">
Update existing members (if email already exists)
</Label>
</div>
</div>
) : (
// Import Results
<div className="grid gap-6 py-4">
{/* Summary Cards */}
<div className="grid md:grid-cols-4 gap-4">
<div className="p-4 bg-white rounded-xl border border-[#ddd8eb] text-center">
<p className="text-sm text-[#664fa3] mb-1">Total Rows</p>
<p className="text-2xl font-semibold text-[#422268]">{importResult.total_rows}</p>
</div>
<div className="p-4 bg-green-50 rounded-xl border border-green-200 text-center">
<p className="text-sm text-green-700 mb-1">Successful</p>
<p className="text-2xl font-semibold text-green-600">{importResult.successful_rows}</p>
</div>
<div className="p-4 bg-red-50 rounded-xl border border-red-200 text-center">
<p className="text-sm text-red-700 mb-1">Failed</p>
<p className="text-2xl font-semibold text-red-600">{importResult.failed_rows}</p>
</div>
<div className="p-4 bg-white rounded-xl border border-[#ddd8eb] flex items-center justify-center gap-2">
{getStatusIcon(importResult.status)}
{getStatusBadge(importResult.status)}
</div>
</div>
{/* Errors Table */}
{importResult.errors && importResult.errors.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Errors ({importResult.errors.length} {importResult.errors.length === 10 ? '- showing first 10' : ''})
</h3>
<div className="border border-[#ddd8eb] rounded-xl overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-[#DDD8EB] hover:bg-[#DDD8EB]">
<TableHead className="text-[#422268] font-semibold">Row</TableHead>
<TableHead className="text-[#422268] font-semibold">Email</TableHead>
<TableHead className="text-[#422268] font-semibold">Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{importResult.errors.map((error, idx) => (
<TableRow key={idx} className="hover:bg-[#F9F8FB]">
<TableCell className="font-medium text-[#422268]">{error.row}</TableCell>
<TableCell className="text-[#664fa3]">{error.email}</TableCell>
<TableCell className="text-red-600 text-sm">{error.error}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
)}
<DialogFooter>
{!importResult ? (
<>
<Button
variant="outline"
onClick={handleClose}
className="rounded-xl"
disabled={loading}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
disabled={loading || !file}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Importing...
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
Import Members
</>
)}
</Button>
</>
) : (
<Button
onClick={handleClose}
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
>
Done
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default ImportMembersDialog;

View File

@@ -0,0 +1,313 @@
import React, { useState, useEffect } from 'react';
import api from '../utils/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { toast } from 'sonner';
import { Loader2, Mail, Copy, Check } from 'lucide-react';
const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
const [formData, setFormData] = useState({
email: '',
first_name: '',
last_name: '',
phone: '',
role: 'admin'
});
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
const [invitationUrl, setInvitationUrl] = useState(null);
const [copied, setCopied] = useState(false);
const [roles, setRoles] = useState([]);
const [loadingRoles, setLoadingRoles] = useState(false);
// Fetch roles when dialog opens
useEffect(() => {
if (open) {
fetchRoles();
}
}, [open]);
const fetchRoles = async () => {
setLoadingRoles(true);
try {
const response = await api.get('/admin/roles');
// Filter to show only admin-type roles (not guest or member)
const staffRoles = response.data.filter(role =>
['admin', 'superadmin', 'finance'].includes(role.code) || !role.is_system_role
);
setRoles(staffRoles);
} catch (error) {
console.error('Failed to fetch roles:', error);
toast.error('Failed to load roles');
} finally {
setLoadingRoles(false);
}
};
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: null }));
}
};
const validate = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Invalid email format';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) {
return;
}
setLoading(true);
try {
const response = await api.post('/admin/users/invite', formData);
toast.success('Invitation sent successfully');
// Show invitation URL
setInvitationUrl(response.data.invitation_url);
// Don't close dialog yet - show invitation URL first
if (onSuccess) onSuccess();
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to send invitation';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
const copyToClipboard = () => {
navigator.clipboard.writeText(invitationUrl);
setCopied(true);
toast.success('Invitation link copied to clipboard');
setTimeout(() => setCopied(false), 2000);
};
const handleClose = () => {
// Reset form
setFormData({
email: '',
first_name: '',
last_name: '',
phone: '',
role: 'admin'
});
setInvitationUrl(null);
setCopied(false);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px] rounded-2xl">
<DialogHeader>
<DialogTitle className="text-2xl text-[#422268] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
<Mail className="h-6 w-6" />
{invitationUrl ? 'Invitation Sent' : 'Invite Staff Member'}
</DialogTitle>
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{invitationUrl
? 'The invitation has been sent via email. You can also copy the link below.'
: 'Send an email invitation to join as staff. They will set their own password.'}
</DialogDescription>
</DialogHeader>
{invitationUrl ? (
// Show invitation URL after successful send
<div className="py-4">
<Label className="text-[#422268] mb-2 block">Invitation Link (expires in 7 days)</Label>
<div className="flex gap-2">
<Input
value={invitationUrl}
readOnly
className="rounded-xl border-2 border-[#ddd8eb] bg-gray-50"
/>
<Button
onClick={copyToClipboard}
className="rounded-xl bg-[#664fa3] hover:bg-[#422268] text-white flex-shrink-0"
>
{copied ? (
<>
<Check className="h-4 w-4 mr-2" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
Copy
</>
)}
</Button>
</div>
</div>
) : (
// Show invitation form
<form onSubmit={handleSubmit}>
<div className="grid gap-6 py-4">
{/* Email */}
<div className="grid gap-2">
<Label htmlFor="email" className="text-[#422268]">
Email <span className="text-red-500">*</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="staff@example.com"
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
{/* First Name (Optional) */}
<div className="grid gap-2">
<Label htmlFor="first_name" className="text-[#422268]">
First Name <span className="text-gray-400">(Optional)</span>
</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="John"
/>
</div>
{/* Last Name (Optional) */}
<div className="grid gap-2">
<Label htmlFor="last_name" className="text-[#422268]">
Last Name <span className="text-gray-400">(Optional)</span>
</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => handleChange('last_name', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="Doe"
/>
</div>
{/* Phone (Optional) */}
<div className="grid gap-2">
<Label htmlFor="phone" className="text-[#422268]">
Phone <span className="text-gray-400">(Optional)</span>
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange('phone', e.target.value)}
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
placeholder="(555) 123-4567"
/>
</div>
{/* Role */}
<div className="grid gap-2">
<Label htmlFor="role" className="text-[#422268]">
Role <span className="text-red-500">*</span>
</Label>
<Select
value={formData.role}
onValueChange={(value) => handleChange('role', value)}
disabled={loadingRoles}
>
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
<SelectValue placeholder={loadingRoles ? "Loading roles..." : "Select a role"} />
</SelectTrigger>
<SelectContent>
{roles.map(role => (
<SelectItem key={role.id} value={role.code}>
<div className="flex items-center gap-2">
<span>{role.name}</span>
{role.is_system_role && (
<span className="text-xs text-gray-500">(System)</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{roles.length > 0 && (
<p className="text-xs text-gray-500">
{roles.find(r => r.code === formData.role)?.description || ''}
</p>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
className="rounded-xl"
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
<Mail className="h-4 w-4 mr-2" />
Send Invitation
</>
)}
</Button>
</DialogFooter>
</form>
)}
{invitationUrl && (
<DialogFooter>
<Button
onClick={handleClose}
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
>
Done
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
};
export default InviteStaffDialog;

View File

@@ -32,7 +32,7 @@ const Navbar = () => {
Welcome, {user.first_name}
</span>
)}
{user?.role === 'admin' && (
{(user?.role === 'admin' || user?.role === 'superadmin') && (
<Link to="/admin">
<button
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
@@ -320,7 +320,7 @@ const Navbar = () => {
{/* Footer Actions */}
<div className="p-4 border-t border-white/20 space-y-3">
{user?.role === 'admin' && (
{(user?.role === 'admin' || user?.role === 'superadmin') && (
<Link
to="/admin"
onClick={() => setIsMobileMenuOpen(false)}

View File

@@ -0,0 +1,235 @@
import React, { useEffect, useState } from 'react';
import api from '../utils/api';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from './ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from './ui/alert-dialog';
import { toast } from 'sonner';
import { Mail, Trash2, MailCheck, Clock } from 'lucide-react';
const PendingInvitationsTable = () => {
const [invitations, setInvitations] = useState([]);
const [loading, setLoading] = useState(true);
const [revokeDialog, setRevokeDialog] = useState({ open: false, invitation: null });
const [resending, setResending] = useState(null);
useEffect(() => {
fetchInvitations();
}, []);
const fetchInvitations = async () => {
try {
const response = await api.get('/admin/users/invitations?status=pending');
setInvitations(response.data);
} catch (error) {
toast.error('Failed to fetch invitations');
} finally {
setLoading(false);
}
};
const handleResend = async (invitationId) => {
setResending(invitationId);
try {
await api.post(`/admin/users/invitations/${invitationId}/resend`);
toast.success('Invitation resent successfully');
fetchInvitations(); // Refresh list to show new expiry date
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to resend invitation');
} finally {
setResending(null);
}
};
const handleRevoke = async () => {
if (!revokeDialog.invitation) return;
try {
await api.delete(`/admin/users/invitations/${revokeDialog.invitation.id}`);
toast.success('Invitation revoked');
setRevokeDialog({ open: false, invitation: null });
fetchInvitations(); // Refresh list
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to revoke invitation');
}
};
const getRoleBadge = (role) => {
const config = {
superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' },
admin: { label: 'Admin', className: 'bg-[#81B29A] text-white' },
member: { label: 'Member', className: 'bg-[#DDD8EB] text-[#422268]' }
};
const roleConfig = config[role] || { label: role, className: 'bg-gray-500 text-white' };
return (
<Badge className={`${roleConfig.className} px-3 py-1 rounded-full text-sm`}>
{roleConfig.label}
</Badge>
);
};
const isExpiringSoon = (expiresAt) => {
const expiry = new Date(expiresAt);
const now = new Date();
const hoursDiff = (expiry - now) / (1000 * 60 * 60);
return hoursDiff < 24 && hoursDiff > 0;
};
const formatDate = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const hoursDiff = (date - now) / (1000 * 60 * 60);
if (hoursDiff < 0) {
return 'Expired';
} else if (hoursDiff < 24) {
return `Expires in ${Math.round(hoursDiff)} hours`;
} else {
const daysDiff = Math.round(hoursDiff / 24);
return `Expires in ${daysDiff} day${daysDiff > 1 ? 's' : ''}`;
}
};
if (loading) {
return (
<div className="text-center py-8">
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Loading invitations...
</p>
</div>
);
}
if (invitations.length === 0) {
return (
<Card className="p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
<Mail className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
No Pending Invitations
</h3>
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
All invitations have been accepted or expired
</p>
</Card>
);
}
return (
<>
<Card className="bg-white rounded-2xl border border-[#ddd8eb] overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-[#DDD8EB] hover:bg-[#DDD8EB]">
<TableHead className="text-[#422268] font-semibold">Email</TableHead>
<TableHead className="text-[#422268] font-semibold">Name</TableHead>
<TableHead className="text-[#422268] font-semibold">Role</TableHead>
<TableHead className="text-[#422268] font-semibold">Invited</TableHead>
<TableHead className="text-[#422268] font-semibold">Expires</TableHead>
<TableHead className="text-[#422268] font-semibold text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invitations.map((invitation) => (
<TableRow key={invitation.id} className="hover:bg-[#F9F8FB]">
<TableCell className="font-medium text-[#422268]">
{invitation.email}
</TableCell>
<TableCell className="text-[#664fa3]">
{invitation.first_name && invitation.last_name
? `${invitation.first_name} ${invitation.last_name}`
: '-'}
</TableCell>
<TableCell>{getRoleBadge(invitation.role)}</TableCell>
<TableCell className="text-[#664fa3]">
{new Date(invitation.invited_at).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Clock className={`h-4 w-4 ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500' : 'text-[#664fa3]'}`} />
<span className={`text-sm ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500 font-semibold' : 'text-[#664fa3]'}`}>
{formatDate(invitation.expires_at)}
</span>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleResend(invitation.id)}
disabled={resending === invitation.id}
className="rounded-xl border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
>
{resending === invitation.id ? (
'Resending...'
) : (
<>
<MailCheck className="h-4 w-4 mr-1" />
Resend
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setRevokeDialog({ open: true, invitation })}
className="rounded-xl border-red-500 text-red-500 hover:bg-red-500 hover:text-white"
>
<Trash2 className="h-4 w-4 mr-1" />
Revoke
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Revoke Confirmation Dialog */}
<AlertDialog open={revokeDialog.open} onOpenChange={(open) => setRevokeDialog({ open, invitation: null })}>
<AlertDialogContent className="rounded-2xl">
<AlertDialogHeader>
<AlertDialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
Revoke Invitation
</AlertDialogTitle>
<AlertDialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Are you sure you want to revoke the invitation for{' '}
<span className="font-semibold">{revokeDialog.invitation?.email}</span>?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="rounded-xl">Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRevoke}
className="rounded-xl bg-red-500 hover:bg-red-600 text-white"
>
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
export default PendingInvitationsTable;

View File

@@ -50,12 +50,12 @@ const PublicFooter = () => {
<footer className="bg-gradient-to-r from-[#48286e] to-[#644c9f] border-t border-[rgba(0,0,0,0.1)] px-4 sm:px-8 md:px-16 py-6 md:py-8">
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 justify-between items-center max-w-7xl mx-auto">
<nav className="flex flex-col sm:flex-row gap-4 sm:gap-8 items-center order-1 sm:order-none">
<a href="/#terms" className="text-[#c5b4e3] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Inter', sans-serif" }}>
<Link to="/terms-of-service" className="text-[#c5b4e3] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Inter', sans-serif" }}>
Terms of Service
</a>
<a href="/#privacy" className="text-[#c5b4e3] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Inter', sans-serif" }}>
</Link>
<Link to="/privacy-policy" className="text-[#c5b4e3] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Inter', sans-serif" }}>
Privacy Policy
</a>
</Link>
</nav>
<p className="text-[#c5b4e3] text-sm sm:text-base font-medium text-center order-2 sm:order-none" style={{ fontFamily: "'Inter', sans-serif" }}>
© 2025 LOAF. All Rights Reserved.

View File

@@ -13,7 +13,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Your email is also your username that you can use to login.
Please note you can only login after your application is approved.
Please note you can only login after your application is validated.
</p>
</div>
@@ -69,6 +69,43 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
</p>
)}
</div>
{/* Terms of Service Acceptance */}
<div className="p-4 bg-[#F8F7FB] rounded-lg border border-[#ddd8eb]">
<div className="flex items-start gap-3">
<input
type="checkbox"
id="accepts_tos"
name="accepts_tos"
checked={formData.accepts_tos || false}
onChange={handleInputChange}
className="mt-1 w-4 h-4 text-[#664fa3] border-gray-300 rounded focus:ring-[#664fa3]"
required
data-testid="tos-checkbox"
/>
<label htmlFor="accepts_tos" className="text-sm text-gray-700" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
I agree to the{' '}
<a
href="/membership/terms-of-service"
target="_blank"
rel="noopener noreferrer"
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
>
Terms of Service
</a>
{' '}and{' '}
<a
href="/membership/privacy-policy"
target="_blank"
rel="noopener noreferrer"
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
>
Privacy Policy
</a>
<span className="text-red-500 ml-1">*</span>
</label>
</div>
</div>
</div>
</div>
);