diff --git a/client/src/components/admin/DJManagement.tsx b/client/src/components/admin/DJManagement.tsx index db00391..dbc141e 100644 --- a/client/src/components/admin/DJManagement.tsx +++ b/client/src/components/admin/DJManagement.tsx @@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { useToast } from "@/hooks/use-toast"; import { apiRequest } from "@/lib/queryClient"; import { UserPlus, UserCheck, UserX, Shield, ShieldOff } from "lucide-react"; +import { InvitationManagement } from "./InvitationManagement"; export function DJManagement() { const { toast } = useToast(); @@ -238,26 +239,14 @@ export function DJManagement() { - {invitations && invitations.length > 0 && ( - - - Pending Invitations - - -
- {invitations.map((invitation: any) => ( -
-
-
{invitation.name}
-
{invitation.email}
-
- Pending -
- ))} -
-
-
- )} + + + Invitation Management + + + + + ); } diff --git a/client/src/components/admin/InvitationManagement.tsx b/client/src/components/admin/InvitationManagement.tsx new file mode 100644 index 0000000..96f31b6 --- /dev/null +++ b/client/src/components/admin/InvitationManagement.tsx @@ -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(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 ( +
+
+

Invitation Management

+ +
+ +
+ {invitations.map((invitation) => ( +
+
+
+
+ + {invitation.email} + + {invitation.isUsed ? 'Used' : isExpired(invitation.expiresAt) ? 'Expired' : 'Pending'} + +
+
+

Created: {new Date(invitation.createdAt).toLocaleDateString()}

+

Expires: {new Date(invitation.expiresAt).toLocaleDateString()}

+
+
+ +
+ {!invitation.isUsed && ( + + )} + +
+
+
+ ))} +
+ + {/* Resend Confirmation Dialog */} + + + + Resend Invitation + +

Are you sure you want to resend the invitation to {selectedInvitation?.email}?

+

+ This will generate a new invitation link and extend the expiration date by 7 days. +

+ + + + +
+
+ + {/* Manual User Creation Dialog */} + + + + Manual User Activation + +
+
+ + setManualUserData(prev => ({ ...prev, email: e.target.value }))} + placeholder="dj@example.com" + required + /> +
+ +
+
+ + setManualUserData(prev => ({ ...prev, firstName: e.target.value }))} + placeholder="John" + /> +
+
+ + setManualUserData(prev => ({ ...prev, lastName: e.target.value }))} + placeholder="Doe" + /> +
+
+ +
+ + setManualUserData(prev => ({ ...prev, displayName: e.target.value }))} + placeholder="DJ John" + /> +
+ +
+ +
+ setManualUserData(prev => ({ ...prev, tempPassword: e.target.value }))} + placeholder="Enter temporary password" + required + /> + +
+

+ The user will need to change this password on first login. +

