Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Reserved for app-specific public env vars.
# Anything that ships to the client must be prefixed NEXT_PUBLIC_.
# The miniapp SDK itself needs no configuration — the host injects the wallet at runtime.
# Set to 1 to force guest mode and disable wallet bridge auto-connect (useful for local debugging).
NEXT_PUBLIC_DISABLE_MINIAPP_BRIDGE=0

# Cal.com OAuth client ID (create at app.cal.com → Settings → Developer → OAuth Apps)
# Must be a PUBLIC client with PKCE enabled. Register /cal/callback as a redirect URI.
Expand Down
94 changes: 94 additions & 0 deletions app/api/admin/invitation-links/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { NextResponse } from 'next/server';
import {
addInvitationLinks,
listInvitationLinks,
updateInvitationLinkStatus,
} from '@/lib/db';
import { isAdminRequest, walletFromRequest } from '@/lib/api-auth';
import {
isValidInvitationStatus,
isValidInvitationUrl,
normalizeInvitationUrls,
} from '@/lib/invitation-links';

type AddLinksBody = {
links?: string[];
links_text?: string;
};

type UpdateLinkBody = {
id?: number;
status?: string;
};

export function GET(request: Request) {
if (!isAdminRequest(request)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return NextResponse.json({ links: listInvitationLinks() });
}

export async function POST(request: Request) {
if (!isAdminRequest(request)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}

const caller = walletFromRequest(request)?.toLowerCase();
if (!caller) {
return NextResponse.json({ error: 'Missing admin wallet address' }, { status: 400 });
}

let body: AddLinksBody;
try {
body = (await request.json()) as AddLinksBody;
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}

const fromArray = Array.isArray(body.links) ? body.links : [];
const fromText = typeof body.links_text === 'string' ? normalizeInvitationUrls(body.links_text) : [];
const urls = [...new Set([...fromArray, ...fromText].map((value) => value.trim()).filter(Boolean))];

if (urls.length === 0) {
return NextResponse.json({ error: 'At least one invitation URL is required' }, { status: 400 });
}
if (!urls.every(isValidInvitationUrl)) {
return NextResponse.json({ error: 'All invitation URLs must be valid http(s) links' }, { status: 400 });
}

const result = addInvitationLinks(urls, caller);
return NextResponse.json(
{
ok: true,
...result,
total: urls.length,
},
{ status: 201 },
);
}

export async function PATCH(request: Request) {
if (!isAdminRequest(request)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}

let body: UpdateLinkBody;
try {
body = (await request.json()) as UpdateLinkBody;
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}

if (typeof body.id !== 'number' || !Number.isInteger(body.id) || body.id <= 0) {
return NextResponse.json({ error: 'id must be a positive integer' }, { status: 400 });
}
if (typeof body.status !== 'string' || !isValidInvitationStatus(body.status)) {
return NextResponse.json({ error: 'status must be available, used, or invalid' }, { status: 400 });
}

const link = updateInvitationLinkStatus(body.id, body.status);
if (!link) {
return NextResponse.json({ error: 'Invitation link not found' }, { status: 404 });
}
return NextResponse.json({ ok: true, link });
}
38 changes: 38 additions & 0 deletions app/api/invitation-links/claim/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import { claimNextInvitationLink } from '@/lib/db';
import { getDefaultOnboardingFallbackUrl } from '@/lib/invitation-links';

type ClaimBody = {
wallet_address?: string;
};

export async function POST(request: Request) {
let body: ClaimBody = {};
try {
body = (await request.json()) as ClaimBody;
} catch {
// Accept empty body.
}

const consumer =
typeof body.wallet_address === 'string' && body.wallet_address.trim()
? body.wallet_address.trim().toLowerCase()
: 'anonymous';

const claimed = claimNextInvitationLink(consumer);
if (claimed) {
return NextResponse.json({
ok: true,
source: 'pool',
invitation_url: claimed.url,
link_id: claimed.id,
});
}

return NextResponse.json({
ok: true,
source: 'fallback',
invitation_url: getDefaultOnboardingFallbackUrl(),
reason: 'empty_pool',
});
}
145 changes: 143 additions & 2 deletions components/admin/AdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { ExpertEditForm } from '@/components/experts/ExpertEditForm';
import { ExpertLanguageTags, ExpertSkillTags } from '@/components/ui-patterns/ExpertMeta';
import { getDisplayCallLanguages } from '@/lib/languages';
import type { GroupMemberDto } from '@/lib/admin';
import type { AdminHealthStats, ExpertRow, TagRow, AdminRow } from '@/lib/db';
import type { AdminHealthStats, ExpertRow, TagRow, AdminRow, InvitationLinkRow } from '@/lib/db';

export function AdminPanel() {
const { address, isConnected } = useWallet();
Expand All @@ -25,6 +25,7 @@ export function AdminPanel() {
const [tags, setTags] = useState<TagRow[]>([]);
const [dbAdmins, setDbAdmins] = useState<AdminRow[]>([]);
const [health, setHealth] = useState<AdminHealthStats | null>(null);
const [invitationLinks, setInvitationLinks] = useState<InvitationLinkRow[]>([]);
const [groupMembers, setGroupMembers] = useState<GroupMemberDto[]>([]);
const [membersError, setMembersError] = useState<string | null>(null);
type AdminProfile = { name: string; imageUrl?: string; trustedByCount: number; score: number | null };
Expand All @@ -33,6 +34,9 @@ export function AdminPanel() {
const [editingTagId, setEditingTagId] = useState<number | null>(null);
const [editingTagLabel, setEditingTagLabel] = useState('');
const [newTag, setNewTag] = useState('');
const [newInvitationLinks, setNewInvitationLinks] = useState('');
const [invitationBusy, setInvitationBusy] = useState(false);
const [invitationError, setInvitationError] = useState<string | null>(null);
const [exitingTagIds, setExitingTagIds] = useState<Set<number>>(() => new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -62,11 +66,12 @@ export function AdminPanel() {

if (!admin) return;

const [mRes, tRes, aRes, hRes, memRes] = await Promise.all([
const [mRes, tRes, aRes, hRes, lRes, memRes] = await Promise.all([
fetch('/api/experts?all=1', { headers: hdrs }),
fetch('/api/tags', { headers: hdrs }),
fetch('/api/admin/admins', { headers: hdrs }),
fetch('/api/admin/health', { headers: hdrs }),
fetch('/api/admin/invitation-links', { headers: hdrs }),
ga
? fetch(`/api/admin/members?group=${encodeURIComponent(ga)}`, { headers: hdrs })
: Promise.resolve(null),
Expand All @@ -76,6 +81,12 @@ export function AdminPanel() {
setExperts(Array.isArray(expertsJson) ? expertsJson : []);
setTags(await tRes.json());
setDbAdmins(await aRes.json());
if (lRes.ok) {
const linksJson = (await lRes.json()) as { links?: InvitationLinkRow[] };
setInvitationLinks(Array.isArray(linksJson.links) ? linksJson.links : []);
} else {
setInvitationLinks([]);
}
if (hRes.ok) {
setHealth((await hRes.json()) as AdminHealthStats);
} else {
Expand All @@ -100,6 +111,7 @@ export function AdminPanel() {
}
}, [address, headers]);

// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { load(); }, [load]);

useEffect(() => {
Expand Down Expand Up @@ -180,6 +192,52 @@ export function AdminPanel() {
load();
}

async function addInvitationLinksBatch() {
if (!newInvitationLinks.trim()) return;
setInvitationBusy(true);
setInvitationError(null);
try {
const res = await fetch('/api/admin/invitation-links', {
method: 'POST',
headers: headers(),
body: JSON.stringify({ links_text: newInvitationLinks }),
});
const payload = (await res.json()) as { error?: string };
if (!res.ok) {
setInvitationError(payload.error ?? 'Failed to add invitation links');
return;
}
setNewInvitationLinks('');
await load();
} catch {
setInvitationError('Failed to add invitation links');
} finally {
setInvitationBusy(false);
}
}

async function updateInvitationStatus(id: number, status: 'available' | 'invalid' | 'used') {
setInvitationBusy(true);
setInvitationError(null);
try {
const res = await fetch('/api/admin/invitation-links', {
method: 'PATCH',
headers: headers(),
body: JSON.stringify({ id, status }),
});
const payload = (await res.json()) as { error?: string };
if (!res.ok) {
setInvitationError(payload.error ?? 'Failed to update invitation link');
return;
}
await load();
} catch {
setInvitationError('Failed to update invitation link');
} finally {
setInvitationBusy(false);
}
}

if (!isConnected) {
return <p className="text-sm text-muted-foreground">Connect your wallet to access the admin panel.</p>;
}
Expand Down Expand Up @@ -330,6 +388,89 @@ export function AdminPanel() {
</div>
</section>

{/* Invitation links */}
<section className="flex flex-col gap-4">
<h2 className="text-base font-semibold">Invitation links pool</h2>
<p className="text-sm text-muted-foreground">
Add one invitation URL per line. Links are claimed in FIFO order.
</p>
<div className="flex flex-col gap-2">
<textarea
value={newInvitationLinks}
onChange={(e) => setNewInvitationLinks(e.target.value)}
placeholder="https://example.com/invite/abc123"
className="min-h-28 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none ring-0 transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={invitationBusy || !newInvitationLinks.trim()}
onClick={addInvitationLinksBatch}
>
Add links
</Button>
{invitationBusy ? <span className="text-xs text-muted-foreground">Saving…</span> : null}
</div>
{invitationError ? <p className="text-xs text-destructive">{invitationError}</p> : null}
</div>

<div className="flex flex-col gap-3">
{invitationLinks.length === 0 ? (
<p className="text-sm text-muted-foreground">No invitation links yet.</p>
) : (
invitationLinks.map((link) => (
<div
key={link.id}
className="flex flex-col gap-3 rounded-xl border border-border p-4 md:flex-row md:items-center md:justify-between"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{link.url}</p>
<p className="text-xs text-muted-foreground">
added by {link.added_by} · created {link.created_at}
{link.consumed_at ? ` · consumed ${link.consumed_at}` : ''}
</p>
</div>
<div className="flex items-center gap-2">
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs font-medium',
link.status === 'available' && 'bg-success/15 text-success',
link.status === 'used' && 'bg-muted text-muted-foreground',
link.status === 'invalid' && 'bg-destructive/15 text-destructive',
)}
>
{link.status}
</span>
{link.status !== 'invalid' ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={invitationBusy}
onClick={() => updateInvitationStatus(link.id, 'invalid')}
>
Mark invalid
</Button>
) : (
<Button
type="button"
variant="outline"
size="sm"
disabled={invitationBusy}
onClick={() => updateInvitationStatus(link.id, 'available')}
>
Reactivate
</Button>
)}
</div>
</div>
))
)}
</div>
</section>

{/* Experts */}
<section className="flex flex-col gap-4">
<h2 className="text-base font-semibold">Experts ({experts.length})</h2>
Expand Down
Loading