diff --git a/src/app/users/components/ActiveUsersPanel.tsx b/src/app/users/components/ActiveUsersPanel.tsx index 286b4d1..d2aa6d0 100644 --- a/src/app/users/components/ActiveUsersPanel.tsx +++ b/src/app/users/components/ActiveUsersPanel.tsx @@ -1,5 +1,5 @@ import TablePagination from "@/components/TablePagination"; -import { Clock, Edit3, Loader2, Lock, Shield, Trash2 } from "lucide-react"; +import { Clock, Edit3, Loader2, Lock, Trash2 } from "lucide-react"; import type { UserRecord } from "@/lib/api"; import { getRoleMeta } from "@/app/users/components/user-role-config"; import { @@ -12,8 +12,7 @@ interface ActiveUsersPanelProps { loading: boolean; items: UserRecord[]; currentUserId: string | undefined; - canManageAccess: boolean; - canManageRoles: boolean; + canManageUsers: boolean; mutatingKey: string | null; currentPage: number; totalPages: number; @@ -22,8 +21,7 @@ interface ActiveUsersPanelProps { endItem: number; emptyMessage: string; onPageChange: (page: number) => void; - onEditAccess: (user: UserRecord) => void; - onEditRole: (user: UserRecord) => void; + onEdit: (user: UserRecord) => void; onDelete: (user: UserRecord) => void | Promise; } @@ -31,8 +29,7 @@ export default function ActiveUsersPanel({ loading, items, currentUserId, - canManageAccess, - canManageRoles, + canManageUsers, mutatingKey, currentPage, totalPages, @@ -41,8 +38,7 @@ export default function ActiveUsersPanel({ endItem, emptyMessage, onPageChange, - onEditAccess, - onEditRole, + onEdit, onDelete, }: ActiveUsersPanelProps) { return ( @@ -98,13 +94,9 @@ export default function ActiveUsersPanel({ (assignment) => assignment.server.name, ), }); - const canEditAccess = - canManageAccess && - !(item.role === "SUPER_ADMIN" && currentUserId !== undefined); - const canEditRole = - canManageRoles && item.role !== "SUPER_ADMIN"; + const canEdit = canManageUsers && item.role !== "SUPER_ADMIN"; const canDeactivate = - canManageRoles && item.role !== "SUPER_ADMIN" && !isSelf; + canManageUsers && item.role !== "SUPER_ADMIN" && !isSelf; return ( @@ -243,29 +235,13 @@ export default function ActiveUsersPanel({ - -
-
-
-

- Edit Role -

-

- {user.email} -

-
-
- - {error ? ( -
- - {error} -
- ) : null} - -
- {manageableRoles.map((item) => { - const Icon = item.icon; - return ( - - ); - })} -
- -
- - -
-
- - - ); -} diff --git a/src/app/users/components/EditUserModal.tsx b/src/app/users/components/EditUserModal.tsx new file mode 100644 index 0000000..97f0699 --- /dev/null +++ b/src/app/users/components/EditUserModal.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { AlertCircle, Edit3, Loader2, X } from "lucide-react"; +import { useState } from "react"; +import type { Server as ServerRecord, UserRecord, UserRole } from "@/lib/api"; +import ServerAccessSelector from "@/app/users/components/ServerAccessSelector"; +import { manageableRoles } from "@/app/users/components/user-role-config"; + +export interface UpdateUserBody { + name: string; + email: string; + role: Exclude; + allServersAccess: boolean; + serverIds: string[]; + password?: string; +} + +interface EditUserModalProps { + user: UserRecord; + availableServers: ServerRecord[]; + onClose: () => void; + onSubmit: (payload: UpdateUserBody) => Promise; + submitting: boolean; + error: string; +} + +export default function EditUserModal({ + user, + availableServers, + onClose, + onSubmit, + submitting, + error, +}: EditUserModalProps) { + const [name, setName] = useState(user.name); + const [email, setEmail] = useState(user.email); + const [role, setRole] = useState>( + user.role as Exclude, + ); + const [allServersAccess, setAllServersAccess] = useState( + user.allServersAccess, + ); + const [serverIds, setServerIds] = useState( + user.serverAssignments.map((assignment) => assignment.serverId), + ); + const [password, setPassword] = useState(""); + + return ( +
+
event.stopPropagation()} + > + +
+
+

+ Edit User +

+

+ Update this organization user's access and sign-in details. +

+
+ + {error ? ( +
+ + {error} +
+ ) : null} + +
+ + +
+ + + +
+

+ Role +

+
+ {manageableRoles.map((item) => ( + + ))} +
+
+ +
+

+ Server Access +

+ { + setAllServersAccess(value); + if (value) setServerIds([]); + }} + onSelectionChange={setServerIds} + /> +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/app/users/components/ServerAccessModal.tsx b/src/app/users/components/ServerAccessModal.tsx deleted file mode 100644 index f2ba815..0000000 --- a/src/app/users/components/ServerAccessModal.tsx +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; - -import { AlertCircle, Loader2, Shield, X } from "lucide-react"; -import { useState } from "react"; -import type { Server as ServerRecord, UserRecord } from "@/lib/api"; -import ServerAccessSelector from "@/app/users/components/ServerAccessSelector"; - -interface ServerAccessModalProps { - user: UserRecord; - availableServers: ServerRecord[]; - onClose: () => void; - onSubmit: (payload: { - allServersAccess: boolean; - serverIds: string[]; - }) => Promise; - submitting: boolean; - error: string; -} - -export default function ServerAccessModal({ - user, - availableServers, - onClose, - onSubmit, - submitting, - error, -}: ServerAccessModalProps) { - const [allServersAccess, setAllServersAccess] = useState( - user.role === "SUPER_ADMIN" ? true : user.allServersAccess, - ); - const [serverIds, setServerIds] = useState( - user.serverAssignments.map((assignment) => assignment.serverId), - ); - - return ( -
-
event.stopPropagation()} - > - -
-
-
-

- Server Access -

-

- {user.email} -

-
-
- - {error ? ( -
- - {error} -
- ) : null} - - {user.role === "SUPER_ADMIN" ? ( -
- Super Admin always has access to all servers. -
- ) : ( - { - setAllServersAccess(value); - if (value) setServerIds([]); - }} - onSelectionChange={setServerIds} - /> - )} - -
- - -
-
-
-
- ); -} diff --git a/src/app/users/page.tsx b/src/app/users/page.tsx index 666c41b..f111bcd 100644 --- a/src/app/users/page.tsx +++ b/src/app/users/page.tsx @@ -11,6 +11,7 @@ import { UserInvitationRecord, UserRecord, UserRole, + UpdateUserBody, users as usersApi, servers as serversApi, } from "@/lib/api"; @@ -19,10 +20,9 @@ import { useTablePagination } from "@/lib/use-table-pagination"; import { useToastManager } from "@/lib/use-toast-manager"; import ActiveUsersPanel from "@/app/users/components/ActiveUsersPanel"; import ArchivedUsersPanel from "@/app/users/components/ArchivedUsersPanel"; -import EditRoleModal from "@/app/users/components/EditRoleModal"; +import EditUserModal from "@/app/users/components/EditUserModal"; import InviteUserModal from "@/app/users/components/InviteUserModal"; import PendingInvitesPanel from "@/app/users/components/PendingInvitesPanel"; -import ServerAccessModal from "@/app/users/components/ServerAccessModal"; import UsersRoleSummary from "@/app/users/components/UsersRoleSummary"; import UsersToolbar, { type UsersTabKey, @@ -57,12 +57,7 @@ export default function UsersPage() { email: string; expiresAt: string; } | null>(null); - const [editingRoleUser, setEditingRoleUser] = useState( - null, - ); - const [editingAccessUser, setEditingAccessUser] = useState( - null, - ); + const [editingUser, setEditingUser] = useState(null); const [modalError, setModalError] = useState(""); const [mutatingKey, setMutatingKey] = useState(null); const [copiedInviteId, setCopiedInviteId] = useState(null); @@ -72,8 +67,7 @@ export default function UsersPage() { const canInvite = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "OPERATOR"; const canViewUsers = canInvite; - const canManageAccess = canInvite; - const canManageRoles = currentUser?.role === "SUPER_ADMIN"; + const canManageUsers = currentUser?.role === "SUPER_ADMIN"; const activeUsers = useMemo( () => users.filter((user) => user.isActive), [users], @@ -87,18 +81,39 @@ export default function UsersPage() { if (!query) return activeUsers; return activeUsers.filter((user) => - [user.name, user.email, user.role, ...user.serverAssignments.map((assignment) => assignment.server.name)].some((value) => value.toLowerCase().includes(query)), + [ + user.name, + user.email, + user.role, + ...user.serverAssignments.map((assignment) => assignment.server.name), + ].some((value) => value.toLowerCase().includes(query)), ); }, [activeUsers, search]); const filteredInvitations = useMemo(() => { const query = search.trim().toLowerCase(); if (!query) return invitations; - return invitations.filter((invitation) => [invitation.name, invitation.email, invitation.role, ...invitation.servers.map((server) => server.name)].some((value) => value.toLowerCase().includes(query))); + + return invitations.filter((invitation) => + [ + invitation.name, + invitation.email, + invitation.role, + ...invitation.servers.map((server) => server.name), + ].some((value) => value.toLowerCase().includes(query)), + ); }, [invitations, search]); const filteredArchivedUsers = useMemo(() => { const query = search.trim().toLowerCase(); if (!query) return archivedUsers; - return archivedUsers.filter((user) => [user.name, user.email, user.role, ...user.serverAssignments.map((assignment) => assignment.server.name)].some((value) => value.toLowerCase().includes(query))); + + return archivedUsers.filter((user) => + [ + user.name, + user.email, + user.role, + ...user.serverAssignments.map((assignment) => assignment.server.name), + ].some((value) => value.toLowerCase().includes(query)), + ); }, [archivedUsers, search]); const usersPagination = useTablePagination({ items: filteredActiveUsers, @@ -208,58 +223,27 @@ export default function UsersPage() { } }; - const handleRoleUpdate = async (role: Exclude) => { - if (!editingRoleUser) return; + const handleUserUpdate = async (payload: UpdateUserBody) => { + if (!editingUser) return; setModalError(""); - setMutatingKey(`role:${editingRoleUser.id}`); + setMutatingKey(`edit:${editingUser.id}`); try { - await usersApi.updateRole(editingRoleUser.id, role); - const userName = editingRoleUser.name; - setEditingRoleUser(null); + await usersApi.update(editingUser.id, payload); + const userName = editingUser.name; + setEditingUser(null); await loadData(false); pushToast({ tone: "success", - title: "Role Updated", - message: `${userName} role updated successfully`, + title: "User Updated", + message: `${userName} has been updated successfully`, }); } catch (err: unknown) { const message = - err instanceof Error ? err.message : "Failed to update role"; + err instanceof Error ? err.message : "Failed to update user"; setModalError(message); pushToast({ tone: "error", - title: "Role Update Failed", - message, - }); - } finally { - setMutatingKey(null); - } - }; - - const handleAccessUpdate = async (payload: { - allServersAccess: boolean; - serverIds: string[]; - }) => { - if (!editingAccessUser) return; - setModalError(""); - setMutatingKey(`access:${editingAccessUser.id}`); - try { - await usersApi.updateServerAccess(editingAccessUser.id, payload); - const userName = editingAccessUser.name; - setEditingAccessUser(null); - await loadData(false); - pushToast({ - tone: "success", - title: "Access Updated", - message: `Server access updated for ${userName}`, - }); - } catch (err: unknown) { - const message = - err instanceof Error ? err.message : "Failed to update server access"; - setModalError(message); - pushToast({ - tone: "error", - title: "Access Update Failed", + title: "User Update Failed", message, }); } finally { @@ -435,23 +419,22 @@ export default function UsersPage() { loading={loading} items={usersPagination.paginatedItems} currentUserId={currentUser?.id} - canManageAccess={canManageAccess} - canManageRoles={canManageRoles} + canManageUsers={canManageUsers} mutatingKey={mutatingKey} currentPage={usersPagination.currentPage} totalPages={usersPagination.totalPages} totalItems={usersPagination.totalItems} startItem={usersPagination.startItem} endItem={usersPagination.endItem} - emptyMessage={search.trim() ? "No active users match your search." : "No active users found."} + emptyMessage={ + search.trim() + ? "No active users match your search." + : "No active users found." + } onPageChange={usersPagination.setCurrentPage} - onEditAccess={(user) => { + onEdit={(user) => { setModalError(""); - setEditingAccessUser(user); - }} - onEditRole={(user) => { - setModalError(""); - setEditingRoleUser(user); + setEditingUser(user); }} onDelete={handleDeleteUser} /> @@ -468,7 +451,11 @@ export default function UsersPage() { totalItems={invitationsPagination.totalItems} startItem={invitationsPagination.startItem} endItem={invitationsPagination.endItem} - emptyMessage={search.trim() ? "No pending invitations match your search." : "No pending invitations."} + emptyMessage={ + search.trim() + ? "No pending invitations match your search." + : "No pending invitations." + } onPageChange={invitationsPagination.setCurrentPage} onCopyFreshInviteLink={handleCopyFreshInviteLink} onRevokeInvitation={handleRevokeInvitation} @@ -484,7 +471,11 @@ export default function UsersPage() { totalItems={archivedUsersPagination.totalItems} startItem={archivedUsersPagination.startItem} endItem={archivedUsersPagination.endItem} - emptyMessage={search.trim() ? "No archived users match your search." : "No archived users."} + emptyMessage={ + search.trim() + ? "No archived users match your search." + : "No archived users." + } onPageChange={archivedUsersPagination.setCurrentPage} /> ) : null} @@ -511,30 +502,17 @@ export default function UsersPage() { result={inviteResult} /> )} - {editingRoleUser && ( - { - setEditingRoleUser(null); - setModalError(""); - }} - onSubmit={handleRoleUpdate} - submitting={mutatingKey === `role:${editingRoleUser.id}`} - error={modalError} - /> - )} - {editingAccessUser && ( - { - setEditingAccessUser(null); + setEditingUser(null); setModalError(""); }} - onSubmit={handleAccessUpdate} - submitting={mutatingKey === `access:${editingAccessUser.id}`} + onSubmit={handleUserUpdate} + submitting={mutatingKey === `edit:${editingUser.id}`} error={modalError} /> )} diff --git a/src/lib/api.ts b/src/lib/api.ts index ba59a02..afa58af 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -572,8 +572,7 @@ export function formatApiErrorValue(value: unknown): string { if (Array.isArray(value)) { return value .filter( - (item): item is string => - typeof item === "string" && item.trim() !== "", + (item): item is string => typeof item === "string" && item.trim() !== "", ) .join(" "); } @@ -591,14 +590,13 @@ export function formatApiErrorValue(value: unknown): string { } if (error.fieldErrors && typeof error.fieldErrors === "object") { - const fieldErrors = Object.values(error.fieldErrors).flatMap( - (messages) => - Array.isArray(messages) - ? messages.filter( - (message): message is string => - typeof message === "string" && message.trim() !== "", - ) - : [], + const fieldErrors = Object.values(error.fieldErrors).flatMap((messages) => + Array.isArray(messages) + ? messages.filter( + (message): message is string => + typeof message === "string" && message.trim() !== "", + ) + : [], ); if (fieldErrors.length > 0) { @@ -656,8 +654,7 @@ async function readApiErrorMessage( fallbackMessage, ); const message = - formatApiErrorValue(payload.error) || - formatApiErrorValue(payload.message); + formatApiErrorValue(payload.error) || formatApiErrorValue(payload.message); return message || buildResponseFallbackMessage(response, fallbackMessage); } catch (error) { @@ -937,6 +934,13 @@ export interface UpdateUserServerAccessBody { serverIds: string[]; } +export interface UpdateUserBody extends UpdateUserServerAccessBody { + name: string; + email: string; + role: Exclude; + password?: string; +} + export interface UserInvitationRecord { id: string; name: string; @@ -982,6 +986,9 @@ export const users = { create: (body: CreateUserBody) => post<{ success: boolean; data: UserRecord }>("/users", body), + update: (id: string, body: UpdateUserBody) => + patch<{ success: boolean; data: UserRecord }>(`/users/${id}`, body), + updateRole: (id: string, role: Exclude) => patch<{ success: boolean; data: Pick }>( `/users/${id}/role`, diff --git a/src/server/routes/users.ts b/src/server/routes/users.ts index aedd305..62fd866 100644 --- a/src/server/routes/users.ts +++ b/src/server/routes/users.ts @@ -32,6 +32,15 @@ const UserInvitationCreateSchema = z.object({ expiresInDays: z.number().int().min(1).max(30).default(7), }); +const UserUpdateSchema = z.object({ + email: z.string().trim().email(), + name: z.string().trim().min(2).max(120), + role: ManageableRoleSchema, + allServersAccess: z.boolean(), + serverIds: z.array(z.string()), + password: z.string().min(8).max(128).optional(), +}); + function createInviteToken() { const raw = crypto.randomBytes(32).toString("hex"); const hash = crypto.createHash("sha256").update(raw).digest("hex"); @@ -412,9 +421,148 @@ export async function usersRoutes(app: FastifyInstance) { }, ); + app.patch( + "/:id", + { preHandler: [requireRole("SUPER_ADMIN")] }, + async (req, reply) => { + const { id } = req.params as { id: string }; + const body = UserUpdateSchema.safeParse(req.body); + if (!body.success) { + return reply + .status(400) + .send({ success: false, error: body.error.flatten() }); + } + + const targetUser = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + email: true, + name: true, + role: true, + allServersAccess: true, + organizationMemberships: { select: { organizationId: true } }, + }, + }); + if (!targetUser) { + return reply + .status(404) + .send({ success: false, error: "User not found" }); + } + if (targetUser.role === "SUPER_ADMIN") { + return reply.status(400).send({ + success: false, + error: "Super Admin accounts are managed separately", + }); + } + + const membershipIds = targetUser.organizationMemberships.map( + (membership) => membership.organizationId, + ); + if ( + membershipIds.length !== 1 || + membershipIds[0] !== req.organizationId + ) { + return reply.status(409).send({ + success: false, + error: "Organization user account membership is invalid", + }); + } + + const existingEmailOwner = await prisma.user.findUnique({ + where: { email: body.data.email }, + select: { id: true }, + }); + if (existingEmailOwner && existingEmailOwner.id !== id) { + return reply + .status(409) + .send({ success: false, error: "Email already in use" }); + } + + let validServerIds: string[] = []; + try { + validServerIds = await resolveServerIds( + body.data.serverIds, + req.organizationId!, + ); + } catch (error) { + return reply.status(400).send({ + success: false, + error: + error instanceof Error ? error.message : "Invalid server selection", + }); + } + + const passwordHash = body.data.password + ? await bcrypt.hash(body.data.password, 12) + : undefined; + const updated = await prisma.$transaction(async (tx) => { + await tx.userServerAccess.deleteMany({ where: { userId: id } }); + + return tx.user.update({ + where: { id }, + data: { + name: body.data.name, + email: body.data.email, + role: body.data.role, + allServersAccess: body.data.allServersAccess, + ...(passwordHash ? { passwordHash } : {}), + serverAssignments: + body.data.allServersAccess || validServerIds.length === 0 + ? undefined + : { + create: validServerIds.map((serverId) => ({ serverId })), + }, + }, + select: { + id: true, + name: true, + email: true, + role: true, + isActive: true, + allServersAccess: true, + lastLogin: true, + createdAt: true, + serverAssignments: { + where: { server: { organizationId: req.organizationId! } }, + select: { + serverId: true, + server: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: "asc" }, + }, + }, + }); + }); + + await auditLog({ + userId: req.userId, + organizationId: req.organizationId, + action: "USER_UPDATE", + category: "AUTH", + level: "INFO", + message: `User "${updated.email}" updated`, + meta: { + targetUserId: id, + changed: { + name: targetUser.name !== updated.name, + email: targetUser.email !== updated.email, + role: targetUser.role !== updated.role, + password: Boolean(body.data.password), + serverAccess: + targetUser.allServersAccess !== updated.allServersAccess || + validServerIds.length > 0, + }, + }, + }); + + return reply.send({ success: true, data: updated }); + }, + ); + app.patch( "/:id/server-access", - { preHandler: [requireRole("OPERATOR")] }, + { preHandler: [requireRole("SUPER_ADMIN")] }, async (req, reply) => { const { id } = req.params as { id: string }; const body = ServerAccessSchema.safeParse(req.body);