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) # Comma-separated allowed CORS origins (no trailing slash)
CORS_ORIGINS=http://localhost:8054 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. - **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. - **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). - **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 │ │ ├── morning_routine.py
│ │ ├── break_activity.py # Break activities CRUD │ │ ├── break_activity.py # Break activities CRUD
│ │ ├── dashboard.py # Public snapshot endpoint (TV) │ │ ├── dashboard.py # Public snapshot endpoint (TV)
│ │ ├── admin.py # Super admin: login, user list, impersonation
│ │ └── users.py │ │ └── users.py
│ ├── utils/ │ ├── utils/
│ │ └── timer.py # Elapsed-time computation for block and break timers │ │ └── timer.py # Elapsed-time computation for block and break timers
@@ -85,8 +87,9 @@ homeschool/
├── composables/ ├── composables/
│ ├── useApi.js # Axios with auto token-refresh │ ├── useApi.js # Axios with auto token-refresh
│ └── useWebSocket.js # Auto-reconnecting WebSocket │ └── useWebSocket.js # Auto-reconnecting WebSocket
├── stores/ # Pinia: auth, children, schedule ├── stores/ # Pinia: auth, children, schedule, superAdmin
├── views/ # LoginView, TVView, DashboardView, LogView, AdminView ├── views/ # LoginView, TVView, DashboardView, LogView, AdminView,
│ # SuperAdminLoginView, SuperAdminView
└── components/ # TimerDisplay, ScheduleBlock, NavBar, etc. └── components/ # TimerDisplay, ScheduleBlock, NavBar, etc.
``` ```
@@ -129,6 +132,10 @@ REFRESH_TOKEN_EXPIRE_DAYS=30
# Your host IP or domain (no trailing slash) # Your host IP or domain (no trailing slash)
CORS_ORIGINS=http://localhost:8054 CORS_ORIGINS=http://localhost:8054
# Super admin credentials (for /super-admin)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change_me_admin_password
``` ```
### 3. Build and start ### 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 | | `/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 | | `/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 ### 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: 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`) | | `ACCESS_TOKEN_EXPIRE_MINUTES` | No | Access token lifetime (default: `30`) |
| `REFRESH_TOKEN_EXPIRE_DAYS` | No | Refresh 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`) | | `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) 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: def create_refresh_token(data: dict[str, Any]) -> str:
payload = data.copy() payload = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days) 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 access_token_expire_minutes: int = 30
refresh_token_expire_days: int = 30 refresh_token_expire_days: int = 30
cors_origins: str = "http://localhost:8054" cors_origins: str = "http://localhost:8054"
admin_username: str = "admin"
admin_password: str = "change_me_admin_password"
@property @property
def cors_origins_list(self) -> list[str]: 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") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user 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.database import engine
from app.models import Base from app.models import Base
from app.routers import auth, users, children, subjects, schedules, sessions, logs, dashboard 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 from app.websocket.manager import manager
settings = get_settings() settings = get_settings()
@@ -66,6 +66,7 @@ app.include_router(logs.router)
app.include_router(morning_routine.router) app.include_router(morning_routine.router)
app.include_router(break_activity.router) app.include_router(break_activity.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(admin.router)
@app.get("/api/health") @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} ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-30}
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30} REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-30}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8057} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8057}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change_me_admin_password}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@@ -1,9 +1,28 @@
<template> <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 /> <RouterView />
</div>
</div>
</template> </template>
<script setup> <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> </script>
<style> <style>
@@ -25,3 +44,37 @@ a {
text-decoration: none; text-decoration: none;
} }
</style> </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 { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useSuperAdminStore } from '@/stores/superAdmin'
const routes = [ const routes = [
{ {
@@ -18,6 +19,18 @@ const routes = [
component: () => import('@/views/TVView.vue'), component: () => import('@/views/TVView.vue'),
meta: { public: true }, 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', path: '/dashboard',
name: 'dashboard', name: 'dashboard',
@@ -44,6 +57,14 @@ const router = createRouter({
}) })
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
if (to.meta.requiresAdminAuth) {
const superAdmin = useSuperAdminStore()
if (!superAdmin.isAdminAuthenticated) {
return { name: 'superAdminLogin' }
}
return
}
if (to.meta.requiresAuth) { if (to.meta.requiresAuth) {
const auth = useAuthStore() const auth = useAuthStore()
if (!auth.isAuthenticated) { if (!auth.isAuthenticated) {

View File

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