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

@@ -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)

View File

@@ -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

View File

@@ -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)

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.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"}

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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",

View File

@@ -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() {