Add Super Admin panel with user impersonation

- New /super-admin/login and /super-admin routes with separate auth
- Super admin can view all registered accounts and impersonate any user
- Impersonation banner shows at top of screen with exit button
- ADMIN_USERNAME and ADMIN_PASSWORD config added to .env and docker-compose.yml
- Fixed auth store: export setToken, clearToken, and setUser so they are
  accessible from superAdmin store
- Updated README with super admin feature, new env vars, and setup notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 22:30:44 -08:00
parent a8e1b322f1
commit c560055b10
14 changed files with 600 additions and 6 deletions

View File

@@ -13,3 +13,7 @@ REFRESH_TOKEN_EXPIRE_DAYS=30
# Comma-separated allowed CORS origins (no trailing slash)
CORS_ORIGINS=http://localhost:8054
# Super admin credentials (server-level access)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change_me_admin_password

View File

@@ -19,6 +19,7 @@ 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.
- **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).
- **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`). Lists all registered parent accounts and allows impersonating any user — switching into their session to view and manage their data. An impersonation banner is shown at the top of the screen with a one-click "Exit to Admin Panel" button.
---
@@ -73,6 +74,7 @@ homeschool/
│ │ ├── morning_routine.py
│ │ ├── break_activity.py # Break activities CRUD
│ │ ├── dashboard.py # Public snapshot endpoint (TV)
│ │ ├── admin.py # Super admin: login, user list, impersonation
│ │ └── users.py
│ ├── utils/
│ │ └── timer.py # Elapsed-time computation for block and break timers
@@ -85,8 +87,9 @@ homeschool/
├── composables/
│ ├── useApi.js # Axios with auto token-refresh
│ └── useWebSocket.js # Auto-reconnecting WebSocket
├── stores/ # Pinia: auth, children, schedule
├── views/ # LoginView, TVView, DashboardView, LogView, AdminView
├── stores/ # Pinia: auth, children, schedule, superAdmin
├── views/ # LoginView, TVView, DashboardView, LogView, AdminView,
│ # SuperAdminLoginView, SuperAdminView
└── components/ # TimerDisplay, ScheduleBlock, NavBar, etc.
```
@@ -129,6 +132,10 @@ REFRESH_TOKEN_EXPIRE_DAYS=30
# Your host IP or domain (no trailing slash)
CORS_ORIGINS=http://localhost:8054
# Super admin credentials (for /super-admin)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change_me_admin_password
```
### 3. Build and start
@@ -166,6 +173,15 @@ Open **http://localhost:8054/login** and register. This creates your admin accou
| `/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, schedule templates, and account settings |
### Super Admin Views
| URL | Description |
|-----|-------------|
| `/super-admin/login` | Log in with the `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` |
| `/super-admin` | List all registered parent accounts and impersonate any user |
While impersonating, a yellow banner appears at the top of every page showing who you're viewing as, with an **Exit to Admin Panel** button to return.
### Dashboard Controls
While a session is active, clicking a block in the schedule list **selects** it as the current block without starting the timer. The action buttons then provide explicit control:
@@ -259,6 +275,10 @@ Break timer events (`break_*`) are stored as `TimerEvent` records alongside regu
| `ACCESS_TOKEN_EXPIRE_MINUTES` | No | Access token lifetime (default: `30`) |
| `REFRESH_TOKEN_EXPIRE_DAYS` | No | Refresh token lifetime (default: `30`) |
| `CORS_ORIGINS` | No | Comma-separated allowed origins (default: `http://localhost:8054`) |
| `ADMIN_USERNAME` | No | Super admin login username (default: `admin`) |
| `ADMIN_PASSWORD` | No | Super admin login password (default: `change_me_admin_password`) |
> **Note:** `ADMIN_USERNAME` and `ADMIN_PASSWORD` must be set in `.env` **and** listed in the `backend` service's `environment` block in `docker-compose.yml`. Changing them in `.env` alone is not sufficient — the backend container reads them as environment variables, not from the file directly.
---

View File

@@ -26,6 +26,13 @@ def create_access_token(data: dict[str, Any]) -> str:
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_admin_token(data: dict[str, Any]) -> str:
payload = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=8)
payload.update({"exp": expire, "type": "access", "role": "admin"})
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_refresh_token(data: dict[str, Any]) -> str:
payload = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days)

View File

@@ -9,6 +9,8 @@ class Settings(BaseSettings):
access_token_expire_minutes: int = 30
refresh_token_expire_days: int = 30
cors_origins: str = "http://localhost:8054"
admin_username: str = "admin"
admin_password: str = "change_me_admin_password"
@property
def cors_origins_list(self) -> list[str]:

View File

@@ -42,3 +42,17 @@ async def get_current_user(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
async def get_admin_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
) -> dict:
if not credentials:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
try:
payload = decode_token(credentials.credentials)
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
if payload.get("type") != "access" or payload.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return payload

View File

@@ -9,7 +9,7 @@ from app.config import get_settings
from app.database import engine
from app.models import Base
from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard
from app.routers import morning_routine, break_activity
from app.routers import morning_routine, break_activity, admin
from app.websocket.manager import manager
settings = get_settings()
@@ -66,6 +66,7 @@ app.include_router(logs.router)
app.include_router(morning_routine.router)
app.include_router(break_activity.router)
app.include_router(dashboard.router)
app.include_router(admin.router)
@app.get("/api/health")

View File

@@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.auth.jwt import create_admin_token, create_access_token
from app.config import get_settings
from app.dependencies import get_db, get_admin_user
from app.models.user import User
router = APIRouter(prefix="/api/admin", tags=["admin"])
settings = get_settings()
@router.post("/login")
async def admin_login(body: dict):
username = body.get("username", "")
password = body.get("password", "")
if username != settings.admin_username or password != settings.admin_password:
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"}
@router.get("/users")
async def list_users(
_: dict = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).order_by(User.id))
users = result.scalars().all()
return [
{
"id": u.id,
"email": u.email,
"full_name": u.full_name,
"is_active": u.is_active,
"created_at": u.created_at,
}
for u in users
]
@router.post("/impersonate/{user_id}")
async def impersonate_user(
user_id: int,
_: dict = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
token = create_access_token({"sub": str(user.id)})
return {"access_token": token, "token_type": "bearer"}

View File

@@ -29,6 +29,8 @@ services:
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-30}
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8057}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change_me_admin_password}
depends_on:
db:
condition: service_healthy

View File

@@ -1,9 +1,28 @@
<template>
<div>
<div v-if="superAdmin.isImpersonating" class="impersonation-banner">
<span>Viewing as <strong>{{ auth.user?.full_name || auth.user?.email }}</strong></span>
<button @click="exitImpersonation">Exit to Admin Panel</button>
</div>
<div :class="{ 'banner-offset': superAdmin.isImpersonating }">
<RouterView />
</div>
</div>
</template>
<script setup>
import { RouterView } from 'vue-router'
import { RouterView, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSuperAdminStore } from '@/stores/superAdmin'
const auth = useAuthStore()
const superAdmin = useSuperAdminStore()
const router = useRouter()
function exitImpersonation() {
superAdmin.exitImpersonation()
router.push('/super-admin')
}
</script>
<style>
@@ -25,3 +44,37 @@ a {
text-decoration: none;
}
</style>
<style scoped>
.impersonation-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
background: #f59e0b;
color: #0f172a;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
}
.banner-offset {
padding-top: 2.25rem;
}
.impersonation-banner button {
padding: 0.25rem 0.75rem;
background: #0f172a;
color: #f59e0b;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.8125rem;
font-weight: 600;
}
</style>

View File

