8 Commits

Author SHA1 Message Date
cfc80d2462 Implement web-based user management / admin setup. (#8)
Implement Admin Settings dialog for user management and toggling the public server flag, not needing CLI for maintenance anymore.
2025-10-01 01:10:33 +01:00
Leo Vasanko
bf604334bd Fix Vue TypeScript module declarations for build 2025-09-30 17:32:14 -06:00
Leo Vasanko
3edf595983 Clean up the remaining uses of print() 2025-08-18 10:49:40 -06:00
Leo Vasanko
6aef2e1208 Use zstd rather than brotli for static file compression. 2025-08-17 16:51:46 -06:00
Leo Vasanko
091d57dba7 Fix typing and import in the config file module. 2025-08-17 10:31:54 -06:00
Leo Vasanko
69a897cfec Startup banner with version display, and --version, using stderr/stdout properly. 2025-08-17 10:31:18 -06:00
Leo Vasanko
33db2c01b4 Cleaner server shutdowns:
- Remove a workaround for Sanic server not always terminating cleanly
- Terminate worker threads before server stop
- Silent closing of watching WebSocket attempted to open while shutting down
2025-08-17 08:15:01 -06:00
Leo Vasanko
26addb2f7b Image previews improved, all EXIF Orientations handled. 2025-08-17 07:00:52 -06:00
17 changed files with 579 additions and 93 deletions

View File

@@ -10,8 +10,24 @@ from cista.util import pwgen
del app, server80.app # Only import needed, for Sanic multiprocessing
doc = f"""Cista {cista.__version__} - A file storage for the web.
def create_banner():
"""Create a framed banner with the Cista version."""
title = f"Cista {cista.__version__}"
subtitle = "A file storage for the web"
width = max(len(title), len(subtitle)) + 4
return f"""\
{"" * width}
{title:^{width}}
{subtitle:^{width}}
{"" * width}
"""
banner = create_banner()
doc = """\
Usage:
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] --user <name> [--privileged] [--password]
@@ -35,6 +51,14 @@ User management:
--password Reset password
"""
first_time_help = """\
No config file found! Get started with:
cista --user yourname --privileged # If you want user accounts
cista -l :8000 /path/to/files # Run the server on localhost:8000
See cista --help for other options!
"""
def main():
# Dev mode doesn't catch exceptions
@@ -44,11 +68,19 @@ def main():
try:
return _main()
except Exception as e:
print("Error:", e)
sys.stderr.write(f"Error: {e}\n")
return 1
def _main():
# The banner printing differs by mode, and needs to be done before docopt() printing its messages
if any(arg in sys.argv for arg in ("--help", "-h")):
sys.stdout.write(banner)
elif "--version" in sys.argv:
sys.stdout.write(f"cista {cista.__version__}\n")
return 0
else:
sys.stderr.write(banner)
args = docopt(doc)
if args["--user"]:
return _user(args)
@@ -62,18 +94,11 @@ def _main():
path = None
_confdir(args)
exists = config.conffile.exists()
print(config.conffile, exists)
import_droppy = args["--import-droppy"]
necessary_opts = exists or import_droppy or path
if not necessary_opts:
# Maybe run without arguments
print(doc)
print(
"No config file found! Get started with one of:\n"
" cista --user yourname --privileged\n"
" cista --import-droppy\n"
" cista -l :8000 /path/to/files\n"
)
sys.stderr.write(first_time_help)
return 1
settings = {}
if import_droppy:
@@ -94,7 +119,7 @@ def _main():
# We have no users, so make it public
settings["public"] = True
operation = config.update_config(settings)
print(f"Config {operation}: {config.conffile}")
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
# Prepare to serve
unix = None
url, _ = serve.parse_listen(config.config.listen)
@@ -104,7 +129,7 @@ def _main():
dev = args["--dev"]
if dev:
extra += " (dev mode)"
print(f"Serving {config.config.path} at {url}{extra}")
sys.stderr.write(f"Serving {config.config.path} at {url}{extra}\n")
# Run the server
serve.run(dev=dev)
return 0
@@ -137,7 +162,7 @@ def _user(args):
"public": False,
}
)
print(f"Config {operation}: {config.conffile}\n")
sys.stderr.write(f"Config {operation}: {config.conffile}\n\n")
name = args["--user"]
if not name or not name.isidentifier():
@@ -155,12 +180,12 @@ def _user(args):
changes["password"] = pw = pwgen.generate()
info += f"\n Password: {pw}\n"
res = config.update_user(name, changes)
print(info)
sys.stderr.write(f"{info}\n")
if res == "read":
print(" No changes")
sys.stderr.write(" No changes\n")
if operation == "created":
print(
sys.stderr.write(
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n"
)

View File

@@ -119,8 +119,12 @@ async def watch(req, ws):
# Send updates
while True:
await ws.send(await q.get())
except RuntimeError as e:
if str(e) == "cannot schedule new futures after shutdown":
return # Server shutting down, drop the WebSocket
raise
finally:
del watching.pubsub[uuid]
watching.pubsub.pop(uuid, None) # Remove whether it got added yet or not
def subscribe(uuid, ws):

View File

@@ -9,7 +9,6 @@ from stat import S_IFDIR, S_IFREG
from urllib.parse import unquote
from wsgiref.handlers import format_date_time
import brotli
import sanic.helpers
from blake3 import blake3
from sanic import Blueprint, Sanic, empty, raw, redirect
@@ -17,6 +16,7 @@ from sanic.exceptions import Forbidden, NotFound
from sanic.log import logger
from setproctitle import setproctitle
from stream_zip import ZIP_AUTO, stream_zip
from zstandard import ZstdCompressor
from cista import auth, config, preview, session, watching
from cista.api import bp
@@ -43,14 +43,16 @@ async def main_start(app, loop):
app.ctx.threadexec = ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="cista-ioworker"
)
await watching.start(app, loop)
watching.start(app, loop)
@app.after_server_stop
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
@app.before_server_stop
async def main_stop(app, loop):
quit.set()
await watching.stop(app, loop)
watching.stop(app)
app.ctx.threadexec.shutdown()
logger.debug("Cista worker threads all finished")
@app.on_request
@@ -93,6 +95,7 @@ def _load_wwwroot(www):
wwwnew = {}
base = Path(__file__).with_name("wwwroot")
paths = [PurePath()]
zstd = ZstdCompressor(level=10)
while paths:
path = paths.pop(0)
current = base / path
@@ -124,11 +127,11 @@ def _load_wwwroot(www):
else "no-cache",
"content-type": mime,
}
# Precompress with Brotli
br = brotli.compress(data)
if len(br) >= len(data):
br = False
wwwnew[name] = data, br, headers
# Precompress with ZSTD
zs = zstd.compress(data)
if len(zs) >= len(data):
zs = False
wwwnew[name] = data, zs, headers
if not wwwnew:
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
if not www:
@@ -180,9 +183,9 @@ async def refresh_wwwroot():
for name in sorted(set(wwwold) - set(www)):
changes += f"Deleted /{name}\n"
if changes:
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
logger.info(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e:
print(f"Error loading wwwroot: {e!r}")
logger.error(f"Error loading wwwroot: {e!r}")
await asyncio.sleep(0.5)
except asyncio.CancelledError:
pass
@@ -194,14 +197,14 @@ async def wwwroot(req, path=""):
name = unquote(path)
if name not in www:
raise NotFound(f"File not found: /{path}", extra={"name": name})
data, br, headers = www[name]
data, zs, headers = www[name]
if req.headers.if_none_match == headers["etag"]:
# The client has it cached, respond 304 Not Modified
return empty(304, headers=headers)
# Brotli compressed?
if br and "br" in req.headers.accept_encoding.split(", "):
headers = {**headers, "content-encoding": "br"}
data = br
# Zstandard compressed?
if zs and "zstd" in req.headers.accept_encoding.split(", "):
headers = {**headers, "content-encoding": "zstd"}
data = zs
return raw(data, headers=headers)

View File

@@ -10,6 +10,7 @@ from sanic import Blueprint, html, json, redirect
from sanic.exceptions import BadRequest, Forbidden, Unauthorized
from cista import config, session
from cista.util import pwgen
_argon = argon2.PasswordHasher()
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
@@ -191,3 +192,91 @@ async def change_password(request):
res = json({"message": "Password updated"})
session.create(res, username)
return res
@bp.get("/users")
async def list_users(request):
verify(request, privileged=True)
users = []
for name, user in config.config.users.items():
users.append(
{
"username": name,
"privileged": user.privileged,
"lastSeen": user.lastSeen,
}
)
return json({"users": users})
@bp.post("/users")
async def create_user(request):
verify(request, privileged=True)
try:
if request.headers.content_type == "application/json":
username = request.json["username"]
password = request.json.get("password")
privileged = request.json.get("privileged", False)
else:
username = request.form["username"][0]
password = request.form.get("password", [None])[0]
privileged = request.form.get("privileged", ["false"])[0].lower() == "true"
if not username or not username.isidentifier():
raise ValueError("Invalid username")
except (KeyError, ValueError) as e:
raise BadRequest(str(e)) from e
if username in config.config.users:
raise BadRequest("User already exists")
if not password:
password = pwgen.generate()
changes = {"privileged": privileged}
changes["hash"] = _argon.hash(_pwnorm(password))
try:
config.update_user(username, changes)
except Exception as e:
raise BadRequest(str(e)) from e
return json({"message": f"User {username} created", "password": password})
@bp.put("/users/<username>")
async def update_user(request, username):
verify(request, privileged=True)
try:
if request.headers.content_type == "application/json":
changes = request.json
else:
changes = {}
if "password" in request.form:
changes["password"] = request.form["password"][0]
if "privileged" in request.form:
changes["privileged"] = request.form["privileged"][0].lower() == "true"
except KeyError as e:
raise BadRequest("Missing fields") from e
password_response = None
if "password" in changes:
if changes["password"] == "":
changes["password"] = pwgen.generate()
password_response = changes["password"]
changes["hash"] = _argon.hash(_pwnorm(changes["password"]))
del changes["password"]
if not changes:
return json({"message": "No changes"})
try:
config.update_user(username, changes)
except Exception as e:
raise BadRequest(str(e)) from e
response = {"message": f"User {username} updated"}
if password_response:
response["password"] = password_response
return json(response)
@bp.put("/config/public")
async def update_public(request):
verify(request, privileged=True)
try:
public = request.json["public"]
except KeyError:
raise BadRequest("Missing public field") from None
config.update_config({"public": public})
return json({"message": "Public setting updated"})

View File

@@ -7,9 +7,11 @@ from contextlib import suppress
from functools import wraps
from hashlib import sha256
from pathlib import Path, PurePath
from time import time
from time import sleep, time
from typing import Callable, Concatenate, Literal, ParamSpec
import msgspec
import msgspec.toml
class Config(msgspec.Struct):
@@ -22,6 +24,13 @@ class Config(msgspec.Struct):
links: dict[str, Link] = {}
# Typing: arguments for config-modifying functions
P = ParamSpec("P")
ResultStr = Literal["modified", "created", "read"]
RawModifyFunc = Callable[Concatenate[Config, P], Config]
ModifyPublic = Callable[P, ResultStr]
class User(msgspec.Struct, omit_defaults=True):
privileged: bool = False
hash: str = ""
@@ -34,11 +43,13 @@ class Link(msgspec.Struct, omit_defaults=True):
expires: int = 0
config = None
conffile = None
# Global variables - initialized during application startup
config: Config
conffile: Path
def init_confdir():
def init_confdir() -> None:
global conffile
if p := os.environ.get("CISTA_HOME"):
home = Path(p)
else:
@@ -49,8 +60,6 @@ def init_confdir():
if not home.is_dir():
home.mkdir(parents=True, exist_ok=True)
home.chmod(0o700)
global conffile
conffile = home / "db.toml"
@@ -77,10 +86,10 @@ def dec_hook(typ, obj):
raise TypeError
def config_update(modify):
def config_update(
modify: RawModifyFunc,
) -> ResultStr | Literal["collision"]:
global config
if conffile is None:
init_confdir()
tmpname = conffile.with_suffix(".tmp")
try:
f = tmpname.open("xb")
@@ -95,7 +104,7 @@ def config_update(modify):
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
except FileNotFoundError:
old = b""
c = None
c = Config(path=Path(), listen="", secret=secrets.token_hex(12))
c = modify(c)
new = msgspec.toml.encode(c, enc_hook=enc_hook)
if old == new:
@@ -118,17 +127,23 @@ def config_update(modify):
return "modified" if old else "created"
def modifies_config(modify):
"""Decorator for functions that modify the config file"""
def modifies_config(
modify: Callable[Concatenate[Config, P], Config],
) -> Callable[P, ResultStr]:
"""Decorator for functions that modify the config file
The decorated function takes as first arg Config and returns it modified.
The wrapper handles atomic modification and returns a string indicating the result.
"""
@wraps(modify)
def wrapper(*args, **kwargs):
def m(c):
def wrapper(*args: P.args, **kwargs: P.kwargs) -> ResultStr:
def m(c: Config) -> Config:
return modify(c, *args, **kwargs)
# Retry modification in case of write collision
while (c := config_update(m)) == "collision":
time.sleep(0.01)
sleep(0.01)
return c
return wrapper
@@ -136,8 +151,7 @@ def modifies_config(modify):
def load_config():
global config
if conffile is None:
init_confdir()
init_confdir()
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
@@ -145,7 +159,7 @@ def load_config():
def update_config(conf: Config, changes: dict) -> Config:
"""Create/update the config with new values, respecting changes done by others."""
# Encode into dict, update values with new, convert to Config
settings = {} if conf is None else msgspec.to_builtins(conf, enc_hook=enc_hook)
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings.update(changes)
return msgspec.convert(settings, Config, dec_hook=dec_hook)
@@ -155,8 +169,13 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
"""Create/update a user with new values, respecting changes done by others."""
# Encode into dict, update values with new, convert to Config
try:
u = conf.users[name].__copy__()
except (KeyError, AttributeError):
# Copy user by converting to dict and back
u = msgspec.convert(
msgspec.to_builtins(conf.users[name], enc_hook=enc_hook),
User,
dec_hook=dec_hook,
)
except KeyError:
u = User()
if "password" in changes:
from . import auth
@@ -165,7 +184,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
del changes["password"]
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
udict.update(changes)
settings = msgspec.to_builtins(conf, enc_hook=enc_hook) if conf else {"users": {}}
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
return msgspec.convert(settings, Config, dec_hook=dec_hook)
@@ -173,6 +192,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
@modifies_config
def del_user(conf: Config, name: str) -> Config:
"""Delete named user account."""
ret = conf.__copy__()
ret.users.pop(name)
return ret
# Create a copy by converting to dict and back
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings["users"].pop(name)
return msgspec.convert(settings, Config, dec_hook=dec_hook)

View File

@@ -24,6 +24,17 @@ pillow_heif.register_heif_opener()
bp = Blueprint("preview", url_prefix="/preview")
# Map EXIF Orientation value to a corresponding PIL transpose
EXIF_ORI = {
2: Image.Transpose.FLIP_LEFT_RIGHT,
3: Image.Transpose.ROTATE_180,
4: Image.Transpose.FLIP_TOP_BOTTOM,
5: Image.Transpose.TRANSPOSE,
6: Image.Transpose.ROTATE_270,
7: Image.Transpose.TRANSVERSE,
8: Image.Transpose.ROTATE_90,
}
@bp.get("/<path:path>")
async def preview(req, path):
@@ -69,34 +80,35 @@ def dispatch(path, quality, maxsize, maxzoom):
def process_image(path, *, maxsize, quality):
t_load_start = perf_counter()
img = Image.open(path)
# Force decode to include I/O in load timing
img.load()
t_load_end = perf_counter()
# Resize
orig_w, orig_h = img.size
t_proc_start = perf_counter()
img.thumbnail((min(orig_w, maxsize), min(orig_h, maxsize)))
t_proc_end = perf_counter()
# Save as AVIF
imgdata = io.BytesIO()
t_save_start = perf_counter()
img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1)
t_save_end = perf_counter()
t_load = perf_counter()
with Image.open(path) as img:
# Force decode to include I/O in load timing
img.load()
t_proc = perf_counter()
# Resize
w, h = img.size
img.thumbnail((min(w, maxsize), min(h, maxsize)))
# Transpose pixels according to EXIF Orientation
orientation = img.getexif().get(274, 1)
if orientation in EXIF_ORI:
img = img.transpose(EXIF_ORI[orientation])
# Save as AVIF
imgdata = io.BytesIO()
t_save = perf_counter()
img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1)
t_end = perf_counter()
ret = imgdata.getvalue()
load_ms = (t_load_end - t_load_start) * 1000
proc_ms = (t_proc_end - t_proc_start) * 1000
save_ms = (t_save_end - t_save_start) * 1000
load_ms = (t_proc - t_load) * 1000
proc_ms = (t_save - t_proc) * 1000
save_ms = (t_end - t_save) * 1000
logger.debug(
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms out=%.1fKB",
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms",
path.name,
load_ms,
proc_ms,
save_ms,
len(ret) / 1024,
)
return ret

View File

@@ -1,6 +1,5 @@
import os
import re
import signal
from pathlib import Path
from sanic import Sanic
@@ -12,14 +11,6 @@ def run(*, dev=False):
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
from .app import app
# Set up immediate exit on Ctrl+C for faster termination
def signal_handler(signum, frame):
print("\nReceived interrupt signal, exiting immediately...")
os._exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
url, opts = parse_listen(config.config.listen)
# Silence Sanic's warning about running in production rather than debug
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"

View File

@@ -1,4 +1,5 @@
from time import monotonic
from typing import Callable
class LRUCache:
@@ -12,7 +13,7 @@ class LRUCache:
cache (list): Internal list storing the cache items.
"""
def __init__(self, open: callable, *, capacity: int, maxage: float):
def __init__(self, open: Callable, *, capacity: int, maxage: float):
"""
Initialize LRUCache.
@@ -50,7 +51,6 @@ class LRUCache:
# Add/restore to end of cache
self.cache.insert(0, (key, f, monotonic()))
self.expire_items()
print(self.cache)
return f
def expire_items(self):

View File

@@ -440,7 +440,7 @@ def watcher_poll(loop):
quit.wait(0.1 + 8 * dur)
async def start(app, loop):
def start(app, loop):
global rootpath
config.load_config()
rootpath = config.config.path
@@ -454,6 +454,6 @@ async def start(app, loop):
app.ctx.watcher.start()
async def stop(app, loop):
def stop(app):
quit.set()
app.ctx.watcher.join()

6
frontend/env.d.ts vendored
View File

@@ -1 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -1,6 +1,7 @@
<template>
<LoginModal />
<SettingsModal />
<UserManagementModal />
<header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
<HeaderSelected :path="path.pathList" />
@@ -28,6 +29,7 @@ import { computed } from 'vue'
import Router from '@/router/index'
import type { SortOrder } from './utils/docsort'
import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue'
interface Path {
path: string

View File

@@ -73,7 +73,10 @@ watchEffect(() => {
const settingsMenu = (e: Event) => {
// show the context menu
const items = []
items.push({ label: 'Settings', onClick: () => { store.dialog = 'settings' }})
items.push({ label: 'Change Password', onClick: () => { store.dialog = 'settings' }})
if (store.user.privileged) {
items.push({ label: 'Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
}
if (store.user.isLoggedIn) {
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
} else {

View File

@@ -0,0 +1,250 @@
<template>
<ModalDialog name=usermgmt title="Admin Settings">
<div v-if="loading" class="loading">Loading...</div>
<div v-else>
<h3>Server Settings</h3>
<div class="form-row">
<input
id="publicServer"
type="checkbox"
v-model="serverSettings.public"
@change="updateServerSettings"
/>
<label for="publicServer">Publicly accessible without any user account.</label>
</div>
<h3>Users</h3>
<button @click="addUser" class="button" title="Add new user"> Add User</button>
<div v-if="success" class="success-message">
{{ success }}
<button @click="copySuccess" class="button small" title="Copy to clipboard"><EFBFBD></button>
</div>
<table class="user-table">
<thead>
<tr>
<th>Username</th>
<th>Admin</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.username">
<td>{{ user.username }}</td>
<td>
<input
type="checkbox"
:checked="user.privileged"
@change="toggleAdmin(user, $event)"
:disabled="user.username === store.user.username"
/>
</td>
<td>
<button @click="renameUser(user)" class="button small" title="Rename user"></button>
<button @click="resetPassword(user)" class="button small" title="Reset password">🔑</button>
<button @click="deleteUserAction(user.username)" class="button small danger" :disabled="user.username === store.user.username" title="Delete user">🗑</button>
</td>
</tr>
</tbody>
</table>
<h3 class="error-text">{{ error || '\u00A0' }}</h3>
<div class="dialog-buttons">
<button @click="close" class="button">Close</button>
</div>
</div>
</ModalDialog>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
interface User {
username: string
privileged: boolean
lastSeen: number
}
const store = useMainStore()
const loading = ref(true)
const users = ref<User[]>([])
const error = ref('')
const success = ref('')
const serverSettings = reactive({
public: false
})
const close = () => {
store.dialog = ''
error.value = ''
success.value = ''
}
const loadUsers = async () => {
try {
loading.value = true
const data = await listUsers()
users.value = data.users
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to load users'
} finally {
loading.value = false
}
}
const addUser = async () => {
const username = window.prompt('Enter username for new user:')
if (!username || !username.trim()) return
try {
error.value = ''
success.value = ''
const result = await createUser(username.trim(), undefined, false)
await loadUsers()
if (result.password) {
success.value = `User ${username.trim()} created. Password: ${result.password}`
}
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to add user'
}
}
const toggleAdmin = async (user: User, event: Event) => {
const target = event.target as HTMLInputElement
try {
error.value = ''
await updateUser(user.username, { privileged: target.checked })
user.privileged = target.checked
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to update user'
target.checked = user.privileged // revert
}
}
const renameUser = async (user: User) => {
const newName = window.prompt('Enter new username:', user.username)
if (!newName || !newName.trim() || newName.trim() === user.username) return
// For rename, we need to create new user and delete old, or have a rename endpoint
// Since no rename endpoint, perhaps delete and create
try {
error.value = ''
success.value = ''
const result = await createUser(newName.trim(), undefined, user.privileged)
await deleteUser(user.username)
await loadUsers()
if (result.password) {
success.value = `User renamed to ${newName.trim()}. New password: ${result.password}`
}
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to rename user'
}
}
const resetPassword = async (user: User) => {
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
try {
error.value = ''
success.value = ''
const result = await updateUser(user.username, { password: "" })
if (result.password) {
success.value = `Password reset for ${user.username}. New password: ${result.password}`
}
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to reset password'
}
}
const deleteUserAction = async (username: string) => {
if (!confirm(`Delete user ${username}?`)) return
try {
error.value = ''
await deleteUser(username)
await loadUsers()
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to delete user'
}
}
const copySuccess = async () => {
const passwordMatch = success.value.match(/Password: (.+)/)
if (passwordMatch) {
await navigator.clipboard.writeText(passwordMatch[1])
// Maybe flash or something, but for now just copy
}
}
const updateServerSettings = async () => {
try {
error.value = ''
success.value = ''
await updatePublic(serverSettings.public)
// Update store
store.server.public = serverSettings.public
success.value = 'Server settings updated'
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to update settings'
}
}
onMounted(() => {
serverSettings.public = store.server.public
loadUsers()
})
watch(() => store.server.public, (newVal) => {
serverSettings.public = newVal
})
</script>
<style scoped>
.user-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.user-table th, .user-table td {
border: 1px solid var(--border-color);
padding: 0.5rem;
text-align: left;
}
.user-table th {
background: var(--soft-color);
}
.button.small {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
margin-right: 0.25rem;
}
.button.danger {
background: var(--red-color);
color: white;
}
.button.danger:hover {
background: #d00;
}
.form-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.form-row label {
min-width: 100px;
}
.success-message {
background: var(--accent-color);
color: white;
padding: 0.5rem;
border-radius: 0.25rem;
margin-top: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>

View File

@@ -1,4 +1,20 @@
class ClientClass {
async get(url: string): Promise<any> {
const res = await fetch(url, {
method: 'GET',
headers: {
accept: 'application/json'
}
})
let msg
try {
msg = await res.json()
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
async post(url: string, data?: Record<string, any>): Promise<any> {
const res = await fetch(url, {
method: 'POST',
@@ -17,6 +33,40 @@ class ClientClass {
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
async put(url: string, data?: Record<string, any>): Promise<any> {
const res = await fetch(url, {
method: 'PUT',
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
body: data !== undefined ? JSON.stringify(data) : undefined
})
let msg
try {
msg = await res.json()
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
async delete(url: string): Promise<any> {
const res = await fetch(url, {
method: 'DELETE',
headers: {
accept: 'application/json'
}
})
let msg
try {
msg = await res.json()
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
}
export const Client = new ClientClass()

View File

@@ -24,3 +24,34 @@ export async function changePassword(username: string, passwordChange: string, p
})
return data
}
export const url_users = '/users'
export async function listUsers() {
const data = await Client.get(url_users)
return data
}
export async function createUser(username: string, password?: string, privileged?: boolean) {
const data = await Client.post(url_users, {
username,
password,
privileged
})
return data
}
export async function updateUser(username: string, changes: { password?: string, privileged?: boolean }) {
const data = await Client.put(`${url_users}/${username}`, changes)
return data
}
export async function deleteUser(username: string) {
const data = await Client.delete(`${url_users}/${username}`)
return data
}
export async function updatePublic(publicFlag: boolean) {
const data = await Client.put('/config/public', { public: publicFlag })
return data
}

View File

@@ -18,7 +18,7 @@ export const useMainStore = defineStore({
connected: false,
cursor: '' as string,
server: {} as Record<string, any>,
dialog: '' as '' | 'login' | 'settings',
dialog: '' as '' | 'login' | 'settings' | 'usermgmt',
uprogress: {} as any,
dprogress: {} as any,
prefs: {

View File

@@ -27,7 +27,6 @@ dependencies = [
"argon2-cffi>=25.1.0",
"av>=15.0.0",
"blake3>=1.0.5",
"brotli>=1.1.0",
"docopt>=0.6.2",
"inotify>=0.2.12",
"msgspec>=0.19.0",
@@ -42,6 +41,7 @@ dependencies = [
"setproctitle>=1.3.6",
"stream-zip>=0.0.83",
"tomli_w>=1.2.0",
"zstandard>=0.24.0",
]
[project.urls]