feat: implement profile reporting system with admin review

Added comprehensive profile reporting system to allow users to report
inappropriate profiles and admins to review reports.

Features:
- User can report profiles with predefined reasons + custom details
- Duplicate prevention (one pending report per profile per user)
- Rate limiting (5 pending reports maximum per user)
- Admin dashboard to view and filter reports (All, Pending, Reviewed, etc.)
- Admin review modal to update status and add review notes
- Report button on profile page (only visible when viewing others)
- Font Awesome icons for better UI consistency

Database changes:
- New ProfileReport model with ReportReason/ReportStatus enums
- User relations for reports (reportsMade, reportsReceived, reportsReviewed)
- Indices for efficient querying
This commit is contained in:
2026-02-19 18:33:58 -08:00
committed by Naomi Carrigan
parent 5eec4c7640
commit d797d38ddd
9 changed files with 2056 additions and 6 deletions
@@ -0,0 +1,968 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ReportService } from '../../services/report.service';
import { AuthService } from '../../services/auth.service';
import { ToastService } from '../../services/toast.service';
import { ProfileReportWithUsers, ReportStatus, ReportReason } from '@library/shared-types';
@Component({
selector: 'app-admin-reports',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="admin-reports-container">
<h1>Profile Reports</h1>
@if (loading()) {
<div class="loading" role="status" aria-live="polite">
<p>Loading reports...</p>
</div>
} @else if (error()) {
<div class="error" role="alert" aria-live="assertive">
<p>{{ error() }}</p>
</div>
} @else {
<!-- Filter Tabs -->
<div class="filter-tabs" role="tablist" aria-label="Report status filters">
<button
type="button"
role="tab"
class="tab"
[class.active]="activeFilter() === 'all'"
[attr.aria-selected]="activeFilter() === 'all'"
(click)="setFilter('all')"
>
All ({{ allReports().length }})
</button>
<button
type="button"
role="tab"
class="tab"
[class.active]="activeFilter() === ReportStatus.PENDING"
[attr.aria-selected]="activeFilter() === ReportStatus.PENDING"
(click)="setFilter(ReportStatus.PENDING)"
>
Pending ({{ pendingCount() }})
</button>
<button
type="button"
role="tab"
class="tab"
[class.active]="activeFilter() === ReportStatus.REVIEWED"
[attr.aria-selected]="activeFilter() === ReportStatus.REVIEWED"
(click)="setFilter(ReportStatus.REVIEWED)"
>
Reviewed ({{ reviewedCount() }})
</button>
<button
type="button"
role="tab"
class="tab"
[class.active]="activeFilter() === ReportStatus.DISMISSED"
[attr.aria-selected]="activeFilter() === ReportStatus.DISMISSED"
(click)="setFilter(ReportStatus.DISMISSED)"
>
Dismissed ({{ dismissedCount() }})
</button>
<button
type="button"
role="tab"
class="tab"
[class.active]="activeFilter() === ReportStatus.ACTION_TAKEN"
[attr.aria-selected]="activeFilter() === ReportStatus.ACTION_TAKEN"
(click)="setFilter(ReportStatus.ACTION_TAKEN)"
>
Action Taken ({{ actionTakenCount() }})
</button>
</div>
<!-- Reports List -->
<div class="reports-list" role="region" aria-label="Reports list">
@for (report of filteredReports(); track report.id) {
<div class="report-card">
<!-- Report Header -->
<div class="report-header">
<div class="user-info">
<div class="user-section">
<h3>Reported User</h3>
<div class="user-details">
@if (report.reportedUser.avatar) {
<img
[src]="report.reportedUser.avatar"
[alt]="report.reportedUser.username + ' avatar'"
class="avatar"
/>
} @else {
<div class="avatar-placeholder" [attr.aria-label]="report.reportedUser.username + ' avatar'">
{{ report.reportedUser.username.charAt(0).toUpperCase() }}
</div>
}
<div class="user-names">
<span class="username">{{ report.reportedUser.username }}</span>
@if (report.reportedUser.displayName) {
<span class="display-name">({{ report.reportedUser.displayName }})</span>
}
</div>
</div>
</div>
<div class="user-section">
<h3>Reporter</h3>
<div class="user-details">
@if (report.reporter.avatar) {
<img
[src]="report.reporter.avatar"
[alt]="report.reporter.username + ' avatar'"
class="avatar"
/>
} @else {
<div class="avatar-placeholder" [attr.aria-label]="report.reporter.username + ' avatar'">
{{ report.reporter.username.charAt(0).toUpperCase() }}
</div>
}
<div class="user-names">
<span class="username">{{ report.reporter.username }}</span>
@if (report.reporter.displayName) {
<span class="display-name">({{ report.reporter.displayName }})</span>
}
</div>
</div>
</div>
</div>
<div class="report-meta">
<span
class="status-badge"
[class.pending]="report.status === 'PENDING'"
[class.reviewed]="report.status === 'REVIEWED'"
[class.dismissed]="report.status === 'DISMISSED'"
[class.action-taken]="report.status === 'ACTION_TAKEN'"
[attr.aria-label]="'Status: ' + formatStatus(report.status)"
>
{{ formatStatus(report.status) }}
</span>
<span class="report-date">
{{ formatDate(report.createdAt) }}
</span>
</div>
</div>
<!-- Report Content -->
<div class="report-content">
<div class="reason">
<strong>Reason:</strong> {{ formatReason(report.reason) }}
</div>
<div class="details">
<strong>Details:</strong>
<p>{{ report.details }}</p>
</div>
@if (report.reviewNotes) {
<div class="review-notes">
<strong>Review Notes:</strong>
<p>{{ report.reviewNotes }}</p>
@if (report.reviewer) {
<small class="reviewer">
Reviewed by {{ report.reviewer.username }}
</small>
}
</div>
}
</div>
<!-- Report Actions -->
<div class="report-actions">
<button
type="button"
class="btn btn-review"
(click)="openReviewModal(report)"
[attr.aria-label]="'Review report for ' + report.reportedUser.username"
>
Review
</button>
</div>
</div>
} @empty {
<div class="empty-state">
<p>No reports found.</p>
</div>
}
</div>
}
<!-- Review Modal -->
@if (reviewingReport()) {
<div
class="modal-overlay"
(click)="closeReviewModal()"
(keydown.escape)="closeReviewModal()"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div
class="modal-content"
(click)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
role="document"
tabindex="-1"
>
<div class="modal-header">
<h2 id="modal-title">Review Report</h2>
<button
type="button"
class="modal-close"
(click)="closeReviewModal()"
aria-label="Close modal"
>
×
</button>
</div>
<div class="modal-body">
<!-- Report Summary -->
<div class="review-summary">
<div class="summary-item">
<strong>Reported User:</strong>
<span>{{ reviewingReport()!.reportedUser.username }}</span>
</div>
<div class="summary-item">
<strong>Reporter:</strong>
<span>{{ reviewingReport()!.reporter.username }}</span>
</div>
<div class="summary-item">
<strong>Reason:</strong>
<span>{{ formatReason(reviewingReport()!.reason) }}</span>
</div>
<div class="summary-item">
<strong>Reported On:</strong>
<span>{{ formatDate(reviewingReport()!.createdAt) }}</span>
</div>
</div>
<div class="details-section">
<strong>Details:</strong>
<p class="details-text">{{ reviewingReport()!.details }}</p>
</div>
<!-- Review Form -->
<form (ngSubmit)="submitReview()" class="review-form">
<div class="form-group">
<label for="status">Update Status</label>
<select
id="status"
name="status"
[(ngModel)]="reviewForm.status"
class="form-control"
required
aria-required="true"
>
<option value="PENDING">Pending</option>
<option value="REVIEWED">Reviewed</option>
<option value="DISMISSED">Dismissed</option>
<option value="ACTION_TAKEN">Action Taken</option>
</select>
</div>
<div class="form-group">
<label for="reviewNotes">Review Notes (Optional)</label>
<textarea
id="reviewNotes"
name="reviewNotes"
[(ngModel)]="reviewForm.reviewNotes"
class="form-control"
rows="4"
placeholder="Add any notes about your review..."
></textarea>
</div>
<div class="modal-actions">
<button
type="button"
class="btn btn-secondary"
(click)="closeReviewModal()"
[disabled]="submitting()"
>
Cancel
</button>
<button
type="submit"
class="btn btn-primary"
[disabled]="submitting()"
>
{{ submitting() ? 'Submitting...' : 'Submit Review' }}
</button>
</div>
</form>
</div>
</div>
</div>
}
</div>
`,
styles: [`
.admin-reports-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
h1 {
color: var(--witch-purple);
margin-bottom: 2rem;
font-size: 2rem;
}
.loading, .error, .empty-state {
text-align: center;
padding: 3rem 2rem;
color: var(--witch-mauve);
font-size: 1.1rem;
}
.error {
color: var(--witch-rose);
}
/* Filter Tabs */
.filter-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
flex-wrap: wrap;
border-bottom: 2px solid var(--witch-lavender);
padding-bottom: 0.5rem;
}
.tab {
padding: 0.75rem 1.5rem;
background: transparent;
border: none;
border-radius: 8px 8px 0 0;
color: var(--witch-mauve);
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
position: relative;
}
.tab:hover {
background: var(--witch-lavender);
color: var(--witch-purple);
}
.tab.active {
background: var(--witch-purple);
color: var(--witch-moon);
font-weight: 600;
}
.tab:focus {
outline: 2px solid var(--witch-purple);
outline-offset: 2px;
}
/* Reports List */
.reports-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.report-card {
background: var(--witch-moon);
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 12px var(--witch-shadow);
transition: all 0.3s;
}
.report-card:hover {
box-shadow: 0 6px 16px var(--witch-shadow);
transform: translateY(-2px);
}
/* Report Header */
.report-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--witch-lavender);
flex-wrap: wrap;
gap: 1rem;
}
.user-info {
display: flex;
gap: 2rem;
flex-wrap: wrap;
flex: 1;
}
.user-section h3 {
font-size: 0.85rem;
color: var(--witch-mauve);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.user-details {
display: flex;
align-items: center;
gap: 0.75rem;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--witch-lavender);
}
.avatar-placeholder {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--witch-purple);
color: var(--witch-moon);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 1rem;
border: 2px solid var(--witch-lavender);
}
.user-names {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.username {
font-weight: 600;
color: var(--witch-purple);
font-size: 0.95rem;
}
.display-name {
font-size: 0.85rem;
color: var(--witch-mauve);
}
.report-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.status-badge {
padding: 0.4rem 0.9rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-badge.pending {
background: #ffd93d;
color: #1a1a1a;
}
.status-badge.reviewed {
background: #6ab7ff;
color: #1a1a1a;
}
.status-badge.dismissed {
background: #b0b0b0;
color: #1a1a1a;
}
.status-badge.action-taken {
background: #6bcf7f;
color: #1a1a1a;
}
.report-date {
font-size: 0.85rem;
color: var(--witch-mauve);
}
/* Report Content */
.report-content {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.reason, .details, .review-notes {
color: var(--witch-purple);
}
.reason strong, .details strong, .review-notes strong {
display: block;
margin-bottom: 0.5rem;
color: var(--witch-purple);
font-weight: 600;
}
.details p, .review-notes p {
color: var(--witch-mauve);
line-height: 1.6;
margin: 0;
}
.reviewer {
display: block;
margin-top: 0.5rem;
color: var(--witch-mauve);
font-style: italic;
}
/* Report Actions */
.report-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn {
padding: 0.65rem 1.5rem;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 8px var(--witch-shadow);
}
.btn:focus {
outline: 2px solid var(--witch-purple);
outline-offset: 2px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-review {
background: var(--witch-purple);
color: var(--witch-moon);
}
.btn-review:hover {
background: var(--witch-mauve);
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: var(--witch-moon);
border-radius: 16px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px var(--witch-shadow);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--witch-lavender);
}
.modal-header h2 {
color: var(--witch-purple);
margin: 0;
font-size: 1.5rem;
}
.modal-close {
background: none;
border: none;
font-size: 2rem;
color: var(--witch-mauve);
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.3s;
}
.modal-close:hover {
background: var(--witch-lavender);
color: var(--witch-purple);
}
.modal-close:focus {
outline: 2px solid var(--witch-purple);
outline-offset: 2px;
}
.modal-body {
padding: 1.5rem;
}
/* Review Summary */
.review-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--witch-lavender);
border-radius: 8px;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.summary-item strong {
color: var(--witch-purple);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.summary-item span {
color: var(--witch-mauve);
font-size: 0.95rem;
}
.details-section {
margin-bottom: 1.5rem;
}
.details-section strong {
display: block;
margin-bottom: 0.5rem;
color: var(--witch-purple);
font-weight: 600;
}
.details-text {
color: var(--witch-mauve);
line-height: 1.6;
padding: 1rem;
background: var(--witch-lavender);
border-radius: 8px;
margin: 0;
}
/* Review Form */
.review-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
color: var(--witch-purple);
font-weight: 600;
font-size: 0.95rem;
}
.form-control {
padding: 0.75rem;
border: 2px solid var(--witch-lavender);
border-radius: 8px;
background: var(--witch-moon);
color: var(--witch-purple);
font-size: 1rem;
font-family: inherit;
transition: all 0.3s;
}
.form-control:focus {
outline: none;
border-color: var(--witch-purple);
box-shadow: 0 0 0 3px rgba(155, 89, 182, 0.2);
}
select.form-control {
cursor: pointer;
}
textarea.form-control {
resize: vertical;
min-height: 100px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid var(--witch-lavender);
}
.btn-secondary {
background: var(--witch-lavender);
color: var(--witch-purple);
}
.btn-secondary:hover:not(:disabled) {
background: var(--witch-mauve);
color: var(--witch-moon);
}
.btn-primary {
background: var(--witch-purple);
color: var(--witch-moon);
}
.btn-primary:hover:not(:disabled) {
background: var(--witch-mauve);
}
/* Responsive Design */
@media (max-width: 768px) {
.admin-reports-container {
padding: 1rem;
}
h1 {
font-size: 1.5rem;
}
.filter-tabs {
flex-direction: column;
}
.tab {
text-align: left;
}
.report-header {
flex-direction: column;
}
.user-info {
flex-direction: column;
gap: 1rem;
}
.report-meta {
align-items: flex-start;
}
.review-summary {
grid-template-columns: 1fr;
}
.modal-actions {
flex-direction: column-reverse;
}
.btn {
width: 100%;
}
}
`]
})
export class AdminReportsComponent implements OnInit {
private reportService = inject(ReportService);
private authService = inject(AuthService);
private toastService = inject(ToastService);
private router = inject(Router);
// Make ReportStatus accessible in template
protected readonly ReportStatus = ReportStatus;
allReports = signal<ProfileReportWithUsers[]>([]);
loading = signal(true);
error = signal<string | null>(null);
activeFilter = signal<'all' | ReportStatus>('all');
reviewingReport = signal<ProfileReportWithUsers | null>(null);
submitting = signal(false);
reviewForm = {
status: ReportStatus.PENDING,
reviewNotes: ''
};
filteredReports = computed(() => {
const filter = this.activeFilter();
if (filter === 'all') {
return this.allReports();
}
return this.allReports().filter(report => report.status === filter);
});
pendingCount = computed(() =>
this.allReports().filter(r => r.status === ReportStatus.PENDING).length
);
reviewedCount = computed(() =>
this.allReports().filter(r => r.status === ReportStatus.REVIEWED).length
);
dismissedCount = computed(() =>
this.allReports().filter(r => r.status === ReportStatus.DISMISSED).length
);
actionTakenCount = computed(() =>
this.allReports().filter(r => r.status === ReportStatus.ACTION_TAKEN).length
);
ngOnInit(): void {
if (!this.authService.isAdmin()) {
this.router.navigate(['/']);
return;
}
this.loadReports();
}
private loadReports(): void {
this.loading.set(true);
this.error.set(null);
this.reportService.getAllReports().subscribe({
next: (reports) => {
this.allReports.set(reports);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message ?? 'Failed to load reports');
this.loading.set(false);
this.toastService.error('Failed to load reports');
}
});
}
setFilter(filter: 'all' | ReportStatus): void {
this.activeFilter.set(filter);
}
openReviewModal(report: ProfileReportWithUsers): void {
this.reviewingReport.set(report);
this.reviewForm = {
status: report.status,
reviewNotes: report.reviewNotes || ''
};
}
closeReviewModal(): void {
if (!this.submitting()) {
this.reviewingReport.set(null);
this.reviewForm = {
status: ReportStatus.PENDING,
reviewNotes: ''
};
}
}
submitReview(): void {
const report = this.reviewingReport();
if (!report) {
return;
}
this.submitting.set(true);
this.reportService.updateReport(
report.id,
this.reviewForm.status,
this.reviewForm.reviewNotes || undefined
).subscribe({
next: (updatedReport) => {
this.allReports.update(reports =>
reports.map(r => r.id === updatedReport.id ? updatedReport : r)
);
this.toastService.success('Report updated successfully');
this.submitting.set(false);
this.closeReviewModal();
},
error: (err) => {
this.toastService.error(err.message ?? 'Failed to update report');
this.submitting.set(false);
}
});
}
formatStatus(status: ReportStatus): string {
const statusMap: Record<ReportStatus, string> = {
[ReportStatus.PENDING]: 'Pending',
[ReportStatus.REVIEWED]: 'Reviewed',
[ReportStatus.DISMISSED]: 'Dismissed',
[ReportStatus.ACTION_TAKEN]: 'Action Taken'
};
return statusMap[status];
}
formatReason(reason: ReportReason): string {
const reasonMap: Record<ReportReason, string> = {
[ReportReason.INAPPROPRIATE_CONTENT]: 'Inappropriate Content',
[ReportReason.HARASSMENT]: 'Harassment',
[ReportReason.SPAM]: 'Spam',
[ReportReason.IMPERSONATION]: 'Impersonation',
[ReportReason.OFFENSIVE_NAME]: 'Offensive Name',
[ReportReason.MALICIOUS_LINKS]: 'Malicious Links',
[ReportReason.OTHER]: 'Other'
};
return reasonMap[reason];
}
formatDate(date: Date): string {
return new Date(date).toLocaleString('en-GB', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
}