From f7892dd31b0a0841046d8fc8845680ab7ee24720 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sun, 10 May 2026 02:23:53 +1200 Subject: [PATCH] Admin user editing, knight-rider demos, self-contained user seeds Co-authored-by: Cursor --- README.md | 10 +- src/editor_app/db/models.py | 1 + src/editor_app/main.py | 10 + src/editor_app/routers/users_admin.py | 33 ++- src/editor_app/schemas/users.py | 35 ++- src/editor_app/services/accounts.py | 52 +++- src/editor_app/services/user_workspace.py | 41 ++- src/static/home.html | 318 ++++++++++++++++++++-- src/static/register.html | 29 +- tests/test_auth.py | 83 +++++- tests/test_led_patterns.py | 27 ++ workspace/code/led_patterns.py | 76 ++++++ workspace/code/pattern_chase_demo.py | 75 ++++- workspace/code/pattern_rainbow_demo.py | 35 ++- workspace/code/pattern_twinkle_demo.py | 38 ++- workspace/lib/led_patterns.py | 76 ++++++ 16 files changed, 864 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 1838601..d693349 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Notes: - `data/` is mounted to `/app/data` for the SQLite auth DB. - In container mode, `WORKSPACE_ROOT` and `AUTH_DATABASE_PATH` are set by `docker-compose.yml`. -**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` with an invite link (unless you opt into open signup) 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` with an invite link (unless you opt into open signup) or `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` for the first superuser. Superusers can **GET `/api/users`**, **PATCH `/api/users/{id}`** (username, password reset, admin flag — renames workspace folder when the username changes), or **DELETE `/api/users/{id}`** to manage accounts. New accounts are added only through **invite links** (**`POST /api/users/invites`**) plus self-service registration (`/register?invite=…`). Email invite signup: @@ -128,10 +128,10 @@ Tutorial files: - `LED_TUTORIAL.md` - step-by-step NeoPixel tutorial - `workspace/code/led_tutorial.py` - runnable guided LED example -- `workspace/code/led_patterns.py` - reusable pattern helpers (`rainbow_frame`, `chase_frame`, `twinkle_frame`) -- `workspace/code/pattern_rainbow_demo.py` - rainbow animation demo -- `workspace/code/pattern_chase_demo.py` - chase animation demo -- `workspace/code/pattern_twinkle_demo.py` - twinkle animation demo +- `workspace/code/led_patterns.py` - shared pattern helpers (used by automated tests); each `pattern_*_demo.py` duplicates what it needs and uses only Python stdlib + `machine` / `neopixel` / `time` +- `workspace/code/pattern_rainbow_demo.py` - rainbow animation (self-contained) +- `workspace/code/pattern_chase_demo.py` - Knight Rider–style bouncing scanner (self-contained) +- `workspace/code/pattern_twinkle_demo.py` - twinkle animation (self-contained) - `workspace/code/panel16_utils.py` - helpers for 16x16 serpentine mapping - `workspace/code/panel16_rainbow_wave.py` - 16x16 rainbow wave - `workspace/code/panel16_bounce.py` - 16x16 bouncing pixel with trail diff --git a/src/editor_app/db/models.py b/src/editor_app/db/models.py index 8b185fe..90fad1d 100644 --- a/src/editor_app/db/models.py +++ b/src/editor_app/db/models.py @@ -54,4 +54,5 @@ class InviteToken(Base): consumed_by_user_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True ) + grants_superuser: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[dt.datetime] = mapped_column(DateTime, default=_utc_naive) diff --git a/src/editor_app/main.py b/src/editor_app/main.py index c9c646f..16bae78 100644 --- a/src/editor_app/main.py +++ b/src/editor_app/main.py @@ -3,6 +3,7 @@ from contextlib import asynccontextmanager from fastapi import Depends, FastAPI from fastapi.staticfiles import StaticFiles +from sqlalchemy import text from sqlalchemy.orm import sessionmaker from editor_app.config import STATIC_DIR, WORKSPACE_ROOT @@ -21,6 +22,15 @@ async def lifespan(_app: FastAPI): (WORKSPACE_ROOT / "lib").mkdir(parents=True, exist_ok=True) engine = get_engine() Base.metadata.create_all(bind=engine) + with engine.begin() as conn: + cols = conn.execute(text("PRAGMA table_info(invite_tokens)")).fetchall() + column_names = {row[1] for row in cols} + if column_names and "grants_superuser" not in column_names: + conn.execute( + text( + "ALTER TABLE invite_tokens ADD COLUMN grants_superuser BOOLEAN NOT NULL DEFAULT 0" + ) + ) factory = sessionmaker(autocommit=False, autoflush=False, bind=engine) db = factory() try: diff --git a/src/editor_app/routers/users_admin.py b/src/editor_app/routers/users_admin.py index f99989b..1db54a9 100644 --- a/src/editor_app/routers/users_admin.py +++ b/src/editor_app/routers/users_admin.py @@ -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 InviteCreateRequest, InviteCreateResponse, UserCreateAdmin, UserPublic +from editor_app.schemas.users import InviteCreateRequest, InviteCreateResponse, UserPublic, UserUpdateAdmin from editor_app.services import accounts router = APIRouter(prefix="/api/users", tags=["users"]) @@ -20,21 +20,26 @@ async def list_users( return [UserPublic.model_validate(u) for u in accounts.list_users(db)] -@router.post("", response_model=UserPublic) -async def create_user_admin( - body: UserCreateAdmin, - admin: User = Depends(require_superuser), +@router.patch("/{user_id}", response_model=UserPublic) +async def patch_user_admin( + user_id: int, + body: UserUpdateAdmin, + _admin: User = Depends(require_superuser), db: Session = Depends(get_db), ) -> UserPublic: - if accounts.get_user_by_username(db, body.username): - raise HTTPException(status_code=409, detail="Username already taken") - user = accounts.create_user( - db, - body.username, - body.password, - is_superuser=body.is_superuser, - ) - return UserPublic.model_validate(user) + try: + updated = accounts.update_user( + db, + user_id, + username=body.username, + password=body.password, + is_superuser=body.is_superuser, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if updated is None: + raise HTTPException(status_code=404, detail="User not found") + return UserPublic.model_validate(updated) @router.delete("/{user_id}") diff --git a/src/editor_app/schemas/users.py b/src/editor_app/schemas/users.py index 3450359..20116ed 100644 --- a/src/editor_app/schemas/users.py +++ b/src/editor_app/schemas/users.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator class RegisterRequest(BaseModel): @@ -6,6 +6,14 @@ class RegisterRequest(BaseModel): password: str = Field(min_length=8, max_length=128) invite_token: str | None = Field(default=None, min_length=8, max_length=256) + @field_validator("invite_token", mode="before") + @classmethod + def normalize_invite_token(cls, v: object) -> object: + if isinstance(v, str): + s = v.strip() + return s if s else None + return v + @field_validator("username") @classmethod def username_chars(cls, v: str) -> str: @@ -28,19 +36,34 @@ class UserPublic(BaseModel): model_config = {"from_attributes": True} -class UserCreateAdmin(BaseModel): - username: str = Field(min_length=3, max_length=64) - password: str = Field(min_length=8, max_length=128) - is_superuser: bool = False +class UserUpdateAdmin(BaseModel): + username: str | None = Field(default=None, min_length=3, max_length=64) + password: str | None = Field(default=None, min_length=8, max_length=128) + is_superuser: bool | None = None + + @field_validator("username", "password", mode="before") + @classmethod + def empty_str_to_none(cls, v: object) -> object: + if isinstance(v, str) and not v.strip(): + return None + return v @field_validator("username") @classmethod - def username_chars(cls, v: str) -> str: + def username_chars(cls, v: str | None) -> str | None: + if v is None: + return None s = v.strip() if not s.replace("_", "").isalnum(): raise ValueError("Username may only contain letters, numbers, and underscores") return s + @model_validator(mode="after") + def none_empty_patch(self): + if self.username is None and self.password is None and self.is_superuser is None: + raise ValueError("Provide username, password, and/or superuser changes") + return self + class AuthStatusResponse(BaseModel): auth_enabled: bool diff --git a/src/editor_app/services/accounts.py b/src/editor_app/services/accounts.py index 9a00eb2..e54dd0f 100644 --- a/src/editor_app/services/accounts.py +++ b/src/editor_app/services/accounts.py @@ -88,7 +88,7 @@ def register_user_with_invite(db: Session, username: str, password: str, invite_ 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) + user = create_user(db, username, password, is_superuser=bool(invite.grants_superuser)) invite.used_at = _utc_naive() invite.consumed_by_user_id = user.id db.add(invite) @@ -149,11 +149,59 @@ def delete_user(db: Session, user_id: int) -> bool: return True +def update_user( + db: Session, + user_id: int, + *, + username: str | None = None, + password: str | None = None, + is_superuser: bool | None = None, +) -> User | None: + user = get_user_by_id(db, user_id) + if user is None: + return None + + old_username = user.username + + if username is not None: + normalized = username.strip() + if normalized != old_username: + dup = db.scalars(select(User).where(User.username == normalized, User.id != user_id)).first() + if dup is not None: + raise ValueError("Username already taken") + user_workspace.rename_user_workspace_leaf(user_id, old_username, normalized) + user.username = normalized + + if password is not None: + user.password_hash = hash_password(password) + + if is_superuser is not None: + if user.is_superuser and not is_superuser: + remaining = db.scalar( + select(func.count()) + .select_from(User) + .where(User.is_superuser.is_(True), User.id != user_id) + ) + if not remaining: + raise ValueError("Cannot demote the last administrator") + user.is_superuser = is_superuser + + db.add(user) + db.commit() + db.refresh(user) + return user + + def invite_required() -> bool: return os.environ.get("AUTH_INVITE_ONLY", "true").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: +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( diff --git a/src/editor_app/services/user_workspace.py b/src/editor_app/services/user_workspace.py index 8be2029..3f3b6f7 100644 --- a/src/editor_app/services/user_workspace.py +++ b/src/editor_app/services/user_workspace.py @@ -1,12 +1,20 @@ from __future__ import annotations import re +import shutil from pathlib import Path from editor_app import config DEFAULT_MAIN_PY = 'print("Hello, World!")\n' +# Self-contained demos copied from shipped `workspace/code/` (stdlib + machine/neopixel/time only). +_CANONICAL_DEMO_FILENAMES = ( + "pattern_rainbow_demo.py", + "pattern_twinkle_demo.py", + "pattern_chase_demo.py", +) + def safe_workspace_leaf(username: str, user_id: int) -> str: base = re.sub(r"[^a-zA-Z0-9._-]+", "-", username.strip()).strip("-").lower() or "user" @@ -18,10 +26,41 @@ def user_workspace_root(user_id: int, username: str, workspace_root: Path | None return root / "users" / safe_workspace_leaf(username, user_id) +def _seed_canonical_demos_into_code(code_dir: Path) -> None: + src_root = config.PROJECT_ROOT.resolve() / "workspace" / "code" + for filename in _CANONICAL_DEMO_FILENAMES: + dst = code_dir / filename + if dst.exists(): + continue + src = src_root / filename + if src.is_file(): + dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") + + def ensure_default_code_main(user_root: Path) -> None: - """Ensure code/ exists and add a starter main.py when missing.""" + """Ensure code/ has main.py and self-contained NeoPixel demos (copied from repo workspace/code/).""" code_dir = user_root / "code" code_dir.mkdir(parents=True, exist_ok=True) main_py = code_dir / "main.py" if not main_py.exists(): main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8") + _seed_canonical_demos_into_code(code_dir) + + +def rename_user_workspace_leaf( + user_id: int, old_username: str, new_username: str, workspace_root: Path | None = None +) -> None: + """Rename per-user workspace directory when login name changes.""" + root = (workspace_root or config.WORKSPACE_ROOT).resolve() + users_dir = root / "users" + src = users_dir / safe_workspace_leaf(old_username, user_id) + dst = users_dir / safe_workspace_leaf(new_username, user_id) + if src.resolve() == dst.resolve(): + return + dst.parent.mkdir(parents=True, exist_ok=True) + if dst.exists(): + raise ValueError("Workspace folder for new username already exists; pick another username.") + if src.exists(): + shutil.move(str(src), str(dst)) + else: + ensure_default_code_main(dst) diff --git a/src/static/home.html b/src/static/home.html index 4065d14..576097d 100644 --- a/src/static/home.html +++ b/src/static/home.html @@ -105,6 +105,79 @@ border-radius: 6px; padding: 0.25rem 0.5rem; } + .user-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + align-items: center; + justify-content: flex-end; + } + .btn-danger { + background: transparent; + border-color: #f87171; + color: #fecaca; + } + .btn-danger:hover:not(:disabled) { + background: rgba(248, 113, 113, 0.15); + } + .btn:disabled { + opacity: 0.45; + cursor: not-allowed; + } + #admin-users-feedback:not(:empty) { + padding: 0.5rem 0.65rem; + border-radius: 8px; + font-size: 0.85rem; + margin: 0.5rem 0; + } + #admin-users-feedback.ok { + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(74, 222, 128, 0.35); + color: #bbf7d0; + } + #admin-users-feedback.err { + background: rgba(248, 113, 113, 0.1); + border: 1px solid rgba(248, 113, 113, 0.35); + color: #fecaca; + } + .user-edit-form { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(148, 163, 184, 0.25); + } + .user-edit-form label { + display: block; + margin-bottom: 0.65rem; + } + .user-edit-form input[type='text'], + .user-edit-form input[type='password'] { + width: 100%; + max-width: 320px; + padding: 0.45rem 0.55rem; + border-radius: 8px; + border: 1px solid #64748b; + background: #0f172a; + color: #e2e8f0; + margin-top: 0.25rem; + box-sizing: border-box; + } + .user-edit-form .edit-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.65rem; + } + .user-edit-form .super-line { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: #cbd5e1; + margin-bottom: 0.65rem; + } + .user-edit-form .super-line input { + accent-color: #3b82f6; + } @@ -123,19 +196,48 @@

The key is kept in sessionStorage. You can also use ?api_key=… on the editor URL.

- +