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) ---
|
# --- 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
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user