Finalize Paskia integration and built-in authentication.
This commit is contained in:
+8
-23
@@ -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
@@ -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})
|
||||
|
||||
+13
-5
@@ -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,9 +94,6 @@ 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)
|
||||
|
||||
bp.static(
|
||||
|
||||
+17
-41
@@ -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 validation against auth backend
|
||||
sso = _get_sso()
|
||||
perm = "cista:login cista:admin" if privileged else "cista:login"
|
||||
if sso.paskia_enabled():
|
||||
# SSO validation against auth backend
|
||||
# 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
@@ -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
@@ -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())}",
|
||||
}
|
||||
|
||||
+153
-56
@@ -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,41 +171,49 @@ 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 [
|
||||
resp_hop_by_hop = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
]:
|
||||
resp_headers.pop(hop_header, None)
|
||||
}
|
||||
|
||||
resp_headers = [
|
||||
(key, value)
|
||||
for key, value in response.headers.multi_items()
|
||||
if key.lower() not in resp_hop_by_hop
|
||||
]
|
||||
|
||||
from sanic import raw as raw_response
|
||||
|
||||
return raw_response(
|
||||
response.content,
|
||||
raw_content,
|
||||
status=response.status_code,
|
||||
headers=resp_headers,
|
||||
content_type=response.headers.get("content-type", "application/json"),
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
<template>
|
||||
<dialog v-if="store.dialog === name" ref="dialog" :id=props.name @keydown.escape=close>
|
||||
<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>
|
||||
<div class="modal-content">
|
||||
<slot>
|
||||
Dialog with no content
|
||||
<button @click=close>OK</button>
|
||||
<button @click="close">OK</button>
|
||||
</slot>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</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')
|
||||
overlay.value?.focus()
|
||||
const input = dialog.value?.querySelector('input')
|
||||
if (input) input.focus()
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -4,18 +4,21 @@
|
||||
<div v-else>
|
||||
<h3>Server Settings</h3>
|
||||
<div class="form-row">
|
||||
<label for="authMode">Authentication:</label>
|
||||
<select
|
||||
id="authMode"
|
||||
v-model="serverSettings.authentication"
|
||||
<label for="publicAccess">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="publicAccess"
|
||||
v-model="serverSettings.public"
|
||||
@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>
|
||||
/>
|
||||
Public access (anyone can read and write)
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="serverSettings.authentication === 'password'">
|
||||
<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)">
|
||||
@@ -50,7 +53,6 @@
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user