Security hardening: go-live review fixes

- TV tokens upgraded from 4 to 6 digits; Regen Token button in Admin
- Nginx rate limiting on TV dashboard and WebSocket endpoints
- Login lockout after 5 failed attempts (15 min); clears on admin password reset
- HSTS header added; CSP unsafe-inline removed from script-src; CORS restricted to explicit methods/headers
- Dependency CVE fixes: PyJWT 2.12.0, aiomysql 0.3.0, cryptography 46.0.5, python-multipart 0.0.22
- datetime.utcnow() replaced with datetime.now(timezone.utc) throughout
- SQL identifier whitelist for startup migration queries
- README updated: security notes section, lockout docs, token regen, NPM proxy guidance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 00:00:14 -07:00
parent be86cae7fa
commit 3022bc328b
11 changed files with 228 additions and 30 deletions

View File

@@ -24,10 +24,10 @@ A self-hosted web app for managing homeschool schedules, tracking daily learning
- **Timezone Support** — Set your local timezone in Admin → Settings. All activity log timestamps display in your timezone, including the TV dashboard clock. Midnight strike resets use this timezone.
- **Password Change** — Users can change their own account password from Admin → Settings → Reset Password. The form requires the current password before accepting a new one.
- **Multi-Child Support** — Manage multiple students under one parent account, each with their own color, schedule, and history.
- **JWT Authentication** — Secure parent login with access tokens and httpOnly refresh cookies. TV dashboard is public (no login required). Disabled accounts receive a clear error message explaining the account is disabled rather than a generic "invalid credentials" response.
- **JWT Authentication** — Secure parent login with access tokens and httpOnly refresh cookies. TV dashboard is public (no login required). Disabled accounts receive a clear error message explaining the account is disabled rather than a generic "invalid credentials" response. After **5 consecutive failed login attempts**, an account is locked for **15 minutes** — the error message includes the remaining wait time. Locks clear automatically after the cooldown, or immediately when a super admin resets the account's password.
- **Super Admin Panel** — A separate admin interface (at `/super-admin`) for site-wide management. Log in with a dedicated admin username and password (set in `.env`). Provides full control over all registered parent accounts:
- **Impersonate** — Enter any user's session to view and manage their data. An impersonation banner is shown at the top of every page with a one-click "Exit to Admin Panel" button.
- **Reset Password** — Set a new password for any user without needing the current password.
- **Reset Password** — Set a new password for any user without needing the current password. Also clears any active login lockout on the account.
- **Disable / Enable** — Disable a user's login access. Disabled users cannot log in and see a specific error message. Re-enable at any time.
- **Delete** — Permanently delete a user and all associated data (children, sessions, schedules, subjects, activity logs, etc.) with a confirmation dialog. This action cannot be undone.
- **Last Active** — Shows the date each user last logged in or refreshed their session, displayed in that user's configured timezone. Shows "Never logged in" for accounts that have never authenticated.
@@ -50,7 +50,7 @@ A self-hosted web app for managing homeschool schedules, tracking daily learning
| Real-time | WebSockets via FastAPI |
| Database | MySQL 8 |
| ORM | SQLAlchemy 2.0 (async) |
| Auth | JWT — python-jose + passlib/bcrypt |
| Auth | JWT — PyJWT + passlib/bcrypt |
| Orchestration | Docker Compose |
---
@@ -82,11 +82,11 @@ homeschool/
│ │ ├── rule.py # RuleItem (rules & expectations)
│ │ ├── session_block_agenda.py # SessionBlockAgenda (per-session block overrides)
│ │ ├── strike.py # StrikeEvent (strike history)
│ │ └── user.py # User (incl. timezone, last_active_at)
│ │ └── user.py # User (incl. timezone, last_active_at, failed_login_attempts, locked_until)
│ ├── schemas/ # Pydantic request/response schemas
│ ├── routers/ # API route handlers
│ │ ├── auth.py # Login, register, refresh, logout, change-password
│ │ ├── children.py # Children CRUD + strikes + midnight reset
│ │ ├── children.py # Children CRUD + strikes + midnight reset + TV token regeneration
│ │ ├── subjects.py
│ │ ├── schedules.py
│ │ ├── sessions.py # Timer actions + break timer events + block agenda upsert
@@ -105,7 +105,7 @@ homeschool/
└── frontend/
├── Dockerfile # Multi-stage: Node build → nginx serve
├── nginx.conf # Proxy /api/ and /ws/ to backend
├── nginx.conf # Proxy /api/ and /ws/ to backend; rate limiting, security headers
├── index.html # Favicon set to house emoji via inline SVG
└── src/
├── composables/
@@ -192,7 +192,7 @@ Open **http://localhost:8054/login** and register. This creates your admin accou
6. **Admin** → Scroll to **Settings** (below Schedules) and select your local timezone. You can also change your account password here.
7. **Admin** → Scroll to **Schedules** → Create a schedule template, add time blocks assigned to subjects. For any block that should include a break, check **Break** and enter the break duration in minutes.
8. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template
9. **TV** → From the Dashboard, click **Open TV View** to get the TV URL for that child. Each child is assigned a permanent random 4-digit token (e.g. `http://your-lan-ip:8054/tv/4823`). Open that URL on the living room TV.
9. **TV** → From the Dashboard, click **Open TV View** to get the TV URL for that child. Each child is assigned a permanent random 6-digit token (e.g. `http://your-lan-ip:8054/tv/482391`). Open that URL on the living room TV.
---
@@ -204,7 +204,7 @@ Open **http://localhost:8054/login** and register. This creates your admin accou
|-----|-------------|
| `/dashboard` | Overview, start/stop sessions, select and time blocks, set per-block agendas, issue behavior strikes, trigger TV overlays |
| `/logs` | Browse timer and strike event history and manual notes; filter by child and date |
| `/admin` | Manage children, subjects (with activity options), morning routine, break activities, rules & expectations, schedule templates, and account settings (timezone, password). Includes a Buy Me a Coffee support link at the top of the page. |
| `/admin` | Manage children (incl. TV token display and regeneration), subjects (with activity options), morning routine, break activities, rules & expectations, schedule templates, and account settings (timezone, password). Includes a Buy Me a Coffee support link at the top of the page. |
### Super Admin Views
@@ -249,7 +249,7 @@ Pressing **Reset** after **Done** fully un-marks the block — removes the check
|-----|-------------|
| `/tv/:tvToken` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar, schedule sidebar, meeting warning toasts, meeting start overlay, rules/expectations overlay |
Each child is assigned a permanent random 4-digit token when created (e.g. `/tv/4823`). The token never changes and does not expose the internal database ID. Find the TV URL for a child by clicking **Open TV View** on the Dashboard. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard.
Each child is assigned a permanent random 6-digit token when created (e.g. `/tv/482391`). The token does not expose the internal database ID. Find the TV URL by clicking **Open TV View** on the Dashboard, or view the token directly in **Admin → Children** next to each child's name. To generate a new token (e.g. if the old URL needs to be invalidated), click **Regen Token** in the child's row — the old URL stops working immediately. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard.
### API Documentation
@@ -271,11 +271,15 @@ docker compose up -d
No separate migration tool or manual steps are required.
### Upgrading from an older version (4-digit TV tokens)
TV tokens were changed from 4 digits to 6 digits to improve security. The startup migration does **not** automatically regenerate tokens for existing children — they will keep their old 4-digit token until you manually regenerate it. To do so, go to **Admin → Children**, find the child, and click **Regen Token**. The new 6-digit token is shown immediately in the row. Update the TV URL on your TV — the old URL stops working as soon as the token is regenerated.
---
## WebSocket Events
The TV dashboard connects to `ws://host/ws/{tv_token}` (using the child's 4-digit TV token, not the internal database ID) and receives JSON events:
The TV dashboard connects to `ws://host/ws/{tv_token}` (using the child's 6-digit TV token, not the internal database ID) and receives JSON events:
| Event | Triggered by | Key payload fields |
|-------|-------------|---------|
@@ -347,6 +351,46 @@ The TV dashboard connects to `ws://host/ws/{tv_token}` (using the child's 4-digi
---
## Security Notes
### Rate Limiting
Nginx enforces the following rate limits (per IP):
| Endpoint | Limit | Burst |
|----------|-------|-------|
| `/api/auth/login`, `/api/auth/register` | 5 req/min | 3 |
| `/api/admin/login` | 5 req/min | 3 |
| `/api/dashboard/*` (TV token lookup) | 10 req/min | 5 |
| `/ws/*` (WebSocket handshake) | 10 req/min | 5 |
Requests over the limit receive a `429 Too Many Requests` response.
### Login Lockout
After 5 consecutive failed login attempts, a parent account is locked for 15 minutes. The lock clears automatically after the cooldown, or immediately when a super admin resets the user's password. The lockout threshold and duration are configured in `backend/app/routers/auth.py` (`_LOGIN_MAX_ATTEMPTS`, `_LOGIN_LOCKOUT_MINUTES`).
### Exposing via Reverse Proxy (Nginx Proxy Manager)
If exposing the app externally via NPM:
- Add your public domain to `CORS_ORIGINS` in `.env` and rebuild
- Restrict `/super-admin` and `/api/admin` paths to trusted IPs via NPM's Advanced tab:
```nginx
location ~* ^/(super-admin|api/admin) {
allow 192.168.1.0/24;
deny all;
proxy_pass http://<upstream>:8054;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
---
## Stopping and Restarting
```bash

View File

@@ -1,3 +1,4 @@
import logging
import random
from contextlib import asynccontextmanager
@@ -19,9 +20,36 @@ from app.websocket.manager import manager
settings = get_settings()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("homeschool")
_ALLOWED_IDENTIFIERS: set[str] = {
# tables
"schedule_blocks", "children", "users", "subjects", "daily_sessions", "timer_events",
# columns
"duration_minutes", "break_time_enabled", "break_time_minutes", "strikes",
"timezone", "is_system", "last_active_at", "strikes_last_reset", "tv_token",
"failed_login_attempts", "locked_until",
# index names
"ix_daily_sessions_session_date", "ix_daily_sessions_is_active", "ix_timer_events_event_type",
# index columns
"session_date", "is_active", "event_type",
}
def _check_identifier(*names: str) -> None:
for name in names:
if name not in _ALLOWED_IDENTIFIERS:
raise ValueError(f"Disallowed SQL identifier: {name!r}")
async def _add_column_if_missing(conn, table: str, column: str, definition: str):
"""Add a column to a table, silently ignoring if it already exists (MySQL 1060)."""
_check_identifier(table, column)
try:
await conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {definition}"))
except OperationalError as e:
@@ -29,6 +57,16 @@ async def _add_column_if_missing(conn, table: str, column: str, definition: str)
raise
async def _add_index_if_missing(conn, index_name: str, table: str, column: str):
"""Create an index, silently ignoring if it already exists (MySQL 1061)."""
_check_identifier(index_name, table, column)
try:
await conn.execute(text(f"CREATE INDEX {index_name} ON {table} ({column})"))
except OperationalError as e:
if e.orig.args[0] != 1061: # 1061 = Duplicate key name
raise
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create tables on startup (Alembic handles migrations in prod, this is a safety net)
@@ -44,6 +82,12 @@ async def lifespan(app: FastAPI):
await _add_column_if_missing(conn, "users", "last_active_at", "DATETIME NULL")
await _add_column_if_missing(conn, "children", "strikes_last_reset", "DATE NULL")
await _add_column_if_missing(conn, "children", "tv_token", "INT NULL")
await _add_column_if_missing(conn, "users", "failed_login_attempts", "INT NOT NULL DEFAULT 0")
await _add_column_if_missing(conn, "users", "locked_until", "DATETIME NULL")
# Idempotent index additions
await _add_index_if_missing(conn, "ix_daily_sessions_session_date", "daily_sessions", "session_date")
await _add_index_if_missing(conn, "ix_daily_sessions_is_active", "daily_sessions", "is_active")
await _add_index_if_missing(conn, "ix_timer_events_event_type", "timer_events", "event_type")
# Backfill tv_token for existing children that don't have one
from app.database import AsyncSessionLocal
@@ -53,7 +97,7 @@ async def lifespan(app: FastAPI):
used_tokens = set()
for child in children_without_token:
while True:
token = random.randint(1000, 9999)
token = random.randint(100000, 999999)
if token not in used_tokens:
existing = await db.execute(select(Child).where(Child.tv_token == token))
if not existing.scalar_one_or_none():
@@ -87,9 +131,9 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="Homeschool API",
version="1.0.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
docs_url="/api/docs" if settings.docs_enabled else None,
redoc_url="/api/redoc" if settings.docs_enabled else None,
openapi_url="/api/openapi.json" if settings.docs_enabled else None,
lifespan=lifespan,
)
@@ -97,8 +141,8 @@ app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"],
)
# Routers

View File

@@ -1,5 +1,5 @@
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy import String, Boolean, DateTime, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
@@ -15,6 +15,8 @@ class User(TimestampMixin, Base):
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC")
last_active_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
failed_login_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
children: Mapped[list["Child"]] = relationship("Child", back_populates="user") # noqa: F821
subjects: Mapped[list["Subject"]] = relationship("Subject", back_populates="user") # noqa: F821

View File

@@ -1,7 +1,10 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
logger = logging.getLogger("homeschool.admin")
from app.auth.jwt import create_admin_token, create_access_token, hash_password
from app.config import get_settings
from app.dependencies import get_db, get_admin_user
@@ -16,6 +19,7 @@ async def admin_login(body: dict):
username = body.get("username", "")
password = body.get("password", "")
if username != settings.admin_username or password != settings.admin_password:
logger.warning("Failed super-admin login attempt for username=%s", username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin credentials")
token = create_admin_token({"sub": "admin"})
return {"access_token": token, "token_type": "bearer"}
@@ -87,6 +91,8 @@ async def reset_user_password(
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.hashed_password = hash_password(new_password)
user.failed_login_attempts = 0
user.locked_until = None
await db.commit()
return {"ok": True}

View File

@@ -1,8 +1,11 @@
from datetime import datetime
import logging
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, Response, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
logger = logging.getLogger("homeschool.auth")
from app.auth.jwt import (
create_access_token,
create_refresh_token,
@@ -22,7 +25,7 @@ REFRESH_COOKIE = "refresh_token"
COOKIE_OPTS = {
"httponly": True,
"samesite": "lax",
"secure": False, # set True in production with HTTPS
"secure": True,
}
@@ -57,16 +60,41 @@ async def register(body: RegisterRequest, response: Response, db: AsyncSession =
return TokenResponse(access_token=access)
_LOGIN_MAX_ATTEMPTS = 5
_LOGIN_LOCKOUT_MINUTES = 15
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == body.email))
user = result.scalar_one_or_none()
if not user or not verify_password(body.password, user.hashed_password):
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
now = datetime.now(timezone.utc).replace(tzinfo=None)
if user.locked_until and user.locked_until > now:
remaining = int((user.locked_until - now).total_seconds() / 60) + 1
logger.warning("Locked account login attempt for email=%s", body.email)
raise HTTPException(status_code=429, detail=f"Account locked. Try again in {remaining} minute(s).")
if not user.is_active:
logger.warning("Login attempt on disabled account email=%s", body.email)
raise HTTPException(status_code=403, detail="This account has been disabled. Please contact your administrator.")
user.last_active_at = datetime.utcnow()
if not verify_password(body.password, user.hashed_password):
user.failed_login_attempts += 1
if user.failed_login_attempts >= _LOGIN_MAX_ATTEMPTS:
user.locked_until = now + timedelta(minutes=_LOGIN_LOCKOUT_MINUTES)
logger.warning("Account locked for email=%s after %d failed attempts", body.email, user.failed_login_attempts)
else:
logger.warning("Failed login attempt %d/%d for email=%s", user.failed_login_attempts, _LOGIN_MAX_ATTEMPTS, body.email)
await db.commit()
raise HTTPException(status_code=401, detail="Invalid credentials")
user.failed_login_attempts = 0
user.locked_until = None
user.last_active_at = datetime.now(timezone.utc)
await db.commit()
access = create_access_token({"sub": str(user.id)})
@@ -95,7 +123,7 @@ async def refresh_token(request: Request, response: Response, db: AsyncSession =
if not user:
raise HTTPException(status_code=401, detail="User not found")
user.last_active_at = datetime.utcnow()
user.last_active_at = datetime.now(timezone.utc)
await db.commit()
access = create_access_token({"sub": str(user.id)})

View File

@@ -52,7 +52,7 @@ async def list_children(
async def _generate_tv_token(db: AsyncSession) -> int:
while True:
token = random.randint(1000, 9999)
token = random.randint(100000, 999999)
result = await db.execute(select(Child).where(Child.tv_token == token))
if not result.scalar_one_or_none():
return token
@@ -134,6 +134,24 @@ async def update_strikes(
return child
@router.post("/{child_id}/regenerate-token", response_model=ChildOut)
async def regenerate_tv_token(
child_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Child).where(Child.id == child_id, Child.user_id == current_user.id)
)
child = result.scalar_one_or_none()
if not child:
raise HTTPException(status_code=404, detail="Child not found")
child.tv_token = await _generate_tv_token(db)
await db.commit()
await db.refresh(child)
return child
@router.delete("/{child_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_child(
child_id: int,

View File

@@ -1,5 +1,5 @@
"""Shared timer-elapsed computation used by sessions and dashboard routers."""
from datetime import datetime
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -39,7 +39,7 @@ async def compute_block_elapsed(
last_start = None
running = last_start is not None
if running:
elapsed += (datetime.utcnow() - last_start).total_seconds()
elapsed += (datetime.now(timezone.utc) - last_start).total_seconds()
# is_paused is True whenever the timer is not actively running —
# covers: explicitly paused, never started, or only selected.
@@ -75,7 +75,7 @@ async def compute_break_elapsed(
last_start = None
running = last_start is not None
if running:
elapsed += (datetime.utcnow() - last_start).total_seconds()
elapsed += (datetime.now(timezone.utc) - last_start).total_seconds()
is_paused = not running
return int(elapsed), is_paused

View File

@@ -1,11 +1,12 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy[asyncio]==2.0.35
aiomysql==0.2.0
python-jose[cryptography]==3.3.0
aiomysql==0.3.0
PyJWT==2.12.0
cryptography==46.0.5
passlib[bcrypt]==1.7.4
bcrypt==3.2.2
pydantic-settings==2.5.2
alembic==1.13.3
python-multipart==0.0.12
python-multipart==0.0.22
email-validator==2.2.0

View File

@@ -1,8 +1,46 @@
# Rate limiting zones — included inside http{} block
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=tv_limit:10m rate=10r/m;
server {
listen 80;
server_tokens off;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self' data:;" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/javascript application/json application/x-javascript text/xml application/xml;
gzip_min_length 1024;
# Rate-limited auth endpoints (checked before the generic /api/ block)
location ~ ^/api/(auth/(login|register)|admin/login)$ {
limit_req zone=auth_limit burst=3 nodelay;
limit_req_status 429;
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Rate-limited TV dashboard endpoint (public, token-based)
location ~ ^/api/dashboard/ {
limit_req zone=tv_limit burst=5 nodelay;
limit_req_status 429;
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# API proxy → FastAPI backend
location /api/ {
proxy_pass http://backend:8000;
@@ -13,6 +51,8 @@ server {
# WebSocket proxy → FastAPI backend
location /ws/ {
limit_req zone=tv_limit burst=5 nodelay;
limit_req_status 429;
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;

View File

@@ -32,9 +32,16 @@ export const useChildrenStore = defineStore('children', () => {
children.value = children.value.filter((c) => c.id !== id)
}
async function regenerateToken(id) {
const res = await api.post(`/api/children/${id}/regenerate-token`)
const idx = children.value.findIndex((c) => c.id === id)
if (idx !== -1) children.value[idx] = res.data
return res.data
}
function setActiveChild(child) {
activeChild.value = child
}
return { children, activeChild, fetchChildren, createChild, updateChild, deleteChild, setActiveChild }
return { children, activeChild, fetchChildren, createChild, updateChild, deleteChild, regenerateToken, setActiveChild }
})

View File

@@ -48,11 +48,13 @@
<div class="item-color" :style="{ background: child.color }"></div>
<span class="item-name">{{ child.name }}</span>
<span class="item-meta">{{ child.is_active ? 'Active' : 'Inactive' }}</span>
<span class="item-meta token-meta" title="TV token">📺 {{ child.tv_token }}</span>
<div class="item-actions">
<button class="btn-sm" @click="startEditChild(child)">Edit</button>
<button class="btn-sm" @click="toggleChild(child)">
{{ child.is_active ? 'Deactivate' : 'Activate' }}
</button>
<button class="btn-sm" @click="regenToken(child)" title="Generate a new TV token (old URL will stop working)">Regen Token</button>
<button class="btn-sm btn-danger" @click="deleteChild(child.id)">Delete</button>
</div>
</template>
@@ -536,6 +538,12 @@ async function deleteChild(id) {
}
}
async function regenToken(child) {
if (confirm(`Regenerate TV token for ${child.name}? The old TV URL will stop working immediately.`)) {
await childrenStore.regenerateToken(child.id)
}
}
// Subjects
const showSubjectForm = ref(false)
const newSubject = ref({ name: '', icon: '📚', color: '#10B981' })