feat: add admin management features and user role handling
- Implemented `require_admin` dependency to enforce admin access control. - Added `is_admin` column to the users table for role management. - Created admin routes and schemas for user and project management. - Developed AdminPage with user and project management tabs. - Integrated user editing and deletion functionalities in the admin panel. - Added setup screen for creating the first admin user. - Updated frontend to include admin functionalities and user role display. - Generated Open Graph image for better social media integration.pull/10/head
parent
6d3614e40f
commit
290b149855
|
|
@ -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()
|
||||||
|
|
@ -31,3 +31,13 @@ async def require_auth(
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
return user
|
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
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.api.routes import compile, libraries
|
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.auth import router as auth_router
|
||||||
from app.api.routes.projects import router as projects_router
|
from app.api.routes.projects import router as projects_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
@ -19,6 +20,11 @@ import app.models.project # noqa: F401
|
||||||
async def lifespan(_app: FastAPI):
|
async def lifespan(_app: FastAPI):
|
||||||
async with async_engine.begin() as conn:
|
async with async_engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
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
|
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(libraries.router, prefix="/api/libraries", tags=["libraries"])
|
||||||
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(projects_router, prefix="/api", tags=["projects"])
|
app.include_router(projects_router, prefix="/api", tags=["projects"])
|
||||||
|
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class User(Base):
|
||||||
google_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
|
google_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
|
||||||
avatar_url: Mapped[str | None] = mapped_column(String, nullable=True)
|
avatar_url: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -38,6 +38,7 @@ class UserResponse(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
email: str
|
email: str
|
||||||
avatar_url: str | None
|
avatar_url: str | None
|
||||||
|
is_admin: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@
|
||||||
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
|
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
|
||||||
<link rel="canonical" href="https://velxio.dev/" />
|
<link rel="canonical" href="https://velxio.dev/" />
|
||||||
|
|
||||||
|
<!-- Google Search Console verification — replace token after verifying in Search Console -->
|
||||||
|
<!-- <meta name="google-site-verification" content="REPLACE_WITH_YOUR_VERIFICATION_TOKEN" /> -->
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════
|
||||||
OPEN GRAPH (Facebook, LinkedIn, WhatsApp, Telegram, Discord, Slack…)
|
OPEN GRAPH (Facebook, LinkedIn, WhatsApp, Telegram, Discord, Slack…)
|
||||||
═══════════════════════════════════════════════ -->
|
═══════════════════════════════════════════════ -->
|
||||||
|
|
@ -23,7 +26,8 @@
|
||||||
<meta property="og:title" content="Velxio — Free Local Arduino Emulator" />
|
<meta property="og:title" content="Velxio — Free Local Arduino Emulator" />
|
||||||
<meta property="og:description" content="Write, compile, and simulate Arduino sketches entirely in your browser. Real AVR8 CPU at 16 MHz, 48+ electronic components, Serial Monitor, and Library Manager. Free & open source." />
|
<meta property="og:description" content="Write, compile, and simulate Arduino sketches entirely in your browser. Real AVR8 CPU at 16 MHz, 48+ electronic components, Serial Monitor, and Library Manager. Free & open source." />
|
||||||
<meta property="og:url" content="https://velxio.dev/" />
|
<meta property="og:url" content="https://velxio.dev/" />
|
||||||
<meta property="og:image" content="https://velxio.dev/og-image.svg" />
|
<meta property="og:image" content="https://velxio.dev/og-image.png" />
|
||||||
|
<meta property="og:image:type" content="image/png" />
|
||||||
<meta property="og:image:width" content="1200" />
|
<meta property="og:image:width" content="1200" />
|
||||||
<meta property="og:image:height" content="630" />
|
<meta property="og:image:height" content="630" />
|
||||||
<meta property="og:image:alt" content="Velxio Arduino Emulator — Real AVR8, 48+ Components, Monaco Editor" />
|
<meta property="og:image:alt" content="Velxio Arduino Emulator — Real AVR8, 48+ Components, Monaco Editor" />
|
||||||
|
|
@ -37,7 +41,7 @@
|
||||||
<meta name="twitter:creator" content="@velxio_app" />
|
<meta name="twitter:creator" content="@velxio_app" />
|
||||||
<meta name="twitter:title" content="Velxio — Free Local Arduino Emulator" />
|
<meta name="twitter:title" content="Velxio — Free Local Arduino Emulator" />
|
||||||
<meta name="twitter:description" content="Write, compile, and simulate Arduino sketches entirely in your browser. Real AVR8 CPU at 16 MHz, 48+ components, Serial Monitor, Library Manager. Free & open source." />
|
<meta name="twitter:description" content="Write, compile, and simulate Arduino sketches entirely in your browser. Real AVR8 CPU at 16 MHz, 48+ components, Serial Monitor, Library Manager. Free & open source." />
|
||||||
<meta name="twitter:image" content="https://velxio.dev/og-image.svg" />
|
<meta name="twitter:image" content="https://velxio.dev/og-image.png" />
|
||||||
<meta name="twitter:image:alt" content="Velxio Arduino Emulator — Real AVR8, 48+ Components, Monaco Editor" />
|
<meta name="twitter:image:alt" content="Velxio Arduino Emulator — Real AVR8, 48+ Components, Monaco Editor" />
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════
|
||||||
|
|
@ -129,11 +133,11 @@
|
||||||
"ILI9341 TFT display simulation",
|
"ILI9341 TFT display simulation",
|
||||||
"I2C, SPI, USART, ADC, PWM support"
|
"I2C, SPI, USART, ADC, PWM support"
|
||||||
],
|
],
|
||||||
"screenshot": "https://velxio.dev/og-image.svg",
|
"screenshot": "https://velxio.dev/og-image.png",
|
||||||
"downloadUrl": "https://hub.docker.com/r/davidmonterocrespo24/velxio",
|
"downloadUrl": "https://hub.docker.com/r/davidmonterocrespo24/velxio",
|
||||||
"installUrl": "https://velxio.dev/editor",
|
"installUrl": "https://velxio.dev/editor",
|
||||||
"codeRepository": "https://github.com/davidmonterocrespo24/velxio",
|
"codeRepository": "https://github.com/davidmonterocrespo24/velxio",
|
||||||
"license": "https://opensource.org/licenses/MIT",
|
"license": "https://www.gnu.org/licenses/agpl-3.0.html",
|
||||||
"isAccessibleForFree": true,
|
"isAccessibleForFree": true,
|
||||||
"author": {
|
"author": {
|
||||||
"@type": "Person",
|
"@type": "Person",
|
||||||
|
|
@ -204,7 +208,64 @@
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<!--
|
||||||
|
Static pre-rendered content for search engine crawlers.
|
||||||
|
Google renders JavaScript, but this ensures content is available
|
||||||
|
on the first crawl wave (before JS execution).
|
||||||
|
React will replace #root when it mounts — this is intentional.
|
||||||
|
-->
|
||||||
|
<div id="root">
|
||||||
|
<header style="font-family:sans-serif;background:#0d1117;color:#e6edf3;padding:1.5rem 2rem;">
|
||||||
|
<a href="/" style="text-decoration:none;color:inherit;">
|
||||||
|
<h1 style="margin:0;font-size:1.5rem;">Velxio — Free Local Arduino Emulator</h1>
|
||||||
|
</a>
|
||||||
|
<nav style="margin-top:.75rem;">
|
||||||
|
<a href="/editor" style="color:#58a6ff;margin-right:1.5rem;">Editor</a>
|
||||||
|
<a href="/examples" style="color:#58a6ff;margin-right:1.5rem;">Examples</a>
|
||||||
|
<a href="https://github.com/davidmonterocrespo24/velxio" style="color:#58a6ff;">GitHub</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main style="font-family:sans-serif;background:#0d1117;color:#e6edf3;padding:2rem;">
|
||||||
|
<h2>The free, open-source Arduino emulator that runs in your browser</h2>
|
||||||
|
<p>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.</p>
|
||||||
|
<h3>Features</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Real ATmega328p (AVR8) emulation at 16 MHz via avr8js</li>
|
||||||
|
<li>Raspberry Pi Pico (RP2040) emulation via rp2040js</li>
|
||||||
|
<li>48+ wokwi interactive electronic components (LEDs, resistors, buttons, sensors…)</li>
|
||||||
|
<li>Monaco Code Editor with full C++ / Arduino syntax highlighting</li>
|
||||||
|
<li>arduino-cli compilation backend — produces real .hex / .uf2 files</li>
|
||||||
|
<li>Serial Monitor with auto baud-rate detection and send</li>
|
||||||
|
<li>Library Manager for Arduino libraries</li>
|
||||||
|
<li>Multi-file workspace (.ino, .h, .cpp)</li>
|
||||||
|
<li>Wire system with orthogonal routing</li>
|
||||||
|
<li>ILI9341 TFT display simulation</li>
|
||||||
|
<li>I2C, SPI, USART, ADC, PWM support</li>
|
||||||
|
<li>Docker standalone image — deploy anywhere with one command</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Supported Boards</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Arduino Uno (ATmega328p) — full AVR8 emulation</li>
|
||||||
|
<li>Raspberry Pi Pico (RP2040) — RP2040 emulation</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Get Started</h3>
|
||||||
|
<p>
|
||||||
|
<a href="/editor">Open the Editor</a> — no installation needed.<br />
|
||||||
|
Self-host with Docker: <code>docker run -d -p 3080:80 ghcr.io/davidmonterocrespo24/velxio:master</code>
|
||||||
|
</p>
|
||||||
|
<h3>Frequently Asked Questions</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Is Velxio free?</dt>
|
||||||
|
<dd>Yes. Velxio is free and open-source under the GNU AGPLv3 license. A commercial license is available for proprietary integrations.</dd>
|
||||||
|
<dt>Does Velxio work offline?</dt>
|
||||||
|
<dd>The simulation engine runs entirely in the browser. Compilation requires the local arduino-cli backend. Self-hosted deployments work fully offline once running.</dd>
|
||||||
|
<dt>Is Velxio a Wokwi alternative?</dt>
|
||||||
|
<dd>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.</dd>
|
||||||
|
<dt>What boards are supported?</dt>
|
||||||
|
<dd>Arduino Uno (ATmega328p / AVR8) and Raspberry Pi Pico (RP2040). More boards are planned.</dd>
|
||||||
|
</dl>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate:metadata": "cd .. && npx tsx scripts/generate-component-metadata.ts",
|
"generate:metadata": "cd .. && npx tsx scripts/generate-component-metadata.ts",
|
||||||
"generate:favicons": "node ../scripts/generate-favicons.mjs",
|
"generate:favicons": "node ../scripts/generate-favicons.mjs",
|
||||||
|
"generate:og-image": "node ../scripts/generate-og-image.mjs",
|
||||||
"dev": "npm run generate:metadata && vite",
|
"dev": "npm run generate:metadata && vite",
|
||||||
"build": "npm run generate:metadata && tsc -b && vite build",
|
"build": "npm run generate:metadata && tsc -b && vite build",
|
||||||
"build:docker": "vite build",
|
"build:docker": "vite build",
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
|
|
@ -8,6 +8,7 @@ import { RegisterPage } from './pages/RegisterPage';
|
||||||
import { UserProfilePage } from './pages/UserProfilePage';
|
import { UserProfilePage } from './pages/UserProfilePage';
|
||||||
import { ProjectPage } from './pages/ProjectPage';
|
import { ProjectPage } from './pages/ProjectPage';
|
||||||
import { ProjectByIdPage } from './pages/ProjectByIdPage';
|
import { ProjectByIdPage } from './pages/ProjectByIdPage';
|
||||||
|
import { AdminPage } from './pages/AdminPage';
|
||||||
import { useAuthStore } from './store/useAuthStore';
|
import { useAuthStore } from './store/useAuthStore';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
|
|
@ -26,6 +27,7 @@ function App() {
|
||||||
<Route path="/examples" element={<ExamplesPage />} />
|
<Route path="/examples" element={<ExamplesPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
{/* Canonical project URL by ID */}
|
{/* Canonical project URL by ID */}
|
||||||
<Route path="/project/:id" element={<ProjectByIdPage />} />
|
<Route path="/project/:id" element={<ProjectByIdPage />} />
|
||||||
{/* Legacy slug route — redirects to /project/:id */}
|
{/* Legacy slug route — redirects to /project/:id */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,596 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../store/useAuthStore';
|
||||||
|
import {
|
||||||
|
getAdminSetupStatus,
|
||||||
|
createFirstAdmin,
|
||||||
|
adminListUsers,
|
||||||
|
adminUpdateUser,
|
||||||
|
adminDeleteUser,
|
||||||
|
adminListProjects,
|
||||||
|
adminDeleteProject,
|
||||||
|
type AdminUserResponse,
|
||||||
|
type AdminProjectResponse,
|
||||||
|
type AdminUserUpdateRequest,
|
||||||
|
} from '../services/adminService';
|
||||||
|
|
||||||
|
type Tab = 'users' | 'projects';
|
||||||
|
|
||||||
|
// ── Edit User Modal ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function EditUserModal({
|
||||||
|
user,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
user: AdminUserResponse;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (id: string, body: AdminUserUpdateRequest) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [username, setUsername] = useState(user.username);
|
||||||
|
const [email, setEmail] = useState(user.email);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isAdmin, setIsAdmin] = useState(user.is_admin);
|
||||||
|
const [isActive, setIsActive] = useState(user.is_active);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
const body: AdminUserUpdateRequest = {};
|
||||||
|
if (username !== user.username) body.username = username;
|
||||||
|
if (email !== user.email) body.email = email;
|
||||||
|
if (password) body.password = password;
|
||||||
|
if (isAdmin !== user.is_admin) body.is_admin = isAdmin;
|
||||||
|
if (isActive !== user.is_active) body.is_active = isActive;
|
||||||
|
try {
|
||||||
|
await onSave(user.id, body);
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.detail || 'Failed to save.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={modalStyles.overlay} onClick={onClose}>
|
||||||
|
<div style={modalStyles.box} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2 style={modalStyles.title}>Edit user</h2>
|
||||||
|
{error && <div style={modalStyles.error}>{error}</div>}
|
||||||
|
|
||||||
|
<label style={modalStyles.label}>Username</label>
|
||||||
|
<input style={modalStyles.input} value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||||
|
|
||||||
|
<label style={modalStyles.label}>Email</label>
|
||||||
|
<input style={modalStyles.input} value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
|
||||||
|
<label style={modalStyles.label}>New password (leave blank to keep)</label>
|
||||||
|
<input
|
||||||
|
style={modalStyles.input}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Min. 8 characters"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={modalStyles.checkRow}>
|
||||||
|
<input
|
||||||
|
id="is_admin"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isAdmin}
|
||||||
|
onChange={(e) => setIsAdmin(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="is_admin" style={modalStyles.checkLabel}>Admin</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={modalStyles.checkRow}>
|
||||||
|
<input
|
||||||
|
id="is_active"
|
||||||
|
type="checkbox"
|
||||||
|
checked={isActive}
|
||||||
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="is_active" style={modalStyles.checkLabel}>Active</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={modalStyles.actions}>
|
||||||
|
<button style={modalStyles.cancelBtn} onClick={onClose} disabled={saving}>Cancel</button>
|
||||||
|
<button style={modalStyles.saveBtn} onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalStyles: Record<string, React.CSSProperties> = {
|
||||||
|
overlay: {
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100,
|
||||||
|
},
|
||||||
|
box: {
|
||||||
|
background: '#252526', border: '1px solid #3c3c3c', borderRadius: 8,
|
||||||
|
padding: '1.5rem', width: 400, display: 'flex', flexDirection: 'column', gap: 10,
|
||||||
|
},
|
||||||
|
title: { color: '#ccc', margin: 0, fontSize: 18, fontWeight: 600 },
|
||||||
|
label: { color: '#9d9d9d', fontSize: 13 },
|
||||||
|
input: {
|
||||||
|
background: '#3c3c3c', border: '1px solid #555', borderRadius: 4,
|
||||||
|
padding: '7px 10px', color: '#ccc', fontSize: 14, outline: 'none',
|
||||||
|
},
|
||||||
|
checkRow: { display: 'flex', alignItems: 'center', gap: 8 },
|
||||||
|
checkLabel: { color: '#ccc', fontSize: 14 },
|
||||||
|
actions: { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 },
|
||||||
|
cancelBtn: {
|
||||||
|
background: '#3c3c3c', border: 'none', borderRadius: 4,
|
||||||
|
color: '#ccc', padding: '7px 16px', fontSize: 14, cursor: 'pointer',
|
||||||
|
},
|
||||||
|
saveBtn: {
|
||||||
|
background: '#0e639c', border: 'none', borderRadius: 4,
|
||||||
|
color: '#fff', padding: '7px 16px', fontSize: 14, cursor: 'pointer',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
background: '#5a1d1d', border: '1px solid #f44747', borderRadius: 4,
|
||||||
|
color: '#f44747', padding: '7px 12px', fontSize: 13,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Setup screen ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SetupScreen({ onDone }: { onDone: () => void }) {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError('Passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await createFirstAdmin(username, password);
|
||||||
|
onDone();
|
||||||
|
navigate('/login?redirect=/admin');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.detail || 'Failed to create admin.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={s.page}>
|
||||||
|
<div style={s.card}>
|
||||||
|
<h1 style={s.cardTitle}>Admin setup</h1>
|
||||||
|
<p style={s.muted}>No admin account exists yet. Create the first admin user to proceed.</p>
|
||||||
|
{error && <div style={s.error}>{error}</div>}
|
||||||
|
<form onSubmit={handleCreate} style={s.form}>
|
||||||
|
<label style={s.label}>Username</label>
|
||||||
|
<input
|
||||||
|
style={s.input}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
<label style={s.label}>Password</label>
|
||||||
|
<input
|
||||||
|
style={s.input}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Min. 8 characters"
|
||||||
|
/>
|
||||||
|
<label style={s.label}>Confirm password</label>
|
||||||
|
<input
|
||||||
|
style={s.input}
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={loading} style={s.primaryBtn}>
|
||||||
|
{loading ? 'Creating…' : 'Create admin'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Not-admin screen ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function NotAdminScreen() {
|
||||||
|
return (
|
||||||
|
<div style={s.page}>
|
||||||
|
<div style={s.card}>
|
||||||
|
<h1 style={s.cardTitle}>Admin access required</h1>
|
||||||
|
<p style={s.muted}>You must be logged in as an admin to access this panel.</p>
|
||||||
|
<Link to="/login?redirect=/admin" style={s.primaryBtn}>
|
||||||
|
Go to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Users tab ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function UsersTab({ currentUserId }: { currentUserId: string }) {
|
||||||
|
const [users, setUsers] = useState<AdminUserResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [editUser, setEditUser] = useState<AdminUserResponse | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
setLoading(true);
|
||||||
|
adminListUsers()
|
||||||
|
.then(setUsers)
|
||||||
|
.catch(() => setError('Failed to load users.'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(load, []);
|
||||||
|
|
||||||
|
const handleSave = async (id: string, body: AdminUserUpdateRequest) => {
|
||||||
|
const updated = await adminUpdateUser(id, body);
|
||||||
|
setUsers((prev) => prev.map((u) => (u.id === id ? updated : u)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (user: AdminUserResponse) => {
|
||||||
|
if (!confirm(`Delete user "${user.username}" and all their projects? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
await adminDeleteUser(user.id);
|
||||||
|
setUsers((prev) => prev.filter((u) => u.id !== user.id));
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err?.response?.data?.detail || 'Failed to delete user.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = users.filter(
|
||||||
|
(u) =>
|
||||||
|
u.username.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
u.email.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={s.tabContent}>
|
||||||
|
{error && <div style={s.error}>{error}</div>}
|
||||||
|
<div style={s.searchRow}>
|
||||||
|
<input
|
||||||
|
style={s.searchInput}
|
||||||
|
placeholder="Search by username or email…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span style={s.muted}>{filtered.length} user{filtered.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p style={s.muted}>Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div style={s.tableWrap}>
|
||||||
|
<table style={s.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={s.th}>Username</th>
|
||||||
|
<th style={s.th}>Email</th>
|
||||||
|
<th style={s.th}>Role</th>
|
||||||
|
<th style={s.th}>Status</th>
|
||||||
|
<th style={s.th}>Projects</th>
|
||||||
|
<th style={s.th}>Joined</th>
|
||||||
|
<th style={s.th}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((u) => (
|
||||||
|
<tr key={u.id} style={s.tr}>
|
||||||
|
<td style={s.td}>
|
||||||
|
<span style={s.username}>{u.username}</span>
|
||||||
|
{u.id === currentUserId && (
|
||||||
|
<span style={s.youBadge}>you</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={s.td}>{u.email}</td>
|
||||||
|
<td style={s.td}>
|
||||||
|
<span style={u.is_admin ? s.adminBadge : s.userBadge}>
|
||||||
|
{u.is_admin ? 'admin' : 'user'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={s.td}>
|
||||||
|
<span style={u.is_active ? s.activeBadge : s.inactiveBadge}>
|
||||||
|
{u.is_active ? 'active' : 'disabled'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={s.td}>{u.project_count}</td>
|
||||||
|
<td style={s.td}>{new Date(u.created_at).toLocaleDateString()}</td>
|
||||||
|
<td style={s.td}>
|
||||||
|
<button style={s.editBtn} onClick={() => setEditUser(u)}>Edit</button>
|
||||||
|
{u.id !== currentUserId && (
|
||||||
|
<button style={s.deleteBtn} onClick={() => handleDelete(u)}>Delete</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} style={{ ...s.td, textAlign: 'center', color: '#666' }}>
|
||||||
|
No users found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editUser && (
|
||||||
|
<EditUserModal
|
||||||
|
user={editUser}
|
||||||
|
onClose={() => setEditUser(null)}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Projects tab ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProjectsTab() {
|
||||||
|
const [projects, setProjects] = useState<AdminProjectResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminListProjects()
|
||||||
|
.then(setProjects)
|
||||||
|
.catch(() => setError('Failed to load projects.'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async (project: AdminProjectResponse) => {
|
||||||
|
if (!confirm(`Delete project "${project.name}"? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
await adminDeleteProject(project.id);
|
||||||
|
setProjects((prev) => prev.filter((p) => p.id !== project.id));
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err?.response?.data?.detail || 'Failed to delete project.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = projects.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
p.owner_username.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={s.tabContent}>
|
||||||
|
{error && <div style={s.error}>{error}</div>}
|
||||||
|
<div style={s.searchRow}>
|
||||||
|
<input
|
||||||
|
style={s.searchInput}
|
||||||
|
placeholder="Search by name or owner…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span style={s.muted}>{filtered.length} project{filtered.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p style={s.muted}>Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div style={s.tableWrap}>
|
||||||
|
<table style={s.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={s.th}>Name</th>
|
||||||
|
<th style={s.th}>Owner</th>
|
||||||
|
<th style={s.th}>Board</th>
|
||||||
|
<th style={s.th}>Visibility</th>
|
||||||
|
<th style={s.th}>Updated</th>
|
||||||
|
<th style={s.th}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((p) => (
|
||||||
|
<tr key={p.id} style={s.tr}>
|
||||||
|
<td style={s.td}>
|
||||||
|
<Link
|
||||||
|
to={`/project/${p.id}`}
|
||||||
|
style={{ color: '#4fc3f7', textDecoration: 'none' }}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td style={s.td}>
|
||||||
|
<Link
|
||||||
|
to={`/${p.owner_username}`}
|
||||||
|
style={{ color: '#9d9d9d', textDecoration: 'none' }}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{p.owner_username}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td style={s.td}>{p.board_type}</td>
|
||||||
|
<td style={s.td}>
|
||||||
|
<span style={p.is_public ? s.activeBadge : s.inactiveBadge}>
|
||||||
|
{p.is_public ? 'public' : 'private'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={s.td}>{new Date(p.updated_at).toLocaleDateString()}</td>
|
||||||
|
<td style={s.td}>
|
||||||
|
<button style={s.deleteBtn} onClick={() => handleDelete(p)}>Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} style={{ ...s.td, textAlign: 'center', color: '#666' }}>
|
||||||
|
No projects found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin dashboard ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AdminDashboard() {
|
||||||
|
const [tab, setTab] = useState<Tab>('users');
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={s.dashboard}>
|
||||||
|
<div style={s.header}>
|
||||||
|
<div style={s.headerLeft}>
|
||||||
|
<Link to="/" style={s.backLink}>Velxio</Link>
|
||||||
|
<span style={s.headerSep}>/</span>
|
||||||
|
<span style={s.headerTitle}>Admin panel</span>
|
||||||
|
</div>
|
||||||
|
<div style={s.headerRight}>
|
||||||
|
<span style={s.adminLabel}>{user?.username}</span>
|
||||||
|
<button style={s.logoutBtn} onClick={handleLogout}>Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={s.tabs}>
|
||||||
|
<button
|
||||||
|
style={tab === 'users' ? s.tabActive : s.tabBtn}
|
||||||
|
onClick={() => setTab('users')}
|
||||||
|
>
|
||||||
|
Users
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={tab === 'projects' ? s.tabActive : s.tabBtn}
|
||||||
|
onClick={() => setTab('projects')}
|
||||||
|
>
|
||||||
|
Projects
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'users' && <UsersTab currentUserId={user?.id || ''} />}
|
||||||
|
{tab === 'projects' && <ProjectsTab />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main AdminPage ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type AdminPageState = 'loading' | 'setup' | 'not-admin' | 'dashboard';
|
||||||
|
|
||||||
|
export const AdminPage: React.FC = () => {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const [pageState, setPageState] = useState<AdminPageState>('loading');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAdminSetupStatus()
|
||||||
|
.then(({ has_admin }) => {
|
||||||
|
if (!has_admin) {
|
||||||
|
setPageState('setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!user || !user.is_admin) {
|
||||||
|
setPageState('not-admin');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPageState('dashboard');
|
||||||
|
})
|
||||||
|
.catch(() => setPageState('not-admin'));
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
if (pageState === 'loading') {
|
||||||
|
return (
|
||||||
|
<div style={{ ...s.page, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<p style={s.muted}>Loading…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageState === 'setup') {
|
||||||
|
return <SetupScreen onDone={() => setPageState('not-admin')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageState === 'not-admin') {
|
||||||
|
return <NotAdminScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AdminDashboard />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const s: Record<string, React.CSSProperties> = {
|
||||||
|
page: { minHeight: '100vh', background: '#1e1e1e', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '2rem' },
|
||||||
|
card: { background: '#252526', border: '1px solid #3c3c3c', borderRadius: 8, padding: '2rem', width: 380, display: 'flex', flexDirection: 'column', gap: 12 },
|
||||||
|
cardTitle: { color: '#ccc', margin: 0, fontSize: 22, fontWeight: 600 },
|
||||||
|
muted: { color: '#777', fontSize: 13, margin: 0 },
|
||||||
|
form: { display: 'flex', flexDirection: 'column', gap: 8 },
|
||||||
|
label: { color: '#9d9d9d', fontSize: 13 },
|
||||||
|
input: { background: '#3c3c3c', border: '1px solid #555', borderRadius: 4, padding: '8px 10px', color: '#ccc', fontSize: 14, outline: 'none' },
|
||||||
|
primaryBtn: {
|
||||||
|
display: 'block', textAlign: 'center', textDecoration: 'none',
|
||||||
|
marginTop: 8, background: '#0e639c', border: 'none', borderRadius: 4,
|
||||||
|
color: '#fff', padding: '9px', fontSize: 14, cursor: 'pointer', fontWeight: 500,
|
||||||
|
},
|
||||||
|
error: { background: '#5a1d1d', border: '1px solid #f44747', borderRadius: 4, color: '#f44747', padding: '8px 12px', fontSize: 13 },
|
||||||
|
// Dashboard
|
||||||
|
dashboard: { minHeight: '100vh', background: '#1e1e1e', display: 'flex', flexDirection: 'column' },
|
||||||
|
header: {
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
background: '#252526', borderBottom: '1px solid #3c3c3c', padding: '0 1.5rem', height: 48,
|
||||||
|
},
|
||||||
|
headerLeft: { display: 'flex', alignItems: 'center', gap: 8 },
|
||||||
|
backLink: { color: '#4fc3f7', textDecoration: 'none', fontSize: 14, fontWeight: 600 },
|
||||||
|
headerSep: { color: '#555', fontSize: 14 },
|
||||||
|
headerTitle: { color: '#ccc', fontSize: 14 },
|
||||||
|
headerRight: { display: 'flex', alignItems: 'center', gap: 12 },
|
||||||
|
adminLabel: { color: '#9d9d9d', fontSize: 13 },
|
||||||
|
logoutBtn: { background: 'transparent', border: '1px solid #555', borderRadius: 4, color: '#ccc', padding: '4px 12px', fontSize: 13, cursor: 'pointer' },
|
||||||
|
tabs: { display: 'flex', gap: 0, borderBottom: '1px solid #3c3c3c', padding: '0 1.5rem' },
|
||||||
|
tabBtn: { background: 'transparent', border: 'none', borderBottom: '2px solid transparent', color: '#9d9d9d', padding: '10px 16px', fontSize: 14, cursor: 'pointer' },
|
||||||
|
tabActive: { background: 'transparent', border: 'none', borderBottom: '2px solid #0e639c', color: '#fff', padding: '10px 16px', fontSize: 14, cursor: 'pointer' },
|
||||||
|
tabContent: { padding: '1.5rem', flex: 1 },
|
||||||
|
searchRow: { display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 },
|
||||||
|
searchInput: { background: '#3c3c3c', border: '1px solid #555', borderRadius: 4, padding: '7px 10px', color: '#ccc', fontSize: 14, outline: 'none', width: 300 },
|
||||||
|
tableWrap: { overflowX: 'auto' },
|
||||||
|
table: { width: '100%', borderCollapse: 'collapse', fontSize: 13 },
|
||||||
|
th: { textAlign: 'left', color: '#9d9d9d', padding: '8px 12px', borderBottom: '1px solid #3c3c3c', fontWeight: 500, whiteSpace: 'nowrap' },
|
||||||
|
tr: { borderBottom: '1px solid #2d2d2d' },
|
||||||
|
td: { color: '#ccc', padding: '10px 12px', verticalAlign: 'middle' },
|
||||||
|
username: { fontWeight: 500 },
|
||||||
|
youBadge: { marginLeft: 6, background: '#2d4a2d', color: '#73c991', border: '1px solid #4a7a4a', borderRadius: 4, padding: '1px 6px', fontSize: 11 },
|
||||||
|
adminBadge: { background: '#2d3a5a', color: '#9cdcfe', border: '1px solid #4a6a9a', borderRadius: 4, padding: '2px 8px', fontSize: 11 },
|
||||||
|
userBadge: { background: '#3a3a3a', color: '#9d9d9d', border: '1px solid #555', borderRadius: 4, padding: '2px 8px', fontSize: 11 },
|
||||||
|
activeBadge: { background: '#2d4a2d', color: '#73c991', border: '1px solid #4a7a4a', borderRadius: 4, padding: '2px 8px', fontSize: 11 },
|
||||||
|
inactiveBadge: { background: '#4a2d2d', color: '#f14c4c', border: '1px solid #7a4a4a', borderRadius: 4, padding: '2px 8px', fontSize: 11 },
|
||||||
|
editBtn: { background: '#3c3c3c', border: 'none', borderRadius: 4, color: '#ccc', padding: '4px 10px', fontSize: 12, cursor: 'pointer', marginRight: 4 },
|
||||||
|
deleteBtn: { background: '#5a1d1d', border: 'none', borderRadius: 4, color: '#f44747', padding: '4px 10px', fontSize: 12, cursor: 'pointer' },
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8001/api';
|
||||||
|
|
||||||
|
const api = axios.create({ baseURL: API_BASE, withCredentials: true });
|
||||||
|
|
||||||
|
export interface AdminUserResponse {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
is_admin: boolean;
|
||||||
|
created_at: string;
|
||||||
|
project_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUserUpdateRequest {
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
is_admin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminProjectResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
is_public: boolean;
|
||||||
|
board_type: string;
|
||||||
|
owner_username: string;
|
||||||
|
owner_id: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminSetupStatus(): Promise<{ has_admin: boolean }> {
|
||||||
|
const { data } = await api.get('/admin/setup/status');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFirstAdmin(username: string, password: string): Promise<AdminUserResponse> {
|
||||||
|
const { data } = await api.post('/admin/setup', { username, password });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminListUsers(): Promise<AdminUserResponse[]> {
|
||||||
|
const { data } = await api.get('/admin/users');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminGetUser(userId: string): Promise<AdminUserResponse> {
|
||||||
|
const { data } = await api.get(`/admin/users/${userId}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminUpdateUser(userId: string, body: AdminUserUpdateRequest): Promise<AdminUserResponse> {
|
||||||
|
const { data } = await api.put(`/admin/users/${userId}`, body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminDeleteUser(userId: string): Promise<void> {
|
||||||
|
await api.delete(`/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminListProjects(): Promise<AdminProjectResponse[]> {
|
||||||
|
const { data } = await api.get('/admin/projects');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminDeleteProject(projectId: string): Promise<void> {
|
||||||
|
await api.delete(`/admin/projects/${projectId}`);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ export interface UserResponse {
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
is_admin: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/**
|
||||||
|
* OG Image generator — converts og-image.svg to og-image.png (1200x630).
|
||||||
|
* Run from project root: node scripts/generate-og-image.mjs
|
||||||
|
*
|
||||||
|
* Generates:
|
||||||
|
* frontend/public/og-image.png (1200×630 — required by OG / Twitter Card)
|
||||||
|
*
|
||||||
|
* Note: SVG images are NOT supported by most OG crawlers (Facebook, Slack,
|
||||||
|
* WhatsApp, Google Search Console…). This script produces the required PNG.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = join(__dirname, '..');
|
||||||
|
const PUBLIC = join(ROOT, 'frontend', 'public');
|
||||||
|
|
||||||
|
// ── install @resvg/resvg-js on the fly if missing ──────────────────
|
||||||
|
async function ensureDep(pkg) {
|
||||||
|
try { return await import(pkg); } catch {}
|
||||||
|
console.log(`Installing ${pkg}…`);
|
||||||
|
const { execSync } = await import('child_process');
|
||||||
|
execSync(`npm install --no-save ${pkg}`, { stdio: 'inherit', cwd: ROOT });
|
||||||
|
return await import(pkg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Resvg } = await ensureDep('@resvg/resvg-js');
|
||||||
|
|
||||||
|
// ── render og-image.svg at 1200 px wide ────────────────────────────
|
||||||
|
const svgPath = join(PUBLIC, 'og-image.svg');
|
||||||
|
const svgSrc = readFileSync(svgPath);
|
||||||
|
|
||||||
|
const resvg = new Resvg(svgSrc, {
|
||||||
|
fitTo: { mode: 'width', value: 1200 },
|
||||||
|
font: { loadSystemFonts: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pngData = resvg.render();
|
||||||
|
const pngBuffer = pngData.asPng();
|
||||||
|
|
||||||
|
writeFileSync(join(PUBLIC, 'og-image.png'), pngBuffer);
|
||||||
|
|
||||||
|
console.log(`✓ og-image.png (1200×auto)`);
|
||||||
|
console.log('\nDone. og-image.png is ready in frontend/public/');
|
||||||
Loading…
Reference in New Issue