Enhance DJ management with password reset and deactivation features

This commit introduces the ability to reset user passwords, both temporary and permanent, within the DJ management interface. It also adds soft deactivation and hard deletion options for users. The InvitationManagement component has been updated to improve clarity and user experience during the user creation process. Backend changes include password hashing using bcrypt, new API endpoints for status checks and password management, and modifications to storage functions to handle sanitized user data and password verification.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 3a22ac80-cd1d-4441-9e36-f24fc2f4c3de
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3478f7c3-db8c-4fca-9165-3adbdf1b5829/3a22ac80-cd1d-4441-9e36-f24fc2f4c3de/Yn4xAWn
This commit is contained in:
spliceboti
2025-09-12 21:18:07 +00:00
parent 60b5735588
commit 4e17b6d5c3
8 changed files with 460 additions and 77 deletions
+265 -33
View File
@@ -9,15 +9,19 @@ import { DataTable } from "@/components/ui/data-table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { UserPlus, UserCheck, UserX, Shield, ShieldOff } from "lucide-react";
import { UserPlus, UserCheck, UserX, Shield, ShieldOff, Key, UserMinus, Trash2 } from "lucide-react";
import { InvitationManagement } from "./InvitationManagement";
export function DJManagement() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [showInviteModal, setShowInviteModal] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [inviteName, setInviteName] = useState("");
const [inviteEmail, setInviteEmail] = useState("");
const [selectedUser, setSelectedUser] = useState<any>(null);
const [newPassword, setNewPassword] = useState("");
const [isTemporaryPassword, setIsTemporaryPassword] = useState(true);
const { data: djs, isLoading } = useQuery({
queryKey: ["/api/users"],
@@ -91,6 +95,72 @@ export function DJManagement() {
},
});
const setPasswordMutation = useMutation({
mutationFn: async (data: { userId: string; password: string; isTemporary: boolean }) => {
await apiRequest("POST", `/api/users/${data.userId}/set-password`, {
password: data.password,
isTemporary: data.isTemporary
});
},
onSuccess: () => {
toast({
title: "Password updated",
description: "User password has been updated successfully.",
});
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
setShowPasswordModal(false);
setNewPassword("");
setSelectedUser(null);
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const softDeactivateMutation = useMutation({
mutationFn: async (userId: string) => {
await apiRequest("POST", `/api/users/${userId}/deactivate-soft`);
},
onSuccess: () => {
toast({
title: "User deactivated",
description: "User has been deactivated (data preserved).",
});
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const hardDeleteMutation = useMutation({
mutationFn: async (userId: string) => {
await apiRequest("DELETE", `/api/users/${userId}/delete-hard`);
},
onSuccess: () => {
toast({
title: "User deleted",
description: "User and future data have been permanently deleted.",
});
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const handleInvite = () => {
if (!inviteName || !inviteEmail) {
toast({
@@ -134,11 +204,29 @@ export function DJManagement() {
{
key: "isActive" as const,
header: "Status",
cell: (dj: any) => (
<Badge variant={dj.isActive ? "default" : "destructive"}>
{dj.isActive ? "Active" : "Inactive"}
</Badge>
),
cell: (dj: any) => {
// Use derivedActive which combines isActive flag + password presence (calculated server-side)
const derivedActive = dj.derivedActive;
const needsPasswordSetup = dj.isActive && !derivedActive;
return (
<div className="flex flex-col gap-1">
<Badge variant={derivedActive ? "default" : "destructive"} data-testid={`status-${dj.id}`}>
{derivedActive ? "Active" : "Inactive"}
</Badge>
{dj.needsPasswordChange && (
<Badge variant="outline" className="text-xs" data-testid={`needs-password-change-${dj.id}`}>
Needs Password Change
</Badge>
)}
{needsPasswordSetup && (
<Badge variant="secondary" className="text-xs" data-testid={`needs-password-setup-${dj.id}`}>
Needs Password Setup
</Badge>
)}
</div>
);
},
},
{
key: "maxEventsPerMonth" as const,
@@ -146,31 +234,106 @@ export function DJManagement() {
},
];
const actions = (dj: any) => (
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => toggleUserStatusMutation.mutate({
userId: dj.id,
action: dj.isActive ? "deactivate" : "activate"
})}
>
{dj.isActive ? <UserX className="w-4 h-4" /> : <UserCheck className="w-4 h-4" />}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => toggleAdminMutation.mutate({
userId: dj.id,
action: dj.role === "admin" ? "remove-admin" : "make-admin"
})}
>
{dj.role === "admin" ? <ShieldOff className="w-4 h-4" /> : <Shield className="w-4 h-4" />}
</Button>
</div>
);
const handleSetPassword = (user: any) => {
setSelectedUser(user);
setShowPasswordModal(true);
};
const handleSoftDeactivate = (user: any) => {
if (window.confirm(`Are you sure you want to deactivate ${user.displayName || user.email}? This will hide them from scheduling but preserve their data.`)) {
softDeactivateMutation.mutate(user.id);
}
};
const handleHardDelete = (user: any) => {
if (window.confirm(`DANGER: Are you sure you want to permanently delete ${user.displayName || user.email} and all their future data? This cannot be undone.`)) {
hardDeleteMutation.mutate(user.id);
}
};
const generatePassword = () => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
let password = "";
for (let i = 0; i < 12; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
setNewPassword(password);
};
const actions = (dj: any) => {
// Use derivedActive which combines isActive flag + password presence
const derivedActive = dj.derivedActive;
return (
<div className="flex items-center space-x-1">
{/* Set/Change Password */}
<Button
variant="outline"
size="sm"
onClick={() => handleSetPassword(dj)}
title="Set/Change Password"
data-testid={`button-set-password-${dj.id}`}
>
<Key className="w-4 h-4" />
</Button>
{/* Reactivate (only show if user is inactive) */}
{!derivedActive && (
<Button
variant="outline"
size="sm"
onClick={() => toggleUserStatusMutation.mutate({
userId: dj.id,
action: "activate"
})}
title="Reactivate User"
data-testid={`button-reactivate-${dj.id}`}
>
<UserCheck className="w-4 h-4" />
</Button>
)}
{/* Admin Toggle */}
<Button
variant="outline"
size="sm"
onClick={() => toggleAdminMutation.mutate({
userId: dj.id,
action: dj.role === "admin" ? "remove-admin" : "make-admin"
})}
title={dj.role === "admin" ? "Remove Admin" : "Make Admin"}
data-testid={`button-admin-toggle-${dj.id}`}
>
{dj.role === "admin" ? <ShieldOff className="w-4 h-4" /> : <Shield className="w-4 h-4" />}
</Button>
{/* Soft Deactivate (only show if active) */}
{derivedActive && (
<Button
variant="outline"
size="sm"
onClick={() => handleSoftDeactivate(dj)}
title="Deactivate (Keep Data)"
data-testid={`button-soft-deactivate-${dj.id}`}
>
<UserMinus className="w-4 h-4" />
</Button>
)}
{/* Hard Delete */}
<Button
variant="outline"
size="sm"
onClick={() => handleHardDelete(dj)}
className="text-red-600 hover:text-red-700"
title="Permanently Delete"
data-testid={`button-hard-delete-${dj.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
);
};
return (
<div className="space-y-6">
@@ -224,13 +387,82 @@ export function DJManagement() {
</Dialog>
</div>
{/* Password Management Dialog */}
<Dialog open={showPasswordModal} onOpenChange={setShowPasswordModal}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Set User Password</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600 mb-2">
Setting a password for: <strong>{selectedUser?.displayName || selectedUser?.email}</strong>
</p>
<p className="text-xs text-gray-500">
Users are considered active when they have a password set.
</p>
</div>
<div>
<Label htmlFor="newPassword">New Password</Label>
<div className="flex gap-2">
<Input
id="newPassword"
type="text"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
required
/>
<Button
type="button"
variant="outline"
onClick={generatePassword}
size="sm"
>
Generate
</Button>
</div>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="isTemporary"
checked={isTemporaryPassword}
onChange={(e) => setIsTemporaryPassword(e.target.checked)}
className="rounded"
/>
<Label htmlFor="isTemporary" className="text-sm">
Require password change on next login
</Label>
</div>
</div>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setShowPasswordModal(false)}>
Cancel
</Button>
<Button
onClick={() => selectedUser && setPasswordMutation.mutate({
userId: selectedUser.id,
password: newPassword,
isTemporary: isTemporaryPassword
})}
disabled={setPasswordMutation.isPending || !newPassword}
>
{setPasswordMutation.isPending ? "Setting..." : "Set Password"}
</Button>
</div>
</DialogContent>
</Dialog>
<Card>
<CardHeader>
<CardTitle>Active DJs</CardTitle>
</CardHeader>
<CardContent>
<DataTable
data={djs || []}
data={Array.isArray(djs) ? djs : []}
columns={columns}
actions={actions}
searchable
@@ -244,7 +476,7 @@ export function DJManagement() {
<CardTitle>Invitation Management</CardTitle>
</CardHeader>
<CardContent>
<InvitationManagement invitations={invitations || []} />
<InvitationManagement invitations={Array.isArray(invitations) ? invitations : []} />
</CardContent>
</Card>
</div>
@@ -88,7 +88,7 @@ export function InvitationManagement({ invitations }: InvitationManagementProps)
onSuccess: () => {
toast({
title: "Success",
description: "User created successfully. They can now log in with their temporary password.",
description: "User created and activated successfully. They can now log in with their initial password.",
});
queryClient.invalidateQueries({ queryKey: ['/api/users'] });
setIsManualCreateOpen(false);
@@ -143,7 +143,7 @@ export function InvitationManagement({ invitations }: InvitationManagementProps)
<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
Create User
</Button>
</div>
@@ -236,7 +236,7 @@ export function InvitationManagement({ invitations }: InvitationManagementProps)
<Dialog open={isManualCreateOpen} onOpenChange={setIsManualCreateOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Manual User Activation</DialogTitle>
<DialogTitle>Create New User</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
@@ -283,14 +283,14 @@ export function InvitationManagement({ invitations }: InvitationManagementProps)
</div>
<div>
<Label htmlFor="tempPassword">Temporary Password *</Label>
<Label htmlFor="tempPassword">Initial 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"
placeholder="Enter initial password"
required
/>
<Button
@@ -303,7 +303,7 @@ export function InvitationManagement({ invitations }: InvitationManagementProps)
</Button>
</div>
<p className="text-sm text-gray-600 mt-1">
The user will need to change this password on first login.
User will be active once they have a password set. They will need to change this password on first login.
</p>
</div>
</div>
@@ -58,13 +58,17 @@ export function ProfileModal({ isOpen, onClose }: ProfileModalProps) {
const updateProfileMutation = useMutation({
mutationFn: async (data: any) => {
// Update display name
const updates: any = { displayName: data.displayName };
if (data.newPassword) {
updates.password = data.newPassword;
}
await apiRequest("PATCH", `/api/users/${user?.id}`, updates);
// Handle password change separately using secure endpoint
if (data.newPassword) {
await apiRequest("POST", `/api/users/${user?.id}/set-password`, {
password: data.newPassword,
isTemporary: false
});
}
},
onSuccess: () => {
toast({