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

@@ -6,6 +6,7 @@ import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
import ConfirmationDialog from '../../components/ConfirmationDialog';
const AdminUserView = () => {
const { userId } = useParams();
@@ -14,9 +15,14 @@ const AdminUserView = () => {
const [loading, setLoading] = useState(true);
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
const [resendVerificationLoading, setResendVerificationLoading] = useState(false);
const [subscriptions, setSubscriptions] = useState([]);
const [subscriptionsLoading, setSubscriptionsLoading] = useState(true);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [pendingAction, setPendingAction] = useState(null);
useEffect(() => {
fetchUserProfile();
fetchSubscriptions();
}, [userId]);
const fetchUserProfile = async () => {
@@ -31,52 +37,91 @@ const AdminUserView = () => {
}
};
const handleResetPassword = async () => {
const confirmed = window.confirm(
`Reset password for ${user.first_name} ${user.last_name}?\n\n` +
`A temporary password will be emailed to ${user.email}.\n` +
`They will be required to change it on next login.`
);
if (!confirmed) return;
setResetPasswordLoading(true);
const fetchSubscriptions = async () => {
try {
await api.put(`/admin/users/${userId}/reset-password`, {
force_change: true
});
toast.success(`Password reset email sent to ${user.email}`);
const response = await api.get(`/admin/subscriptions?user_id=${userId}`);
setSubscriptions(response.data);
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to reset password';
toast.error(errorMessage);
console.error('Failed to fetch subscriptions:', error);
} finally {
setResetPasswordLoading(false);
setSubscriptionsLoading(false);
}
};
const handleResendVerification = async () => {
const confirmed = window.confirm(
`Resend verification email to ${user.email}?`
);
const handleResetPasswordRequest = () => {
setPendingAction({ type: 'reset_password' });
setConfirmDialogOpen(true);
};
if (!confirmed) return;
const handleResendVerificationRequest = () => {
setPendingAction({ type: 'resend_verification' });
setConfirmDialogOpen(true);
};
setResendVerificationLoading(true);
const confirmAction = async () => {
if (!pendingAction) return;
try {
await api.post(`/admin/users/${userId}/resend-verification`);
toast.success(`Verification email sent to ${user.email}`);
// Refresh user data to get updated email_verified status if changed
await fetchUserProfile();
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to send verification email';
toast.error(errorMessage);
} finally {
setResendVerificationLoading(false);
const { type } = pendingAction;
setConfirmDialogOpen(false);
if (type === 'reset_password') {
setResetPasswordLoading(true);
try {
await api.put(`/admin/users/${userId}/reset-password`, {
force_change: true
});
toast.success(`Password reset email sent to ${user.email}`);
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to reset password';
toast.error(errorMessage);
} finally {
setResetPasswordLoading(false);
setPendingAction(null);
}
} else if (type === 'resend_verification') {
setResendVerificationLoading(true);
try {
await api.post(`/admin/users/${userId}/resend-verification`);
toast.success(`Verification email sent to ${user.email}`);
// Refresh user data to get updated email_verified status if changed
await fetchUserProfile();
} catch (error) {
const errorMessage = error.response?.data?.detail || 'Failed to send verification email';
toast.error(errorMessage);
} finally {
setResendVerificationLoading(false);
setPendingAction(null);
}
}
};
const getActionMessage = () => {
if (!pendingAction || !user) return {};
const { type } = pendingAction;
const userName = `${user.first_name} ${user.last_name}`;
if (type === 'reset_password') {
return {
title: 'Reset Password?',
description: `This will send a temporary password to ${user.email}. ${userName} will be required to change it on their next login.`,
variant: 'warning',
confirmText: 'Yes, Reset Password',
};
}
if (type === 'resend_verification') {
return {
title: 'Resend Verification Email?',
description: `This will send a new verification email to ${user.email}. ${userName} will need to click the link to verify their email address.`,
variant: 'info',
confirmText: 'Yes, Resend Email',
};
}
return {};
};
if (loading) return <div>Loading...</div>;
if (!user) return null;
@@ -141,7 +186,7 @@ const AdminUserView = () => {
</h2>
<div className="flex flex-wrap gap-3">
<Button
onClick={handleResetPassword}
onClick={handleResetPasswordRequest}
disabled={resetPasswordLoading}
variant="outline"
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9] rounded-full px-4 py-2 disabled:opacity-50"
@@ -152,7 +197,7 @@ const AdminUserView = () => {
{!user.email_verified && (
<Button
onClick={handleResendVerification}
onClick={handleResendVerificationRequest}
disabled={resendVerificationLoading}
variant="outline"
className="border-2 border-[#ff9e77] text-[#ff9e77] hover:bg-[#FFF3E0] rounded-full px-4 py-2 disabled:opacity-50"
@@ -223,10 +268,100 @@ const AdminUserView = () => {
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
Subscription Information
</h2>
{/* TODO: Fetch and display subscription data */}
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Subscription details coming soon...</p>
{subscriptionsLoading ? (
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading subscriptions...</p>
) : subscriptions.length === 0 ? (
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No subscriptions found for this member.</p>
) : (
<div className="space-y-6">
{subscriptions.map((sub) => (
<div key={sub.id} className="p-6 bg-[#F8F7FB] rounded-xl border border-[#ddd8eb]">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{sub.plan.name}
</h3>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.plan.billing_cycle}
</p>
</div>
<Badge className={
sub.status === 'active' ? 'bg-[#81B29A] text-white' :
sub.status === 'expired' ? 'bg-red-500 text-white' :
'bg-gray-400 text-white'
}>
{sub.status}
</Badge>
</div>
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div>
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Start Date</label>
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(sub.start_date).toLocaleDateString()}
</p>
</div>
{sub.end_date && (
<div>
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>End Date</label>
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(sub.end_date).toLocaleDateString()}
</p>
</div>
)}
<div>
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Base Amount</label>
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.base_subscription_cents / 100).toFixed(2)}
</p>
</div>
{sub.donation_cents > 0 && (
<div>
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Donation</label>
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.donation_cents / 100).toFixed(2)}
</p>
</div>
)}
<div>
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Paid</label>
<p className="text-[#422268] font-semibold" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.amount_paid_cents / 100).toFixed(2)}
</p>
</div>
{sub.payment_method && (
<div>
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Method</label>
<p className="text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.payment_method}
</p>
</div>
)}
{sub.stripe_subscription_id && (
<div className="md:col-span-2">
<label className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Stripe Subscription ID</label>
<p className="text-[#422268] text-xs font-mono" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.stripe_subscription_id}
</p>
</div>
)}
</div>
</div>
))}
</div>
)}
</Card>
)}
{/* Admin Action Confirmation Dialog */}
<ConfirmationDialog
open={confirmDialogOpen}
onOpenChange={setConfirmDialogOpen}
onConfirm={confirmAction}
loading={resetPasswordLoading || resendVerificationLoading}
{...getActionMessage()}
/>
</>
);
};