Provide admin tools to manage and resend DJ invitations easily
Implements invitation management features, including resending and manual user creation, via new API endpoints and React components. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 3a22ac80-cd1d-4441-9e36-f24fc2f4c3de Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3478f7c3-db8c-4fca-9165-3adbdf1b5829/dbb6f90a-b277-4de1-a273-07d2fc2d56d1.jpg
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Mail, UserPlus, RotateCcw, Trash2 } from "lucide-react";
|
||||
|
||||
interface Invitation {
|
||||
id: number;
|
||||
email: string;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
isUsed: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface InvitationManagementProps {
|
||||
invitations: Invitation[];
|
||||
}
|
||||
|
||||
export function InvitationManagement({ invitations }: InvitationManagementProps) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isResendOpen, setIsResendOpen] = useState(false);
|
||||
const [isManualCreateOpen, setIsManualCreateOpen] = useState(false);
|
||||
const [selectedInvitation, setSelectedInvitation] = useState<Invitation | null>(null);
|
||||
|
||||
const [manualUserData, setManualUserData] = useState({
|
||||
email: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
displayName: "",
|
||||
tempPassword: ""
|
||||
});
|
||||
|
||||
// Resend invitation mutation
|
||||
const resendInvitationMutation = useMutation({
|
||||
mutationFn: async (invitationId: number) => {
|
||||
await apiRequest(`/api/invitations/${invitationId}/resend`, {
|
||||
method: "POST"
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Invitation resent successfully",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/invitations'] });
|
||||
setIsResendOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to resend invitation: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Delete invitation mutation
|
||||
const deleteInvitationMutation = useMutation({
|
||||
mutationFn: async (invitationId: number) => {
|
||||
await apiRequest(`/api/invitations/${invitationId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Invitation deleted successfully",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/invitations'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to delete invitation: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Manual user creation mutation
|
||||
const createUserMutation = useMutation({
|
||||
mutationFn: async (userData: typeof manualUserData) => {
|
||||
await apiRequest("/api/users/create-manual", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(userData),
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "User created successfully. They can now log in with their temporary password.",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/users'] });
|
||||
setIsManualCreateOpen(false);
|
||||
setManualUserData({
|
||||
email: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
displayName: "",
|
||||
tempPassword: ""
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: `Failed to create user: ${error.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleResend = (invitation: Invitation) => {
|
||||
setSelectedInvitation(invitation);
|
||||
setIsResendOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (invitation: Invitation) => {
|
||||
if (window.confirm(`Are you sure you want to delete the invitation for ${invitation.email}?`)) {
|
||||
deleteInvitationMutation.mutate(invitation.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualCreate = () => {
|
||||
setIsManualCreateOpen(true);
|
||||
};
|
||||
|
||||
const generateTempPassword = () => {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let password = "";
|
||||
for (let i = 0; i < 12; i++) {
|
||||
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
setManualUserData(prev => ({ ...prev, tempPassword: password }));
|
||||
};
|
||||
|
||||
const isExpired = (expiresAt: string) => {
|
||||
return new Date(expiresAt) < new Date();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">Invitation Management</h3>
|
||||
<Button onClick={handleManualCreate} className="bg-green-600 hover:bg-green-700">
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Manual Activate
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{invitations.map((invitation) => (
|
||||
<div
|
||||
key={invitation.id}
|
||||
className={`p-4 border rounded-lg ${
|
||||
invitation.isUsed
|
||||
? 'bg-green-50 border-green-200'
|
||||
: isExpired(invitation.expiresAt)
|
||||
? 'bg-red-50 border-red-200'
|
||||
: 'bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Mail className="w-4 h-4 text-gray-500" />
|
||||
<span className="font-medium">{invitation.email}</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
invitation.isUsed
|
||||
? 'bg-green-100 text-green-800'
|
||||
: isExpired(invitation.expiresAt)
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{invitation.isUsed ? 'Used' : isExpired(invitation.expiresAt) ? 'Expired' : 'Pending'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<p>Created: {new Date(invitation.createdAt).toLocaleDateString()}</p>
|
||||
<p>Expires: {new Date(invitation.expiresAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{!invitation.isUsed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleResend(invitation)}
|
||||
disabled={resendInvitationMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-1" />
|
||||
Resend
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(invitation)}
|
||||
disabled={deleteInvitationMutation.isPending}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Resend Confirmation Dialog */}
|
||||
<Dialog open={isResendOpen} onOpenChange={setIsResendOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Resend Invitation</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p>Are you sure you want to resend the invitation to {selectedInvitation?.email}?</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
This will generate a new invitation link and extend the expiration date by 7 days.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsResendOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => selectedInvitation && resendInvitationMutation.mutate(selectedInvitation.id)}
|
||||
disabled={resendInvitationMutation.isPending}
|
||||
>
|
||||
{resendInvitationMutation.isPending ? "Sending..." : "Resend"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Manual User Creation Dialog */}
|
||||
<Dialog open={isManualCreateOpen} onOpenChange={setIsManualCreateOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manual User Activation</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email">Email Address *</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={manualUserData.email}
|
||||
onChange={(e) => setManualUserData(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="dj@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="firstName">First Name</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={manualUserData.firstName}
|
||||
onChange={(e) => setManualUserData(prev => ({ ...prev, firstName: e.target.value }))}
|
||||
placeholder="John"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="lastName">Last Name</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={manualUserData.lastName}
|
||||
onChange={(e) => setManualUserData(prev => ({ ...prev, lastName: e.target.value }))}
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="displayName">Display Name</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={manualUserData.displayName}
|
||||
onChange={(e) => setManualUserData(prev => ({ ...prev, displayName: e.target.value }))}
|
||||
placeholder="DJ John"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tempPassword">Temporary Password *</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="tempPassword"
|
||||
type="text"
|
||||
value={manualUserData.tempPassword}
|
||||
onChange={(e) => setManualUserData(prev => ({ ...prev, tempPassword: e.target.value }))}
|
||||
placeholder="Enter temporary password"
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={generateTempPassword}
|
||||
size="sm"
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
The user will need to change this password on first login.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsManualCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => createUserMutation.mutate(manualUserData)}
|
||||
disabled={createUserMutation.isPending || !manualUserData.email || !manualUserData.tempPassword}
|
||||
>
|
||||
{createUserMutation.isPending ? "Creating..." : "Create User"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user