diff --git a/.replit b/.replit index 51f344f..5f05a64 100644 --- a/.replit +++ b/.replit @@ -14,6 +14,10 @@ run = ["npm", "run", "start"] localPort = 5000 externalPort = 80 +[[ports]] +localPort = 34677 +externalPort = 3000 + [workflows] runButton = "Project" diff --git a/client/src/components/admin/DJManagement.tsx b/client/src/components/admin/DJManagement.tsx index dbc141e..a9a8855 100644 --- a/client/src/components/admin/DJManagement.tsx +++ b/client/src/components/admin/DJManagement.tsx @@ -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(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) => ( - - {dj.isActive ? "Active" : "Inactive"} - - ), + cell: (dj: any) => { + // Use derivedActive which combines isActive flag + password presence (calculated server-side) + const derivedActive = dj.derivedActive; + const needsPasswordSetup = dj.isActive && !derivedActive; + + return ( +
+ + {derivedActive ? "Active" : "Inactive"} + + {dj.needsPasswordChange && ( + + Needs Password Change + + )} + {needsPasswordSetup && ( + + Needs Password Setup + + )} +
+ ); + }, }, { key: "maxEventsPerMonth" as const, @@ -146,31 +234,106 @@ export function DJManagement() { }, ]; - const actions = (dj: any) => ( -
- - - -
- ); + 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 ( +
+ {/* Set/Change Password */} + + + {/* Reactivate (only show if user is inactive) */} + {!derivedActive && ( + + )} + + {/* Admin Toggle */} + + + {/* Soft Deactivate (only show if active) */} + {derivedActive && ( + + )} + + {/* Hard Delete */} + +
+ ); + }; return (
@@ -224,13 +387,82 @@ export function DJManagement() {
+ {/* Password Management Dialog */} + + + + Set User Password + +
+
+

+ Setting a password for: {selectedUser?.displayName || selectedUser?.email} +

+

+ Users are considered active when they have a password set. +

+
+ +
+ +
+ setNewPassword(e.target.value)} + placeholder="Enter new password" + required + /> + +
+
+ +
+ setIsTemporaryPassword(e.target.checked)} + className="rounded" + /> + +
+
+
+ + +
+
+
+ Active DJs Invitation Management - + diff --git a/client/src/components/admin/InvitationManagement.tsx b/client/src/components/admin/InvitationManagement.tsx index 5cb24b9..b00c5f5 100644 --- a/client/src/components/admin/InvitationManagement.tsx +++ b/client/src/components/admin/InvitationManagement.tsx @@ -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)

Invitation Management

@@ -236,7 +236,7 @@ export function InvitationManagement({ invitations }: InvitationManagementProps) - Manual User Activation + Create New User
@@ -283,14 +283,14 @@ export function InvitationManagement({ invitations }: InvitationManagementProps)
- +
setManualUserData(prev => ({ ...prev, tempPassword: e.target.value }))} - placeholder="Enter temporary password" + placeholder="Enter initial password" required />

- 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.

