From 290b1498554fb06981fa2821e8c61d6ee28ea23a Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Fri, 6 Mar 2026 23:46:36 -0300 Subject: [PATCH] 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. --- backend/app/api/routes/admin.py | 241 +++++++++++ backend/app/core/dependencies.py | 10 + backend/app/main.py | 7 + backend/app/models/user.py | 1 + backend/app/schemas/admin.py | 44 ++ backend/app/schemas/auth.py | 1 + frontend/index.html | 71 ++- frontend/package.json | 1 + frontend/public/og-image.png | Bin 0 -> 73778 bytes frontend/src/App.tsx | 2 + frontend/src/pages/AdminPage.tsx | 596 ++++++++++++++++++++++++++ frontend/src/services/adminService.ts | 75 ++++ frontend/src/store/useAuthStore.ts | 1 + scripts/generate-og-image.mjs | 46 ++ 14 files changed, 1091 insertions(+), 5 deletions(-) create mode 100644 backend/app/api/routes/admin.py create mode 100644 backend/app/schemas/admin.py create mode 100644 frontend/public/og-image.png create mode 100644 frontend/src/pages/AdminPage.tsx create mode 100644 frontend/src/services/adminService.ts create mode 100644 scripts/generate-og-image.mjs 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 @@ - + +
+
+ +

Velxio — Free Local Arduino Emulator

+
+ +
+
+

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.
+
+
+
diff --git a/frontend/package.json b/frontend/package.json index 7ac29a7..ac9ab83 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "scripts": { "generate:metadata": "cd .. && npx tsx scripts/generate-component-metadata.ts", "generate:favicons": "node ../scripts/generate-favicons.mjs", + "generate:og-image": "node ../scripts/generate-og-image.mjs", "dev": "npm run generate:metadata && vite", "build": "npm run generate:metadata && tsc -b && vite build", "build:docker": "vite build", diff --git a/frontend/public/og-image.png b/frontend/public/og-image.png new file mode 100644 index 0000000000000000000000000000000000000000..7fcba51a1c38f3575271befaaa54f18c7d64acfb GIT binary patch literal 73778 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 literal 0 HcmV?d00001 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 084a6c7..8fba3a6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { RegisterPage } from './pages/RegisterPage'; import { UserProfilePage } from './pages/UserProfilePage'; import { ProjectPage } from './pages/ProjectPage'; import { ProjectByIdPage } from './pages/ProjectByIdPage'; +import { AdminPage } from './pages/AdminPage'; import { useAuthStore } from './store/useAuthStore'; import './App.css'; @@ -26,6 +27,7 @@ function App() { } /> } /> } /> + } /> {/* Canonical project URL by ID */} } /> {/* Legacy slug route — redirects to /project/:id */} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx new file mode 100644 index 0000000..011769b --- /dev/null +++ b/frontend/src/pages/AdminPage.tsx @@ -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; +}) { + 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 ( +

+
e.stopPropagation()}> +

Edit user

+ {error &&
{error}
} + + + setUsername(e.target.value)} /> + + + setEmail(e.target.value)} /> + + + setPassword(e.target.value)} + placeholder="Min. 8 characters" + /> + +
+ setIsAdmin(e.target.checked)} + /> + +
+ +
+ setIsActive(e.target.checked)} + /> + +
+ +
+ + +
+
+
+ ); +} + +const modalStyles: Record = { + 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 ( +
+
+

Admin setup

+

No admin account exists yet. Create the first admin user to proceed.

+ {error &&
{error}
} +
+ + setUsername(e.target.value)} + required + autoFocus + placeholder="admin" + /> + + setPassword(e.target.value)} + required + placeholder="Min. 8 characters" + /> + + setConfirm(e.target.value)} + required + /> + +
+
+
+ ); +} + +// ── Not-admin screen ────────────────────────────────────────────────────────── + +function NotAdminScreen() { + return ( +
+
+

Admin access required

+

You must be logged in as an admin to access this panel.

