Admin console: reserved usernames management + claim request review system
This commit is contained in:
parent
6a1f20759b
commit
2fb413c8d2
458
admin/src/app/usernames/page.tsx
Normal file
458
admin/src/app/usernames/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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."})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue