diff --git a/.env.example b/.env.example index 05eb479..631aa8a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 1aedefc..eaccbfb 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/backend/app/auth/jwt.py b/backend/app/auth/jwt.py index bd3a938..c74e0b5 100644 --- a/backend/app/auth/jwt.py +++ b/backend/app/auth/jwt.py @@ -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) diff --git a/backend/app/config.py b/backend/app/config.py index 88711ec..ef1f13e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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]: diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 3f66cf2..a2aebd3 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index f40cb6d..e414d8e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..ee5e3ca --- /dev/null +++ b/backend/app/routers/admin.py @@ -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"} diff --git a/docker-compose.yml b/docker-compose.yml index c8bf445..64ed1ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 84c2e76..535b45e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,9 +1,28 @@ + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 179ac89..736b90d 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,5 +1,6 @@ import { createRouter, createWebHistory } from 'vue-router' import { useAuthStore } from '@/stores/auth' +import { useSuperAdminStore } from '@/stores/superAdmin' const routes = [ { @@ -18,13 +19,25 @@ 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', component: () => import('@/views/DashboardView.vue'), meta: { requiresAuth: true }, }, -{ + { path: '/logs', name: 'logs', component: () => import('@/views/LogView.vue'), @@ -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) { diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 14c32fc..bc9839c 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -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, diff --git a/frontend/src/stores/superAdmin.js b/frontend/src/stores/superAdmin.js new file mode 100644 index 0000000..4d60d42 --- /dev/null +++ b/frontend/src/stores/superAdmin.js @@ -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, + } +}) diff --git a/frontend/src/views/SuperAdminLoginView.vue b/frontend/src/views/SuperAdminLoginView.vue new file mode 100644 index 0000000..6000fd5 --- /dev/null +++ b/frontend/src/views/SuperAdminLoginView.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/frontend/src/views/SuperAdminView.vue b/frontend/src/views/SuperAdminView.vue new file mode 100644 index 0000000..81aff90 --- /dev/null +++ b/frontend/src/views/SuperAdminView.vue @@ -0,0 +1,211 @@ + + + + +