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

+ +

Supported Boards

+ +

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 0000000..7fcba51 Binary files /dev/null and b/frontend/public/og-image.png differ 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/');