feat(users): implement EditUserModal for user updates and refactor user management logic

This commit is contained in:
KodekaTeam committed 2026-08-08 13:03:31 +07:00
1 parent e488207bf8
commit b12f0f9669
7 files changed
+519 -437

No files matched your search

+11 -35
View File
@@ -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<void>;
}
@@ -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 (
<tr key={item.id}>
@@ -243,29 +235,13 @@ export default function ActiveUsersPanel({
<button
className="btn btn-ghost"
style={{ padding: "5px 8px" }}
title="Server access"
onClick={() => onEditAccess(item)}
title={canEdit ? "Edit user" : "Protected Super Admin account"}
onClick={() => onEdit(item)}
disabled={
!canEditAccess ||
mutatingKey === `access:${item.id}`
!canEdit || mutatingKey === `edit:${item.id}`
}
>
{mutatingKey === `access:${item.id}` ? (
<Loader2 size={11} className="animate-spin" />
) : (
<Shield size={11} />
)}
</button>
<button
className="btn btn-ghost"
style={{ padding: "5px 8px" }}
title="Edit role"
onClick={() => onEditRole(item)}
disabled={
!canEditRole || mutatingKey === `role:${item.id}`
}
>
{mutatingKey === `role:${item.id}` ? (
{mutatingKey === `edit:${item.id}` ? (
<Loader2 size={11} className="animate-spin" />
) : (
<Edit3 size={11} />
-158
View File
@@ -1,158 +0,0 @@
"use client";
import { AlertCircle, Edit3, Loader2, X } from "lucide-react";
import { useState } from "react";
import type { UserRecord, UserRole } from "@/lib/api";
import { manageableRoles } from "@/app/users/components/user-role-config";
interface EditRoleModalProps {
user: UserRecord;
onClose: () => void;
onSubmit: (role: Exclude<UserRole, "SUPER_ADMIN">) => Promise<void>;
submitting: boolean;
error: string;
}
export default function EditRoleModal({
user,
onClose,
onSubmit,
submitting,
error,
}: EditRoleModalProps) {
const [role, setRole] = useState<Exclude<UserRole, "SUPER_ADMIN">>(
user.role as Exclude<UserRole, "SUPER_ADMIN">,
);
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-shell"
style={{ maxWidth: 420 }}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={onClose}
className="modal-close"
aria-label="Close edit role modal"
>
<X size={22} />
</button>
<div
className="modal"
style={{ maxWidth: 420 }}
>
<div
style={{
display: "flex",
alignItems: "center",
marginBottom: 20,
paddingRight: 36,
}}
>
<div>
<h3
style={{
fontSize: 15,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
Edit Role
</h3>
<p style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 2 }}>
{user.email}
</p>
</div>
</div>
{error ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
color: "#ef4444",
padding: "10px 12px",
borderRadius: 8,
fontSize: 12,
marginBottom: 12,
}}
>
<AlertCircle size={14} />
<span>{error}</span>
</div>
) : null}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{manageableRoles.map((item) => {
const Icon = item.icon;
return (
<label
key={item.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
background: "var(--bg-input)",
padding: "10px 12px",
borderRadius: 8,
border: `1px solid ${role === item.id ? item.color : "var(--border)"}`,
cursor: "pointer",
}}
>
<input
type="radio"
checked={role === item.id}
name="role"
onChange={() =>
setRole(item.id as Exclude<UserRole, "SUPER_ADMIN">)
}
style={{ accentColor: item.color }}
/>
<Icon size={13} style={{ color: item.color }} />
<div>
<p
style={{
fontSize: 12,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{item.label}
</p>
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
{item.desc}
</p>
</div>
</label>
);
})}
</div>
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>
Cancel
</button>
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={() => void onSubmit(role)}
disabled={submitting}
>
{submitting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Edit3 size={12} />
)}
Save Role
</button>
</div>
</div>
</div>
</div>
);
}
+277
View File
@@ -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<UserRole, "SUPER_ADMIN">;
allServersAccess: boolean;
serverIds: string[];
password?: string;
}
interface EditUserModalProps {
user: UserRecord;
availableServers: ServerRecord[];
onClose: () => void;
onSubmit: (payload: UpdateUserBody) => Promise<void>;
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<Exclude<UserRole, "SUPER_ADMIN">>(
user.role as Exclude<UserRole, "SUPER_ADMIN">,
);
const [allServersAccess, setAllServersAccess] = useState(
user.allServersAccess,
);
const [serverIds, setServerIds] = useState(
user.serverAssignments.map((assignment) => assignment.serverId),
);
const [password, setPassword] = useState("");
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-shell"
style={{ maxWidth: 640 }}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={onClose}
className="modal-close"
aria-label="Close edit user modal"
>
<X size={22} />
</button>
<div className="modal" style={{ maxWidth: 640 }}>
<div style={{ marginBottom: 20, paddingRight: 36 }}>
<h3
style={{
fontSize: 15,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
Edit User
</h3>
<p
style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 2 }}
>
Update this organization user&apos;s access and sign-in details.
</p>
</div>
{error ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
color: "#ef4444",
padding: "10px 12px",
borderRadius: 8,
fontSize: 12,
marginBottom: 12,
}}
>
<AlertCircle size={14} />
<span>{error}</span>
</div>
) : null}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 12,
}}
>
<label
style={{
display: "flex",
flexDirection: "column",
gap: 6,
fontSize: 12,
color: "var(--text-secondary)",
}}
>
Name
<input
className="input"
value={name}
onChange={(event) => setName(event.target.value)}
minLength={2}
required
/>
</label>
<label
style={{
display: "flex",
flexDirection: "column",
gap: 6,
fontSize: 12,
color: "var(--text-secondary)",
}}
>
Email
<input
className="input"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
</div>
<label
style={{
display: "flex",
flexDirection: "column",
gap: 6,
fontSize: 12,
color: "var(--text-secondary)",
marginTop: 12,
}}
>
New password{" "}
<span style={{ color: "var(--text-muted)" }}>
(leave blank to keep current)
</span>
<input
className="input"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
autoComplete="new-password"
/>
</label>
<div style={{ marginTop: 16 }}>
<p
style={{
fontSize: 12,
fontWeight: 600,
color: "var(--text-secondary)",
marginBottom: 8,
}}
>
Role
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: 8,
}}
>
{manageableRoles.map((item) => (
<label
key={item.id}
style={{
display: "flex",
alignItems: "center",
gap: 7,
padding: "9px 10px",
borderRadius: 8,
border: `1px solid ${role === item.id ? item.color : "var(--border)"}`,
background: "var(--bg-input)",
cursor: "pointer",
fontSize: 12,
}}
>
<input
type="radio"
name="role"
checked={role === item.id}
onChange={() =>
setRole(item.id as Exclude<UserRole, "SUPER_ADMIN">)
}
style={{ accentColor: item.color }}
/>
{item.label}
</label>
))}
</div>
</div>
<div style={{ marginTop: 16 }}>
<p
style={{
fontSize: 12,
fontWeight: 600,
color: "var(--text-secondary)",
marginBottom: 8,
}}
>
Server Access
</p>
<ServerAccessSelector
availableServers={availableServers}
allServersAccess={allServersAccess}
selectedServerIds={serverIds}
onAllServersChange={(value) => {
setAllServersAccess(value);
if (value) setServerIds([]);
}}
onSelectionChange={setServerIds}
/>
</div>
<div style={{ display: "flex", gap: 10, marginTop: 18 }}>
<button
className="btn btn-ghost"
style={{ flex: 1 }}
onClick={onClose}
disabled={submitting}
>
Cancel
</button>
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={() =>
void onSubmit({
name: name.trim(),
email: email.trim(),
role,
allServersAccess,
serverIds,
...(password ? { password } : {}),
})
}
disabled={submitting || name.trim().length < 2 || !email.trim()}
>
{submitting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Edit3 size={12} />
)}
Save User
</button>
</div>
</div>
</div>
</div>
);
}
@@ -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<void>;
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 (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-shell"
style={{ maxWidth: 600 }}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={onClose}
className="modal-close"
aria-label="Close server access modal"
>
<X size={22} />
</button>
<div
className="modal"
style={{ maxWidth: 600 }}
>
<div
style={{
display: "flex",
alignItems: "center",
marginBottom: 20,
paddingRight: 36,
}}
>
<div>
<h3
style={{
fontSize: 15,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
Server Access
</h3>
<p style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 2 }}>
{user.email}
</p>
</div>
</div>
{error ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
color: "#ef4444",
padding: "10px 12px",
borderRadius: 8,
fontSize: 12,
marginBottom: 12,
}}
>
<AlertCircle size={14} />
<span>{error}</span>
</div>
) : null}
{user.role === "SUPER_ADMIN" ? (
<div
style={{
padding: 14,
borderRadius: 10,
border: "1px solid rgba(239,68,68,0.25)",
background: "rgba(239,68,68,0.08)",
color: "var(--text-secondary)",
fontSize: 12,
}}
>
Super Admin always has access to all servers.
</div>
) : (
<ServerAccessSelector
availableServers={availableServers}
allServersAccess={allServersAccess}
selectedServerIds={serverIds}
onAllServersChange={(value) => {
setAllServersAccess(value);
if (value) setServerIds([]);
}}
onSelectionChange={setServerIds}
/>
)}
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>
Cancel
</button>
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={() => void onSubmit({ allServersAccess, serverIds })}
disabled={submitting}
>
{submitting ? (
<Loader2 size={12} className="animate-spin" />
) : (
<Shield size={12} />
)}
Save Access
</button>
</div>
</div>
</div>
</div>
);
}
+63 -85
View File
@@ -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<UserRecord | null>(
null,
);
const [editingAccessUser, setEditingAccessUser] = useState<UserRecord | null>(
null,
);
const [editingUser, setEditingUser] = useState<UserRecord | null>(null);
const [modalError, setModalError] = useState("");
const [mutatingKey, setMutatingKey] = useState<string | null>(null);
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(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<UserRole, "SUPER_ADMIN">) => {
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 && (
<EditRoleModal
key={editingRoleUser.id}
user={editingRoleUser}
onClose={() => {
setEditingRoleUser(null);
setModalError("");
}}
onSubmit={handleRoleUpdate}
submitting={mutatingKey === `role:${editingRoleUser.id}`}
error={modalError}
/>
)}
{editingAccessUser && (
<ServerAccessModal
key={editingAccessUser.id}
user={editingAccessUser}
{editingUser && (
<EditUserModal
key={editingUser.id}
user={editingUser}
availableServers={availableServers}
onClose={() => {
setEditingAccessUser(null);
setEditingUser(null);
setModalError("");
}}
onSubmit={handleAccessUpdate}
submitting={mutatingKey === `access:${editingAccessUser.id}`}
onSubmit={handleUserUpdate}
submitting={mutatingKey === `edit:${editingUser.id}`}
error={modalError}
/>
)}
+19 -12
View File
@@ -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<UserRole, "SUPER_ADMIN">;
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<UserRole, "SUPER_ADMIN">) =>
patch<{ success: boolean; data: Pick<UserRecord, "id" | "name" | "role"> }>(
`/users/${id}/role`,
+149 -1
View File
@@ -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);