Allow admin marking profile as verified

This commit is contained in:
Jared Schoeny
2025-12-26 13:28:50 -10:00
parent 1275d07c3e
commit 1db1367a84
7 changed files with 87 additions and 22 deletions

View File

@@ -6,6 +6,7 @@ import DashboardClient from "@/components/Dashboard/DashboardClient";
import ArchiverManagement from "@/components/Dashboard/ArchiverManagement";
import { getDownloadsSeriesAll } from "./actions";
import type { HackRow } from "@/components/Dashboard/DashboardClient";
import { FaCircleCheck } from "react-icons/fa6";
export default async function DashboardPage() {
const supa = await createClient();
@@ -18,7 +19,8 @@ export default async function DashboardPage() {
created_by: string;
creator_username: string | null;
creator_full_name: string | null;
creator_email: string | null
creator_email: string | null;
creator_verified: boolean;
})[] = [];
if (isAdmin) {
const { data: pendingHacksData } = await supa
@@ -32,14 +34,16 @@ export default async function DashboardPage() {
const creatorIds = [...new Set(pendingHacksData.map(h => h.created_by as string))];
const { data: profiles } = await supa
.from("profiles")
.select("id,username,full_name")
.select("id,username,full_name,verified")
.in("id", creatorIds);
const usernameById = new Map<string, string | null>();
const fullNameById = new Map<string, string | null>();
const verifiedById = new Map<string, boolean>();
(profiles || []).forEach((p) => {
usernameById.set(p.id, p.username);
fullNameById.set(p.id, p.full_name);
verifiedById.set(p.id, p.verified);
});
// Fetch creator emails using service client (admin API)
@@ -62,6 +66,7 @@ export default async function DashboardPage() {
creator_username: usernameById.get(h.created_by as string) || null,
creator_full_name: fullNameById.get(h.created_by as string) || null,
creator_email: emailById.get(h.created_by as string) || null,
creator_verified: verifiedById.get(h.created_by as string) || false,
}));
}
}
@@ -164,7 +169,17 @@ export default async function DashboardPage() {
</div>
<div className="col-span-4 flex flex-col min-w-0">
{h.creator_full_name && <div className="text-xs text-amber-900/70 dark:text-amber-200/70">{h.creator_full_name}</div>}
<div className="text-amber-900/90 dark:text-amber-200/90">{creator}</div>
<div className="text-amber-900/90 dark:text-amber-200/90">
{creator}
{h.creator_verified && (
<div className="group/verified relative inline-flex items-center group ml-1">
<FaCircleCheck className="text-amber-950/90 dark:text-amber-100/90" size={12} />
<div className="pointer-events-none absolute left-1/2 top-full z-10 mt-1 hidden -translate-x-1/2 whitespace-nowrap rounded bg-black px-2 py-1 text-xs text-white opacity-0 group-hover/verified:block group-hover/verified:opacity-100">
Creator is verified
</div>
</div>
)}
</div>
{h.creator_email && (
<div className="text-xs text-amber-900/60 dark:text-amber-200/60 truncate mt-0.5">{h.creator_email}</div>
)}

View File

@@ -34,6 +34,7 @@ export interface HackMetadata {
profile: {
username: string | null;
avatar_url: string | null;
verified: boolean;
} | null;
otherHacks: {
slug: string;
@@ -90,7 +91,7 @@ export async function getHackMetadata(slug: string): Promise<HackMetadata | null
// Fetch profile
const { data: profile } = await supabase
.from("profiles")
.select("username,avatar_url")
.select("username,avatar_url,verified")
.eq("id", hack.created_by as string)
.maybeSingle();
@@ -145,6 +146,7 @@ export async function getHackMetadata(slug: string): Promise<HackMetadata | null
profile: profile ? {
username: profile.username,
avatar_url: profile.avatar_url,
verified: profile.verified,
} : null,
otherHacks,
patch,

View File

@@ -25,12 +25,20 @@ export default async function ApprovePage({ params }: ApprovePageProps) {
// Fetch hack data
const { data: hack, error } = await supabase
.from("hacks")
.select("title, approved, approved_at, approved_by")
.select("title, approved, approved_at, approved_by, created_by")
.eq("slug", slug)
.maybeSingle();
if (error || !hack) return notFound();
const { data: creatorProfile, error: creatorProfileError } = await supabase
.from("profiles")
.select("verified, username")
.eq("id", hack.created_by as string)
.maybeSingle();
if (creatorProfileError || !creatorProfile) return notFound();
// If already approved, fetch approver's username
let approverUsername: string | null = null;
if (hack.approved && hack.approved_by) {
@@ -54,9 +62,10 @@ export default async function ApprovePage({ params }: ApprovePageProps) {
second: "2-digit"
}) : null;
async function handleApprove() {
async function handleApprove(formData: FormData) {
"use server";
await approveHack(slug);
const verified = creatorProfile?.verified ? undefined : formData.get("verified") === "on";
await approveHack(slug, verified);
}
return (
@@ -94,15 +103,28 @@ export default async function ApprovePage({ params }: ApprovePageProps) {
<p className="text-foreground/75 mb-6">
By approving this hack, it will become visible to the public.
</p>
<form action={handleApprove} className="flex gap-3 justify-center md:justify-start">
<Button type="submit" variant="primary">
Approve
</Button>
<Link href={`/hack/${slug}`}>
<Button type="button" variant="secondary">
Cancel
<form action={handleApprove} className="flex flex-col gap-3 justify-center md:justify-start">
{/* Checkbox to verify the hack creator */}
<div className="flex items-center gap-2">
{creatorProfile.verified ? (
<p className="text-foreground/75"><span className="font-semibold">@{creatorProfile.username}</span> is already verified.</p>
) : (
<>
<input type="checkbox" name="verified" id="verified" />
<label htmlFor="verified">I have verified that the account that submitted this hack is the original creator.</label>
</>
)}
</div>
<div className="flex items-center gap-2">
<Button type="submit" variant="primary">
Approve
</Button>
</Link>
<Link href={`/hack/${slug}`}>
<Button type="button" variant="secondary">
Cancel
</Button>
</Link>
</div>
</form>
</div>
)}

View File

@@ -298,7 +298,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
</div>
)}
</div>
<div className={`mt-1 flex items-center gap-2 ${!hack.original_author ? "h-[28px]" : ""}`}>
<div className={`mt-1 flex items-center ${!hack.original_author ? "h-[28px]" : ""}`}>
{!hack.original_author ? (
<>
<Avatar
@@ -306,7 +306,15 @@ export default async function HackDetail({ params }: HackDetailProps) {
url={profile?.avatar_url ?? null}
size={28}
/>
<p className="text-[16px] md:text-[18px] text-foreground/70">{author}</p>
<p className="text-[16px] md:text-[18px] text-foreground/70 ml-2">{author}</p>
{isAdmin && profile?.verified && (
<div className="relative flex items-center group">
<FaCircleCheck className="text-foreground/70 ml-1" size={16} />
<div className="pointer-events-none absolute left-1/2 top-full z-10 mt-1 hidden -translate-x-1/2 whitespace-nowrap rounded bg-black px-2 py-1 text-xs text-white opacity-0 group-hover:block group-hover:opacity-100">
Creator is verified
</div>
</div>
)}
</>
) : (
<p className="text-[16px] md:text-[18px] text-foreground/70">By {author}</p>

View File

@@ -1,6 +1,6 @@
"use server";
import { createClient } from "@/utils/supabase/server";
import { createClient, createServiceClient } from "@/utils/supabase/server";
import type { TablesInsert } from "@/types/db";
import { getMinioClient, PATCHES_BUCKET, COVERS_BUCKET } from "@/utils/minio/server";
import { revalidatePath, revalidateTag } from "next/cache";
@@ -273,7 +273,7 @@ export async function presignCoverUpload(args: { slug: string; objectKey: string
}
export async function approveHack(slug: string) {
export async function approveHack(slug: string, verified?: boolean) {
const supabase = await createClient();
const {
data: { user },
@@ -284,8 +284,10 @@ export async function approveHack(slug: string) {
const { data: isAdmin } = await supabase.rpc("is_admin");
if (!isAdmin) return { ok: false, error: "Forbidden" } as const;
const serviceClient = await createServiceClient();
// Check if hack exists
const { data: hack, error: hErr } = await supabase
const { data: hack, error: hErr } = await serviceClient
.from("hacks")
.select("slug, approved, title, created_by")
.eq("slug", slug)
@@ -293,6 +295,17 @@ export async function approveHack(slug: string) {
if (hErr) return { ok: false, error: hErr.message } as const;
if (!hack) return { ok: false, error: "Hack not found" } as const;
if (verified === true) {
const { error: updateErr } = await serviceClient
.from("profiles")
.update({ verified: true })
.eq("id", hack.created_by);
if (updateErr) {
// No need to return an error here
console.error(updateErr);
}
}
// If already approved, return success
if (hack.approved) {
revalidatePath(`/hack/${slug}`);
@@ -300,7 +313,7 @@ export async function approveHack(slug: string) {
}
// Approve the hack
const { error: updateErr } = await supabase
const { error: updateErr } = await serviceClient
.from("hacks")
.update({
approved: true,
@@ -312,7 +325,7 @@ export async function approveHack(slug: string) {
if (updateErr) return { ok: false, error: updateErr.message } as const;
if (process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL) {
const { data: profile } = await supabase.from('profiles').select('*').eq('id', hack.created_by).single();
const { data: profile } = await serviceClient.from('profiles').select('*').eq('id', hack.created_by).single();
const displayName = profile?.username ? `@${profile.username}` : user.id;
const embed: APIEmbed = {
title: `:tada: ${hack.title} :tada:`,

View File

@@ -396,6 +396,7 @@ export type Database = {
id: string
updated_at: string | null
username: string | null
verified: boolean
website: string | null
}
Insert: {
@@ -404,6 +405,7 @@ export type Database = {
id: string
updated_at?: string | null
username?: string | null
verified?: boolean
website?: string | null
}
Update: {
@@ -412,6 +414,7 @@ export type Database = {
id?: string
updated_at?: string | null
username?: string | null
verified?: boolean
website?: string | null
}
Relationships: []

View File

@@ -0,0 +1,2 @@
alter table if exists public.profiles
add column if not exists verified boolean not null default false;