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>
467 lines
16 KiB
Python
467 lines
16 KiB
Python
"""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)
|