feat(users): implement EditUserModal for user updates and refactor user management logic
This commit is contained in:
1 parent
e488207bf8
commit
b12f0f9669
7 files changed
+519
-437
No files matched your search
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user