Added Roles and Permissions. Some minor UI fixes

This commit is contained in:
2026-02-18 13:12:55 +02:00
parent f54cdd525d
commit dbd15c00f8
31 changed files with 1825 additions and 331 deletions

View 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)" }}>
&larr; {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>
);
}