Improve User Manager: Workbench-style permissions dialog, live mode support, double-click to open

- Permissions dialog now uses two-level UI: compact Edit User with >> buttons
  opening a separate grid dialog (Groups x Operator/Admin columns) with
  Clear All / Set All and auto-cascade (write implies read, admin implies operator)
- Provisioning has its own >> popup dialog
- Removed live mode restrictions for add/edit/delete user operations
- Broadened type detection and added safe int conversion for live mode values
- Double-click on tree node or canvas box opens special view (User Manager for
  UserService, Properties for other components)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 20:57:49 +03:00
co-authored by Claude Opus 4.6
parent 43549046e5
commit d5ef8eea6f
3 changed files with 264 additions and 76 deletions
+225 -75
View File
@@ -99,6 +99,18 @@ def _get_prop(comp, name):
return prop.get("val") if prop is not None else None
def _safe_int(val):
if val is None:
return 0
try:
return int(val)
except (TypeError, ValueError):
try:
return int(float(val))
except (TypeError, ValueError):
return 0
def _get_users(service_comp):
"""Returns child comp elements, excluding uncached placeholders."""
return [c for c in service_comp
@@ -124,23 +136,176 @@ def is_user_service(comp):
if comp is None:
return False
type_name = comp.get("type", "")
if type_name.endswith("::UserService") or type_name == "UserService":
if "UserService" in type_name:
return True
for child in comp:
if child.tag == "comp" and child.get("type", "").endswith("::User"):
if child.tag == "comp" and "User" in child.get("type", ""):
return True
return False
class _PermissionsDialog:
"""Workbench-style permissions grid: Groups x Operator/Admin columns."""
def __init__(self, parent, perm_val):
self.result = None
groups = _decode_perm(perm_val)
self._dlg = tk.Toplevel(parent)
self._dlg.title("Permissions")
self._dlg.transient(parent)
self._dlg.grab_set()
self._dlg.resizable(False, False)
grid = tk.Frame(self._dlg, padx=8, pady=8)
grid.pack(fill=tk.BOTH, expand=True)
COL_HEADERS = [
("Operator", ["Read", "Write", "Invoke"]),
("Admin", ["Read", "Write", "Invoke", "User"]),
]
# header row 0: "Groups" + category spans
tk.Label(grid, text="Groups", relief=tk.GROOVE, bg="#e8e8e8",
padx=4, pady=2).grid(row=0, column=0, rowspan=2, sticky=tk.NSEW)
col = 1
for cat_name, sub_cols in COL_HEADERS:
span = len(sub_cols)
tk.Label(grid, text=cat_name, relief=tk.GROOVE, bg="#d8e8f0",
font=("TkDefaultFont", 8, "bold"),
padx=4, pady=2).grid(row=0, column=col, columnspan=span,
sticky=tk.NSEW)
col += span
# header row 1: sub-column names
col = 1
for _, sub_cols in COL_HEADERS:
for sub in sub_cols:
tk.Label(grid, text=sub, relief=tk.GROOVE, bg="#e8e8e8",
font=("TkDefaultFont", 7), padx=4, pady=1).grid(
row=1, column=col, sticky=tk.NSEW)
col += 1
# data rows
self._perm_vars = []
for g in range(4):
r = g + 2
tk.Label(grid, text=f"Group {g + 1}", relief=tk.GROOVE,
bg="#f4f4f4", padx=4, pady=1).grid(row=r, column=0,
sticky=tk.NSEW)
gvars = []
for f_idx in range(7):
var = tk.BooleanVar(value=groups[g][f_idx])
var.trace_add("write", lambda *_a, g=g, f=f_idx: self._on_perm_toggle(g, f))
ttk.Checkbutton(grid, variable=var).grid(
row=r, column=f_idx + 1, padx=6, pady=1)
gvars.append(var)
self._perm_vars.append(gvars)
# Clear All / Set All
action_frame = tk.Frame(grid)
action_frame.grid(row=6, column=0, columnspan=8, sticky=tk.E, pady=(6, 0))
ttk.Button(action_frame, text="Clear All", width=8,
command=self._clear_all).pack(side=tk.LEFT, padx=2)
ttk.Button(action_frame, text="Set All", width=8,
command=self._set_all).pack(side=tk.LEFT, padx=2)
# OK / Cancel
btn_frame = tk.Frame(self._dlg, pady=6)
btn_frame.pack()
ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack(
side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack(
side=tk.LEFT, padx=4)
self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel)
self._dlg.wait_window()
def _on_perm_toggle(self, group, flag_idx):
"""Auto-cascade: write implies read, admin implies operator."""
gvars = self._perm_vars[group]
if not gvars[flag_idx].get():
return
# Flags: 0=or, 1=ow, 2=oi, 3=ar, 4=aw, 5=ai, 6=ua
# Write (1) → Read (0); Admin Write (4) → Admin Read (3)
if flag_idx in (1, 4):
gvars[flag_idx - 1].set(True)
# Admin flag (3,4,5) → same Operator flag (0,1,2)
if 3 <= flag_idx <= 5:
gvars[flag_idx - 3].set(True)
def _clear_all(self):
for gvars in self._perm_vars:
for v in gvars:
v.set(False)
def _set_all(self):
for gvars in self._perm_vars:
for v in gvars:
v.set(True)
def _on_ok(self):
groups = [[v.get() for v in gvars] for gvars in self._perm_vars]
self.result = _encode_perm(groups)
self._dlg.destroy()
def _on_cancel(self):
self._dlg.destroy()
class _ProvisioningDialog:
"""Provisioning flags popup: app, kits, svm checkboxes."""
def __init__(self, parent, prov_val):
self.result = None
flags = _decode_prov(prov_val)
self._dlg = tk.Toplevel(parent)
self._dlg.title("Provisioning Permissions")
self._dlg.transient(parent)
self._dlg.grab_set()
self._dlg.resizable(False, False)
main = ttk.Frame(self._dlg, padding=10)
main.pack(fill=tk.BOTH, expand=True)
self._prov_vars = {}
for name, _ in PROV_FLAGS:
var = tk.BooleanVar(value=flags.get(name, False))
ttk.Checkbutton(main, text=name, variable=var).pack(
anchor=tk.W, pady=2)
self._prov_vars[name] = var
btn_frame = ttk.Frame(main)
btn_frame.pack(pady=(10, 0))
ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack(
side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack(
side=tk.LEFT, padx=4)
self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel)
self._dlg.wait_window()
def _on_ok(self):
self.result = _encode_prov(
{name: var.get() for name, var in self._prov_vars.items()})
self._dlg.destroy()
def _on_cancel(self):
self._dlg.destroy()
class _UserDialog:
"""Modal dialog for adding or editing a user."""
"""Compact Edit/Add User dialog with >> buttons opening Permissions/Provisioning popups."""
def __init__(self, parent, service_comp, user_elem, on_done):
self._service_comp = service_comp
self._user_elem = user_elem
self._on_done = on_done
self._is_edit = user_elem is not None
self.result = None
self._perm_val = _safe_int(_get_prop(user_elem, "perm")) if self._is_edit else 0
self._prov_val = _safe_int(_get_prop(user_elem, "prov")) if self._is_edit else 0
self._dlg = tk.Toplevel(parent)
self._dlg.title("Edit User" if self._is_edit else "Add User")
@@ -152,81 +317,78 @@ class _UserDialog:
main.pack(fill=tk.BOTH, expand=True)
row = 0
ttk.Label(main, text="Username:").grid(row=row, column=0, sticky=tk.W, pady=2)
ttk.Label(main, text="Username").grid(row=row, column=0, sticky=tk.W, pady=2)
self._name_var = tk.StringVar(
value=user_elem.get("name", "") if self._is_edit else "")
name_entry = ttk.Entry(main, textvariable=self._name_var, width=24)
name_entry.grid(row=row, column=1, columnspan=3, sticky=tk.EW, pady=2)
name_entry = ttk.Entry(main, textvariable=self._name_var, width=28)
name_entry.grid(row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
if self._is_edit:
name_entry.config(state="disabled")
row += 1
ttk.Label(main, text="Password:").grid(row=row, column=0, sticky=tk.W, pady=2)
ttk.Label(main, text="Password").grid(row=row, column=0, sticky=tk.W, pady=2)
self._pass_var = tk.StringVar()
ttk.Entry(main, textvariable=self._pass_var, show="*", width=24).grid(
row=row, column=1, columnspan=3, sticky=tk.EW, pady=2)
ttk.Entry(main, textvariable=self._pass_var, show="*", width=28).grid(
row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
row += 1
ttk.Label(main, text="Confirm:").grid(row=row, column=0, sticky=tk.W, pady=2)
ttk.Label(main, text="Confirm").grid(row=row, column=0, sticky=tk.W, pady=2)
self._confirm_var = tk.StringVar()
ttk.Entry(main, textvariable=self._confirm_var, show="*", width=24).grid(
row=row, column=1, columnspan=3, sticky=tk.EW, pady=2)
ttk.Entry(main, textvariable=self._confirm_var, show="*", width=28).grid(
row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
# permissions
# permissions summary + >> button
row += 1
ttk.Separator(main, orient=tk.HORIZONTAL).grid(
row=row, column=0, columnspan=4, sticky=tk.EW, pady=6)
row=row, column=0, columnspan=3, sticky=tk.EW, pady=6)
row += 1
ttk.Label(main, text="Group Permissions:").grid(
row=row, column=0, columnspan=4, sticky=tk.W, pady=2)
ttk.Label(main, text="Permissions").grid(row=row, column=0, sticky=tk.W, pady=2)
self._perm_label = tk.StringVar(value=_format_perm(self._perm_val))
ttk.Label(main, textvariable=self._perm_label,
font=("TkDefaultFont", 8)).grid(
row=row, column=1, sticky=tk.W, pady=2)
ttk.Button(main, text="»", width=3,
command=self._open_permissions).grid(
row=row, column=2, sticky=tk.E, pady=2)
perm_groups = (_decode_perm(_get_prop(user_elem, "perm"))
if self._is_edit else [[False] * 7 for _ in range(4)])
self._perm_vars = []
for g in range(4):
row += 1
ttk.Label(main, text=f"Group {g + 1}:").grid(row=row, column=0, sticky=tk.W)
gframe = ttk.Frame(main)
gframe.grid(row=row, column=1, columnspan=3, sticky=tk.W)
gvars = []
for f_idx, label in enumerate(PERM_LABELS):
var = tk.BooleanVar(value=perm_groups[g][f_idx])
ttk.Checkbutton(gframe, text=label, variable=var).pack(
side=tk.LEFT, padx=1)
gvars.append(var)
self._perm_vars.append(gvars)
# provisioning
# provisioning summary + >> button
row += 1
ttk.Separator(main, orient=tk.HORIZONTAL).grid(
row=row, column=0, columnspan=4, sticky=tk.EW, pady=6)
ttk.Label(main, text="Provisioning Permissions").grid(
row=row, column=0, sticky=tk.W, pady=2)
self._prov_label = tk.StringVar(value=_format_prov(self._prov_val))
ttk.Label(main, textvariable=self._prov_label,
font=("TkDefaultFont", 8)).grid(
row=row, column=1, sticky=tk.W, pady=2)
ttk.Button(main, text="»", width=3,
command=self._open_provisioning).grid(
row=row, column=2, sticky=tk.E, pady=2)
row += 1
ttk.Label(main, text="Provisioning:").grid(row=row, column=0, sticky=tk.W, pady=2)
prov_dict = (_decode_prov(_get_prop(user_elem, "prov"))
if self._is_edit else {n: False for n, _ in PROV_FLAGS})
prov_frame = ttk.Frame(main)
prov_frame.grid(row=row, column=1, columnspan=3, sticky=tk.W)
self._prov_vars = {}
for name, _ in PROV_FLAGS:
var = tk.BooleanVar(value=prov_dict.get(name, False))
ttk.Checkbutton(prov_frame, text=name, variable=var).pack(
side=tk.LEFT, padx=4)
self._prov_vars[name] = var
# buttons
# OK / Cancel
row += 1
btn_frame = ttk.Frame(main)
btn_frame.grid(row=row, column=0, columnspan=4, pady=(10, 0))
ttk.Button(btn_frame, text="OK", command=self._on_ok).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="Cancel", command=self._on_cancel).pack(
btn_frame.grid(row=row, column=0, columnspan=3, pady=(10, 0))
ttk.Button(btn_frame, text="OK", width=10, command=self._on_ok).pack(
side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="Cancel", width=10, command=self._on_cancel).pack(
side=tk.LEFT, padx=4)
self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel)
name_entry.focus_set()
self._dlg.wait_window()
def _open_permissions(self):
dlg = _PermissionsDialog(self._dlg, self._perm_val)
if dlg.result is not None:
self._perm_val = dlg.result
self._perm_label.set(_format_perm(self._perm_val))
def _open_provisioning(self):
dlg = _ProvisioningDialog(self._dlg, self._prov_val)
if dlg.result is not None:
self._prov_val = dlg.result
self._prov_label.set(_format_prov(self._prov_val))
def _on_ok(self):
username = self._name_var.get().strip()
password = self._pass_var.get()
@@ -243,32 +405,29 @@ class _UserDialog:
messagebox.showwarning("Validation", "Passwords do not match.", parent=self._dlg)
return
# check duplicate name on add
if not self._is_edit:
for c in _get_users(self._service_comp):
if c.get("name", "") == username:
messagebox.showwarning("Validation", f"User '{username}' already exists.",
messagebox.showwarning("Validation",
f"User '{username}' already exists.",
parent=self._dlg)
return
perm_groups = [[v.get() for v in gvars] for gvars in self._perm_vars]
prov_dict = {name: var.get() for name, var in self._prov_vars.items()}
if self._is_edit:
self._apply_edit(username, password, perm_groups, prov_dict)
self._apply_edit(username, password)
else:
self._apply_add(username, password, perm_groups, prov_dict)
self._apply_add(username, password)
self._dlg.destroy()
if self._on_done:
self._on_done()
def _apply_add(self, username, password, perm_groups, prov_dict):
def _apply_add(self, username, password):
elem = ET.Element("comp", name=username, type="sys::User")
cred = _hash_credentials(username, password)
ET.SubElement(elem, "prop", name="cred", val=cred)
ET.SubElement(elem, "prop", name="perm", val=str(_encode_perm(perm_groups)))
ET.SubElement(elem, "prop", name="prov", val=str(_encode_prov(prov_dict)))
ET.SubElement(elem, "prop", name="perm", val=str(self._perm_val))
ET.SubElement(elem, "prop", name="prov", val=str(self._prov_val))
index = len(list(self._service_comp))
cmd = undo.AddComponentCommand(self._service_comp, index, elem, label="add user")
@@ -276,16 +435,16 @@ class _UserDialog:
undo.push(cmd)
app_state.mark_dirty()
def _apply_edit(self, username, password, perm_groups, prov_dict):
def _apply_edit(self, username, password):
user = self._user_elem
changes = []
new_perm = str(_encode_perm(perm_groups))
new_perm = str(self._perm_val)
old_perm = _get_prop(user, "perm")
if new_perm != old_perm:
changes.append((user, "perm", old_perm, new_perm))
new_prov = str(_encode_prov(prov_dict))
new_prov = str(self._prov_val)
old_prov = _get_prop(user, "prov")
if new_prov != old_prov:
changes.append((user, "prov", old_prov, new_prov))
@@ -412,9 +571,6 @@ class UserServicePanel:
self.on_change()
def _on_add(self):
if app_state.live_mode:
app_state.set_status("Add user is not available in live mode", warn=True)
return
_UserDialog(self.frame.winfo_toplevel(), self.comp, None, self._after_change)
def _on_edit(self):
@@ -423,9 +579,6 @@ class UserServicePanel:
messagebox.showinfo("Edit User", "Select a user first.",
parent=self.frame.winfo_toplevel())
return
if app_state.live_mode:
app_state.set_status("Edit user is not available in live mode", warn=True)
return
_UserDialog(self.frame.winfo_toplevel(), self.comp, user_elem, self._after_change)
def _on_delete(self):
@@ -434,9 +587,6 @@ class UserServicePanel:
messagebox.showinfo("Delete User", "Select a user first.",
parent=self.frame.winfo_toplevel())
return
if app_state.live_mode:
app_state.set_status("Delete user is not available in live mode", warn=True)
return
name = user_elem.get("name", "?")
if not messagebox.askyesno("Delete User", f"Delete user '{name}'?",