"""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("", 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)