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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user