Files
sedona_editor/user_service_view.py
T
ArdaandClaude Opus 5.5 8411f0c9ae User Manager: undo/redo user add and delete on a device
Uses LiveComponentCommand, so undoing a delete puts the user back on
the device with its exact cred, perm and prov.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:07:24 +03:00

691 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The User Manager: a table of sys::User components under a sys::UserService.
Like the other panels it takes the editor pane over from the canvas rather than
floating above it. `main` is what hides the canvas and puts this in its place;
this module knows only the frame it was handed.
Each sys::User component carries three properties:
cred — SHA-1 of "username:password" (20 bytes, base64 in the SAX)
perm — 32-bit int: four groups × 7 permission flags per group
prov — 8-bit byte: app/kits/svm provisioning flags
"""
import base64
import hashlib
import tkinter as tk
from tkinter import font as tkfont, messagebox, ttk
import xml.etree.ElementTree as ET
import app_state
import live_hooks
import sax_file
import undo
from sax_dom import set_prop_value
HEADER_BG = "#f2f2f2"
PERM_FLAGS = ["or", "ow", "oi", "ar", "aw", "ai", "ua"]
PERM_LABELS = "rwIRWIu"
PROV_FLAGS = [("app", 0x01), ("kits", 0x02), ("svm", 0x04)]
def _hash_credentials(username, password):
"""SHA-1 of 'username:password', the Sedona credential algorithm."""
return base64.b64encode(
hashlib.sha1(f"{username}:{password}".encode("utf-8")).digest()
).decode("ascii")
def _decode_perm(perm_val):
"""32-bit int -> list of four 7-element bool lists."""
try:
val = int(perm_val)
except (TypeError, ValueError):
val = 0
groups = []
for g in range(4):
byte = (val >> (g * 8)) & 0xFF
groups.append([(byte >> bit) & 1 == 1 for bit in range(7)])
return groups
def _encode_perm(groups):
"""List of four 7-element bool lists -> 32-bit int."""
val = 0
for g, flags in enumerate(groups):
byte = 0
for bit, on in enumerate(flags):
if on:
byte |= (1 << bit)
val |= (byte << (g * 8))
return val
def _format_perm(perm_val):
"""Human-readable: '1=rwIRWIu, 2=rwIRWIu, ...'"""
groups = _decode_perm(perm_val)
parts = []
for g, flags in enumerate(groups):
chars = "".join(PERM_LABELS[i] if on else "-" for i, on in enumerate(flags))
parts.append(f"{g + 1}={chars}")
return ", ".join(parts)
def _decode_prov(prov_val):
"""8-bit byte -> dict of flag name -> bool."""
try:
val = int(prov_val)
except (TypeError, ValueError):
val = 0
return {name: bool(val & mask) for name, mask in PROV_FLAGS}
def _encode_prov(flags_dict):
val = 0
for name, mask in PROV_FLAGS:
if flags_dict.get(name):
val |= mask
return val
def _format_prov(prov_val):
"""Human-readable: 'app,kits,svm' for set flags."""
flags = _decode_prov(prov_val)
active = [name for name, on in flags.items() if on]
return ",".join(active) if active else "(none)"
def _get_prop(comp, name):
prop = comp.find(f"prop[@name='{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
if c.tag == "comp" and c.get("name", "") != "..."]
def _resolve_comp(comp):
"""Re-locate a comp element in the current DOM by id (live mode rebuilds the tree)."""
root = app_state.xml_root_element
if root is None:
return comp
comp_id = comp.get("id")
if comp_id is None:
return comp
for elem in root.iter("comp"):
if elem.get("id") == comp_id:
return elem
return comp
def is_user_service(comp):
"""Whether a component is a sys::UserService (or looks like one in live mode)."""
if comp is None:
return False
type_name = comp.get("type", "")
if "UserService" in type_name:
return True
for child in comp:
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:
"""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._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")
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)
row = 0
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=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)
self._pass_var = tk.StringVar()
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)
self._confirm_var = tk.StringVar()
ttk.Entry(main, textvariable=self._confirm_var, show="*", width=28).grid(
row=row, column=1, columnspan=2, sticky=tk.EW, pady=2)
# permissions summary + >> button
row += 1
ttk.Separator(main, orient=tk.HORIZONTAL).grid(
row=row, column=0, columnspan=3, sticky=tk.EW, pady=6)
row += 1
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)
# provisioning summary + >> button
row += 1
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)
# OK / Cancel
row += 1
btn_frame = ttk.Frame(main)
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()
confirm = self._confirm_var.get()
if not username:
messagebox.showwarning("Validation", "Username is required.", parent=self._dlg)
return
if not self._is_edit and not password:
messagebox.showwarning("Validation", "Password is required for new users.",
parent=self._dlg)
return
if password and password != confirm:
messagebox.showwarning("Validation", "Passwords do not match.", parent=self._dlg)
return
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.",
parent=self._dlg)
return
if self._is_edit:
self._apply_edit(username, password)
else:
self._apply_add(username, password)
self._dlg.destroy()
if self._on_done:
self._on_done()
def _apply_add(self, username, password):
elem = ET.Element("comp", name=username, type="sys::User")
cred = _hash_credentials(username, password)
values = {"cred": cred, "perm": str(self._perm_val), "prov": str(self._prov_val)}
if app_state.live_mode:
# One Sox add carrying every config value, so the user never exists on the
# device without its password and rights.
try:
config = sax_file.build_config_values("sys::User", values)
except (ValueError, TypeError) as e:
live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device",
f"A value does not fit its slot: {e}")
return
new_id = live_hooks.live_add_component(
self._service_comp, "sys", "User", username, config)
if new_id is None:
live_hooks.warn_failure("Add User", f"User '{username}' was not added to the device")
return
elem.set("id", str(new_id))
for name, val in values.items():
ET.SubElement(elem, "prop", name=name, val=val)
if app_state.live_mode:
index = len(list(self._service_comp))
self._service_comp.append(elem)
undo.push(sax_file.LiveComponentCommand(
self._service_comp, index, elem, present=True, label="add user"))
app_state.mark_dirty()
app_state.set_status(f"User '{username}' added to device.")
return
index = len(list(self._service_comp))
cmd = undo.AddComponentCommand(self._service_comp, index, elem, label="add user")
cmd.redo()
undo.push(cmd)
app_state.mark_dirty()
def _apply_edit(self, username, password):
user = self._user_elem
changes = []
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(self._prov_val)
old_prov = _get_prop(user, "prov")
if new_prov != old_prov:
changes.append((user, "prov", old_prov, new_prov))
if password:
new_cred = _hash_credentials(username, password)
old_cred = _get_prop(user, "cred")
if new_cred != old_cred:
changes.append((user, "cred", old_cred, new_cred))
if not changes:
return
if app_state.live_mode:
failures = []
applied = []
for change in changes:
elem, prop, _old, new = change
if live_hooks.live_set_prop_value(elem, prop, new):
set_prop_value(elem, prop, new)
applied.append(change)
app_state.mark_dirty()
else:
failures.append(f"{prop}: {live_hooks.last_error}")
if applied:
undo.push(undo.LiveSlotValueCommand(applied, label="edit user"))
if failures:
live_hooks.warn_failure(
"Edit User",
f"{len(failures)} of {len(changes)} change(s) to user '{username}' did "
"not reach the device and were not applied",
"\n".join(f" • {failure}" for failure in failures),
)
else:
app_state.set_status(f"User '{username}' updated on device.")
return
cmd = undo.SlotValueCommand(changes, label="edit user")
cmd.redo()
undo.push(cmd)
app_state.mark_dirty()
def _on_cancel(self):
self._dlg.destroy()
class UserServicePanel:
"""Table of users under a sys::UserService, with Add/Edit/Delete."""
def __init__(self, parent, comp, on_change=None, on_close=None):
self.comp = comp
self.on_change = on_change
self.on_close = on_close
self.dirty = False
self.frame = tk.Frame(parent)
self._build_header()
self._build_table()
self._build_footer()
self._refresh_table()
def _build_header(self):
header = tk.Frame(self.frame, padx=8, pady=6, bg=HEADER_BG)
header.pack(fill=tk.X)
name_font = tkfont.Font(family="Segoe UI", size=9, weight="bold")
plain_font = tkfont.Font(family="Segoe UI", size=9)
tk.Label(header, text="User Manager", font=name_font,
bg=HEADER_BG).pack(side=tk.LEFT)
tk.Label(header, text=f"— {self.comp.get('name', '?')}",
font=plain_font, fg="#555555", bg=HEADER_BG).pack(side=tk.LEFT,
padx=(8, 0))
self.count_var = tk.StringVar(value="")
tk.Label(header, textvariable=self.count_var, font=plain_font, fg="#555555",
bg=HEADER_BG).pack(side=tk.RIGHT)
def _build_table(self):
area = tk.Frame(self.frame)
area.pack(fill=tk.BOTH, expand=True, padx=8, pady=(4, 0))
columns = ("username", "permissions", "provisioning")
self.table = ttk.Treeview(area, columns=columns, show="headings",
selectmode="browse")
self.table.heading("username", text="Username")
self.table.heading("permissions", text="Group Permissions")
self.table.heading("provisioning", text="Provisioning")
self.table.column("username", width=120, minwidth=80)
self.table.column("permissions", width=320, minwidth=200)
self.table.column("provisioning", width=120, minwidth=80)
vscroll = ttk.Scrollbar(area, orient=tk.VERTICAL, command=self.table.yview)
self.table.configure(yscrollcommand=vscroll.set)
self.table.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
vscroll.pack(side=tk.RIGHT, fill=tk.Y)
self.table.bind("<Double-1>", lambda _e: self._on_edit())
self._user_elements = {}
def _build_footer(self):
footer = tk.Frame(self.frame, padx=8, pady=6)
footer.pack(side=tk.BOTTOM, fill=tk.X)
ttk.Button(footer, text="Close", width=9,
command=self.close).pack(side=tk.RIGHT)
ttk.Button(footer, text="Delete User", width=12,
command=self._on_delete).pack(side=tk.RIGHT, padx=(0, 6))
ttk.Button(footer, text="Edit User", width=12,
command=self._on_edit).pack(side=tk.RIGHT, padx=(0, 6))
ttk.Button(footer, text="Add User", width=12,
command=self._on_add).pack(side=tk.RIGHT, padx=(0, 6))
def _refresh_table(self):
for item in self.table.get_children():
self.table.delete(item)
self._user_elements.clear()
self.comp = _resolve_comp(self.comp)
users = _get_users(self.comp)
for user_elem in users:
name = user_elem.get("name", "?")
perm = _get_prop(user_elem, "perm")
prov = _get_prop(user_elem, "prov")
iid = self.table.insert("", tk.END, values=(
name,
_format_perm(perm),
_format_prov(prov),
))
self._user_elements[iid] = user_elem
self.count_var.set(f"{len(users)} user{'' if len(users) == 1 else 's'}")
def refresh(self):
self._refresh_table()
def _selected_user(self):
sel = self.table.selection()
if not sel:
return None, None
return sel[0], self._user_elements.get(sel[0])
def _after_change(self):
self._refresh_table()
if self.on_change:
self.on_change()
def _on_add(self):
_UserDialog(self.frame.winfo_toplevel(), self.comp, None, self._after_change)
def _on_edit(self):
iid, user_elem = self._selected_user()
if user_elem is None:
messagebox.showinfo("Edit User", "Select a user first.",
parent=self.frame.winfo_toplevel())
return
_UserDialog(self.frame.winfo_toplevel(), self.comp, user_elem, self._after_change)
def _on_delete(self):
iid, user_elem = self._selected_user()
if user_elem is None:
messagebox.showinfo("Delete User", "Select a user first.",
parent=self.frame.winfo_toplevel())
return
name = user_elem.get("name", "?")
if not messagebox.askyesno("Delete User", f"Delete user '{name}'?",
parent=self.frame.winfo_toplevel()):
return
if app_state.live_mode:
# Read before it goes: once deleted, the device has nothing left to read back.
snapshot = live_hooks.read_subtree(user_elem)
unreadable = live_hooks.last_error if snapshot is None else ""
if not live_hooks.live_delete_component(user_elem):
live_hooks.warn_failure("Delete User", f"User '{name}' was not deleted from the device")
return
index = list(self.comp).index(user_elem)
self.comp.remove(user_elem)
app_state.mark_dirty()
if snapshot is not None:
undo.push(sax_file.LiveComponentCommand(
self.comp, index, user_elem, present=False, snapshot=snapshot,
label="delete user"))
app_state.set_status(f"User '{name}' deleted from device.")
else:
app_state.set_status(
f"User '{name}' was deleted from the device, but this delete cannot be "
f"undone: {unreadable}", warn=True)
self._after_change()
return
index = list(self.comp).index(user_elem)
cmd = undo.AddComponentCommand(self.comp, index, user_elem, label="delete user")
cmd.undo()
undo.push(cmd)
app_state.mark_dirty()
self._after_change()
def close(self):
if self.on_close is not None:
self.on_close()
return True
def destroy(self):
if self.frame.winfo_exists():
self.frame.destroy()
def build_user_service_panel(parent, comp, on_change=None, on_close=None):
if comp is None or parent is None:
return None
return UserServicePanel(parent, comp, on_change=on_change, on_close=on_close)