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>
);
}