Add admin invites and user workspace management tools.
Implement invite-token registration with optional email delivery, add admin UI actions for creating invites and opening user workspaces, and support superuser workspace override while preserving per-user code isolation with shared read-only lib. Made-with: Cursor
This commit is contained in:
10
.env.example
10
.env.example
@@ -9,10 +9,20 @@
|
||||
# --- User accounts (SQLite) ---
|
||||
# AUTH_ENABLED=true # require sign-in for /api/* (except /api/auth/*)
|
||||
# AUTH_REGISTER_OPEN=true # allow POST /api/auth/register
|
||||
# AUTH_INVITE_ONLY=false # require invite token for registration
|
||||
# AUTH_DATABASE_PATH=./data/editor.db
|
||||
# AUTH_SESSION_DAYS=14
|
||||
# BOOTSTRAP_ADMIN_USERNAME=admin # first-run only: create superuser if DB has zero users
|
||||
# BOOTSTRAP_ADMIN_PASSWORD=change-me-in-production
|
||||
|
||||
# Optional invite email (used by POST /api/users/invites)
|
||||
# PUBLIC_BASE_URL=http://127.0.0.1:8080
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=mailer@example.com
|
||||
# SMTP_PASSWORD=app-password
|
||||
# SMTP_FROM=Python Editor <mailer@example.com>
|
||||
# SMTP_TLS=true
|
||||
|
||||
# Base URL for `pipenv run test-selenium` (app must be running separately)
|
||||
# SELENIUM_BASE_URL=http://127.0.0.1:8080
|
||||
|
||||
31
README.md
31
README.md
@@ -1,6 +1,6 @@
|
||||
# python-editor
|
||||
|
||||
Browser-based Python editing: **FastAPI** serves static assets, stores workspace files, and optional **API key auth**. **Pyodide** runs your scripts and **Jedi** (inside Pyodide) powers completions — no server-side Python execution or Jedi.
|
||||
Browser-based Python editing: **FastAPI** serves static assets, stores workspace files, and optional **API key auth**. **Pyodide** runs your scripts and **Jedi** (inside Pyodide) powers completions and syntax diagnostics — no server-side Python execution or LSP process.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -48,6 +48,13 @@ If nothing is listening, the smoke test **skips** with a short message instead o
|
||||
|
||||
Open [http://localhost:8080](http://localhost:8080).
|
||||
|
||||
### Editor runtime controls
|
||||
|
||||
- `Run Python` runs the active open `.py` tab.
|
||||
- Enable `Run main.py` to always run `code/main.py` instead.
|
||||
- Pressing `Run Python` while a script is running will stop and restart with the selected target.
|
||||
- `LSP` badge in the header shows in-browser Jedi syntax status (`n/a`, `checking...`, `OK`, or issue count).
|
||||
|
||||
## Deploy with Docker
|
||||
|
||||
Build and run with Docker Compose:
|
||||
@@ -68,6 +75,17 @@ Notes:
|
||||
|
||||
**User accounts** — Set `AUTH_ENABLED=true` in `.env` to require sign-in for workspace APIs. Users live in a SQLite file (`AUTH_DATABASE_PATH`, default `./data/editor.db`). Use `/register` (if `AUTH_REGISTER_OPEN=true`) or `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` for the first superuser. Superusers can **GET/POST/DELETE `/api/users`** to list, create, or remove accounts.
|
||||
|
||||
Email invite signup:
|
||||
|
||||
- Superusers can create invites via `POST /api/users/invites` with `{ "email": "...", "expires_days": 7 }`.
|
||||
- Response includes `invite_url`; if SMTP is configured the invite email is sent automatically.
|
||||
- Set `AUTH_INVITE_ONLY=true` to require invite tokens for all registrations.
|
||||
- Registration page accepts invite links like `/register?invite=<token>`.
|
||||
|
||||
When auth is enabled, file APIs use a per-user workspace under `WORKSPACE_ROOT/users/<username-id>/` for **isolated `code/`**. The `lib/` tree is shared and read-only for all users. When auth is disabled, the shared workspace root is used for everything.
|
||||
|
||||
Admins can open another user's workspace from the home page user management panel (links to `/editor?workspace_user_id=<id>`). Only superusers may use this override.
|
||||
|
||||
**API key** — If `EDITOR_API_KEY` is set, requests may use `Authorization: Bearer …` instead of a session (useful for automation). When `AUTH_ENABLED=true`, a valid session *or* API key is accepted.
|
||||
|
||||
The home page can store the API key in `sessionStorage` when you are not using cookie login, or use `?api_key=` on `/editor`.
|
||||
@@ -95,7 +113,16 @@ np[0] = (255, 0, 0)
|
||||
np.write()
|
||||
```
|
||||
|
||||
`write()` updates the NeoPixel simulator window so you can verify behavior visually.
|
||||
`write()` updates the NeoPixel simulator so you can verify behavior visually.
|
||||
|
||||
Simulator modes:
|
||||
|
||||
- Default: in-app LED strip/panel section under the editor.
|
||||
- `16x16 panel` checkbox: opens a dedicated popup with 16x16 serpentine mapping:
|
||||
- first LED at top-right
|
||||
- first row goes right -> left
|
||||
- rows zig-zag left/right.
|
||||
- The 16x16 popup closes automatically on **Stop** or when script execution finishes.
|
||||
|
||||
Tutorial files:
|
||||
|
||||
|
||||
@@ -38,3 +38,20 @@ class AuthSession(Base):
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive)
|
||||
|
||||
user: Mapped[User] = relationship("User", back_populates="sessions")
|
||||
|
||||
|
||||
class InviteToken(Base):
|
||||
__tablename__ = "invite_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(320), index=True)
|
||||
token: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
expires_at: Mapped[dt.datetime] = mapped_column(DateTime)
|
||||
used_at: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
invited_by_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
consumed_by_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Cookie, Depends, Header, HTTPException
|
||||
from fastapi import Cookie, Depends, Header, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from editor_app.db.session import get_db
|
||||
from editor_app.db.models import User
|
||||
from editor_app import config
|
||||
from editor_app.services import accounts
|
||||
|
||||
|
||||
@@ -61,3 +64,33 @@ async def require_superuser(
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Superuser required")
|
||||
return user
|
||||
|
||||
|
||||
def _safe_workspace_leaf(user: User) -> str:
|
||||
base = re.sub(r"[^a-zA-Z0-9._-]+", "-", user.username).strip("-").lower() or "user"
|
||||
return f"{base}-{user.id}"
|
||||
|
||||
|
||||
def _seed_user_workspace(user_root: Path) -> None:
|
||||
(user_root / "code").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def get_workspace_root(
|
||||
user: User | None = Depends(get_current_user_optional),
|
||||
workspace_user_id: int | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Path:
|
||||
root = config.WORKSPACE_ROOT.resolve()
|
||||
if not accounts.auth_enabled() or user is None:
|
||||
return root
|
||||
target_user = user
|
||||
if workspace_user_id is not None:
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Superuser required for workspace override")
|
||||
lookup = accounts.get_user_by_id(db, int(workspace_user_id))
|
||||
if lookup is None:
|
||||
raise HTTPException(status_code=404, detail="Workspace user not found")
|
||||
target_user = lookup
|
||||
user_root = root / "users" / _safe_workspace_leaf(target_user)
|
||||
_seed_user_workspace(user_root)
|
||||
return user_root
|
||||
|
||||
@@ -33,7 +33,11 @@ def _clear_session_cookie(response: Response, request: Request) -> None:
|
||||
|
||||
@router.get("/status", response_model=AuthStatusResponse)
|
||||
async def auth_status() -> AuthStatusResponse:
|
||||
return AuthStatusResponse(auth_enabled=accounts.auth_enabled(), register_open=accounts.register_open())
|
||||
return AuthStatusResponse(
|
||||
auth_enabled=accounts.auth_enabled(),
|
||||
register_open=accounts.register_open(),
|
||||
invite_required=accounts.invite_required(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@@ -57,10 +61,17 @@ async def register(
|
||||
) -> UserPublic:
|
||||
if not accounts.auth_enabled():
|
||||
raise HTTPException(status_code=400, detail="Set AUTH_ENABLED=true to use accounts")
|
||||
if not accounts.register_open():
|
||||
if not accounts.register_open() and not body.invite_token:
|
||||
raise HTTPException(status_code=403, detail="Registration is disabled (AUTH_REGISTER_OPEN=false)")
|
||||
try:
|
||||
user = accounts.register_user(db, body.username, body.password)
|
||||
if accounts.invite_required():
|
||||
if not body.invite_token:
|
||||
raise HTTPException(status_code=403, detail="Invite token is required")
|
||||
user = accounts.register_user_with_invite(db, body.username, body.password, body.invite_token)
|
||||
elif body.invite_token:
|
||||
user = accounts.register_user_with_invite(db, body.username, body.password, body.invite_token)
|
||||
else:
|
||||
user = accounts.register_user(db, body.username, body.password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return UserPublic.model_validate(user)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from editor_app.deps import get_workspace_root
|
||||
from editor_app.models import FileContent, FolderOperation, MoveFileRequest
|
||||
from editor_app.services import filesystem
|
||||
|
||||
@@ -7,50 +10,51 @@ router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
async def list_files(path: str = ""):
|
||||
files = filesystem.list_files(path)
|
||||
async def list_files(path: str = "", workspace_root: Path = Depends(get_workspace_root)):
|
||||
files = filesystem.list_files(path, workspace_root=workspace_root)
|
||||
return {"files": files}
|
||||
|
||||
|
||||
@router.get("/workspace/py-sources")
|
||||
async def workspace_python_sources():
|
||||
return {"files": filesystem.collect_python_sources()}
|
||||
async def workspace_python_sources(workspace_root: Path = Depends(get_workspace_root)):
|
||||
return {"files": filesystem.collect_python_sources(workspace_root=workspace_root)}
|
||||
|
||||
|
||||
@router.get("/file/{file_path:path}")
|
||||
async def read_file(file_path: str):
|
||||
content, filename = filesystem.read_text_file(file_path)
|
||||
async def read_file(file_path: str, workspace_root: Path = Depends(get_workspace_root)):
|
||||
content, filename = filesystem.read_text_file(file_path, workspace_root=workspace_root)
|
||||
return {"content": content, "filename": filename}
|
||||
|
||||
|
||||
@router.post("/file/{file_path:path}")
|
||||
async def save_file(file_path: str, file_data: FileContent):
|
||||
filename = filesystem.save_text_file(file_path, file_data.content)
|
||||
async def save_file(file_path: str, file_data: FileContent, workspace_root: Path = Depends(get_workspace_root)):
|
||||
filename = filesystem.save_text_file(file_path, file_data.content, workspace_root=workspace_root)
|
||||
return {"message": "File saved successfully", "filename": filename}
|
||||
|
||||
|
||||
@router.post("/file-move")
|
||||
async def move_file(move_data: MoveFileRequest):
|
||||
async def move_file(move_data: MoveFileRequest, workspace_root: Path = Depends(get_workspace_root)):
|
||||
new_path, moved_type = filesystem.move_path(
|
||||
source_path=move_data.source_path,
|
||||
destination_folder=move_data.destination_folder,
|
||||
workspace_root=workspace_root,
|
||||
)
|
||||
return {"message": "Path moved successfully", "new_path": new_path, "moved_type": moved_type}
|
||||
|
||||
|
||||
@router.delete("/file/{file_path:path}")
|
||||
async def delete_file(file_path: str):
|
||||
filesystem.delete_file(file_path)
|
||||
async def delete_file(file_path: str, workspace_root: Path = Depends(get_workspace_root)):
|
||||
filesystem.delete_file(file_path, workspace_root=workspace_root)
|
||||
return {"message": "File deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/folder/new/{folder_path:path}")
|
||||
async def create_folder(folder_path: str, folder_data: FolderOperation):
|
||||
folder_name = filesystem.create_folder(folder_path)
|
||||
async def create_folder(folder_path: str, folder_data: FolderOperation, workspace_root: Path = Depends(get_workspace_root)):
|
||||
folder_name = filesystem.create_folder(folder_path, workspace_root=workspace_root)
|
||||
return {"message": "Folder created successfully", "folder": folder_name}
|
||||
|
||||
|
||||
@router.delete("/folder/{folder_path:path}")
|
||||
async def delete_folder(folder_path: str):
|
||||
filesystem.delete_folder(folder_path)
|
||||
async def delete_folder(folder_path: str, workspace_root: Path = Depends(get_workspace_root)):
|
||||
filesystem.delete_folder(folder_path, workspace_root=workspace_root)
|
||||
return {"message": "Folder deleted successfully"}
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
|
||||
from editor_app.db.session import get_db
|
||||
from editor_app.db.models import User
|
||||
from editor_app.deps import require_superuser
|
||||
from editor_app.schemas.users import UserCreateAdmin, UserPublic
|
||||
from editor_app.schemas.users import InviteCreateRequest, InviteCreateResponse, UserCreateAdmin, UserPublic
|
||||
from editor_app.services import accounts
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
@@ -48,3 +48,26 @@ async def delete_user_admin(
|
||||
if not accounts.delete_user(db, user_id):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return {"message": "User deleted"}
|
||||
|
||||
|
||||
@router.post("/invites", response_model=InviteCreateResponse)
|
||||
async def create_invite_admin(
|
||||
body: InviteCreateRequest,
|
||||
admin: User = Depends(require_superuser),
|
||||
db: Session = Depends(get_db),
|
||||
) -> InviteCreateResponse:
|
||||
email = (body.email or "").strip().lower()
|
||||
invite = accounts.create_invite(
|
||||
db,
|
||||
email,
|
||||
invited_by_user_id=admin.id,
|
||||
expires_days=body.expires_days,
|
||||
)
|
||||
invite_url = accounts.build_invite_url(invite.token)
|
||||
delivered = False
|
||||
if email:
|
||||
try:
|
||||
delivered = accounts.send_invite_email(email, invite_url)
|
||||
except Exception:
|
||||
delivered = False
|
||||
return InviteCreateResponse(email=invite.email, invite_url=invite_url, delivered=delivered)
|
||||
|
||||
@@ -4,6 +4,7 @@ from pydantic import BaseModel, Field, field_validator
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
invite_token: str | None = Field(default=None, min_length=8, max_length=256)
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
@@ -44,3 +45,27 @@ class UserCreateAdmin(BaseModel):
|
||||
class AuthStatusResponse(BaseModel):
|
||||
auth_enabled: bool
|
||||
register_open: bool
|
||||
invite_required: bool = False
|
||||
|
||||
|
||||
class InviteCreateRequest(BaseModel):
|
||||
email: str | None = Field(default=None, max_length=320)
|
||||
expires_days: int = Field(default=7, ge=1, le=30)
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def email_sane(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
s = v.strip().lower()
|
||||
if not s:
|
||||
return None
|
||||
if "@" not in s or "." not in s.split("@")[-1]:
|
||||
raise ValueError("Invalid email address")
|
||||
return s
|
||||
|
||||
|
||||
class InviteCreateResponse(BaseModel):
|
||||
email: str
|
||||
invite_url: str
|
||||
delivered: bool
|
||||
|
||||
@@ -3,13 +3,15 @@ from __future__ import annotations
|
||||
import datetime as dt
|
||||
import os
|
||||
import secrets
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import bcrypt
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from editor_app.db.models import AuthSession, User
|
||||
from editor_app.db.models import AuthSession, InviteToken, User
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
@@ -75,6 +77,20 @@ def register_user(db: Session, username: str, password: str) -> User:
|
||||
return create_user(db, username, password, is_superuser=first)
|
||||
|
||||
|
||||
def register_user_with_invite(db: Session, username: str, password: str, invite_token: str) -> User:
|
||||
invite = get_valid_invite(db, invite_token)
|
||||
if invite is None:
|
||||
raise ValueError("Invite is invalid or expired")
|
||||
if get_user_by_username(db, username):
|
||||
raise ValueError("Username already taken")
|
||||
user = create_user(db, username, password, is_superuser=False)
|
||||
invite.used_at = _utc_naive()
|
||||
invite.consumed_by_user_id = user.id
|
||||
db.add(invite)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def authenticate(db: Session, username: str, password: str) -> User | None:
|
||||
user = get_user_by_username(db, username.strip())
|
||||
if not user or not verify_password(password, user.password_hash):
|
||||
@@ -126,3 +142,68 @@ def delete_user(db: Session, user_id: int) -> bool:
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def invite_required() -> bool:
|
||||
return os.environ.get("AUTH_INVITE_ONLY", "false").strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def create_invite(db: Session, email: str, invited_by_user_id: int | None = None, expires_days: int = 7) -> InviteToken:
|
||||
token = secrets.token_urlsafe(36)
|
||||
expires = _utc_naive() + dt.timedelta(days=max(1, min(30, int(expires_days))))
|
||||
row = InviteToken(
|
||||
email=email.strip().lower(),
|
||||
token=token,
|
||||
expires_at=expires,
|
||||
invited_by_user_id=invited_by_user_id,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def get_valid_invite(db: Session, token: str | None) -> InviteToken | None:
|
||||
if not token:
|
||||
return None
|
||||
row = db.scalars(select(InviteToken).where(InviteToken.token == token.strip())).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
if row.used_at is not None:
|
||||
return None
|
||||
if row.expires_at < _utc_naive():
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
def build_invite_url(token: str) -> str:
|
||||
base = (os.environ.get("PUBLIC_BASE_URL") or "http://127.0.0.1:8080").rstrip("/")
|
||||
return f"{base}/register?invite={token}"
|
||||
|
||||
|
||||
def send_invite_email(email: str, invite_url: str) -> bool:
|
||||
host = (os.environ.get("SMTP_HOST") or "").strip()
|
||||
if not host:
|
||||
return False
|
||||
port = int((os.environ.get("SMTP_PORT") or "587").strip())
|
||||
user = (os.environ.get("SMTP_USER") or "").strip()
|
||||
password = os.environ.get("SMTP_PASSWORD") or ""
|
||||
sender = (os.environ.get("SMTP_FROM") or user or "noreply@python-editor.local").strip()
|
||||
use_tls = (os.environ.get("SMTP_TLS", "true").strip().lower() in ("1", "true", "yes", "on"))
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = "Your Python Editor invite"
|
||||
msg["From"] = sender
|
||||
msg["To"] = email
|
||||
msg.set_content(
|
||||
"You have been invited to Python Editor.\n\n"
|
||||
f"Use this link to sign up:\n{invite_url}\n\n"
|
||||
"If you did not expect this invite, you can ignore this message.\n"
|
||||
)
|
||||
with smtplib.SMTP(host, port, timeout=10) as smtp:
|
||||
if use_tls:
|
||||
smtp.starttls()
|
||||
if user:
|
||||
smtp.login(user, password)
|
||||
smtp.send_message(msg)
|
||||
return True
|
||||
|
||||
@@ -10,6 +10,14 @@ LIB_DIR_NAME = "lib"
|
||||
WRITABLE_ROOTS = {"code"}
|
||||
|
||||
|
||||
def _workspace_root(workspace_root: Path | None = None) -> Path:
|
||||
return (workspace_root or config.WORKSPACE_ROOT).resolve()
|
||||
|
||||
|
||||
def _shared_lib_root() -> Path:
|
||||
return (config.WORKSPACE_ROOT.resolve() / LIB_DIR_NAME).resolve()
|
||||
|
||||
|
||||
def normalize_relative_path(relative_path: str) -> str:
|
||||
cleaned = (relative_path or "").strip().lstrip("/")
|
||||
if not cleaned:
|
||||
@@ -22,33 +30,48 @@ def normalize_relative_path(relative_path: str) -> str:
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def resolve_workspace_path(relative_path: str) -> Path:
|
||||
def resolve_workspace_path(relative_path: str, workspace_root: Path | None = None) -> Path:
|
||||
relative_path = normalize_relative_path(relative_path)
|
||||
target_path = (config.WORKSPACE_ROOT / relative_path).resolve()
|
||||
root = _workspace_root(workspace_root)
|
||||
if relative_path == LIB_DIR_NAME or relative_path.startswith(f"{LIB_DIR_NAME}/"):
|
||||
suffix = relative_path[len(LIB_DIR_NAME) :].lstrip("/")
|
||||
target_path = (_shared_lib_root() / suffix).resolve()
|
||||
try:
|
||||
target_path.relative_to(_shared_lib_root())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Path escapes shared lib") from exc
|
||||
return target_path
|
||||
target_path = (root / relative_path).resolve()
|
||||
try:
|
||||
target_path.relative_to(config.WORKSPACE_ROOT.resolve())
|
||||
target_path.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Path escapes workspace") from exc
|
||||
return target_path
|
||||
|
||||
|
||||
def _is_path_in_lib(target_path: Path) -> bool:
|
||||
workspace = config.WORKSPACE_ROOT.resolve()
|
||||
def _is_path_in_lib(target_path: Path, workspace_root: Path | None = None) -> bool:
|
||||
workspace = _workspace_root(workspace_root)
|
||||
lib_root = (workspace / LIB_DIR_NAME).resolve()
|
||||
shared_lib_root = _shared_lib_root()
|
||||
try:
|
||||
target_path.resolve().relative_to(lib_root)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
target_path.resolve().relative_to(shared_lib_root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_not_lib_path(target_path: Path) -> None:
|
||||
if _is_path_in_lib(target_path):
|
||||
def _ensure_not_lib_path(target_path: Path, workspace_root: Path | None = None) -> None:
|
||||
if _is_path_in_lib(target_path, workspace_root):
|
||||
raise HTTPException(status_code=403, detail="lib is read-only")
|
||||
|
||||
|
||||
def _is_writable_path(target_path: Path) -> bool:
|
||||
workspace = config.WORKSPACE_ROOT.resolve()
|
||||
def _is_writable_path(target_path: Path, workspace_root: Path | None = None) -> bool:
|
||||
workspace = _workspace_root(workspace_root)
|
||||
resolved = target_path.resolve()
|
||||
try:
|
||||
relative = resolved.relative_to(workspace)
|
||||
@@ -59,17 +82,18 @@ def _is_writable_path(target_path: Path) -> bool:
|
||||
return relative.parts[0] in WRITABLE_ROOTS
|
||||
|
||||
|
||||
def _ensure_writable_path(target_path: Path) -> None:
|
||||
if not _is_writable_path(target_path):
|
||||
def _ensure_writable_path(target_path: Path, workspace_root: Path | None = None) -> None:
|
||||
if not _is_writable_path(target_path, workspace_root):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only code/ is writable (lib is read-only)",
|
||||
)
|
||||
|
||||
|
||||
def list_files(path: str = "") -> list[FileInfo]:
|
||||
def list_files(path: str = "", workspace_root: Path | None = None) -> list[FileInfo]:
|
||||
path = normalize_relative_path(path)
|
||||
target_path = config.WORKSPACE_ROOT / path if path else config.WORKSPACE_ROOT
|
||||
root = _workspace_root(workspace_root)
|
||||
target_path = resolve_workspace_path(path, root) if path else root
|
||||
if not target_path.exists() or not target_path.is_dir():
|
||||
raise HTTPException(status_code=404, detail="Directory not found")
|
||||
|
||||
@@ -77,6 +101,8 @@ def list_files(path: str = "") -> list[FileInfo]:
|
||||
for item in sorted(target_path.iterdir()):
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
if not path and item.name == "users":
|
||||
continue
|
||||
files.append(
|
||||
FileInfo(
|
||||
name=item.name,
|
||||
@@ -84,11 +110,15 @@ def list_files(path: str = "") -> list[FileInfo]:
|
||||
size=item.stat().st_size if item.is_file() else None,
|
||||
)
|
||||
)
|
||||
if not path:
|
||||
shared_lib = _shared_lib_root()
|
||||
if shared_lib.exists() and not any(f.name == LIB_DIR_NAME for f in files):
|
||||
files.append(FileInfo(name=LIB_DIR_NAME, is_directory=True, size=None))
|
||||
return files
|
||||
|
||||
|
||||
def read_text_file(file_path: str) -> tuple[str, str]:
|
||||
target_path = resolve_workspace_path(file_path)
|
||||
def read_text_file(file_path: str, workspace_root: Path | None = None) -> tuple[str, str]:
|
||||
target_path = resolve_workspace_path(file_path, workspace_root)
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if target_path.is_dir():
|
||||
@@ -100,19 +130,19 @@ def read_text_file(file_path: str) -> tuple[str, str]:
|
||||
return content, target_path.name
|
||||
|
||||
|
||||
def save_text_file(file_path: str, content: str) -> str:
|
||||
target_path = resolve_workspace_path(file_path)
|
||||
_ensure_not_lib_path(target_path)
|
||||
_ensure_writable_path(target_path)
|
||||
def save_text_file(file_path: str, content: str, workspace_root: Path | None = None) -> str:
|
||||
target_path = resolve_workspace_path(file_path, workspace_root)
|
||||
_ensure_not_lib_path(target_path, workspace_root)
|
||||
_ensure_writable_path(target_path, workspace_root)
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
return target_path.name
|
||||
|
||||
|
||||
def delete_file(file_path: str) -> None:
|
||||
target_path = resolve_workspace_path(file_path)
|
||||
_ensure_not_lib_path(target_path)
|
||||
_ensure_writable_path(target_path)
|
||||
def delete_file(file_path: str, workspace_root: Path | None = None) -> None:
|
||||
target_path = resolve_workspace_path(file_path, workspace_root)
|
||||
_ensure_not_lib_path(target_path, workspace_root)
|
||||
_ensure_writable_path(target_path, workspace_root)
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if target_path.is_dir():
|
||||
@@ -120,20 +150,21 @@ def delete_file(file_path: str) -> None:
|
||||
target_path.unlink()
|
||||
|
||||
|
||||
def move_path(source_path: str, destination_folder: str) -> tuple[str, str]:
|
||||
source = resolve_workspace_path(source_path)
|
||||
_ensure_not_lib_path(source)
|
||||
_ensure_writable_path(source)
|
||||
def move_path(source_path: str, destination_folder: str, workspace_root: Path | None = None) -> tuple[str, str]:
|
||||
root = _workspace_root(workspace_root)
|
||||
source = resolve_workspace_path(source_path, root)
|
||||
_ensure_not_lib_path(source, root)
|
||||
_ensure_writable_path(source, root)
|
||||
if not source.exists():
|
||||
raise HTTPException(status_code=404, detail="Source path not found")
|
||||
|
||||
destination_dir = (
|
||||
resolve_workspace_path(destination_folder)
|
||||
resolve_workspace_path(destination_folder, root)
|
||||
if destination_folder
|
||||
else config.WORKSPACE_ROOT
|
||||
else root
|
||||
)
|
||||
_ensure_not_lib_path(destination_dir)
|
||||
_ensure_writable_path(destination_dir)
|
||||
_ensure_not_lib_path(destination_dir, root)
|
||||
_ensure_writable_path(destination_dir, root)
|
||||
if not destination_dir.exists() or not destination_dir.is_dir():
|
||||
raise HTTPException(status_code=404, detail="Destination folder not found")
|
||||
|
||||
@@ -157,23 +188,23 @@ def move_path(source_path: str, destination_folder: str) -> tuple[str, str]:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
source.rename(destination)
|
||||
moved_type = "folder" if destination.is_dir() else "file"
|
||||
return str(destination.relative_to(config.WORKSPACE_ROOT)), moved_type
|
||||
return str(destination.relative_to(root)), moved_type
|
||||
|
||||
|
||||
def create_folder(folder_path: str) -> str:
|
||||
target_path = resolve_workspace_path(folder_path)
|
||||
_ensure_not_lib_path(target_path)
|
||||
_ensure_writable_path(target_path)
|
||||
def create_folder(folder_path: str, workspace_root: Path | None = None) -> str:
|
||||
target_path = resolve_workspace_path(folder_path, workspace_root)
|
||||
_ensure_not_lib_path(target_path, workspace_root)
|
||||
_ensure_writable_path(target_path, workspace_root)
|
||||
if target_path.exists():
|
||||
raise HTTPException(status_code=400, detail="Folder already exists")
|
||||
target_path.mkdir(parents=True, exist_ok=False)
|
||||
return target_path.name
|
||||
|
||||
|
||||
def delete_folder(folder_path: str) -> None:
|
||||
target_path = resolve_workspace_path(folder_path)
|
||||
_ensure_not_lib_path(target_path)
|
||||
_ensure_writable_path(target_path)
|
||||
def delete_folder(folder_path: str, workspace_root: Path | None = None) -> None:
|
||||
target_path = resolve_workspace_path(folder_path, workspace_root)
|
||||
_ensure_not_lib_path(target_path, workspace_root)
|
||||
_ensure_writable_path(target_path, workspace_root)
|
||||
if not target_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
if not target_path.is_dir():
|
||||
@@ -181,10 +212,10 @@ def delete_folder(folder_path: str) -> None:
|
||||
shutil.rmtree(target_path)
|
||||
|
||||
|
||||
def collect_python_sources() -> dict[str, str]:
|
||||
def collect_python_sources(workspace_root: Path | None = None) -> dict[str, str]:
|
||||
"""Return all UTF-8 .py files under the workspace for browser-side Pyodide sync."""
|
||||
result: dict[str, str] = {}
|
||||
workspace = config.WORKSPACE_ROOT.resolve()
|
||||
workspace = _workspace_root(workspace_root)
|
||||
if not workspace.exists():
|
||||
return result
|
||||
for path in workspace.rglob("*.py"):
|
||||
@@ -199,4 +230,18 @@ def collect_python_sources() -> dict[str, str]:
|
||||
result[key] = path.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
shared_lib = _shared_lib_root()
|
||||
if shared_lib.exists() and shared_lib.is_dir() and shared_lib != (workspace / LIB_DIR_NAME).resolve():
|
||||
for path in shared_lib.rglob("*.py"):
|
||||
try:
|
||||
rel = path.relative_to(shared_lib)
|
||||
except ValueError:
|
||||
continue
|
||||
if any(part.startswith(".") for part in rel.parts):
|
||||
continue
|
||||
try:
|
||||
key = str(Path(LIB_DIR_NAME) / rel).replace("\\", "/")
|
||||
result[key] = path.read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue
|
||||
return result
|
||||
|
||||
@@ -52,6 +52,59 @@
|
||||
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
|
||||
.nav span { color: #94a3b8; font-size: 0.9rem; }
|
||||
.hidden { display: none !important; }
|
||||
.invite-panel {
|
||||
margin: 1rem 0;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 10px;
|
||||
background: rgba(30, 41, 59, 0.45);
|
||||
}
|
||||
.invite-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.invite-row input[type="email"] {
|
||||
flex: 1 1 260px;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
.invite-row button {
|
||||
margin: 0;
|
||||
}
|
||||
.invite-result {
|
||||
margin-top: 0.55rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
word-break: break-all;
|
||||
}
|
||||
.users-panel {
|
||||
margin: 1rem 0;
|
||||
padding: 0.9rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 10px;
|
||||
background: rgba(30, 41, 59, 0.45);
|
||||
}
|
||||
.users-list {
|
||||
margin-top: 0.5rem;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.user-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.user-row a {
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(147, 197, 253, 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -70,6 +123,20 @@
|
||||
<input id="api-key" type="password" autocomplete="off" placeholder="Leave blank if not used" />
|
||||
<p class="note">The key is kept in <code>sessionStorage</code>. You can also use <code>?api_key=…</code> on the editor URL.</p>
|
||||
</div>
|
||||
<section id="invite-panel" class="invite-panel hidden">
|
||||
<strong>Admin invites</strong>
|
||||
<p class="note" style="margin-top:0.4rem">Create an email invite link for signup.</p>
|
||||
<div class="invite-row">
|
||||
<input id="invite-email" type="email" placeholder="new.user@example.com" autocomplete="email" />
|
||||
<button type="button" class="btn btn-primary" id="invite-create-btn">Create invite</button>
|
||||
<button type="button" class="btn btn-ghost" id="invite-link-btn">Create link only</button>
|
||||
</div>
|
||||
<div id="invite-result" class="invite-result"></div>
|
||||
</section>
|
||||
<section id="users-panel" class="users-panel hidden">
|
||||
<strong>User management</strong>
|
||||
<div id="users-list" class="users-list"></div>
|
||||
</section>
|
||||
<a class="btn btn-primary" href="/editor" id="open-editor">Open Editor</a>
|
||||
</main>
|
||||
<script>
|
||||
@@ -85,11 +152,15 @@
|
||||
const outEl = document.getElementById('btn-logout');
|
||||
const greet = document.getElementById('auth-greeting');
|
||||
const optionalKey = document.getElementById('optional-api-key');
|
||||
const invitePanel = document.getElementById('invite-panel');
|
||||
const usersPanel = document.getElementById('users-panel');
|
||||
if (!status.auth_enabled) {
|
||||
loginEl.classList.add('hidden');
|
||||
regEl.classList.add('hidden');
|
||||
outEl.classList.add('hidden');
|
||||
greet.classList.add('hidden');
|
||||
if (invitePanel) invitePanel.classList.add('hidden');
|
||||
if (usersPanel) usersPanel.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
loginEl.classList.remove('hidden');
|
||||
@@ -105,9 +176,50 @@
|
||||
regEl.classList.add('hidden');
|
||||
outEl.classList.remove('hidden');
|
||||
if (optionalKey) optionalKey.classList.add('hidden');
|
||||
if (invitePanel) {
|
||||
if (j.user && j.user.is_superuser) {
|
||||
invitePanel.classList.remove('hidden');
|
||||
if (usersPanel) usersPanel.classList.remove('hidden');
|
||||
await refreshUsersList();
|
||||
} else {
|
||||
invitePanel.classList.add('hidden');
|
||||
if (usersPanel) usersPanel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
outEl.classList.add('hidden');
|
||||
greet.classList.add('hidden');
|
||||
if (invitePanel) invitePanel.classList.add('hidden');
|
||||
if (usersPanel) usersPanel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUsersList() {
|
||||
const usersList = document.getElementById('users-list');
|
||||
if (!usersList) return;
|
||||
usersList.textContent = 'Loading users...';
|
||||
try {
|
||||
const res = await fetch('/api/users', { credentials: 'include' });
|
||||
const users = await res.json().catch(() => []);
|
||||
if (!res.ok) {
|
||||
usersList.textContent = 'Unable to load users.';
|
||||
return;
|
||||
}
|
||||
usersList.innerHTML = '';
|
||||
for (const user of users) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'user-row';
|
||||
const name = document.createElement('span');
|
||||
name.textContent = `${user.username}${user.is_superuser ? ' (admin)' : ''}`;
|
||||
const link = document.createElement('a');
|
||||
link.href = `/editor?workspace_user_id=${encodeURIComponent(String(user.id))}`;
|
||||
link.textContent = 'Open workspace';
|
||||
row.appendChild(name);
|
||||
row.appendChild(link);
|
||||
usersList.appendChild(row);
|
||||
}
|
||||
} catch (_err) {
|
||||
usersList.textContent = 'Unable to load users.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +249,54 @@
|
||||
}
|
||||
} catch (_e) {}
|
||||
});
|
||||
|
||||
document.getElementById('invite-create-btn').addEventListener('click', async () => {
|
||||
const emailInput = document.getElementById('invite-email');
|
||||
const result = document.getElementById('invite-result');
|
||||
const email = (emailInput.value || '').trim();
|
||||
result.textContent = '';
|
||||
if (!email) {
|
||||
result.textContent = 'Enter an email address first.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/users/invites', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, expires_days: 7 })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
result.textContent = body.detail || res.statusText || 'Failed to create invite';
|
||||
return;
|
||||
}
|
||||
const delivered = body.delivered ? 'Email sent.' : 'Email not sent (SMTP not configured), copy link manually.';
|
||||
result.innerHTML = `${delivered}<br><a href="${body.invite_url}" style="color:#93c5fd">${body.invite_url}</a>`;
|
||||
} catch (err) {
|
||||
result.textContent = String(err.message || err);
|
||||
}
|
||||
});
|
||||
document.getElementById('invite-link-btn').addEventListener('click', async () => {
|
||||
const result = document.getElementById('invite-result');
|
||||
result.textContent = '';
|
||||
try {
|
||||
const res = await fetch('/api/users/invites', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: null, expires_days: 7 })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
result.textContent = body.detail || res.statusText || 'Failed to create invite link';
|
||||
return;
|
||||
}
|
||||
result.innerHTML = `Invite link created.<br><a href="${body.invite_url}" style="color:#93c5fd">${body.invite_url}</a>`;
|
||||
} catch (err) {
|
||||
result.textContent = String(err.message || err);
|
||||
}
|
||||
});
|
||||
refreshAuthNav();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<span id="save-status" class="save-status"></span>
|
||||
<span class="runtime-hint" title="Python runs locally in your browser via Pyodide; completions use Jedi in the same runtime.">Browser · Pyodide</span>
|
||||
<span id="lsp-status" class="runtime-hint" title="Jedi in-browser diagnostics">LSP: n/a</span>
|
||||
<span id="workspace-badge" class="runtime-hint hidden"></span>
|
||||
</div>
|
||||
<div class="mode-toggle">
|
||||
<a id="home-btn" class="mode-btn active" href="/">Home</a>
|
||||
@@ -86,6 +87,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/static/script.js?v=22"></script>
|
||||
<script type="module" src="/static/script.js?v=23"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -61,11 +61,16 @@
|
||||
<input id="username" name="username" autocomplete="username" required />
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" name="password" autocomplete="new-password" required />
|
||||
<input id="invite-token" type="hidden" name="invite_token" />
|
||||
<button type="submit" id="submit">Register</button>
|
||||
</form>
|
||||
<p><a href="/login">Sign in</a> · <a href="/">Home</a></p>
|
||||
</main>
|
||||
<script>
|
||||
const inviteToken = new URLSearchParams(window.location.search).get("invite") || "";
|
||||
const inviteInput = document.getElementById("invite-token");
|
||||
if (inviteInput) inviteInput.value = inviteToken;
|
||||
|
||||
(async function checkStatus() {
|
||||
try {
|
||||
const r = await fetch("/api/auth/status");
|
||||
@@ -73,9 +78,12 @@
|
||||
if (!s.auth_enabled) {
|
||||
document.getElementById("err").textContent = "Registration is disabled (AUTH_ENABLED is not set).";
|
||||
document.getElementById("form").style.display = "none";
|
||||
} else if (!s.register_open) {
|
||||
} else if (!s.register_open && !inviteToken) {
|
||||
document.getElementById("err").textContent = "Public registration is closed. Ask an administrator.";
|
||||
document.getElementById("form").style.display = "none";
|
||||
} else if (s.invite_required && !inviteToken) {
|
||||
document.getElementById("err").textContent = "This server requires an invite link to register.";
|
||||
document.getElementById("form").style.display = "none";
|
||||
}
|
||||
} catch (_e) {}
|
||||
})();
|
||||
@@ -90,6 +98,7 @@
|
||||
const body = {
|
||||
username: document.getElementById("username").value.trim(),
|
||||
password: document.getElementById("password").value,
|
||||
invite_token: inviteToken || null,
|
||||
};
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
|
||||
@@ -42,16 +42,22 @@ class TextEditor {
|
||||
this.ledPanelDismissed = false;
|
||||
this.lastLedFrame = null;
|
||||
this.ledPanelWindow = null;
|
||||
this.workspaceUserId = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
try {
|
||||
const fromQuery = new URLSearchParams(window.location.search).get('api_key');
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const fromQuery = params.get('api_key');
|
||||
const workspaceUserId = params.get('workspace_user_id');
|
||||
if (fromQuery) {
|
||||
sessionStorage.setItem('python-editor.api_key', fromQuery);
|
||||
}
|
||||
if (workspaceUserId && /^\d+$/.test(workspaceUserId)) {
|
||||
this.workspaceUserId = workspaceUserId;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore query / storage failures.
|
||||
}
|
||||
@@ -61,10 +67,22 @@ class TextEditor {
|
||||
this.setupDevAutoReload();
|
||||
this.updateRunButtonState();
|
||||
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
|
||||
this.updateWorkspaceBanner();
|
||||
this.prewarmPyWorker();
|
||||
this.loadInitialDirectoryState().then(() => this.restoreSessionTabs());
|
||||
}
|
||||
|
||||
updateWorkspaceBanner() {
|
||||
const badge = document.getElementById('workspace-badge');
|
||||
if (!badge) return;
|
||||
if (this.workspaceUserId) {
|
||||
badge.textContent = `Workspace: user ${this.workspaceUserId}`;
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
setupDevAutoReload() {
|
||||
const isLocalhost =
|
||||
window.location.hostname === 'localhost' ||
|
||||
@@ -105,7 +123,19 @@ class TextEditor {
|
||||
}
|
||||
next.headers = headers;
|
||||
next.credentials = 'include';
|
||||
return fetch(url, next);
|
||||
let finalUrl = url;
|
||||
if (this.workspaceUserId && typeof url === 'string' && url.startsWith('/api/')) {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
if (!parsed.searchParams.has('workspace_user_id')) {
|
||||
parsed.searchParams.set('workspace_user_id', this.workspaceUserId);
|
||||
}
|
||||
finalUrl = parsed.pathname + parsed.search;
|
||||
} catch (_error) {
|
||||
// ignore URL parse failure and use original
|
||||
}
|
||||
}
|
||||
return fetch(finalUrl, next);
|
||||
}
|
||||
|
||||
disposePyWorker() {
|
||||
|
||||
@@ -11,6 +11,8 @@ def _reload_app(tmp_path, monkeypatch, **env):
|
||||
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db"))
|
||||
monkeypatch.setenv("AUTH_REGISTER_OPEN", "true")
|
||||
monkeypatch.setenv("AUTH_INVITE_ONLY", "false")
|
||||
monkeypatch.delenv("EDITOR_API_KEY", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
|
||||
@@ -26,7 +28,7 @@ def test_auth_status_public(tmp_path, monkeypatch):
|
||||
with TestClient(_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="false")) as client:
|
||||
r = client.get("/api/auth/status")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"auth_enabled": False, "register_open": True}
|
||||
assert r.json() == {"auth_enabled": False, "register_open": True, "invite_required": False}
|
||||
|
||||
|
||||
def test_register_login_and_api_access(tmp_path, monkeypatch):
|
||||
@@ -85,6 +87,53 @@ def test_register_closed(tmp_path, monkeypatch):
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_admin_can_create_invite_and_register_with_token(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "admin", "password": "password99", "invite_token": None})
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
|
||||
invite = client.post("/api/users/invites", json={"email": "newuser@example.com", "expires_days": 7})
|
||||
assert invite.status_code == 200
|
||||
invite_url = invite.json()["invite_url"]
|
||||
token = invite_url.split("invite=", 1)[1]
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
reg = client.post(
|
||||
"/api/auth/register",
|
||||
json={"username": "newuser", "password": "password99", "invite_token": token},
|
||||
)
|
||||
assert reg.status_code == 200
|
||||
assert reg.json()["username"] == "newuser"
|
||||
|
||||
|
||||
def test_admin_can_create_link_only_invite(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "admin", "password": "password99", "invite_token": None})
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
|
||||
invite = client.post("/api/users/invites", json={"email": None, "expires_days": 7})
|
||||
assert invite.status_code == 200
|
||||
body = invite.json()
|
||||
assert body["invite_url"].startswith("http://")
|
||||
assert body["delivered"] is False
|
||||
|
||||
|
||||
def test_invite_required_blocks_public_register(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
AUTH_ENABLED="true",
|
||||
AUTH_REGISTER_OPEN="true",
|
||||
AUTH_INVITE_ONLY="true",
|
||||
)
|
||||
) as client:
|
||||
blocked = client.post("/api/auth/register", json={"username": "plain", "password": "password99"})
|
||||
assert blocked.status_code == 403
|
||||
|
||||
|
||||
def test_superuser_lists_and_creates_users(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
@@ -121,6 +170,93 @@ def test_non_superuser_cannot_list_users(tmp_path, monkeypatch):
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_users_have_isolated_workspaces(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "alice", "password": "password99"})
|
||||
save = client.post("/api/file/code/only_alice.py", json={"content": "owner = 'alice'\n"})
|
||||
assert save.status_code == 200
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "bob", "password": "password99"})
|
||||
missing = client.get("/api/file/code/only_alice.py")
|
||||
assert missing.status_code == 404
|
||||
listing = client.get("/api/files", params={"path": "code"})
|
||||
assert listing.status_code == 200
|
||||
names = {item["name"] for item in listing.json()["files"]}
|
||||
assert "only_alice.py" not in names
|
||||
|
||||
|
||||
def test_lib_is_shared_read_only_across_users(tmp_path, monkeypatch):
|
||||
shared_lib = tmp_path / "lib"
|
||||
shared_lib.mkdir(parents=True, exist_ok=True)
|
||||
(shared_lib / "shared.py").write_text("VALUE = 42\n", encoding="utf-8")
|
||||
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "alice", "password": "password99"})
|
||||
lib_read = client.get("/api/file/lib/shared.py")
|
||||
assert lib_read.status_code == 200
|
||||
assert "VALUE = 42" in lib_read.json()["content"]
|
||||
lib_write = client.post("/api/file/lib/shared.py", json={"content": "VALUE = 0\n"})
|
||||
assert lib_write.status_code == 403
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "bob", "password": "password99"})
|
||||
lib_read_bob = client.get("/api/file/lib/shared.py")
|
||||
assert lib_read_bob.status_code == 200
|
||||
assert "VALUE = 42" in lib_read_bob.json()["content"]
|
||||
|
||||
|
||||
def test_superuser_can_open_other_user_workspace(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "admin", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "bob", "password": "password99"})
|
||||
me = client.get("/api/auth/me").json()
|
||||
bob_id = me["user"]["id"]
|
||||
client.post("/api/file/code/bob_only.py", json={"content": "owner='bob'\n"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "admin", "password": "password99"})
|
||||
as_bob = client.get("/api/file/code/bob_only.py", params={"workspace_user_id": bob_id})
|
||||
assert as_bob.status_code == 200
|
||||
assert "owner='bob'" in as_bob.json()["content"]
|
||||
|
||||
|
||||
def test_non_admin_cannot_override_workspace_user(tmp_path, monkeypatch):
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
) as client:
|
||||
client.post("/api/auth/register", json={"username": "admin", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "alice", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
client.post("/api/auth/register", json={"username": "bob", "password": "password99"})
|
||||
client.post("/api/auth/logout")
|
||||
|
||||
client.post("/api/auth/login", json={"username": "alice", "password": "password99"})
|
||||
denied = client.get("/api/files", params={"workspace_user_id": 1})
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_login_serves_page(client):
|
||||
r = client.get("/login")
|
||||
assert r.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user