diff --git a/Backend/Auth.py b/Backend/Auth.py index c4fca17..e2af48e 100644 --- a/Backend/Auth.py +++ b/Backend/Auth.py @@ -6,6 +6,7 @@ from typing import Any from fastapi import Depends, HTTPException, Request +from Groups import AdminGroupName, GetGroupsForUser, IsUserInGroup from Users import GetUser @@ -39,9 +40,14 @@ def GetCurrentUser(request: Request) -> dict[str, Any]: if User is None: raise HTTPException(status_code=401, detail="Session user no longer exists.") + # Admin isn't a stored flag — it's membership in the built-in Admin + # group, computed live, so it behaves exactly like any other group. + UserGroups = GetGroupsForUser(Username) + return { "Username": Username, - "IsAdmin": User.get("IsAdmin", False), + "IsAdmin": AdminGroupName in UserGroups, + "Groups": UserGroups, "Colour": User.get("Colour", "Purple"), } diff --git a/Backend/Blame.py b/Backend/Blame.py index 475938b..399bd28 100644 --- a/Backend/Blame.py +++ b/Backend/Blame.py @@ -44,6 +44,33 @@ def SaveBlame(RootName: str, RelativePath: str, Blame: dict[str, dict[str, Any]] tomli_w.dump({"Lines": Blame}, BlameFile) +def RenameUserInBlame(OldUsername: str, NewUsername: str) -> None: + """Rewrite every recorded blame attribution from OldUsername to + NewUsername across every file's blame history — called when a user + renames their account, so past attributions stay coherent. + """ + BlameDirectory = GetBlameDirectory() + + if not BlameDirectory.is_dir(): + return + + for BlamePath in BlameDirectory.rglob("*.blame.toml"): + with BlamePath.open("rb") as BlameFile: + Loaded = tomllib.load(BlameFile) + + Lines = Loaded.get("Lines", {}) + Changed = False + + for Entry in Lines.values(): + if Entry.get("Username") == OldUsername: + Entry["Username"] = NewUsername + Changed = True + + if Changed: + with BlamePath.open("wb") as BlameFile: + tomli_w.dump({"Lines": Lines}, BlameFile) + + def UpdateBlameOnSave( RootName: str, RelativePath: str, OldContent: str, NewContent: str, Username: str ) -> None: diff --git a/Backend/Groups.py b/Backend/Groups.py new file mode 100644 index 0000000..26ffac8 --- /dev/null +++ b/Backend/Groups.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import tomllib + +from typing import Any + +import tomli_w + +from Settings import GetDataDirectory + + +AdminGroupName = "Admin" + + +def GetGroupsPath(): + return GetDataDirectory() / "Groups.toml" + + +def LoadGroups() -> dict[str, list[str]]: + GroupsPath = GetGroupsPath() + + if not GroupsPath.is_file(): + # First run after upgrading from the old boolean-flag admin model: + # seed the Admin group from whoever already had IsAdmin set, so + # nobody silently loses admin access. + from Users import LoadUsers + + SeededAdmins = [ + Username for Username, Data in LoadUsers().items() if Data.get("IsAdmin", False) + ] + Groups = {AdminGroupName: SeededAdmins} + SaveGroups(Groups) + return Groups + + with GroupsPath.open("rb") as GroupsFile: + Loaded = tomllib.load(GroupsFile) + + Groups = Loaded.get("Groups", {}) + + if AdminGroupName not in Groups: + Groups[AdminGroupName] = [] + SaveGroups(Groups) + + return Groups + + +def SaveGroups(Groups: dict[str, list[str]]) -> None: + GetDataDirectory().mkdir(parents=True, exist_ok=True) + + with GetGroupsPath().open("wb") as GroupsFile: + tomli_w.dump({"Groups": Groups}, GroupsFile) + + +def ListGroups() -> list[dict[str, Any]]: + Groups = LoadGroups() + return [{"Name": Name, "Members": Members} for Name, Members in sorted(Groups.items())] + + +def CreateGroup(Name: str) -> None: + Name = Name.strip() + + if not Name: + raise ValueError("Group name cannot be empty.") + + Groups = LoadGroups() + + if Name in Groups: + raise ValueError(f"Group '{Name}' already exists.") + + Groups[Name] = [] + SaveGroups(Groups) + + +def DeleteGroup(Name: str) -> None: + if Name == AdminGroupName: + raise ValueError("The Admin group can't be deleted.") + + Groups = LoadGroups() + + if Name not in Groups: + raise ValueError(f"Group '{Name}' does not exist.") + + del Groups[Name] + SaveGroups(Groups) + + +def SetGroupMembers(Name: str, Members: list[str]) -> None: + Groups = LoadGroups() + + if Name not in Groups: + raise ValueError(f"Group '{Name}' does not exist.") + + # De-duplicate while keeping things predictable. + Groups[Name] = sorted(set(Members)) + SaveGroups(Groups) + + +def AddMember(GroupName: str, Username: str) -> None: + Groups = LoadGroups() + + if GroupName not in Groups: + raise ValueError(f"Group '{GroupName}' does not exist.") + + if Username not in Groups[GroupName]: + Groups[GroupName].append(Username) + SaveGroups(Groups) + + +def RemoveMember(GroupName: str, Username: str) -> None: + Groups = LoadGroups() + + if GroupName not in Groups: + raise ValueError(f"Group '{GroupName}' does not exist.") + + if Username in Groups[GroupName]: + Groups[GroupName].remove(Username) + SaveGroups(Groups) + + +def GetGroupsForUser(Username: str) -> list[str]: + Groups = LoadGroups() + return [Name for Name, Members in Groups.items() if Username in Members] + + +def IsUserInGroup(Username: str, GroupName: str) -> bool: + return Username in LoadGroups().get(GroupName, []) + + +def RenameUserInGroups(OldUsername: str, NewUsername: str) -> None: + Groups = LoadGroups() + Changed = False + + for Members in Groups.values(): + if OldUsername in Members: + Members.remove(OldUsername) + Members.append(NewUsername) + Changed = True + + if Changed: + SaveGroups(Groups) diff --git a/Backend/Locks.py b/Backend/Locks.py new file mode 100644 index 0000000..d44df00 --- /dev/null +++ b/Backend/Locks.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import tomllib + +from typing import Any + +import tomli_w +from fastapi import HTTPException + +from Settings import GetDataDirectory + + +def GetLocksPath(): + return GetDataDirectory() / "LockedDirectories.toml" + + +def LoadLocks() -> list[dict[str, Any]]: + LocksPath = GetLocksPath() + + if not LocksPath.is_file(): + return [] + + with LocksPath.open("rb") as LocksFile: + Loaded = tomllib.load(LocksFile) + + return Loaded.get("Locks", []) + + +def SaveLocks(Locks: list[dict[str, Any]]) -> None: + GetDataDirectory().mkdir(parents=True, exist_ok=True) + + with GetLocksPath().open("wb") as LocksFile: + tomli_w.dump({"Locks": Locks}, LocksFile) + + +def GetLockForPath(RootName: str, RelativePath: str) -> dict[str, Any] | None: + for Lock in LoadLocks(): + if Lock.get("Root") == RootName and Lock.get("Path") == RelativePath: + return Lock + + return None + + +def SetLock(RootName: str, RelativePath: str, Usernames: list[str], GroupNames: list[str]) -> None: + """Restrict a file or directory to specific users and/or groups. Passing + both empty lists is the same as removing the lock entirely. + """ + if not RelativePath: + raise ValueError("Can't lock an entire root — pick a specific file or directory.") + + if not Usernames and not GroupNames: + RemoveLock(RootName, RelativePath) + return + + Locks = LoadLocks() + + for Lock in Locks: + if Lock.get("Root") == RootName and Lock.get("Path") == RelativePath: + Lock["Users"] = Usernames + Lock["Groups"] = GroupNames + SaveLocks(Locks) + return + + Locks.append({"Root": RootName, "Path": RelativePath, "Users": Usernames, "Groups": GroupNames}) + SaveLocks(Locks) + + +def RemoveLock(RootName: str, RelativePath: str) -> None: + Locks = [ + Lock + for Lock in LoadLocks() + if not (Lock.get("Root") == RootName and Lock.get("Path") == RelativePath) + ] + SaveLocks(Locks) + + +def IsPathAccessible( + RootName: str, RelativePath: str, Username: str, UserGroups: list[str], IsAdmin: bool +) -> bool: + """A path is inaccessible if it IS a locked file/directory, or lies + anywhere underneath a locked directory, and the user isn't permitted + (by username or group) on every applicable lock. Locks don't propagate + to subdirectories as separate records — there's only ever one lock + entry per locked directory — but access to anything inside still + requires being permitted on that ancestor lock, so you always have to + "enter the parent first." Admins bypass every lock. + """ + if IsAdmin: + return True + + for Lock in LoadLocks(): + if Lock.get("Root") != RootName: + continue + + LockPath = Lock.get("Path", "") + + if not LockPath: + continue + + if RelativePath == LockPath or RelativePath.startswith(f"{LockPath}/"): + PermittedUsers = Lock.get("Users", []) + PermittedGroups = Lock.get("Groups", []) + IsPermittedByUser = Username in PermittedUsers + IsPermittedByGroup = any(Group in PermittedGroups for Group in UserGroups) + + if not IsPermittedByUser and not IsPermittedByGroup: + return False + + return True + + +def RequirePathAccessible(RootName: str, RelativePath: str, CurrentUser: dict[str, Any]) -> None: + """Raises a 404 (never a distinguishing "access denied") for a path the + current user isn't permitted to see, so a locked directory's existence + isn't revealed to anyone not on its list. + """ + if not IsPathAccessible( + RootName, + RelativePath, + CurrentUser["Username"], + CurrentUser.get("Groups", []), + CurrentUser.get("IsAdmin", False), + ): + raise HTTPException(status_code=404, detail="Not found.") diff --git a/Backend/Main.py b/Backend/Main.py index f788fa4..647779a 100644 --- a/Backend/Main.py +++ b/Backend/Main.py @@ -15,10 +15,25 @@ from fastapi.staticfiles import StaticFiles from Auth import CreateSession, DeleteSession, GetCurrentUser, RequireAdmin, SessionCookieName from BookReader import ReadBookForFile -from Blame import LoadBlame, UpdateBlameOnSave +from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave from Core import ResolvePath from Editor import WriteTextFile from Explorer import ClassifyFile, CreateEntry, DeleteEntry, ListDirectory, ListRoots, MoveEntry +from Groups import ( + CreateGroup, + DeleteGroup, + ListGroups, + RenameUserInGroups, + SetGroupMembers, +) +from Locks import ( + GetLockForPath, + IsPathAccessible, + LoadLocks, + RemoveLock, + RequirePathAccessible, + SetLock, +) from Preview import GetFileSize, GetMimeType, ParseRangeHeader, ReadTextFile, TextFileTypes from Settings import ( DeleteTheme, @@ -26,18 +41,22 @@ from Settings import ( LoadSettings, LoadTheme, PaletteRoles, + RenameUserSettings, SaveSettings, SaveTheme, ) from Users import ( AnyUsersExist, + ChangePassword, CreateUser, GetAvatarPath, ListUsers, + RenameUser, + RenameUserAvatar, + SaveAvatar, UpdateUserColour, UserColourOptions, VerifyLogin, - SaveAvatar, ) from pydantic import BaseModel @@ -122,7 +141,7 @@ class CreateUserRequest(BaseModel): @Application.get("/api/users") -def GetUsersEndpoint(CurrentUser: dict = Depends(RequireAdmin)) -> list[dict]: +def GetUsersEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]: return ListUsers() @@ -136,7 +155,7 @@ def PostUsersEndpoint(Body: CreateUserRequest, CurrentUser: dict = Depends(Requi return {"Created": True} -# --- Profile (colour + avatar) -------------------------------------------- +# --- Profile (colour, avatar, username, password) -------------------------- @Application.get("/api/profile/colours") @@ -186,6 +205,163 @@ def GetProfileAvatarEndpoint(Username: str, CurrentUser: dict = Depends(GetCurre return StreamingResponse(iter([AvatarPath.read_bytes()]), media_type=MimeType) +class ChangeUsernameRequest(BaseModel): + NewUsername: str + + +@Application.put("/api/profile/username") +def PutProfileUsernameEndpoint( + Body: ChangeUsernameRequest, + request: Request, + response: Response, + CurrentUser: dict = Depends(GetCurrentUser), +) -> dict: + OldUsername = CurrentUser["Username"] + NewUsername = Body.NewUsername.strip() + + try: + RenameUser(OldUsername, NewUsername) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + if NewUsername != OldUsername: + RenameUserSettings(OldUsername, NewUsername) + RenameUserAvatar(OldUsername, NewUsername) + RenameUserInBlame(OldUsername, NewUsername) + RenameUserInGroups(OldUsername, NewUsername) + + # Re-authenticate this same request under the new name so the + # person doesn't get logged out by their own rename. Any OTHER + # active session for the old name is left to fail naturally on its + # next request — a re-login there is the simplest correct outcome. + OldToken = request.cookies.get(SessionCookieName) + if OldToken: + DeleteSession(OldToken) + + NewToken = CreateSession(NewUsername) + response.set_cookie( + key=SessionCookieName, + value=NewToken, + httponly=True, + samesite="lax", + max_age=60 * 60 * 24 * 30, + ) + + return {"Username": NewUsername, "IsAdmin": CurrentUser["IsAdmin"], "Colour": CurrentUser["Colour"]} + + +class ChangePasswordRequest(BaseModel): + CurrentPassword: str + NewPassword: str + + +@Application.put("/api/profile/password") +def PutProfilePasswordEndpoint( + Body: ChangePasswordRequest, CurrentUser: dict = Depends(GetCurrentUser) +) -> dict: + try: + ChangePassword(CurrentUser["Username"], Body.CurrentPassword, Body.NewPassword) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + return {"Changed": True} + + +# --- Groups ----------------------------------------------------------------- + + +@Application.get("/api/groups") +def GetGroupsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]: + # Readable by anyone — picking an existing group as a lock target is + # open to everyone, same as picking a user. + return ListGroups() + + +class CreateGroupRequest(BaseModel): + Name: str + + +@Application.post("/api/groups") +def PostGroupsEndpoint(Body: CreateGroupRequest, CurrentUser: dict = Depends(RequireAdmin)) -> dict: + try: + CreateGroup(Body.Name) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + return {"Created": True} + + +@Application.delete("/api/groups") +def DeleteGroupsEndpoint(Name: str, CurrentUser: dict = Depends(RequireAdmin)) -> dict: + try: + DeleteGroup(Name) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + return {"Deleted": True} + + +class SetGroupMembersRequest(BaseModel): + Members: list[str] + + +@Application.put("/api/groups/members") +def PutGroupMembersEndpoint( + Name: str, Body: SetGroupMembersRequest, CurrentUser: dict = Depends(RequireAdmin) +) -> dict: + try: + SetGroupMembers(Name, Body.Members) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + return {"Saved": True} + + +# --- Locked files/directories ----------------------------------------------- +# Setting a lock is open to anyone who currently has access to the path — +# not admin-only. If you can see it, you're trusted to decide who else can. +# Admins additionally bypass every lock (see Locks.IsPathAccessible). + + +@Application.get("/api/lock") +def GetLockEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + RequirePathAccessible(Root, Path, CurrentUser) + return GetLockForPath(Root, Path) or {} + + +class SetLockRequest(BaseModel): + Users: list[str] = [] + Groups: list[str] = [] + + +@Application.put("/api/lock") +def PutLockEndpoint( + Root: str, Path: str, Body: SetLockRequest, CurrentUser: dict = Depends(GetCurrentUser) +) -> dict: + RequirePathAccessible(Root, Path, CurrentUser) + + try: + SetLock(Root, Path, Body.Users, Body.Groups) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + return {"Locked": True} + + +@Application.delete("/api/lock") +def DeleteLockEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + RequirePathAccessible(Root, Path, CurrentUser) + RemoveLock(Root, Path) + return {"Unlocked": True} + + +@Application.get("/api/locks") +def GetLocksEndpoint(CurrentUser: dict = Depends(RequireAdmin)) -> list[dict]: + # The full list (across every root/path) stays admin-only — it's a + # global overview, not a per-item action. + return LoadLocks() + + # --- Files ---------------------------------------------------------------- @@ -198,12 +374,32 @@ def GetRootsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]: def GetTreeEndpoint( Root: str, Path: str = "", CurrentUser: dict = Depends(GetCurrentUser) ) -> list[dict]: + RequirePathAccessible(Root, Path, CurrentUser) + try: ShowHidden = LoadSettings(CurrentUser["Username"]).get("ShowHiddenFolders", False) - return ListDirectory(Root, Path, ShowHidden) + Entries = ListDirectory(Root, Path, ShowHidden) except (ValueError, NotADirectoryError, FileNotFoundError) as Error: raise HTTPException(status_code=400, detail=str(Error)) + Username = CurrentUser["Username"] + UserGroups = CurrentUser.get("Groups", []) + IsAdmin = CurrentUser.get("IsAdmin", False) + + # A locked subdirectory (or anything inside one) shouldn't even appear + # in its parent's listing for someone without access to it. + return [ + Entry + for Entry in Entries + if IsPathAccessible( + Root, + f"{Path}/{Entry['Name']}" if Path else Entry["Name"], + Username, + UserGroups, + IsAdmin, + ) + ] + class CreateEntryRequest(BaseModel): IsDirectory: bool @@ -213,6 +409,8 @@ class CreateEntryRequest(BaseModel): def PostTreeEndpoint( Root: str, Path: str, Body: CreateEntryRequest, CurrentUser: dict = Depends(GetCurrentUser) ) -> dict: + RequirePathAccessible(Root, Path, CurrentUser) + try: CreateEntry(Root, Path, Body.IsDirectory) except ValueError as Error: @@ -234,6 +432,8 @@ class MoveEntryRequest(BaseModel): def PatchTreeEndpoint( Root: str, Path: str, Body: MoveEntryRequest, CurrentUser: dict = Depends(GetCurrentUser) ) -> dict: + RequirePathAccessible(Root, Path, CurrentUser) + if Body.DestinationPath: Destination = Body.DestinationPath elif Body.NewName: @@ -242,6 +442,8 @@ def PatchTreeEndpoint( else: raise HTTPException(status_code=400, detail="Provide NewName or DestinationPath.") + RequirePathAccessible(Root, Destination, CurrentUser) + try: MoveEntry(Root, Path, Destination) except ValueError as Error: @@ -258,6 +460,8 @@ def PatchTreeEndpoint( @Application.delete("/api/tree") def DeleteTreeEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + RequirePathAccessible(Root, Path, CurrentUser) + try: DeleteEntry(Root, Path) except ValueError as Error: @@ -274,6 +478,8 @@ def DeleteTreeEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurr def GetFileEndpoint( Root: str, Path: str, request: Request, CurrentUser: dict = Depends(GetCurrentUser) ): + RequirePathAccessible(Root, Path, CurrentUser) + try: FilePath = ResolvePath(Root, Path) except ValueError as Error: @@ -322,6 +528,8 @@ def GetFileEndpoint( async def PutFileEndpoint( Root: str, Path: str, request: Request, CurrentUser: dict = Depends(GetCurrentUser) ): + RequirePathAccessible(Root, Path, CurrentUser) + try: FilePath = ResolvePath(Root, Path) except ValueError as Error: @@ -348,12 +556,41 @@ async def PutFileEndpoint( @Application.get("/api/blame") def GetBlameEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + RequirePathAccessible(Root, Path, CurrentUser) + try: ResolvePath(Root, Path) except ValueError as Error: raise HTTPException(status_code=400, detail=str(Error)) - return LoadBlame(Root, Path) + RawBlame = LoadBlame(Root, Path) + AllUsers = {User["Username"]: User for User in ListUsers()} + ViewerUsername = CurrentUser["Username"] + Enriched: dict[str, dict] = {} + + for LineNumber, Entry in RawBlame.items(): + AuthorUsername = Entry.get("Username") + Timestamp = Entry.get("Timestamp") + + if AuthorUsername == ViewerUsername: + IsVisible = True + elif AuthorUsername in AllUsers: + IsVisible = LoadSettings(AuthorUsername).get("VisibleInBlame", True) + else: + IsVisible = True + + if IsVisible: + AuthorRecord = AllUsers.get(AuthorUsername, {}) + Enriched[LineNumber] = { + "Username": AuthorUsername, + "Timestamp": Timestamp, + "Colour": AuthorRecord.get("Colour", "Purple"), + "HasAvatar": AuthorUsername is not None and GetAvatarPath(AuthorUsername) is not None, + } + else: + Enriched[LineNumber] = {"Username": None, "Hidden": True, "Timestamp": Timestamp} + + return Enriched @Application.get("/api/raw/{RootName}/{FilePath:path}") @@ -365,6 +602,8 @@ def GetRawFileEndpoint( and scripts INSIDE a previewed HTML document resolve correctly against sibling files in the same directory. """ + RequirePathAccessible(RootName, FilePath, CurrentUser) + try: ResolvedPath = ResolvePath(RootName, FilePath) except ValueError as Error: @@ -385,6 +624,8 @@ def GetRawFileEndpoint( @Application.get("/api/book") def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + RequirePathAccessible(Root, Path, CurrentUser) + try: FilePath = ResolvePath(Root, Path) except ValueError as Error: diff --git a/Backend/Settings.py b/Backend/Settings.py index b67794a..0bd15c5 100644 --- a/Backend/Settings.py +++ b/Backend/Settings.py @@ -42,6 +42,8 @@ DefaultSettings: dict[str, Any] = { "NewEntryButtonsAtTop": False, "StickyToolbar": True, "ConfirmBeforeDelete": True, + "VisibleInBlame": True, + "ShowBlameAvatars": False, } DefaultThemesDirectory = ApplicationDirectory / "DefaultThemes" @@ -135,6 +137,13 @@ def SaveSettings(Username: str, NewSettings: dict[str, Any]) -> dict[str, Any]: return Merged +def RenameUserSettings(OldUsername: str, NewUsername: str) -> None: + OldPath = GetSettingsPath(OldUsername) + + if OldPath.is_file(): + OldPath.rename(GetSettingsPath(NewUsername)) + + def ListThemeNames() -> list[str]: EnsureDataDirectory() return sorted(ThemeFile.stem for ThemeFile in GetThemesDirectory().glob("*.toml")) diff --git a/Backend/Users.py b/Backend/Users.py index 231d61b..af427e3 100644 --- a/Backend/Users.py +++ b/Backend/Users.py @@ -12,6 +12,7 @@ import tomli_w from Settings import GetDataDirectory +from Groups import AddMember, AdminGroupName, GetGroupsForUser Pbkdf2Iterations = 260_000 @@ -96,11 +97,13 @@ def CreateUser(Username: str, Password: str, IsAdmin: bool, Colour: str = Defaul Users[Username] = { "PasswordHash": HashPassword(Password), - "IsAdmin": IsAdmin, "Colour": Colour, } SaveUsers(Users) + if IsAdmin: + AddMember(AdminGroupName, Username) + def UpdateUserColour(Username: str, Colour: str) -> None: if Colour not in UserColourOptions: @@ -115,6 +118,44 @@ def UpdateUserColour(Username: str, Colour: str) -> None: SaveUsers(Users) +def RenameUser(OldUsername: str, NewUsername: str) -> None: + NewUsername = NewUsername.strip() + + if not NewUsername: + raise ValueError("Username cannot be empty.") + + if NewUsername == OldUsername: + return + + Users = LoadUsers() + + if OldUsername not in Users: + raise ValueError("User not found.") + + if NewUsername in Users: + raise ValueError(f"User '{NewUsername}' already exists.") + + Users[NewUsername] = Users.pop(OldUsername) + SaveUsers(Users) + + +def ChangePassword(Username: str, CurrentPassword: str, NewPassword: str) -> None: + User = GetUser(Username) + + if User is None: + raise ValueError("User not found.") + + if not VerifyPassword(CurrentPassword, User["PasswordHash"]): + raise ValueError("Current password is incorrect.") + + if not NewPassword: + raise ValueError("New password cannot be empty.") + + Users = LoadUsers() + Users[Username]["PasswordHash"] = HashPassword(NewPassword) + SaveUsers(Users) + + def VerifyLogin(Username: str, Password: str) -> dict[str, Any] | None: User = GetUser(Username) @@ -126,7 +167,7 @@ def VerifyLogin(Username: str, Password: str) -> dict[str, Any] | None: return { "Username": Username, - "IsAdmin": User.get("IsAdmin", False), + "IsAdmin": AdminGroupName in GetGroupsForUser(Username), "Colour": User.get("Colour", DefaultUserColour), } @@ -136,7 +177,7 @@ def ListUsers() -> list[dict[str, Any]]: return [ { "Username": Name, - "IsAdmin": Data.get("IsAdmin", False), + "IsAdmin": AdminGroupName in GetGroupsForUser(Name), "Colour": Data.get("Colour", DefaultUserColour), } for Name, Data in Users.items() @@ -168,3 +209,13 @@ def GetAvatarPath(Username: str) -> Path | None: Matches = sorted(Directory.glob(f"{Username}.*")) return Matches[0] if Matches else None + + +def RenameUserAvatar(OldUsername: str, NewUsername: str) -> None: + Directory = GetAvatarDirectory() + + if not Directory.is_dir(): + return + + for ExistingPath in Directory.glob(f"{OldUsername}.*"): + ExistingPath.rename(Directory / f"{NewUsername}{ExistingPath.suffix}") diff --git a/Frontend/src/Api.js b/Frontend/src/Api.js index 3ceb3c8..ae82629 100644 --- a/Frontend/src/Api.js +++ b/Frontend/src/Api.js @@ -81,6 +81,58 @@ export function GetAvatarUrl(Username) { return `${ApiBase}/profile/avatar?${Params}`; } +export function UpdateUsername(NewUsername) { + return FetchJson("/profile/username", { method: "PUT", ...JsonBody({ NewUsername }) }); +} + +export function ChangePassword(CurrentPassword, NewPassword) { + return FetchJson("/profile/password", { + method: "PUT", + ...JsonBody({ CurrentPassword, NewPassword }), + }); +} + +// --- Groups --------------------------------------------------------------- + +export function FetchGroups() { + return FetchJson("/groups"); +} + +export function CreateGroupApi(Name) { + return FetchJson("/groups", { method: "POST", ...JsonBody({ Name }) }); +} + +export function DeleteGroupApi(Name) { + const Params = new URLSearchParams({ Name }); + return FetchJson(`/groups?${Params}`, { method: "DELETE" }); +} + +export function SetGroupMembersApi(Name, Members) { + const Params = new URLSearchParams({ Name }); + return FetchJson(`/groups/members?${Params}`, { method: "PUT", ...JsonBody({ Members }) }); +} + +// --- Locked files/directories ----------------------------------------- + +export function FetchLocks() { + return FetchJson("/locks"); +} + +export function FetchLock(RootName, RelativePath) { + const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); + return FetchJson(`/lock?${Params}`); +} + +export function SetLock(RootName, RelativePath, Users, Groups) { + const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); + return FetchJson(`/lock?${Params}`, { method: "PUT", ...JsonBody({ Users, Groups }) }); +} + +export function RemoveLock(RootName, RelativePath) { + const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); + return FetchJson(`/lock?${Params}`, { method: "DELETE" }); +} + // --- Files -------------------------------------------------------------- export function FetchRoots() { diff --git a/Frontend/src/App.jsx b/Frontend/src/App.jsx index e8c5a1e..0ac93f8 100644 --- a/Frontend/src/App.jsx +++ b/Frontend/src/App.jsx @@ -238,6 +238,7 @@ export default function App() { FileType={ViewFileType} OnNavigateLink={HandleSelectFile} ShowGitBlame={Settings ? Settings.ShowGitBlame : false} + ShowBlameAvatars={Settings ? Settings.ShowBlameAvatars : false} StickyToolbar={Settings ? Settings.StickyToolbar : true} /> )} diff --git a/Frontend/src/Components/AccessModal.jsx b/Frontend/src/Components/AccessModal.jsx new file mode 100644 index 0000000..a10f7ac --- /dev/null +++ b/Frontend/src/Components/AccessModal.jsx @@ -0,0 +1,72 @@ +import React, { useState } from "react"; + +import AccessPicker from "./AccessPicker.jsx"; + +export default function AccessModal({ + Title, + ShowNameField, + InitialName, + NamePlaceholder, + InitialUsers, + InitialGroups, + ConfirmLabel, + OnConfirm, + OnCancel, +}) { + const [Name, SetName] = useState(InitialName || ""); + const [Users, SetUsers] = useState(InitialUsers || []); + const [Groups, SetGroups] = useState(InitialGroups || []); + + function HandleConfirm() { + OnConfirm(Name, Users, Groups); + } + + function HandleRemoveRestriction() { + OnConfirm(Name, [], []); + } + + const HasRestriction = (InitialUsers && InitialUsers.length > 0) || (InitialGroups && InitialGroups.length > 0); + + return ( +
+
Event.stopPropagation()}> +
+ {Title} + +
+
+ {ShowNameField && ( +
+ + SetName(Event.target.value)} + placeholder={NamePlaceholder} + autoFocus + /> +
+ )} +
+ + +
+
+
+ {!ShowNameField && HasRestriction && ( + + )} + + +
+
+
+ ); +} diff --git a/Frontend/src/Components/AccessPicker.jsx b/Frontend/src/Components/AccessPicker.jsx new file mode 100644 index 0000000..84e51af --- /dev/null +++ b/Frontend/src/Components/AccessPicker.jsx @@ -0,0 +1,75 @@ +import React, { useEffect, useState } from "react"; + +import { FetchGroups, FetchUsers } from "../Api.js"; + +export default function AccessPicker({ SelectedUsers, SelectedGroups, OnChangeUsers, OnChangeGroups }) { + const [AllUsers, SetAllUsers] = useState([]); + const [AllGroups, SetAllGroups] = useState([]); + const [Query, SetQuery] = useState(""); + + useEffect(() => { + FetchUsers().then(SetAllUsers); + FetchGroups().then(SetAllGroups); + }, []); + + function ToggleUser(Username) { + if (SelectedUsers.includes(Username)) { + OnChangeUsers(SelectedUsers.filter((U) => U !== Username)); + } else { + OnChangeUsers([...SelectedUsers, Username]); + } + } + + function ToggleGroup(Name) { + if (SelectedGroups.includes(Name)) { + OnChangeGroups(SelectedGroups.filter((G) => G !== Name)); + } else { + OnChangeGroups([...SelectedGroups, Name]); + } + } + + const LowerQuery = Query.toLowerCase(); + const FilteredGroups = AllGroups.filter((Group) => Group.Name.toLowerCase().includes(LowerQuery)); + const FilteredUsers = AllUsers.filter((User) => User.Username.toLowerCase().includes(LowerQuery)); + + return ( +
+ SetQuery(Event.target.value)} + /> +
+ {FilteredGroups.map((Group) => ( + + ))} + {FilteredUsers.map((User) => ( + + ))} + {FilteredGroups.length === 0 && FilteredUsers.length === 0 && ( +
No matches.
+ )} +
+
+ ); +} diff --git a/Frontend/src/Components/DirectoryPane.jsx b/Frontend/src/Components/DirectoryPane.jsx index 3b2779c..56c993c 100644 --- a/Frontend/src/Components/DirectoryPane.jsx +++ b/Frontend/src/Components/DirectoryPane.jsx @@ -1,7 +1,16 @@ import React, { useEffect, useState } from "react"; -import { CreateEntry, DeleteEntry, FetchTree, MoveEntry, RenameEntry } from "../Api.js"; +import { + CreateEntry, + DeleteEntry, + FetchLock, + FetchTree, + MoveEntry, + RenameEntry, + SetLock as SetLockApi, +} from "../Api.js"; import { GetFileIcon, GetFolderIcon } from "../IconStyle.js"; +import AccessModal from "./AccessModal.jsx"; export default function DirectoryPane({ RootName, @@ -16,6 +25,7 @@ export default function DirectoryPane({ }) { const [Entries, SetEntries] = useState([]); const [LoadError, SetLoadError] = useState(null); + const [ModalConfig, SetModalConfig] = useState(null); async function Reload() { try { @@ -38,29 +48,29 @@ export default function DirectoryPane({ return DirectoryPath ? `${DirectoryPath}/${Name}` : Name; } - async function HandleCreateFile() { - const Name = window.prompt("New file name (e.g. Notes.md):"); - if (!Name) return; - - try { - await CreateEntry(RootName, JoinPath(Name), false); - await Reload(); - OnEntryCreated(); - } catch (Error) { - SetLoadError(Error.message); - } + function OpenCreateFileModal() { + SetModalConfig({ Mode: "createFile" }); } - async function HandleCreateDirectory() { - const Name = window.prompt("New directory name:"); - if (!Name) return; + function OpenCreateDirectoryModal() { + SetModalConfig({ Mode: "createDirectory" }); + } + + async function HandleRequestLockEdit(Event, Entry) { + Event.stopPropagation(); + const FullPath = JoinPath(Entry.Name); try { - await CreateEntry(RootName, JoinPath(Name), true); - await Reload(); - OnEntryCreated(); + const Existing = await FetchLock(RootName, FullPath); + SetModalConfig({ + Mode: "editLock", + TargetPath: FullPath, + EntryName: Entry.Name, + InitialUsers: (Existing && Existing.Users) || [], + InitialGroups: (Existing && Existing.Groups) || [], + }); } catch (Error) { - SetLoadError(Error.message); + window.alert(Error.message); } } @@ -101,11 +111,37 @@ export default function DirectoryPane({ } } + async function HandleModalConfirm(Name, Users, Groups) { + try { + if (ModalConfig.Mode === "createFile" || ModalConfig.Mode === "createDirectory") { + if (!Name || !Name.trim()) return; + + const IsDirectory = ModalConfig.Mode === "createDirectory"; + const FullPath = JoinPath(Name.trim()); + await CreateEntry(RootName, FullPath, IsDirectory); + + if (Users.length > 0 || Groups.length > 0) { + await SetLockApi(RootName, FullPath, Users, Groups); + } + + await Reload(); + OnEntryCreated(); + } else if (ModalConfig.Mode === "editLock") { + await SetLockApi(RootName, ModalConfig.TargetPath, Users, Groups); + await Reload(); + } + + SetModalConfig(null); + } catch (Error) { + window.alert(Error.message); + } + } + return (
- - + +
{LoadError &&
{LoadError}
}
@@ -122,6 +158,13 @@ export default function DirectoryPane({ {Entry.Name} +
+ + {ModalConfig && ModalConfig.Mode === "createFile" && ( + SetModalConfig(null)} + /> + )} + {ModalConfig && ModalConfig.Mode === "createDirectory" && ( + SetModalConfig(null)} + /> + )} + {ModalConfig && ModalConfig.Mode === "editLock" && ( + SetModalConfig(null)} + /> + )}
); } diff --git a/Frontend/src/Components/PreviewPane.jsx b/Frontend/src/Components/PreviewPane.jsx index 9f8140e..88e1f8a 100644 --- a/Frontend/src/Components/PreviewPane.jsx +++ b/Frontend/src/Components/PreviewPane.jsx @@ -7,7 +7,7 @@ import { json } from "@codemirror/lang-json"; import MarkdownIt from "markdown-it"; import MarkdownItTaskLists from "markdown-it-task-lists"; -import { FetchBlame, FetchFile, GetFileUrl, GetRawFileUrl, SaveFile } from "../Api.js"; +import { FetchBlame, FetchFile, GetAvatarUrl, GetFileUrl, GetRawFileUrl, SaveFile } from "../Api.js"; const MarkdownRenderer = new MarkdownIt({ breaks: true }).use(MarkdownItTaskLists, { enabled: true, @@ -90,26 +90,45 @@ function GetLanguageExtension(FileType) { } class BlameMarker extends GutterMarker { - constructor(ShortLabel, Title) { + constructor(Mode, Content, Colour, Title) { super(); - this.ShortLabel = ShortLabel; + this.Mode = Mode; + this.Content = Content; + this.Colour = Colour; this.Title = Title; } eq(Other) { - return this.ShortLabel === Other.ShortLabel && this.Title === Other.Title; + return ( + this.Mode === Other.Mode && + this.Content === Other.Content && + this.Colour === Other.Colour && + this.Title === Other.Title + ); } toDOM() { + if (this.Mode === "avatar") { + const Image = document.createElement("img"); + Image.className = "BlameGutterAvatar"; + Image.src = this.Content; + Image.title = this.Title; + Image.alt = ""; + return Image; + } + const Element = document.createElement("span"); - Element.className = "BlameGutterMarker"; - Element.textContent = this.ShortLabel; + Element.className = `BlameGutterMarker${this.Mode === "hidden" ? " BlameGutterHidden" : ""}`; + Element.textContent = this.Content; Element.title = this.Title; + if (this.Colour) { + Element.style.color = `var(--${this.Colour})`; + } return Element; } } -function BuildBlameGutter(BlameMap) { +function BuildBlameGutter(BlameMap, ShowAvatars) { return gutter({ class: "BlameGutter", lineMarker(view, line) { @@ -117,9 +136,18 @@ function BuildBlameGutter(BlameMap) { const Entry = BlameMap[String(LineNumber)]; if (!Entry) return null; - const ShortLabel = Entry.Username.slice(0, 6); + if (Entry.Hidden) { + const Title = Entry.Timestamp ? new Date(Entry.Timestamp).toLocaleString() : ""; + return new BlameMarker("hidden", "•••", null, Title); + } + const Title = `${Entry.Username} — ${new Date(Entry.Timestamp).toLocaleString()}`; - return new BlameMarker(ShortLabel, Title); + + if (ShowAvatars && Entry.HasAvatar) { + return new BlameMarker("avatar", GetAvatarUrl(Entry.Username), null, Title); + } + + return new BlameMarker("text", Entry.Username.slice(0, 6), Entry.Colour, Title); }, }); } @@ -130,6 +158,7 @@ export default function PreviewPane({ FileType, OnNavigateLink, ShowGitBlame, + ShowBlameAvatars, StickyToolbar, }) { const [Content, SetContent] = useState(""); @@ -271,7 +300,7 @@ export default function PreviewPane({ height="100%" extensions={[ ...GetLanguageExtension(FileType), - ...(ShowGitBlame ? [BuildBlameGutter(BlameMap)] : []), + ...(ShowGitBlame ? [BuildBlameGutter(BlameMap, ShowBlameAvatars)] : []), ]} onChange={SetContent} theme="dark" diff --git a/Frontend/src/Components/SettingsPanel.jsx b/Frontend/src/Components/SettingsPanel.jsx index c14dd02..de09ea8 100644 --- a/Frontend/src/Components/SettingsPanel.jsx +++ b/Frontend/src/Components/SettingsPanel.jsx @@ -1,8 +1,13 @@ import React, { useEffect, useState } from "react"; import { + ChangePassword, + CreateGroupApi, CreateUserAccount, + DeleteGroupApi, DeleteTheme, + FetchGroups, + FetchLocks, FetchProfileColourOptions, FetchSettings, FetchTheme, @@ -10,9 +15,12 @@ import { FetchUsers, GetAvatarUrl, Logout, + RemoveLock, SaveSettings, SaveTheme, + SetGroupMembersApi, UpdateProfileColour, + UpdateUsername, UploadAvatar, } from "../Api.js"; import Switch from "./Switch.jsx"; @@ -46,12 +54,32 @@ export default function SettingsPanel({ const [ProfileError, SetProfileError] = useState(null); const [AvatarNonce, SetAvatarNonce] = useState(0); + const [UsernameField, SetUsernameField] = useState(""); + const [UsernameError, SetUsernameError] = useState(null); + const [CurrentPasswordField, SetCurrentPasswordField] = useState(""); + const [NewPasswordField, SetNewPasswordField] = useState(""); + const [PasswordError, SetPasswordError] = useState(null); + const [PasswordChanged, SetPasswordChanged] = useState(false); + + const [Locks, SetLocksList] = useState(null); + const [LockError, SetLockError] = useState(null); + + const [Groups, SetGroupsList] = useState(null); + const [NewGroupName, SetNewGroupName] = useState(""); + const [GroupError, SetGroupError] = useState(null); + const [ExpandedGroup, SetExpandedGroup] = useState(null); + useEffect(() => { FetchSettings().then(SetSettings); FetchThemeNames().then(SetThemeNames); FetchProfileColourOptions().then(SetColourOptions); + if (CurrentUser) { + SetUsernameField(CurrentUser.Username); + } if (CurrentUser && CurrentUser.IsAdmin) { FetchUsers().then(SetUsers); + FetchLocks().then(SetLocksList); + FetchGroups().then(SetGroupsList); } }, [CurrentUser]); @@ -181,6 +209,86 @@ export default function SettingsPanel({ } } + async function HandleChangeUsername() { + SetUsernameError(null); + + if (!UsernameField.trim() || UsernameField.trim() === CurrentUser.Username) return; + + try { + const Updated = await UpdateUsername(UsernameField.trim()); + OnProfileUpdated(Updated); + } catch (Error) { + SetUsernameError(Error.message); + } + } + + async function HandleChangePassword() { + SetPasswordError(null); + SetPasswordChanged(false); + + try { + await ChangePassword(CurrentPasswordField, NewPasswordField); + SetCurrentPasswordField(""); + SetNewPasswordField(""); + SetPasswordChanged(true); + } catch (Error) { + SetPasswordError(Error.message); + } + } + + async function HandleUnlock(Lock) { + SetLockError(null); + try { + await RemoveLock(Lock.Root, Lock.Path); + const Updated = await FetchLocks(); + SetLocksList(Updated); + } catch (Error) { + SetLockError(Error.message); + } + } + + async function HandleCreateGroup() { + SetGroupError(null); + + try { + await CreateGroupApi(NewGroupName.trim()); + const Updated = await FetchGroups(); + SetGroupsList(Updated); + SetNewGroupName(""); + } catch (Error) { + SetGroupError(Error.message); + } + } + + async function HandleDeleteGroup(Name) { + if (!window.confirm(`Delete group "${Name}"? This can't be undone.`)) return; + + SetGroupError(null); + try { + await DeleteGroupApi(Name); + const Updated = await FetchGroups(); + SetGroupsList(Updated); + } catch (Error) { + SetGroupError(Error.message); + } + } + + async function HandleToggleGroupMember(Group, Username) { + const CurrentMembers = Group.Members || []; + const NewMembers = CurrentMembers.includes(Username) + ? CurrentMembers.filter((Member) => Member !== Username) + : [...CurrentMembers, Username]; + + SetGroupError(null); + try { + await SetGroupMembersApi(Group.Name, NewMembers); + const Updated = await FetchGroups(); + SetGroupsList(Updated); + } catch (Error) { + SetGroupError(Error.message); + } + } + return (
Event.stopPropagation()}> @@ -245,6 +353,16 @@ export default function SettingsPanel({ UpdateField("ShowGitBlame", Event.target.checked)} />
+
+ + UpdateField("ShowBlameAvatars", Event.target.checked)} /> +
+ +
+ + UpdateField("VisibleInBlame", Event.target.checked)} /> +
+
UpdateField("StickyToolbar", Event.target.checked)} /> @@ -352,6 +470,42 @@ export default function SettingsPanel({
+ +
+ +
+ SetUsernameField(Event.target.value)} + /> + +
+ {UsernameError &&
{UsernameError}
} +
+ Renaming logs out any other devices/browsers you're signed in on — this one + stays signed in under the new name. +
+
+ +
+ + SetCurrentPasswordField(Event.target.value)} + /> + SetNewPasswordField(Event.target.value)} + /> + + {PasswordChanged &&
Password updated.
} + {PasswordError &&
{PasswordError}
} +
{CurrentUser && CurrentUser.IsAdmin && ( @@ -409,6 +563,86 @@ export default function SettingsPanel({ )} + {CurrentUser && CurrentUser.IsAdmin && ( +
+
Locked Directories (admin)
+ {Locks && Locks.length === 0 && ( +
+ Nothing locked yet — use the 🔒 button next to any directory to lock it. +
+ )} + {Locks && + Locks.map((Lock, Index) => ( +
+ + {Lock.Root}/{Lock.Path} —{" "} + {[...(Lock.Users || []), ...(Lock.Groups || []).map((G) => `@${G}`)].join( + ", " + )} + + +
+ ))} + {LockError &&
{LockError}
} +
+ )} + + {CurrentUser && CurrentUser.IsAdmin && ( +
+
Groups (admin)
+ {Groups && + Groups.map((Group) => ( +
+
+ + {Group.Name} ({(Group.Members || []).length}) + +
+ + {Group.Name !== "Admin" && ( + + )} +
+
+ {ExpandedGroup === Group.Name && ( +
+ {(Users || []).map((User) => ( + + ))} +
+ )} +
+ ))} +
+ SetNewGroupName(Event.target.value)} + /> + +
+ {GroupError &&
{GroupError}
} +
+ )} + {SaveError &&
{SaveError}
} diff --git a/Frontend/src/Components/Sidebar.jsx b/Frontend/src/Components/Sidebar.jsx index bdc12be..98846be 100644 --- a/Frontend/src/Components/Sidebar.jsx +++ b/Frontend/src/Components/Sidebar.jsx @@ -1,7 +1,17 @@ import React, { useEffect, useState } from "react"; -import { CreateEntry, DeleteEntry, FetchTree, GetAvatarUrl, MoveEntry, RenameEntry } from "../Api.js"; +import { + CreateEntry, + DeleteEntry, + FetchLock, + FetchTree, + GetAvatarUrl, + MoveEntry, + RenameEntry, + SetLock as SetLockApi, +} from "../Api.js"; import { GetFileIcon, GetFolderIcon } from "../IconStyle.js"; +import AccessModal from "./AccessModal.jsx"; function TreeNode({ RootName, @@ -14,6 +24,7 @@ function TreeNode({ OnBrowseDirectory, OnEntryRenamed, OnEntryDeleted, + OnRequestLockEdit, UseHyphenBullets, UseAsciiFolderIcons, NavigateIntoDirectories, @@ -103,6 +114,11 @@ function TreeNode({ } } + function HandleLockClick(Event) { + Event.stopPropagation(); + OnRequestLockEdit(FullPath, Entry.Name); + } + return (
{Entry.Name} + @@ -141,6 +160,7 @@ function TreeNode({ OnBrowseDirectory={OnBrowseDirectory} OnEntryRenamed={OnEntryRenamed} OnEntryDeleted={OnEntryDeleted} + OnRequestLockEdit={OnRequestLockEdit} UseHyphenBullets={UseHyphenBullets} UseAsciiFolderIcons={UseAsciiFolderIcons} NavigateIntoDirectories={NavigateIntoDirectories} @@ -207,6 +227,7 @@ export default function Sidebar({ }) { const [RootEntries, SetRootEntries] = useState([]); const [LoadError, SetLoadError] = useState(null); + const [ModalConfig, SetModalConfig] = useState(null); useEffect(() => { if (RootName) { @@ -221,39 +242,53 @@ export default function Sidebar({ return CurrentDirectoryPath ? `${CurrentDirectoryPath}/${Name}` : Name; } - async function HandleCreateFile() { - const Name = window.prompt( - `New file name in ${CurrentDirectoryPath || "root"} (e.g. Notes.md):` - ); - if (!Name) return; + function OpenCreateFileModal() { + SetModalConfig({ Mode: "createFile" }); + } + function OpenCreateDirectoryModal() { + SetModalConfig({ Mode: "createDirectory" }); + } + + async function HandleRequestLockEdit(FullPath, EntryName) { try { - await CreateEntry(RootName, JoinPath(Name), false); - OnEntryCreated(); + const Existing = await FetchLock(RootName, FullPath); + SetModalConfig({ + Mode: "editLock", + TargetPath: FullPath, + EntryName, + InitialUsers: (Existing && Existing.Users) || [], + InitialGroups: (Existing && Existing.Groups) || [], + }); } catch (Error) { - SetLoadError(Error.message); + window.alert(Error.message); } } - async function HandleCreateDirectory() { - const Name = window.prompt(`New directory name in ${CurrentDirectoryPath || "root"}:`); - if (!Name) return; - + async function HandleModalConfirm(Name, Users, Groups) { try { - await CreateEntry(RootName, JoinPath(Name), true); - OnEntryCreated(); + if (ModalConfig.Mode === "createFile" || ModalConfig.Mode === "createDirectory") { + if (!Name || !Name.trim()) return; + + const IsDirectory = ModalConfig.Mode === "createDirectory"; + const FullPath = JoinPath(Name.trim()); + await CreateEntry(RootName, FullPath, IsDirectory); + + if (Users.length > 0 || Groups.length > 0) { + await SetLockApi(RootName, FullPath, Users, Groups); + } + + OnEntryCreated(); + } else if (ModalConfig.Mode === "editLock") { + await SetLockApi(RootName, ModalConfig.TargetPath, Users, Groups); + } + + SetModalConfig(null); } catch (Error) { - SetLoadError(Error.message); + window.alert(Error.message); } } - const NewEntryButtons = ( -
- - -
- ); - return (
@@ -267,7 +302,12 @@ export default function Sidebar({
- {NewEntryButtonsAtTop && NewEntryButtons} + {NewEntryButtonsAtTop && ( +
+ + +
+ )} {LoadError &&
{LoadError}
}
{RootEntries.map((Entry) => ( @@ -283,6 +323,7 @@ export default function Sidebar({ OnBrowseDirectory={OnBrowseDirectory} OnEntryRenamed={OnEntryRenamed} OnEntryDeleted={OnEntryDeleted} + OnRequestLockEdit={HandleRequestLockEdit} UseHyphenBullets={UseHyphenBullets} UseAsciiFolderIcons={UseAsciiFolderIcons} NavigateIntoDirectories={NavigateIntoDirectories} @@ -290,7 +331,48 @@ export default function Sidebar({ /> ))}
- {!NewEntryButtonsAtTop && NewEntryButtons} + {!NewEntryButtonsAtTop && ( +
+ + +
+ )} + + {ModalConfig && ModalConfig.Mode === "createFile" && ( + SetModalConfig(null)} + /> + )} + {ModalConfig && ModalConfig.Mode === "createDirectory" && ( + SetModalConfig(null)} + /> + )} + {ModalConfig && ModalConfig.Mode === "editLock" && ( + SetModalConfig(null)} + /> + )}
); } diff --git a/Frontend/src/Styles/Theme.css b/Frontend/src/Styles/Theme.css index 33e98c1..a65fb69 100644 --- a/Frontend/src/Styles/Theme.css +++ b/Frontend/src/Styles/Theme.css @@ -454,6 +454,20 @@ img, white-space: nowrap; } +.BlameGutterHidden { + color: var(--TextMuted); + opacity: 0.6; +} + +.BlameGutterAvatar { + width: 14px; + height: 14px; + border-radius: 50%; + object-fit: cover; + margin: 0 4px; + vertical-align: middle; +} + .PreviewMedia img, .PreviewMedia video { margin: auto; @@ -747,6 +761,76 @@ img, font-size: 13px; } +.SettingsHint { + color: var(--TextMuted); + font-size: 12px; +} + +.AccessPicker { + display: flex; + flex-direction: column; + gap: 6px; +} + +.AccessPicker input[type="text"] { + background: var(--SurfaceAlt); + color: var(--Text); + border: 1px solid var(--Border); + border-radius: 4px; + padding: 6px 8px; + font-family: inherit; +} + +.AccessPickerList { + max-height: 160px; + overflow-y: auto; + border: 1px solid var(--Border); + border-radius: 4px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.AccessPickerRow { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px; + border-radius: 3px; + cursor: pointer; + font-size: 13px; +} + +.AccessPickerRow:hover { + background: var(--SurfaceAlt); +} + +.AccessPickerType { + color: var(--Purple); + font-size: 11px; + border: 1px solid var(--Purple); + border-radius: 3px; + padding: 0 4px; +} + +.AccessPickerEmpty { + color: var(--TextMuted); + font-size: 13px; + padding: 4px 6px; +} + +.GroupRow { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; +} + +.GroupMemberList { + margin-left: 8px; +} + /* Login screen */ .LoginScreen { diff --git a/README.md b/README.md index c4783d3..af8f49c 100644 --- a/README.md +++ b/README.md @@ -139,3 +139,37 @@ own icon/entry in ZimaOS's app store the way bundled apps do — that would need a CasaOS-format AppStore manifest (icon, category, metadata) submitted separately, which is a reasonable next step if you want it to feel fully native rather than "a compose file I pasted in." + +## Groups and access control + +Admin isn't a hardcoded flag — it's the built-in "Admin" group, which can't +be deleted but is otherwise just a group like any other. Manage groups +(create, delete, edit membership) from Settings → Groups (admin-only). + +Setting access on a file or directory is **not** admin-only — anyone can +restrict a file or directory to specific users and/or groups, whether at +creation time (the New File/New Directory dialog includes a search-and- +checkbox picker) or afterwards via the 🔒 button next to any entry, in +either the sidebar tree or the directory-browse pane. If you can currently +see something, you're trusted to change who else can. Admins additionally +bypass every restriction, so they can always troubleshoot. + +Restricting is per-file-or-directory — locking a directory covers everything +inside it automatically (there's only ever one restriction record per locked +directory; reaching anything underneath still requires being permitted on +that ancestor), while locking an individual file affects only that file. + +Anyone without access gets a plain 404, both for direct requests and for +that entry showing up in a directory listing — a restricted item's existence +isn't revealed to people who can't see it. Admins get a global overview of +every current restriction from Settings → Locked Directories. + +## Blame privacy + +Each person can turn off "Show my edits in others' blame view" in their own +Settings. When that's off, other users still see that lines were changed — +just with the identity redacted, rather than attributing it to someone who +asked not to be shown. You can always see your own attribution regardless of +this setting. There's also a per-viewer toggle for showing avatars instead +of usernames in the blame gutter, using each author's chosen colour either +way.