@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSuperAdminStore } from '@/stores/superAdmin'
const routes = [
{
@@ -18,6 +19,18 @@ const routes = [
component: () => import('@/views/TVView.vue'),
meta: { public: true },
},
{
path: '/super-admin/login',
name: 'superAdminLogin',
component: () => import('@/views/SuperAdminLoginView.vue'),
meta: { public: true },
},
{
path: '/super-admin',
name: 'superAdmin',
component: () => import('@/views/SuperAdminView.vue'),
meta: { requiresAdminAuth: true },
},
{
path: '/dashboard',
name: 'dashboard',
@@ -44,6 +57,14 @@ const router = createRouter({
})
router.beforeEach(async (to) => {
if (to.meta.requiresAdminAuth) {
const superAdmin = useSuperAdminStore()
if (!superAdmin.isAdminAuthenticated) {
return { name: 'superAdminLogin' }
}
return
}
if (to.meta.requiresAuth) {
const auth = useAuthStore()
if (!auth.isAuthenticated) {

View File

@@ -64,11 +64,18 @@ export const useAuthStore = defineStore('auth', () => {
}
}
function setUser(userData) {
user.value = userData
}
return {
accessToken,
user,
isAuthenticated,
timezone,
setToken,
clearToken,
setUser,
login,
register,
logout,

View File

@@ -0,0 +1,67 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
export const useSuperAdminStore = defineStore('superAdmin', () => {
const adminToken = ref(localStorage.getItem('admin_token') || null)
const isAdminAuthenticated = computed(() => !!adminToken.value)
function setAdminToken(token) {
adminToken.value = token
localStorage.setItem('admin_token', token)
}
function clearAdminToken() {
adminToken.value = null
localStorage.removeItem('admin_token')
}
const auth = () => useAuthStore()
const isImpersonating = computed(() => !!adminToken.value && auth().isAuthenticated)
async function adminLogin(username, password) {
const res = await axios.post('/api/admin/login', { username, password })
setAdminToken(res.data.access_token)
}
async function impersonateUser(userId) {
// Resolve authStore before any awaits to avoid Pinia context issues
const authStore = useAuthStore()
const impersonateRes = await axios.post(
`/api/admin/impersonate/${userId}`,
{},
{ headers: { Authorization: `Bearer ${adminToken.value}` } },
)
const token = impersonateRes.data.access_token
// Fetch user data directly with the new token (bypasses the auto-refresh interceptor)
const userRes = await axios.get('/api/users/me', {
headers: { Authorization: `Bearer ${token}` },
})
authStore.setToken(token)
authStore.setUser(userRes.data)
}
function exitImpersonation() {
auth().clearToken()
}
function adminLogout() {
if (auth().isAuthenticated) {
auth().clearToken()
}
clearAdminToken()
}
return {
adminToken,
isAdminAuthenticated,
isImpersonating,
adminLogin,
impersonateUser,
exitImpersonation,
adminLogout,
}
})

View File

@@ -0,0 +1,131 @@
<template>
<div class="login-page">
<div class="login-card">
<h1>Super Admin</h1>
<p class="subtitle">Server-level access</p>
<form @submit.prevent="handleLogin">
<div class="field">
<label>Username</label>
<input v-model="username" type="text" autocomplete="username" required />
</div>
<div class="field">
<label>Password</label>
<input v-model="password" type="password" autocomplete="current-password" required />
</div>
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" :disabled="loading">
{{ loading ? 'Signing in…' : 'Sign In' }}
</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSuperAdminStore } from '@/stores/superAdmin'
const router = useRouter()
const superAdmin = useSuperAdminStore()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function handleLogin() {
error.value = ''
loading.value = true
try {
await superAdmin.adminLogin(username.value, password.value)
router.push('/super-admin')
} catch {
error.value = 'Invalid credentials'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0f172a;
}
.login-card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 360px;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
color: #f8fafc;
margin-bottom: 0.25rem;
}
.subtitle {
color: #94a3b8;
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.field {
margin-bottom: 1rem;
}
label {
display: block;
font-size: 0.875rem;
color: #94a3b8;
margin-bottom: 0.375rem;
}
input {
width: 100%;
padding: 0.625rem 0.75rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
color: #f1f5f9;
font-size: 0.9375rem;
outline: none;
}
input:focus {
border-color: #f59e0b;
}
.error {
color: #f87171;
font-size: 0.875rem;
margin-bottom: 0.75rem;
}
button {
width: 100%;
padding: 0.625rem;
background: #f59e0b;
color: #0f172a;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9375rem;
margin-top: 0.5rem;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<div class="admin-page">
<header class="page-header">
<h1>Super Admin</h1>
<button class="logout-btn" @click="handleLogout">Logout</button>
</header>
<div class="content">
<h2>Registered Users</h2>
<p v-if="loading" class="loading">Loading</p>
<p v-else-if="error" class="error">{{ error }}</p>
<div v-else class="user-grid">
<div v-for="u in users" :key="u.id" class="user-card">
<div class="user-info">
<div class="user-name">{{ u.full_name || '(no name)' }}</div>
<div class="user-email">{{ u.email }}</div>
<div class="user-meta">
<span :class="['badge', u.is_active ? 'active' : 'inactive']">
{{ u.is_active ? 'Active' : 'Inactive' }}
</span>
<span class="joined">Joined {{ formatDate(u.created_at) }}</span>
</div>
</div>
<button class="enter-btn" :disabled="entering === u.id" @click="enter(u.id)">
{{ entering === u.id ? 'Entering' : 'Enter as User' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { useSuperAdminStore } from '@/stores/superAdmin'
const router = useRouter()
const superAdmin = useSuperAdminStore()
const users = ref([])
const loading = ref(true)
const error = ref('')
const entering = ref(null)
onMounted(async () => {
try {
const res = await axios.get('/api/admin/users', {
headers: { Authorization: `Bearer ${superAdmin.adminToken}` },
})
users.value = res.data
} catch {
error.value = 'Failed to load users'
} finally {
loading.value = false
}
})
async function enter(userId) {
entering.value = userId
try {
await superAdmin.impersonateUser(userId)
router.push('/dashboard')
} catch {
error.value = 'Failed to impersonate user'
entering.value = null
}
}
function handleLogout() {
superAdmin.adminLogout()
router.push('/super-admin/login')
}
function formatDate(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
</script>
<style scoped>
.admin-page {
min-height: 100vh;
background: #0f172a;
color: #f1f5f9;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 2rem;
border-bottom: 1px solid #1e293b;
background: #0f172a;
}
h1 {
font-size: 1.25rem;
font-weight: 700;
}
h2 {
font-size: 1rem;
font-weight: 600;
color: #94a3b8;
margin-bottom: 1rem;
}
.logout-btn {
padding: 0.4rem 0.9rem;
background: transparent;
border: 1px solid #334155;
border-radius: 6px;
color: #94a3b8;
cursor: pointer;
font-size: 0.875rem;
}
.logout-btn:hover {
border-color: #f87171;
color: #f87171;
}
.content {
padding: 2rem;
max-width: 960px;
margin: 0 auto;
}
.loading,
.error {
color: #94a3b8;
font-size: 0.9rem;
}
.error {
color: #f87171;
}
.user-grid {
display: grid;
gap: 0.75rem;
}
.user-card {
display: flex;
align-items: center;
justify-content: space-between;
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
padding: 1rem 1.25rem;
}
.user-name {
font-weight: 600;
margin-bottom: 0.15rem;
}
.user-email {
color: #94a3b8;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.user-meta {
display: flex;
align-items: center;
gap: 0.75rem;
}
.badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.badge.active {
background: #14532d;
color: #4ade80;
}
.badge.inactive {
background: #292524;
color: #a8a29e;
}
.joined {
color: #64748b;
font-size: 0.8rem;
}
.enter-btn {
padding: 0.45rem 1rem;
background: #f59e0b;
color: #0f172a;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
white-space: nowrap;
}
.enter-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>