mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 08:34:13 -05:00
Allow admin marking profile as verified
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:`,
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table if exists public.profiles
|
||||
add column if not exists verified boolean not null default false;
|
||||
Reference in New Issue
Block a user