Initial Commit. Split cloud service from the combined project
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Environment
|
||||
.env
|
||||
*.env
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Node (for future frontends)
|
||||
node_modules/
|
||||
dist/
|
||||
.next/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Runtime data (databases, uploaded images)
|
||||
data/
|
||||
14
cloud_backend/.env.example
Normal file
14
cloud_backend/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Cloud Backend — copy this to .env and fill in values
|
||||
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change-me-generate-a-long-random-string
|
||||
|
||||
# SQLite for local dev. Switch to postgresql://user:pass@host/dbname for VPS.
|
||||
DATABASE_URL=sqlite:////app/data/cloud.db
|
||||
|
||||
# JWT expiry in minutes
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
|
||||
# Default sysadmin credentials (created on first startup if admin table is empty)
|
||||
ADMIN_USERNAME=sysadmin
|
||||
ADMIN_PASSWORD=changeme
|
||||
10
cloud_backend/Dockerfile
Normal file
10
cloud_backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
44
cloud_backend/auth_utils.py
Normal file
44
cloud_backend/auth_utils.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from config import settings
|
||||
from database import get_db
|
||||
from models.admin import Admin
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
payload = data.copy()
|
||||
payload["exp"] = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def get_current_admin(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> Admin:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
admin = db.query(Admin).filter(Admin.username == username).first()
|
||||
if not admin:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin not found")
|
||||
return admin
|
||||
17
cloud_backend/config.py
Normal file
17
cloud_backend/config.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
_HERE = Path(__file__).parent
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
SECRET_KEY: str = "change-me-generate-a-long-random-string"
|
||||
DATABASE_URL: str = "sqlite:////app/data/cloud.db"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||
ADMIN_USERNAME: str = "sysadmin"
|
||||
ADMIN_PASSWORD: str = "changeme"
|
||||
|
||||
model_config = {"env_file": str(_HERE / ".env")}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
17
cloud_backend/database.py
Normal file
17
cloud_backend/database.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
from config import settings
|
||||
|
||||
connect_args = {"check_same_thread": False} if settings.DATABASE_URL.startswith("sqlite") else {}
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, connect_args=connect_args)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
51
cloud_backend/main.py
Normal file
51
cloud_backend/main.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from config import settings
|
||||
from database import engine, Base
|
||||
from auth_utils import hash_password
|
||||
import models.admin # noqa: F401
|
||||
import models.site # noqa: F401
|
||||
|
||||
from routers import auth, sites, heartbeat
|
||||
|
||||
|
||||
def _seed_default_admin():
|
||||
from sqlalchemy.orm import Session
|
||||
from models.admin import Admin
|
||||
|
||||
with Session(engine) as db:
|
||||
if not db.query(Admin).filter(Admin.username == settings.ADMIN_USERNAME).first():
|
||||
db.add(Admin(
|
||||
username=settings.ADMIN_USERNAME,
|
||||
password_hash=hash_password(settings.ADMIN_PASSWORD),
|
||||
role="sysadmin",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_seed_default_admin()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="POS Cloud Backend", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(sites.router, prefix="/api/sites", tags=["sites"])
|
||||
app.include_router(heartbeat.router, prefix="/api/heartbeat", tags=["heartbeat"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
0
cloud_backend/models/__init__.py
Normal file
0
cloud_backend/models/__init__.py
Normal file
11
cloud_backend/models/admin.py
Normal file
11
cloud_backend/models/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from database import Base
|
||||
|
||||
|
||||
class Admin(Base):
|
||||
__tablename__ = "admins"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String, unique=True, nullable=False)
|
||||
password_hash = Column(String, nullable=False)
|
||||
role = Column(String, default="sysadmin")
|
||||
21
cloud_backend/models/site.py
Normal file
21
cloud_backend/models/site.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
|
||||
|
||||
class Site(Base):
|
||||
__tablename__ = "sites"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
site_id = Column(String, unique=True, nullable=False, index=True)
|
||||
name = Column(String, nullable=False)
|
||||
owner_name = Column(String, nullable=False)
|
||||
contact_email = Column(String, nullable=False)
|
||||
secret_key_hash = Column(String, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_locked = Column(Boolean, default=False)
|
||||
lock_reason = Column(String, nullable=True)
|
||||
license_expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
last_seen_at = Column(DateTime(timezone=True), nullable=True)
|
||||
last_seen_ip = Column(String, nullable=True)
|
||||
10
cloud_backend/requirements.txt
Normal file
10
cloud_backend/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
sqlalchemy==2.0.35
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.9
|
||||
httpx==0.27.2
|
||||
0
cloud_backend/routers/__init__.py
Normal file
0
cloud_backend/routers/__init__.py
Normal file
18
cloud_backend/routers/auth.py
Normal file
18
cloud_backend/routers/auth.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth_utils import verify_password, create_access_token
|
||||
from database import get_db
|
||||
from models.admin import Admin
|
||||
from schemas.admin import LoginRequest, TokenOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenOut)
|
||||
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
admin = db.query(Admin).filter(Admin.username == body.username).first()
|
||||
if not admin or not verify_password(body.password, admin.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = create_access_token({"sub": admin.username, "role": admin.role})
|
||||
return TokenOut(access_token=token)
|
||||
37
cloud_backend/routers/heartbeat.py
Normal file
37
cloud_backend/routers/heartbeat.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, Request, status
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models.site import Site
|
||||
from schemas.site import HeartbeatRequest, HeartbeatResponse
|
||||
|
||||
router = APIRouter()
|
||||
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
@router.post("/", response_model=HeartbeatResponse)
|
||||
def heartbeat(
|
||||
body: HeartbeatRequest,
|
||||
request: Request,
|
||||
x_site_id: str = Header(..., alias="X-Site-ID"),
|
||||
x_site_key: str = Header(..., alias="X-Site-Key"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
site = db.query(Site).filter(Site.site_id == x_site_id).first()
|
||||
if not site or not _pwd.verify(x_site_key, site.secret_key_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid site credentials")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
site.last_seen_at = now
|
||||
site.last_seen_ip = request.client.host if request.client else None
|
||||
db.commit()
|
||||
|
||||
licensed = site.is_active and (site.license_expires_at.replace(tzinfo=timezone.utc) > now)
|
||||
return HeartbeatResponse(
|
||||
licensed=licensed,
|
||||
locked=site.is_locked,
|
||||
lock_reason=site.lock_reason,
|
||||
expires_at=site.license_expires_at,
|
||||
)
|
||||
90
cloud_backend/routers/sites.py
Normal file
90
cloud_backend/routers/sites.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import secrets
|
||||
import uuid
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth_utils import get_current_admin
|
||||
from database import get_db
|
||||
from models.site import Site
|
||||
from schemas.site import SiteCreate, SiteUpdate, SiteOut, SiteCreatedOut, LockRequest
|
||||
|
||||
router = APIRouter()
|
||||
_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SiteOut])
|
||||
def list_sites(db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
return db.query(Site).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=SiteCreatedOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_site(body: SiteCreate, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
raw_key = secrets.token_urlsafe(32)
|
||||
site = Site(
|
||||
site_id=str(uuid.uuid4()),
|
||||
name=body.name,
|
||||
owner_name=body.owner_name,
|
||||
contact_email=body.contact_email,
|
||||
secret_key_hash=_pwd.hash(raw_key),
|
||||
license_expires_at=body.license_expires_at,
|
||||
)
|
||||
db.add(site)
|
||||
db.commit()
|
||||
db.refresh(site)
|
||||
data = SiteOut.model_validate(site).model_dump()
|
||||
data["secret_key"] = raw_key
|
||||
return SiteCreatedOut(**data)
|
||||
|
||||
|
||||
@router.get("/{site_id}", response_model=SiteOut)
|
||||
def get_site(site_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
return site
|
||||
|
||||
|
||||
@router.put("/{site_id}", response_model=SiteOut)
|
||||
def update_site(site_id: str, body: SiteUpdate, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(site, field, value)
|
||||
db.commit()
|
||||
db.refresh(site)
|
||||
return site
|
||||
|
||||
|
||||
@router.post("/{site_id}/lock", response_model=SiteOut)
|
||||
def lock_site(site_id: str, body: LockRequest, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
site.is_locked = True
|
||||
site.lock_reason = body.reason
|
||||
db.commit()
|
||||
db.refresh(site)
|
||||
return site
|
||||
|
||||
|
||||
@router.post("/{site_id}/unlock", response_model=SiteOut)
|
||||
def unlock_site(site_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
site.is_locked = False
|
||||
site.lock_reason = None
|
||||
db.commit()
|
||||
db.refresh(site)
|
||||
return site
|
||||
|
||||
|
||||
@router.delete("/{site_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_site(site_id: str, db: Session = Depends(get_db), _=Depends(get_current_admin)):
|
||||
site = db.query(Site).filter(Site.site_id == site_id).first()
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
db.delete(site)
|
||||
db.commit()
|
||||
0
cloud_backend/schemas/__init__.py
Normal file
0
cloud_backend/schemas/__init__.py
Normal file
11
cloud_backend/schemas/admin.py
Normal file
11
cloud_backend/schemas/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenOut(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
53
cloud_backend/schemas/site.py
Normal file
53
cloud_backend/schemas/site.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SiteCreate(BaseModel):
|
||||
name: str
|
||||
owner_name: str
|
||||
contact_email: str
|
||||
license_expires_at: datetime
|
||||
|
||||
|
||||
class SiteUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
owner_name: str | None = None
|
||||
contact_email: str | None = None
|
||||
license_expires_at: datetime | None = None
|
||||
|
||||
|
||||
class SiteOut(BaseModel):
|
||||
id: int
|
||||
site_id: str
|
||||
name: str
|
||||
owner_name: str
|
||||
contact_email: str
|
||||
is_active: bool
|
||||
is_locked: bool
|
||||
lock_reason: str | None
|
||||
license_expires_at: datetime
|
||||
created_at: datetime
|
||||
last_seen_at: datetime | None
|
||||
last_seen_ip: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SiteCreatedOut(SiteOut):
|
||||
secret_key: str
|
||||
|
||||
|
||||
class LockRequest(BaseModel):
|
||||
reason: str
|
||||
|
||||
|
||||
class HeartbeatRequest(BaseModel):
|
||||
version: str = "1.0.0"
|
||||
uptime_seconds: int = 0
|
||||
|
||||
|
||||
class HeartbeatResponse(BaseModel):
|
||||
licensed: bool
|
||||
locked: bool
|
||||
lock_reason: str | None
|
||||
expires_at: datetime
|
||||
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
cloud_backend:
|
||||
build: ./cloud_backend
|
||||
ports:
|
||||
- "8001:8001"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./cloud_backend/.env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
|
||||
sysadmin_panel:
|
||||
build: ./sysadmin_panel
|
||||
ports:
|
||||
- "5175:5175"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./sysadmin_panel/.env
|
||||
depends_on:
|
||||
- cloud_backend
|
||||
12
sysadmin_panel/index.html
Normal file
12
sysadmin_panel/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>POS Sysadmin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3080
sysadmin_panel/package-lock.json
generated
Normal file
3080
sysadmin_panel/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
sysadmin_panel/package.json
Normal file
26
sysadmin_panel/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "sysadmin-panel",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
6
sysadmin_panel/postcss.config.js
Normal file
6
sysadmin_panel/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
81
sysadmin_panel/src/App.jsx
Normal file
81
sysadmin_panel/src/App.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'
|
||||
import useAuthStore from './store/authStore'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import SitesPage from './pages/SitesPage'
|
||||
import SiteDetailPage from './pages/SiteDetailPage'
|
||||
import RegisterSitePage from './pages/RegisterSitePage'
|
||||
|
||||
function Layout({ children }) {
|
||||
const { logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
function handleLogout() {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950">
|
||||
<nav className="border-b border-gray-800 bg-gray-950/80 backdrop-blur sticky top-0 z-10">
|
||||
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => navigate('/sites')}
|
||||
className="flex items-center gap-2 text-white font-semibold text-sm"
|
||||
>
|
||||
<div className="w-6 h-6 bg-cyan-500/20 border border-cyan-500/40 rounded flex items-center justify-center">
|
||||
<svg className="w-3 h-3 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
Sysadmin
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`text-xs px-2.5 py-1 rounded font-mono ${
|
||||
location.pathname.startsWith('/sites') ? 'text-cyan-400 bg-cyan-500/10' : 'text-gray-500'
|
||||
}`}>
|
||||
Sites
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 py-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RequireAuth({ children }) {
|
||||
const { token } = useAuthStore()
|
||||
if (!token) return <Navigate to="/login" replace />
|
||||
return children
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { token } = useAuthStore()
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={token ? <Navigate to="/sites" replace /> : <LoginPage />} />
|
||||
<Route path="/sites" element={
|
||||
<RequireAuth><Layout><SitesPage /></Layout></RequireAuth>
|
||||
} />
|
||||
<Route path="/sites/register" element={
|
||||
<RequireAuth><Layout><RegisterSitePage /></Layout></RequireAuth>
|
||||
} />
|
||||
<Route path="/sites/:siteId" element={
|
||||
<RequireAuth><Layout><SiteDetailPage /></Layout></RequireAuth>
|
||||
} />
|
||||
<Route path="*" element={<Navigate to="/sites" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
24
sysadmin_panel/src/api/client.js
Normal file
24
sysadmin_panel/src/api/client.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_CLOUD_URL || 'http://localhost:8001'
|
||||
|
||||
const client = axios.create({ baseURL: BASE_URL })
|
||||
|
||||
client.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('sysadmin_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
client.interceptors.response.use(
|
||||
res => res,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('sysadmin_token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default client
|
||||
29
sysadmin_panel/src/components/ConfirmModal.jsx
Normal file
29
sysadmin_panel/src/components/ConfirmModal.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
export default function ConfirmModal({ title, message, confirmLabel = 'Confirm', danger = false, onConfirm, onCancel, children }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md shadow-2xl">
|
||||
<h2 className="text-white font-semibold text-lg mb-2">{title}</h2>
|
||||
{message && <p className="text-gray-400 text-sm mb-4">{message}</p>}
|
||||
{children}
|
||||
<div className="flex gap-3 justify-end mt-5">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-gray-300 hover:text-white bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 text-sm font-semibold rounded-lg transition-colors ${
|
||||
danger
|
||||
? 'bg-red-600 hover:bg-red-500 text-white'
|
||||
: 'bg-cyan-600 hover:bg-cyan-500 text-white'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
sysadmin_panel/src/components/LicenseStatus.jsx
Normal file
17
sysadmin_panel/src/components/LicenseStatus.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export default function LicenseStatus({ site }) {
|
||||
const now = new Date()
|
||||
const expires = new Date(site.license_expires_at)
|
||||
const lastSeen = site.last_seen_at ? new Date(site.last_seen_at) : null
|
||||
const hoursAgo = lastSeen ? (now - lastSeen) / 1000 / 3600 : null
|
||||
|
||||
if (site.is_locked) {
|
||||
return <span className="inline-flex items-center gap-1.5 text-xs font-medium text-red-400"><span className="w-2 h-2 rounded-full bg-red-500" />Locked</span>
|
||||
}
|
||||
if (expires < now) {
|
||||
return <span className="inline-flex items-center gap-1.5 text-xs font-medium text-red-400"><span className="w-2 h-2 rounded-full bg-red-500" />Expired</span>
|
||||
}
|
||||
if (hoursAgo === null || hoursAgo > 12) {
|
||||
return <span className="inline-flex items-center gap-1.5 text-xs font-medium text-yellow-400"><span className="w-2 h-2 rounded-full bg-yellow-400" />No Heartbeat</span>
|
||||
}
|
||||
return <span className="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-400"><span className="w-2 h-2 rounded-full bg-emerald-400" />Active</span>
|
||||
}
|
||||
64
sysadmin_panel/src/components/SiteCard.jsx
Normal file
64
sysadmin_panel/src/components/SiteCard.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import LicenseStatus from './LicenseStatus'
|
||||
|
||||
function fmtDate(dt) {
|
||||
if (!dt) return '—'
|
||||
return new Date(dt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function fmtAgo(dt) {
|
||||
if (!dt) return 'Never'
|
||||
const diff = (Date.now() - new Date(dt)) / 1000
|
||||
if (diff < 60) return 'Just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
|
||||
export default function SiteCard({ site }) {
|
||||
const navigate = useNavigate()
|
||||
const expires = new Date(site.license_expires_at)
|
||||
const isExpired = expires < new Date()
|
||||
const isLocked = site.is_locked
|
||||
|
||||
const borderColor = isLocked || isExpired
|
||||
? 'border-red-800/60 hover:border-red-700'
|
||||
: site.last_seen_at && (Date.now() - new Date(site.last_seen_at)) / 3600000 < 12
|
||||
? 'border-emerald-800/40 hover:border-emerald-600'
|
||||
: 'border-yellow-800/40 hover:border-yellow-600'
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/sites/${site.site_id}`)}
|
||||
className={`bg-gray-900 border ${borderColor} rounded-xl p-4 cursor-pointer transition-all hover:bg-gray-800`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-white font-semibold text-sm truncate">{site.name}</h3>
|
||||
<p className="text-gray-500 text-xs truncate">{site.owner_name}</p>
|
||||
</div>
|
||||
<LicenseStatus site={site} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
<div>
|
||||
<span className="text-gray-600">Expires</span>
|
||||
<p className={`font-medium ${isExpired ? 'text-red-400' : 'text-gray-300'}`}>{fmtDate(site.license_expires_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Last seen</span>
|
||||
<p className="text-gray-300 font-medium">{fmtAgo(site.last_seen_at)}</p>
|
||||
</div>
|
||||
<div className="col-span-2 mt-1">
|
||||
<span className="text-gray-600 font-mono text-[10px]">{site.site_id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLocked && site.lock_reason && (
|
||||
<p className="mt-2 text-xs text-red-400 bg-red-900/20 border border-red-800/30 rounded px-2 py-1 truncate">
|
||||
Locked: {site.lock_reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
sysadmin_panel/src/index.css
Normal file
8
sysadmin_panel/src/index.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
background-color: #030712;
|
||||
color: #f9fafb;
|
||||
}
|
||||
25
sysadmin_panel/src/main.jsx
Normal file
25
sysadmin_panel/src/main.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: '#1f2937',
|
||||
color: '#f9fafb',
|
||||
border: '1px solid #374151',
|
||||
fontSize: '14px',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
89
sysadmin_panel/src/pages/LoginPage.jsx
Normal file
89
sysadmin_panel/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import useAuthStore from '../store/authStore'
|
||||
import client from '../api/client'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!username.trim() || !password) return
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await client.post('/api/auth/login', {
|
||||
username: username.trim(),
|
||||
password,
|
||||
})
|
||||
login(data.access_token)
|
||||
navigate('/sites', { replace: true })
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Invalid credentials')
|
||||
setPassword('')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 w-full max-w-sm shadow-2xl">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-cyan-500/10 border border-cyan-500/30 rounded-lg mb-4">
|
||||
<svg className="w-6 h-6 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-white">POS Sysadmin</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">Cloud Control Panel</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wider">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wider">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-900/20 border border-red-800/50 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username.trim() || !password}
|
||||
className="w-full bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-semibold py-2.5 rounded-lg text-sm transition-colors mt-2"
|
||||
>
|
||||
{loading ? 'Authenticating…' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
196
sysadmin_panel/src/pages/RegisterSitePage.jsx
Normal file
196
sysadmin_panel/src/pages/RegisterSitePage.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import client from '../api/client'
|
||||
|
||||
export default function RegisterSitePage() {
|
||||
const navigate = useNavigate()
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
owner_name: '',
|
||||
contact_email: '',
|
||||
license_expires_at: '',
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [created, setCreated] = useState(null) // { site_id, secret_key, name }
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
function update(k, v) {
|
||||
setForm(f => ({ ...f, [k]: v }))
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!form.name.trim() || !form.owner_name.trim() || !form.contact_email.trim() || !form.license_expires_at) return
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const { data } = await client.post('/api/sites/', {
|
||||
name: form.name.trim(),
|
||||
owner_name: form.owner_name.trim(),
|
||||
contact_email: form.contact_email.trim(),
|
||||
license_expires_at: new Date(form.license_expires_at).toISOString(),
|
||||
})
|
||||
setCreated(data)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Registration failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAll() {
|
||||
const text = `SITE_ID=${created.site_id}\nSITE_SECRET=${created.secret_key}`
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2500)
|
||||
}
|
||||
|
||||
if (created) {
|
||||
return (
|
||||
<div className="max-w-lg">
|
||||
<div className="bg-emerald-900/20 border border-emerald-700/50 rounded-xl p-6 mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 bg-emerald-500/20 rounded-full flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-white font-semibold">Site Registered: {created.name}</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-900/30 border border-yellow-700/50 rounded-lg px-4 py-3 mb-5 flex items-start gap-2">
|
||||
<svg className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
</svg>
|
||||
<p className="text-yellow-300 text-sm font-medium">
|
||||
Copy these credentials now. The secret key will <strong>never</strong> be shown again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-5">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 uppercase tracking-wider block mb-1">Site ID</label>
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 font-mono text-sm text-cyan-300 select-all break-all">
|
||||
{created.site_id}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 uppercase tracking-wider block mb-1">Secret Key</label>
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 font-mono text-sm text-cyan-300 select-all break-all">
|
||||
{created.secret_key}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 mb-5 bg-gray-900/60 border border-gray-700 rounded-lg px-3 py-2">
|
||||
<p className="font-medium text-gray-400 mb-1">Set these in the local backend <code>.env</code>:</p>
|
||||
<pre className="text-gray-300 font-mono whitespace-pre-wrap">SITE_ID={created.site_id}{'\n'}SITE_SECRET={created.secret_key}</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={copyAll}
|
||||
className="flex items-center gap-2 bg-cyan-700 hover:bg-cyan-600 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{copied ? 'Copied!' : 'Copy .env vars'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/sites/${created.site_id}`)}
|
||||
className="text-sm text-gray-400 hover:text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
View Site →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/sites')}
|
||||
className="text-sm text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
← Back to all sites
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<button
|
||||
onClick={() => navigate('/sites')}
|
||||
className="flex items-center gap-1.5 text-gray-500 hover:text-gray-300 text-sm mb-6 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
All Sites
|
||||
</button>
|
||||
|
||||
<h1 className="text-xl font-bold text-white mb-6">Register New Site</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wider">Restaurant Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => update('name', e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600"
|
||||
placeholder="e.g. Taverna Kostas"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wider">Owner Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.owner_name}
|
||||
onChange={e => update('owner_name', e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600"
|
||||
placeholder="e.g. Kostas Papadopoulos"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wider">Contact Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.contact_email}
|
||||
onChange={e => update('contact_email', e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600"
|
||||
placeholder="kostas@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wider">License Expiry</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.license_expires_at}
|
||||
onChange={e => update('license_expires_at', e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-900/20 border border-red-800/50 rounded-lg px-3 py-2">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-semibold py-2.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
{loading ? 'Registering…' : 'Register Site'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
293
sysadmin_panel/src/pages/SiteDetailPage.jsx
Normal file
293
sysadmin_panel/src/pages/SiteDetailPage.jsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import client from '../api/client'
|
||||
import LicenseStatus from '../components/LicenseStatus'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
|
||||
function fmtDt(dt) {
|
||||
if (!dt) return '—'
|
||||
return new Date(dt).toLocaleString('en-GB', {
|
||||
day: '2-digit', month: 'short', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function fmtDate(dt) {
|
||||
if (!dt) return ''
|
||||
return new Date(dt).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export default function SiteDetailPage() {
|
||||
const { siteId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [site, setSite] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license'
|
||||
const [lockReason, setLockReason] = useState('')
|
||||
const [newExpiry, setNewExpiry] = useState('')
|
||||
const [acting, setActing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await client.get(`/api/sites/${siteId}`)
|
||||
setSite(data)
|
||||
setNewExpiry(fmtDate(data.license_expires_at))
|
||||
} catch {
|
||||
setError('Site not found')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [siteId])
|
||||
|
||||
async function doLock() {
|
||||
if (!lockReason.trim()) return
|
||||
setActing(true)
|
||||
try {
|
||||
const { data } = await client.post(`/api/sites/${siteId}/lock`, { reason: lockReason.trim() })
|
||||
setSite(data)
|
||||
setModal(null)
|
||||
setLockReason('')
|
||||
toast.success('Site locked')
|
||||
} catch (e) {
|
||||
toast.error(e.response?.data?.detail || 'Failed to lock site')
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function doUnlock() {
|
||||
setActing(true)
|
||||
try {
|
||||
const { data } = await client.post(`/api/sites/${siteId}/unlock`)
|
||||
setSite(data)
|
||||
setModal(null)
|
||||
toast.success('Site unlocked')
|
||||
} catch (e) {
|
||||
toast.error(e.response?.data?.detail || 'Failed to unlock site')
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function doExtendLicense() {
|
||||
if (!newExpiry) return
|
||||
setActing(true)
|
||||
try {
|
||||
const { data } = await client.put(`/api/sites/${siteId}`, {
|
||||
license_expires_at: new Date(newExpiry).toISOString(),
|
||||
})
|
||||
setSite(data)
|
||||
setModal(null)
|
||||
toast.success('License updated')
|
||||
} catch (e) {
|
||||
toast.error(e.response?.data?.detail || 'Failed to update license')
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
setActing(true)
|
||||
try {
|
||||
await client.delete(`/api/sites/${siteId}`)
|
||||
toast.success('Site deregistered')
|
||||
navigate('/sites', { replace: true })
|
||||
} catch (e) {
|
||||
toast.error(e.response?.data?.detail || 'Failed to delete site')
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-center py-16 text-gray-600">Loading…</div>
|
||||
if (error || !site) return <div className="text-red-400 py-8">{error || 'Not found'}</div>
|
||||
|
||||
const expires = new Date(site.license_expires_at)
|
||||
const isExpired = expires < new Date()
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<button
|
||||
onClick={() => navigate('/sites')}
|
||||
className="flex items-center gap-1.5 text-gray-500 hover:text-gray-300 text-sm mb-6 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
All Sites
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">{site.name}</h1>
|
||||
<p className="text-gray-500 text-sm">{site.owner_name} · {site.contact_email}</p>
|
||||
</div>
|
||||
<LicenseStatus site={site} />
|
||||
</div>
|
||||
|
||||
{/* Site info */}
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mb-4">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Site Info</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Site ID</span>
|
||||
<span className="text-gray-300 font-mono text-xs">{site.site_id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Registered</span>
|
||||
<span className="text-gray-300">{fmtDt(site.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Contact</span>
|
||||
<span className="text-gray-300">{site.contact_email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* License */}
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">License</h2>
|
||||
<button
|
||||
onClick={() => setModal('license')}
|
||||
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||
>
|
||||
Extend →
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Expires</span>
|
||||
<span className={`font-medium ${isExpired ? 'text-red-400' : 'text-gray-300'}`}>
|
||||
{fmtDt(site.license_expires_at)}
|
||||
{isExpired && ' (EXPIRED)'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heartbeat */}
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mb-4">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Heartbeat</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Last seen</span>
|
||||
<span className="text-gray-300">{fmtDt(site.last_seen_at)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Last IP</span>
|
||||
<span className="text-gray-300 font-mono text-xs">{site.last_seen_ip || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lock status */}
|
||||
{site.is_locked && (
|
||||
<div className="bg-red-900/20 border border-red-800/50 rounded-xl p-4 mb-4">
|
||||
<h2 className="text-xs font-semibold text-red-500 uppercase tracking-wider mb-2">Locked</h2>
|
||||
<p className="text-red-300 text-sm">{site.lock_reason || 'No reason given'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{site.is_locked ? (
|
||||
<button
|
||||
onClick={() => setModal('unlock')}
|
||||
className="px-4 py-2 text-sm font-medium bg-emerald-700 hover:bg-emerald-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Unlock Site
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setModal('lock')}
|
||||
className="px-4 py-2 text-sm font-medium bg-yellow-700 hover:bg-yellow-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Lock Site
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setNewExpiry(fmtDate(site.license_expires_at)); setModal('license') }}
|
||||
className="px-4 py-2 text-sm font-medium bg-cyan-700 hover:bg-cyan-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Extend License
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModal('delete')}
|
||||
className="px-4 py-2 text-sm font-medium bg-red-900/50 hover:bg-red-800 border border-red-700 text-red-300 rounded-lg transition-colors ml-auto"
|
||||
>
|
||||
Deregister Site
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{modal === 'lock' && (
|
||||
<ConfirmModal
|
||||
title="Lock Site"
|
||||
confirmLabel={acting ? 'Locking…' : 'Lock Site'}
|
||||
danger
|
||||
onCancel={() => { setModal(null); setLockReason('') }}
|
||||
onConfirm={doLock}
|
||||
>
|
||||
<textarea
|
||||
value={lockReason}
|
||||
onChange={e => setLockReason(e.target.value)}
|
||||
placeholder="Reason for locking (required)"
|
||||
rows={3}
|
||||
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-red-500 mb-2 resize-none placeholder-gray-600"
|
||||
/>
|
||||
</ConfirmModal>
|
||||
)}
|
||||
|
||||
{modal === 'unlock' && (
|
||||
<ConfirmModal
|
||||
title="Unlock Site"
|
||||
message={`Unlock "${site.name}"? The site will be able to connect again.`}
|
||||
confirmLabel={acting ? 'Unlocking…' : 'Unlock Site'}
|
||||
onCancel={() => setModal(null)}
|
||||
onConfirm={doUnlock}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modal === 'license' && (
|
||||
<ConfirmModal
|
||||
title="Extend License"
|
||||
confirmLabel={acting ? 'Saving…' : 'Save'}
|
||||
onCancel={() => setModal(null)}
|
||||
onConfirm={doExtendLicense}
|
||||
>
|
||||
<div className="mb-2">
|
||||
<label className="block text-xs text-gray-400 mb-1.5">New expiry date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newExpiry}
|
||||
onChange={e => setNewExpiry(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||
/>
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
)}
|
||||
|
||||
{modal === 'delete' && (
|
||||
<ConfirmModal
|
||||
title="Deregister Site"
|
||||
message={`This will permanently remove "${site.name}" from the system. The local backend will lose its license on the next heartbeat. This cannot be undone.`}
|
||||
confirmLabel={acting ? 'Deleting…' : 'Deregister'}
|
||||
danger
|
||||
onCancel={() => setModal(null)}
|
||||
onConfirm={doDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
sysadmin_panel/src/pages/SitesPage.jsx
Normal file
97
sysadmin_panel/src/pages/SitesPage.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import client from '../api/client'
|
||||
import SiteCard from '../components/SiteCard'
|
||||
|
||||
export default function SitesPage() {
|
||||
const [sites, setSites] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const fetchSites = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await client.get('/api/sites/')
|
||||
setSites(data)
|
||||
setError('')
|
||||
} catch {
|
||||
setError('Failed to load sites')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSites()
|
||||
const id = setInterval(fetchSites, 30000)
|
||||
return () => clearInterval(id)
|
||||
}, [fetchSites])
|
||||
|
||||
const active = sites.filter(s => !s.is_locked && new Date(s.license_expires_at) > new Date()).length
|
||||
const locked = sites.filter(s => s.is_locked).length
|
||||
const expired = sites.filter(s => !s.is_locked && new Date(s.license_expires_at) <= new Date()).length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">Sites</h1>
|
||||
<p className="text-gray-500 text-sm mt-0.5">{sites.length} registered</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/sites/register')}
|
||||
className="flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Register New Site
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary row */}
|
||||
{sites.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-center">
|
||||
<p className="text-2xl font-bold text-emerald-400">{active}</p>
|
||||
<p className="text-gray-500 text-xs mt-0.5">Active</p>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-center">
|
||||
<p className="text-2xl font-bold text-yellow-400">{locked}</p>
|
||||
<p className="text-gray-500 text-xs mt-0.5">Locked</p>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-center">
|
||||
<p className="text-2xl font-bold text-red-400">{expired}</p>
|
||||
<p className="text-gray-500 text-xs mt-0.5">Expired</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-16 text-gray-600">Loading…</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-red-400 bg-red-900/20 border border-red-800/50 rounded-lg px-4 py-3 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && sites.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-gray-600 text-sm">No sites registered yet.</p>
|
||||
<button
|
||||
onClick={() => navigate('/sites/register')}
|
||||
className="mt-4 text-cyan-400 hover:text-cyan-300 text-sm underline"
|
||||
>
|
||||
Register your first site
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sites.map(site => (
|
||||
<SiteCard key={site.site_id} site={site} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
sysadmin_panel/src/store/authStore.js
Normal file
17
sysadmin_panel/src/store/authStore.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
const useAuthStore = create((set) => ({
|
||||
token: localStorage.getItem('sysadmin_token') || null,
|
||||
|
||||
login(token) {
|
||||
localStorage.setItem('sysadmin_token', token)
|
||||
set({ token })
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('sysadmin_token')
|
||||
set({ token: null })
|
||||
},
|
||||
}))
|
||||
|
||||
export default useAuthStore
|
||||
17
sysadmin_panel/tailwind.config.js
Normal file
17
sysadmin_panel/tailwind.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
cyan: {
|
||||
400: '#22d3ee',
|
||||
500: '#06b6d4',
|
||||
600: '#0891b2',
|
||||
700: '#0e7490',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
10
sysadmin_panel/vite.config.js
Normal file
10
sysadmin_panel/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5175,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user