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:
2026-05-01 21:13:13 +12:00
parent e4c811f51d
commit 7d682cce8d
15 changed files with 683 additions and 71 deletions

View File

@@ -9,10 +9,20 @@
# --- User accounts (SQLite) --- # --- User accounts (SQLite) ---
# AUTH_ENABLED=true # require sign-in for /api/* (except /api/auth/*) # AUTH_ENABLED=true # require sign-in for /api/* (except /api/auth/*)
# AUTH_REGISTER_OPEN=true # allow POST /api/auth/register # 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_DATABASE_PATH=./data/editor.db
# AUTH_SESSION_DAYS=14 # AUTH_SESSION_DAYS=14
# BOOTSTRAP_ADMIN_USERNAME=admin # first-run only: create superuser if DB has zero users # BOOTSTRAP_ADMIN_USERNAME=admin # first-run only: create superuser if DB has zero users
# BOOTSTRAP_ADMIN_PASSWORD=change-me-in-production # 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) # Base URL for `pipenv run test-selenium` (app must be running separately)
# SELENIUM_BASE_URL=http://127.0.0.1:8080 # SELENIUM_BASE_URL=http://127.0.0.1:8080

View File

@@ -1,6 +1,6 @@
# python-editor # 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 ## 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). 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 ## Deploy with Docker
Build and run with Docker Compose: 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. **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. **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`. 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() 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: Tutorial files:

View File

@@ -38,3 +38,20 @@ class AuthSession(Base):
created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive) created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive)
user: Mapped[User] = relationship("User", back_populates="sessions") 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)

View File

@@ -1,12 +1,15 @@
from __future__ import annotations from __future__ import annotations
import os 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 sqlalchemy.orm import Session
from editor_app.db.session import get_db from editor_app.db.session import get_db
from editor_app.db.models import User from editor_app.db.models import User
from editor_app import config
from editor_app.services import accounts from editor_app.services import accounts
@@ -61,3 +64,33 @@ async def require_superuser(
if not user.is_superuser: if not user.is_superuser:
raise HTTPException(status_code=403, detail="Superuser required") raise HTTPException(status_code=403, detail="Superuser required")
return user 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

View File

@@ -33,7 +33,11 @@ def _clear_session_cookie(response: Response, request: Request) -> None:
@router.get("/status", response_model=AuthStatusResponse) @router.get("/status", response_model=AuthStatusResponse)
async def auth_status() -> 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") @router.get("/me")
@@ -57,10 +61,17 @@ async def register(
) -> UserPublic: ) -> UserPublic:
if not accounts.auth_enabled(): if not accounts.auth_enabled():
raise HTTPException(status_code=400, detail="Set AUTH_ENABLED=true to use accounts") 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)") raise HTTPException(status_code=403, detail="Registration is disabled (AUTH_REGISTER_OPEN=false)")
try: 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: except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
return UserPublic.model_validate(user) return UserPublic.model_validate(user)

View File

@@ -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.models import FileContent, FolderOperation, MoveFileRequest
from editor_app.services import filesystem from editor_app.services import filesystem
@@ -7,50 +10,51 @@ router = APIRouter(prefix="/api")
@router.get("/files") @router.get("/files")
async def list_files(path: str = ""): async def list_files(path: str = "", workspace_root: Path = Depends(get_workspace_root)):
files = filesystem.list_files(path) files = filesystem.list_files(path, workspace_root=workspace_root)
return {"files": files} return {"files": files}
@router.get("/workspace/py-sources") @router.get("/workspace/py-sources")
async def workspace_python_sources(): async def workspace_python_sources(workspace_root: Path = Depends(get_workspace_root)):
return {"files": filesystem.collect_python_sources()} return {"files": filesystem.collect_python_sources(workspace_root=workspace_root)}
@router.get("/file/{file_path:path}") @router.get("/file/{file_path:path}")
async def read_file(file_path: str): async def read_file(file_path: str, workspace_root: Path = Depends(get_workspace_root)):
content, filename = filesystem.read_text_file(file_path) content, filename = filesystem.read_text_file(file_path, workspace_root=workspace_root)
return {"content": content, "filename": filename} return {"content": content, "filename": filename}
@router.post("/file/{file_path:path}") @router.post("/file/{file_path:path}")
async def save_file(file_path: str, file_data: FileContent): 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) filename = filesystem.save_text_file(file_path, file_data.content, workspace_root=workspace_root)
return {"message": "File saved successfully", "filename": filename} return {"message": "File saved successfully", "filename": filename}
@router.post("/file-move") @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( new_path, moved_type = filesystem.move_path(
source_path=move_data.source_path, source_path=move_data.source_path,
destination_folder=move_data.destination_folder, destination_folder=move_data.destination_folder,
workspace_root=workspace_root,
) )
return {"message": "Path moved successfully", "new_path": new_path, "moved_type": moved_type} return {"message": "Path moved successfully", "new_path": new_path, "moved_type": moved_type}
@router.delete("/file/{file_path:path}") @router.delete("/file/{file_path:path}")
async def delete_file(file_path: str): async def delete_file(file_path: str, workspace_root: Path = Depends(get_workspace_root)):
filesystem.delete_file(file_path) filesystem.delete_file(file_path, workspace_root=workspace_root)
return {"message": "File deleted successfully"} return {"message": "File deleted successfully"}
@router.post("/folder/new/{folder_path:path}") @router.post("/folder/new/{folder_path:path}")
async def create_folder(folder_path: str, folder_data: FolderOperation): async def create_folder(folder_path: str, folder_data: FolderOperation, workspace_root: Path = Depends(get_workspace_root)):
folder_name = filesystem.create_folder(folder_path) folder_name = filesystem.create_folder(folder_path, workspace_root=workspace_root)
return {"message": "Folder created successfully", "folder": folder_name} return {"message": "Folder created successfully", "folder": folder_name}
@router.delete("/folder/{folder_path:path}") @router.delete("/folder/{folder_path:path}")
async def delete_folder(folder_path: str): async def delete_folder(folder_path: str, workspace_root: Path = Depends(get_workspace_root)):
filesystem.delete_folder(folder_path) filesystem.delete_folder(folder_path, workspace_root=workspace_root)
return {"message": "Folder deleted successfully"} return {"message": "Folder deleted successfully"}

View File

@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from editor_app.db.session import get_db from editor_app.db.session import get_db
from editor_app.db.models import User from editor_app.db.models import User
from editor_app.deps import require_superuser 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 from editor_app.services import accounts
router = APIRouter(prefix="/api/users", tags=["users"]) router = APIRouter(prefix="/api/users", tags=["users"])
@@ -48,3 +48,26 @@ async def delete_user_admin(
if not accounts.delete_user(db, user_id): if not accounts.delete_user(db, user_id):
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
return {"message": "User deleted"} 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)

View File

@@ -4,6 +4,7 @@ from pydantic import BaseModel, Field, field_validator
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=64) username: str = Field(min_length=3, max_length=64)
password: str = Field(min_length=8, max_length=128) 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") @field_validator("username")
@classmethod @classmethod
@@ -44,3 +45,27 @@ class UserCreateAdmin(BaseModel):
class AuthStatusResponse(BaseModel): class AuthStatusResponse(BaseModel):
auth_enabled: bool auth_enabled: bool
register_open: 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

View File

@@ -3,13 +3,15 @@ from __future__ import annotations
import datetime as dt import datetime as dt
import os import os
import secrets import secrets
import smtplib
from email.message import EmailMessage
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import bcrypt import bcrypt
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session 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: if TYPE_CHECKING:
pass pass
@@ -75,6 +77,20 @@ def register_user(db: Session, username: str, password: str) -> User:
return create_user(db, username, password, is_superuser=first) 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: def authenticate(db: Session, username: str, password: str) -> User | None:
user = get_user_by_username(db, username.strip()) user = get_user_by_username(db, username.strip())
if not user or not verify_password(password, user.password_hash): 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.delete(user)
db.commit() db.commit()
return True 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

View File

@@ -10,6 +10,14 @@ LIB_DIR_NAME = "lib"
WRITABLE_ROOTS = {"code"} 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: def normalize_relative_path(relative_path: str) -> str:
cleaned = (relative_path or "").strip().lstrip("/") cleaned = (relative_path or "").strip().lstrip("/")
if not cleaned: if not cleaned:
@@ -22,33 +30,48 @@ def normalize_relative_path(relative_path: str) -> str:
return "/".join(parts) 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) 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: try:
target_path.relative_to(config.WORKSPACE_ROOT.resolve()) target_path.relative_to(root)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail="Path escapes workspace") from exc raise HTTPException(status_code=400, detail="Path escapes workspace") from exc
return target_path return target_path
def _is_path_in_lib(target_path: Path) -> bool: def _is_path_in_lib(target_path: Path, workspace_root: Path | None = None) -> bool:
workspace = config.WORKSPACE_ROOT.resolve() workspace = _workspace_root(workspace_root)
lib_root = (workspace / LIB_DIR_NAME).resolve() lib_root = (workspace / LIB_DIR_NAME).resolve()
shared_lib_root = _shared_lib_root()
try: try:
target_path.resolve().relative_to(lib_root) target_path.resolve().relative_to(lib_root)
return True return True
except ValueError:
pass
try:
target_path.resolve().relative_to(shared_lib_root)
return True
except ValueError: except ValueError:
return False return False
def _ensure_not_lib_path(target_path: Path) -> None: def _ensure_not_lib_path(target_path: Path, workspace_root: Path | None = None) -> None:
if _is_path_in_lib(target_path): if _is_path_in_lib(target_path, workspace_root):
raise HTTPException(status_code=403, detail="lib is read-only") raise HTTPException(status_code=403, detail="lib is read-only")
def _is_writable_path(target_path: Path) -> bool: def _is_writable_path(target_path: Path, workspace_root: Path | None = None) -> bool:
workspace = config.WORKSPACE_ROOT.resolve() workspace = _workspace_root(workspace_root)
resolved = target_path.resolve() resolved = target_path.resolve()
try: try:
relative = resolved.relative_to(workspace) relative = resolved.relative_to(workspace)
@@ -59,17 +82,18 @@ def _is_writable_path(target_path: Path) -> bool:
return relative.parts[0] in WRITABLE_ROOTS return relative.parts[0] in WRITABLE_ROOTS
def _ensure_writable_path(target_path: Path) -> None: def _ensure_writable_path(target_path: Path, workspace_root: Path | None = None) -> None:
if not _is_writable_path(target_path): if not _is_writable_path(target_path, workspace_root):
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
detail="Only code/ is writable (lib is read-only)", 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) 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(): if not target_path.exists() or not target_path.is_dir():
raise HTTPException(status_code=404, detail="Directory not found") 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()): for item in sorted(target_path.iterdir()):
if item.name.startswith("."): if item.name.startswith("."):
continue continue
if not path and item.name == "users":
continue
files.append( files.append(
FileInfo( FileInfo(
name=item.name, 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, 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 return files
def read_text_file(file_path: str) -> tuple[str, str]: def read_text_file(file_path: str, workspace_root: Path | None = None) -> tuple[str, str]:
target_path = resolve_workspace_path(file_path) target_path = resolve_workspace_path(file_path, workspace_root)
if not target_path.exists(): if not target_path.exists():
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
if target_path.is_dir(): if target_path.is_dir():
@@ -100,19 +130,19 @@ def read_text_file(file_path: str) -> tuple[str, str]:
return content, target_path.name return content, target_path.name
def save_text_file(file_path: str, content: str) -> str: def save_text_file(file_path: str, content: str, workspace_root: Path | None = None) -> str:
target_path = resolve_workspace_path(file_path) target_path = resolve_workspace_path(file_path, workspace_root)
_ensure_not_lib_path(target_path) _ensure_not_lib_path(target_path, workspace_root)
_ensure_writable_path(target_path) _ensure_writable_path(target_path, workspace_root)
target_path.parent.mkdir(parents=True, exist_ok=True) target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8") target_path.write_text(content, encoding="utf-8")
return target_path.name return target_path.name
def delete_file(file_path: str) -> None: def delete_file(file_path: str, workspace_root: Path | None = None) -> None:
target_path = resolve_workspace_path(file_path) target_path = resolve_workspace_path(file_path, workspace_root)
_ensure_not_lib_path(target_path) _ensure_not_lib_path(target_path, workspace_root)
_ensure_writable_path(target_path) _ensure_writable_path(target_path, workspace_root)
if not target_path.exists(): if not target_path.exists():
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
if target_path.is_dir(): if target_path.is_dir():
@@ -120,20 +150,21 @@ def delete_file(file_path: str) -> None:
target_path.unlink() target_path.unlink()
def move_path(source_path: str, destination_folder: str) -> tuple[str, str]: def move_path(source_path: str, destination_folder: str, workspace_root: Path | None = None) -> tuple[str, str]:
source = resolve_workspace_path(source_path) root = _workspace_root(workspace_root)
_ensure_not_lib_path(source) source = resolve_workspace_path(source_path, root)
_ensure_writable_path(source) _ensure_not_lib_path(source, root)
_ensure_writable_path(source, root)
if not source.exists(): if not source.exists():
raise HTTPException(status_code=404, detail="Source path not found") raise HTTPException(status_code=404, detail="Source path not found")
destination_dir = ( destination_dir = (
resolve_workspace_path(destination_folder) resolve_workspace_path(destination_folder, root)
if destination_folder if destination_folder
else config.WORKSPACE_ROOT else root
) )
_ensure_not_lib_path(destination_dir) _ensure_not_lib_path(destination_dir, root)
_ensure_writable_path(destination_dir) _ensure_writable_path(destination_dir, root)
if not destination_dir.exists() or not destination_dir.is_dir(): if not destination_dir.exists() or not destination_dir.is_dir():
raise HTTPException(status_code=404, detail="Destination folder not found") 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) destination.parent.mkdir(parents=True, exist_ok=True)
source.rename(destination) source.rename(destination)
moved_type = "folder" if destination.is_dir() else "file" 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: def create_folder(folder_path: str, workspace_root: Path | None = None) -> str:
target_path = resolve_workspace_path(folder_path) target_path = resolve_workspace_path(folder_path, workspace_root)
_ensure_not_lib_path(target_path) _ensure_not_lib_path(target_path, workspace_root)
_ensure_writable_path(target_path) _ensure_writable_path(target_path, workspace_root)
if target_path.exists(): if target_path.exists():
raise HTTPException(status_code=400, detail="Folder already exists") raise HTTPException(status_code=400, detail="Folder already exists")
target_path.mkdir(parents=True, exist_ok=False) target_path.mkdir(parents=True, exist_ok=False)
return target_path.name return target_path.name
def delete_folder(folder_path: str) -> None: def delete_folder(folder_path: str, workspace_root: Path | None = None) -> None:
target_path = resolve_workspace_path(folder_path) target_path = resolve_workspace_path(folder_path, workspace_root)
_ensure_not_lib_path(target_path) _ensure_not_lib_path(target_path, workspace_root)
_ensure_writable_path(target_path) _ensure_writable_path(target_path, workspace_root)
if not target_path.exists(): if not target_path.exists():
raise HTTPException(status_code=404, detail="Folder not found") raise HTTPException(status_code=404, detail="Folder not found")
if not target_path.is_dir(): if not target_path.is_dir():
@@ -181,10 +212,10 @@ def delete_folder(folder_path: str) -> None:
shutil.rmtree(target_path) 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.""" """Return all UTF-8 .py files under the workspace for browser-side Pyodide sync."""
result: dict[str, str] = {} result: dict[str, str] = {}
workspace = config.WORKSPACE_ROOT.resolve() workspace = _workspace_root(workspace_root)
if not workspace.exists(): if not workspace.exists():
return result return result
for path in workspace.rglob("*.py"): 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") result[key] = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError): except (UnicodeDecodeError, OSError):
continue 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 return result

View File

@@ -52,6 +52,59 @@
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; } .nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
.nav span { color: #94a3b8; font-size: 0.9rem; } .nav span { color: #94a3b8; font-size: 0.9rem; }
.hidden { display: none !important; } .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> </style>
</head> </head>
<body> <body>
@@ -70,6 +123,20 @@
<input id="api-key" type="password" autocomplete="off" placeholder="Leave blank if not used" /> <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> <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> </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> <a class="btn btn-primary" href="/editor" id="open-editor">Open Editor</a>
</main> </main>
<script> <script>
@@ -85,11 +152,15 @@
const outEl = document.getElementById('btn-logout'); const outEl = document.getElementById('btn-logout');
const greet = document.getElementById('auth-greeting'); const greet = document.getElementById('auth-greeting');
const optionalKey = document.getElementById('optional-api-key'); const optionalKey = document.getElementById('optional-api-key');
const invitePanel = document.getElementById('invite-panel');
const usersPanel = document.getElementById('users-panel');
if (!status.auth_enabled) { if (!status.auth_enabled) {
loginEl.classList.add('hidden'); loginEl.classList.add('hidden');
regEl.classList.add('hidden'); regEl.classList.add('hidden');
outEl.classList.add('hidden'); outEl.classList.add('hidden');
greet.classList.add('hidden'); greet.classList.add('hidden');
if (invitePanel) invitePanel.classList.add('hidden');
if (usersPanel) usersPanel.classList.add('hidden');
return; return;
} }
loginEl.classList.remove('hidden'); loginEl.classList.remove('hidden');
@@ -105,9 +176,50 @@
regEl.classList.add('hidden'); regEl.classList.add('hidden');
outEl.classList.remove('hidden'); outEl.classList.remove('hidden');
if (optionalKey) optionalKey.classList.add('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 { } else {
outEl.classList.add('hidden'); outEl.classList.add('hidden');
greet.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) {} } 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(); refreshAuthNav();
</script> </script>
</body> </body>

View File

@@ -30,6 +30,7 @@
<span id="save-status" class="save-status"></span> <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 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="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>
<div class="mode-toggle"> <div class="mode-toggle">
<a id="home-btn" class="mode-btn active" href="/">Home</a> <a id="home-btn" class="mode-btn active" href="/">Home</a>
@@ -86,6 +87,6 @@
</div> </div>
</div> </div>
<script type="module" src="/static/script.js?v=22"></script> <script type="module" src="/static/script.js?v=23"></script>
</body> </body>
</html> </html>

View File

@@ -61,11 +61,16 @@
<input id="username" name="username" autocomplete="username" required /> <input id="username" name="username" autocomplete="username" required />
<label for="password">Password</label> <label for="password">Password</label>
<input id="password" type="password" name="password" autocomplete="new-password" required /> <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> <button type="submit" id="submit">Register</button>
</form> </form>
<p><a href="/login">Sign in</a> · <a href="/">Home</a></p> <p><a href="/login">Sign in</a> · <a href="/">Home</a></p>
</main> </main>
<script> <script>
const inviteToken = new URLSearchParams(window.location.search).get("invite") || "";
const inviteInput = document.getElementById("invite-token");
if (inviteInput) inviteInput.value = inviteToken;
(async function checkStatus() { (async function checkStatus() {
try { try {
const r = await fetch("/api/auth/status"); const r = await fetch("/api/auth/status");
@@ -73,9 +78,12 @@
if (!s.auth_enabled) { if (!s.auth_enabled) {
document.getElementById("err").textContent = "Registration is disabled (AUTH_ENABLED is not set)."; document.getElementById("err").textContent = "Registration is disabled (AUTH_ENABLED is not set).";
document.getElementById("form").style.display = "none"; 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("err").textContent = "Public registration is closed. Ask an administrator.";
document.getElementById("form").style.display = "none"; 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) {} } catch (_e) {}
})(); })();
@@ -90,6 +98,7 @@
const body = { const body = {
username: document.getElementById("username").value.trim(), username: document.getElementById("username").value.trim(),
password: document.getElementById("password").value, password: document.getElementById("password").value,
invite_token: inviteToken || null,
}; };
const res = await fetch("/api/auth/register", { const res = await fetch("/api/auth/register", {
method: "POST", method: "POST",

View File

@@ -42,16 +42,22 @@ class TextEditor {
this.ledPanelDismissed = false; this.ledPanelDismissed = false;
this.lastLedFrame = null; this.lastLedFrame = null;
this.ledPanelWindow = null; this.ledPanelWindow = null;
this.workspaceUserId = null;
this.init(); this.init();
} }
init() { init() {
try { 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) { if (fromQuery) {
sessionStorage.setItem('python-editor.api_key', fromQuery); sessionStorage.setItem('python-editor.api_key', fromQuery);
} }
if (workspaceUserId && /^\d+$/.test(workspaceUserId)) {
this.workspaceUserId = workspaceUserId;
}
} catch (_error) { } catch (_error) {
// Ignore query / storage failures. // Ignore query / storage failures.
} }
@@ -61,10 +67,22 @@ class TextEditor {
this.setupDevAutoReload(); this.setupDevAutoReload();
this.updateRunButtonState(); this.updateRunButtonState();
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics'); this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
this.updateWorkspaceBanner();
this.prewarmPyWorker(); this.prewarmPyWorker();
this.loadInitialDirectoryState().then(() => this.restoreSessionTabs()); 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() { setupDevAutoReload() {
const isLocalhost = const isLocalhost =
window.location.hostname === 'localhost' || window.location.hostname === 'localhost' ||
@@ -105,7 +123,19 @@ class TextEditor {
} }
next.headers = headers; next.headers = headers;
next.credentials = 'include'; 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() { disposePyWorker() {

View File

@@ -11,6 +11,8 @@ def _reload_app(tmp_path, monkeypatch, **env):
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path)) monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
monkeypatch.setenv("AUTH_DATABASE_PATH", str(tmp_path / "auth.db")) 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("EDITOR_API_KEY", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False) monkeypatch.delenv("BOOTSTRAP_ADMIN_USERNAME", raising=False)
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", 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: with TestClient(_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="false")) as client:
r = client.get("/api/auth/status") r = client.get("/api/auth/status")
assert r.status_code == 200 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): 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 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): def test_superuser_lists_and_creates_users(tmp_path, monkeypatch):
with TestClient( with TestClient(
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true") _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 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): def test_login_serves_page(client):
r = client.get("/login") r = client.get("/login")
assert r.status_code == 200 assert r.status_code == 200