Added groups and restricted access.

This commit is contained in:
sarah committed 2026-09-21 06:52:31 +02:00
1 parent 8aa223bc7b
commit 9a5b9530a3
17 files changed
+1406 -66

No files matched your search

+7 -1
View File
@@ -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"),
}
+27
View File
@@ -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:
+140
View File
@@ -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)
+124
View File
@@ -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
View File
@@ -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:
+9
View File
@@ -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
View File
@@ -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}")
+52
View File
@@ -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() {
+1
View File
@@ -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}
/>
)}
+72
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+100 -21
View File
@@ -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>
);
}
+39 -10
View File
@@ -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"
+234
View File
@@ -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>
+107 -25
View File
@@ -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>
);
}
+84
View File
@@ -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 {
+34
View File
@@ -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.