From f29640eb476414113d615de841e62a2c75266939 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 30 Jul 2026 07:41:23 +0000 Subject: [PATCH] Add admin user management API and frontend UI Backend: - /api/admin/users: GET list, POST create, PUT update, DELETE - Admin users can be added with email, display name, and password - Self-deletion and last-admin deletion are blocked - Admin role auto-assigned on creation Frontend: - AdminUsersTabComponent: list + add/delete admin users - AdminUserService: HTTP client for admin user CRUD - AdminUser interface - 'Admins' tab added to admin dashboard Tests: - 10 new tests for admin user management endpoints - 140 total tests pass (130 original + 10 new) --- backend/app/api/users.py | 146 ++++++++++++ backend/app/main.py | 4 + backend/tests/test_admin_users.py | 229 +++++++++++++++++++ src/app/admin/admin-users-tab.component.html | 82 +++++++ src/app/admin/admin-users-tab.component.scss | 23 ++ src/app/admin/admin-users-tab.component.ts | 134 +++++++++++ src/app/admin/admin.component.html | 5 + src/app/admin/admin.component.ts | 5 +- src/app/interfaces/admin-user.ts | 7 + src/app/services/admin-user.service.ts | 40 ++++ 10 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/users.py create mode 100644 backend/tests/test_admin_users.py create mode 100644 src/app/admin/admin-users-tab.component.html create mode 100644 src/app/admin/admin-users-tab.component.scss create mode 100644 src/app/admin/admin-users-tab.component.ts create mode 100644 src/app/interfaces/admin-user.ts create mode 100644 src/app/services/admin-user.service.ts diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..d868f0c --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,146 @@ +"""Admin user management: list, create, delete admin users.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.orm import joinedload +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import get_current_admin_user, hash_password +from app.database import get_session +from app.user_models import User, Role, user_roles + +router = APIRouter() + + +# ── Schemas ──────────────────────────────────────────────────── + +class AdminUserResponse(BaseModel): + id: int + email: str + display_name: str + auth_provider: str + is_admin: bool + + model_config = {"from_attributes": True} + + +class CreateAdminRequest(BaseModel): + email: str = Field(..., pattern=r'^[^@\s]+@[^@>\s]+.[^@\s.]+$') + display_name: str + password: str + + +class UpdateAdminRequest(BaseModel): + display_name: str | None = None + + +# ── Routes ───────────────────────────────────────────────────── + +@router.get("/users", response_model=list[AdminUserResponse]) +async def list_admin_users(session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_admin_user)): + """List all users with admin access.""" + result = await session.execute( + select(User).options(joinedload(User.roles)) + ) + users = result.scalars().unique().all() + # Filter to only admin users + admins = [u for u in users if u.is_admin_effective] + return admins + + +@router.post("/users", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED) +async def create_admin_user( + payload: CreateAdminRequest, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_admin_user), +): + """Create a new admin user. Assigns the 'admin' role automatically.""" + # Check if user already exists + existing = await session.execute(select(User).where(User.email == payload.email)) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"User with email {payload.email} already exists", + ) + + # Ensure admin role exists + role_result = await session.execute(select(Role).where(Role.name == "admin")) + admin_role = role_result.scalar_one_or_none() + if admin_role is None: + admin_role = Role(name="admin", description="Full administrative access") + session.add(admin_role) + await session.flush() + + # Create user with hashed password + user = User( + email=payload.email, + display_name=payload.display_name, + auth_provider="local", + password_hash=hash_password(payload.password), + is_admin=True, + ) + user.roles.append(admin_role) + session.add(user) + await session.commit() + await session.refresh(user) + return user + + +@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_admin_user( + user_id: int, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_admin_user), +): + """Delete an admin user. Cannot delete yourself. At least one admin must remain.""" + # Prevent self-deletion + if user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete your own account", + ) + + result = await session.execute( + select(User).options(joinedload(User.roles)).where(User.id == user_id) + ) + user = result.unique().scalar_one_or_none() + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + # Ensure at least one admin remains (don't count the user being deleted) + other_admins = await session.execute( + select(User).options(joinedload(User.roles)).where(User.id != user_id) + ) + admin_count = sum(1 for u in other_admins.scalars().unique().all() if u.is_admin_effective) + if admin_count == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete the last admin user", + ) + + # Remove all roles + user.roles.clear() + await session.delete(user) + await session.commit() + + +@router.put("/users/{user_id}", response_model=AdminUserResponse) +async def update_admin_user( + user_id: int, + payload: UpdateAdminRequest, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_admin_user), +): + """Update admin user details.""" + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + if payload.display_name is not None: + user.display_name = payload.display_name + + await session.commit() + await session.refresh(user) + return user diff --git a/backend/app/main.py b/backend/app/main.py index 160ae8c..71fcf5d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -234,6 +234,10 @@ app.include_router(stats.router, prefix="/api/stats", tags=["stats"]) app.include_router(mobile_builds.router, prefix="/api/mobile-builds", tags=["mobile-builds"]) app.include_router(theme.router, prefix="/api/theme", tags=["theme"]) +# Admin user management (available in all environments) +from app.api import users +app.include_router(users.router, prefix="/api/admin", tags=["admin-users"]) + # Dev-only admin router (disabled in production) if settings.ENV != "production": from app.api import admin diff --git a/backend/tests/test_admin_users.py b/backend/tests/test_admin_users.py new file mode 100644 index 0000000..5548c37 --- /dev/null +++ b/backend/tests/test_admin_users.py @@ -0,0 +1,229 @@ +"""Tests for /api/admin/users admin user management endpoints.""" + +import pytest +from httpx import AsyncClient, ASGITransport +from jose import jwt +from datetime import datetime, timedelta, timezone + +from app.auth import hash_password +from app.config import settings +from app.database import async_session +from app.main import app +from app.user_models import User, Role + + +@pytest.fixture +async def admin_token(): + """Create a valid admin JWT.""" + expire = datetime.now(timezone.utc) + timedelta(minutes=30) + payload = { + "sub": "99999", + "is_admin": True, + "exp": expire, + } + return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256") + + +@pytest.fixture +async def seed_admin_user(admin_token: str): + """Seed the test admin user that matches the JWT sub claim.""" + async with async_session() as session: + user = User( + id=99999, + email="testadmin@example.com", + display_name="Test Admin", + auth_provider="local", + is_admin=True, + ) + session.add(user) + await session.commit() + return user + + +@pytest.fixture +async def client(): + """Create an AsyncClient for the FastAPI app.""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +@pytest.mark.asyncio +async def test_list_admin_users_empty(client: AsyncClient, admin_token: str, seed_admin_user: User): + """GET /api/admin/users returns empty list when only JWT user (no others) exists.""" + resp = await client.get("/api/admin/users", headers={"Authorization": f"Bearer {admin_token}"}) + assert resp.status_code == 200 + # The seed_admin_user IS an admin, so it shows up + data = resp.json() + assert len(data) == 1 + assert data[0]["email"] == "testadmin@example.com" + + +@pytest.mark.asyncio +async def test_list_admin_users_filters_non_admins(client: AsyncClient, admin_token: str, seed_admin_user: User): + """GET /api/admin/users returns only users with admin access.""" + # Add a non-admin user + async with async_session() as session: + regular = User( + id=100, + email="regular@example.com", + display_name="Regular User", + auth_provider="google", + is_admin=False, + ) + session.add(regular) + await session.commit() + + resp = await client.get("/api/admin/users", headers={"Authorization": f"Bearer {admin_token}"}) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["email"] == "testadmin@example.com" + + +@pytest.mark.asyncio +async def test_create_admin_user(client: AsyncClient, admin_token: str, seed_admin_user: User): + """POST /api/admin/users creates a new admin with hashed password.""" + resp = await client.post( + "/api/admin/users", + headers={"Authorization": f"Bearer {admin_token}"}, + json={ + "email": "newadmin@example.com", + "display_name": "New Admin", + "password": "securepass123", + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["email"] == "newadmin@example.com" + assert data["display_name"] == "New Admin" + assert data["is_admin"] is True + + +@pytest.mark.asyncio +async def test_create_admin_user_duplicate_email(client: AsyncClient, admin_token: str, seed_admin_user: User): + """POST /api/admin/users returns 409 for duplicate email.""" + # Create first user + await client.post( + "/api/admin/users", + headers={"Authorization": f"Bearer {admin_token}"}, + json={ + "email": "dup@example.com", + "display_name": "Dup Admin", + "password": "pass123", + }, + ) + + # Attempt duplicate + resp = await client.post( + "/api/admin/users", + headers={"Authorization": f"Bearer {admin_token}"}, + json={ + "email": "dup@example.com", + "display_name": "Dup Admin 2", + "password": "pass123", + }, + ) + assert resp.status_code == 409 + + +@pytest.mark.asyncio +async def test_delete_admin_user(client: AsyncClient, admin_token: str, seed_admin_user: User): + """DELETE /api/admin/users/{id} removes the user.""" + # Create another admin via the API + await client.post( + "/api/admin/users", + headers={"Authorization": f"Bearer {admin_token}"}, + json={ + "email": "otheradmin@example.com", + "display_name": "Other Admin", + "password": "pass123", + }, + ) + + resp = await client.delete( + "/api/admin/users/88888", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + # The new user won't have id=88888 in the test DB; we need to get the actual ID + # Let's list users first to find the ID + list_resp = await client.get("/api/admin/users", headers={"Authorization": f"Bearer {admin_token}"}) + users = list_resp.json() + other_user = next((u for u in users if u["email"] == "otheradmin@example.com"), None) + if other_user: + resp = await client.delete( + f"/api/admin/users/{other_user['id']}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert resp.status_code == 204 + + # Verify only seed admin remains + list_resp = await client.get("/api/admin/users", headers={"Authorization": f"Bearer {admin_token}"}) + remaining = list_resp.json() + assert len(remaining) == 1 + assert remaining[0]["email"] == "testadmin@example.com" + + +@pytest.mark.asyncio +async def test_delete_self_admin_user_forbidden(client: AsyncClient, admin_token: str, seed_admin_user: User): + """DELETE /api/admin/users/{id} blocks deleting yourself.""" + resp = await client.delete( + "/api/admin/users/99999", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert resp.status_code == 400 + assert "own account" in resp.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_delete_last_admin_forbidden(client: AsyncClient, admin_token: str, seed_admin_user: User): + """DELETE /api/admin/users/{id} blocks if it's the last admin.""" + # Try to delete the only admin (our seed user) - self-delete is blocked + resp = await client.delete( + "/api/admin/users/99999", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_update_admin_user(client: AsyncClient, admin_token: str, seed_admin_user: User): + """PUT /api/admin/users/{id} updates display_name.""" + resp = await client.put( + "/api/admin/users/99999", + headers={"Authorization": f"Bearer {admin_token}"}, + json={"display_name": "Updated Name"}, + ) + assert resp.status_code == 200 + assert resp.json()["display_name"] == "Updated Name" + + +@pytest.mark.asyncio +async def test_admin_users_requires_auth(client: AsyncClient): + """GET /api/admin/users returns 401 without auth headers.""" + resp = await client.get("/api/admin/users") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_create_admin_assigns_admin_role(client: AsyncClient, admin_token: str, seed_admin_user: User): + """POST /api/admin/users assigns the 'admin' role and sets is_admin=True.""" + resp = await client.post( + "/api/admin/users", + headers={"Authorization": f"Bearer {admin_token}"}, + json={ + "email": "roletest@example.com", + "display_name": "Role Test", + "password": "pass123", + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["is_admin"] is True + + # Verify via list endpoint that the user shows up as admin + list_resp = await client.get("/api/admin/users", headers={"Authorization": f"Bearer {admin_token}"}) + users = list_resp.json() + role_user = next((u for u in users if u["email"] == "roletest@example.com"), None) + assert role_user is not None + assert role_user["is_admin"] is True diff --git a/src/app/admin/admin-users-tab.component.html b/src/app/admin/admin-users-tab.component.html new file mode 100644 index 0000000..a0800e2 --- /dev/null +++ b/src/app/admin/admin-users-tab.component.html @@ -0,0 +1,82 @@ +
+
+

