Finalize Paskia integration and built-in authentication.

This commit is contained in:
2026-01-31 00:47:04 +00:00
parent be69164c8f
commit 232fd92b22
19 changed files with 668 additions and 449 deletions
+8 -23
View File
@@ -29,7 +29,7 @@ banner = create_banner()
doc = """\
Usage:
cista [-c <confdir>] [-l <host>] [--auth <mode>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] --user <name> [--privileged] [--password]
Options:
@@ -39,20 +39,20 @@ Options:
<addr>:3000 (bind another address, port)
/path/to/unix.sock (unix socket)
example.com (run on 80 and 443 with LetsEncrypt)
--auth MODE Authentication mode: none, password, paskia
none - public access, no login required
password - built-in user accounts (default)
paskia - external SSO via PASKIA_BACKEND_URL
--import-droppy Import Droppy config from ~/.droppy/config
--dev Developer mode (reloads, friendlier crashes, more logs)
Listen address, path, auth mode and imported options are preserved in config,
Listen address and path are preserved in config,
and only config dir and dev mode need to be specified on subsequent runs.
User management:
--user NAME Create or modify user
--privileged Give the user full admin rights
--password Reset password
Environment:
PASKIA_BACKEND_URL Paskia single sign-on (e.g. http://localhost:4401)
https://git.zi.fi/leovasanko/paskia
"""
first_time_help = """\
@@ -111,11 +111,7 @@ def _main():
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}",
)
settings = droppy.readconf()
# Convert Droppy's public flag to authentication mode
if "public" in settings:
settings["authentication"] = (
"none" if settings.pop("public") else "password"
)
# Droppy's public flag is kept as-is (same name in our config)
if path:
settings["path"] = path
elif not exists:
@@ -124,17 +120,6 @@ def _main():
settings["listen"] = listen
elif not exists:
settings["listen"] = ":8000"
# Authentication mode
auth_mode = args["--auth"]
if auth_mode:
if auth_mode not in ("none", "password", "paskia"):
raise ValueError(
f"Invalid auth mode: {auth_mode}. Use: none, password, paskia"
)
settings["authentication"] = auth_mode
elif not exists and not import_droppy:
# We have no users, so make it public
settings["authentication"] = "none"
operation = config.update_config(settings)
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
# Prepare to serve
@@ -176,7 +161,7 @@ def _user(args):
{
"listen": ":8000",
"path": Path.home() / "Downloads",
"authentication": "password",
"public": False,
}
)
sys.stderr.write(f"Config {operation}: {config.conffile}\n\n")
+29 -16
View File
@@ -6,7 +6,7 @@ import msgspec
from sanic import Blueprint, json
from sanic.exceptions import BadRequest
from cista import __version__, auth, config, watching
from cista import __version__, auth, config, sso, watching
from cista.fileio import FileServer
from cista.protocol import ControlTypes, FileRange, StatusMsg
from cista.util.apphelpers import asend, websocket_wrapper
@@ -93,20 +93,33 @@ async def control(req, ws):
@bp.websocket("watch")
@websocket_wrapper
async def watch(req, ws):
# Build user info from either built-in auth or SSO
user_info = None
if sso_user := getattr(req.ctx, "sso_user", None):
# SSO auth (paskia mode): extract from validation response
ctx = sso_user.get("ctx", {})
perms = ctx.get("permissions", [])
user_info = {
"username": ctx.get("user", {}).get("display_name", ""),
"privileged": "cista:admin" in perms,
}
elif req.ctx.user:
# Built-in auth: use local user database
user_info = {
"username": req.ctx.username,
"privileged": req.ctx.user.privileged,
}
await ws.send(
msgspec.json.encode(
{
"server": {
"name": config.config.name or config.config.path.name,
"version": __version__,
"authentication": config.config.authentication,
"public": config.config.public,
"paskia": sso.paskia_enabled(),
},
"user": {
"username": req.ctx.username,
"privileged": req.ctx.user.privileged,
}
if req.ctx.user
else None,
"user": user_info,
}
).decode()
)
@@ -139,16 +152,16 @@ def subscribe(uuid, ws):
)
@bp.put("config/authentication")
async def update_authentication(request):
@bp.put("config/public")
async def update_public(request):
await auth.verify(request, privileged=True)
try:
mode = request.json["authentication"]
if mode not in ("none", "paskia", "password"):
raise ValueError("Invalid authentication mode")
public = request.json["public"]
if not isinstance(public, bool):
raise ValueError("public must be a boolean")
except KeyError:
raise BadRequest("Missing authentication field") from None
raise BadRequest("Missing public field") from None
except ValueError as e:
raise BadRequest(str(e)) from None
config.update_config({"authentication": mode})
return json({"message": "Authentication setting updated", "authentication": mode})
config.update_config({"public": public})
return json({"message": "Public access setting updated", "public": public})
+14 -6
View File
@@ -26,8 +26,11 @@ from cista.util.apphelpers import handle_sanic_exception
sanic.helpers._ENTITY_HEADERS = frozenset()
app = Sanic("cista", strict_slashes=True)
app.blueprint(auth.bp)
app.blueprint(sso.bp) # SSO proxy for /auth/* routes (when paskia mode enabled)
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL
if sso.paskia_enabled():
app.blueprint(sso.bp) # SSO proxy for /auth/* routes
else:
app.blueprint(auth.bp) # Built-in auth routes
app.blueprint(preview.bp)
app.blueprint(bp)
app.exception(Exception)(handle_sanic_exception)
@@ -76,6 +79,14 @@ async def use_session(req):
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
@app.on_response
async def forward_sso_cookies(req, res):
"""Forward Set-Cookie headers from SSO validation to client."""
if cookies := getattr(req.ctx, "sso_cookies", None):
for cookie in cookies:
res.headers.add("set-cookie", cookie)
@app.before_server_start
def http_fileserver(app):
bp = Blueprint("fileserver")
@@ -83,10 +94,7 @@ def http_fileserver(app):
@bp.on_request
async def verify_fileserver(request):
"""Verify access to file server routes."""
if config.config.authentication == "paskia":
await auth.verify_sso(request)
else:
await auth.verify(request)
await auth.verify(request)
bp.static(
"/files/",
+17 -41
View File
@@ -139,7 +139,7 @@ form.onsubmit = async (e) => {
submitBtn.textContent = 'Logging in...';
try {
const res = await fetch('/login', {
const res = await fetch('/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -234,9 +234,9 @@ class LoginResponse(msgspec.Struct):
async def verify(request, *, privileged=False):
"""Verify that the request is authorized.
For paskia mode, validates against the SSO backend.
For password mode, checks session-based authentication.
For none mode, allows all requests.
For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend.
For built-in mode, checks session-based authentication.
For public mode (config.public=True), allows all requests.
All 401/403 responses include auth.iframe URL for consistent frontend handling
via the paskia library's showAuthIframe().
@@ -249,10 +249,11 @@ async def verify(request, *, privileged=False):
Unauthorized: If authentication is required
Forbidden: If access is denied
"""
if config.config.authentication == "paskia":
sso = _get_sso()
if sso.paskia_enabled():
# SSO validation against auth backend
sso = _get_sso()
perm = "cista:login cista:admin" if privileged else "cista:login"
# Always check cista:login; privileged flag comes from response perm list
perm = "cista:admin" if privileged else "cista:login"
await sso.validate_sso_request(request, perm=perm)
return
@@ -263,64 +264,39 @@ async def verify(request, *, privileged=False):
return
raise Forbidden(
"Access Forbidden: Only for privileged users",
context={"auth": {"iframe": "/auth/api/restricted?mode=forbidden"}},
quiet=True,
)
elif config.config.authentication == "none" or user:
elif config.config.public or user:
return
# Return iframe URL for paskia library to show login dialog
raise Unauthorized(
f"Login required for {request.path}",
"cookie",
context={"auth": {"iframe": "/auth/api/restricted?mode=login"}},
context={"auth": {"iframe": "/auth/restricted"}},
quiet=True,
)
# Blueprint for built-in auth (only registered when paskia is NOT enabled)
bp = Blueprint("auth", url_prefix="/auth")
@bp.on_request
async def check_external_auth(request):
"""Disable built-in auth routes when external auth is enabled"""
if config.config.authentication == "paskia":
from sanic.exceptions import NotFound
raise NotFound("Not available in external auth mode")
@bp.get("/api/restricted")
@bp.get("/restricted")
async def login_page(request):
"""Login page that works both standalone and in paskia iframe.
Query params:
- mode: 'login' (default), 'reauth', or 'forbidden' - affects messaging
"""
mode = request.args.get("mode", "login")
"""Login page that works both standalone and in paskia iframe."""
s = session.get(request)
# Check if already logged in
if s and mode == "login":
if s:
# Already authenticated - signal success if in iframe
return html(_login_success_page(s["username"]))
title = {
"forbidden": "Access Denied",
"reauth": "Re-authenticate",
}.get(mode, "Login Required")
message = {
"forbidden": "You don't have permission. Try logging in with a different account.",
"reauth": "Your session has expired. Please log in again.",
}.get(mode, "Please log in to continue.")
doc = Document(f"Cista - {title}")
doc = Document("Cista - Login")
# Add paskia-compatible styling and scripts
doc.style(_LOGIN_PAGE_CSS)
with doc.div(class_="login-card"):
doc.h1(title)
doc.h1("Authentication Required")
with doc.div(class_="content"):
doc.p(message, class_="message")
with doc.form(method="POST", id="loginForm", autocomplete="on"):
doc.label("Username:", for_="username")
doc.input(
@@ -388,7 +364,7 @@ async def login_post(request):
return res
@bp.post("/logout")
@bp.post("/api/logout")
async def logout_post(request):
s = request.ctx.session
msg = "Logged out" if s else "Not logged in"
+6 -9
View File
@@ -13,15 +13,12 @@ from typing import Callable, Concatenate, Literal, ParamSpec
import msgspec
import msgspec.toml
# Authentication modes
AuthMode = Literal["none", "paskia", "password"]
class Config(msgspec.Struct):
path: Path
listen: str
secret: str = secrets.token_hex(12)
authentication: AuthMode = "password"
public: bool = False
name: str = ""
users: dict[str, User] = {}
links: dict[str, Link] = {}
@@ -157,12 +154,12 @@ def load_config():
init_confdir()
raw = conffile.read_bytes()
config = msgspec.toml.decode(raw, type=Config, dec_hook=dec_hook)
# Migrate from old public flag if present
# Migrate from old authentication field if present
raw_dict = msgspec.toml.decode(raw)
if "public" in raw_dict and "authentication" not in raw_dict:
# Old config: migrate public flag to authentication mode
new_auth = "none" if raw_dict["public"] else "password"
config = msgspec.structs.replace(config, authentication=new_auth)
if "authentication" in raw_dict and "public" not in raw_dict:
# Old config with authentication mode: migrate to public bool
new_public = raw_dict["authentication"] == "none"
config = msgspec.structs.replace(config, public=new_public)
update_config({}) # Save the migrated config
+1 -1
View File
@@ -60,7 +60,7 @@ async def preview(req, path):
"etag": etag,
"last-modified": format_date_time(stat.st_mtime),
"cache-control": "max-age=604800, immutable"
+ ("" if config.config.authentication == "none" else ", private"),
+ ("" if config.config.public else ", private"),
"content-type": "image/avif",
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
}
+165 -68
View File
@@ -1,26 +1,43 @@
"""SSO (paskia) authentication proxy and validation module.
When paskia authentication mode is enabled:
When paskia mode is enabled (PASKIA_BACKEND_URL is set):
- Backend validates requests against PASKIA_BACKEND_URL/auth/api/validate?perm=cista:login
- All /auth/* requests are proxied to the paskia backend
Environment variables:
PASKIA_BACKEND_URL - URL of the paskia auth server (default: http://localhost:4401)
PASKIA_BACKEND_URL - URL of the paskia auth server (e.g., http://localhost:4401)
Must include scheme (http/https), no trailing slash
"""
import asyncio
import os
import re
import httpx
import websockets
from sanic import Blueprint
from sanic.exceptions import Forbidden, Unauthorized
from sanic.exceptions import Forbidden, SanicException, Unauthorized
from sanic.log import logger
from cista import config
# Auth backend URL for SSO validation (from env, no trailing slash)
_raw_url = os.environ.get("PASKIA_BACKEND_URL", "").rstrip("/")
# Validate and set PASKIA_BACKEND_URL
if _raw_url:
if not re.match(r"^https?://[^\s/]+$", _raw_url):
raise ValueError(
f"Invalid PASKIA_BACKEND_URL: {_raw_url!r} - "
"must be http(s)://host[:port] with no path or trailing slash"
)
PASKIA_BACKEND_URL = _raw_url
else:
PASKIA_BACKEND_URL = ""
def paskia_enabled() -> bool:
"""Check if paskia SSO mode is enabled (PASKIA_BACKEND_URL is set)."""
return bool(PASKIA_BACKEND_URL)
# Auth backend URL for SSO validation (from env with default, no trailing slash)
PASKIA_BACKEND_URL = os.environ.get(
"PASKIA_BACKEND_URL", "http://localhost:4401"
).rstrip("/")
# Shared httpx client for SSO requests (reused for connection pooling)
_client: httpx.AsyncClient | None = None
@@ -30,7 +47,7 @@ async def get_client() -> httpx.AsyncClient:
"""Get or create the shared httpx client."""
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(timeout=10.0)
_client = httpx.AsyncClient(timeout=1.0)
return _client
@@ -56,43 +73,50 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
Forbidden: If access is denied (403)
Unauthorized: If authentication is required (401)
"""
if config.config.authentication != "paskia":
if not paskia_enabled():
return None
client = await get_client()
# Forward relevant headers (especially cookies for session validation)
headers = {}
if "host" in request.headers:
headers["host"] = request.headers["host"]
if "cookie" in request.headers:
headers["cookie"] = request.headers["cookie"]
if "authorization" in request.headers:
headers["authorization"] = request.headers["authorization"]
headers["accept"] = "application/json"
headers["x-forwarded-for"] = request.ip
if "x-forwarded-for" in request.headers:
headers["x-forwarded-for"] = request.headers["x-forwarded-for"]
headers["x-forwarded-for"] = request.client_ip
headers["x-forwarded-host"] = request.host
headers["x-forwarded-proto"] = request.scheme
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
try:
response = await client.post(
f"{PASKIA_BACKEND_URL}/auth/api/validate",
params={"perm": perm},
url,
headers=headers,
)
if response.status_code == 200:
# Validation successful
try:
return response.json()
data = response.json()
request.ctx.sso_user = data
if "set-cookie" in response.headers:
request.ctx.sso_cookies = response.headers.get_list("set-cookie")
return data
except Exception:
request.ctx.sso_user = {}
return {}
# Handle auth errors - return the JSON response for frontend handling
try:
error_data = response.json()
except Exception:
error_data = {"detail": response.text or "Authentication error"}
if response.status_code == 401:
if "auth" in error_data and "iframe" in error_data["auth"]:
error_data["auth"]["iframe"] += "&theme=light"
raise Unauthorized(
error_data.get("detail", "Authentication required"),
"cookie",
@@ -106,19 +130,21 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
quiet=True,
)
else:
detail = error_data.get("detail", "")
logger.warning(
f"SSO validation returned unexpected status: {response.status_code}"
f"SSO validation {url} returned {response.status_code}: {detail}"
)
raise Forbidden(
error_data.get("detail", "Authentication error"),
detail or "Authentication error",
context=error_data,
quiet=True,
)
except httpx.RequestError as e:
logger.error(f"SSO validation request failed: {e}")
raise Forbidden(
logger.error(f"SSO validation {url} network error: {e}")
raise SanicException(
"Authentication service unavailable",
status_code=502,
quiet=True,
)
@@ -130,18 +156,13 @@ async def proxy_auth_request(request):
"""
client = await get_client()
# Build the target URL - strip any prefix and forward to auth backend
path = request.path
query_string = request.query_string
url = f"{PASKIA_BACKEND_URL}{path}"
if query_string:
url = f"{url}?{query_string}"
# Forward headers
headers = dict(request.headers)
# Remove hop-by-hop headers
for hop_header in [
"host",
skip_headers = {
"connection",
"keep-alive",
"transfer-encoding",
@@ -150,45 +171,53 @@ async def proxy_auth_request(request):
"upgrade",
"proxy-authorization",
"proxy-authenticate",
]:
headers.pop(hop_header, None)
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
}
# Add forwarded headers
headers["x-forwarded-for"] = request.ip
headers["x-forwarded-host"] = request.host
headers["x-forwarded-proto"] = request.scheme
headers = [
(key, value)
for key, value in request.headers.items()
if key.lower() not in skip_headers
]
headers.append(("x-forwarded-for", request.client_ip))
headers.append(("x-forwarded-host", request.host))
headers.append(("x-forwarded-proto", request.scheme))
try:
response = await client.request(
async with client.stream(
method=request.method,
url=url,
headers=headers,
content=request.body if request.body else None,
)
) as response:
raw_content = b"".join([chunk async for chunk in response.aiter_raw()])
# Build response headers
resp_headers = dict(response.headers)
# Remove hop-by-hop headers from response
for hop_header in [
"connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"content-encoding",
"content-length",
]:
resp_headers.pop(hop_header, None)
resp_hop_by_hop = {
"connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
}
from sanic import raw as raw_response
resp_headers = [
(key, value)
for key, value in response.headers.multi_items()
if key.lower() not in resp_hop_by_hop
]
return raw_response(
response.content,
status=response.status_code,
headers=resp_headers,
content_type=response.headers.get("content-type", "application/json"),
)
from sanic import raw as raw_response
return raw_response(
raw_content,
status=response.status_code,
headers=resp_headers,
content_type=response.headers.get("content-type", "application/json"),
)
except httpx.RequestError as e:
logger.error(f"Auth proxy request failed: {e}")
@@ -200,28 +229,96 @@ async def proxy_auth_request(request):
)
# Blueprint for auth proxy routes
async def proxy_auth_websocket(request, ws):
"""Proxy a WebSocket connection to the auth backend."""
path = request.path
query_string = request.query_string
ws_backend = PASKIA_BACKEND_URL.replace("http://", "ws://").replace(
"https://", "wss://"
)
url = f"{ws_backend}{path}"
if query_string:
url = f"{url}?{query_string}"
additional_headers = {}
if "cookie" in request.headers:
additional_headers["cookie"] = request.headers["cookie"]
if "authorization" in request.headers:
additional_headers["authorization"] = request.headers["authorization"]
if "origin" in request.headers:
additional_headers["origin"] = request.headers["origin"]
if "user-agent" in request.headers:
additional_headers["user-agent"] = request.headers["user-agent"]
additional_headers["x-forwarded-for"] = request.ip
additional_headers["x-forwarded-host"] = request.host
additional_headers["x-forwarded-proto"] = request.scheme
try:
async with websockets.connect(
url, additional_headers=additional_headers
) as backend_ws:
async def forward_to_backend():
try:
async for message in ws:
await backend_ws.send(message)
except Exception:
pass
async def forward_to_client():
try:
async for message in backend_ws:
await ws.send(message)
except Exception:
pass
await asyncio.gather(
forward_to_backend(),
forward_to_client(),
return_exceptions=True,
)
except Exception as e:
logger.error(f"WebSocket proxy to {url} failed: {e}")
def _is_websocket_request(request) -> bool:
"""Check if the request is a WebSocket upgrade request."""
connection = request.headers.get("connection", "").lower()
upgrade = request.headers.get("upgrade", "").lower()
connection_tokens = [t.strip() for t in connection.split(",")]
return "upgrade" in connection_tokens and upgrade == "websocket"
async def _handle_websocket_upgrade(request):
"""Handle WebSocket upgrade and proxy the connection."""
protocol = request.transport.get_protocol()
ws = await protocol.websocket_handshake(request, subprotocols=None)
await proxy_auth_websocket(request, ws)
# Blueprint for auth proxy routes (only registered when paskia_enabled())
bp = Blueprint("sso", url_prefix="/auth")
@bp.on_request
async def check_sso_enabled(request):
"""Only handle requests if paskia mode is enabled."""
if config.config.authentication != "paskia":
from sanic.exceptions import NotFound
raise NotFound("SSO authentication not enabled")
@bp.route(
"/<path:path>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"]
)
async def auth_proxy(request, path=""):
"""Proxy all auth requests to the auth backend."""
if _is_websocket_request(request):
await _handle_websocket_upgrade(request)
from sanic import empty
return empty()
return await proxy_auth_request(request)
@bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def auth_proxy_root(request):
"""Proxy root auth requests to the auth backend."""
if _is_websocket_request(request):
await _handle_websocket_upgrade(request)
from sanic import empty
return empty()
return await proxy_auth_request(request)
+25
View File
@@ -1,6 +1,10 @@
<template>
<div v-if="store.error && !store.authInProgress" class="toast-message" @click="store.error = ''">
{{ store.error }}
</div>
<SettingsModal />
<UserManagementModal />
<AccessDeniedModal />
<header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
<HeaderSelected :path="path.pathList" />
@@ -29,6 +33,7 @@ import Router from '@/router/index'
import type { SortOrder } from './utils/docsort'
import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue'
interface Path {
path: string
@@ -157,3 +162,23 @@ onUnmounted(() => {
})
export type { Path }
</script>
<style>
/* Toast notifications - fixed at top center of viewport */
.toast-message {
position: fixed;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 2000;
padding: 0.75rem 1.5rem;
background: var(--accent-color);
color: #000;
font-weight: bold;
border-radius: 0.25rem;
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.3);
cursor: pointer;
max-width: 90vw;
text-align: center;
}
</style>
+137
View File
@@ -0,0 +1,137 @@
// SVG icon index - all icons bundled together
import AddFile from './add-file.svg'
import AddFolder from './add-folder.svg'
import Arrow from './arrow.svg'
import ArrowsH from './arrows-h.svg'
import ArrowsV from './arrows-v.svg'
import Check from './check.svg'
import Code from './code.svg'
import Cog from './cog.svg'
import Copy from './copy.svg'
import CreateFile from './create-file.svg'
import CreateFolder from './create-folder.svg'
import Cross from './cross.svg'
import Disk from './disk.svg'
import Download from './download.svg'
import Exclamation from './exclamation.svg'
import Eye from './eye.svg'
import Find from './find.svg'
import Fullscreen from './fullscreen.svg'
import Github from './github.svg'
import Home from './home.svg'
import Info from './info.svg'
import Link from './link.svg'
import Logo from './logo.svg'
import Loop from './loop.svg'
import Menu from './menu.svg'
import Next from './next.svg'
import Open from './open.svg'
import Paste from './paste.svg'
import Pause from './pause.svg'
import Pencil from './pencil.svg'
import Play from './play.svg'
import Plus from './plus.svg'
import Previous from './previous.svg'
import Reload from './reload.svg'
import Rename from './rename.svg'
import Scissors from './scissors.svg'
import Shuffle from './shuffle.svg'
import Signin from './signin.svg'
import Signout from './signout.svg'
import Skip from './skip.svg'
import Spinner from './spinner.svg'
import Stop from './stop.svg'
import Trash from './trash.svg'
import Triangle from './triangle.svg'
import Unfullscreen from './unfullscreen.svg'
import UpArrow from './up-arrow.svg'
import UploadCloud from './upload-cloud.svg'
import UserCog from './user-cog.svg'
import User from './user.svg'
import VolumeHigh from './volume-high.svg'
import VolumeLow from './volume-low.svg'
import VolumeMedium from './volume-medium.svg'
import VolumeMute from './volume-mute.svg'
import WindowCross from './window-cross.svg'
import Window from './window.svg'
import Wordwrap from './wordwrap.svg'
import Zoomin from './zoomin.svg'
import Zoomout from './zoomout.svg'
// Named exports for direct imports
export {
AddFile, AddFolder, Arrow, ArrowsH, ArrowsV,
Check, Code, Cog, Copy, CreateFile, CreateFolder, Cross,
Disk, Download, Exclamation, Eye, Find, Fullscreen,
Github, Home, Info, Link, Logo, Loop, Menu,
Next, Open, Paste, Pause, Pencil, Play, Plus, Previous,
Reload, Rename, Scissors, Shuffle, Signin, Signout, Skip,
Spinner, Stop, Trash, Triangle, Unfullscreen, UpArrow,
UploadCloud, UserCog, User, VolumeHigh, VolumeLow,
VolumeMedium, VolumeMute, WindowCross, Window, Wordwrap,
Zoomin, Zoomout
}
// Icon lookup by kebab-case name (for SvgButton compatibility)
export const icons = {
'add-file': AddFile,
'add-folder': AddFolder,
'arrow': Arrow,
'arrows-h': ArrowsH,
'arrows-v': ArrowsV,
'check': Check,
'code': Code,
'cog': Cog,
'copy': Copy,
'create-file': CreateFile,
'create-folder': CreateFolder,
'cross': Cross,
'disk': Disk,
'download': Download,
'exclamation': Exclamation,
'eye': Eye,
'find': Find,
'fullscreen': Fullscreen,
'github': Github,
'home': Home,
'info': Info,
'link': Link,
'logo': Logo,
'loop': Loop,
'menu': Menu,
'next': Next,
'open': Open,
'paste': Paste,
'pause': Pause,
'pencil': Pencil,
'play': Play,
'plus': Plus,
'previous': Previous,
'reload': Reload,
'rename': Rename,
'scissors': Scissors,
'shuffle': Shuffle,
'signin': Signin,
'signout': Signout,
'skip': Skip,
'spinner': Spinner,
'stop': Stop,
'trash': Trash,
'triangle': Triangle,
'unfullscreen': Unfullscreen,
'up-arrow': UpArrow,
'upload-cloud': UploadCloud,
'user-cog': UserCog,
'user': User,
'volume-high': VolumeHigh,
'volume-low': VolumeLow,
'volume-medium': VolumeMedium,
'volume-mute': VolumeMute,
'window-cross': WindowCross,
'window': Window,
'wordwrap': Wordwrap,
'zoomin': Zoomin,
'zoomout': Zoomout,
} as const
export type IconName = keyof typeof icons
@@ -0,0 +1,46 @@
<template>
<div v-if="store.dialog === 'accessdenied'" class="modal-overlay">
<div class="modal-dialog" id="accessdenied">
<div class="modal-content access-denied">
<p class="icon"></p>
<p class="message">Access Denied</p>
<button @click="reload" class="button">Reload</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { holdGlobalBackdrop } from 'paskia'
import { watchEffect } from 'vue'
const store = useMainStore()
const reload = () => {
location.reload()
}
// Keep backdrop active when this dialog shows
watchEffect(() => {
if (store.dialog === 'accessdenied') {
holdGlobalBackdrop()
}
})
</script>
<style scoped>
.access-denied {
text-align: center;
padding: 2rem !important;
}
.access-denied .icon {
font-size: 4rem;
margin: 0 0 1rem 0;
}
.access-denied .message {
font-size: 1.5rem;
font-weight: bold;
margin: 0 0 1.5rem 0;
}
</style>
+6 -2
View File
@@ -1,7 +1,8 @@
<template>
<div v-if="!props.path || documents.length === 0" class="empty-container">
<component :is="cog" class="cog"/>
<p v-if="!store.connected">No Connection</p>
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
<p v-else-if="!store.connected">No Connection</p>
<p v-else-if="store.document.length === 0">Waiting for File List</p>
<p v-else-if="store.query">No matches!</p>
<p v-else-if="!exists(props.path)">Folder not found</p>
@@ -35,4 +36,7 @@ svg.cog {
filter: drop-shadow(0 0 1rem black);
fill: #888;
}
svg.cog.stopped {
animation: none;
}
</style>
+8 -26
View File
@@ -1,9 +1,5 @@
<template>
<nav class="headermain buttons">
<template v-if="store.error">
<div class="error-message" @click="store.error = ''">{{ store.error }}</div>
<div class="smallgap"></div>
</template>
<UploadButton :path="props.path" />
<SvgButton
name="create-folder"
@@ -31,7 +27,7 @@
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { useSsoAuthStore } from '@/stores/ssoAuth'
import { ref, nextTick, watchEffect, computed } from 'vue'
import { ref, nextTick, watchEffect } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu'
import { showAuthIframe } from 'paskia'
import { resumeWatching } from '@/repositories/WS'
@@ -43,14 +39,6 @@ const showSearchInput = ref<boolean>(false)
const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>()
// Display name for SSO users
const displayUserName = computed(() => {
if (ssoStore.isExternalAuth && ssoStore.userName) {
return ssoStore.userName
}
return store.user.username
})
const props = defineProps<{
path: Array<string>
query: string
@@ -90,33 +78,27 @@ const settingsMenu = (e: Event) => {
// For external auth, show user name as link to /auth/
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({
label: displayUserName.value || 'User Account',
label: '👤 ' + (store.user.username || 'User Account'),
onClick: () => { window.location.href = '/auth/' }
})
items.push({ divided: true })
}
// Only show password change for non-SSO users
if (!ssoStore.isExternalAuth) {
items.push({ label: 'Change Password', onClick: () => { store.dialog = 'settings' }})
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }})
}
if (store.user.privileged) {
items.push({ label: 'Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
}
if (store.user.isLoggedIn) {
if (ssoStore.isExternalAuth) {
// For SSO, link to auth logout
items.push({ label: 'Logout', onClick: () => { window.location.href = '/auth/' }})
} else {
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
}
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
} else if (!ssoStore.isExternalAuth) {
// Show login in paskia iframe overlay
items.push({ label: 'Login', onClick: async () => {
items.push({ label: '🔐 Login', onClick: async () => {
try {
await showAuthIframe('/auth/api/restricted')
await showAuthIframe('/auth/restricted')
resumeWatching()
} catch (e) {
console.log('Login cancelled')
+81 -67
View File
@@ -1,25 +1,27 @@
<template>
<dialog v-if="store.dialog === name" ref="dialog" :id=props.name @keydown.escape=close>
<h1 v-if="props.title">{{ props.title }}</h1>
<div>
<slot>
Dialog with no content
<button @click=close>OK</button>
</slot>
<div v-if="store.dialog === name" class="modal-overlay" @click.self="close" @keydown.escape="close" tabindex="-1" ref="overlay">
<div class="modal-dialog" :id="props.name" ref="dialog">
<h1 v-if="props.title">{{ props.title }}</h1>
<div class="modal-content">
<slot>
Dialog with no content
<button @click="close">OK</button>
</slot>
</div>
</div>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watchEffect, nextTick } from 'vue'
import { ref, watchEffect, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
const dialog = ref<HTMLDialogElement | null>(null)
const overlay = ref<HTMLDivElement | null>(null)
const dialog = ref<HTMLDivElement | null>(null)
const store = useMainStore()
const close = () => {
dialog.value!.close()
store.dialog = ''
releaseGlobalBackdrop()
}
@@ -32,44 +34,48 @@ const props = defineProps<{
const show = () => {
store.dialog = props.name
holdGlobalBackdrop()
setTimeout(() => {
dialog.value!.showModal()
nextTick(() => {
const input = dialog.value!.querySelector('input')
if (input) input.focus()
})
}, 0)
nextTick(() => {
overlay.value?.focus()
const input = dialog.value?.querySelector('input')
if (input) input.focus()
})
}
defineExpose({ show, close })
watchEffect(() => {
if (dialog.value) show()
if (overlay.value) {
overlay.value.focus()
const input = dialog.value?.querySelector('input')
if (input) input.focus()
}
})
</script>
<style>
/* ===========================================
DIALOG GLOBAL STYLES
MODAL DIALOG GLOBAL STYLES
Shared styling for all modal dialogs.
Login page (auth.py) has matching CSS.
=========================================== */
dialog::backdrop {
display: none;
/* Overlay - covers entire viewport */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
/* No backdrop - paskia handles that */
}
/* Dialog container */
dialog[open] {
.modal-dialog {
background: #ddd;
color: #000;
border: none;
border-radius: 0.5rem;
box-shadow: 0 0 1rem #0008;
padding: 0;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1100;
max-width: 90vw;
max-height: 90vh;
overflow: auto;
@@ -77,7 +83,7 @@ dialog[open] {
}
/* Dialog title bar */
dialog[open] > h1 {
.modal-dialog > h1 {
background: #146;
color: #fff;
font-size: 1.2rem;
@@ -89,24 +95,32 @@ dialog[open] > h1 {
}
/* Dialog content area */
dialog[open] > div {
.modal-dialog > .modal-content {
padding: 1rem;
}
/* Section headings inside dialog */
dialog h3 {
.modal-dialog h3 {
font-size: 1rem;
font-weight: 600;
margin: 1rem 0 0.5rem 0;
}
dialog h3:first-child {
.modal-dialog h3:first-child {
margin-top: 0;
}
/* Links */
.modal-dialog a {
color: #146;
}
.modal-dialog a:hover {
color: #f80;
}
/* Form inputs */
dialog input[type="text"],
dialog input[type="password"],
dialog select {
.modal-dialog input[type="text"],
.modal-dialog input[type="password"],
.modal-dialog select {
font: inherit;
font-size: 1rem;
padding: 0.5rem;
@@ -117,23 +131,23 @@ dialog select {
min-width: 12rem;
}
dialog input[type="text"]:focus,
dialog input[type="password"]:focus,
dialog select:focus {
.modal-dialog input[type="text"]:focus,
.modal-dialog input[type="password"]:focus,
.modal-dialog select:focus {
outline: none;
border-color: #f80;
}
/* Labels */
dialog label {
.modal-dialog label {
font-size: 1rem;
}
/* Buttons */
dialog button,
dialog input[type="submit"],
dialog input[type="reset"],
dialog .button {
.modal-dialog button,
.modal-dialog input[type="submit"],
.modal-dialog input[type="reset"],
.modal-dialog .button {
font: inherit;
font-size: 1rem;
padding: 0.5rem 1rem;
@@ -144,37 +158,37 @@ dialog .button {
cursor: pointer;
}
dialog button:hover,
dialog input[type="submit"]:hover,
dialog input[type="reset"]:hover,
dialog .button:hover {
.modal-dialog button:hover,
.modal-dialog input[type="submit"]:hover,
.modal-dialog input[type="reset"]:hover,
.modal-dialog .button:hover {
background: #f80;
}
dialog button:disabled,
dialog input[type="submit"]:disabled,
dialog input[type="reset"]:disabled,
dialog .button:disabled {
.modal-dialog button:disabled,
.modal-dialog input[type="submit"]:disabled,
.modal-dialog input[type="reset"]:disabled,
.modal-dialog .button:disabled {
background: #888;
cursor: not-allowed;
}
/* Small button variant */
dialog .button.small {
.modal-dialog .button.small {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
/* Danger button variant */
dialog .button.danger {
.modal-dialog .button.danger {
background: #c00;
}
dialog .button.danger:hover:not(:disabled) {
.modal-dialog .button.danger:hover:not(:disabled) {
background: #f00;
}
/* Form row layout (label + input side by side) */
dialog .form-row {
.modal-dialog .form-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
@@ -183,7 +197,7 @@ dialog .form-row {
}
/* Form grid for multiple label+input pairs */
dialog .form-grid {
.modal-dialog .form-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
@@ -191,7 +205,7 @@ dialog .form-grid {
}
/* Dialog button row (footer) */
dialog .dialog-buttons {
.modal-dialog .dialog-buttons {
display: flex;
justify-content: flex-end;
align-items: center;
@@ -200,7 +214,7 @@ dialog .dialog-buttons {
}
/* Error text */
dialog .error-text {
.modal-dialog .error-text {
color: #c00;
font-size: 0.875rem;
min-height: 1.2em;
@@ -208,7 +222,7 @@ dialog .error-text {
}
/* Success message */
dialog .success-message {
.modal-dialog .success-message {
background: #f80;
color: #000;
padding: 0.5rem;
@@ -221,43 +235,43 @@ dialog .success-message {
}
/* Data tables inside dialogs */
dialog table {
.modal-dialog table {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
font-size: 1rem;
}
dialog th,
dialog td {
.modal-dialog th,
.modal-dialog td {
border: 1px solid #888;
padding: 0.5rem;
text-align: left;
}
dialog th {
.modal-dialog th {
background: #146;
color: #fff;
font-weight: normal;
}
dialog td {
.modal-dialog td {
background: #fff;
}
/* Checkbox alignment in tables */
dialog td input[type="checkbox"] {
.modal-dialog td input[type="checkbox"] {
margin: 0;
}
/* Paragraph text */
dialog p {
.modal-dialog p {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
/* Loading state */
dialog .loading {
.modal-dialog .loading {
padding: 2rem;
text-align: center;
color: #666;
+4 -9
View File
@@ -26,9 +26,6 @@
v-model="form.password"
/>
</div>
<p class="error-text">
{{ form.error || '\u00A0' }}
</p>
<div class="dialog-buttons">
<input id="close" type="reset" value="Close" class="button" @click=close />
<div class="spacer"></div>
@@ -54,28 +51,26 @@ import { useMainStore } from '@/stores/main'
const confirmLoading = ref<boolean>(false)
const store = useMainStore()
const passwordChange = ref()
const password = ref()
const form = reactive({
passwordChange: '',
password: '',
error: ''
password: ''
})
const close = () => {
form.passwordChange = ''
form.password = ''
form.error = ''
store.dialog = ''
}
const submit = async (ev: Event) => {
ev.preventDefault()
try {
form.error = ''
if (form.passwordChange) {
if (!form.password) {
form.error = '⚠️ Current password is required'
store.error = '⚠️ Current password is required'
password.value!.focus()
return
}
@@ -84,7 +79,7 @@ const submit = async (ev: Event) => {
close()
} catch (error) {
const httpError = error as ISimpleError
form.error = httpError.message || '🛑 Unknown error'
store.error = httpError.message || '🛑 Unknown error'
} finally {
confirmLoading.value = false
}
+69 -68
View File
@@ -4,53 +4,55 @@
<div v-else>
<h3>Server Settings</h3>
<div class="form-row">
<label for="authMode">Authentication:</label>
<select
id="authMode"
v-model="serverSettings.authentication"
@change="updateServerSettings"
>
<option value="password">Password (built-in users)</option>
<option value="paskia">Paskia (external SSO)</option>
<option value="none">None (public access)</option>
</select>
<label for="publicAccess">
<input
type="checkbox"
id="publicAccess"
v-model="serverSettings.public"
@change="updateServerSettings"
/>
Public access (anyone can read and write)
</label>
</div>
<template v-if="serverSettings.authentication === 'password'">
<h3>Users</h3>
<button @click="addUser" class="button" title="Add new user"> Add User</button>
<div v-if="success" class="success-message" @click="copySuccess(false)">
{{ success }}
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
</div>
<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>
<template v-if="store.server.paskia">
<h3>User Management</h3>
<p>See <a href="/auth/admin/">Paskia Admin</a>.</p>
</template>
<template v-else>
<h3>Users</h3>
<button @click="addUser" class="button" title="Add new user"> Add User</button>
<div v-if="success" class="success-message" @click="copySuccess(false)">
{{ success }}
<button v-if="success.includes('Password:') || success.includes('New password:')" @click.stop="copySuccess(true)" class="button small" title="Copy to clipboard">{{ copyButtonText }}</button>
</div>
<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>
</template>
<p class="error-text">{{ error || '\u00A0' }}</p>
<div class="dialog-buttons">
<button @click="close" class="button">Close</button>
</div>
@@ -60,7 +62,7 @@
<script lang="ts" setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { listUsers, createUser, updateUser, deleteUser, updateAuthentication, type AuthMode } from '@/repositories/User'
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
@@ -73,16 +75,14 @@ interface User {
const store = useMainStore()
const loading = ref(true)
const users = ref<User[]>([])
const error = ref('')
const success = ref('')
const copyButtonText = ref('📋')
const serverSettings = reactive({
authentication: 'password' as AuthMode
public: false
})
const close = () => {
store.dialog = ''
error.value = ''
success.value = ''
}
@@ -93,7 +93,7 @@ const loadUsers = async () => {
users.value = data.users
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to load users'
store.error = httpError.message || 'Failed to load users'
} finally {
loading.value = false
}
@@ -103,7 +103,6 @@ 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()
@@ -112,19 +111,18 @@ const addUser = async () => {
}
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to add user'
store.error = 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'
store.error = httpError.message || 'Failed to update user'
target.checked = user.privileged // revert
}
}
@@ -135,7 +133,6 @@ const renameUser = async (user: User) => {
// 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)
@@ -145,14 +142,13 @@ const renameUser = async (user: User) => {
}
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to rename user'
store.error = 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) {
@@ -160,19 +156,18 @@ const resetPassword = async (user: User) => {
}
} catch (e) {
const httpError = e as ISimpleError
error.value = httpError.message || 'Failed to reset password'
store.error = 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'
store.error = httpError.message || 'Failed to delete user'
}
}
@@ -200,25 +195,31 @@ const copySuccess = async (isButtonClick: boolean = false) => {
const updateServerSettings = async () => {
try {
error.value = ''
success.value = ''
await updateAuthentication(serverSettings.authentication)
await updatePublic(serverSettings.public)
// Update store
store.server.authentication = serverSettings.authentication
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'
store.error = httpError.message || 'Failed to update settings'
}
}
onMounted(() => {
serverSettings.authentication = store.server.authentication || 'password'
loadUsers()
serverSettings.public = store.server.public || false
loading.value = false
})
watch(() => store.server.authentication, (newVal) => {
serverSettings.authentication = newVal || 'password'
// Load users when dialog opens (only in built-in auth mode)
watch(() => store.dialog, (newVal) => {
if (newVal === 'usermgmt' && !store.server.paskia) {
loadUsers()
}
})
watch(() => store.server.public, (newVal) => {
serverSettings.public = newVal || false
})
</script>
+3 -5
View File
@@ -1,7 +1,7 @@
import Client from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
export const url_login = '/auth/login'
export const url_logout = '/auth/logout'
export const url_logout = '/auth/api/logout'
export const url_password = '/auth/password-change'
export async function loginUser(username: string, password: string) {
@@ -51,9 +51,7 @@ export async function deleteUser(username: string) {
return data
}
export type AuthMode = 'none' | 'paskia' | 'password'
export async function updateAuthentication(mode: AuthMode) {
const data = await Client.put('/api/config/authentication', { authentication: mode })
export async function updatePublic(isPublic: boolean) {
const data = await Client.put('/api/config/public', { public: isPublic })
return data
}
+15 -7
View File
@@ -1,5 +1,4 @@
import { useMainStore } from "@/stores/main"
import { useSsoAuthStore } from "@/stores/ssoAuth"
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
@@ -13,6 +12,11 @@ let wsWatch = null as WebSocket | null
// Track when we're awaiting authentication to prevent reconnection loops
let awaitingAuth = false
// Clear the local tree cache (called on logout/auth failure)
export const clearTree = () => {
tree = []
}
export const loadSession = () => {
const s = localStorage['cista-files']
if (!s) return false
@@ -42,8 +46,14 @@ export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEv
async function handleWsAuthError(msg: any) {
const iframe = msg.error?.auth?.iframe
if (iframe) {
// Clear sensitive data immediately on auth failure
const store = useMainStore()
store.clearSensitiveData()
clearTree()
// Stop reconnection attempts while showing auth dialog
awaitingAuth = true
store.authInProgress = true
store.error = '' // Clear any connection message
if (watchTimeout !== null) {
clearTimeout(watchTimeout)
watchTimeout = null
@@ -52,12 +62,15 @@ async function handleWsAuthError(msg: any) {
await showAuthIframe(iframe)
// Auth succeeded - reconnect
awaitingAuth = false
store.authInProgress = false
watchConnect()
} catch (e) {
awaitingAuth = false
store.authInProgress = false
if (e instanceof AuthCancelledError) {
console.log('User cancelled authentication')
// User cancelled - don't automatically retry, wait for user action
// Show access denied dialog
store.dialog = 'accessdenied'
} else {
console.error('Auth iframe error:', e)
}
@@ -100,11 +113,6 @@ export const watchConnect = () => {
store.error = ''
if (msg.user) store.login(msg.user.username, msg.user.privileged)
else if (store.isUserLogged) store.logout()
// Start SSO validation polling only in paskia mode
if (msg.server.authentication === 'paskia') {
const ssoStore = useSsoAuthStore()
ssoStore.startValidationPolling()
}
}
})
}
+28 -7
View File
@@ -2,7 +2,6 @@ import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
import { Doc } from '@/repositories/Document'
import { defineStore, type StateTree } from 'pinia'
import { collator } from '@/utils'
import { logoutUser } from '@/repositories/User'
import { watchConnect, resumeWatching } from '@/repositories/WS'
import { sorted, type SortOrder } from '@/utils/docsort'
@@ -14,9 +13,10 @@ export const useMainStore = defineStore('main', {
fileExplorer: null as any,
error: '' as string,
connected: false,
authInProgress: false,
cursor: '' as string,
server: {} as Record<string, any> & { authentication?: 'none' | 'paskia' | 'password' },
dialog: '' as '' | 'settings' | 'usermgmt',
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied',
uprogress: {} as any,
dprogress: {} as any,
prefs: {
@@ -69,12 +69,33 @@ export const useMainStore = defineStore('main', {
this.dialog = ''
if (!this.connected) resumeWatching()
},
clearSensitiveData() {
// Clear all sensitive state on logout or auth failure
localStorage.removeItem('cista-files')
this.document = []
this.selected.clear()
this.user.username = ''
this.user.privileged = false
this.user.isLoggedIn = false
this.connected = false
this.dialog = ''
this.cursor = ''
},
async logout() {
console.log("Logout")
await logoutUser()
this.$reset()
localStorage.clear()
history.go() // Reload page
try {
const res = await fetch('/auth/api/logout', { method: 'POST' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
this.error = data.message || data.detail || 'Logout failed'
return
}
} catch (e) {
this.error = 'Logout failed'
return
}
this.clearSensitiveData()
resumeWatching()
},
toggleSort(name: SortOrder) {
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name
+6 -94
View File
@@ -1,107 +1,19 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { computed } from 'vue'
import { useMainStore } from './main'
import { SessionValidator, apiFetch, AuthCancelledError } from 'paskia'
// Session validator instance (only used in paskia mode)
let sessionValidator: SessionValidator | null = null
import { clearTree } from '@/repositories/WS'
export const useSsoAuthStore = defineStore('ssoAuth', () => {
// State
const userName = ref('')
const userUuid = ref('')
// Getters
const isExternalAuth = computed(() => {
const mainStore = useMainStore()
return mainStore.server?.authentication === 'paskia'
return mainStore.server?.paskia === true
})
// Actions
function clearDataOnUnauth() {
const mainStore = useMainStore()
// Clear localStorage
localStorage.removeItem('cista-files')
// Clear visible files by resetting document
mainStore.document = []
mainStore.selected.clear()
mainStore.user.isLoggedIn = false
userName.value = ''
userUuid.value = ''
mainStore.clearSensitiveData()
clearTree()
}
function handleSessionLost(error: Error) {
console.warn('Session lost:', error)
clearDataOnUnauth()
// Trigger re-authentication by reloading - paskia will handle the auth flow
location.reload()
}
async function validateSession(): Promise<boolean> {
// Only do session validation in paskia mode
if (!isExternalAuth.value) return true
try {
const res = await apiFetch('/auth/api/validate', {
method: 'POST',
headers: { 'accept': 'application/json' }
})
if (res.ok) {
// Extract user display name from Remote-Name header
userName.value = res.headers.get('Remote-Name') || ''
try {
const data = await res.json()
if (data.uuid) userUuid.value = data.uuid
} catch {
// Response may not have JSON body
}
return true
}
return false
} catch (e) {
if (e instanceof AuthCancelledError) {
console.log('User cancelled authentication')
return false
}
console.error('SSO validation error:', e)
return false
}
}
function startValidationPolling() {
if (!isExternalAuth.value) return
// Stop any existing validator
stopValidationPolling()
// Initial validation to get user info
validateSession()
// Use paskia's SessionValidator for ongoing session monitoring
sessionValidator = new SessionValidator(
() => userUuid.value || undefined, // getter for current user ID
handleSessionLost // callback when session is lost
)
sessionValidator.start()
}
function stopValidationPolling() {
if (sessionValidator) {
sessionValidator.stop()
sessionValidator = null
}
}
return {
// State
userName,
userUuid,
// Getters
isExternalAuth,
// Actions
validateSession,
clearDataOnUnauth,
startValidationPolling,
stopValidationPolling,
}
return { isExternalAuth, clearDataOnUnauth }
})