update: Major Overhaul to all subsystems
This commit is contained in:
@@ -3,123 +3,278 @@ 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: "Issues and Notes" },
|
||||
];
|
||||
// ─── Default permission sets ───────────────────────────────────────────────
|
||||
|
||||
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 EDITOR_PERMS = {
|
||||
melodies: { view: true, add: true, delete: true, safe_edit: true, full_edit: true, archetype_access: true, settings_access: true, compose_access: true },
|
||||
devices: { view: true, add: true, delete: true, safe_edit: true, edit_bells: true, edit_clock: true, edit_warranty: true, full_edit: true, control: true },
|
||||
app_users: { view: true, add: true, delete: true, safe_edit: true, full_edit: true },
|
||||
issues_notes: { view: true, add: true, delete: true, edit: true },
|
||||
mail: { view: true, compose: true, reply: true },
|
||||
crm: { activity_log: true },
|
||||
crm_customers: { full_access: true, overview: true, orders_view: true, orders_edit: true, quotations_view: true, quotations_edit: true, comms_view: true, comms_log: true, comms_edit: true, comms_compose: true, add: true, delete: true, files_view: true, files_edit: true, devices_view: true, devices_edit: true },
|
||||
crm_products: { view: true, add: true, edit: true },
|
||||
mfg: { view_inventory: true, edit: true, provision: true, firmware_view: true, firmware_edit: true },
|
||||
api_reference: { access: true },
|
||||
mqtt: { access: 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,
|
||||
const USER_PERMS = {
|
||||
melodies: { view: true, add: false, delete: false, safe_edit: false, full_edit: false, archetype_access: false, settings_access: false, compose_access: false },
|
||||
devices: { view: true, add: false, delete: false, safe_edit: false, edit_bells: false, edit_clock: false, edit_warranty: false, full_edit: false, control: false },
|
||||
app_users: { view: true, add: false, delete: false, safe_edit: false, full_edit: false },
|
||||
issues_notes: { view: true, add: false, delete: false, edit: false },
|
||||
mail: { view: true, compose: false, reply: false },
|
||||
crm: { activity_log: false },
|
||||
crm_customers: { full_access: false, overview: true, orders_view: true, orders_edit: false, quotations_view: true, quotations_edit: false, comms_view: true, comms_log: false, comms_edit: false, comms_compose: false, add: false, delete: false, files_view: true, files_edit: false, devices_view: true, devices_edit: false },
|
||||
crm_products: { view: true, add: false, edit: false },
|
||||
mfg: { view_inventory: true, edit: false, provision: false, firmware_view: true, firmware_edit: false },
|
||||
api_reference: { access: false },
|
||||
mqtt: { access: false },
|
||||
};
|
||||
|
||||
function deepClone(obj) {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
// ─── Dependency rules ──────────────────────────────────────────────────────
|
||||
|
||||
function applyDependencies(section, key, value, prev) {
|
||||
const s = { ...prev[section] };
|
||||
s[key] = value;
|
||||
|
||||
if (value) {
|
||||
const VIEW_FORCING = ["add", "delete", "safe_edit", "full_edit", "edit_bells", "edit_clock", "edit_warranty", "control", "edit"];
|
||||
if (VIEW_FORCING.includes(key) && "view" in s) s.view = true;
|
||||
|
||||
if (section === "melodies" && key === "full_edit") s.safe_edit = true;
|
||||
if (section === "devices" && key === "full_edit") { s.safe_edit = true; s.edit_bells = true; s.edit_clock = true; s.edit_warranty = true; s.view = true; }
|
||||
if (section === "app_users" && key === "full_edit") s.safe_edit = true;
|
||||
|
||||
if (section === "crm_customers") {
|
||||
if (key === "full_access") Object.keys(s).forEach((k) => { s[k] = true; });
|
||||
if (key === "orders_edit") s.orders_view = true;
|
||||
if (key === "quotations_edit") s.quotations_view = true;
|
||||
if (key === "files_edit") s.files_view = true;
|
||||
if (key === "devices_edit") s.devices_view = true;
|
||||
if (["comms_log", "comms_edit", "comms_compose"].includes(key)) s.comms_view = true;
|
||||
}
|
||||
|
||||
if (section === "mfg" && key === "firmware_edit") s.firmware_view = true;
|
||||
if (section === "mfg" && key === "edit") s.view_inventory = true;
|
||||
if (section === "mfg" && key === "provision") s.view_inventory = true;
|
||||
}
|
||||
|
||||
return { ...prev, [section]: s };
|
||||
}
|
||||
|
||||
// ─── Pill button colors ────────────────────────────────────────────────────
|
||||
// Disabled / No Access → danger red
|
||||
// View (middle) → badge blue
|
||||
// Enabled / Edit → accent green
|
||||
|
||||
const PILL_STYLES = {
|
||||
off: {
|
||||
active: { bg: "var(--danger-bg)", border: "var(--danger)", color: "var(--danger-text)", fontWeight: 600 },
|
||||
inactive: { bg: "var(--bg-input)", border: "var(--border-primary)", color: "var(--text-muted)", fontWeight: 400 },
|
||||
},
|
||||
view: {
|
||||
active: { bg: "var(--badge-blue-bg)", border: "var(--badge-blue-text)", color: "var(--badge-blue-text)", fontWeight: 600 },
|
||||
inactive: { bg: "var(--bg-input)", border: "var(--border-primary)", color: "var(--text-muted)", fontWeight: 400 },
|
||||
},
|
||||
on: {
|
||||
active: { bg: "var(--success-bg)", border: "var(--accent)", color: "var(--success-text)", fontWeight: 600 },
|
||||
inactive: { bg: "var(--bg-input)", border: "var(--border-primary)", color: "var(--text-muted)", fontWeight: 400 },
|
||||
},
|
||||
};
|
||||
|
||||
function pillStyle(tone, isActive) {
|
||||
const s = PILL_STYLES[tone][isActive ? "active" : "inactive"];
|
||||
return { backgroundColor: s.bg, borderColor: s.border, color: s.color, fontWeight: s.fontWeight };
|
||||
}
|
||||
|
||||
// ─── Sub-components ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A permission row: label/description on the left, dual pill buttons on the right.
|
||||
* Disabled / Enabled (red / green)
|
||||
*/
|
||||
function PermRow({ label, description, value, onChange, disabled }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 py-2.5"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border-secondary)",
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
pointerEvents: disabled ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm" style={{ color: "var(--text-primary)" }}>{label}</span>
|
||||
{description && (
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex rounded-md overflow-hidden border flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(false)}
|
||||
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||
style={{ ...pillStyle("off", !value), borderRight: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
Disabled
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(true)}
|
||||
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||
style={pillStyle("on", !!value)}
|
||||
>
|
||||
Enabled
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 3-state row: No Access (red) | View (blue) | Edit (green)
|
||||
* value: "none" | "view" | "edit"
|
||||
*/
|
||||
function SegmentedRow({ label, description, value, onChange, disabled }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 py-2.5"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border-secondary)",
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
pointerEvents: disabled ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm" style={{ color: "var(--text-primary)" }}>{label}</span>
|
||||
{description && (
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex rounded-md overflow-hidden border flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("none")}
|
||||
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||
style={{ ...pillStyle("off", value === "none"), borderRight: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
No Access
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("view")}
|
||||
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||
style={{ ...pillStyle("view", value === "view"), borderRight: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("edit")}
|
||||
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||
style={pillStyle("on", value === "edit")}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Section card wrapper */
|
||||
function PermSection({ title, children }) {
|
||||
return (
|
||||
<section className="ui-section-card">
|
||||
<h2 className="ui-section-card__title-row">
|
||||
<span className="ui-section-card__title">{title}</span>
|
||||
</h2>
|
||||
<div className="pt-1">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ────────────────────────────────────────────────────────
|
||||
|
||||
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 [form, setForm] = useState({ name: "", email: "", password: "", role: "user", is_active: true });
|
||||
const [permissions, setPermissions] = useState(deepClone(USER_PERMS));
|
||||
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));
|
||||
}
|
||||
if (!isEdit) return;
|
||||
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) {
|
||||
const base = data.role === "editor" ? deepClone(EDITOR_PERMS) : deepClone(USER_PERMS);
|
||||
const merged = {};
|
||||
Object.keys(base).forEach((sec) => { merged[sec] = { ...base[sec], ...(data.permissions[sec] || {}) }; });
|
||||
setPermissions(merged);
|
||||
} else if (data.role === "editor") {
|
||||
setPermissions(deepClone(EDITOR_PERMS));
|
||||
} else {
|
||||
setPermissions(deepClone(USER_PERMS));
|
||||
}
|
||||
})
|
||||
.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 });
|
||||
}
|
||||
if (newRole === "editor") setPermissions(deepClone(EDITOR_PERMS));
|
||||
else if (newRole === "user") setPermissions(deepClone(USER_PERMS));
|
||||
};
|
||||
|
||||
const togglePermission = (section, action) => {
|
||||
setPermissions((prev) => ({
|
||||
...prev,
|
||||
[section]: {
|
||||
...prev[section],
|
||||
[action]: !prev[section][action],
|
||||
},
|
||||
}));
|
||||
const setPerm = (section, key, value) =>
|
||||
setPermissions((prev) => applyDependencies(section, key, value, prev));
|
||||
|
||||
const setSegmented = (section, viewKey, editKey, val) => {
|
||||
setPermissions((prev) => {
|
||||
const next = { ...prev, [section]: { ...prev[section] } };
|
||||
next[section][viewKey] = val !== "none";
|
||||
next[section][editKey] = val === "edit";
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleMqtt = () => {
|
||||
setPermissions((prev) => ({ ...prev, mqtt: !prev.mqtt }));
|
||||
const segVal = (section, viewKey, editKey) => {
|
||||
const s = permissions[section] || {};
|
||||
if (s[editKey]) return "edit";
|
||||
if (s[viewKey]) return "view";
|
||||
return "none";
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const body = {
|
||||
name: form.name,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
body.permissions = (form.role === "editor" || form.role === "user") ? 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;
|
||||
}
|
||||
if (form.role === "editor" || form.role === "user") body.permissions = permissions;
|
||||
const result = await api.post("/staff", body);
|
||||
navigate(`/settings/staff/${result.id}`);
|
||||
}
|
||||
@@ -134,18 +289,36 @@ export default function StaffForm() {
|
||||
|
||||
const roleOptions = user?.role === "sysadmin"
|
||||
? ["sysadmin", "admin", "editor", "user"]
|
||||
: ["editor", "user"]; // Admin can only create editor/user
|
||||
: ["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)" };
|
||||
|
||||
const showPerms = form.role === "editor" || form.role === "user";
|
||||
|
||||
const mel = permissions.melodies || {};
|
||||
const dev = permissions.devices || {};
|
||||
const usr = permissions.app_users || {};
|
||||
const iss = permissions.issues_notes || {};
|
||||
const mail = permissions.mail || {};
|
||||
const crm = permissions.crm || {};
|
||||
const cc = permissions.crm_customers || {};
|
||||
const cprod = permissions.crm_products || {};
|
||||
const mfg = permissions.mfg || {};
|
||||
const apir = permissions.api_reference || {};
|
||||
const mqtt = permissions.mqtt || {};
|
||||
|
||||
const ccLocked = !!cc.full_access;
|
||||
|
||||
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>
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||
{isEdit ? "Edit Staff Member" : "Add Staff Member"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -154,56 +327,29 @@ export default function StaffForm() {
|
||||
</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>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
||||
{/* ── Account Information ── */}
|
||||
<section className="ui-section-card">
|
||||
<h2 className="ui-section-card__header-title mb-4">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}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
@@ -212,12 +358,7 @@ export default function StaffForm() {
|
||||
{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}
|
||||
>
|
||||
<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>
|
||||
@@ -226,77 +367,234 @@ export default function StaffForm() {
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* ── Admin / SysAdmin notice ── */}
|
||||
{(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>
|
||||
<section className="ui-section-card">
|
||||
<h2 className="ui-section-card__header-title mb-3">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."
|
||||
? "SysAdmin has full god-mode 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">
|
||||
{/* ── Permission Sections ── */}
|
||||
{showPerms && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
{/* Melodies */}
|
||||
<PermSection title="Melodies">
|
||||
<PermRow label="View" value={mel.view} onChange={(v) => setPerm("melodies", "view", v)} />
|
||||
<PermRow label="Add" value={mel.add} onChange={(v) => setPerm("melodies", "add", v)} />
|
||||
<PermRow label="Delete" value={mel.delete} onChange={(v) => setPerm("melodies", "delete", v)} />
|
||||
<PermRow label="Safe Edit" value={mel.safe_edit} onChange={(v) => setPerm("melodies", "safe_edit", v)} description="Name, Description, Tone, Type, Steps, Colour, Tags" disabled={mel.full_edit} />
|
||||
<PermRow label="Full Edit" value={mel.full_edit} onChange={(v) => setPerm("melodies", "full_edit", v)} description="Edit everything — enables Safe Edit automatically" />
|
||||
<PermRow label="Archetype Access" value={mel.archetype_access} onChange={(v) => setPerm("melodies", "archetype_access", v)} description="Access the Archetype Editor" />
|
||||
<PermRow label="Settings Access" value={mel.settings_access} onChange={(v) => setPerm("melodies", "settings_access", v)} description="View & change global melody settings" />
|
||||
<PermRow label="Compose Access" value={mel.compose_access} onChange={(v) => setPerm("melodies", "compose_access", v)} description="Use the Composer to create melodies" />
|
||||
</PermSection>
|
||||
|
||||
{/* Devices */}
|
||||
<PermSection title="Devices">
|
||||
<PermRow label="View" value={dev.view} onChange={(v) => setPerm("devices", "view", v)} />
|
||||
<PermRow label="Add" value={dev.add} onChange={(v) => setPerm("devices", "add", v)} />
|
||||
<PermRow label="Delete" value={dev.delete} onChange={(v) => setPerm("devices", "delete", v)} />
|
||||
<PermRow label="Safe Edit" value={dev.safe_edit} onChange={(v) => setPerm("devices", "safe_edit", v)} description="Edit General Info tab only" disabled={dev.full_edit} />
|
||||
<PermRow label="Edit Bells" value={dev.edit_bells} onChange={(v) => setPerm("devices", "edit_bells", v)} description="Bell Mechanisms tab" disabled={dev.full_edit} />
|
||||
<PermRow label="Edit Clock & Alerts" value={dev.edit_clock} onChange={(v) => setPerm("devices", "edit_clock", v)} description="Clock & Alerts tab" disabled={dev.full_edit} />
|
||||
<PermRow label="Edit Warranty / Sub" value={dev.edit_warranty} onChange={(v) => setPerm("devices", "edit_warranty", v)} description="Warranty and Subscription tab" disabled={dev.full_edit} />
|
||||
<PermRow label="Full Edit" value={dev.full_edit} onChange={(v) => setPerm("devices", "full_edit", v)} description="Enables all edit options above" />
|
||||
<PermRow label="Control" value={dev.control} onChange={(v) => setPerm("devices", "control", v)} description="Send commands via the Control tab" />
|
||||
</PermSection>
|
||||
|
||||
{/* App Users */}
|
||||
<PermSection title="App Users">
|
||||
<PermRow label="View" value={usr.view} onChange={(v) => setPerm("app_users", "view", v)} />
|
||||
<PermRow label="Add" value={usr.add} onChange={(v) => setPerm("app_users", "add", v)} />
|
||||
<PermRow label="Delete" value={usr.delete} onChange={(v) => setPerm("app_users", "delete", v)} />
|
||||
<PermRow label="Safe Edit" value={usr.safe_edit} onChange={(v) => setPerm("app_users", "safe_edit", v)} description="Photo, Name, Email, Phone, Title only" disabled={usr.full_edit} />
|
||||
<PermRow label="Full Edit" value={usr.full_edit} onChange={(v) => setPerm("app_users", "full_edit", v)} description="Edit everything — enables Safe Edit automatically" />
|
||||
</PermSection>
|
||||
|
||||
{/* Issues & Notes */}
|
||||
<PermSection title="Issues & Notes">
|
||||
<p className="text-xs pb-2 pt-1" style={{ color: "var(--text-muted)", borderBottom: "1px solid var(--border-secondary)" }}>
|
||||
These permissions also apply to Notes linked from Device and User pages.
|
||||
</p>
|
||||
<PermRow label="View" value={iss.view} onChange={(v) => setPerm("issues_notes", "view", v)} />
|
||||
<PermRow label="Add" value={iss.add} onChange={(v) => setPerm("issues_notes", "add", v)} />
|
||||
<PermRow label="Delete" value={iss.delete} onChange={(v) => setPerm("issues_notes", "delete", v)} />
|
||||
<PermRow label="Edit" value={iss.edit} onChange={(v) => setPerm("issues_notes", "edit", v)} />
|
||||
</PermSection>
|
||||
|
||||
{/* Mail */}
|
||||
<PermSection title="Mail">
|
||||
<PermRow label="View Inbox" value={mail.view} onChange={(v) => setPerm("mail", "view", v)} />
|
||||
<PermRow label="Compose" value={mail.compose} onChange={(v) => setPerm("mail", "compose", v)} description="Send new emails" />
|
||||
<PermRow label="Reply" value={mail.reply} onChange={(v) => setPerm("mail", "reply", v)} description="Reply to existing emails" />
|
||||
</PermSection>
|
||||
|
||||
{/* CRM */}
|
||||
<PermSection title="CRM">
|
||||
<PermRow label="View Activity Log" value={crm.activity_log} onChange={(v) => setPerm("crm", "activity_log", v)} />
|
||||
</PermSection>
|
||||
|
||||
{/* CRM Products */}
|
||||
<PermSection title="CRM Products">
|
||||
<PermRow label="View" value={cprod.view} onChange={(v) => setPerm("crm_products", "view", v)} />
|
||||
<PermRow label="Add" value={cprod.add} onChange={(v) => setPerm("crm_products", "add", v)} />
|
||||
<PermRow label="Edit / Delete" value={cprod.edit} onChange={(v) => setPerm("crm_products", "edit", v)} />
|
||||
</PermSection>
|
||||
|
||||
{/* Manufacturing */}
|
||||
<PermSection title="Manufacturing">
|
||||
<PermRow label="View Inventory" value={mfg.view_inventory} onChange={(v) => setPerm("mfg", "view_inventory", v)} />
|
||||
<PermRow label="Edit" value={mfg.edit} onChange={(v) => setPerm("mfg", "edit", v)} description="Change device status, delete, download NVS Binary" />
|
||||
<PermRow label="Provision Device" value={mfg.provision} onChange={(v) => setPerm("mfg", "provision", v)} description="Flash devices via the Provisioning page" />
|
||||
<SegmentedRow
|
||||
label="Firmware"
|
||||
value={segVal("mfg", "firmware_view", "firmware_edit")}
|
||||
onChange={(val) => setSegmented("mfg", "firmware_view", "firmware_edit", val)}
|
||||
/>
|
||||
</PermSection>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── CRM Customers — full width ── */}
|
||||
<section className="ui-section-card">
|
||||
<h2 className="ui-section-card__title-row">
|
||||
<span className="ui-section-card__title">CRM Customers</span>
|
||||
</h2>
|
||||
|
||||
{/* Full Access row */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 mt-3 mb-4 p-3 rounded-md"
|
||||
style={{
|
||||
border: `1px solid ${cc.full_access ? "var(--accent)" : "var(--border-secondary)"}`,
|
||||
backgroundColor: cc.full_access ? "rgba(116,184,22,0.07)" : "transparent",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-semibold" style={{ color: cc.full_access ? "var(--accent)" : "var(--text-primary)" }}>
|
||||
Full Access
|
||||
</span>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
Enables all CRM Customer permissions. When active, individual settings are locked.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex rounded-md overflow-hidden border flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPerm("crm_customers", "full_access", false)}
|
||||
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||
style={{ ...pillStyle("off", !cc.full_access), borderRight: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
Disabled
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPerm("crm_customers", "full_access", true)}
|
||||
className="px-3 py-1.5 text-xs cursor-pointer transition-colors"
|
||||
style={pillStyle("on", !!cc.full_access)}
|
||||
>
|
||||
Enabled
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8">
|
||||
|
||||
{/* Left column */}
|
||||
<div>
|
||||
<PermRow
|
||||
label="Overview Tab"
|
||||
value={cc.overview}
|
||||
onChange={(v) => setPerm("crm_customers", "overview", v)}
|
||||
disabled={ccLocked}
|
||||
/>
|
||||
<SegmentedRow
|
||||
label="Orders"
|
||||
value={segVal("crm_customers", "orders_view", "orders_edit")}
|
||||
onChange={(val) => setSegmented("crm_customers", "orders_view", "orders_edit", val)}
|
||||
disabled={ccLocked}
|
||||
/>
|
||||
<SegmentedRow
|
||||
label="Quotations"
|
||||
value={segVal("crm_customers", "quotations_view", "quotations_edit")}
|
||||
onChange={(val) => setSegmented("crm_customers", "quotations_view", "quotations_edit", val)}
|
||||
disabled={ccLocked}
|
||||
/>
|
||||
<SegmentedRow
|
||||
label="Files & Media"
|
||||
value={segVal("crm_customers", "files_view", "files_edit")}
|
||||
onChange={(val) => setSegmented("crm_customers", "files_view", "files_edit", val)}
|
||||
disabled={ccLocked}
|
||||
/>
|
||||
<SegmentedRow
|
||||
label="Devices Tab"
|
||||
value={segVal("crm_customers", "devices_view", "devices_edit")}
|
||||
onChange={(val) => setSegmented("crm_customers", "devices_view", "devices_edit", val)}
|
||||
disabled={ccLocked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div>
|
||||
{/* Add + Delete on one row */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 py-2.5"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border-secondary)",
|
||||
opacity: ccLocked ? 0.4 : 1,
|
||||
pointerEvents: ccLocked ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
<span className="text-sm" style={{ color: "var(--text-primary)" }}>Customer</span>
|
||||
<div className="flex items-center gap-10 flex-shrink-0">
|
||||
{/* Add */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>Add:</span>
|
||||
<div className="flex rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)" }}>
|
||||
<button type="button" onClick={() => setPerm("crm_customers", "add", false)} className="px-3 py-1.5 text-xs cursor-pointer transition-colors" style={{ ...pillStyle("off", !cc.add), borderRight: "1px solid var(--border-primary)" }}>Disabled</button>
|
||||
<button type="button" onClick={() => setPerm("crm_customers", "add", true)} className="px-3 py-1.5 text-xs cursor-pointer transition-colors" style={pillStyle("on", !!cc.add)}>Enabled</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Delete */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>Delete:</span>
|
||||
<div className="flex rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)" }}>
|
||||
<button type="button" onClick={() => setPerm("crm_customers", "delete", false)} className="px-3 py-1.5 text-xs cursor-pointer transition-colors" style={{ ...pillStyle("off", !cc.delete), borderRight: "1px solid var(--border-primary)" }}>Disabled</button>
|
||||
<button type="button" onClick={() => setPerm("crm_customers", "delete", true)} className="px-3 py-1.5 text-xs cursor-pointer transition-colors" style={pillStyle("on", !!cc.delete)}>Enabled</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PermRow label="Comms: View" value={cc.comms_view} onChange={(v) => setPerm("crm_customers", "comms_view", v)} disabled={ccLocked} />
|
||||
<PermRow label="Comms: Log Entry" value={cc.comms_log} onChange={(v) => setPerm("crm_customers", "comms_log", v)} disabled={ccLocked} />
|
||||
<PermRow label="Comms: Edit Entries" value={cc.comms_edit} onChange={(v) => setPerm("crm_customers", "comms_edit", v)} disabled={ccLocked} />
|
||||
<PermRow label="Comms: Compose & Send" value={cc.comms_compose} onChange={(v) => setPerm("crm_customers", "comms_compose", v)} disabled={ccLocked} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── API Reference + MQTT ── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<PermSection title="API Reference">
|
||||
<PermRow label="Access API Reference Page" value={apir.access} onChange={(v) => setPerm("api_reference", "access", v)} />
|
||||
</PermSection>
|
||||
<PermSection title="MQTT">
|
||||
<PermRow label="MQTT Access" value={mqtt.access} onChange={(v) => setPerm("mqtt", "access", v)} description="Dashboard, Commands, Logs, WebSocket" />
|
||||
</PermSection>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Submit ── */}
|
||||
<div className="flex gap-3 pb-8">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
@@ -314,6 +612,7 @@ export default function StaffForm() {
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user