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