Add super admin panel and update README
- Admin account bootstrapped from ADMIN_EMAIL/ADMIN_PASSWORD env vars on startup - Admin panel: list users, view content, reset passwords, disable/delete accounts - is_admin and is_disabled columns on users table - Disabled accounts blocked at login - README updated with admin setup instructions and panel docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,3 +2,6 @@ MYSQL_ROOT_PASSWORD=sproutly_root_secret
|
|||||||
MYSQL_USER=sproutly
|
MYSQL_USER=sproutly
|
||||||
MYSQL_PASSWORD=sproutly_secret
|
MYSQL_PASSWORD=sproutly_secret
|
||||||
SECRET_KEY=your-secret-key-change-this
|
SECRET_KEY=your-secret-key-change-this
|
||||||
|
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
ADMIN_PASSWORD=change-this-password
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -9,6 +9,7 @@ Sproutly takes the guesswork out of seed starting. Enter your plant varieties on
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Multi-user** — each user has their own account with fully isolated data
|
- **Multi-user** — each user has their own account with fully isolated data
|
||||||
|
- **Admin panel** — manage all user accounts: view content, reset passwords, disable, or delete
|
||||||
- **Dashboard** — at-a-glance view of overdue, today's, and upcoming tasks with a full year planting timeline
|
- **Dashboard** — at-a-glance view of overdue, today's, and upcoming tasks with a full year planting timeline
|
||||||
- **Seed Library** — manage plant varieties with frost-relative timing, germination days, sun/water requirements
|
- **Seed Library** — manage plant varieties with frost-relative timing, germination days, sun/water requirements
|
||||||
- **Garden Tracker** — log growing batches and track status from `planned` → `germinating` → `seedling` → `potted up` → `hardening off` → `garden` → `harvested`
|
- **Garden Tracker** — log growing batches and track status from `planned` → `germinating` → `seedling` → `potted up` → `hardening off` → `garden` → `harvested`
|
||||||
@@ -60,6 +61,8 @@ MYSQL_ROOT_PASSWORD=sproutly_root_secret
|
|||||||
MYSQL_USER=sproutly
|
MYSQL_USER=sproutly
|
||||||
MYSQL_PASSWORD=sproutly_secret
|
MYSQL_PASSWORD=sproutly_secret
|
||||||
SECRET_KEY=your-secret-key-change-this
|
SECRET_KEY=your-secret-key-change-this
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
ADMIN_PASSWORD=change-this-password
|
||||||
```
|
```
|
||||||
|
|
||||||
`SECRET_KEY` is used to sign JWT tokens. Generate a secure value with:
|
`SECRET_KEY` is used to sign JWT tokens. Generate a secure value with:
|
||||||
@@ -68,6 +71,8 @@ SECRET_KEY=your-secret-key-change-this
|
|||||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`ADMIN_EMAIL` and `ADMIN_PASSWORD` define the super admin account. This account is created (or updated) automatically every time the backend starts — changing these values in `.env` and restarting is all that's needed to update the credentials.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -84,6 +89,7 @@ sproutly/
|
|||||||
│ ├── database.py
|
│ ├── database.py
|
||||||
│ └── routers/
|
│ └── routers/
|
||||||
│ ├── auth.py # /auth/register, /auth/login, /auth/me
|
│ ├── auth.py # /auth/register, /auth/login, /auth/me
|
||||||
|
│ ├── admin.py # /admin/users — admin-only user management
|
||||||
│ ├── varieties.py
|
│ ├── varieties.py
|
||||||
│ ├── batches.py
|
│ ├── batches.py
|
||||||
│ ├── dashboard.py
|
│ ├── dashboard.py
|
||||||
@@ -114,6 +120,24 @@ Key endpoints:
|
|||||||
- `POST /api/notifications/test` — send test ntfy notification
|
- `POST /api/notifications/test` — send test ntfy notification
|
||||||
- `POST /api/notifications/daily` — trigger daily summary
|
- `POST /api/notifications/daily` — trigger daily summary
|
||||||
- `GET /api/notifications/log` — recent notification history
|
- `GET /api/notifications/log` — recent notification history
|
||||||
|
- `GET /api/admin/users` — list all users with stats (admin only)
|
||||||
|
- `GET /api/admin/users/{id}/varieties` — view a user's seed library (admin only)
|
||||||
|
- `GET /api/admin/users/{id}/batches` — view a user's batches (admin only)
|
||||||
|
- `POST /api/admin/users/{id}/reset-password` — reset a user's password (admin only)
|
||||||
|
- `POST /api/admin/users/{id}/disable` — toggle account disabled state (admin only)
|
||||||
|
- `DELETE /api/admin/users/{id}` — delete a user and all their data (admin only)
|
||||||
|
|
||||||
|
## Admin Panel
|
||||||
|
|
||||||
|
Log in with the `ADMIN_EMAIL` / `ADMIN_PASSWORD` credentials from your `.env`. Once logged in, an **Admin** link appears in the sidebar. From there you can:
|
||||||
|
|
||||||
|
- View all registered users with their variety and batch counts
|
||||||
|
- Browse any user's seed library and growing batches
|
||||||
|
- Reset a user's password
|
||||||
|
- Disable or re-enable an account
|
||||||
|
- Permanently delete an account and all associated data
|
||||||
|
|
||||||
|
The admin account itself cannot be disabled or deleted through the panel.
|
||||||
|
|
||||||
## Ntfy Authentication
|
## Ntfy Authentication
|
||||||
|
|
||||||
|
|||||||
@@ -45,3 +45,9 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(
|
|||||||
if not user:
|
if not user:
|
||||||
raise exc
|
raise exc
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
|
return current_user
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -7,10 +8,11 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
from database import SessionLocal
|
from database import SessionLocal
|
||||||
from models import Settings, NotificationLog
|
from models import Settings, NotificationLog, User
|
||||||
from routers import varieties, batches, dashboard, settings, notifications
|
from routers import varieties, batches, dashboard, settings, notifications
|
||||||
from routers import auth as auth_router
|
from routers import auth as auth_router, admin as admin_router
|
||||||
from routers.notifications import build_daily_summary, send_ntfy
|
from routers.notifications import build_daily_summary, send_ntfy
|
||||||
|
from auth import hash_password
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger("sproutly")
|
logger = logging.getLogger("sproutly")
|
||||||
@@ -49,8 +51,37 @@ def get_notification_schedule(db) -> tuple[int, int]:
|
|||||||
return 7, 0
|
return 7, 0
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_admin(retries: int = 5, delay: float = 2.0):
|
||||||
|
import time
|
||||||
|
admin_email = os.environ.get("ADMIN_EMAIL")
|
||||||
|
admin_password = os.environ.get("ADMIN_PASSWORD")
|
||||||
|
if not admin_email or not admin_password:
|
||||||
|
return
|
||||||
|
for attempt in range(retries):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
admin = db.query(User).filter(User.email == admin_email).first()
|
||||||
|
if admin:
|
||||||
|
admin.hashed_password = hash_password(admin_password)
|
||||||
|
admin.is_admin = True
|
||||||
|
admin.is_disabled = False
|
||||||
|
else:
|
||||||
|
admin = User(email=admin_email, hashed_password=hash_password(admin_password), is_admin=True)
|
||||||
|
db.add(admin)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Admin user ready: {admin_email}")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Admin bootstrap attempt {attempt + 1} failed: {e}")
|
||||||
|
time.sleep(delay)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
logger.error("Admin bootstrap failed after all retries")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
bootstrap_admin()
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
hour, minute = get_notification_schedule(db)
|
hour, minute = get_notification_schedule(db)
|
||||||
db.close()
|
db.close()
|
||||||
@@ -77,6 +108,7 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth_router.router)
|
app.include_router(auth_router.router)
|
||||||
|
app.include_router(admin_router.router)
|
||||||
app.include_router(varieties.router)
|
app.include_router(varieties.router)
|
||||||
app.include_router(batches.router)
|
app.include_router(batches.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class User(Base):
|
|||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
email = Column(String(255), unique=True, nullable=False)
|
email = Column(String(255), unique=True, nullable=False)
|
||||||
hashed_password = Column(String(255), nullable=False)
|
hashed_password = Column(String(255), nullable=False)
|
||||||
|
is_admin = Column(Boolean, default=False, nullable=False)
|
||||||
|
is_disabled = Column(Boolean, default=False, nullable=False)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
varieties = relationship("Variety", back_populates="user", cascade="all, delete-orphan")
|
varieties = relationship("Variety", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|||||||
88
backend/routers/admin.py
Normal file
88
backend/routers/admin.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from auth import get_admin_user, hash_password
|
||||||
|
from database import get_db
|
||||||
|
from models import Batch, User, Variety
|
||||||
|
from schemas import AdminResetPassword, AdminUserOut, BatchOut, VarietyOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
def _user_stats(db: Session, user_id: int) -> dict:
|
||||||
|
return {
|
||||||
|
"variety_count": db.query(Variety).filter(Variety.user_id == user_id).count(),
|
||||||
|
"batch_count": db.query(Batch).filter(Batch.user_id == user_id).count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=List[AdminUserOut])
|
||||||
|
def list_users(db: Session = Depends(get_db), _: User = Depends(get_admin_user)):
|
||||||
|
users = db.query(User).order_by(User.created_at).all()
|
||||||
|
return [
|
||||||
|
AdminUserOut(
|
||||||
|
id=u.id, email=u.email, is_admin=u.is_admin, is_disabled=u.is_disabled,
|
||||||
|
created_at=u.created_at, **_user_stats(db, u.id)
|
||||||
|
)
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}/varieties", response_model=List[VarietyOut])
|
||||||
|
def user_varieties(user_id: int, db: Session = Depends(get_db), _: User = Depends(get_admin_user)):
|
||||||
|
_require_user(db, user_id)
|
||||||
|
return db.query(Variety).filter(Variety.user_id == user_id).order_by(Variety.category, Variety.name).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}/batches", response_model=List[BatchOut])
|
||||||
|
def user_batches(user_id: int, db: Session = Depends(get_db), _: User = Depends(get_admin_user)):
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
_require_user(db, user_id)
|
||||||
|
return (
|
||||||
|
db.query(Batch)
|
||||||
|
.options(joinedload(Batch.variety))
|
||||||
|
.filter(Batch.user_id == user_id)
|
||||||
|
.order_by(Batch.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/reset-password")
|
||||||
|
def reset_password(user_id: int, data: AdminResetPassword, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
||||||
|
user = _require_user(db, user_id)
|
||||||
|
if not data.new_password or len(data.new_password) < 8:
|
||||||
|
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||||
|
user.hashed_password = hash_password(data.new_password)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/disable")
|
||||||
|
def toggle_disable(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
||||||
|
user = _require_user(db, user_id)
|
||||||
|
if user.id == admin.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot disable your own account")
|
||||||
|
if user.is_admin:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot disable another admin account")
|
||||||
|
user.is_disabled = not user.is_disabled
|
||||||
|
db.commit()
|
||||||
|
return {"is_disabled": user.is_disabled}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}", status_code=204)
|
||||||
|
def delete_user(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
||||||
|
user = _require_user(db, user_id)
|
||||||
|
if user.id == admin.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||||||
|
if user.is_admin:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete another admin account")
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_user(db: Session, user_id: int) -> User:
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return user
|
||||||
@@ -46,6 +46,8 @@ def login(data: UserLogin, db: Session = Depends(get_db)):
|
|||||||
user = db.query(User).filter(User.email == data.email).first()
|
user = db.query(User).filter(User.email == data.email).first()
|
||||||
if not user or not verify_password(data.password, user.hashed_password):
|
if not user or not verify_password(data.password, user.hashed_password):
|
||||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||||
|
if user.is_disabled:
|
||||||
|
raise HTTPException(status_code=403, detail="Account has been disabled")
|
||||||
return {"access_token": create_access_token(user.id), "token_type": "bearer"}
|
return {"access_token": create_access_token(user.id), "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,18 +13,34 @@ class UserCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserLogin(BaseModel):
|
class UserLogin(BaseModel):
|
||||||
email: EmailStr
|
email: str
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class UserOut(BaseModel):
|
class UserOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
email: str
|
email: str
|
||||||
|
is_admin: bool = False
|
||||||
|
is_disabled: bool = False
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
email: str
|
||||||
|
is_admin: bool
|
||||||
|
is_disabled: bool
|
||||||
|
created_at: Optional[datetime]
|
||||||
|
variety_count: int
|
||||||
|
batch_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class AdminResetPassword(BaseModel):
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ services:
|
|||||||
DB_USER: ${MYSQL_USER}
|
DB_USER: ${MYSQL_USER}
|
||||||
DB_PASSWORD: ${MYSQL_PASSWORD}
|
DB_PASSWORD: ${MYSQL_PASSWORD}
|
||||||
SECRET_KEY: ${SECRET_KEY}
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
email VARCHAR(255) NOT NULL UNIQUE,
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
hashed_password VARCHAR(255) NOT NULL,
|
hashed_password VARCHAR(255) NOT NULL,
|
||||||
|
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -681,3 +681,27 @@ a:hover { text-decoration: underline; }
|
|||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.btn-logout:hover { background: var(--border); color: var(--text); }
|
.btn-logout:hover { background: var(--border); color: var(--text); }
|
||||||
|
|
||||||
|
/* ===== Admin ===== */
|
||||||
|
.admin-table-wrap { overflow-x: auto; }
|
||||||
|
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||||||
|
.admin-table th { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 2px solid var(--border); color: var(--text-light); font-weight: 600; white-space: nowrap; }
|
||||||
|
.admin-table td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||||||
|
.admin-table tr:last-child td { border-bottom: none; }
|
||||||
|
.admin-email { font-weight: 500; }
|
||||||
|
.admin-date, .admin-num { color: var(--text-light); white-space: nowrap; }
|
||||||
|
.admin-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
|
||||||
|
.badge-admin { background: var(--green-dark); color: #fff; font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: 99px; vertical-align: middle; }
|
||||||
|
.status-pill { font-size: 0.78rem; padding: 0.2rem 0.55rem; border-radius: 99px; font-weight: 500; }
|
||||||
|
.status-pill.active { background: #d1fae5; color: #065f46; }
|
||||||
|
.status-pill.disabled { background: #fee2e2; color: #991b1b; }
|
||||||
|
.btn-xs { padding: 0.2rem 0.55rem; font-size: 0.78rem; }
|
||||||
|
.btn-warn { background: #fef3c7; color: #92400e; border: 1px solid #fde68a; }
|
||||||
|
.btn-warn:hover { background: #fde68a; }
|
||||||
|
.btn-danger { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
|
||||||
|
.btn-danger:hover { background: #fca5a5; }
|
||||||
|
.variety-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 0.35rem; vertical-align: middle; }
|
||||||
|
.admin-view-tabs { display: flex; gap: 0; margin-bottom: 1rem; border-bottom: 2px solid var(--border); }
|
||||||
|
.admin-view-tab { background: none; border: none; padding: 0.5rem 1rem; font-size: 0.9rem; cursor: pointer; color: var(--text-light); border-bottom: 2px solid transparent; margin-bottom: -2px; }
|
||||||
|
.admin-view-tab.active { color: var(--green-dark); border-bottom-color: var(--green-dark); font-weight: 500; }
|
||||||
|
.admin-view-panel { max-height: 60vh; overflow-y: auto; }
|
||||||
|
|||||||
@@ -73,6 +73,9 @@
|
|||||||
<a href="#settings" class="nav-link" data-page="settings">
|
<a href="#settings" class="nav-link" data-page="settings">
|
||||||
<span class="nav-icon">⚙</span> Settings
|
<span class="nav-icon">⚙</span> Settings
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#admin" class="nav-link admin-only hidden" data-page="admin">
|
||||||
|
<span class="nav-icon">🔐</span> Admin
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<span id="sidebar-user" class="sidebar-user"></span>
|
<span id="sidebar-user" class="sidebar-user"></span>
|
||||||
@@ -277,6 +280,19 @@
|
|||||||
<span id="settings-status" class="settings-status"></span>
|
<span id="settings-status" class="settings-status"></span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<!-- ADMIN -->
|
||||||
|
<section id="page-admin" class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Admin</h1>
|
||||||
|
<p class="page-subtitle">Manage user accounts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="admin-users-container">
|
||||||
|
<div class="empty-state">Loading users...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- MODALS -->
|
<!-- MODALS -->
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ const Auth = (() => {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
localStorage.setItem('sproutly_token', data.access_token);
|
localStorage.setItem('sproutly_token', data.access_token);
|
||||||
localStorage.setItem('sproutly_user', email);
|
localStorage.setItem('sproutly_user', email);
|
||||||
showApp();
|
const user = await apiFetch('/auth/me');
|
||||||
initApp();
|
showApp(user);
|
||||||
|
initApp(user);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errEl.textContent = e.message;
|
errEl.textContent = e.message;
|
||||||
errEl.classList.remove('hidden');
|
errEl.classList.remove('hidden');
|
||||||
@@ -71,8 +72,9 @@ const Auth = (() => {
|
|||||||
const data = await loginRes.json();
|
const data = await loginRes.json();
|
||||||
localStorage.setItem('sproutly_token', data.access_token);
|
localStorage.setItem('sproutly_token', data.access_token);
|
||||||
localStorage.setItem('sproutly_user', email);
|
localStorage.setItem('sproutly_user', email);
|
||||||
showApp();
|
const user = await apiFetch('/auth/me');
|
||||||
initApp();
|
showApp(user);
|
||||||
|
initApp(user);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errEl.textContent = e.message;
|
errEl.textContent = e.message;
|
||||||
errEl.classList.remove('hidden');
|
errEl.classList.remove('hidden');
|
||||||
@@ -90,10 +92,10 @@ const Auth = (() => {
|
|||||||
return { showTab, submit, submitRegister, logout };
|
return { showTab, submit, submitRegister, logout };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function showApp() {
|
function showApp(user) {
|
||||||
document.getElementById('auth-screen').classList.add('hidden');
|
document.getElementById('auth-screen').classList.add('hidden');
|
||||||
document.getElementById('app-shell').classList.remove('hidden');
|
document.getElementById('app-shell').classList.remove('hidden');
|
||||||
const email = localStorage.getItem('sproutly_user') || '';
|
const email = (user?.email) || localStorage.getItem('sproutly_user') || '';
|
||||||
document.getElementById('sidebar-user').textContent = email;
|
document.getElementById('sidebar-user').textContent = email;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +199,7 @@ function navigate(page) {
|
|||||||
if (page === 'varieties') loadVarieties();
|
if (page === 'varieties') loadVarieties();
|
||||||
if (page === 'garden') loadGarden();
|
if (page === 'garden') loadGarden();
|
||||||
if (page === 'settings') loadSettings();
|
if (page === 'settings') loadSettings();
|
||||||
|
if (page === 'admin') loadAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Dashboard =====
|
// ===== Dashboard =====
|
||||||
@@ -890,14 +893,183 @@ async function deleteBatch(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Admin =====
|
||||||
|
async function loadAdmin() {
|
||||||
|
const container = document.getElementById('admin-users-container');
|
||||||
|
try {
|
||||||
|
const users = await api.get('/admin/users');
|
||||||
|
if (!users.length) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No users found.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Joined</th>
|
||||||
|
<th>Varieties</th>
|
||||||
|
<th>Batches</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${users.map(u => `
|
||||||
|
<tr id="admin-row-${u.id}">
|
||||||
|
<td><span class="admin-email">${esc(u.email)}</span>${u.is_admin ? ' <span class="badge-admin">admin</span>' : ''}</td>
|
||||||
|
<td class="admin-date">${fmt(u.created_at)}</td>
|
||||||
|
<td class="admin-num">${u.variety_count}</td>
|
||||||
|
<td class="admin-num">${u.batch_count}</td>
|
||||||
|
<td><span class="status-pill ${u.is_disabled ? 'disabled' : 'active'}">${u.is_disabled ? 'Disabled' : 'Active'}</span></td>
|
||||||
|
<td class="admin-actions">
|
||||||
|
<button class="btn btn-xs btn-secondary" onclick="App.adminViewUser(${u.id}, '${esc(u.email)}')">View</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" onclick="App.adminResetPassword(${u.id}, '${esc(u.email)}')">Reset PW</button>
|
||||||
|
${!u.is_admin ? `<button class="btn btn-xs ${u.is_disabled ? 'btn-secondary' : 'btn-warn'}" onclick="App.adminToggleDisable(${u.id}, ${u.is_disabled})">${u.is_disabled ? 'Enable' : 'Disable'}</button>` : ''}
|
||||||
|
${!u.is_admin ? `<button class="btn btn-xs btn-danger" onclick="App.adminDeleteUser(${u.id}, '${esc(u.email)}')">Delete</button>` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<div class="empty-state">Error: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminViewUser(id, email) {
|
||||||
|
document.getElementById('modal-title').textContent = `User: ${email}`;
|
||||||
|
document.getElementById('modal-body').innerHTML = '<div class="empty-state">Loading...</div>';
|
||||||
|
document.getElementById('modal-overlay').classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [varieties, batches] = await Promise.all([
|
||||||
|
api.get(`/admin/users/${id}/varieties`),
|
||||||
|
api.get(`/admin/users/${id}/batches`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
document.getElementById('modal-body').innerHTML = `
|
||||||
|
<div class="admin-view-tabs">
|
||||||
|
<button class="admin-view-tab active" onclick="App.adminSwitchTab(this, 'av-varieties')">Varieties (${varieties.length})</button>
|
||||||
|
<button class="admin-view-tab" onclick="App.adminSwitchTab(this, 'av-batches')">Batches (${batches.length})</button>
|
||||||
|
</div>
|
||||||
|
<div id="av-varieties" class="admin-view-panel">
|
||||||
|
${varieties.length ? `
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead><tr><th>Name</th><th>Category</th><th>Start wks</th><th>Garden wks</th></tr></thead>
|
||||||
|
<tbody>${varieties.map(v => `
|
||||||
|
<tr>
|
||||||
|
<td><span class="variety-dot" style="background:${v.color}"></span>${esc(v.name)}${v.variety_name ? ` <em>${esc(v.variety_name)}</em>` : ''}</td>
|
||||||
|
<td>${v.category}</td>
|
||||||
|
<td>${v.weeks_to_start ?? '—'}</td>
|
||||||
|
<td>${v.weeks_to_garden ?? '—'}</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>` : '<div class="empty-state">No varieties.</div>'}
|
||||||
|
</div>
|
||||||
|
<div id="av-batches" class="admin-view-panel hidden">
|
||||||
|
${batches.length ? `
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead><tr><th>Plant</th><th>Label</th><th>Status</th><th>Sown</th></tr></thead>
|
||||||
|
<tbody>${batches.map(b => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(b.variety?.name || '—')}</td>
|
||||||
|
<td>${esc(b.label || '—')}</td>
|
||||||
|
<td>${statusLabel(b.status)}</td>
|
||||||
|
<td>${fmt(b.sow_date)}</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>` : '<div class="empty-state">No batches.</div>'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('modal-body').innerHTML = `<div class="empty-state">Error: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminSwitchTab(btn, panelId) {
|
||||||
|
document.querySelectorAll('.admin-view-tab').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.admin-view-panel').forEach(p => p.classList.add('hidden'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById(panelId).classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminResetPassword(id, email) {
|
||||||
|
document.getElementById('modal-title').textContent = `Reset Password: ${email}`;
|
||||||
|
document.getElementById('modal-body').innerHTML = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">New Password</label>
|
||||||
|
<input type="password" id="admin-new-pw" class="form-input" placeholder="At least 8 characters"
|
||||||
|
onkeydown="if(event.key==='Enter') App.adminSubmitReset(${id})" />
|
||||||
|
</div>
|
||||||
|
<div id="admin-pw-error" class="auth-msg error hidden"></div>
|
||||||
|
<div class="btn-row" style="margin-top:1rem">
|
||||||
|
<button class="btn btn-primary" onclick="App.adminSubmitReset(${id})">Reset Password</button>
|
||||||
|
<button class="btn btn-secondary" onclick="App.closeModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('modal-overlay').classList.remove('hidden');
|
||||||
|
setTimeout(() => document.getElementById('admin-new-pw')?.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminSubmitReset(id) {
|
||||||
|
const pw = document.getElementById('admin-new-pw').value;
|
||||||
|
const errEl = document.getElementById('admin-pw-error');
|
||||||
|
if (pw.length < 8) {
|
||||||
|
errEl.textContent = 'Password must be at least 8 characters';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/users/${id}/reset-password`, { new_password: pw });
|
||||||
|
closeModal();
|
||||||
|
toast('Password reset successfully');
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message;
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminToggleDisable(id, currentlyDisabled) {
|
||||||
|
try {
|
||||||
|
const result = await api.post(`/admin/users/${id}/disable`, {});
|
||||||
|
toast(result.is_disabled ? 'Account disabled' : 'Account enabled');
|
||||||
|
loadAdmin();
|
||||||
|
} catch (e) {
|
||||||
|
toast('Error: ' + e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminDeleteUser(id, email) {
|
||||||
|
if (!confirm(`Permanently delete account "${email}" and all their data?\n\nThis cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/users/${id}`);
|
||||||
|
toast('User deleted');
|
||||||
|
loadAdmin();
|
||||||
|
} catch (e) {
|
||||||
|
toast('Error: ' + e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Init =====
|
// ===== Init =====
|
||||||
function initApp() {
|
function initApp(user) {
|
||||||
document.getElementById('sidebar-date').textContent =
|
document.getElementById('sidebar-date').textContent =
|
||||||
new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
|
||||||
|
// Show admin nav if user is admin
|
||||||
|
document.querySelectorAll('.admin-only').forEach(el => {
|
||||||
|
el.classList.toggle('hidden', !user?.is_admin);
|
||||||
|
});
|
||||||
|
|
||||||
|
const validPages = ['dashboard', 'varieties', 'garden', 'settings'];
|
||||||
|
if (user?.is_admin) validPages.push('admin');
|
||||||
|
|
||||||
function handleNav() {
|
function handleNav() {
|
||||||
const page = (location.hash.replace('#','') || 'dashboard');
|
const page = (location.hash.replace('#','') || 'dashboard');
|
||||||
navigate(['dashboard','varieties','garden','settings'].includes(page) ? page : 'dashboard');
|
navigate(validPages.includes(page) ? page : 'dashboard');
|
||||||
}
|
}
|
||||||
window.removeEventListener('hashchange', handleNav);
|
window.removeEventListener('hashchange', handleNav);
|
||||||
window.addEventListener('hashchange', handleNav);
|
window.addEventListener('hashchange', handleNav);
|
||||||
@@ -908,9 +1080,9 @@ async function init() {
|
|||||||
const token = localStorage.getItem('sproutly_token');
|
const token = localStorage.getItem('sproutly_token');
|
||||||
if (!token) return; // auth screen is visible by default
|
if (!token) return; // auth screen is visible by default
|
||||||
try {
|
try {
|
||||||
await apiFetch('/auth/me');
|
const user = await apiFetch('/auth/me');
|
||||||
showApp();
|
showApp(user);
|
||||||
initApp();
|
initApp(user);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// token invalid — auth screen stays visible
|
// token invalid — auth screen stays visible
|
||||||
}
|
}
|
||||||
@@ -922,6 +1094,7 @@ window.App = {
|
|||||||
showAddBatchModal, showEditBatchModal, submitAddBatch, submitEditBatch, deleteBatch,
|
showAddBatchModal, showEditBatchModal, submitAddBatch, submitEditBatch, deleteBatch,
|
||||||
filterVarieties, filterBatches,
|
filterVarieties, filterBatches,
|
||||||
saveSettings, toggleNtfyAuth, sendTestNotification, sendDailySummary,
|
saveSettings, toggleNtfyAuth, sendTestNotification, sendDailySummary,
|
||||||
|
adminViewUser, adminResetPassword, adminSubmitReset, adminToggleDisable, adminDeleteUser, adminSwitchTab,
|
||||||
closeModal: (e) => closeModal(e),
|
closeModal: (e) => closeModal(e),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user