Admin console: reserved usernames management + claim request review system

This commit is contained in:
Patrick Britton 2026-02-06 17:13:15 -06:00
parent 6a1f20759b
commit 2fb413c8d2
9 changed files with 871 additions and 3 deletions

View file

@ -0,0 +1,458 @@
'use client';
import AdminShell from '@/components/AdminShell';
import { api } from '@/lib/api';
import { useEffect, useState } from 'react';
import { AtSign, Plus, Trash2, Search, Check, X, Clock, ChevronDown, Upload } from 'lucide-react';
const CATEGORIES = [
{ value: '', label: 'All Categories' },
{ value: 'platform', label: 'Platform' },
{ value: 'brand', label: 'Brand' },
{ value: 'public_figure', label: 'Public Figure' },
{ value: 'custom', label: 'Custom' },
];
export default function UsernamesPage() {
const [tab, setTab] = useState<'reserved' | 'claims'>('reserved');
return (
<AdminShell>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Username Management</h1>
<p className="text-sm text-gray-500 mt-1">Manage reserved usernames and review claim requests</p>
</div>
{/* Tab Switcher */}
<div className="flex gap-1 bg-warm-200 p-1 rounded-lg w-fit mb-6">
<button
onClick={() => setTab('reserved')}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
tab === 'reserved' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
Reserved Usernames
</button>
<button
onClick={() => setTab('claims')}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
tab === 'claims' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
Claim Requests
</button>
</div>
{tab === 'reserved' ? <ReservedTab /> : <ClaimsTab />}
</AdminShell>
);
}
// ─── Reserved Usernames Tab ───────────────────────────────
function ReservedTab() {
const [items, setItems] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
const [showAdd, setShowAdd] = useState(false);
const [showBulk, setShowBulk] = useState(false);
const [addForm, setAddForm] = useState({ username: '', category: 'custom', reason: '' });
const [bulkForm, setBulkForm] = useState({ text: '', category: 'custom', reason: '' });
const [saving, setSaving] = useState(false);
const [offset, setOffset] = useState(0);
const limit = 50;
const load = () => {
setLoading(true);
api.listReservedUsernames({ search: search || undefined, category: category || undefined, limit, offset })
.then((data) => {
setItems(data.reserved_usernames || []);
setTotal(data.total || 0);
})
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, [search, category, offset]);
const handleAdd = async () => {
if (!addForm.username.trim()) return;
setSaving(true);
try {
await api.addReservedUsername(addForm);
setAddForm({ username: '', category: 'custom', reason: '' });
setShowAdd(false);
load();
} catch (e: any) {
alert(e.message);
} finally {
setSaving(false);
}
};
const handleBulkAdd = async () => {
const usernames = bulkForm.text.split('\n').map(u => u.trim()).filter(Boolean);
if (usernames.length === 0) return;
setSaving(true);
try {
const res = await api.bulkAddReservedUsernames({ usernames, category: bulkForm.category, reason: bulkForm.reason });
alert(res.message);
setBulkForm({ text: '', category: 'custom', reason: '' });
setShowBulk(false);
load();
} catch (e: any) {
alert(e.message);
} finally {
setSaving(false);
}
};
const handleRemove = async (id: string, username: string) => {
if (!confirm(`Remove "${username}" from reserved list?`)) return;
try {
await api.removeReservedUsername(id);
load();
} catch (e: any) {
alert(e.message);
}
};
return (
<div>
{/* Actions Bar */}
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search usernames..."
value={search}
onChange={(e) => { setSearch(e.target.value); setOffset(0); }}
className="w-full pl-9 pr-3 py-2 text-sm border border-warm-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<select
value={category}
onChange={(e) => { setCategory(e.target.value); setOffset(0); }}
className="text-sm border border-warm-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<button onClick={() => setShowAdd(!showAdd)} className="btn-primary text-sm flex items-center gap-1.5">
<Plus className="w-4 h-4" /> Add
</button>
<button onClick={() => setShowBulk(!showBulk)} className="btn-secondary text-sm flex items-center gap-1.5">
<Upload className="w-4 h-4" /> Bulk Add
</button>
</div>
{/* Add Form */}
{showAdd && (
<div className="card p-4 mb-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Add Reserved Username</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<input
type="text"
placeholder="Username"
value={addForm.username}
onChange={(e) => setAddForm({ ...addForm, username: e.target.value.toLowerCase() })}
className="text-sm border border-warm-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
<select
value={addForm.category}
onChange={(e) => setAddForm({ ...addForm, category: e.target.value })}
className="text-sm border border-warm-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="platform">Platform</option>
<option value="brand">Brand</option>
<option value="public_figure">Public Figure</option>
<option value="custom">Custom</option>
</select>
<input
type="text"
placeholder="Reason (optional)"
value={addForm.reason}
onChange={(e) => setAddForm({ ...addForm, reason: e.target.value })}
className="text-sm border border-warm-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
<button onClick={handleAdd} disabled={saving} className="btn-primary text-sm">
{saving ? 'Saving...' : 'Reserve'}
</button>
</div>
</div>
)}
{/* Bulk Add Form */}
{showBulk && (
<div className="card p-4 mb-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Bulk Add Reserved Usernames</h3>
<p className="text-xs text-gray-400 mb-2">One username per line</p>
<textarea
rows={6}
placeholder={"google\napple\nmicrosoft\n..."}
value={bulkForm.text}
onChange={(e) => setBulkForm({ ...bulkForm, text: e.target.value })}
className="w-full text-sm border border-warm-300 rounded-lg px-3 py-2 mb-3 focus:outline-none focus:ring-2 focus:ring-brand-500 font-mono"
/>
<div className="flex items-center gap-3">
<select
value={bulkForm.category}
onChange={(e) => setBulkForm({ ...bulkForm, category: e.target.value })}
className="text-sm border border-warm-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="platform">Platform</option>
<option value="brand">Brand</option>
<option value="public_figure">Public Figure</option>
<option value="custom">Custom</option>
</select>
<input
type="text"
placeholder="Reason (optional)"
value={bulkForm.reason}
onChange={(e) => setBulkForm({ ...bulkForm, reason: e.target.value })}
className="text-sm border border-warm-300 rounded-lg px-3 py-2 flex-1 focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
<button onClick={handleBulkAdd} disabled={saving} className="btn-primary text-sm">
{saving ? 'Adding...' : `Add ${bulkForm.text.split('\n').filter(Boolean).length} Usernames`}
</button>
</div>
</div>
)}
{/* Table */}
<div className="card overflow-hidden">
<div className="px-4 py-3 border-b border-warm-200 flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">{total} reserved username{total !== 1 ? 's' : ''}</span>
</div>
{loading ? (
<div className="p-8 text-center text-gray-400 text-sm">Loading...</div>
) : items.length === 0 ? (
<div className="p-8 text-center text-gray-400 text-sm">No reserved usernames found</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-warm-200 bg-warm-100">
<th className="text-left px-4 py-2.5 font-medium text-gray-600">Username</th>
<th className="text-left px-4 py-2.5 font-medium text-gray-600">Category</th>
<th className="text-left px-4 py-2.5 font-medium text-gray-600">Reason</th>
<th className="text-left px-4 py-2.5 font-medium text-gray-600">Added</th>
<th className="text-right px-4 py-2.5 font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b border-warm-100 hover:bg-warm-50 transition-colors">
<td className="px-4 py-2.5 font-mono font-medium text-gray-900">@{item.username}</td>
<td className="px-4 py-2.5">
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
item.category === 'platform' ? 'bg-blue-100 text-blue-700' :
item.category === 'brand' ? 'bg-purple-100 text-purple-700' :
item.category === 'public_figure' ? 'bg-amber-100 text-amber-700' :
'bg-gray-100 text-gray-700'
}`}>
{item.category.replace('_', ' ')}
</span>
</td>
<td className="px-4 py-2.5 text-gray-500 max-w-xs truncate">{item.reason || '—'}</td>
<td className="px-4 py-2.5 text-gray-400">{new Date(item.created_at).toLocaleDateString()}</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => handleRemove(item.id, item.username)}
className="p-1.5 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="Remove"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Pagination */}
{total > limit && (
<div className="px-4 py-3 border-t border-warm-200 flex items-center justify-between">
<span className="text-xs text-gray-400">
Showing {offset + 1}{Math.min(offset + limit, total)} of {total}
</span>
<div className="flex gap-2">
<button
onClick={() => setOffset(Math.max(0, offset - limit))}
disabled={offset === 0}
className="text-xs px-3 py-1.5 border border-warm-300 rounded-lg disabled:opacity-40"
>
Previous
</button>
<button
onClick={() => setOffset(offset + limit)}
disabled={offset + limit >= total}
className="text-xs px-3 py-1.5 border border-warm-300 rounded-lg disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
);
}
// ─── Claim Requests Tab ───────────────────────────────────
function ClaimsTab() {
const [items, setItems] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState('pending');
const [reviewingId, setReviewingId] = useState<string | null>(null);
const [reviewNotes, setReviewNotes] = useState('');
const [saving, setSaving] = useState(false);
const load = () => {
setLoading(true);
api.listClaimRequests({ status })
.then((data) => {
setItems(data.claim_requests || []);
setTotal(data.total || 0);
})
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, [status]);
const handleReview = async (id: string, decision: 'approved' | 'denied') => {
setSaving(true);
try {
await api.reviewClaimRequest(id, decision, reviewNotes);
setReviewingId(null);
setReviewNotes('');
load();
} catch (e: any) {
alert(e.message);
} finally {
setSaving(false);
}
};
return (
<div>
{/* Status Filter */}
<div className="flex gap-2 mb-4">
{['pending', 'approved', 'denied'].map((s) => (
<button
key={s}
onClick={() => setStatus(s)}
className={`px-3 py-1.5 text-sm rounded-lg font-medium transition-colors ${
status === s
? s === 'pending' ? 'bg-yellow-100 text-yellow-800' :
s === 'approved' ? 'bg-green-100 text-green-800' :
'bg-red-100 text-red-800'
: 'bg-warm-200 text-gray-600 hover:bg-warm-300'
}`}
>
{s.charAt(0).toUpperCase() + s.slice(1)}
</button>
))}
</div>
{/* Cards */}
{loading ? (
<div className="card p-8 text-center text-gray-400 text-sm">Loading...</div>
) : items.length === 0 ? (
<div className="card p-8 text-center text-gray-400 text-sm">No {status} claim requests</div>
) : (
<div className="space-y-3">
{items.map((item) => (
<div key={item.id} className="card p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono font-bold text-gray-900">@{item.requested_username}</span>
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
item.status === 'pending' ? 'bg-yellow-100 text-yellow-700' :
item.status === 'approved' ? 'bg-green-100 text-green-700' :
'bg-red-100 text-red-700'
}`}>
{item.status}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-1 text-sm mt-2">
<div><span className="text-gray-400">Email:</span> <span className="text-gray-700">{item.requester_email}</span></div>
{item.requester_name && <div><span className="text-gray-400">Name:</span> <span className="text-gray-700">{item.requester_name}</span></div>}
{item.organization && <div><span className="text-gray-400">Organization:</span> <span className="text-gray-700">{item.organization}</span></div>}
{item.proof_url && <div><span className="text-gray-400">Proof:</span> <a href={item.proof_url} target="_blank" rel="noopener" className="text-brand-600 hover:underline">{item.proof_url}</a></div>}
<div><span className="text-gray-400">Submitted:</span> <span className="text-gray-700">{new Date(item.created_at).toLocaleString()}</span></div>
</div>
<div className="mt-2 p-2 bg-warm-100 rounded-lg text-sm text-gray-600">
<span className="font-medium text-gray-500">Justification:</span> {item.justification}
</div>
{item.review_notes && (
<div className="mt-2 p-2 bg-blue-50 rounded-lg text-sm text-blue-700">
<span className="font-medium">Review notes:</span> {item.review_notes}
</div>
)}
</div>
{/* Actions */}
{item.status === 'pending' && (
<div className="flex flex-col gap-2 flex-shrink-0">
{reviewingId === item.id ? (
<div className="w-64">
<textarea
rows={2}
placeholder="Review notes (optional)..."
value={reviewNotes}
onChange={(e) => setReviewNotes(e.target.value)}
className="w-full text-sm border border-warm-300 rounded-lg px-3 py-2 mb-2 focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
<div className="flex gap-2">
<button
onClick={() => handleReview(item.id, 'approved')}
disabled={saving}
className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-sm font-medium bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
>
<Check className="w-3.5 h-3.5" /> Approve
</button>
<button
onClick={() => handleReview(item.id, 'denied')}
disabled={saving}
className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-sm font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
>
<X className="w-3.5 h-3.5" /> Deny
</button>
</div>
<button
onClick={() => { setReviewingId(null); setReviewNotes(''); }}
className="w-full mt-1 text-xs text-gray-400 hover:text-gray-600"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setReviewingId(item.id)}
className="btn-primary text-sm"
>
Review
</button>
)}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -6,7 +6,7 @@ import { useAuth } from '@/lib/auth';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
LayoutDashboard, Users, FileText, Shield, Scale, Flag, LayoutDashboard, Users, FileText, Shield, Scale, Flag,
Settings, Activity, LogOut, ChevronLeft, ChevronRight, Sliders, FolderTree, HardDrive, Settings, Activity, LogOut, ChevronLeft, ChevronRight, Sliders, FolderTree, HardDrive, AtSign,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
@ -19,6 +19,7 @@ const navItems = [
{ href: '/reports', label: 'Reports', icon: Flag }, { href: '/reports', label: 'Reports', icon: Flag },
{ href: '/algorithm', label: 'Algorithm', icon: Sliders }, { href: '/algorithm', label: 'Algorithm', icon: Sliders },
{ href: '/categories', label: 'Categories', icon: FolderTree }, { href: '/categories', label: 'Categories', icon: FolderTree },
{ href: '/usernames', label: 'Usernames', icon: AtSign },
{ href: '/storage', label: 'Storage', icon: HardDrive }, { href: '/storage', label: 'Storage', icon: HardDrive },
{ href: '/system', label: 'System Health', icon: Activity }, { href: '/system', label: 'System Health', icon: Activity },
{ href: '/settings', label: 'Settings', icon: Settings }, { href: '/settings', label: 'Settings', icon: Settings },

View file

@ -282,6 +282,50 @@ class ApiClient {
body: JSON.stringify({ bucket, key }), body: JSON.stringify({ bucket, key }),
}); });
} }
// Reserved Usernames
async listReservedUsernames(params: { category?: string; search?: string; limit?: number; offset?: number } = {}) {
const qs = new URLSearchParams();
if (params.category) qs.set('category', params.category);
if (params.search) qs.set('search', params.search);
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
return this.request<any>(`/api/v1/admin/usernames/reserved?${qs}`);
}
async addReservedUsername(data: { username: string; category?: string; reason?: string }) {
return this.request<any>('/api/v1/admin/usernames/reserved', {
method: 'POST',
body: JSON.stringify(data),
});
}
async bulkAddReservedUsernames(data: { usernames: string[]; category?: string; reason?: string }) {
return this.request<any>('/api/v1/admin/usernames/reserved/bulk', {
method: 'POST',
body: JSON.stringify(data),
});
}
async removeReservedUsername(id: string) {
return this.request<any>(`/api/v1/admin/usernames/reserved/${id}`, { method: 'DELETE' });
}
// Username Claim Requests
async listClaimRequests(params: { status?: string; limit?: number; offset?: number } = {}) {
const qs = new URLSearchParams();
if (params.status) qs.set('status', params.status);
if (params.limit) qs.set('limit', String(params.limit));
if (params.offset) qs.set('offset', String(params.offset));
return this.request<any>(`/api/v1/admin/usernames/claims?${qs}`);
}
async reviewClaimRequest(id: string, decision: string, notes?: string) {
return this.request<any>(`/api/v1/admin/usernames/claims/${id}`, {
method: 'PATCH',
body: JSON.stringify({ decision, notes }),
});
}
} }
export const api = new ApiClient(); export const api = new ApiClient();