+
+
+ + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index 5e12c25..a4ce06e 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -539,6 +539,90 @@ export async function registerRoutes(app: Express): Promise { } }); + // Resend invitation + app.post('/api/invitations/:id/resend', isAuthenticated, isAdmin, async (req, res) => { + try { + const invitationId = parseInt(req.params.id); + const invitation = await storage.getInvitation(invitationId); + + if (!invitation) { + return res.status(404).json({ message: "Invitation not found" }); + } + + // Generate new token and extend expiration + const newToken = crypto.randomBytes(32).toString('hex'); + const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + + const updatedInvitation = await storage.updateInvitation(invitationId, { + token: newToken, + expiresAt: newExpiresAt, + isUsed: false + }); + + res.json({ + message: "Invitation resent successfully", + invitation: updatedInvitation + }); + } catch (error) { + console.error("Error resending invitation:", error); + res.status(500).json({ message: "Failed to resend invitation" }); + } + }); + + // Delete invitation + app.delete('/api/invitations/:id', isAuthenticated, isAdmin, async (req, res) => { + try { + const invitationId = parseInt(req.params.id); + await storage.deleteInvitation(invitationId); + res.json({ message: "Invitation deleted successfully" }); + } catch (error) { + console.error("Error deleting invitation:", error); + res.status(500).json({ message: "Failed to delete invitation" }); + } + }); + + // Manual activation - create user directly + app.post('/api/users/create-manual', isAuthenticated, isAdmin, async (req, res) => { + try { + const { email, firstName, lastName, displayName, tempPassword } = req.body; + + if (!email || !tempPassword) { + return res.status(400).json({ message: "Email and temporary password are required" }); + } + + // Check if user already exists + const existingUser = await storage.getUserByEmail(email); + if (existingUser) { + return res.status(409).json({ message: "User with this email already exists" }); + } + + // Create user with temporary ID (will be replaced when they log in via Replit Auth) + const tempUserId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + const userData = { + id: tempUserId, + email, + firstName: firstName || '', + lastName: lastName || '', + displayName: displayName || `${firstName || ''} ${lastName || ''}`.trim(), + role: 'dj' as const, + isActive: true, + tempPassword, // Store temporarily - will be removed after first login + needsPasswordChange: true + }; + + const user = await storage.createUser(userData); + + res.json({ + message: "User created successfully", + user: { ...user, tempPassword: undefined } // Don't send password back + }); + } catch (error) { + console.error("Error creating user manually:", error); + res.status(500).json({ message: "Failed to create user manually" }); + } + }); + // Statistics routes app.get('/api/stats/dashboard', isAuthenticated, async (req: any, res) => { try { diff --git a/server/storage.ts b/server/storage.ts index 4e753b8..9a34808 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -42,7 +42,9 @@ import { eq, and, gte, lte, desc, asc, sql, inArray } from "drizzle-orm"; export interface IStorage { // User operations (IMPORTANT: mandatory for Replit Auth) getUser(id: string): Promise; + getUserByEmail(email: string): Promise; upsertUser(user: UpsertUser): Promise; + createUser(user: InsertUser): Promise; updateUser(id: string, updates: UpdateUser): Promise; getAllDJs(): Promise; deactivateUser(id: string): Promise; @@ -108,7 +110,10 @@ export interface IStorage { // Invitation operations createInvitation(invitation: InsertInvitation): Promise; + getInvitation(id: number): Promise; getInvitationByToken(token: string): Promise; + updateInvitation(id: number, updates: Partial): Promise; + deleteInvitation(id: number): Promise; markInvitationAsUsed(id: number): Promise; getActiveInvitations(): Promise; @@ -134,6 +139,16 @@ export class DatabaseStorage implements IStorage { return user; } + async getUserByEmail(email: string): Promise { + const [user] = await db.select().from(users).where(eq(users.email, email)); + return user; + } + + async createUser(userData: InsertUser): Promise { + const [user] = await db.insert(users).values(userData).returning(); + return user; + } + async upsertUser(userData: UpsertUser): Promise { const [user] = await db .insert(users) @@ -450,11 +465,29 @@ export class DatabaseStorage implements IStorage { return newInvitation; } + async getInvitation(id: number): Promise { + const [invitation] = await db.select().from(invitations).where(eq(invitations.id, id)); + return invitation; + } + async getInvitationByToken(token: string): Promise { const [invitation] = await db.select().from(invitations).where(eq(invitations.token, token)); return invitation; } + async updateInvitation(id: number, updates: Partial): Promise { + const [invitation] = await db + .update(invitations) + .set(updates) + .where(eq(invitations.id, id)) + .returning(); + return invitation; + } + + async deleteInvitation(id: number): Promise { + await db.delete(invitations).where(eq(invitations.id, id)); + } + async markInvitationAsUsed(id: number): Promise { await db.update(invitations).set({ isUsed: true }).where(eq(invitations.id, id)); } diff --git a/shared/schema.ts b/shared/schema.ts index 633100b..bb01042 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -43,6 +43,8 @@ export const users = pgTable("users", { role: userRoleEnum("role").default("dj").notNull(), isActive: boolean("is_active").default(true).notNull(), maxEventsPerMonth: integer("max_events_per_month").default(2).notNull(), + tempPassword: varchar("temp_password"), + needsPasswordChange: boolean("needs_password_change").default(false), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), });