Client: your server settings, profile, account and admin in Settings

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-13 17:26:26 +02:00
1 parent e2cb25e2e1
commit c7a7596e99
3 files changed
+401 -2

No files matched your search

+312
View File
@@ -0,0 +1,312 @@
"""Your SaraView account and settings on the server, in Settings while you're
logged in to one: the web view's Settings panel (Frontend/src/Components/
SettingsPanel.jsx), section for section, through the same /api endpoints.
- Web view: your settings on the server (saved with Settings' Save). Most
are how the web view looks; hidden files and .sv_ notes also apply here.
- Profile (colour, avatar), Account (username, password, log out) and, for
admins, Users, Groups and Locked directories: these take effect at once,
like the web view's."""
from pathlib import Path
from typing import Callable
import flet as ft
from Core import ChipButton, Palette
from Diagrams import ThemePalette
from Sources import ServerSource, SourceError
def _Note(Text: str) -> ft.Text:
return ft.Text(Text, size=12, color=Palette.Overlay0)
def _Heading(Text: str) -> ft.Text:
return ft.Text(Text, color=Palette.Mauve)
def _Divider() -> ft.Divider:
return ft.Divider(height=1, color=Palette.Surface1)
class ServerSections:
"""Builds the sections (Categories) and saves the Web view ones (Save)."""
def __init__(self, TheApp, OnLoggedOut: Callable[[], None]):
self.TheApp = TheApp
self.Server: ServerSource = TheApp.Server
self.OnLoggedOut = OnLoggedOut
self.Values = self.Server.UserSettings() # raises SourceError: then there are no sections
self.ThemeNames = self.Server.ThemeNames()
self.IsAdmin = bool((self.Server.User or {}).get("IsAdmin"))
self.Switches: dict[str, ft.Switch] = {}
self.Categories: dict[str, ft.Control] = {
"Web view": self._WebView(),
"Profile": self._Profile(),
"Account": self._Account(),
}
if self.IsAdmin:
self.UsersList = ft.Column(spacing=6)
self.GroupsList = ft.Column(spacing=10)
self.LocksList = ft.Column(spacing=6)
self.Categories["Users"] = self._Users()
self.Categories["Groups"] = self._Groups()
self.Categories["Locked directories"] = self._Locks()
self._RefreshAdmin(Update=False)
def _Try(self, Action: Callable[[], None], Failure: str) -> bool:
try:
Action()
return True
except SourceError as Error:
self.TheApp.Notify(f"{Failure}: {Error}", "error")
return False
# --- Web view (saved with Settings' Save) -------------------------------
def _Switch(self, Key: str, Label: str) -> ft.Switch:
self.Switches[Key] = ft.Switch(label=Label, value=bool(self.Values.get(Key)))
return self.Switches[Key]
def _WebView(self) -> ft.Control:
V = self.Values
self.MarkdownEditing = ft.Dropdown(label="Markdown editing", value=V.get("MarkdownEditing", "Live"), options=[
ft.DropdownOption(key="Live", text="Live: edit the Markdown where you click in the rendered file"),
ft.DropdownOption(key="Separate", text="Separate: View and Edit are two modes")])
self.ThemePicker = ft.Dropdown(label="Colour theme", value=V.get("ThemeName"),
options=[ft.DropdownOption(key=Name, text=Name) for Name in self.ThemeNames])
self.NewThemeName = ft.TextField(label="New theme name (a copy of the selected one)", dense=True, expand=True)
self.FontFamily = ft.TextField(label="Font family", value=V.get("FontFamily", ""))
self.FontSize = ft.TextField(label="Font size (px)", value=str(V.get("FontSize", 14)),
keyboard_type=ft.KeyboardType.NUMBER)
self.PanButton = ft.Dropdown(label="Graph: pan with mouse button", value=V.get("GraphPanButton", "Right"), options=[
ft.DropdownOption(key="Right", text="Right (left-click opens a dot)"),
ft.DropdownOption(key="Left", text="Left (right-click opens a dot)")])
return ft.Column([
_Note("Your settings on the server, the same as the web view's. Most change the web view; "
"hidden directories and .sv_ notes also apply here while connected."),
_Heading("Files"),
self._Switch("ShowHiddenFolders", "Show hidden directories"),
self._Switch("ShowSvFiles", "Show directory note files (.sv_*)"),
self._Switch("ShowBreadcrumbs", "Show breadcrumbs"),
self._Switch("UseHyphenBullets", "Hyphen file icons (-)"),
self._Switch("UseAsciiFolderIcons", "ASCII directory icons (>/v)"),
self._Switch("NavigateIntoDirectories", "Open directory file on click"),
self._Switch("NewEntryButtonsAtTop", "New File/Directory buttons at top of sidebar"),
self._Switch("ConfirmBeforeDelete", "Confirm before delete"),
_Divider(), _Heading("Editor"),
self.MarkdownEditing,
self._Switch("StickyToolbar", "Sticky edit/view/save toolbar"),
self._Switch("ShowGitBlame", "Show blame gutter (who last changed each line)"),
self._Switch("ShowBlameAvatars", "Show avatars instead of names in blame"),
self._Switch("VisibleInBlame", "Show my edits in others' blame view"),
_Divider(), _Heading("Appearance"),
self.ThemePicker,
ft.Row([self.NewThemeName, ChipButton("Create", lambda e: self._CreateTheme())]),
ft.Row([ChipButton("Delete selected theme", lambda e: self._DeleteTheme(), Accent=Palette.Red)]),
self.FontFamily, self.FontSize,
_Divider(), _Heading("Graph"),
self._Switch("GraphShowDirectories", "Show directories (off: only files and the links between them)"),
self._Switch("GraphColourByDepth", "Colour directories by depth (off: all purple)"),
self._Switch("GraphShrinkByDepth", "Smaller directory dots the deeper they are"),
self.PanButton,
], spacing=12, scroll=ft.ScrollMode.AUTO)
def _RefreshThemes(self, Selected: str) -> None:
self.ThemeNames = self.Server.ThemeNames()
self.ThemePicker.options = [ft.DropdownOption(key=Name, text=Name) for Name in self.ThemeNames]
self.ThemePicker.value = Selected if Selected in self.ThemeNames else self.ThemeNames[0]
self.ThemePicker.update()
def _CreateTheme(self) -> None:
Name = (self.NewThemeName.value or "").strip()
Base = self.ThemePicker.value
if not Name or not Base:
return
if self._Try(lambda: self.Server.SaveTheme(Name, self.Server.Theme(Base)), "Couldn't create the theme"):
self.NewThemeName.value = ""
self.NewThemeName.update()
self._RefreshThemes(Name)
def _DeleteTheme(self) -> None:
Name = self.ThemePicker.value
if not Name:
return
if len(self.ThemeNames) <= 1:
self.TheApp.Notify("Can't delete the only remaining theme.", "error")
return
if self._Try(lambda: self.Server.DeleteTheme(Name), "Couldn't delete the theme"):
self._RefreshThemes("")
def Save(self) -> bool:
"""Saves the Web view section; False (after saying why) if it couldn't."""
Values = dict(self.Values)
for Key, Switch in self.Switches.items():
Values[Key] = bool(Switch.value)
Values["MarkdownEditing"] = self.MarkdownEditing.value or Values.get("MarkdownEditing", "Live")
Values["ThemeName"] = self.ThemePicker.value or Values.get("ThemeName")
Values["FontFamily"] = (self.FontFamily.value or "").strip() or Values.get("FontFamily")
try:
Values["FontSize"] = max(8, min(40, int(self.FontSize.value)))
except (TypeError, ValueError):
pass
Values["GraphPanButton"] = self.PanButton.value or Values.get("GraphPanButton", "Right")
return self._Try(lambda: self.Server.SaveUserSettings(Values), "Couldn't save your server settings")
# --- Profile and Account (at once) ---------------------------------------
def _Profile(self) -> ft.Control:
Colours = ThemePalette()
Current = (self.Server.User or {}).get("Colour", "Purple")
self.Swatches = ft.Row(wrap=True, spacing=8, run_spacing=8)
def Pick(Colour: str) -> None:
if self._Try(lambda: self.Server.SetColour(Colour), "Couldn't change your colour"):
Draw(Colour)
self.Swatches.update()
def Draw(Selected: str) -> None:
self.Swatches.controls = [
ft.Container(width=28, height=28, border_radius=14, bgcolor=Colours.get(Name, Palette.Mauve),
border=ft.Border.all(3, Palette.Text) if Name == Selected else None,
tooltip=Name, on_click=lambda e, N=Name: Pick(N))
for Name in self.Server.ColourOptions()
]
Draw(Current)
async def PickAvatar(e) -> None:
Picked = await self.TheApp.Picker.pick_files(dialog_title="Avatar", allow_multiple=False,
file_type=ft.FilePickerFileType.IMAGE)
if Picked and Picked[0].path:
if self._Try(lambda: self.Server.UploadAvatar(Path(Picked[0].path)), "Couldn't upload the avatar"):
self.TheApp.Notify("Avatar updated.", "success")
return ft.Column([
ft.Text("Your colour"), _Note("Shown on your edits in blame, and on your badge."), self.Swatches,
_Divider(), ft.Text("Avatar"), ChipButton("Choose an image…", PickAvatar, Icon=ft.Icons.IMAGE_OUTLINED),
], spacing=12, scroll=ft.ScrollMode.AUTO)
def _Account(self) -> ft.Control:
User = self.Server.User or {}
UsernameField = ft.TextField(label="Username", value=User.get("Username", ""))
CurrentPassword = ft.TextField(label="Current password", password=True, can_reveal_password=True)
NewPassword = ft.TextField(label="New password", password=True, can_reveal_password=True)
def ChangeName(e) -> None:
Name = (UsernameField.value or "").strip()
if Name and Name != (self.Server.User or {}).get("Username") and \
self._Try(lambda: self.Server.ChangeUsername(Name), "Couldn't change your username"):
self.TheApp.Notify(f"You're now {Name}.", "success")
def ChangePassword(e) -> None:
if self._Try(lambda: self.Server.ChangePassword(CurrentPassword.value or "", NewPassword.value or ""),
"Couldn't change your password"):
CurrentPassword.value = NewPassword.value = ""
CurrentPassword.update()
NewPassword.update()
self.TheApp.Notify("Password changed.", "success")
return ft.Column([
_Note(f"Logged in to {self.Server.Url} as {User.get('Username', '?')}"
+ (" (admin)." if self.IsAdmin else ".")),
ft.Row([ft.Container(UsernameField, expand=True), ChipButton("Change", ChangeName)]),
_Divider(),
CurrentPassword, NewPassword, ft.Row([ChipButton("Change password", ChangePassword)]),
_Divider(),
ft.Row([ChipButton("Log out", lambda e: self.OnLoggedOut(), Icon=ft.Icons.LOGOUT, Accent=Palette.Red)]),
], spacing=12, scroll=ft.ScrollMode.AUTO)
# --- Admin (at once) ------------------------------------------------------
def _RefreshAdmin(self, Update: bool = True) -> None:
try:
Users, Groups, Locks = self.Server.Users(), self.Server.Groups(), self.Server.Locks()
except SourceError as Error:
self.TheApp.Notify(f"Couldn't load the admin lists: {Error}", "error")
return
Colours = ThemePalette()
self.UsersList.controls = [
ft.Row([ft.Icon(ft.Icons.CIRCLE, size=12, color=Colours.get(U.get("Colour", "Purple"), Palette.Mauve)),
ft.Text(U["Username"]), ft.Text("admin", color=Palette.Overlay0, size=12) if U.get("IsAdmin") else ft.Container()],
spacing=8)
for U in Users
]
Names = [U["Username"] for U in Users]
self.GroupsList.controls = [self._GroupRow(G, Names) for G in Groups] or [_Note("No groups yet.")]
self.LocksList.controls = [
ft.Row([ft.Text(f"{L.get('Root')}/{L.get('Path')}", expand=True),
ft.Text(", ".join([*(L.get("Users") or []), *(f"@{G}" for G in L.get("Groups") or [])]),
color=Palette.Overlay0, size=12),
ChipButton("Unlock", lambda e, L=L: self._Admin(lambda: self.Server.RemoveLock(L["Root"], L["Path"]),
"Couldn't unlock"))])
for L in Locks
] or [_Note("Nothing is locked.")]
if Update:
for Listing in (self.UsersList, self.GroupsList, self.LocksList):
try:
Listing.update()
except RuntimeError:
pass # its section isn't open: it shows the new list when it is
def _Admin(self, Action: Callable[[], None], Failure: str) -> None:
if self._Try(Action, Failure):
self._RefreshAdmin()
def _GroupRow(self, Group: dict, Usernames: list[str]) -> ft.Control:
Members = Group.get("Members") or []
def Toggle(Name: str) -> None:
New = [M for M in Members if M != Name] if Name in Members else [*Members, Name]
self._Admin(lambda: self.Server.SetGroupMembers(Group["Name"], New), "Couldn't change the group")
Chips = [ft.Container(ft.Text(Name, size=12, color=Palette.Mauve if Name in Members else Palette.Overlay0),
bgcolor=Palette.Surface0 if Name in Members else None,
border=ft.Border.all(1, Palette.Surface1), border_radius=4,
padding=ft.Padding.symmetric(horizontal=6, vertical=2),
tooltip="Click to add or remove", on_click=lambda e, N=Name: Toggle(N))
for Name in Usernames]
return ft.Column([
ft.Row([ft.Text(Group["Name"], expand=True),
ChipButton("Delete", lambda e: self.TheApp.Confirm(
"Delete group", f'Delete group "{Group["Name"]}"? This can\'t be undone.',
lambda: self._Admin(lambda: self.Server.DeleteGroup(Group["Name"]), "Couldn't delete the group")),
Accent=Palette.Red)]),
ft.Row(Chips, wrap=True, spacing=4, run_spacing=4),
], spacing=4)
def _Users(self) -> ft.Control:
Name = ft.TextField(label="Username", dense=True)
Password = ft.TextField(label="Password", password=True, can_reveal_password=True, dense=True)
IsAdmin = ft.Switch(label="Admin", value=False)
Colour = ft.Dropdown(label="Colour", value="Purple", dense=True,
options=[ft.DropdownOption(key=C, text=C) for C in self.Server.ColourOptions()])
def Create(e) -> None:
if (Name.value or "").strip():
self._Admin(lambda: self.Server.CreateUser(Name.value.strip(), Password.value or "", bool(IsAdmin.value),
Colour.value or "Purple"), "Couldn't create the user")
Name.value = Password.value = ""
Name.update()
Password.update()
return ft.Column([self.UsersList, _Divider(), ft.Text("New user"), Name, Password, ft.Row([IsAdmin, Colour]),
ft.Row([ChipButton("Create user", Create)])], spacing=12, scroll=ft.ScrollMode.AUTO)
def _Groups(self) -> ft.Control:
Name = ft.TextField(label="New group name", dense=True, expand=True)
def Create(e) -> None:
if (Name.value or "").strip():
self._Admin(lambda: self.Server.CreateGroup(Name.value.strip()), "Couldn't create the group")
Name.value = ""
Name.update()
return ft.Column([_Note("Click a name to add it to the group or take it out."), self.GroupsList,
_Divider(), ft.Row([Name, ChipButton("Create group", Create)])],
spacing=12, scroll=ft.ScrollMode.AUTO)
def _Locks(self) -> ft.Control:
return ft.Column([_Note("Directories only some users or groups can see."), self.LocksList],
spacing=12, scroll=ft.ScrollMode.AUTO)
+26 -2
View File
@@ -15,6 +15,8 @@ from Core import (
AppConfig, AssetsDir, AvailableThemes, ChipButton, DataHome, ExportSettings, Home, HumanSize, AppConfig, AssetsDir, AvailableThemes, ChipButton, DataHome, ExportSettings, Home, HumanSize,
ImportSettings, InstalledFontFamilies, LoadSharedSettings, Palette, SharedSettings, ImportSettings, InstalledFontFamilies, LoadSharedSettings, Palette, SharedSettings,
) )
from ServerSettings import ServerSections
from Sources import SourceError
DialogWidth = 700 DialogWidth = 700
DialogHeight = 520 DialogHeight = 520
@@ -252,6 +254,23 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
], spacing=12, scroll=ft.ScrollMode.AUTO), ], spacing=12, scroll=ft.ScrollMode.AUTO),
} }
# Logged in to a server: your account and server settings too, like the
# web view's Settings (ServerSettings.py).
Server = TheApp.Server
Extra = None
if Server is not None and Server.User is not None and not TheApp.ConnectError:
def LogOut() -> None:
TheApp.Close()
TheApp.Logout()
try:
Extra = ServerSections(TheApp, LogOut)
Categories.update(Extra.Categories)
except SourceError as Error:
TheApp.Notify(f"Couldn't load your server settings: {Error}", "error")
if StartCategory not in Categories:
StartCategory = "General"
DetailArea = ft.Container(Categories[StartCategory], expand=True, padding=ft.Padding.only(left=12)) DetailArea = ft.Container(Categories[StartCategory], expand=True, padding=ft.Padding.only(left=12))
CategoryTiles: dict[str, ft.ListTile] = {} CategoryTiles: dict[str, ft.ListTile] = {}
@@ -264,12 +283,16 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
for Name in Categories: for Name in Categories:
CategoryTiles[Name] = ft.ListTile( CategoryTiles[Name] = ft.ListTile(
title=Name, selected=Name == StartCategory, selected_tile_color=Palette.Surface1, title=Name, selected=Name == StartCategory, dense=True, # room for the server sections too selected_tile_color=Palette.Surface1,
selected_color=Palette.Mauve, on_click=lambda e, N=Name: SelectCategory(N), selected_color=Palette.Mauve, on_click=lambda e, N=Name: SelectCategory(N),
) )
CategoryList = ft.Column(list(CategoryTiles.values()), spacing=0, scroll=ft.ScrollMode.AUTO) CategoryList = ft.Column(list(CategoryTiles.values()), spacing=0, scroll=ft.ScrollMode.AUTO)
def Save(e) -> None: def Save(e) -> None:
ServerFilesBefore = Extra and (Extra.Values.get("ShowHiddenFolders"), Extra.Values.get("ShowSvFiles"))
if Extra is not None and not Extra.Save():
return # stays open, so nothing's lost
ServerFilesAfter = Extra and (Extra.Switches["ShowHiddenFolders"].value, Extra.Switches["ShowSvFiles"].value)
SourceBefore = (Config.Mode, Config.LocalRoot, Config.ServerUrl, Config.ShowHiddenFolders, SourceBefore = (Config.Mode, Config.LocalRoot, Config.ServerUrl, Config.ShowHiddenFolders,
Config.ShowSvFiles, Config.HideInternalFiles) Config.ShowSvFiles, Config.HideInternalFiles)
Config.UseSharedSettings = UseSharedSwitch.value Config.UseSharedSettings = UseSharedSwitch.value
@@ -305,7 +328,8 @@ def ShowSettingsDialog(TheApp: App, OnSaved: Callable[[bool], None], StartCatego
SourceAfter = (Config.Mode, Config.LocalRoot, Config.ServerUrl, Config.ShowHiddenFolders, SourceAfter = (Config.Mode, Config.LocalRoot, Config.ServerUrl, Config.ShowHiddenFolders,
Config.ShowSvFiles, Config.HideInternalFiles) Config.ShowSvFiles, Config.HideInternalFiles)
TheApp.Close() TheApp.Close()
OnSaved(SourceAfter != SourceBefore) # A server's hidden-file settings change what it lists: reload like a new source.
OnSaved(SourceAfter != SourceBefore or ServerFilesAfter != ServerFilesBefore)
TheApp.Notify("Settings saved.", "success") TheApp.Notify("Settings saved.", "success")
if RemoveDiagramTools: if RemoveDiagramTools:
def RemoveTools() -> None: def RemoveTools() -> None:
+63
View File
@@ -480,6 +480,69 @@ class ServerSource(Source):
# A big root takes the server a while to read through. # A big root takes the server a while to read through.
return self._Json("GET", "/graph", params=self._At(Root, RelativePath), timeout=120) return self._Json("GET", "/graph", params=self._At(Root, RelativePath), timeout=120)
# --- Your account and settings on the server (ServerSettings.py) ------
# The same /api endpoints as the web view's Settings panel.
def UserSettings(self) -> dict:
return self._Json("GET", "/settings")
def SaveUserSettings(self, Settings: dict) -> dict:
return self._Json("PUT", "/settings", json=Settings)
def ThemeNames(self) -> list[str]:
return self._Json("GET", "/themes")
def Theme(self, Name: str) -> dict:
return self._Json("GET", "/theme", params={"Name": Name})
def SaveTheme(self, Name: str, Palette: dict) -> None:
self._Json("PUT", "/theme", params={"Name": Name}, json={"Palette": Palette})
def DeleteTheme(self, Name: str) -> None:
self._Json("DELETE", "/theme", params={"Name": Name})
def ColourOptions(self) -> list[str]:
return self._Json("GET", "/profile/colours")
def SetColour(self, Colour: str) -> dict:
self.User = {**(self.User or {}), **self._Json("PUT", "/profile/colour", json={"Colour": Colour})}
return self.User
def UploadAvatar(self, LocalFile: Path) -> None:
with LocalFile.open("rb") as Handle:
self._Json("POST", "/profile/avatar", files=[("Avatar", (LocalFile.name, Handle))])
def ChangeUsername(self, NewUsername: str) -> dict:
self.User = {**(self.User or {}), **self._Json("PUT", "/profile/username", json={"NewUsername": NewUsername})}
return self.User
def ChangePassword(self, CurrentPassword: str, NewPassword: str) -> None:
self._Json("PUT", "/profile/password", json={"CurrentPassword": CurrentPassword, "NewPassword": NewPassword})
def Users(self) -> list[dict]:
return self._Json("GET", "/users")
def CreateUser(self, Username: str, Password: str, IsAdmin: bool, Colour: str) -> None:
self._Json("POST", "/users", json={"Username": Username, "Password": Password, "IsAdmin": IsAdmin, "Colour": Colour})
def Groups(self) -> list[dict]:
return self._Json("GET", "/groups")
def CreateGroup(self, Name: str) -> None:
self._Json("POST", "/groups", json={"Name": Name})
def DeleteGroup(self, Name: str) -> None:
self._Json("DELETE", "/groups", params={"Name": Name})
def SetGroupMembers(self, Name: str, Members: list[str]) -> None:
self._Json("PUT", "/groups/members", params={"Name": Name}, json={"Members": Members})
def Locks(self) -> list[dict]:
return self._Json("GET", "/locks")
def RemoveLock(self, Root: str, RelativePath: str) -> None:
self._Json("DELETE", "/lock", params=self._At(Root, RelativePath))
def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes: def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes:
"""A ```mermaid block as a PNG, drawn by the server (Diagrams.py).""" """A ```mermaid block as a PNG, drawn by the server (Diagrams.py)."""
Body = {"Source": Source, "Palette": ThemePalette, "FontSize": FontSize} Body = {"Source": Source, "Palette": ThemePalette, "FontSize": FontSize}