Initial Commit. Split cloud service from the combined project

This commit is contained in:
2026-05-08 13:20:23 +03:00
commit 4cbf8986df
37 changed files with 4543 additions and 0 deletions

28
.gitignore vendored Normal file
View 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/

View 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
View 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"]

View 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
View 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
View 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
View 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"}

View File

View 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")

View 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)

View 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

View File

View 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)

View 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,
)

View 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()

View File

View 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"

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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>
)
}

View 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

View 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>
)
}

View 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>
}

View 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>
)
}

View File

@@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background-color: #030712;
color: #f9fafb;
}

View 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>
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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

View 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: [],
}

View 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',
},
})