View file

@ -433,8 +433,21 @@ func main() {
admin.GET("/storage/objects", adminHandler.ListStorageObjects) admin.GET("/storage/objects", adminHandler.ListStorageObjects)
admin.GET("/storage/object", adminHandler.GetStorageObject) admin.GET("/storage/object", adminHandler.GetStorageObject)
admin.DELETE("/storage/object", adminHandler.DeleteStorageObject) admin.DELETE("/storage/object", adminHandler.DeleteStorageObject)
// Reserved Usernames
admin.GET("/usernames/reserved", adminHandler.ListReservedUsernames)
admin.POST("/usernames/reserved", adminHandler.AddReservedUsername)
admin.POST("/usernames/reserved/bulk", adminHandler.BulkAddReservedUsernames)
admin.DELETE("/usernames/reserved/:id", adminHandler.RemoveReservedUsername)
// Username Claim Requests
admin.GET("/usernames/claims", adminHandler.ListClaimRequests)
admin.PATCH("/usernames/claims/:id", adminHandler.ReviewClaimRequest)
} }
// Public claim request endpoint (no auth)
r.POST("/api/v1/username-claim", adminHandler.SubmitClaimRequest)
srv := &http.Server{ srv := &http.Server{
Addr: ":" + cfg.Port, Addr: ":" + cfg.Port,
Handler: r, Handler: r,

View file

@ -1982,3 +1982,325 @@ func (h *AdminHandler) DeleteStorageObject(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Object deleted"}) c.JSON(http.StatusOK, gin.H{"message": "Object deleted"})
} }
// ──────────────────────────────────────────────
// Reserved Usernames Management
// ──────────────────────────────────────────────
func (h *AdminHandler) ListReservedUsernames(c *gin.Context) {
ctx := c.Request.Context()
category := c.Query("category")
search := c.Query("search")
limit := 50
offset := 0
if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 {
limit = v
}
if v, err := strconv.Atoi(c.Query("offset")); err == nil && v >= 0 {
offset = v
}
query := `SELECT id, username, category, reason, created_at FROM reserved_usernames WHERE 1=1`
args := []interface{}{}
argIdx := 1
if category != "" {
query += fmt.Sprintf(` AND category = $%d`, argIdx)
args = append(args, category)
argIdx++
}
if search != "" {
query += fmt.Sprintf(` AND username ILIKE $%d`, argIdx)
args = append(args, "%"+search+"%")
argIdx++
}
// Count
countQuery := strings.Replace(query, "id, username, category, reason, created_at", "COUNT(*)", 1)
var total int
h.pool.QueryRow(ctx, countQuery, args...).Scan(&total)
query += fmt.Sprintf(` ORDER BY username ASC LIMIT $%d OFFSET $%d`, argIdx, argIdx+1)
args = append(args, limit, offset)
rows, err := h.pool.Query(ctx, query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list reserved usernames"})
return
}
defer rows.Close()
type ReservedUsername struct {
ID string `json:"id"`
Username string `json:"username"`
Category string `json:"category"`
Reason *string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
}
var items []ReservedUsername
for rows.Next() {
var item ReservedUsername
if err := rows.Scan(&item.ID, &item.Username, &item.Category, &item.Reason, &item.CreatedAt); err == nil {
items = append(items, item)
}
}
if items == nil {
items = []ReservedUsername{}
}
c.JSON(http.StatusOK, gin.H{"reserved_usernames": items, "total": total})
}
func (h *AdminHandler) AddReservedUsername(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
var req struct {
Username string `json:"username" binding:"required"`
Category string `json:"category"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Username = strings.ToLower(strings.TrimSpace(req.Username))
if req.Category == "" {
req.Category = "custom"
}
adminUUID, _ := uuid.Parse(adminID.(string))
_, err := h.pool.Exec(ctx, `
INSERT INTO reserved_usernames (username, category, reason, added_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (username) DO UPDATE SET category = $2, reason = $3
`, req.Username, req.Category, req.Reason, adminUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add reserved username"})
return
}
// Audit
h.pool.Exec(ctx, `
INSERT INTO audit_log (actor_id, action, target_type, details)
VALUES ($1, 'admin_reserve_username', 'username', $2)
`, adminUUID, fmt.Sprintf(`{"username":"%s","category":"%s"}`, req.Username, req.Category))
c.JSON(http.StatusOK, gin.H{"message": "Username reserved"})
}
func (h *AdminHandler) RemoveReservedUsername(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
id := c.Param("id")
var username string
h.pool.QueryRow(ctx, `SELECT username FROM reserved_usernames WHERE id = $1`, id).Scan(&username)
_, err := h.pool.Exec(ctx, `DELETE FROM reserved_usernames WHERE id = $1`, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove reserved username"})
return
}
adminUUID, _ := uuid.Parse(adminID.(string))
h.pool.Exec(ctx, `
INSERT INTO audit_log (actor_id, action, target_type, details)
VALUES ($1, 'admin_unreserve_username', 'username', $2)
`, adminUUID, fmt.Sprintf(`{"username":"%s"}`, username))
c.JSON(http.StatusOK, gin.H{"message": "Reserved username removed"})
}
func (h *AdminHandler) BulkAddReservedUsernames(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
var req struct {
Usernames []string `json:"usernames" binding:"required"`
Category string `json:"category"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Category == "" {
req.Category = "custom"
}
adminUUID, _ := uuid.Parse(adminID.(string))
added := 0
for _, u := range req.Usernames {
u = strings.ToLower(strings.TrimSpace(u))
if u == "" {
continue
}
_, err := h.pool.Exec(ctx, `
INSERT INTO reserved_usernames (username, category, reason, added_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (username) DO NOTHING
`, u, req.Category, req.Reason, adminUUID)
if err == nil {
added++
}
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Added %d reserved usernames", added), "added": added})
}
// ──────────────────────────────────────────────
// Username Claim Requests
// ──────────────────────────────────────────────
func (h *AdminHandler) ListClaimRequests(c *gin.Context) {
ctx := c.Request.Context()
status := c.DefaultQuery("status", "pending")
limit := 50
offset := 0
if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 {
limit = v
}
if v, err := strconv.Atoi(c.Query("offset")); err == nil && v >= 0 {
offset = v
}
var total int
h.pool.QueryRow(ctx, `SELECT COUNT(*) FROM username_claim_requests WHERE status = $1`, status).Scan(&total)
rows, err := h.pool.Query(ctx, `
SELECT id, requested_username, requester_email, requester_name, requester_user_id,
organization, justification, proof_url, status, review_notes, reviewed_at, created_at
FROM username_claim_requests
WHERE status = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`, status, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list claim requests"})
return
}
defer rows.Close()
type ClaimRequest struct {
ID string `json:"id"`
RequestedUsername string `json:"requested_username"`
RequesterEmail string `json:"requester_email"`
RequesterName *string `json:"requester_name"`
RequesterUserID *string `json:"requester_user_id"`
Organization *string `json:"organization"`
Justification string `json:"justification"`
ProofURL *string `json:"proof_url"`
Status string `json:"status"`
ReviewNotes *string `json:"review_notes"`
ReviewedAt *time.Time `json:"reviewed_at"`
CreatedAt time.Time `json:"created_at"`
}
var items []ClaimRequest
for rows.Next() {
var item ClaimRequest
if err := rows.Scan(
&item.ID, &item.RequestedUsername, &item.RequesterEmail, &item.RequesterName,
&item.RequesterUserID, &item.Organization, &item.Justification, &item.ProofURL,
&item.Status, &item.ReviewNotes, &item.ReviewedAt, &item.CreatedAt,
); err == nil {
items = append(items, item)
}
}
if items == nil {
items = []ClaimRequest{}
}
c.JSON(http.StatusOK, gin.H{"claim_requests": items, "total": total})
}
func (h *AdminHandler) ReviewClaimRequest(c *gin.Context) {
ctx := c.Request.Context()
adminID, _ := c.Get("user_id")
id := c.Param("id")
var req struct {
Decision string `json:"decision" binding:"required"` // approved, denied
Notes string `json:"notes"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Decision != "approved" && req.Decision != "denied" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Decision must be 'approved' or 'denied'"})
return
}
adminUUID, _ := uuid.Parse(adminID.(string))
_, err := h.pool.Exec(ctx, `
UPDATE username_claim_requests
SET status = $1, reviewer_id = $2, review_notes = $3, reviewed_at = NOW(), updated_at = NOW()
WHERE id = $4
`, req.Decision, adminUUID, req.Notes, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update claim request"})
return
}
// If approved, remove from reserved list so user can register it
if req.Decision == "approved" {
var username string
h.pool.QueryRow(ctx, `SELECT requested_username FROM username_claim_requests WHERE id = $1`, id).Scan(&username)
if username != "" {
h.pool.Exec(ctx, `DELETE FROM reserved_usernames WHERE username = $1`, strings.ToLower(username))
}
}
// Audit
h.pool.Exec(ctx, `
INSERT INTO audit_log (actor_id, action, target_type, target_id, details)
VALUES ($1, 'admin_review_claim', 'claim_request', $2, $3)
`, adminUUID, id, fmt.Sprintf(`{"decision":"%s"}`, req.Decision))
c.JSON(http.StatusOK, gin.H{"message": "Claim request " + req.Decision})
}
// ──────────────────────────────────────────────
// Public: Submit a claim request (no auth required)
// ──────────────────────────────────────────────
func (h *AdminHandler) SubmitClaimRequest(c *gin.Context) {
ctx := c.Request.Context()
var req struct {
RequestedUsername string `json:"requested_username" binding:"required"`
Email string `json:"email" binding:"required,email"`
Name string `json:"name"`
Organization string `json:"organization"`
Justification string `json:"justification" binding:"required"`
ProofURL string `json:"proof_url"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check for duplicate pending request
var existing int
h.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM username_claim_requests
WHERE requested_username = $1 AND requester_email = $2 AND status = 'pending'
`, strings.ToLower(req.RequestedUsername), strings.ToLower(req.Email)).Scan(&existing)
if existing > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "You already have a pending claim for this username"})
return
}
_, err := h.pool.Exec(ctx, `
INSERT INTO username_claim_requests (requested_username, requester_email, requester_name, organization, justification, proof_url)
VALUES ($1, $2, $3, $4, $5, $6)
`, strings.ToLower(req.RequestedUsername), strings.ToLower(req.Email), req.Name, req.Organization, req.Justification, req.ProofURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit claim request"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Your claim request has been submitted and will be reviewed by our team."})
}

View file

@ -85,7 +85,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
} }
// Validate handle against reserved names and inappropriate content // Validate handle against reserved names and inappropriate content
handleCheck := services.ValidateUsername(req.Handle) handleCheck := services.ValidateUsernameWithDB(c.Request.Context(), h.repo.Pool(), req.Handle)
if handleCheck.Violation != services.UsernameOK { if handleCheck.Violation != services.UsernameOK {
status := http.StatusBadRequest status := http.StatusBadRequest
if handleCheck.Violation == services.UsernameReserved { if handleCheck.Violation == services.UsernameReserved {

View file

@ -187,7 +187,7 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
// Validate handle if being changed // Validate handle if being changed
if req.Handle != nil { if req.Handle != nil {
handleCheck := services.ValidateUsername(*req.Handle) handleCheck := services.ValidateUsernameWithDB(c.Request.Context(), h.repo.Pool(), *req.Handle)
if handleCheck.Violation != services.UsernameOK { if handleCheck.Violation != services.UsernameOK {
status := http.StatusBadRequest status := http.StatusBadRequest
if handleCheck.Violation == services.UsernameReserved { if handleCheck.Violation == services.UsernameReserved {

View file

@ -23,6 +23,10 @@ func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
return &UserRepository{pool: pool} return &UserRepository{pool: pool}
} }
func (r *UserRepository) Pool() *pgxpool.Pool {
return r.pool
}
func (r *UserRepository) CreateProfile(ctx context.Context, profile *models.Profile) error { func (r *UserRepository) CreateProfile(ctx context.Context, profile *models.Profile) error {
query := ` query := `
INSERT INTO public.profiles (id, handle, display_name, bio, origin_country) INSERT INTO public.profiles (id, handle, display_name, bio, origin_country)

View file

@ -1,8 +1,11 @@
package services package services
import ( import (
"context"
"regexp" "regexp"
"strings" "strings"
"github.com/jackc/pgx/v5/pgxpool"
) )
type UsernameViolation int type UsernameViolation int
@ -19,6 +22,29 @@ type UsernameCheckResult struct {
Message string Message string
} }
// ValidateUsernameWithDB checks a handle against reserved names (hardcoded + DB),
// inappropriate words, and format rules.
func ValidateUsernameWithDB(ctx context.Context, pool *pgxpool.Pool, handle string) UsernameCheckResult {
result := ValidateUsername(handle)
if result.Violation != UsernameOK {
return result
}
// Also check DB reserved_usernames table
if pool != nil {
var count int
err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM reserved_usernames WHERE username = $1`, strings.ToLower(strings.TrimSpace(handle))).Scan(&count)
if err == nil && count > 0 {
return UsernameCheckResult{
UsernameReserved,
"This username is reserved. If you officially represent this brand, company, or public figure, you can submit a verification request at support@sojorn.net to claim it.",
}
}
}
return UsernameCheckResult{UsernameOK, ""}
}
// ValidateUsername checks a handle against reserved names, inappropriate words, // ValidateUsername checks a handle against reserved names, inappropriate words,
// and format rules. Returns a result with a user-facing message. // and format rules. Returns a result with a user-facing message.
func ValidateUsername(handle string) UsernameCheckResult { func ValidateUsername(handle string) UsernameCheckResult {