diff --git a/src/app/admin/list/page.tsx b/src/app/admin/list/page.tsx new file mode 100644 index 0000000..240c381 --- /dev/null +++ b/src/app/admin/list/page.tsx @@ -0,0 +1,372 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import { isAddress } from "viem"; +import { LoaderCircle, UserRound, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { SectionCard } from "@/components/admin/section-card"; +import { AdminField, adminInputClass, adminButtonClass } from "@/components/admin/admin-field"; +import { useAdmin } from "@/components/admin/use-admin"; + +type AdminRow = { + wallet_address: string; + member_name: string | null; + added_by_name: string | null; + added_at: string; + revoked_at: string | null; + revoked_by_name: string | null; +}; + +type MemberResult = { + id: string; + display_name: string; + unique_name: string | null; + profile_picture_url: string | null; + wallet_address: string; +}; + +export default function AdminListing() { + const { adminFetch } = useAdmin(); + const [fetchError, setFetchError] = useState(null); + const [admins, setAdmins] = useState(null); + const [saving, setSaving] = useState(false); + + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const searchRequestId = useRef(0); + + const loadAdmins = async () => { + const res = await adminFetch("/api/admin/admin-users"); + + if (!res.ok) { + setFetchError("Could not fetch the data"); + setAdmins(null); + return; + } + + const { admins } = await res.json(); + setAdmins(admins); + setFetchError(null); + }; + + useEffect(() => { + loadAdmins(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // A typed-out wallet address is added as-is; anything else is treated as a + // name to search for, so there is nothing to look up once one is picked. + useEffect(() => { + const clean = query.trim().replace(/\s+/g, " "); + const currentRequest = ++searchRequestId.current; + + if (selected || clean.length < 2 || isAddress(clean, { strict: false })) { + setResults([]); + setSearching(false); + return; + } + + setSearching(true); + + const timeout = window.setTimeout(async () => { + try { + const res = await adminFetch( + `/api/admin/member-search?q=${encodeURIComponent(clean)}`, + ); + const { members } = await res.json(); + if (searchRequestId.current === currentRequest) { + setResults(members ?? []); + } + } catch { + if (searchRequestId.current === currentRequest) setResults([]); + } finally { + if (searchRequestId.current === currentRequest) setSearching(false); + } + }, 250); + + return () => window.clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query, selected]); + + const clearSelection = () => { + setSelected(null); + setQuery(""); + setResults([]); + }; + + const handleAdd = async () => { + const walletAddress = selected?.wallet_address ?? query.trim(); + if (!walletAddress) return; + + setSaving(true); + try { + const res = await adminFetch("/api/admin/admin-users", { + method: "POST", + body: JSON.stringify({ walletAddress }), + }); + if (!res.ok) { + const { error } = await res.json().catch(() => ({})); + throw new Error(error || "Failed to add admin"); + } + clearSelection(); + await loadAdmins(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + alert(message); + } finally { + setSaving(false); + } + }; + + const handleRevoke = async (walletAddress: string) => { + if (!confirm(`Revoke admin access for ${walletAddress}?`)) return; + + try { + const res = await adminFetch("/api/admin/admin-users", { + method: "PUT", + body: JSON.stringify({ walletAddress }), + }); + if (!res.ok) { + const { error } = await res.json().catch(() => ({})); + throw new Error(error || "Failed to revoke admin"); + } + await loadAdmins(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + alert(message); + } + }; + + const activeAdmins = admins?.filter((a) => !a.revoked_at) ?? []; + const revokedAdmins = + admins + ?.filter((a) => a.revoked_at) + .sort( + (a, b) => + new Date(b.revoked_at!).getTime() - new Date(a.revoked_at!).getTime(), + ) ?? []; + const addedHistory = + admins + ?.slice() + .sort( + (a, b) => + new Date(b.added_at).getTime() - new Date(a.added_at).getTime(), + ) ?? []; + + return ( +
+ +
+
+ + {selected ? ( +
+ + {selected.profile_picture_url ? ( + + ) : ( + + + {selected.display_name} + + +
+ ) : ( +
+ setQuery(e.target.value)} + /> + {searching && ( +
+ )} +
+ + {!selected && results.length > 0 && ( +
    + {results.map((member) => ( +
  • + +
  • + ))} +
+ )} + + {!selected && + !searching && + results.length === 0 && + query.trim().length >= 2 && + !isAddress(query.trim(), { strict: false }) && ( +

+ No members found +

+ )} +
+ + +
+
+ + +
+ {activeAdmins.map((admin) => ( +
+
+

+ {admin.member_name ?? admin.wallet_address} +

+

+ {admin.wallet_address} +

+
+ +
+ ))} + + {admins && activeAdmins.length === 0 && ( +

+ No Admin Users Found +

+ )} + + {fetchError && ( +
+

+ Could not load admin users +

+

+ {fetchError} +

+
+ )} +
+
+ + +
+
+

+ Added +

+
+ {addedHistory.map((admin) => ( +
+

+ {admin.member_name ?? admin.wallet_address} +

+

+ {admin.added_by_name + ? `Added by ${admin.added_by_name} · ` + : ""} + {new Date(admin.added_at).toLocaleString()} +

+
+ ))} + + {addedHistory.length === 0 && ( +

+ No History +

+ )} +
+
+ +
+

+ Revoked +

+
+ {revokedAdmins.map((admin) => ( +
+

+ {admin.member_name ?? admin.wallet_address} +

+

+ {admin.revoked_by_name + ? `Revoked by ${admin.revoked_by_name} · ` + : ""} + {new Date(admin.revoked_at!).toLocaleString()} +

+
+ ))} + + {revokedAdmins.length === 0 && ( +

+ No History +

+ )} +
+
+
+
+
+ ); +} diff --git a/src/app/api/admin/admin-users/route.ts b/src/app/api/admin/admin-users/route.ts new file mode 100644 index 0000000..807657c --- /dev/null +++ b/src/app/api/admin/admin-users/route.ts @@ -0,0 +1,258 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isAddress, verifyMessage } from "viem"; +import { isAdminAddress } from "@/lib/admin-auth-server"; +import { getSupabaseAdmin } from "@/services/supabase-admin"; + +// added_by / revoked_by store a public_profiles.id, not an admins.id — +// resolve the acting admin's wallet to that profile id via +// registrations.wallet_id -> registrations.id -> public_profiles.registration_id. +async function resolveProfileId( + supabase: ReturnType, + walletAddress: string, +) { + const { data: registration } = await supabase + .from("registrations") + .select("id") + .ilike("wallet_id", walletAddress) + .maybeSingle(); + + if (!registration) return null; + + const { data: profile } = await supabase + .from("public_profiles") + .select("id") + .eq("registration_id", registration.id) + .maybeSingle(); + + return profile?.id ?? null; +} + +async function verifyAdminAuth(req: NextRequest) { + const address = req.headers.get("x-admin-address")?.toLowerCase(); + const signature = req.headers.get("x-admin-signature"); + const timestamp = req.headers.get("x-admin-timestamp"); + + if (!address || !signature || !timestamp) return false; + if (!(await isAdminAddress(address))) return false; + + // Prevent replay attacks (valid for 5 mins) + const now = Date.now(); + if (now - parseInt(timestamp) > 5 * 60 * 1000) return false; + + try { + const valid = await verifyMessage({ + address: address as `0x${string}`, + message: `Admin Auth ${timestamp}`, + signature: signature as `0x${string}`, + }); + return valid; + } catch { + return false; + } +} + +// GET all admin rows (active and revoked) — the page derives the active +// listing and the added/revoked history from the same set. +export async function GET(req: NextRequest) { + const isAuth = await verifyAdminAuth(req); + if (!isAuth) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const supabase = getSupabaseAdmin(); + + const { data, error } = await supabase + .from("admins") + .select() + .order("added_at", { ascending: false }); + + if (error) { + return NextResponse.json( + { error: "Failed to fetch admin users" }, + { status: 500 }, + ); + } + + // member_name and added_by_name are plain text columns that nothing + // populates automatically — the oldest rows had them filled in by hand. + // Resolve the current names instead, falling back to the stored value for + // rows whose wallet no longer matches a registration. + const wallets = Array.from( + new Set(data.map((a) => a.wallet_address).filter(Boolean)), + ); + const profileIds = Array.from( + new Set( + [...data.map((a) => a.added_by), ...data.map((a) => a.revoked_by)].filter( + Boolean, + ), + ), + ); + + const { data: registrations } = wallets.length + ? await supabase + .from("registrations") + .select("id, wallet_id") + .in("wallet_id", wallets) + : { data: [] }; + + const registrationIds = (registrations ?? []).map((r) => r.id); + + const { data: profilesByRegistration } = registrationIds.length + ? await supabase + .from("public_profiles") + .select("registration_id, display_name") + .in("registration_id", registrationIds) + : { data: [] }; + + const { data: profilesById } = profileIds.length + ? await supabase + .from("public_profiles") + .select("id, display_name") + .in("id", profileIds) + : { data: [] }; + + const registrationIdByWallet = new Map( + (registrations ?? []).map((r) => [r.wallet_id, r.id]), + ); + const nameByRegistrationId = new Map( + (profilesByRegistration ?? []).map((p) => [p.registration_id, p.display_name]), + ); + const nameByProfileId = new Map( + (profilesById ?? []).map((p) => [p.id, p.display_name]), + ); + + const nameForWallet = (wallet: string) => { + const registrationId = registrationIdByWallet.get(wallet); + return registrationId + ? (nameByRegistrationId.get(registrationId) ?? null) + : null; + }; + + const admins = data.map((admin) => ({ + ...admin, + member_name: nameForWallet(admin.wallet_address) ?? admin.member_name, + added_by_name: + (admin.added_by ? nameByProfileId.get(admin.added_by) : null) ?? + admin.added_by_name, + revoked_by_name: admin.revoked_by + ? (nameByProfileId.get(admin.revoked_by) ?? null) + : null, + })); + + return NextResponse.json({ admins }); +} + +// Add a new admin by wallet address. +export async function POST(req: NextRequest) { + const isAuth = await verifyAdminAuth(req); + if (!isAuth) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { walletAddress } = await req.json(); + if (!isAddress(walletAddress ?? "", { strict: false })) { + return NextResponse.json( + { error: "Enter a valid wallet address (0x followed by 40 hex characters)" }, + { status: 400 }, + ); + } + + const wallet = walletAddress.toLowerCase(); + const addedByAddress = req.headers.get("x-admin-address")?.toLowerCase(); + const supabase = getSupabaseAdmin(); + + const adderProfileId = addedByAddress + ? await resolveProfileId(supabase, addedByAddress) + : null; + + // Reuse an existing row for this wallet instead of inserting a duplicate — + // otherwise revoking someone by accident and re-adding them leaves two + // rows for the same wallet (one revoked, one active). + const { data: existing } = await supabase + .from("admins") + .select("id, revoked_at") + .eq("wallet_address", wallet) + .order("added_at", { ascending: false }) + .limit(1) + .maybeSingle(); + + if (existing && !existing.revoked_at) { + return NextResponse.json( + { error: "This wallet is already an admin" }, + { status: 409 }, + ); + } + + const { error } = existing + ? await supabase + .from("admins") + .update({ + revoked_at: null, + revoked_by: null, + added_at: new Date().toISOString(), + added_by: adderProfileId, + }) + .eq("id", existing.id) + : await supabase.from("admins").insert({ + wallet_address: wallet, + added_by: adderProfileId, + }); + + if (error) { + return NextResponse.json( + { error: "Failed to add admin" }, + { status: 500 }, + ); + } + + return NextResponse.json({ ok: true }); +} + +// Revoke an existing admin by wallet address. +export async function PUT(req: NextRequest) { + const isAuth = await verifyAdminAuth(req); + if (!isAuth) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { walletAddress } = await req.json(); + if (typeof walletAddress !== "string" || !walletAddress) { + return NextResponse.json( + { error: "Wallet address is required" }, + { status: 400 }, + ); + } + + const revokedByAddress = req.headers.get("x-admin-address")?.toLowerCase(); + const supabase = getSupabaseAdmin(); + + const revokerProfileId = revokedByAddress + ? await resolveProfileId(supabase, revokedByAddress) + : null; + + // Matched verbatim, not lowercased or format-checked: rows added before + // the address validation existed can hold arbitrary text, and they still + // need to be revocable. .eq is exact equality, so there is no wildcard risk. + const { data, error } = await supabase + .from("admins") + .update({ + revoked_at: new Date().toISOString(), + revoked_by: revokerProfileId, + }) + .eq("wallet_address", walletAddress) + .is("revoked_at", null) + .select("id"); + + if (error) { + return NextResponse.json( + { error: "Failed to revoke admin" }, + { status: 500 }, + ); + } + + if (!data?.length) { + return NextResponse.json( + { error: "No active admin found for that wallet address" }, + { status: 404 }, + ); + } + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/admin/approve/route.ts b/src/app/api/admin/approve/route.ts index 0ff3d9b..3889bd1 100644 --- a/src/app/api/admin/approve/route.ts +++ b/src/app/api/admin/approve/route.ts @@ -2,14 +2,14 @@ import { NextRequest, NextResponse } from "next/server"; import { getPrisma } from "@/lib/prisma"; import { setName } from "@/lib/namestone"; import { verifyMessage } from "viem"; -import { isAllowedAdminAddress } from "@/lib/admin-auth"; +import { isAdminAddress } from "@/lib/admin-auth-server"; async function verifyAdminAuth(req: NextRequest) { const address = req.headers.get("x-admin-address")?.toLowerCase(); const signature = req.headers.get("x-admin-signature"); const timestamp = req.headers.get("x-admin-timestamp"); - if (!address || !signature || !timestamp || !isAllowedAdminAddress(address)) + if (!address || !signature || !timestamp || !(await isAdminAddress(address))) return false; if (Date.now() - parseInt(timestamp) > 5 * 60 * 1000) return false; diff --git a/src/app/api/admin/checkin/verify/route.ts b/src/app/api/admin/checkin/verify/route.ts index 8a46323..efa9a8e 100644 --- a/src/app/api/admin/checkin/verify/route.ts +++ b/src/app/api/admin/checkin/verify/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { verifyMessage } from "viem"; -import { isAllowedAdminAddress } from "@/lib/admin-auth"; +import { isAdminAddress } from "@/lib/admin-auth-server"; import { getSupabaseAdmin } from "@/services/supabase-admin"; async function verifyAdminAuth(req: NextRequest) { @@ -8,7 +8,7 @@ async function verifyAdminAuth(req: NextRequest) { const signature = req.headers.get("x-admin-signature"); const timestamp = req.headers.get("x-admin-timestamp"); - if (!address || !signature || !timestamp || !isAllowedAdminAddress(address)) + if (!address || !signature || !timestamp || !(await isAdminAddress(address))) return false; // Longer window than other admin routes (5 min) since this gets reused // across a whole door-scanning session instead of a single one-off action. diff --git a/src/app/api/admin/claims/route.ts b/src/app/api/admin/claims/route.ts index 2fcc87e..d82102c 100644 --- a/src/app/api/admin/claims/route.ts +++ b/src/app/api/admin/claims/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getPrisma } from "@/lib/prisma"; import { verifyMessage } from "viem"; -import { isAllowedAdminAddress } from "@/lib/admin-auth"; +import { isAdminAddress } from "@/lib/admin-auth-server"; async function verifyAdminAuth(req: NextRequest) { const address = req.headers.get("x-admin-address")?.toLowerCase(); @@ -9,7 +9,7 @@ async function verifyAdminAuth(req: NextRequest) { const timestamp = req.headers.get("x-admin-timestamp"); if (!address || !signature || !timestamp) return false; - if (!isAllowedAdminAddress(address)) return false; + if (!(await isAdminAddress(address))) return false; // Prevent replay attacks (valid for 5 mins) const now = Date.now(); diff --git a/src/app/api/admin/events/image/route.ts b/src/app/api/admin/events/image/route.ts index 469d8b1..bdffcf3 100644 --- a/src/app/api/admin/events/image/route.ts +++ b/src/app/api/admin/events/image/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { EventService } from "@/services/event-service"; -import { isAllowedAdminAddress } from "@/lib/admin-auth"; +import { isAdminAddress } from "@/lib/admin-auth-server"; import { verifyMessage } from "viem"; async function verifyAdminAuth(req: NextRequest) { @@ -8,7 +8,7 @@ async function verifyAdminAuth(req: NextRequest) { const signature = req.headers.get("x-admin-signature"); const timestamp = req.headers.get("x-admin-timestamp"); - if (!address || !signature || !timestamp || !isAllowedAdminAddress(address)) + if (!address || !signature || !timestamp || !(await isAdminAddress(address))) return false; const now = Date.now(); diff --git a/src/app/api/admin/events/route.ts b/src/app/api/admin/events/route.ts index f0607bb..55429de 100644 --- a/src/app/api/admin/events/route.ts +++ b/src/app/api/admin/events/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getSupabase } from "@/services/supabase"; import { RegistrationService } from "@/services/registrations/registrations-service"; import { verifyMessage } from "viem"; -import { isAllowedAdminAddress } from "@/lib/admin-auth"; +import { isAdminAddress } from "@/lib/admin-auth-server"; import { getSupabaseAdmin } from "@/services/supabase-admin"; async function verifyAdminAuth(req: NextRequest) { @@ -11,7 +11,7 @@ async function verifyAdminAuth(req: NextRequest) { const timestamp = req.headers.get("x-admin-timestamp"); if (!address || !signature || !timestamp) return false; - if (!isAllowedAdminAddress(address)) return false; + if (!(await isAdminAddress(address))) return false; if (Date.now() - parseInt(timestamp) > 5 * 60 * 1000) return false; // Prevent replay attacks (valid for 5 mins) diff --git a/src/app/api/admin/is-admin/route.ts b/src/app/api/admin/is-admin/route.ts new file mode 100644 index 0000000..fe9dacb --- /dev/null +++ b/src/app/api/admin/is-admin/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isAdminAddress } from "@/lib/admin-auth-server"; + +// Unauthenticated on purpose — this only decides whether to show the "Admin +// Panel" nav link. It grants no access; every actual admin action still +// requires the full signed-message check in its own route. +export async function GET(req: NextRequest) { + const address = req.nextUrl.searchParams.get("address"); + const isAdmin = await isAdminAddress(address); + return NextResponse.json({ isAdmin }); +} diff --git a/src/app/api/admin/member-search/route.ts b/src/app/api/admin/member-search/route.ts new file mode 100644 index 0000000..ba1e245 --- /dev/null +++ b/src/app/api/admin/member-search/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifyMessage } from "viem"; +import { isAdminAddress } from "@/lib/admin-auth-server"; +import { getSupabaseAdmin } from "@/services/supabase-admin"; + +async function verifyAdminAuth(req: NextRequest) { + const address = req.headers.get("x-admin-address")?.toLowerCase(); + const signature = req.headers.get("x-admin-signature"); + const timestamp = req.headers.get("x-admin-timestamp"); + + if (!address || !signature || !timestamp) return false; + if (!(await isAdminAddress(address))) return false; + + // Prevent replay attacks (valid for 5 mins) + if (Date.now() - parseInt(timestamp) > 5 * 60 * 1000) return false; + + try { + return await verifyMessage({ + address: address as `0x${string}`, + message: `Admin Auth ${timestamp}`, + signature: signature as `0x${string}`, + }); + } catch { + return false; + } +} + +// Search members by name so an admin can pick one instead of pasting a +// wallet address. Uses the same RPC as the public profile search, then +// resolves each match to its full wallet address — which the public search +// deliberately withholds (it returns only wallet_suffix). +export async function GET(req: NextRequest) { + const isAuth = await verifyAdminAuth(req); + if (!isAuth) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const query = req.nextUrl.searchParams.get("q")?.trim().replace(/\s+/g, " "); + if (!query || query.length < 2) { + return NextResponse.json({ members: [] }); + } + + const supabase = getSupabaseAdmin(); + + const { data: matches, error } = await supabase.rpc("search_public_profiles", { + search_query: query.slice(0, 64), + max_results: 8, + excluded_wallet: null, + }); + + if (error) { + return NextResponse.json({ error: "Search failed" }, { status: 500 }); + } + + const profileIds = (matches ?? []).map((m: { id: string }) => m.id); + if (!profileIds.length) return NextResponse.json({ members: [] }); + + const { data: profiles } = await supabase + .from("public_profiles") + .select("id, registration_id") + .in("id", profileIds); + + const registrationIds = (profiles ?? []).map((p) => p.registration_id); + + const { data: registrations } = await supabase + .from("registrations") + .select("id, wallet_id") + .in("id", registrationIds); + + const walletByRegistration = new Map( + (registrations ?? []).map((r) => [r.id, r.wallet_id]), + ); + const registrationByProfile = new Map( + (profiles ?? []).map((p) => [p.id, p.registration_id]), + ); + + const members = (matches ?? []) + .map( + (match: { + id: string; + display_name: string; + unique_name: string | null; + profile_picture_url: string | null; + }) => { + const registrationId = registrationByProfile.get(match.id); + const walletAddress = registrationId + ? walletByRegistration.get(registrationId) + : null; + + return { + id: match.id, + display_name: match.display_name, + unique_name: match.unique_name, + profile_picture_url: match.profile_picture_url, + wallet_address: walletAddress ?? null, + }; + }, + ) + // A member with no wallet on file cannot be made an admin. + .filter((member: { wallet_address: string | null }) => member.wallet_address); + + return NextResponse.json({ members }); +} diff --git a/src/app/api/admin/names/route.ts b/src/app/api/admin/names/route.ts index 9bb5390..d95538c 100644 --- a/src/app/api/admin/names/route.ts +++ b/src/app/api/admin/names/route.ts @@ -1,14 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { getNames } from "@/lib/namestone"; import { verifyMessage } from "viem"; -import { isAllowedAdminAddress } from "@/lib/admin-auth"; +import { isAdminAddress } from "@/lib/admin-auth-server"; async function verifyAdminAuth(req: NextRequest) { const address = req.headers.get("x-admin-address")?.toLowerCase(); const signature = req.headers.get("x-admin-signature"); const timestamp = req.headers.get("x-admin-timestamp"); - if (!address || !signature || !timestamp || !isAllowedAdminAddress(address)) + if (!address || !signature || !timestamp || !(await isAdminAddress(address))) return false; if (Date.now() - parseInt(timestamp) > 5 * 60 * 1000) return false; diff --git a/src/app/api/admin/revoke/route.ts b/src/app/api/admin/revoke/route.ts index eedf9fe..c4b0b58 100644 --- a/src/app/api/admin/revoke/route.ts +++ b/src/app/api/admin/revoke/route.ts @@ -2,14 +2,14 @@ import { NextRequest, NextResponse } from "next/server"; import { getPrisma } from "@/lib/prisma"; import { deleteName } from "@/lib/namestone"; import { verifyMessage } from "viem"; -import { isAllowedAdminAddress } from "@/lib/admin-auth"; +import { isAdminAddress } from "@/lib/admin-auth-server"; async function verifyAdminAuth(req: NextRequest) { const address = req.headers.get("x-admin-address")?.toLowerCase(); const signature = req.headers.get("x-admin-signature"); const timestamp = req.headers.get("x-admin-timestamp"); - if (!address || !signature || !timestamp || !isAllowedAdminAddress(address)) + if (!address || !signature || !timestamp || !(await isAdminAddress(address))) return false; if (Date.now() - parseInt(timestamp) > 5 * 60 * 1000) return false; diff --git a/src/app/api/admin/session/route.ts b/src/app/api/admin/session/route.ts index 9bfbfa7..a5d0952 100644 --- a/src/app/api/admin/session/route.ts +++ b/src/app/api/admin/session/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { verifyMessage } from "viem"; -import { isAllowedAdminAddress } from "@/lib/admin-auth"; +import { isAdminAddress } from "@/lib/admin-auth-server"; import { createAdminSessionToken } from "@/lib/admin-session-server"; export async function POST(req: NextRequest) { @@ -16,7 +16,7 @@ export async function POST(req: NextRequest) { ); } - if (!isAllowedAdminAddress(address)) { + if (!(await isAdminAddress(address))) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/components/admin/admin-nav.tsx b/src/components/admin/admin-nav.tsx index d713b4d..a659a2b 100644 --- a/src/components/admin/admin-nav.tsx +++ b/src/components/admin/admin-nav.tsx @@ -8,6 +8,7 @@ const tabs = [ { label: "Events", href: "/admin/events" }, { label: "Claim ID", href: "/admin/claims" }, { label: "Attendance", href: "/admin/attendance" }, + { label: "Admin Users", href: "/admin/list" }, ]; /** diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index b0584f3..3c7eb4b 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -32,6 +32,7 @@ export function Navbar() { const [mounted, setMounted] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const { address } = useAccount(); + const [isDbAdmin, setIsDbAdmin] = useState(false); const pathname = usePathname(); @@ -39,6 +40,25 @@ export function Navbar() { setMounted(true); }, []); + useEffect(() => { + if (!address) { + setIsDbAdmin(false); + return; + } + + let cancelled = false; + fetch(`/api/admin/is-admin?address=${encodeURIComponent(address)}`) + .then((res) => res.json()) + .then((data) => { + if (!cancelled) setIsDbAdmin(!!data.isAdmin); + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; + }, [address]); + useEffect(() => { const saved = localStorage.getItem("theme") as "light" | "dark" | null; const preferred = window.matchMedia("(prefers-color-scheme: dark)").matches @@ -69,7 +89,7 @@ export function Navbar() { return () => clearTimeout(timeout); }, [theme, themeReady]); - const isAdmin = mounted && isAllowedAdminAddress(address); + const isAdmin = mounted && (isAllowedAdminAddress(address) || isDbAdmin); const isActive = (href: string) => { if (href.startsWith("#")) return false; diff --git a/src/hooks/use-wallet.ts b/src/hooks/use-wallet.ts index 60b5624..1553928 100644 --- a/src/hooks/use-wallet.ts +++ b/src/hooks/use-wallet.ts @@ -11,11 +11,35 @@ export function useWallet() { const { mutate: disconnect } = useDisconnect(); const { open } = useAppKit(); + // Env allowlist resolves instantly; the admins table needs a round trip, + // so start from the env check and upgrade to true if the table says so. + const [isDbAdmin, setIsDbAdmin] = useState(false); + useEffect(() => { setMounted(true); }, []); - const isAdmin = mounted && isAllowedAdminAddress(address); + useEffect(() => { + if (!address) { + setIsDbAdmin(false); + return; + } + + let cancelled = false; + fetch(`/api/admin/is-admin?address=${encodeURIComponent(address)}`) + .then((res) => res.json()) + .then((data) => { + if (!cancelled) setIsDbAdmin(!!data.isAdmin); + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; + }, [address]); + + const isAdmin = + mounted && (isAllowedAdminAddress(address) || isDbAdmin); return { address, diff --git a/src/lib/admin-auth-server.ts b/src/lib/admin-auth-server.ts new file mode 100644 index 0000000..c81670d --- /dev/null +++ b/src/lib/admin-auth-server.ts @@ -0,0 +1,32 @@ +import "server-only"; +import { isAddress } from "viem"; +import { isAllowedAdminAddress } from "@/lib/admin-auth"; +import { getSupabaseAdmin } from "@/services/supabase-admin"; + +/** + * Admin access is granted by either source: the env allowlist (for + * permanent/founding admins) or an active row in the `admins` table + * (for admins added at runtime through the admin panel). + * + * Server-only: pulls in the service-role Supabase client, so this must + * never be imported from a client component — use isAllowedAdminAddress + * from "@/lib/admin-auth" there instead. + */ +export async function isAdminAddress( + address?: string | null, +): Promise { + // strict:false — a pure format check. Addresses are lowercased before + // comparison, so EIP-55 checksum validation would only reject valid + // all-uppercase input. + if (!isAddress(address ?? "", { strict: false })) return false; + if (isAllowedAdminAddress(address)) return true; + + const { data } = await getSupabaseAdmin() + .from("admins") + .select("id") + .eq("wallet_address", address!.toLowerCase()) + .is("revoked_at", null) + .maybeSingle(); + + return !!data; +}