From 43549046e59859c5c72849e55ef1e288dc20d14a Mon Sep 17 00:00:00 2001 From: Arda Aydin Date: Tue, 22 Sep 2026 20:10:03 +0300 Subject: [PATCH] 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 --- app_state.py | 2 +- main.py | 58 +++++- user_service_view.py | 466 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 522 insertions(+), 4 deletions(-) create mode 100644 user_service_view.py diff --git a/app_state.py b/app_state.py index de5a192..e30ee94 100644 --- a/app_state.py +++ b/app_state.py @@ -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 diff --git a/main.py b/main.py index d9312af..f3e9243 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/user_service_view.py b/user_service_view.py new file mode 100644 index 0000000..20d680a --- /dev/null +++ b/user_service_view.py @@ -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("", 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)