Consistent dialog styling widgets and using Paskia's shared backdrop. Internal password auth mimics Paskia. API paths changed (/auth goes to internal or paskia depending on config). All API calls and previews get access checks.

This commit is contained in:
Leo Vasanko
2026-01-30 18:28:26 +00:00
parent fa82fee53e
commit 14f2177514
23 changed files with 1109 additions and 386 deletions
+21 -6
View File
@@ -29,7 +29,7 @@ banner = create_banner()
doc = """\
Usage:
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] [-l <host>] [--auth <mode>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] --user <name> [--privileged] [--password]
Options:
@@ -39,11 +39,15 @@ 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 and imported options are preserved in config, and only
custom config dir and dev mode need to be specified on subsequent runs.
Listen address, path, auth mode and imported options 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
@@ -107,6 +111,11 @@ 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"
)
if path:
settings["path"] = path
elif not exists:
@@ -115,9 +124,15 @@ def _main():
settings["listen"] = listen
elif not exists:
settings["listen"] = ":8000"
if not exists and not import_droppy:
# 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["public"] = True
settings["authentication"] = "none"
operation = config.update_config(settings)
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
# Prepare to serve
@@ -159,7 +174,7 @@ def _user(args):
{
"listen": ":8000",
"path": Path.home() / "Downloads",
"public": False,
"authentication": "password",
}
)
sys.stderr.write(f"Config {operation}: {config.conffile}\n\n")
+20 -2
View File
@@ -5,7 +5,10 @@ from secrets import token_bytes
import msgspec
from sanic import Blueprint
from cista import __version__, config, watching
from sanic import json
from sanic.exceptions import BadRequest
from cista import __version__, auth, config, watching
from cista.fileio import FileServer
from cista.protocol import ControlTypes, FileRange, StatusMsg
from cista.util.apphelpers import asend, websocket_wrapper
@@ -98,7 +101,7 @@ async def watch(req, ws):
"server": {
"name": config.config.name or config.config.path.name,
"version": __version__,
"public": config.config.public,
"authentication": config.config.authentication,
},
"user": {
"username": req.ctx.username,
@@ -136,3 +139,18 @@ def subscribe(uuid, ws):
watching.format_space(watching.state.space),
watching.format_root(watching.state.root),
)
@bp.put("config/authentication")
async def update_authentication(request):
await auth.verify(request, privileged=True)
try:
mode = request.json["authentication"]
if mode not in ("none", "paskia", "password"):
raise ValueError("Invalid authentication mode")
except KeyError:
raise BadRequest("Missing authentication 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})
+16 -2
View File
@@ -18,7 +18,7 @@ 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 import auth, config, preview, session, sso, watching
from cista.api import bp
from cista.util.apphelpers import handle_sanic_exception
@@ -27,6 +27,7 @@ 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)
app.blueprint(preview.bp)
app.blueprint(bp)
app.exception(Exception)(handle_sanic_exception)
@@ -52,6 +53,7 @@ async def main_stop(app):
quit.set()
watching.stop(app)
app.ctx.threadexec.shutdown()
await sso.close_client()
logger.debug("Cista worker threads all finished")
@@ -77,7 +79,15 @@ async def use_session(req):
@app.before_server_start
def http_fileserver(app):
bp = Blueprint("fileserver")
bp.on_request(auth.verify)
@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)
bp.static(
"/files/",
config.config.path,
@@ -239,6 +249,10 @@ def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
@app.get("/zip/<keys>/<zipfile:ext=zip>")
async def zip_download(req, keys, zipfile, ext):
"""Download a zip archive of the given keys"""
if config.config.authentication == "paskia":
await auth.verify_sso(req)
else:
auth.verify(req)
wanted = set(keys.split("+"))
files = get_files(wanted)
+287 -59
View File
@@ -12,6 +12,174 @@ from sanic.exceptions import BadRequest, Forbidden, Unauthorized
from cista import config, session
from cista.util import pwgen
_LOGIN_PAGE_CSS = """\
/* ===========================================
LOGIN PAGE STYLES
Must match ModalDialog.vue global styles.
=========================================== */
* { box-sizing: border-box; }
body {
font-family: 'Roboto', system-ui, -apple-system, sans-serif;
font-size: 1rem;
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
}
.login-card {
background: #ddd;
color: #000;
border-radius: 0.5rem;
box-shadow: 0 0 1rem #0008;
width: 100%;
max-width: 320px;
}
h1 {
background: #146;
color: #fff;
margin: 0;
padding: 0.5rem 1rem;
font-size: 1.2rem;
font-weight: normal;
border-radius: 0.5rem 0.5rem 0 0;
}
.content {
padding: 1rem;
}
.message {
color: #444;
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
}
form {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
align-items: center;
}
label {
font-size: 1rem;
}
input[type="text"],
input[type="password"] {
font: inherit;
font-size: 1rem;
padding: 0.5rem;
border: 2px solid #888;
border-radius: 0.25rem;
background: #fff;
color: #000;
min-width: 0;
}
input:focus {
outline: none;
border-color: #f80;
}
.button-row {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
margin-top: 0.5rem;
}
button {
font: inherit;
font-size: 1rem;
padding: 0.5rem 1rem;
background: #146;
color: #fff;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
button:hover { background: #f80; }
button:disabled {
background: #888;
cursor: not-allowed;
}
.error {
grid-column: 1 / -1;
color: #c00;
font-size: 0.875rem;
min-height: 1.2em;
margin: 0;
}
"""
_LOGIN_PAGE_JS = """\
const form = document.getElementById('loginForm');
const error = document.getElementById('error');
const submitBtn = document.getElementById('submitBtn');
const usernameField = document.getElementById('username');
const passwordField = document.getElementById('password');
const isInIframe = window.parent !== window;
// Focus username field on load
usernameField.focus();
const showError = (msg) => {
error.textContent = msg;
submitBtn.disabled = false;
submitBtn.textContent = 'Log in';
// Focus and select the relevant field
if (msg.toLowerCase().includes('password')) {
passwordField.focus();
passwordField.select();
} else {
usernameField.focus();
usernameField.select();
}
};
form.onsubmit = async (e) => {
e.preventDefault();
error.textContent = '';
submitBtn.disabled = true;
submitBtn.textContent = 'Logging in...';
try {
const res = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
username: usernameField.value,
password: passwordField.value
})
});
if (res.ok) {
if (isInIframe) {
window.parent.postMessage({type: 'auth-success'}, '*');
} else {
window.location.href = '/';
}
} else {
const data = await res.json();
showError(data.message || data.detail || 'Login failed');
}
} catch (err) {
showError('Connection error. Please try again.');
}
};
"""
# Import for SSO validation (lazily loaded to avoid circular imports)
_sso_module = None
def _get_sso():
global _sso_module
if _sso_module is None:
from cista import sso
_sso_module = sso
return _sso_module
_argon = argon2.PasswordHasher()
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
@@ -63,62 +231,133 @@ class LoginResponse(msgspec.Struct):
error: str = ""
def verify(request, *, privileged=False):
"""Raise Unauthorized or Forbidden if the request is not authorized"""
if privileged:
if request.ctx.user:
if request.ctx.user.privileged:
return
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
elif config.config.public or request.ctx.user:
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.
All 401/403 responses include auth.iframe URL for consistent frontend handling
via the paskia library's showAuthIframe().
Args:
request: The Sanic request object
privileged: If True, requires admin privileges
Raises:
Unauthorized: If authentication is required
Forbidden: If access is denied
"""
if config.config.authentication == "paskia":
# SSO validation against auth backend
sso = _get_sso()
perm = "cista:login cista:admin" if privileged else "cista:login"
await sso.validate_sso_request(request, perm=perm)
return
raise Unauthorized(f"Login required for {request.path}", "cookie", quiet=True)
user = getattr(request.ctx, "user", None)
if privileged:
if user:
if user.privileged:
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:
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"}},
quiet=True,
)
bp = Blueprint("auth")
bp = Blueprint("auth", url_prefix="/auth")
@bp.get("/login")
@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")
async def login_page(request):
doc = Document("Cista Login")
with doc.div(id="login"):
with doc.form(method="POST", autocomplete="on"):
doc.h1("Login")
doc.input(
name="username",
placeholder="Username",
autocomplete="username",
required=True,
).br
doc.input(
type="password",
name="password",
placeholder="Password",
autocomplete="current-password",
required=True,
).br
doc.input(type="submit", value="Login")
s = session.get(request)
if s:
name = s["username"]
with doc.form(method="POST", action="/logout"):
doc.input(type="submit", value=f"Logout {name}")
flash = request.cookies.message
if flash:
doc.dialog(
flash,
id="flash",
open=True,
style="position: fixed; top: 0; left: 0; width: 100%; opacity: .8",
)
"""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")
s = session.get(request)
# Check if already logged in
if s and mode == "login":
# 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}")
# Add paskia-compatible styling and scripts
doc.style(_LOGIN_PAGE_CSS)
with doc.div(class_="login-card"):
doc.h1(title)
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(
type="text",
id="username",
name="username",
autocomplete="username webauthn",
required=True,
)
doc.label("Password:", for_="password")
doc.input(
type="password",
id="password",
name="password",
autocomplete="current-password webauthn",
required=True,
)
with doc.div(class_="button-row"):
doc.button("Log in", type="submit", id="submitBtn")
doc.p("", class_="error", id="error")
# JavaScript for AJAX login and postMessage communication
doc.script_(_LOGIN_PAGE_JS)
res = html(doc)
if flash:
res.cookies.delete_cookie("flash")
if s is False:
session.delete(res)
return res
def _login_success_page(username: str) -> str:
"""Minimal page that signals auth-success to parent iframe."""
return str(
Document().script_("window.parent.postMessage({type:'auth-success'},'*')")
)
@bp.post("/login")
async def login_post(request):
try:
@@ -196,7 +435,7 @@ async def change_password(request):
@bp.get("/users")
async def list_users(request):
verify(request, privileged=True)
await verify(request, privileged=True)
users = []
for name, user in config.config.users.items():
users.append(
@@ -211,7 +450,7 @@ async def list_users(request):
@bp.post("/users")
async def create_user(request):
verify(request, privileged=True)
await verify(request, privileged=True)
try:
if request.headers.content_type == "application/json":
username = request.json["username"]
@@ -240,7 +479,7 @@ async def create_user(request):
@bp.put("/users/<username>")
async def update_user(request, username):
verify(request, privileged=True)
await verify(request, privileged=True)
try:
if request.headers.content_type == "application/json":
changes = request.json
@@ -273,7 +512,7 @@ async def update_user(request, username):
@bp.delete("/users/<username>")
async def delete_user(request, username):
verify(request, privileged=True)
await verify(request, privileged=True)
if username not in config.config.users:
raise BadRequest("User does not exist")
try:
@@ -281,14 +520,3 @@ async def delete_user(request, username):
except Exception as e:
raise BadRequest(str(e)) from e
return json({"message": f"User {username} deleted"})
@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"})
+13 -2
View File
@@ -13,12 +13,15 @@ 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)
public: bool = False
authentication: AuthMode = "password"
name: str = ""
users: dict[str, User] = {}
links: dict[str, Link] = {}
@@ -152,7 +155,15 @@ def modifies_config(
def load_config():
global config
init_confdir()
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
raw = conffile.read_bytes()
config = msgspec.toml.decode(raw, type=Config, dec_hook=dec_hook)
# Migrate from old public flag 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)
update_config({}) # Save the migrated config
@modifies_config
+9 -2
View File
@@ -17,13 +17,20 @@ from sanic import Blueprint, empty, raw, redirect
from sanic.exceptions import NotFound
from sanic.log import logger
from cista import config
from cista import auth, config
from cista.util.filename import sanitize
pillow_heif.register_heif_opener()
bp = Blueprint("preview", url_prefix="/preview")
@bp.on_request
async def verify_preview(request):
"""Verify access to preview routes."""
await auth.verify(request)
# Map EXIF Orientation value to a corresponding PIL transpose
EXIF_ORI = {
2: Image.Transpose.FLIP_LEFT_RIGHT,
@@ -53,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.public else ", private"),
+ ("" if config.config.authentication == "none" else ", private"),
"content-type": "image/avif",
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
}
+225
View File
@@ -0,0 +1,225 @@
"""SSO (paskia) authentication proxy and validation module.
When paskia authentication mode is enabled:
- 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)
"""
import os
import httpx
from sanic import Blueprint
from sanic.exceptions import Forbidden, Unauthorized
from sanic.log import logger
from cista import config
# 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
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)
return _client
async def close_client():
"""Close the shared httpx client."""
global _client
if _client is not None and not _client.is_closed:
await _client.aclose()
_client = None
async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | None:
"""Validate an SSO request against the auth backend.
Args:
request: The Sanic request object
perm: Permission to validate (default: cista:login, privileged also cista:admin)
Returns:
User info dict if valid, None if validation fails with auth required response
Raises:
Forbidden: If access is denied (403)
Unauthorized: If authentication is required (401)
"""
if config.config.authentication != "paskia":
return None
client = await get_client()
# Forward relevant headers (especially cookies for session validation)
headers = {}
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"]
try:
response = await client.post(
f"{PASKIA_BACKEND_URL}/auth/api/validate",
params={"perm": perm},
headers=headers,
)
if response.status_code == 200:
# Validation successful
try:
return response.json()
except Exception:
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:
raise Unauthorized(
error_data.get("detail", "Authentication required"),
"cookie",
context=error_data,
quiet=True,
)
elif response.status_code == 403:
raise Forbidden(
error_data.get("detail", "Access denied"),
context=error_data,
quiet=True,
)
else:
logger.warning(
f"SSO validation returned unexpected status: {response.status_code}"
)
raise Forbidden(
error_data.get("detail", "Authentication error"),
context=error_data,
quiet=True,
)
except httpx.RequestError as e:
logger.error(f"SSO validation request failed: {e}")
raise Forbidden(
"Authentication service unavailable",
quiet=True,
)
async def proxy_auth_request(request):
"""Proxy a request to the auth backend.
All requests under /auth/ are proxied when paskia mode is enabled.
"""
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",
"connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"proxy-authorization",
"proxy-authenticate",
]:
headers.pop(hop_header, None)
# Add forwarded headers
headers["x-forwarded-for"] = request.ip
headers["x-forwarded-host"] = request.host
headers["x-forwarded-proto"] = request.scheme
try:
response = await client.request(
method=request.method,
url=url,
headers=headers,
content=request.body if request.body else None,
)
# 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)
from sanic import raw as raw_response
return raw_response(
response.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}")
from sanic import json
return json(
{"detail": "Authentication service unavailable", "error": str(e)},
status=503,
)
# Blueprint for auth proxy routes
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."""
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."""
return await proxy_auth_request(request)
+5 -2
View File
@@ -33,8 +33,11 @@ async def handle_sanic_exception(request, e):
logger.exception(e)
# Non-browsers get JSON errors
if "text/html" not in request.headers.accept:
# Include auth context if present (for SSO auth required responses)
# Auth must be at top level for paskia library to detect it
response_data = {"code": code, "message": message, "detail": message, **context}
return jres(
ErrorMsg({"code": code, "message": message, **context}),
response_data,
status=code,
)
# Redirections flash the error message via cookies
@@ -52,7 +55,7 @@ def websocket_wrapper(handler):
@wraps(handler)
async def wrapper(request, ws, *args, **kwargs):
try:
auth.verify(request)
await auth.verify(request)
await handler(request, ws, *args, **kwargs)
except Exception as e:
context, code, message = {}, 500, str(e) or "Internal Server Error"
+1
View File
@@ -21,6 +21,7 @@
"esbuild": "^0.19.5",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"paskia": "^0.1.2",
"pinia": "^2.1.6",
"pinia-plugin-persistedstate": "^3.2.0",
"unplugin-vue-components": "^0.25.2",
-1
View File
@@ -1,5 +1,4 @@
<template>
<LoginModal />
<SettingsModal />
<UserManagementModal />
<header>
+2 -1
View File
@@ -4,6 +4,7 @@
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { apiFetch } from '@/repositories/Client'
import type { SelectedItems } from '@/repositories/Document'
import { reactive } from 'vue';
@@ -96,7 +97,7 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
const writable = await fileHandle.createWritable()
const url = `/files/${rel}`
console.log('Fetching', url)
const res = await fetch(url)
const res = await apiFetch(url)
if (!res.ok) {
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
+46 -5
View File
@@ -30,14 +30,27 @@
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { ref, nextTick, watchEffect } from 'vue'
import { useSsoAuthStore } from '@/stores/ssoAuth'
import { ref, nextTick, watchEffect, computed } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu'
import { showAuthIframe } from 'paskia'
import { resumeWatching } from '@/repositories/WS'
import router from '@/router';
const store = useMainStore()
const ssoStore = useSsoAuthStore()
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
@@ -73,14 +86,42 @@ watchEffect(() => {
const settingsMenu = (e: Event) => {
// show the context menu
const items = []
items.push({ label: 'Change Password', onClick: () => { store.dialog = 'settings' }})
// For external auth, show user name as link to /auth/
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({
label: displayUserName.value || '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 (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 {
items.push({ label: 'Login', onClick: () => store.loginDialog() })
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() })
}
} else if (!ssoStore.isExternalAuth) {
// Show login in paskia iframe overlay
items.push({ label: 'Login', onClick: async () => {
try {
await showAuthIframe('/auth/api/restricted')
resumeWatching()
} catch (e) {
console.log('Login cancelled')
}
}})
}
ContextMenu.showContextMenu({
// @ts-ignore
-101
View File
@@ -1,101 +0,0 @@
<template>
<ModalDialog name="login" title="Authentication required">
<form @submit.prevent="login">
<div class="login-container">
<label for="username">Username:</label>
<input
id="username"
name="username"
autocomplete="username"
spellcheck="false"
autocorrect="off"
required
v-model="loginForm.username"
/>
<label for="password">Password:</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
spellcheck="false"
autocorrect="off"
required
v-model="loginForm.password"
/>
</div>
<h3 class="error-text">
{{ loginForm.error || '\u00A0' }}
</h3>
<div class="dialog-buttons">
<div class="spacer"></div>
<input id="submit" type="submit" value="Login" class="button-login" />
</div>
</form>
</ModalDialog>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import { loginUser } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
const confirmLoading = ref<boolean>(false)
const store = useMainStore()
const loginForm = reactive({
username: '',
password: '',
error: ''
})
const login = async () => {
try {
loginForm.error = ''
confirmLoading.value = true
const msg = await loginUser(loginForm.username, loginForm.password)
store.login(msg.data.username, !!msg.data.privileged)
} catch (error) {
const httpError = error as ISimpleError
loginForm.error = httpError.message || '🛑 Unknown error'
} finally {
confirmLoading.value = false
}
}
</script>
<style scoped>
.login-container {
display: grid;
gap: 1rem;
grid-template-columns: 1fr 2fr;
justify-content: center;
align-items: center;
margin: 1rem 0;
}
.dialog-buttons {
display: flex;
justify-content: space-between;
align-items: center;
}
.button-login {
color: #fff;
background: var(--soft-color);
cursor: pointer;
font-weight: bold;
border: 0;
border-radius: .5rem;
padding: .5rem 2rem;
margin-left: auto;
transition: all var(--transition-time) linear;
}
.button-login:hover, .button-login:focus {
background: var(--accent-color);
box-shadow: 0 0 .3rem #000;
}
.error-text {
color: var(--red-color);
height: 1em;
}
</style>
+206 -31
View File
@@ -13,6 +13,7 @@
<script setup lang="ts">
import { ref, onMounted, watchEffect, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
const dialog = ref<HTMLDialogElement | null>(null)
const store = useMainStore()
@@ -20,6 +21,7 @@ const store = useMainStore()
const close = () => {
dialog.value!.close()
store.dialog = ''
releaseGlobalBackdrop()
}
const props = defineProps<{
@@ -29,6 +31,7 @@ const props = defineProps<{
const show = () => {
store.dialog = props.name
holdGlobalBackdrop()
setTimeout(() => {
dialog.value!.showModal()
nextTick(() => {
@@ -44,47 +47,219 @@ watchEffect(() => {
</script>
<style>
/* Style for the background */
/* ===========================================
DIALOG GLOBAL STYLES
Shared styling for all modal dialogs.
Login page (auth.py) has matching CSS.
=========================================== */
dialog::backdrop {
content: '';
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #0008;
backdrop-filter: blur(0.4em);
z-index: 1000;
display: none;
}
/* Hide the dialog by default */
/* Dialog container */
dialog[open] {
background: #ddd;
color: black;
display: block;
color: #000;
border: none;
font-size: 1.2rem;
border-radius: 0.5rem;
box-shadow: 0.2rem 0.2rem 1rem #000;
padding: 1rem;
box-shadow: 0 0 1rem #0008;
padding: 0;
position: fixed;
top: 0;
left: 0;
z-index: 1001;
}
input {
font: inherit;
}
dialog[open] > h1 {
background: var(--soft-color);
color: #fff;
font-size: 1.2rem;
margin: -1rem -1rem 0 -1rem;
padding: 0.5rem 1rem 0.5rem 1rem;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1100;
max-width: 90vw;
max-height: 90vh;
overflow: auto;
font-size: 1rem;
}
/* Dialog title bar */
dialog[open] > h1 {
background: #146;
color: #fff;
font-size: 1.2rem;
font-weight: normal;
margin: 0;
padding: 0.5rem 1rem;
position: sticky;
top: 0;
}
/* Dialog content area */
dialog[open] > div {
padding: 1em 0;
padding: 1rem;
}
/* Section headings inside dialog */
dialog h3 {
font-size: 1rem;
font-weight: 600;
margin: 1rem 0 0.5rem 0;
}
dialog h3:first-child {
margin-top: 0;
}
/* Form inputs */
dialog input[type="text"],
dialog input[type="password"],
dialog select {
font: inherit;
font-size: 1rem;
padding: 0.5rem;
border: 2px solid #888;
border-radius: 0.25rem;
background: #fff;
color: #000;
min-width: 12rem;
}
dialog input[type="text"]:focus,
dialog input[type="password"]:focus,
dialog select:focus {
outline: none;
border-color: #f80;
}
/* Labels */
dialog label {
font-size: 1rem;
}
/* Buttons */
dialog button,
dialog input[type="submit"],
dialog input[type="reset"],
dialog .button {
font: inherit;
font-size: 1rem;
padding: 0.5rem 1rem;
background: #146;
color: #fff;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
dialog button:hover,
dialog input[type="submit"]:hover,
dialog input[type="reset"]:hover,
dialog .button:hover {
background: #f80;
}
dialog button:disabled,
dialog input[type="submit"]:disabled,
dialog input[type="reset"]:disabled,
dialog .button:disabled {
background: #888;
cursor: not-allowed;
}
/* Small button variant */
dialog .button.small {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
/* Danger button variant */
dialog .button.danger {
background: #c00;
}
dialog .button.danger:hover:not(:disabled) {
background: #f00;
}
/* Form row layout (label + input side by side) */
dialog .form-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
align-items: center;
margin-bottom: 0.5rem;
}
/* Form grid for multiple label+input pairs */
dialog .form-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
align-items: center;
}
/* Dialog button row (footer) */
dialog .dialog-buttons {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
}
/* Error text */
dialog .error-text {
color: #c00;
font-size: 0.875rem;
min-height: 1.2em;
margin: 0.5rem 0;
}
/* Success message */
dialog .success-message {
background: #f80;
color: #000;
padding: 0.5rem;
border-radius: 0.25rem;
margin: 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
/* Data tables inside dialogs */
dialog table {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
font-size: 1rem;
}
dialog th,
dialog td {
border: 1px solid #888;
padding: 0.5rem;
text-align: left;
}
dialog th {
background: #146;
color: #fff;
font-weight: normal;
}
dialog td {
background: #fff;
}
/* Checkbox alignment in tables */
dialog td input[type="checkbox"] {
margin: 0;
}
/* Paragraph text */
dialog p {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
/* Loading state */
dialog .loading {
padding: 2rem;
text-align: center;
color: #666;
}
</style>
+5 -36
View File
@@ -3,8 +3,8 @@
<form>
<template v-if="store.user.isLoggedIn">
<h3>Update your authentication</h3>
<div class="login-container">
<label for="username">New password:</label>
<div class="form-grid">
<label for="passwordChange">New password:</label>
<input
ref="passwordChange"
id="passwordChange"
@@ -26,9 +26,9 @@
v-model="form.password"
/>
</div>
<h3 class="error-text">
<p class="error-text">
{{ form.error || '\u00A0' }}
</h3>
</p>
<div class="dialog-buttons">
<input id="close" type="reset" value="Close" class="button" @click=close />
<div class="spacer"></div>
@@ -92,36 +92,5 @@ const submit = async (ev: Event) => {
</script>
<style scoped>
.login-container {
display: grid;
gap: 1rem;
grid-template-columns: 1fr 2fr;
justify-content: center;
align-items: center;
margin: 1rem 0;
}
.dialog-buttons {
display: flex;
justify-content: space-between;
align-items: center;
}
.button-login {
color: #fff;
background: var(--soft-color);
cursor: pointer;
font-weight: bold;
border: 0;
border-radius: .5rem;
padding: .5rem 2rem;
margin-left: auto;
transition: all var(--transition-time) linear;
}
.button-login:hover, .button-login:focus {
background: var(--accent-color);
box-shadow: 0 0 .3rem #000;
}
.error-text {
color: var(--red-color);
height: 1em;
}
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
</style>
+22 -60
View File
@@ -4,21 +4,25 @@
<div v-else>
<h3>Server Settings</h3>
<div class="form-row">
<input
id="publicServer"
type="checkbox"
v-model="serverSettings.public"
<label for="authMode">Authentication:</label>
<select
id="authMode"
v-model="serverSettings.authentication"
@change="updateServerSettings"
/>
<label for="publicServer">Publicly accessible without any user account.</label>
>
<option value="password">Password (built-in users)</option>
<option value="paskia">Paskia (external SSO)</option>
<option value="none">None (public access)</option>
</select>
</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 class="user-table">
<table>
<thead>
<tr>
<th>Username</th>
@@ -45,7 +49,8 @@
</tr>
</tbody>
</table>
<h3 class="error-text">{{ error || '\u00A0' }}</h3>
</template>
<p class="error-text">{{ error || '\u00A0' }}</p>
<div class="dialog-buttons">
<button @click="close" class="button">Close</button>
</div>
@@ -55,7 +60,7 @@
<script lang="ts" setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
import { listUsers, createUser, updateUser, deleteUser, updateAuthentication, type AuthMode } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
@@ -72,7 +77,7 @@ const error = ref('')
const success = ref('')
const copyButtonText = ref('📋')
const serverSettings = reactive({
public: false
authentication: 'password' as AuthMode
})
const close = () => {
@@ -197,9 +202,9 @@ const updateServerSettings = async () => {
try {
error.value = ''
success.value = ''
await updatePublic(serverSettings.public)
await updateAuthentication(serverSettings.authentication)
// Update store
store.server.public = serverSettings.public
store.server.authentication = serverSettings.authentication
success.value = 'Server settings updated'
} catch (e) {
const httpError = e as ISimpleError
@@ -208,58 +213,15 @@ const updateServerSettings = async () => {
}
onMounted(() => {
serverSettings.public = store.server.public
serverSettings.authentication = store.server.authentication || 'password'
loadUsers()
})
watch(() => store.server.public, (newVal) => {
serverSettings.public = newVal
watch(() => store.server.authentication, (newVal) => {
serverSettings.authentication = newVal || 'password'
})
</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>
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
</style>
+49 -48
View File
@@ -1,71 +1,71 @@
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
// Type for API error responses
interface ApiError {
error: {
code: number
message: string
}
}
function hasError(msg: unknown): msg is ApiError {
return typeof msg === 'object' && msg !== null && 'error' in msg
}
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()
const msg = await apiJson(url, { method: 'GET' })
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
}
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',
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
body: data !== undefined ? JSON.stringify(data) : undefined
})
let msg
try {
msg = await res.json()
const msg = await apiJson(url, {
method: 'POST',
body: data
})
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
}
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()
const msg = await apiJson(url, {
method: 'PUT',
body: data
})
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
}
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()
const msg = await apiJson(url, { method: 'DELETE' })
if (hasError(msg)) throw new SimpleError(msg.error.code, msg.error.message)
return msg
} catch (e) {
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
if (e instanceof AuthCancelledError) {
throw new SimpleError(401, 'Authentication cancelled')
}
throw e
}
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
}
@@ -82,4 +82,5 @@ class SimpleError extends Error implements ISimpleError {
}
}
export { apiFetch }
export default Client
+8 -6
View File
@@ -1,8 +1,8 @@
import Client from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
export const url_login = '/login'
export const url_logout = '/logout'
export const url_password = '/password-change'
export const url_login = '/auth/login'
export const url_logout = '/auth/logout'
export const url_password = '/auth/password-change'
export async function loginUser(username: string, password: string) {
const user = await Client.post(url_login, {
@@ -25,7 +25,7 @@ export async function changePassword(username: string, passwordChange: string, p
return data
}
export const url_users = '/users'
export const url_users = '/auth/users'
export async function listUsers() {
const data = await Client.get(url_users)
@@ -51,7 +51,9 @@ export async function deleteUser(username: string) {
return data
}
export async function updatePublic(publicFlag: boolean) {
const data = await Client.put('/config/public', { public: publicFlag })
export type AuthMode = 'none' | 'paskia' | 'password'
export async function updateAuthentication(mode: AuthMode) {
const data = await Client.put('/api/config/authentication', { authentication: mode })
return data
}
+59 -12
View File
@@ -1,4 +1,6 @@
import { useMainStore } from "@/stores/main"
import { useSsoAuthStore } from "@/stores/ssoAuth"
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
export const controlUrl = '/api/control'
@@ -8,6 +10,8 @@ export const watchUrl = '/api/watch'
let tree = [] as FileEntry[]
let reconnDelay = 500
let wsWatch = null as WebSocket | null
// Track when we're awaiting authentication to prevent reconnection loops
let awaitingAuth = false
export const loadSession = () => {
const s = localStorage['cista-files']
@@ -34,6 +38,35 @@ export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEv
return webSocket
}
// Handle auth error from WebSocket - show paskia iframe and reconnect on success
async function handleWsAuthError(msg: any) {
const iframe = msg.error?.auth?.iframe
if (iframe) {
// Stop reconnection attempts while showing auth dialog
awaitingAuth = true
if (watchTimeout !== null) {
clearTimeout(watchTimeout)
watchTimeout = null
}
try {
await showAuthIframe(iframe)
// Auth succeeded - reconnect
awaitingAuth = false
watchConnect()
} catch (e) {
awaitingAuth = false
if (e instanceof AuthCancelledError) {
console.log('User cancelled authentication')
// User cancelled - don't automatically retry, wait for user action
} else {
console.error('Auth iframe error:', e)
}
}
return true
}
return false
}
export const watchConnect = () => {
if (watchTimeout !== null) {
clearTimeout(watchTimeout)
@@ -51,9 +84,9 @@ export const watchConnect = () => {
if (store.connected) return
const msg = JSON.parse(event.data)
if ('error' in msg) {
if (msg.error.code === 401) {
store.user.isLoggedIn = false
store.dialog = 'login'
if (msg.error.code === 401 || msg.error.code === 403) {
// Show paskia auth iframe (works for both password and paskia modes)
handleWsAuthError(msg)
} else {
store.error = msg.error.message
}
@@ -67,7 +100,11 @@ export const watchConnect = () => {
store.error = ''
if (msg.user) store.login(msg.user.username, msg.user.privileged)
else if (store.isUserLogged) store.logout()
if (!msg.server.public && !msg.user) store.dialog = 'login'
// Start SSO validation polling only in paskia mode
if (msg.server.authentication === 'paskia') {
const ssoStore = useSsoAuthStore()
ssoStore.startValidationPolling()
}
}
})
}
@@ -78,21 +115,31 @@ export const watchDisconnect = () => {
wsWatch = null
}
// Reset auth state and reconnect - call after successful authentication
export const resumeWatching = () => {
awaitingAuth = false
if (watchTimeout !== null) {
clearTimeout(watchTimeout)
watchTimeout = null
}
watchConnect()
}
let watchTimeout: any = null
const watchReconnect = (event: MessageEvent) => {
const store = useMainStore()
// Don't reconnect if we're awaiting authentication or auth iframe is showing
if (awaitingAuth || isAuthIframeOpen()) {
console.log('Skipping reconnect - awaiting authentication')
return
}
if (store.connected) {
console.warn("Disconnected from server", event)
store.connected = false
store.error = 'Reconnecting...'
}
if (watchTimeout !== null) clearTimeout(watchTimeout)
// Don't hammer the server while on login dialog
if (store.dialog === 'login') {
watchTimeout = setTimeout(watchReconnect, 100)
return
}
reconnDelay = Math.min(5000, reconnDelay + 500)
// The server closes the websocket after errors, so we need to reopen it
watchTimeout = setTimeout(watchConnect, reconnDelay)
@@ -152,9 +199,9 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
function handleError(msg: errorEvent) {
const store = useMainStore()
if (msg.error.code === 401) {
store.user.isLoggedIn = false
store.dialog = 'login'
if (msg.error.code === 401 || msg.error.code === 403) {
// Show paskia auth iframe (works for both password and paskia modes)
handleWsAuthError(msg as any)
return
}
}
+4 -7
View File
@@ -3,7 +3,7 @@ import { Doc } from '@/repositories/Document'
import { defineStore, type StateTree } from 'pinia'
import { collator } from '@/utils'
import { logoutUser } from '@/repositories/User'
import { watchConnect } from '@/repositories/WS'
import { watchConnect, resumeWatching } from '@/repositories/WS'
import { shallowRef } from 'vue'
import { sorted, type SortOrder } from '@/utils/docsort'
@@ -17,8 +17,8 @@ export const useMainStore = defineStore({
error: '' as string,
connected: false,
cursor: '' as string,
server: {} as Record<string, any>,
dialog: '' as '' | 'login' | 'settings' | 'usermgmt',
server: {} as Record<string, any> & { authentication?: 'none' | 'paskia' | 'password' },
dialog: '' as '' | 'settings' | 'usermgmt',
uprogress: {} as any,
dprogress: {} as any,
prefs: {
@@ -69,10 +69,7 @@ export const useMainStore = defineStore({
this.user.privileged = privileged
this.user.isLoggedIn = true
this.dialog = ''
if (!this.connected) watchConnect()
},
loginDialog() {
this.dialog = 'login'
if (!this.connected) resumeWatching()
},
async logout() {
console.log("Logout")
+107
View File
@@ -0,0 +1,107 @@
import { defineStore } from 'pinia'
import { ref, 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
export const useSsoAuthStore = defineStore('ssoAuth', () => {
// State
const userName = ref('')
const userUuid = ref('')
// Getters
const isExternalAuth = computed(() => {
const mainStore = useMainStore()
return mainStore.server?.authentication === 'paskia'
})
// 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 = ''
}
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,
}
})
+1 -3
View File
@@ -36,10 +36,8 @@ export default defineConfig({
server: {
proxy: {
"/api": dev_backend,
"/auth": dev_backend,
"/files": dev_backend,
"/login": dev_backend,
"/logout": dev_backend,
"/password-change": dev_backend,
"/zip": dev_backend,
"/preview": dev_backend,
}
+3
View File
@@ -28,6 +28,9 @@ dependencies = [
"av>=15.0.0",
"blake3>=1.0.5",
"docopt>=0.6.2",
"fastapi-vue>=0.5.0",
"html5tagger>=1.3.0",
"httpx>=0.28.0",
"inotify>=0.2.12",
"msgspec>=0.19.0",
"natsort>=8.4.0",