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)
This commit is contained in:
@@ -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
|
||||||
@@ -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(mobile_builds.router, prefix="/api/mobile-builds", tags=["mobile-builds"])
|
||||||
app.include_router(theme.router, prefix="/api/theme", tags=["theme"])
|
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)
|
# Dev-only admin router (disabled in production)
|
||||||
if settings.ENV != "production":
|
if settings.ENV != "production":
|
||||||
from app.api import admin
|
from app.api import admin
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<h2>Admin Users ({{ users.length }})</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (error) {
|
||||||
|
<div class="form-error" role="alert">{{ error }}</div>
|
||||||
|
}
|
||||||
|
@if (success) {
|
||||||
|
<div class="form-success" role="alert">{{ success }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Admin list -->
|
||||||
|
<div class="admin-users-section">
|
||||||
|
@if (loading && users.length === 0) {
|
||||||
|
<p class="loading-text">Loading...</p>
|
||||||
|
} @else {
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Provider</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (user of users; track user.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ user.display_name }}</td>
|
||||||
|
<td>{{ user.email }}</td>
|
||||||
|
<td>{{ user.auth_provider }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn-icon btn-delete" (click)="onDelete(user.id, user.display_name)">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="empty-row">No admin users yet. Use the form below to add one.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add admin form -->
|
||||||
|
<div class="add-admin-section">
|
||||||
|
<h3>Add New Admin</h3>
|
||||||
|
<form (ngSubmit)="onAdd()" class="admin-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="admin-email">Email</label>
|
||||||
|
<input id="admin-email" type="email" [(ngModel)]="email" name="email" placeholder="admin@example.com" required [class.is-invalid]="submitted && !email">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="admin-display-name">Display Name</label>
|
||||||
|
<input id="admin-display-name" type="text" [(ngModel)]="displayName" name="displayName" placeholder="Full name" required [class.is-invalid]="submitted && !displayName">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="admin-password">Password</label>
|
||||||
|
<input id="admin-password" type="password" [(ngModel)]="password" name="password" placeholder="Min 4 characters" required [class.is-invalid]="submitted && !password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="admin-confirm-password">Confirm Password</label>
|
||||||
|
<input id="admin-confirm-password" type="password" [(ngModel)]="confirmPassword" name="confirmPassword" placeholder="Repeat password" required [class.is-invalid]="submitted && !confirmPassword">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-save" [disabled]="loading">
|
||||||
|
{{ loading ? 'Adding...' : 'Add Admin' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<void>();
|
||||||
|
readonly closed = EventEmitter<void>();
|
||||||
|
|
||||||
|
@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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
<button class="tab-btn" [class.active]="activeTab() === 'underwriters'" (click)="activeTab.set('underwriters')">Underwriters</button>
|
<button class="tab-btn" [class.active]="activeTab() === 'underwriters'" (click)="activeTab.set('underwriters')">Underwriters</button>
|
||||||
<button class="tab-btn" [class.active]="activeTab() === 'mobile-builds'" (click)="activeTab.set('mobile-builds')">Mobile Builds</button>
|
<button class="tab-btn" [class.active]="activeTab() === 'mobile-builds'" (click)="activeTab.set('mobile-builds')">Mobile Builds</button>
|
||||||
<button class="tab-btn" [class.active]="activeTab() === 'stats'" (click)="activeTab.set('stats')">Stats</button>
|
<button class="tab-btn" [class.active]="activeTab() === 'stats'" (click)="activeTab.set('stats')">Stats</button>
|
||||||
|
<button class="tab-btn" [class.active]="activeTab() === 'admins'" (click)="activeTab.set('admins')">Admins</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab content -->
|
<!-- Tab content -->
|
||||||
@@ -73,6 +74,10 @@
|
|||||||
<app-admin-stats-dashboard />
|
<app-admin-stats-dashboard />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (activeTab() === 'admins') {
|
||||||
|
<app-admin-users-tab />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form modals -->
|
<!-- Form modals -->
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { AdminTeamFormComponent } from './admin-team-form.component';
|
|||||||
import { AdminCommunityFormComponent } from './admin-community-form.component';
|
import { AdminCommunityFormComponent } from './admin-community-form.component';
|
||||||
import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component';
|
import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component';
|
||||||
import { AdminStatsDashboardComponent } from './admin-stats-dashboard.component';
|
import { AdminStatsDashboardComponent } from './admin-stats-dashboard.component';
|
||||||
|
import { AdminUsersTabComponent } from './admin-users-tab.component';
|
||||||
|
|
||||||
type TabKey =
|
type TabKey =
|
||||||
| 'shows'
|
| 'shows'
|
||||||
@@ -37,7 +38,8 @@ type TabKey =
|
|||||||
| 'theme'
|
| 'theme'
|
||||||
| 'underwriters'
|
| 'underwriters'
|
||||||
| 'mobile-builds'
|
| 'mobile-builds'
|
||||||
| 'stats';
|
| 'stats'
|
||||||
|
| 'admins';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin',
|
selector: 'app-admin',
|
||||||
@@ -55,6 +57,7 @@ type TabKey =
|
|||||||
AdminUnderwritersTabComponent,
|
AdminUnderwritersTabComponent,
|
||||||
AdminMobileBuildsTabComponent,
|
AdminMobileBuildsTabComponent,
|
||||||
AdminStatsDashboardComponent,
|
AdminStatsDashboardComponent,
|
||||||
|
AdminUsersTabComponent,
|
||||||
AdminShowFormComponent,
|
AdminShowFormComponent,
|
||||||
AdminEventFormComponent,
|
AdminEventFormComponent,
|
||||||
AdminStationFormComponent,
|
AdminStationFormComponent,
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface AdminUser {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
display_name: string;
|
||||||
|
auth_provider: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
}
|
||||||
@@ -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<AdminUser[]> {
|
||||||
|
return this.http.get<AdminUser[]>(this.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
createAdminUser(payload: CreateAdminPayload): Observable<AdminUser> {
|
||||||
|
return this.http.post<AdminUser>(this.baseUrl, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAdminUser(id: number, payload: UpdateAdminPayload): Observable<AdminUser> {
|
||||||
|
return this.http.put<AdminUser>(`${this.baseUrl}/${id}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAdminUser(id: number): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user