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,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() === '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() === 'admins'" (click)="activeTab.set('admins')">Admins</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab content -->
|
||||
@@ -73,6 +74,10 @@
|
||||
<app-admin-stats-dashboard />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (activeTab() === 'admins') {
|
||||
<app-admin-users-tab />
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Form modals -->
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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