+ + Go to login + +
+
+ ); +} + +// ── Users tab ───────────────────────────────────────────────────────────────── + +function UsersTab({ currentUserId }: { currentUserId: string }) { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [editUser, setEditUser] = useState(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 ( +
+ {error &&
{error}
} +
+ setSearch(e.target.value)} + /> + {filtered.length} user{filtered.length !== 1 ? 's' : ''} +
+ + {loading ? ( +

Loading…

+ ) : ( +
+ + + + + + + + + + + + + + {filtered.map((u) => ( + + + + + + + + + + ))} + {filtered.length === 0 && ( + + + + )} + +
UsernameEmailRoleStatusProjectsJoinedActions
+ {u.username} + {u.id === currentUserId && ( + you + )} + {u.email} + + {u.is_admin ? 'admin' : 'user'} + + + + {u.is_active ? 'active' : 'disabled'} + + {u.project_count}{new Date(u.created_at).toLocaleDateString()} + + {u.id !== currentUserId && ( + + )} +
+ No users found. +
+
+ )} + + {editUser && ( + setEditUser(null)} + onSave={handleSave} + /> + )} +
+ ); +} + +// ── Projects tab ────────────────────────────────────────────────────────────── + +function ProjectsTab() { + const [projects, setProjects] = useState([]); + 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 ( +
+ {error &&
{error}
} +
+ setSearch(e.target.value)} + /> + {filtered.length} project{filtered.length !== 1 ? 's' : ''} +
+ + {loading ? ( +

Loading…

+ ) : ( +
+ + + + + + + + + + + + + {filtered.map((p) => ( + + + + + + + + + ))} + {filtered.length === 0 && ( + + + + )} + +
NameOwnerBoardVisibilityUpdatedActions
+ + {p.name} + + + + {p.owner_username} + + {p.board_type} + + {p.is_public ? 'public' : 'private'} + + {new Date(p.updated_at).toLocaleDateString()} + +
+ No projects found. +
+
+ )} +
+ ); +} + +// ── Admin dashboard ─────────────────────────────────────────────────────────── + +function AdminDashboard() { + const [tab, setTab] = useState('users'); + const user = useAuthStore((s) => s.user); + const logout = useAuthStore((s) => s.logout); + const navigate = useNavigate(); + + const handleLogout = async () => { + await logout(); + navigate('/'); + }; + + return ( +
+
+
+ Velxio + / + Admin panel +
+
+ {user?.username} + +
+
+ +
+ + +
+ + {tab === 'users' && } + {tab === 'projects' && } +
+ ); +} + +// ── Main AdminPage ──────────────────────────────────────────────────────────── + +type AdminPageState = 'loading' | 'setup' | 'not-admin' | 'dashboard'; + +export const AdminPage: React.FC = () => { + const user = useAuthStore((s) => s.user); + const [pageState, setPageState] = useState('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 ( +
+

Loading…

+
+ ); + } + + if (pageState === 'setup') { + return setPageState('not-admin')} />; + } + + if (pageState === 'not-admin') { + return ; + } + + return ; +}; + +// ── Styles ──────────────────────────────────────────────────────────────────── + +const s: Record = { + 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' }, +}; diff --git a/frontend/src/services/adminService.ts b/frontend/src/services/adminService.ts new file mode 100644 index 0000000..45757cc --- /dev/null +++ b/frontend/src/services/adminService.ts @@ -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 { + const { data } = await api.post('/admin/setup', { username, password }); + return data; +} + +export async function adminListUsers(): Promise { + const { data } = await api.get('/admin/users'); + return data; +} + +export async function adminGetUser(userId: string): Promise { + const { data } = await api.get(`/admin/users/${userId}`); + return data; +} + +export async function adminUpdateUser(userId: string, body: AdminUserUpdateRequest): Promise { + const { data } = await api.put(`/admin/users/${userId}`, body); + return data; +} + +export async function adminDeleteUser(userId: string): Promise { + await api.delete(`/admin/users/${userId}`); +} + +export async function adminListProjects(): Promise { + const { data } = await api.get('/admin/projects'); + return data; +} + +export async function adminDeleteProject(projectId: string): Promise { + await api.delete(`/admin/projects/${projectId}`); +} diff --git a/frontend/src/store/useAuthStore.ts b/frontend/src/store/useAuthStore.ts index 81525ba..1dbf793 100644 --- a/frontend/src/store/useAuthStore.ts +++ b/frontend/src/store/useAuthStore.ts @@ -7,6 +7,7 @@ export interface UserResponse { username: string; email: string; avatar_url: string | null; + is_admin: boolean; created_at: string; } diff --git a/scripts/generate-og-image.mjs b/scripts/generate-og-image.mjs new file mode 100644 index 0000000..2b2b367 --- /dev/null +++ b/scripts/generate-og-image.mjs @@ -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/');