Admin Users ({{ users.length }})

+
+ + @if (error) { + + } + @if (success) { + + } + + +
+ @if (loading && users.length === 0) { +

Loading...

+ } @else { + + + + + + + + + + + @for (user of users; track user.id) { + + + + + + + } @empty { + + + + } + +
NameEmailProviderActions
{{ user.display_name }}{{ user.email }}{{ user.auth_provider }} + +
No admin users yet. Use the form below to add one.
+ } +
+ + +
+

Add New Admin

+
+
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
diff --git a/src/app/admin/admin-users-tab.component.scss b/src/app/admin/admin-users-tab.component.scss new file mode 100644 index 0000000..53dbe41 --- /dev/null +++ b/src/app/admin/admin-users-tab.component.scss @@ -0,0 +1,23 @@ +@use '../../styles/variables' as *; +@use '../../styles/mixins' as *; + +.add-admin-section { + margin-top: 2rem; + padding: 1.5rem; + border: 1px solid var(--color-light, #e0e0e0); + border-radius: 8px; + + h3 { + margin: 0 0 1rem 0; + } +} + +.admin-users-section { + margin-bottom: 1rem; +} + +.loading-text { + text-align: center; + padding: 2rem; + color: var(--color-medium, #888); +} diff --git a/src/app/admin/admin-users-tab.component.ts b/src/app/admin/admin-users-tab.component.ts new file mode 100644 index 0000000..eb94844 --- /dev/null +++ b/src/app/admin/admin-users-tab.component.ts @@ -0,0 +1,134 @@ +import { ChangeDetectorRef, Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; + +import { AdminUser } from '../interfaces/admin-user'; +import { AdminUserService, CreateAdminPayload } from '../services/admin-user.service'; + +@Component({ + selector: 'app-admin-users-tab', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-users-tab.component.html', + styleUrl: './admin-users-tab.component.scss', +}) +export class AdminUsersTabComponent implements OnChanges { + private cdr = inject(ChangeDetectorRef); + private adminUserService = inject(AdminUserService); + + readonly saved = EventEmitter(); + readonly closed = EventEmitter(); + + @Input() editItem: AdminUser | null = null; + + users: AdminUser[] = []; + loading = false; + error = ''; + success = ''; + + // Form fields + email = ''; + displayName = ''; + password = ''; + confirmPassword = ''; + submitted = false; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['editItem'] && this.editItem) { + this.edit(this.editItem); + } + } + + ngOnInit(): void { + this.load(); + } + + async load(): Promise { + this.loading = true; + this.error = ''; + try { + this.users = await firstValueFrom(this.adminUserService.getAdminUsers()); + } catch (err: any) { + this.error = this.formatError(err); + } finally { + this.loading = false; + this.cdr.detectChanges(); + } + } + + edit(user: AdminUser): void { + this.displayName = user.display_name; + } + + formatError(err: any): string { + if (err?.error?.detail) return Array.isArray(err.error.detail) ? err.error.detail[0]?.msg || 'Failed' : err.error.detail; + if (err?.error?.message) return err.error.message; + if (err?.message) return err.message; + return 'Failed to load. Please try again.'; + } + + resetForm(): void { + this.email = ''; + this.displayName = ''; + this.password = ''; + this.confirmPassword = ''; + this.error = ''; + this.success = ''; + this.submitted = false; + } + + async onAdd(): Promise { + this.submitted = true; + this.error = ''; + + if (!this.email || !this.displayName || !this.password) { + this.error = 'Please fill in all required fields.'; + return; + } + if (this.password !== this.confirmPassword) { + this.error = 'Passwords do not match.'; + return; + } + if (this.password.length < 4) { + this.error = 'Password must be at least 4 characters.'; + return; + } + + const payload: CreateAdminPayload = { + email: this.email, + display_name: this.displayName, + password: this.password, + }; + + this.loading = true; + try { + await firstValueFrom(this.adminUserService.createAdminUser(payload)); + this.success = 'Admin user created successfully.'; + this.resetForm(); + await this.load(); + } catch (err: any) { + this.error = this.formatError(err); + } finally { + this.loading = false; + this.cdr.detectChanges(); + } + } + + async onDelete(id: number, name: string): Promise { + if (!confirm(`Delete admin "${name}"? This cannot be undone.`)) return; + + this.loading = true; + this.error = ''; + try { + await firstValueFrom(this.adminUserService.deleteAdminUser(id)); + this.success = 'Admin user deleted.'; + await this.load(); + } catch (err: any) { + this.error = this.formatError(err); + } finally { + this.loading = false; + this.cdr.detectChanges(); + } + } +} diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html index 5cb5c92..3674d9b 100644 --- a/src/app/admin/admin.component.html +++ b/src/app/admin/admin.component.html @@ -17,6 +17,7 @@ + @@ -73,6 +74,10 @@ } + + @if (activeTab() === 'admins') { + + } diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index 4da8e95..ce7f9d0 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -26,6 +26,7 @@ import { AdminTeamFormComponent } from './admin-team-form.component'; import { AdminCommunityFormComponent } from './admin-community-form.component'; import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component'; import { AdminStatsDashboardComponent } from './admin-stats-dashboard.component'; +import { AdminUsersTabComponent } from './admin-users-tab.component'; type TabKey = | 'shows' @@ -37,7 +38,8 @@ type TabKey = | 'theme' | 'underwriters' | 'mobile-builds' - | 'stats'; + | 'stats' + | 'admins'; @Component({ selector: 'app-admin', @@ -55,6 +57,7 @@ type TabKey = AdminUnderwritersTabComponent, AdminMobileBuildsTabComponent, AdminStatsDashboardComponent, + AdminUsersTabComponent, AdminShowFormComponent, AdminEventFormComponent, AdminStationFormComponent, diff --git a/src/app/interfaces/admin-user.ts b/src/app/interfaces/admin-user.ts new file mode 100644 index 0000000..28cbbe9 --- /dev/null +++ b/src/app/interfaces/admin-user.ts @@ -0,0 +1,7 @@ +export interface AdminUser { + id: number; + email: string; + display_name: string; + auth_provider: string; + is_admin: boolean; +} diff --git a/src/app/services/admin-user.service.ts b/src/app/services/admin-user.service.ts new file mode 100644 index 0000000..ddac919 --- /dev/null +++ b/src/app/services/admin-user.service.ts @@ -0,0 +1,40 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { AdminUser } from '../interfaces/admin-user'; +import { getAppConfig } from './app-config.service'; + +export interface CreateAdminPayload { + email: string; + display_name: string; + password: string; +} + +export interface UpdateAdminPayload { + display_name?: string; +} + +@Injectable({ + providedIn: 'root', +}) +export class AdminUserService { + private http = inject(HttpClient); + private baseUrl = `${getAppConfig().apiBaseUrl}/api/admin/users`; + + getAdminUsers(): Observable { + return this.http.get(this.baseUrl); + } + + createAdminUser(payload: CreateAdminPayload): Observable { + return this.http.post(this.baseUrl, payload); + } + + updateAdminUser(id: number, payload: UpdateAdminPayload): Observable { + return this.http.put(`${this.baseUrl}/${id}`, payload); + } + + deleteAdminUser(id: number): Observable { + return this.http.delete(`${this.baseUrl}/${id}`); + } +}