diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py
new file mode 100644
index 0000000..bcc4724
--- /dev/null
+++ b/backend/app/api/routes/admin.py
@@ -0,0 +1,241 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.dependencies import require_admin
+from app.core.security import hash_password
+from app.database.session import get_db
+from app.models.project import Project
+from app.models.user import User
+from app.schemas.admin import (
+ AdminProjectResponse,
+ AdminSetupRequest,
+ AdminUserResponse,
+ AdminUserUpdateRequest,
+)
+from app.utils.slug import is_valid_username
+
+router = APIRouter()
+
+
+# ── Setup ─────────────────────────────────────────────────────────────────────
+
+@router.get("/setup/status")
+async def setup_status(db: AsyncSession = Depends(get_db)):
+ """Check whether any admin user exists."""
+ result = await db.execute(select(User).where(User.is_admin == True)) # noqa: E712
+ has_admin = result.scalar_one_or_none() is not None
+ return {"has_admin": has_admin}
+
+
+@router.post("/setup", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED)
+async def setup_admin(body: AdminSetupRequest, db: AsyncSession = Depends(get_db)):
+ """Create the first admin user. Fails if an admin already exists."""
+ existing_admin = await db.execute(select(User).where(User.is_admin == True)) # noqa: E712
+ if existing_admin.scalar_one_or_none():
+ raise HTTPException(status_code=400, detail="Admin already configured.")
+
+ username = body.username.lower().strip()
+ if not is_valid_username(username):
+ raise HTTPException(
+ status_code=400,
+ detail="Username must be 3-30 chars, only lowercase letters/numbers/underscores/hyphens.",
+ )
+ if len(body.password) < 8:
+ raise HTTPException(status_code=400, detail="Password must be at least 8 characters.")
+
+ # Check uniqueness
+ conflict = await db.execute(
+ select(User).where(User.username == username)
+ )
+ if conflict.scalar_one_or_none():
+ raise HTTPException(status_code=400, detail="Username already taken.")
+
+ # Generate a placeholder email for the admin setup account
+ email = f"{username}@admin.local"
+ email_conflict = await db.execute(select(User).where(User.email == email))
+ if email_conflict.scalar_one_or_none():
+ email = f"{username}.admin@admin.local"
+
+ user = User(
+ username=username,
+ email=email,
+ hashed_password=hash_password(body.password),
+ is_admin=True,
+ is_active=True,
+ )
+ db.add(user)
+ await db.commit()
+ await db.refresh(user)
+
+ count_result = await db.execute(
+ select(func.count()).where(Project.user_id == user.id)
+ )
+ project_count = count_result.scalar() or 0
+
+ return AdminUserResponse(
+ id=user.id,
+ username=user.username,
+ email=user.email,
+ avatar_url=user.avatar_url,
+ is_active=user.is_active,
+ is_admin=user.is_admin,
+ created_at=user.created_at,
+ project_count=project_count,
+ )
+
+
+# ── Users ─────────────────────────────────────────────────────────────────────
+
+async def _user_with_count(db: AsyncSession, user: User) -> AdminUserResponse:
+ count_result = await db.execute(
+ select(func.count()).where(Project.user_id == user.id)
+ )
+ project_count = count_result.scalar() or 0
+ return AdminUserResponse(
+ id=user.id,
+ username=user.username,
+ email=user.email,
+ avatar_url=user.avatar_url,
+ is_active=user.is_active,
+ is_admin=user.is_admin,
+ created_at=user.created_at,
+ project_count=project_count,
+ )
+
+
+@router.get("/users", response_model=list[AdminUserResponse])
+async def list_users(
+ db: AsyncSession = Depends(get_db),
+ _admin: User = Depends(require_admin),
+):
+ result = await db.execute(select(User).order_by(User.created_at.desc()))
+ users = result.scalars().all()
+ return [await _user_with_count(db, u) for u in users]
+
+
+@router.get("/users/{user_id}", response_model=AdminUserResponse)
+async def get_user(
+ user_id: str,
+ db: AsyncSession = Depends(get_db),
+ _admin: User = Depends(require_admin),
+):
+ result = await db.execute(select(User).where(User.id == user_id))
+ user = result.scalar_one_or_none()
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found.")
+ return await _user_with_count(db, user)
+
+
+@router.put("/users/{user_id}", response_model=AdminUserResponse)
+async def update_user(
+ user_id: str,
+ body: AdminUserUpdateRequest,
+ db: AsyncSession = Depends(get_db),
+ admin: User = Depends(require_admin),
+):
+ result = await db.execute(select(User).where(User.id == user_id))
+ user = result.scalar_one_or_none()
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found.")
+
+ if body.username is not None:
+ new_username = body.username.lower().strip()
+ if not is_valid_username(new_username):
+ raise HTTPException(status_code=400, detail="Invalid username format.")
+ if new_username != user.username:
+ conflict = await db.execute(select(User).where(User.username == new_username))
+ if conflict.scalar_one_or_none():
+ raise HTTPException(status_code=400, detail="Username already taken.")
+ user.username = new_username
+
+ if body.email is not None:
+ if body.email != user.email:
+ conflict = await db.execute(select(User).where(User.email == body.email))
+ if conflict.scalar_one_or_none():
+ raise HTTPException(status_code=400, detail="Email already in use.")
+ user.email = body.email
+
+ if body.password is not None:
+ if len(body.password) < 8:
+ raise HTTPException(status_code=400, detail="Password must be at least 8 characters.")
+ user.hashed_password = hash_password(body.password)
+
+ if body.is_active is not None:
+ user.is_active = body.is_active
+
+ if body.is_admin is not None:
+ # Prevent removing admin from yourself
+ if user.id == admin.id and not body.is_admin:
+ raise HTTPException(status_code=400, detail="Cannot remove your own admin privileges.")
+ user.is_admin = body.is_admin
+
+ await db.commit()
+ await db.refresh(user)
+ return await _user_with_count(db, user)
+
+
+@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_user(
+ user_id: str,
+ db: AsyncSession = Depends(get_db),
+ admin: User = Depends(require_admin),
+):
+ result = await db.execute(select(User).where(User.id == user_id))
+ user = result.scalar_one_or_none()
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found.")
+ if user.id == admin.id:
+ raise HTTPException(status_code=400, detail="Cannot delete your own account.")
+
+ # Delete all user's projects first
+ projects_result = await db.execute(select(Project).where(Project.user_id == user_id))
+ for project in projects_result.scalars().all():
+ await db.delete(project)
+
+ await db.delete(user)
+ await db.commit()
+
+
+# ── Projects ──────────────────────────────────────────────────────────────────
+
+@router.get("/projects", response_model=list[AdminProjectResponse])
+async def list_all_projects(
+ db: AsyncSession = Depends(get_db),
+ _admin: User = Depends(require_admin),
+):
+ result = await db.execute(
+ select(Project, User.username)
+ .join(User, User.id == Project.user_id)
+ .order_by(Project.created_at.desc())
+ )
+ rows = result.all()
+ return [
+ AdminProjectResponse(
+ id=project.id,
+ name=project.name,
+ slug=project.slug,
+ description=project.description,
+ is_public=project.is_public,
+ board_type=project.board_type,
+ owner_username=username,
+ owner_id=project.user_id,
+ created_at=project.created_at,
+ updated_at=project.updated_at,
+ )
+ for project, username in rows
+ ]
+
+
+@router.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_project(
+ project_id: str,
+ db: AsyncSession = Depends(get_db),
+ _admin: User = Depends(require_admin),
+):
+ result = await db.execute(select(Project).where(Project.id == project_id))
+ project = result.scalar_one_or_none()
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found.")
+ await db.delete(project)
+ await db.commit()
diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py
index 825e8c5..d3db29b 100644
--- a/backend/app/core/dependencies.py
+++ b/backend/app/core/dependencies.py
@@ -31,3 +31,13 @@ async def require_auth(
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return user
+
+
+async def require_admin(
+ user: User | None = Depends(get_current_user),
+) -> User:
+ if user is None:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
+ if not user.is_admin:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
+ return user
diff --git a/backend/app/main.py b/backend/app/main.py
index 6cb3ab6..b355494 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -5,6 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from app.api.routes import compile, libraries
+from app.api.routes.admin import router as admin_router
from app.api.routes.auth import router as auth_router
from app.api.routes.projects import router as projects_router
from app.core.config import settings
@@ -19,6 +20,11 @@ import app.models.project # noqa: F401
async def lifespan(_app: FastAPI):
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+ # Add is_admin column to existing databases that predate this feature
+ try:
+ await conn.execute(text("ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0"))
+ except Exception:
+ pass # Column already exists
yield
@@ -48,6 +54,7 @@ app.include_router(compile.router, prefix="/api/compile", tags=["compilation"])
app.include_router(libraries.router, prefix="/api/libraries", tags=["libraries"])
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(projects_router, prefix="/api", tags=["projects"])
+app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
@app.get("/")
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index f07d3c3..cc800e3 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -17,6 +17,7 @@ class User(Base):
google_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+ is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py
new file mode 100644
index 0000000..2135bdd
--- /dev/null
+++ b/backend/app/schemas/admin.py
@@ -0,0 +1,44 @@
+from datetime import datetime
+
+from pydantic import BaseModel, EmailStr
+
+
+class AdminSetupRequest(BaseModel):
+ username: str
+ password: str
+
+
+class AdminUserResponse(BaseModel):
+ id: str
+ username: str
+ email: str
+ avatar_url: str | None
+ is_active: bool
+ is_admin: bool
+ created_at: datetime
+ project_count: int = 0
+
+ model_config = {"from_attributes": True}
+
+
+class AdminUserUpdateRequest(BaseModel):
+ username: str | None = None
+ email: EmailStr | None = None
+ password: str | None = None
+ is_active: bool | None = None
+ is_admin: bool | None = None
+
+
+class AdminProjectResponse(BaseModel):
+ id: str
+ name: str
+ slug: str
+ description: str | None
+ is_public: bool
+ board_type: str
+ owner_username: str
+ owner_id: str
+ created_at: datetime
+ updated_at: datetime
+
+ model_config = {"from_attributes": True}
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
index 83f53c7..7f12135 100644
--- a/backend/app/schemas/auth.py
+++ b/backend/app/schemas/auth.py
@@ -38,6 +38,7 @@ class UserResponse(BaseModel):
username: str
email: str
avatar_url: str | None
+ is_admin: bool = False
created_at: datetime
model_config = {"from_attributes": True}
diff --git a/frontend/index.html b/frontend/index.html
index 1178b48..43bdb11 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -15,6 +15,9 @@
+
+
+
@@ -23,7 +26,8 @@
-
+
+
@@ -37,7 +41,7 @@
-
+
+
+
+
+ The free, open-source Arduino emulator that runs in your browser
+ Write Arduino code, compile it, and simulate it with real AVR8 CPU emulation and 48+ interactive electronic components — all running locally in your browser. No cloud, no latency, no account required.
+ Features
+
+ Real ATmega328p (AVR8) emulation at 16 MHz via avr8js
+ Raspberry Pi Pico (RP2040) emulation via rp2040js
+ 48+ wokwi interactive electronic components (LEDs, resistors, buttons, sensors…)
+ Monaco Code Editor with full C++ / Arduino syntax highlighting
+ arduino-cli compilation backend — produces real .hex / .uf2 files
+ Serial Monitor with auto baud-rate detection and send
+ Library Manager for Arduino libraries
+ Multi-file workspace (.ino, .h, .cpp)
+ Wire system with orthogonal routing
+ ILI9341 TFT display simulation
+ I2C, SPI, USART, ADC, PWM support
+ Docker standalone image — deploy anywhere with one command
+
+ Supported Boards
+
+ Arduino Uno (ATmega328p) — full AVR8 emulation
+ Raspberry Pi Pico (RP2040) — RP2040 emulation
+
+ Get Started
+
+ Open the Editor — no installation needed.
+ Self-host with Docker: docker run -d -p 3080:80 ghcr.io/davidmonterocrespo24/velxio:master
+
+ Frequently Asked Questions
+
+ Is Velxio free?
+ Yes. Velxio is free and open-source under the GNU AGPLv3 license. A commercial license is available for proprietary integrations.
+ Does Velxio work offline?
+ The simulation engine runs entirely in the browser. Compilation requires the local arduino-cli backend. Self-hosted deployments work fully offline once running.
+ Is Velxio a Wokwi alternative?
+ Yes. Velxio is a free, self-hosted alternative to Wokwi. It uses the same avr8js and wokwi-elements open-source libraries but runs on your own machine.
+ What boards are supported?
+ Arduino Uno (ATmega328p / AVR8) and Raspberry Pi Pico (RP2040). More boards are planned.
+
+
+