Add User Manager panel for sys::UserService components

New user_service_view.py with table of sys::User children, add/edit/delete
dialogs, SHA-1 credential hashing, 32-bit permission bitmask (4 groups x 7
flags), 8-bit provisioning byte, and undo support. Wired into main.py with
context menu entry, panel management, history navigation, and live-mode DOM
resolution for correct display on connected devices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 20:10:03 +03:00
co-authored by Claude Opus 4.6
parent 190ab79bdc
commit 43549046e5
3 changed files with 522 additions and 4 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ so later rebinding would only ever be seen by the module that did it.
import configparser
import os
VERSION = "0.0.0.116"
VERSION = "0.0.0.117"
PROPERTIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "editor.properties")
DEFAULT_GRID_SIZE = 10
DEFAULT_ADD_TIMESTAMP = True
+55 -3
View File
@@ -18,6 +18,7 @@ import properties
import sax_file
import schema
import undo
import user_service_view
import wiresheet
from sax_dom import (find_component_by_path, find_parent_element, get_component_path,
read_meta_property, update_meta_property)
@@ -78,6 +79,7 @@ HISTORY_LIMIT = 50
VIEW_SHEET = "sheet"
VIEW_PROPERTIES = "properties"
VIEW_LINKS = "links"
VIEW_USERS = "users"
# Tk class names of the widgets that own the keyboard while they have focus. Del and
# Backspace mean "edit this text" in each of them, never "delete the selected component".
@@ -122,6 +124,7 @@ show_left_column_var = None
properties_panel = None
link_panel = None
kits_panel = None
user_service_panel = None
# The sheet to put the tree back on when a sheet change is refused because of unsaved
# edits, and the flag that stops the restoring selection from being read as a fresh
@@ -189,6 +192,12 @@ def refresh_all_views():
else:
close_editor_panel(force=True)
if user_service_panel is not None:
if _component_in_document(user_service_panel.comp):
user_service_panel.refresh()
else:
close_editor_panel(force=True)
def _component_in_document(comp):
root = app_state.xml_root_element
@@ -210,7 +219,7 @@ def delete_component(comp):
than going through refresh_all_views, so the panel would otherwise be left describing
a component the document no longer has.
"""
panel = properties_panel or link_panel
panel = properties_panel or link_panel or user_service_panel
showing = panel is not None and panel.comp is comp
if sax_file.execute_cascading_deletion(comp) and showing:
close_editor_panel(force=True)
@@ -218,7 +227,8 @@ def delete_component(comp):
def editor_panel_showing():
"""Whether something other than the wiresheet has the editor pane."""
return properties_panel is not None or link_panel is not None or kits_panel is not None
return (properties_panel is not None or link_panel is not None
or kits_panel is not None or user_service_panel is not None)
def _take_editor_pane():
@@ -330,6 +340,38 @@ def dismiss_link_view(restore_canvas=True):
_give_editor_pane_back()
def show_user_service(comp):
"""Puts the User Manager for a sys::UserService in the editor pane."""
global user_service_panel
if comp is None:
return
if not close_editor_panel():
return
_take_editor_pane()
user_service_panel = user_service_view.build_user_service_panel(
frame_editor, comp, on_change=refresh_all_views,
on_close=dismiss_user_service,
)
user_service_panel.frame.pack(fill=tk.BOTH, expand=True)
record_visit(VIEW_USERS, comp)
app_state.set_status(f"User Manager: {comp.get('name', '?')}")
def dismiss_user_service(restore_canvas=True):
"""Takes the User Manager down and gives the pane back."""
global user_service_panel
if user_service_panel is None:
return
user_service_panel.destroy()
user_service_panel = None
if restore_canvas:
_give_editor_pane_back()
def show_kits():
"""Puts the kit table in the editor pane, in place of the wiresheet."""
global kits_panel
@@ -378,6 +420,9 @@ def close_editor_panel(force=False):
if kits_panel is not None:
dismiss_kits()
return True
if user_service_panel is not None:
dismiss_user_service()
return True
if properties_panel is None:
return True
if force:
@@ -1207,6 +1252,11 @@ def fill_component_popup(menu, comp):
label="Link View",
command=lambda: show_link_view(comp),
)
if user_service_view.is_user_service(comp):
menu.add_command(
label="User Manager",
command=lambda: show_user_service(comp),
)
menu.add_separator()
menu.add_command(
label="Delete Component",
@@ -1644,6 +1694,8 @@ def _show_history_entry(position):
_show_sheet_entry(element)
elif kind == VIEW_PROPERTIES:
show_properties(element)
elif kind == VIEW_USERS:
show_user_service(element)
else:
show_link_view(element)
finally:
@@ -1707,7 +1759,7 @@ def on_tree_filled():
# A different file has no such component, and a rebuild after a delete may have taken
# it. Neither is a moment to ask about edits that now have nowhere to go.
panel = properties_panel or link_panel
panel = properties_panel or link_panel or user_service_panel
if panel is not None and not _component_in_document(panel.comp):
close_editor_panel(force=True)
+466
View File
@@ -0,0 +1,466 @@
"""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 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 _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 type_name.endswith("::UserService") or type_name == "UserService":
return True
for child in comp:
if child.tag == "comp" and child.get("type", "").endswith("::User"):
return True
return False
class _UserDialog:
"""Modal dialog for adding or editing a user."""
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._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=24)
name_entry.grid(row=row, column=1, columnspan=3, 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=24).grid(
row=row, column=1, columnspan=3, 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=24).grid(
row=row, column=1, columnspan=3, sticky=tk.EW, pady=2)
# permissions
row += 1
ttk.Separator(main, orient=tk.HORIZONTAL).grid(
row=row, column=0, columnspan=4, 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)
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
row += 1
ttk.Separator(main, orient=tk.HORIZONTAL).grid(
row=row, column=0, columnspan=4, sticky=tk.EW, pady=6)
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
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(
side=tk.LEFT, padx=4)
self._dlg.protocol("WM_DELETE_WINDOW", self._on_cancel)
name_entry.focus_set()
self._dlg.wait_window()
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
# 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.",
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)
else:
self._apply_add(username, password, perm_groups, prov_dict)
self._dlg.destroy()
if self._on_done:
self._on_done()
def _apply_add(self, username, password, perm_groups, prov_dict):
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)))
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, perm_groups, prov_dict):
user = self._user_elem
changes = []
new_perm = str(_encode_perm(perm_groups))
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))
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
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):
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):
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
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):
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
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}'?",
parent=self.frame.winfo_toplevel()):
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)