diff --git a/client/src/components/profile/ProfileModal.tsx b/client/src/components/profile/ProfileModal.tsx index 9830b59..b8a022d 100644 --- a/client/src/components/profile/ProfileModal.tsx +++ b/client/src/components/profile/ProfileModal.tsx @@ -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({ diff --git a/package-lock.json b/package-lock.json index b6bffa5..39b41df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,9 @@ "@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.0", "@tanstack/react-query": "^5.60.5", + "@types/bcrypt": "^6.0.0", "@types/memoizee": "^0.4.12", + "bcrypt": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -3322,6 +3324,15 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -3753,6 +3764,20 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -6418,12 +6443,20 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "license": "ISC" }, - "node_modules/node-gyp-build": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz", - "integrity": "sha512-EMS95CMJzdoSKoIiXo8pxKoL8DYxwIZXYlLmgPb8KUv794abpnLK6ynsCAWNliOjREKruYKdzbh76HHYUHX7nw==", + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", "license": "MIT", - "optional": true, "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", diff --git a/package.json b/package.json index a05083a..cbd4e97 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,9 @@ "@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.0", "@tanstack/react-query": "^5.60.5", + "@types/bcrypt": "^6.0.0", "@types/memoizee": "^0.4.12", + "bcrypt": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/server/routes.ts b/server/routes.ts index e4e807b..a0a5a3e 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -613,7 +613,7 @@ export async function registerRoutes(app: Express): Promise { res.json({ message: "User created successfully", - user: { ...user, tempPassword: undefined } // Don't send password back + user }); } catch (error) { console.error("Error creating user manually:", error); @@ -628,6 +628,12 @@ export async function registerRoutes(app: Express): Promise { if (!password) { return res.status(400).json({ message: "Password is required" }); } + + // Basic password strength validation + if (password.length < 8) { + return res.status(400).json({ message: "Password must be at least 8 characters long" }); + } + await storage.setUserPassword(req.params.id, password, isTemporary || false); res.json({ message: "Password updated successfully" }); } catch (error) { @@ -658,7 +664,31 @@ export async function registerRoutes(app: Express): Promise { } }); - // Get user active status + // Get user status (for frontend to check password status without exposing passwords) + app.get('/api/users/:id/status', isAuthenticated, async (req: any, res) => { + try { + const userId = req.user.claims.sub; + const targetUserId = req.params.id; + const user = await storage.getUser(userId); + + // Allow users to check their own status or admins to check any status + if (userId !== targetUserId && user?.role !== "admin") { + return res.status(403).json({ message: "Permission denied" }); + } + + const status = await storage.getUserStatus(targetUserId); + res.json({ + isActive: status.derivedActive, + hasPassword: status.hasPassword, + needsPasswordChange: status.needsPasswordChange + }); + } catch (error) { + console.error("Error getting user status:", error); + res.status(500).json({ message: "Failed to get user status" }); + } + }); + + // Get user active status (admin only) app.get('/api/users/:id/active-status', isAuthenticated, isAdmin, async (req, res) => { try { const isActive = await storage.getUserActiveStatus(req.params.id); diff --git a/server/storage.ts b/server/storage.ts index 7d4a4f6..0dfe644 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -38,15 +38,40 @@ import { } from "@shared/schema"; import { db } from "./db"; import { eq, and, gte, lte, desc, asc, sql, inArray } from "drizzle-orm"; +import * as bcrypt from "bcrypt"; + +// Password hashing utilities +const SALT_ROUNDS = 12; + +export async function hashPassword(password: string): Promise { + return await bcrypt.hash(password, SALT_ROUNDS); +} + +export async function verifyPassword(password: string, hashedPassword: string): Promise { + return await bcrypt.compare(password, hashedPassword); +} + +// Safe user type without password fields +export type SafeUser = Omit; + +// Helper function to remove password fields from user objects +export function sanitizeUser(user: User): SafeUser { + const { password, tempPassword, ...safeUser } = user; + return safeUser; +} + +export function sanitizeUsers(users: User[]): SafeUser[] { + return users.map(sanitizeUser); +} 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; + getUser(id: string): Promise; + getUserByEmail(email: string): Promise; + upsertUser(user: UpsertUser): Promise; + createUser(user: InsertUser): Promise; + updateUser(id: string, updates: UpdateUser): Promise; + getAllDJs(): Promise<(SafeUser & { derivedActive: boolean })[]>; deactivateUser(id: string): Promise; reactivateUser(id: string): Promise; makeAdmin(id: string): Promise; @@ -55,7 +80,9 @@ export interface IStorage { deactivateUserSoft(id: string): Promise; deleteUserHard(id: string): Promise; getUserActiveStatus(id: string): Promise; - createUserWithPassword(userData: InsertUser & { password: string, isTemporary?: boolean }): Promise; + getUserStatus(id: string): Promise<{ hasPassword: boolean; isActiveFlag: boolean; derivedActive: boolean; needsPasswordChange: boolean }>; + createUserWithPassword(userData: InsertUser & { password: string, isTemporary?: boolean }): Promise; + verifyUserPassword(id: string, password: string): Promise; // Event operations createEvent(event: InsertEvent): Promise; @@ -139,34 +166,35 @@ export interface IStorage { export class DatabaseStorage implements IStorage { // User operations - async getUser(id: string): Promise { + async getUser(id: string): Promise { const [user] = await db.select().from(users).where(eq(users.id, id)); - return user; + return user ? sanitizeUser(user) : undefined; } - async getUserByEmail(email: string): Promise { + async getUserByEmail(email: string): Promise { const [user] = await db.select().from(users).where(eq(users.email, email)); - return user; + return user ? sanitizeUser(user) : undefined; } - async createUser(userData: InsertUser): Promise { + async createUser(userData: InsertUser): Promise { const [user] = await db.insert(users).values(userData).returning(); - return user; + return sanitizeUser(user); } - async createUserWithPassword(userData: InsertUser & { password: string, isTemporary?: boolean }): Promise { + async createUserWithPassword(userData: InsertUser & { password: string, isTemporary?: boolean }): Promise { const { password, isTemporary, ...userFields } = userData; + const hashedPassword = await hashPassword(password); const [user] = await db.insert(users).values({ ...userFields, - password, - tempPassword: isTemporary ? password : null, + password: hashedPassword, + tempPassword: isTemporary ? hashedPassword : null, needsPasswordChange: isTemporary || false, isActive: true // Activate immediately when password is provided }).returning(); - return user; + return sanitizeUser(user); } - async upsertUser(userData: UpsertUser): Promise { + async upsertUser(userData: UpsertUser): Promise { const [user] = await db .insert(users) .values(userData) @@ -178,20 +206,29 @@ export class DatabaseStorage implements IStorage { }, }) .returning(); - return user; + return sanitizeUser(user); } - async updateUser(id: string, updates: UpdateUser): Promise { + async updateUser(id: string, updates: UpdateUser): Promise { const [user] = await db .update(users) .set({ ...updates, updatedAt: new Date() }) .where(eq(users.id, id)) .returning(); - return user; + return sanitizeUser(user); } - async getAllDJs(): Promise { - return await db.select().from(users).where(eq(users.role, "dj")).orderBy(asc(users.displayName)); + async getAllDJs(): Promise<(SafeUser & { derivedActive: boolean })[]> { + const users_list = await db.select().from(users).where(eq(users.role, "dj")).orderBy(asc(users.displayName)); + + // Add derived active status based on password presence while sanitizing + return users_list.map(user => { + const sanitizedUser = sanitizeUser(user); + return { + ...sanitizedUser, + derivedActive: user.isActive && !!user.password + }; + }); } async deactivateUser(id: string): Promise { @@ -211,9 +248,10 @@ export class DatabaseStorage implements IStorage { } async setUserPassword(id: string, password: string, isTemporary: boolean = false): Promise { + const hashedPassword = await hashPassword(password); await db.update(users).set({ - password, - tempPassword: isTemporary ? password : null, + password: hashedPassword, + tempPassword: isTemporary ? hashedPassword : null, needsPasswordChange: isTemporary, isActive: true, // Activate user when password is set updatedAt: new Date() @@ -222,7 +260,14 @@ export class DatabaseStorage implements IStorage { async deactivateUserSoft(id: string): Promise { // Soft deactivation - hide from availability/scheduling but keep all data - await db.update(users).set({ isActive: false, updatedAt: new Date() }).where(eq(users.id, id)); + console.log(`[DEBUG] Attempting to deactivate user with id: ${id}`); + + const result = await db.update(users).set({ isActive: false, updatedAt: new Date() }).where(eq(users.id, id)); + console.log(`[DEBUG] Deactivation result:`, result); + + // Verify the update by reading the user back + const [updatedUser] = await db.select({ id: users.id, isActive: users.isActive }).from(users).where(eq(users.id, id)); + console.log(`[DEBUG] User after update:`, updatedUser); } async deleteUserHard(id: string): Promise { @@ -259,6 +304,39 @@ export class DatabaseStorage implements IStorage { return user ? user.isActive && !!user.password : false; } + async getUserStatus(id: string): Promise<{ hasPassword: boolean; isActiveFlag: boolean; derivedActive: boolean; needsPasswordChange: boolean }> { + const [user] = await db.select({ + isActive: users.isActive, + password: users.password, + needsPasswordChange: users.needsPasswordChange + }) + .from(users) + .where(eq(users.id, id)); + + if (!user) { + return { hasPassword: false, isActiveFlag: false, derivedActive: false, needsPasswordChange: false }; + } + + const hasPassword = !!user.password; + const isActiveFlag = user.isActive; + const derivedActive = isActiveFlag && hasPassword; + const needsPasswordChange = user.needsPasswordChange || false; + + return { hasPassword, isActiveFlag, derivedActive, needsPasswordChange }; + } + + async verifyUserPassword(id: string, password: string): Promise { + const [user] = await db.select({ password: users.password }) + .from(users) + .where(eq(users.id, id)); + + if (!user?.password) { + return false; + } + + return await verifyPassword(password, user.password); + } + // Event operations async createEvent(event: InsertEvent): Promise { const [newEvent] = await db.insert(events).values(event).returning();