mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 18:38:11 +02:00
Added groups and restricted access.
This commit is contained in:
1 parent
8aa223bc7b
commit
9a5b9530a3
17 files changed
+1406
-66
No files matched your search
+7
-1
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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.")
|
||||
+247
-6
@@ -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:
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+54
-3
@@ -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}")
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div className="SettingsOverlay" onClick={OnCancel}>
|
||||
<div className="SettingsModal" onClick={(Event) => Event.stopPropagation()}>
|
||||
<div className="SettingsHeader">
|
||||
<span>{Title}</span>
|
||||
<button className="SettingsClose" onClick={OnCancel}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="SettingsBody">
|
||||
{ShowNameField && (
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={Name}
|
||||
onChange={(Event) => SetName(Event.target.value)}
|
||||
placeholder={NamePlaceholder}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Restrict access to (leave empty for everyone)</label>
|
||||
<AccessPicker
|
||||
SelectedUsers={Users}
|
||||
SelectedGroups={Groups}
|
||||
OnChangeUsers={SetUsers}
|
||||
OnChangeGroups={SetGroups}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="SettingsFooter">
|
||||
{!ShowNameField && HasRestriction && (
|
||||
<button onClick={HandleRemoveRestriction}>Remove restriction</button>
|
||||
)}
|
||||
<button onClick={OnCancel}>Cancel</button>
|
||||
<button onClick={HandleConfirm}>{ConfirmLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="AccessPicker">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users or groups…"
|
||||
value={Query}
|
||||
onChange={(Event) => SetQuery(Event.target.value)}
|
||||
/>
|
||||
<div className="AccessPickerList">
|
||||
{FilteredGroups.map((Group) => (
|
||||
<label className="AccessPickerRow" key={`group:${Group.Name}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={SelectedGroups.includes(Group.Name)}
|
||||
onChange={() => ToggleGroup(Group.Name)}
|
||||
/>
|
||||
<span className="AccessPickerType">Group</span>
|
||||
<span>{Group.Name}</span>
|
||||
</label>
|
||||
))}
|
||||
{FilteredUsers.map((User) => (
|
||||
<label className="AccessPickerRow" key={`user:${User.Username}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={SelectedUsers.includes(User.Username)}
|
||||
onChange={() => ToggleUser(User.Username)}
|
||||
/>
|
||||
<span
|
||||
className="ColourSwatchSmall"
|
||||
style={{ background: `var(--${User.Colour || "Purple"})` }}
|
||||
/>
|
||||
<span>{User.Username}</span>
|
||||
</label>
|
||||
))}
|
||||
{FilteredGroups.length === 0 && FilteredUsers.length === 0 && (
|
||||
<div className="AccessPickerEmpty">No matches.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="DirectoryPane">
|
||||
<div className="PreviewToolbar">
|
||||
<button onClick={HandleCreateFile}>New File</button>
|
||||
<button onClick={HandleCreateDirectory}>New Directory</button>
|
||||
<button onClick={OpenCreateFileModal}>New File</button>
|
||||
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
|
||||
</div>
|
||||
{LoadError && <div className="SidebarError">{LoadError}</div>}
|
||||
<div className="DirectoryList">
|
||||
@@ -122,6 +158,13 @@ export default function DirectoryPane({
|
||||
</span>
|
||||
<span className="TreeLabel">{Entry.Name}</span>
|
||||
<span className="TreeRowActions">
|
||||
<button
|
||||
className="TreeRowActionButton"
|
||||
onClick={(Event) => HandleRequestLockEdit(Event, Entry)}
|
||||
title="Access control"
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
<button
|
||||
className="TreeRowActionButton"
|
||||
onClick={(Event) => HandleRename(Event, Entry)}
|
||||
@@ -143,6 +186,42 @@ export default function DirectoryPane({
|
||||
<div className="DirectoryEmpty">Empty directory.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ModalConfig && ModalConfig.Mode === "createFile" && (
|
||||
<AccessModal
|
||||
Title={`New file in ${DirectoryPath || "root"}`}
|
||||
ShowNameField
|
||||
NamePlaceholder="Notes.md"
|
||||
InitialUsers={[]}
|
||||
InitialGroups={[]}
|
||||
ConfirmLabel="Create"
|
||||
OnConfirm={HandleModalConfirm}
|
||||
OnCancel={() => SetModalConfig(null)}
|
||||
/>
|
||||
)}
|
||||
{ModalConfig && ModalConfig.Mode === "createDirectory" && (
|
||||
<AccessModal
|
||||
Title={`New directory in ${DirectoryPath || "root"}`}
|
||||
ShowNameField
|
||||
NamePlaceholder="MyFolder"
|
||||
InitialUsers={[]}
|
||||
InitialGroups={[]}
|
||||
ConfirmLabel="Create"
|
||||
OnConfirm={HandleModalConfirm}
|
||||
OnCancel={() => SetModalConfig(null)}
|
||||
/>
|
||||
)}
|
||||
{ModalConfig && ModalConfig.Mode === "editLock" && (
|
||||
<AccessModal
|
||||
Title={`Access for "${ModalConfig.EntryName}"`}
|
||||
ShowNameField={false}
|
||||
InitialUsers={ModalConfig.InitialUsers}
|
||||
InitialGroups={ModalConfig.InitialGroups}
|
||||
ConfirmLabel="Save"
|
||||
OnConfirm={HandleModalConfirm}
|
||||
OnCancel={() => SetModalConfig(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
<div className="SettingsOverlay" onClick={OnClose}>
|
||||
<div className="SettingsModal" onClick={(Event) => Event.stopPropagation()}>
|
||||
@@ -245,6 +353,16 @@ export default function SettingsPanel({
|
||||
<Switch Checked={Settings.ShowGitBlame} OnChange={(Event) => UpdateField("ShowGitBlame", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Show avatars instead of names in blame</label>
|
||||
<Switch Checked={Settings.ShowBlameAvatars} OnChange={(Event) => UpdateField("ShowBlameAvatars", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Show my edits in others' blame view</label>
|
||||
<Switch Checked={Settings.VisibleInBlame} OnChange={(Event) => UpdateField("VisibleInBlame", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Sticky edit/view/save toolbar</label>
|
||||
<Switch Checked={Settings.StickyToolbar} OnChange={(Event) => UpdateField("StickyToolbar", Event.target.checked)} />
|
||||
@@ -352,6 +470,42 @@ export default function SettingsPanel({
|
||||
</span>
|
||||
<button onClick={HandleLogout}>Log out</button>
|
||||
</div>
|
||||
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Username</label>
|
||||
<div className="SettingsRowInline">
|
||||
<input
|
||||
type="text"
|
||||
value={UsernameField}
|
||||
onChange={(Event) => SetUsernameField(Event.target.value)}
|
||||
/>
|
||||
<button onClick={HandleChangeUsername}>Save</button>
|
||||
</div>
|
||||
{UsernameError && <div className="SettingsSaveError">{UsernameError}</div>}
|
||||
<div className="SettingsHint">
|
||||
Renaming logs out any other devices/browsers you're signed in on — this one
|
||||
stays signed in under the new name.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Change password</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Current password"
|
||||
value={CurrentPasswordField}
|
||||
onChange={(Event) => SetCurrentPasswordField(Event.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={NewPasswordField}
|
||||
onChange={(Event) => SetNewPasswordField(Event.target.value)}
|
||||
/>
|
||||
<button onClick={HandleChangePassword}>Update password</button>
|
||||
{PasswordChanged && <div className="SettingsHint">Password updated.</div>}
|
||||
{PasswordError && <div className="SettingsSaveError">{PasswordError}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{CurrentUser && CurrentUser.IsAdmin && (
|
||||
@@ -409,6 +563,86 @@ export default function SettingsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{CurrentUser && CurrentUser.IsAdmin && (
|
||||
<div className="SettingsSection">
|
||||
<div className="SettingsSectionTitle">Locked Directories (admin)</div>
|
||||
{Locks && Locks.length === 0 && (
|
||||
<div className="SettingsHint">
|
||||
Nothing locked yet — use the 🔒 button next to any directory to lock it.
|
||||
</div>
|
||||
)}
|
||||
{Locks &&
|
||||
Locks.map((Lock, Index) => (
|
||||
<div className="SettingsRowInline" key={`${Lock.Root}:${Lock.Path}:${Index}`}>
|
||||
<span className="SettingsLabel">
|
||||
{Lock.Root}/{Lock.Path} —{" "}
|
||||
{[...(Lock.Users || []), ...(Lock.Groups || []).map((G) => `@${G}`)].join(
|
||||
", "
|
||||
)}
|
||||
</span>
|
||||
<button onClick={() => HandleUnlock(Lock)}>Unlock</button>
|
||||
</div>
|
||||
))}
|
||||
{LockError && <div className="SettingsSaveError">{LockError}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{CurrentUser && CurrentUser.IsAdmin && (
|
||||
<div className="SettingsSection">
|
||||
<div className="SettingsSectionTitle">Groups (admin)</div>
|
||||
{Groups &&
|
||||
Groups.map((Group) => (
|
||||
<div key={Group.Name} className="GroupRow">
|
||||
<div className="SettingsRowInline">
|
||||
<span className="SettingsLabel">
|
||||
{Group.Name} ({(Group.Members || []).length})
|
||||
</span>
|
||||
<div className="SettingsRowInline">
|
||||
<button
|
||||
onClick={() =>
|
||||
SetExpandedGroup(ExpandedGroup === Group.Name ? null : Group.Name)
|
||||
}
|
||||
>
|
||||
{ExpandedGroup === Group.Name ? "Hide" : "Members"}
|
||||
</button>
|
||||
{Group.Name !== "Admin" && (
|
||||
<button onClick={() => HandleDeleteGroup(Group.Name)}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{ExpandedGroup === Group.Name && (
|
||||
<div className="AccessPickerList GroupMemberList">
|
||||
{(Users || []).map((User) => (
|
||||
<label className="AccessPickerRow" key={User.Username}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(Group.Members || []).includes(User.Username)}
|
||||
onChange={() => HandleToggleGroupMember(Group, User.Username)}
|
||||
/>
|
||||
<span
|
||||
className="ColourSwatchSmall"
|
||||
style={{ background: `var(--${User.Colour || "Purple"})` }}
|
||||
/>
|
||||
<span>{User.Username}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="SettingsRowInline">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New group name"
|
||||
value={NewGroupName}
|
||||
onChange={(Event) => SetNewGroupName(Event.target.value)}
|
||||
/>
|
||||
<button onClick={HandleCreateGroup}>Create group</button>
|
||||
</div>
|
||||
{GroupError && <div className="SettingsSaveError">{GroupError}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{SaveError && <div className="SettingsSaveError">{SaveError}</div>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="TreeNode">
|
||||
<div
|
||||
@@ -118,6 +134,9 @@ function TreeNode({
|
||||
</span>
|
||||
<span className="TreeLabel">{Entry.Name}</span>
|
||||
<span className="TreeRowActions">
|
||||
<button className="TreeRowActionButton" onClick={HandleLockClick} title="Access control">
|
||||
🔒
|
||||
</button>
|
||||
<button className="TreeRowActionButton" onClick={HandleRename} title="Rename / move">
|
||||
✎
|
||||
</button>
|
||||
@@ -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 = (
|
||||
<div className={`SidebarNewEntry${NewEntryButtonsAtTop ? " SidebarNewEntryTop" : ""}`}>
|
||||
<button onClick={HandleCreateFile}>New File</button>
|
||||
<button onClick={HandleCreateDirectory}>New Directory</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="Sidebar">
|
||||
<div className="SidebarHeader">
|
||||
@@ -267,7 +302,12 @@ export default function Sidebar({
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{NewEntryButtonsAtTop && NewEntryButtons}
|
||||
{NewEntryButtonsAtTop && (
|
||||
<div className="SidebarNewEntry SidebarNewEntryTop">
|
||||
<button onClick={OpenCreateFileModal}>New File</button>
|
||||
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
|
||||
</div>
|
||||
)}
|
||||
{LoadError && <div className="SidebarError">{LoadError}</div>}
|
||||
<div className="SidebarTree">
|
||||
{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({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!NewEntryButtonsAtTop && NewEntryButtons}
|
||||
{!NewEntryButtonsAtTop && (
|
||||
<div className="SidebarNewEntry">
|
||||
<button onClick={OpenCreateFileModal}>New File</button>
|
||||
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ModalConfig && ModalConfig.Mode === "createFile" && (
|
||||
<AccessModal
|
||||
Title={`New file in ${CurrentDirectoryPath || "root"}`}
|
||||
ShowNameField
|
||||
NamePlaceholder="Notes.md"
|
||||
InitialUsers={[]}
|
||||
InitialGroups={[]}
|
||||
ConfirmLabel="Create"
|
||||
OnConfirm={HandleModalConfirm}
|
||||
OnCancel={() => SetModalConfig(null)}
|
||||
/>
|
||||
)}
|
||||
{ModalConfig && ModalConfig.Mode === "createDirectory" && (
|
||||
<AccessModal
|
||||
Title={`New directory in ${CurrentDirectoryPath || "root"}`}
|
||||
ShowNameField
|
||||
NamePlaceholder="MyFolder"
|
||||
InitialUsers={[]}
|
||||
InitialGroups={[]}
|
||||
ConfirmLabel="Create"
|
||||
OnConfirm={HandleModalConfirm}
|
||||
OnCancel={() => SetModalConfig(null)}
|
||||
/>
|
||||
)}
|
||||
{ModalConfig && ModalConfig.Mode === "editLock" && (
|
||||
<AccessModal
|
||||
Title={`Access for "${ModalConfig.EntryName}"`}
|
||||
ShowNameField={false}
|
||||
InitialUsers={ModalConfig.InitialUsers}
|
||||
InitialGroups={ModalConfig.InitialGroups}
|
||||
ConfirmLabel="Save"
|
||||
OnConfirm={HandleModalConfirm}
|
||||
OnCancel={() => SetModalConfig(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user