Added Roles and Permissions. Some minor UI fixes
This commit is contained in:
283
frontend/src/settings/StaffDetail.jsx
Normal file
283
frontend/src/settings/StaffDetail.jsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import api from "../api/client";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import ConfirmDialog from "../components/ConfirmDialog";
|
||||
|
||||
const ROLE_COLORS = {
|
||||
sysadmin: { bg: "var(--danger-bg)", text: "var(--danger-text)" },
|
||||
admin: { bg: "#3b2a0a", text: "#f6ad55" },
|
||||
editor: { bg: "var(--badge-blue-bg)", text: "var(--badge-blue-text)" },
|
||||
user: { bg: "var(--bg-card-hover)", text: "var(--text-muted)" },
|
||||
};
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: "melodies", label: "Melodies" },
|
||||
{ key: "devices", label: "Devices" },
|
||||
{ key: "app_users", label: "App Users" },
|
||||
{ key: "equipment", label: "Equipment Notes" },
|
||||
];
|
||||
|
||||
const ACTIONS = ["view", "add", "edit", "delete"];
|
||||
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>{label}</dt>
|
||||
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>{children || "-"}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StaffDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [member, setMember] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [showResetPw, setShowResetPw] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [pwError, setPwError] = useState("");
|
||||
const [pwSuccess, setPwSuccess] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
loadStaff();
|
||||
}, [id]);
|
||||
|
||||
const loadStaff = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.get(`/staff/${id}`);
|
||||
setMember(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await api.delete(`/staff/${id}`);
|
||||
navigate("/settings/staff");
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setShowDelete(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
setPwError("");
|
||||
setPwSuccess("");
|
||||
if (newPassword.length < 6) {
|
||||
setPwError("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(`/staff/${id}/password`, { new_password: newPassword });
|
||||
setPwSuccess("Password updated successfully");
|
||||
setNewPassword("");
|
||||
setTimeout(() => {
|
||||
setShowResetPw(false);
|
||||
setPwSuccess("");
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setPwError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
||||
if (error) return (
|
||||
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
if (!member) return null;
|
||||
|
||||
const canEdit = !(user?.role === "admin" && member.role === "sysadmin");
|
||||
const canDelete = canEdit && member.id !== user?.sub;
|
||||
const roleColors = ROLE_COLORS[member.role] || ROLE_COLORS.user;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<button onClick={() => navigate("/settings/staff")} className="text-sm hover:underline mb-2 inline-block" style={{ color: "var(--accent)" }}>
|
||||
← Back to Staff
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>{member.name}</h1>
|
||||
<span className="px-2 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: roleColors.bg, color: roleColors.text }}>
|
||||
{member.role}
|
||||
</span>
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs rounded-full"
|
||||
style={
|
||||
member.is_active
|
||||
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
|
||||
: { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }
|
||||
}
|
||||
>
|
||||
{member.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => navigate(`/settings/staff/${id}/edit`)} className="px-4 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>
|
||||
Edit
|
||||
</button>
|
||||
<button onClick={() => setShowResetPw(true)} className="px-4 py-2 text-sm rounded-md transition-colors border" style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}>
|
||||
Reset Password
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button onClick={() => setShowDelete(true)} className="px-4 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--danger)", color: "var(--text-white)" }}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Account Info */}
|
||||
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Account Information</h2>
|
||||
<dl className="grid grid-cols-2 gap-4">
|
||||
<Field label="Name">{member.name}</Field>
|
||||
<Field label="Email">{member.email}</Field>
|
||||
<Field label="Role">
|
||||
<span className="px-2 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: roleColors.bg, color: roleColors.text }}>
|
||||
{member.role}
|
||||
</span>
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs rounded-full"
|
||||
style={
|
||||
member.is_active
|
||||
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
|
||||
: { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }
|
||||
}
|
||||
>
|
||||
{member.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</Field>
|
||||
<Field label="Document ID">
|
||||
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{member.id}</span>
|
||||
</Field>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* Permissions */}
|
||||
{(member.role === "editor" || member.role === "user") && member.permissions && (
|
||||
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--border-primary)" }}>
|
||||
<th className="px-3 py-2 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Section</th>
|
||||
{ACTIONS.map((a) => (
|
||||
<th key={a} className="px-3 py-2 text-center font-medium capitalize" style={{ color: "var(--text-secondary)" }}>{a}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{SECTIONS.map((sec) => {
|
||||
const sp = member.permissions[sec.key] || {};
|
||||
return (
|
||||
<tr key={sec.key} style={{ borderBottom: "1px solid var(--border-secondary)" }}>
|
||||
<td className="px-3 py-2 font-medium" style={{ color: "var(--text-primary)" }}>{sec.label}</td>
|
||||
{ACTIONS.map((a) => (
|
||||
<td key={a} className="px-3 py-2 text-center">
|
||||
{sp[a] ? (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Yes</span>
|
||||
) : (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>No</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>MQTT Access:</span>
|
||||
{member.permissions.mqtt ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Enabled</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>Disabled</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(member.role === "sysadmin" || member.role === "admin") && (
|
||||
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2>
|
||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
|
||||
{member.role === "sysadmin"
|
||||
? "SysAdmin has full access to all features and settings."
|
||||
: "Admin has full access to all features, except managing SysAdmin accounts and system-level settings."}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reset Password Dialog */}
|
||||
{showResetPw && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: "rgba(0,0,0,0.5)" }}>
|
||||
<div className="rounded-lg border p-6 w-full max-w-md" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||
<h3 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Reset Password</h3>
|
||||
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>Enter a new password for {member.name}.</p>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="New password"
|
||||
className="w-full px-3 py-2 rounded-md text-sm border mb-3"
|
||||
style={{ backgroundColor: "var(--bg-input)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }}
|
||||
/>
|
||||
{pwError && <p className="text-xs mb-2" style={{ color: "var(--danger-text)" }}>{pwError}</p>}
|
||||
{pwSuccess && <p className="text-xs mb-2" style={{ color: "var(--success-text)" }}>{pwSuccess}</p>}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => { setShowResetPw(false); setNewPassword(""); setPwError(""); setPwSuccess(""); }}
|
||||
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
|
||||
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetPassword}
|
||||
className="px-4 py-2 text-sm rounded-md cursor-pointer"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
Update Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDelete}
|
||||
title="Delete Staff Member"
|
||||
message={`Are you sure you want to delete "${member.name}" (${member.email})? This action cannot be undone.`}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setShowDelete(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
frontend/src/settings/StaffForm.jsx
Normal file
320
frontend/src/settings/StaffForm.jsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import api from "../api/client";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: "melodies", label: "Melodies" },
|
||||
{ key: "devices", label: "Devices" },
|
||||
{ key: "app_users", label: "App Users" },
|
||||
{ key: "equipment", label: "Equipment Notes" },
|
||||
];
|
||||
|
||||
const ACTIONS = ["view", "add", "edit", "delete"];
|
||||
|
||||
const DEFAULT_PERMS_EDITOR = {
|
||||
melodies: { view: true, add: true, edit: true, delete: true },
|
||||
devices: { view: true, add: true, edit: true, delete: true },
|
||||
app_users: { view: true, add: true, edit: true, delete: true },
|
||||
equipment: { view: true, add: true, edit: true, delete: true },
|
||||
mqtt: true,
|
||||
};
|
||||
|
||||
const DEFAULT_PERMS_USER = {
|
||||
melodies: { view: true, add: false, edit: false, delete: false },
|
||||
devices: { view: true, add: false, edit: false, delete: false },
|
||||
app_users: { view: true, add: false, edit: false, delete: false },
|
||||
equipment: { view: true, add: false, edit: false, delete: false },
|
||||
mqtt: false,
|
||||
};
|
||||
|
||||
export default function StaffForm() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const isEdit = !!id;
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
role: "user",
|
||||
is_active: true,
|
||||
});
|
||||
const [permissions, setPermissions] = useState({ ...DEFAULT_PERMS_USER });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
setLoading(true);
|
||||
api.get(`/staff/${id}`)
|
||||
.then((data) => {
|
||||
setForm({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
password: "",
|
||||
role: data.role,
|
||||
is_active: data.is_active,
|
||||
});
|
||||
if (data.permissions) {
|
||||
setPermissions(data.permissions);
|
||||
} else if (data.role === "editor") {
|
||||
setPermissions({ ...DEFAULT_PERMS_EDITOR });
|
||||
} else if (data.role === "user") {
|
||||
setPermissions({ ...DEFAULT_PERMS_USER });
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const handleRoleChange = (newRole) => {
|
||||
setForm((f) => ({ ...f, role: newRole }));
|
||||
if (newRole === "editor") {
|
||||
setPermissions({ ...DEFAULT_PERMS_EDITOR });
|
||||
} else if (newRole === "user") {
|
||||
setPermissions({ ...DEFAULT_PERMS_USER });
|
||||
}
|
||||
};
|
||||
|
||||
const togglePermission = (section, action) => {
|
||||
setPermissions((prev) => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section],
|
||||
[action]: !prev[section][action],
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleMqtt = () => {
|
||||
setPermissions((prev) => ({ ...prev, mqtt: !prev.mqtt }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const body = {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
body.is_active = form.is_active;
|
||||
if (form.role === "editor" || form.role === "user") {
|
||||
body.permissions = permissions;
|
||||
} else {
|
||||
body.permissions = null;
|
||||
}
|
||||
await api.put(`/staff/${id}`, body);
|
||||
navigate(`/settings/staff/${id}`);
|
||||
} else {
|
||||
body.password = form.password;
|
||||
if (form.role === "editor" || form.role === "user") {
|
||||
body.permissions = permissions;
|
||||
}
|
||||
const result = await api.post("/staff", body);
|
||||
navigate(`/settings/staff/${result.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
||||
|
||||
const roleOptions = user?.role === "sysadmin"
|
||||
? ["sysadmin", "admin", "editor", "user"]
|
||||
: ["editor", "user"]; // Admin can only create editor/user
|
||||
|
||||
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
|
||||
const inputStyle = { backgroundColor: "var(--bg-input)", color: "var(--text-primary)", borderColor: "var(--border-primary)" };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<button onClick={() => navigate(isEdit ? `/settings/staff/${id}` : "/settings/staff")} className="text-sm hover:underline mb-2 inline-block" style={{ color: "var(--accent)" }}>
|
||||
← {isEdit ? "Back to Staff Member" : "Back to Staff"}
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>{isEdit ? "Edit Staff Member" : "Add Staff Member"}</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
|
||||
{/* Basic Info */}
|
||||
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Account Information</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
required
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
required
|
||||
minLength={6}
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
placeholder="Min 6 characters"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Role</label>
|
||||
<select
|
||||
value={form.role}
|
||||
onChange={(e) => handleRoleChange(e.target.value)}
|
||||
className={`${inputClass} cursor-pointer`}
|
||||
style={inputStyle}
|
||||
>
|
||||
{roleOptions.map((r) => (
|
||||
<option key={r} value={r}>{r.charAt(0).toUpperCase() + r.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{isEdit && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Status</label>
|
||||
<select
|
||||
value={form.is_active ? "active" : "inactive"}
|
||||
onChange={(e) => setForm((f) => ({ ...f, is_active: e.target.value === "active" }))}
|
||||
className={`${inputClass} cursor-pointer`}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Permissions Matrix - only for editor/user */}
|
||||
{(form.role === "editor" || form.role === "user") && (
|
||||
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2>
|
||||
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>
|
||||
Configure which sections and actions this staff member can access.
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--border-primary)" }}>
|
||||
<th className="px-3 py-2 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Section</th>
|
||||
{ACTIONS.map((a) => (
|
||||
<th key={a} className="px-3 py-2 text-center font-medium capitalize" style={{ color: "var(--text-secondary)" }}>{a}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{SECTIONS.map((sec) => {
|
||||
const sp = permissions[sec.key] || {};
|
||||
return (
|
||||
<tr key={sec.key} style={{ borderBottom: "1px solid var(--border-secondary)" }}>
|
||||
<td className="px-3 py-3 font-medium" style={{ color: "var(--text-primary)" }}>{sec.label}</td>
|
||||
{ACTIONS.map((a) => (
|
||||
<td key={a} className="px-3 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!sp[a]}
|
||||
onChange={() => togglePermission(sec.key, a)}
|
||||
className="h-4 w-4 rounded cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!permissions.mqtt}
|
||||
onChange={toggleMqtt}
|
||||
className="h-4 w-4 rounded cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>MQTT Access</span>
|
||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
(Dashboard, Commands, Logs, WebSocket)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(form.role === "sysadmin" || form.role === "admin") && (
|
||||
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2>
|
||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
|
||||
{form.role === "sysadmin"
|
||||
? "SysAdmin has full access to all features and settings. No permission customization needed."
|
||||
: "Admin has full access to all features, except managing SysAdmin accounts and system-level settings. No permission customization needed."}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-6 py-2 text-sm rounded-md transition-colors cursor-pointer"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.6 : 1 }}
|
||||
>
|
||||
{saving ? "Saving..." : isEdit ? "Save Changes" : "Create Staff Member"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(isEdit ? `/settings/staff/${id}` : "/settings/staff")}
|
||||
className="px-6 py-2 text-sm rounded-md border transition-colors cursor-pointer"
|
||||
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
frontend/src/settings/StaffList.jsx
Normal file
209
frontend/src/settings/StaffList.jsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../api/client";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import SearchBar from "../components/SearchBar";
|
||||
import ConfirmDialog from "../components/ConfirmDialog";
|
||||
|
||||
const ROLE_OPTIONS = ["", "sysadmin", "admin", "editor", "user"];
|
||||
|
||||
const ROLE_COLORS = {
|
||||
sysadmin: { bg: "var(--danger-bg)", text: "var(--danger-text)" },
|
||||
admin: { bg: "#3b2a0a", text: "#f6ad55" },
|
||||
editor: { bg: "var(--badge-blue-bg)", text: "var(--badge-blue-text)" },
|
||||
user: { bg: "var(--bg-card-hover)", text: "var(--text-muted)" },
|
||||
};
|
||||
|
||||
function RoleBadge({ role }) {
|
||||
const colors = ROLE_COLORS[role] || ROLE_COLORS.user;
|
||||
return (
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs rounded-full capitalize"
|
||||
style={{ backgroundColor: colors.bg, color: colors.text }}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StaffList() {
|
||||
const [staff, setStaff] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const fetchStaff = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("search", search);
|
||||
if (roleFilter) params.set("role", roleFilter);
|
||||
const qs = params.toString();
|
||||
const data = await api.get(`/staff${qs ? `?${qs}` : ""}`);
|
||||
setStaff(data.staff);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStaff();
|
||||
}, [search, roleFilter]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await api.delete(`/staff/${deleteTarget.id}`);
|
||||
setDeleteTarget(null);
|
||||
fetchStaff();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const canDeleteStaff = (staffMember) => {
|
||||
if (staffMember.id === user?.sub) return false;
|
||||
if (user?.role === "admin" && staffMember.role === "sysadmin") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const canEditStaff = (staffMember) => {
|
||||
if (user?.role === "admin" && staffMember.role === "sysadmin") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const selectClass = "px-3 py-2 rounded-md text-sm cursor-pointer border";
|
||||
const selectStyle = {
|
||||
backgroundColor: "var(--bg-card)",
|
||||
color: "var(--text-primary)",
|
||||
borderColor: "var(--border-primary)",
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Staff</h1>
|
||||
<button
|
||||
onClick={() => navigate("/settings/staff/new")}
|
||||
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
Add Staff
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-3">
|
||||
<SearchBar onSearch={setSearch} placeholder="Search by name or email..." />
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)} className={selectClass} style={selectStyle}>
|
||||
<option value="">All Roles</option>
|
||||
{ROLE_OPTIONS.filter(Boolean).map((r) => (
|
||||
<option key={r} value={r}>{r.charAt(0).toUpperCase() + r.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm" style={{ color: "var(--text-muted)" }}>
|
||||
{staff.length} {staff.length === 1 ? "member" : "members"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||
) : staff.length === 0 ? (
|
||||
<div className="rounded-lg p-8 text-center text-sm border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
|
||||
No staff members found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg overflow-hidden border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
|
||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Email</th>
|
||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Role</th>
|
||||
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium w-24" style={{ color: "var(--text-secondary)" }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{staff.map((member) => (
|
||||
<tr
|
||||
key={member.id}
|
||||
onClick={() => navigate(`/settings/staff/${member.id}`)}
|
||||
className="cursor-pointer transition-colors"
|
||||
style={{ borderBottom: "1px solid var(--border-secondary)" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium" style={{ color: "var(--text-heading)" }}>{member.name}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{member.email}</td>
|
||||
<td className="px-4 py-3"><RoleBadge role={member.role} /></td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs rounded-full"
|
||||
style={
|
||||
member.is_active
|
||||
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
|
||||
: { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }
|
||||
}
|
||||
>
|
||||
{member.is_active ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
{canEditStaff(member) && (
|
||||
<button
|
||||
onClick={() => navigate(`/settings/staff/${member.id}/edit`)}
|
||||
className="hover:opacity-80 text-xs cursor-pointer"
|
||||
style={{ color: "var(--text-link)" }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{canDeleteStaff(member) && (
|
||||
<button
|
||||
onClick={() => setDeleteTarget(member)}
|
||||
className="hover:opacity-80 text-xs cursor-pointer"
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="Delete Staff Member"
|
||||
message={`Are you sure you want to delete "${deleteTarget?.name}" (${deleteTarget?.email})? This action cannot be undone.`}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user