diff --git a/src/app/dashboard/actions.ts b/src/app/dashboard/actions.ts index 0870042..e409bd0 100644 --- a/src/app/dashboard/actions.ts +++ b/src/app/dashboard/actions.ts @@ -2,6 +2,8 @@ import { unstable_cache as cache } from "next/cache"; import { createClient, createServiceClient } from "@/utils/supabase/server"; +import { canEditAsCreator, canEditAsAdminOrArchiver } from "@/utils/hack"; +import { checkUserRoles } from "@/utils/user"; interface SeriesDataset { slug: string; @@ -48,16 +50,40 @@ function buildUtcDateLabels(days: number, endExclusiveUtc: Date): string[] { export const getDownloadsSeriesAll = async ({ days = 30 }: { days?: number }): Promise => { const { startISO, endISO, ttl, dayStamp, startOfTodayUtc } = getUtcBounds(days); - // Resolve user and owned slugs OUTSIDE cache (cookies not allowed in cache) + // Resolve user and accessible slugs OUTSIDE cache (cookies not allowed in cache) const supa = await createClient(); const { data: userResp } = await supa.auth.getUser(); const user = userResp.user; if (!user) throw new Error("Unauthorized"); - const { data: hacks } = await supa + + // Get hacks owned by user + const { data: ownedHacks } = await supa .from("hacks") - .select("slug") + .select("slug,created_by,current_patch,original_author,permission_from") .eq("created_by", user.id); - const slugs = (hacks ?? []).map((h) => h.slug); + const ownedSlugs = (ownedHacks ?? []).map((h) => h.slug); + + // Get archive hacks that user can edit as archiver + const { data: allArchiveHacks } = await supa + .from("hacks") + .select("slug,created_by,current_patch,original_author,permission_from") + .not("original_author", "is", null); + + const accessibleArchiveSlugs: string[] = []; + if (allArchiveHacks) { + const { isAdmin, isArchiver } = await checkUserRoles(supa); + for (const hack of allArchiveHacks) { + // Skip if already owned + if (canEditAsCreator(hack, user.id)) continue; + + // Check if user can edit as archiver (function already checks if it's an archive) + if (await canEditAsAdminOrArchiver(hack, user.id, supa, { roles: { isAdmin, isArchiver } })) { + accessibleArchiveSlugs.push(hack.slug); + } + } + } + + const slugs = [...ownedSlugs, ...accessibleArchiveSlugs]; const runner = cache( async () => { @@ -141,14 +167,22 @@ export const getHackInsights = async ({ slug }: { slug: string }): Promise { const supabase = await createClient(); @@ -34,9 +35,8 @@ export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: } } - // Check if this is an Archive hack (no patch available) - const isArchive = hack.original_author != null && hack.current_patch === null; - if (isArchive) { + // Check if this is an Informational Archive hack (no patch available) + if (isInformationalArchiveHack(hack)) { return { ok: false, error: "Archive hacks do not have patch files available" }; } diff --git a/src/app/hack/[slug]/edit/page.tsx b/src/app/hack/[slug]/edit/page.tsx index 1a05d79..f29d0da 100644 --- a/src/app/hack/[slug]/edit/page.tsx +++ b/src/app/hack/[slug]/edit/page.tsx @@ -4,6 +4,7 @@ import { createClient } from "@/utils/supabase/server"; import { FaChevronLeft, FaChevronRight } from "react-icons/fa6"; import Link from "next/link"; import { getCoverSignedUrls } from "@/app/hack/actions"; +import { checkEditPermission } from "@/utils/hack"; interface EditPageProps { params: Promise<{ slug: string }>; @@ -19,22 +20,16 @@ export default async function EditHackPage({ params }: EditPageProps) { const { data: hack } = await supabase .from("hacks") - .select("slug,title,summary,description,base_rom,language,box_art,social_links,created_by,current_patch,original_author") + .select("slug,title,summary,description,base_rom,language,box_art,social_links,created_by,current_patch,original_author,permission_from") .eq("slug", slug) .maybeSingle(); if (!hack) return notFound(); // Check if user can edit: either they're the creator, or they're admin/archiver editing an Archive hack - const canEditAsCreator = hack.created_by === user!.id; - const isArchive = hack.original_author != null && hack.current_patch === null; - let canEditAsAdminOrArchiver = false; - if (isArchive && !canEditAsCreator) { - // Admin check automatically included with is_archiver check - const { data: isArchiver } = await supabase.rpc("is_archiver"); - canEditAsAdminOrArchiver = !!isArchiver; - } + const permission = await checkEditPermission(hack, user!.id, supabase); + const { isInformationalArchive, isDownloadableArchive, isArchive } = permission; - if (!canEditAsCreator && !canEditAsAdminOrArchiver) { + if (!permission.canEdit) { redirect(`/hack/${slug}`); } diff --git a/src/app/hack/[slug]/edit/patch/page.tsx b/src/app/hack/[slug]/edit/patch/page.tsx index 868125a..286f618 100644 --- a/src/app/hack/[slug]/edit/patch/page.tsx +++ b/src/app/hack/[slug]/edit/patch/page.tsx @@ -3,6 +3,7 @@ import { createClient } from "@/utils/supabase/server"; import HackPatchForm from "@/components/Hack/HackPatchForm"; import Link from "next/link"; import { FaChevronLeft } from "react-icons/fa6"; +import { isInformationalArchiveHack, isDownloadableArchiveHack, canEditAsCreator, canEditAsArchiver } from "@/utils/hack"; interface EditPatchPageProps { params: Promise<{ slug: string }>; @@ -18,12 +19,17 @@ export default async function EditPatchPage({ params }: EditPatchPageProps) { const { data: hack } = await supabase .from("hacks") - .select("slug,base_rom,created_by,title,current_patch") + .select("slug,base_rom,created_by,title,current_patch,original_author,permission_from") .eq("slug", slug) .maybeSingle(); if (!hack) return notFound(); - if (hack.created_by !== user!.id) { - redirect(`/hack/${slug}`); + if (!canEditAsCreator(hack, user!.id)) { + const isInformationalArchive = isInformationalArchiveHack(hack); + const isDownloadableArchive = isDownloadableArchiveHack(hack); + const isEditableByArchiver = await canEditAsArchiver(hack, user!.id, supabase); + + if (isInformationalArchive) redirect(`/hack/${slug}`); + if (isDownloadableArchive && !isEditableByArchiver) redirect(`/hack/${slug}`); } const { data: patchRows } = await supabase diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index 8f6c777..29c72d6 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -20,6 +20,7 @@ import { FaCircleCheck } from "react-icons/fa6"; import { sortOrderedTags } from "@/utils/format"; import { RiArchiveStackFill } from "react-icons/ri"; import { getCoverSignedUrls } from "@/app/hack/actions"; +import { isInformationalArchiveHack, isDownloadableArchiveHack, isArchiveHack, checkEditPermission } from "@/utils/hack"; interface HackDetailProps { params: Promise<{ slug: string }>; @@ -46,7 +47,7 @@ export async function generateMetadata({ params }: HackDetailProps): Promise r.id === hack.base_rom)?.name ?? "Pokémon"; const pageUrl = `/hack/${slug}`; const title = isArchive ? `${hack.title} | Archive` : `${hack.title} | ROM hack download`; @@ -116,15 +117,12 @@ export default async function HackDetail({ params }: HackDetailProps) { const supabase = await createClient(); const { data: hack, error } = await supabase .from("hacks") - .select("slug,title,summary,description,base_rom,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by,approved,original_author") + .select("slug,title,summary,description,base_rom,created_at,updated_at,downloads,current_patch,box_art,social_links,created_by,approved,original_author,permission_from") .eq("slug", slug) .maybeSingle(); if (error || !hack) return notFound(); const baseRom = baseRoms.find((r) => r.id === hack.base_rom); - // Detect if this is an Archive hack - const isArchive = hack.original_author != null && hack.current_patch === null; - let images: string[] = []; const { data: covers } = await supabase .from("hack_covers") @@ -158,8 +156,14 @@ export default async function HackDetail({ params }: HackDetailProps) { const { data: { user }, } = await supabase.auth.getUser(); - const canEdit = !!user && user.id === (hack.created_by as string); - const canUploadPatch = (!!user && user.id === (hack.created_by as string) && !isArchive); + const { + canEdit, + canEditAsAdminOrArchiver, + isInformationalArchive, + isDownloadableArchive, + isArchive, + } = await checkEditPermission(hack, user?.id as string, supabase); + const canUploadPatch = canEdit && !isInformationalArchive; let isAdmin = false; if ((!hack.approved && !canEdit) || isArchive) { @@ -171,6 +175,10 @@ export default async function HackDetail({ params }: HackDetailProps) { } } + if (isArchive && !isAdmin && !canEditAsAdminOrArchiver) { + return notFound(); + } + // Get patch info, but don't sign URL yet (happens on user interaction) let patchFilename: string | null = null; let patchVersion = isArchive ? "Archive" : ""; @@ -254,7 +262,7 @@ export default async function HackDetail({ params }: HackDetailProps) { type="application/ld+json" dangerouslySetInnerHTML={{ __html: serialize(jsonLd, { isJSON: true }) }} /> - {!isArchive && ( + {!isInformationalArchive && ( )} - {isArchive && ( + {isInformationalArchive && (
@@ -434,7 +442,7 @@ export default async function HackDetail({ params }: HackDetailProps) {
)} - {isArchive ? ( + {isInformationalArchive ? (

This is an archive entry for {hack.title} preserved for informational purposes. @@ -449,7 +457,7 @@ export default async function HackDetail({ params }: HackDetailProps) { ) : (

- This page provides the official patch file for {hack.title}. You can safely download the patched ROM for this hack + This page provides {isDownloadableArchive ? "an archived" : "the official"} patch file for {hack.title}{isDownloadableArchive ? " with permission from the original creator" : ""}. You can safely download the patched ROM for this hack using our built-in patcher.

diff --git a/src/app/hack/[slug]/stats/page.tsx b/src/app/hack/[slug]/stats/page.tsx index 960de93..68acc62 100644 --- a/src/app/hack/[slug]/stats/page.tsx +++ b/src/app/hack/[slug]/stats/page.tsx @@ -2,6 +2,7 @@ import { createClient } from "@/utils/supabase/server"; import { notFound, redirect } from "next/navigation"; import HackStatsClient from "@/components/Hack/Stats/HackStatsClient"; import { getDownloadsSeriesAll, getHackInsights } from "@/app/dashboard/actions"; +import { isArchiveHack, canEditAsAdminOrArchiver } from "@/utils/hack"; export default async function HackStatsPage({ params: { slug } }: { params: { slug: string } }) { const supa = await createClient(); @@ -11,15 +12,16 @@ export default async function HackStatsPage({ params: { slug } }: { params: { sl const { data: hack } = await supa .from("hacks") - .select("slug,created_by,title") + .select("slug,created_by,title,original_author,current_patch,permission_from") .eq("slug", slug) .maybeSingle(); if (!hack) notFound(); let isOwner = hack.created_by === user.id; if (!isOwner) { - const { data: admin } = await supa.rpc("is_admin"); - if (!admin) notFound(); + const isArchive = isArchiveHack(hack); + const isEditableByArchiver = await canEditAsAdminOrArchiver(hack, user.id, supa); + if (!isOwner && !isArchive && !isEditableByArchiver) notFound(); } const allSeries = await getDownloadsSeriesAll({ days: 30 }); diff --git a/src/app/hack/actions.ts b/src/app/hack/actions.ts index 3ac49c7..53bab3c 100644 --- a/src/app/hack/actions.ts +++ b/src/app/hack/actions.ts @@ -7,6 +7,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { APIEmbed } from "discord-api-types/v10"; import { sendDiscordMessageEmbed } from "@/utils/discord"; +import { checkEditPermission, checkPatchEditPermission } from "@/utils/hack"; export async function updateHack(args: { slug: string; @@ -28,23 +29,14 @@ export async function updateHack(args: { const { data: hack, error: hErr } = await supabase .from("hacks") - .select("slug, created_by, current_patch, original_author") + .select("slug, created_by, current_patch, original_author, permission_from") .eq("slug", args.slug) .maybeSingle(); if (hErr) return { ok: false, error: hErr.message } as const; if (!hack) return { ok: false, error: "Hack not found" } as const; - // Check if user can edit: either they're the creator, or they're admin/archiver editing an Archive hack - const canEditAsCreator = hack.created_by === user.id; - const isArchive = hack.original_author != null && hack.current_patch === null; - let canEditAsAdminOrArchiver = false; - if (isArchive && !canEditAsCreator) { - const { data: isAdmin } = await supabase.rpc("is_admin"); - const { data: isArchiver } = await supabase.rpc("is_archiver"); - canEditAsAdminOrArchiver = !!isAdmin || !!isArchiver; - } - - if (!canEditAsCreator && !canEditAsAdminOrArchiver) { + const permission = await checkEditPermission(hack, user.id, supabase); + if (!permission.canEdit) { return { ok: false, error: "Forbidden" } as const; } @@ -128,12 +120,16 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] } const { data: hack, error: hErr } = await supabase .from("hacks") - .select("slug, created_by") + .select("slug, created_by, current_patch, original_author, permission_from") .eq("slug", args.slug) .maybeSingle(); if (hErr) return { ok: false, error: hErr.message } as const; if (!hack) return { ok: false, error: "Hack not found" } as const; - if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const; + + const permission = await checkEditPermission(hack, user.id, supabase); + if (!permission.canEdit) { + return { ok: false, error: "Forbidden" } as const; + } // Fetch current covers to compute removals and preserve alt text const { data: currentRows, error: cErr } = await supabase @@ -204,15 +200,22 @@ export async function presignNewPatchVersion(args: { slug: string; version: stri } = await supabase.auth.getUser(); if (!user) return { ok: false, error: "Unauthorized" } as const; - // Ensure hack exists and belongs to user + // Ensure hack exists and user has permission const { data: hack, error: hErr } = await supabase .from("hacks") - .select("slug, created_by") + .select("slug, created_by, current_patch, original_author, permission_from") .eq("slug", args.slug) .maybeSingle(); if (hErr) return { ok: false, error: hErr.message } as const; if (!hack) return { ok: false, error: "Hack not found" } as const; - if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const; + + const permission = await checkPatchEditPermission(hack, user.id, supabase); + if (permission.error) { + return { ok: false, error: permission.error } as const; + } + if (!permission.canEdit) { + return { ok: false, error: "Forbidden" } as const; + } // Enforce unique version per hack const { data: existing } = await supabase @@ -241,15 +244,19 @@ export async function presignCoverUpload(args: { slug: string; objectKey: string } = await supabase.auth.getUser(); if (!user) return { ok: false, error: "Unauthorized" } as const; - // Ensure hack exists and belongs to user + // Ensure hack exists and user has permission const { data: hack, error: hErr } = await supabase .from("hacks") - .select("slug, created_by") + .select("slug, created_by, current_patch, original_author, permission_from") .eq("slug", args.slug) .maybeSingle(); if (hErr) return { ok: false, error: hErr.message } as const; if (!hack) return { ok: false, error: "Hack not found" } as const; - if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const; + + const permission = await checkEditPermission(hack, user.id, supabase); + if (!permission.canEdit) { + return { ok: false, error: "Forbidden" } as const; + } const client = getMinioClient(); // 10 minutes to upload diff --git a/src/app/submit/actions.ts b/src/app/submit/actions.ts index 6615e3c..f9ec8e9 100644 --- a/src/app/submit/actions.ts +++ b/src/app/submit/actions.ts @@ -6,6 +6,7 @@ import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server"; import { sendDiscordMessageEmbed } from "@/utils/discord"; import { APIEmbed } from "discord-api-types/v10"; import { slugify } from "@/utils/format"; +import { checkEditPermission, checkPatchEditPermission } from "@/utils/hack"; type HackInsert = TablesInsert<"hacks">; @@ -119,15 +120,19 @@ export async function saveHackCovers(args: { slug: string; coverUrls: string[] } } = await supabase.auth.getUser(); if (!user) return { ok: false, error: "Unauthorized" } as const; - // Ensure hack exists and belongs to user (created_by) to prevent spoof + // Ensure hack exists and user has permission const { data: hack, error: hErr } = await supabase .from("hacks") - .select("slug, created_by") + .select("slug, created_by, current_patch, original_author, permission_from") .eq("slug", args.slug) .maybeSingle(); if (hErr) return { ok: false, error: hErr.message } as const; if (!hack) return { ok: false, error: "Hack not found" } as const; - if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const; + + const permission = await checkEditPermission(hack, user.id, supabase); + if (!permission.canEdit) { + return { ok: false, error: "Forbidden" } as const; + } // Insert covers (overwrite positions) if (args.coverUrls && args.coverUrls.length > 0) { @@ -154,15 +159,22 @@ export async function presignPatchAndSaveCovers(args: { } = await supabase.auth.getUser(); if (!user) return { ok: false, error: "Unauthorized" } as const; - // Ensure hack exists and belongs to user (created_by) to prevent spoof + // Ensure hack exists and user has permission const { data: hack, error: hErr } = await supabase .from("hacks") - .select("slug, created_by") + .select("slug, created_by, current_patch, original_author, permission_from") .eq("slug", args.slug) .maybeSingle(); if (hErr) return { ok: false, error: hErr.message } as const; if (!hack) return { ok: false, error: "Hack not found" } as const; - if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const; + + const permission = await checkPatchEditPermission(hack, user.id, supabase); + if (permission.error) { + return { ok: false, error: permission.error } as const; + } + if (!permission.canEdit) { + return { ok: false, error: "Forbidden" } as const; + } // Insert covers (overwrite positions) if (args.coverUrls && args.coverUrls.length > 0) { @@ -192,12 +204,19 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string const { data: hack, error: hErr } = await supabase .from("hacks") - .select("slug, created_by, title") + .select("slug, created_by, title, current_patch, original_author, permission_from") .eq("slug", args.slug) .maybeSingle(); if (hErr) return { ok: false, error: hErr.message } as const; if (!hack) return { ok: false, error: "Hack not found" } as const; - if (hack.created_by !== user.id) return { ok: false, error: "Forbidden" } as const; + + const permission = await checkPatchEditPermission(hack, user.id, supabase); + if (permission.error) { + return { ok: false, error: permission.error } as const; + } + if (!permission.canEdit) { + return { ok: false, error: "Forbidden" } as const; + } // Enforce unique version per hack defensively (avoid race with presign step) const { data: existing, error: vErr } = await supabase diff --git a/src/utils/hack.ts b/src/utils/hack.ts new file mode 100644 index 0000000..2658042 --- /dev/null +++ b/src/utils/hack.ts @@ -0,0 +1,173 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { checkUserRoles } from "@/utils/user"; + +type HackWithArchiveFields = { + original_author: string | null; + current_patch: number | null; + permission_from?: string | null; + created_by: string; +}; + +/** + * Check if a hack is an informational archive (has original_author but no patch) + */ +export function isInformationalArchiveHack(hack: HackWithArchiveFields): boolean { + return hack.original_author != null && hack.current_patch === null; +} + +/** + * Check if a hack is a downloadable archive (has original_author, patch, and permission_from) + */ +export function isDownloadableArchiveHack(hack: HackWithArchiveFields): boolean { + return hack.original_author != null && hack.current_patch !== null && hack.permission_from != null; +} + +/** + * Check if a hack is any type of archive (informational or downloadable) + */ +export function isArchiveHack(hack: HackWithArchiveFields): boolean { + return isInformationalArchiveHack(hack) || isDownloadableArchiveHack(hack); +} + +/** + * Check if a user can edit a hack as the creator + */ +export function canEditAsCreator(hack: HackWithArchiveFields, userId: string): boolean { + return hack.created_by === userId; +} + +/** + * Check if a user can edit a hack as admin or archiver (for archive hacks only) + * Requires a Supabase client to check RPC functions + */ +export async function canEditAsAdminOrArchiver( + hack: HackWithArchiveFields, + userId: string, + supabase: SupabaseClient, + options?: { + roles?: { + isAdmin: boolean; + isArchiver: boolean; + } + }, +): Promise { + if (!isArchiveHack(hack) || canEditAsCreator(hack, userId)) { + return false; + } + + if (options?.roles) { + return options.roles.isAdmin || options.roles.isArchiver; + } + + const { isAdmin, isArchiver } = await checkUserRoles(supabase); + return isAdmin || isArchiver; +} + +/** + * Check if a user can edit a hack as archiver (for downloadable archives only) + * Requires a Supabase client to check RPC functions + */ +export async function canEditAsArchiver( + hack: HackWithArchiveFields, + userId: string, + supabase: SupabaseClient, + options?: { + roles?: { + isArchiver: boolean; + } + }, +): Promise { + if (!isDownloadableArchiveHack(hack) || canEditAsCreator(hack, userId)) { + return false; + } + + if (options?.roles) { + return options.roles.isArchiver; + } + + const { isArchiver } = await checkUserRoles(supabase); + return isArchiver; +} + +/** + * Check if a user can edit a hack (as creator, admin, or archiver) + * Returns an object with permission details + */ +export async function checkEditPermission( + hack: HackWithArchiveFields, + userId: string, + supabase: SupabaseClient +): Promise<{ + canEdit: boolean; + canEditAsCreator: boolean; + canEditAsAdminOrArchiver: boolean; + isInformationalArchive: boolean; + isDownloadableArchive: boolean; + isArchive: boolean; +}> { + const canEditAsCreatorValue = canEditAsCreator(hack, userId); + const isInformationalArchiveValue = isInformationalArchiveHack(hack); + const isDownloadableArchiveValue = isDownloadableArchiveHack(hack); + const isArchiveValue = isArchiveHack(hack); + + let canEditAsAdminOrArchiverValue = false; + if (isArchiveValue && !canEditAsCreatorValue) { + canEditAsAdminOrArchiverValue = await canEditAsAdminOrArchiver(hack, userId, supabase); + } + + return { + canEdit: canEditAsCreatorValue || canEditAsAdminOrArchiverValue, + canEditAsCreator: canEditAsCreatorValue, + canEditAsAdminOrArchiver: canEditAsAdminOrArchiverValue, + isInformationalArchive: isInformationalArchiveValue, + isDownloadableArchive: isDownloadableArchiveValue, + isArchive: isArchiveValue, + }; +} + +/** + * Check if a user can edit a hack for patch operations (blocks informational archives) + * Returns an object with permission details + */ +export async function checkPatchEditPermission( + hack: HackWithArchiveFields, + userId: string, + supabase: SupabaseClient +): Promise<{ + canEdit: boolean; + canEditAsCreator: boolean; + canEditAsArchiver: boolean; + isInformationalArchive: boolean; + isDownloadableArchive: boolean; + error?: string; +}> { + const canEditAsCreatorValue = canEditAsCreator(hack, userId); + const isInformationalArchiveValue = isInformationalArchiveHack(hack); + const isDownloadableArchiveValue = isDownloadableArchiveHack(hack); + + // Informational archives cannot have patches + if (isInformationalArchiveValue) { + return { + canEdit: false, + canEditAsCreator: canEditAsCreatorValue, + canEditAsArchiver: false, + isInformationalArchive: isInformationalArchiveValue, + isDownloadableArchive: isDownloadableArchiveValue, + error: "Informational archives cannot have patch files", + }; + } + + let canEditAsArchiverValue = false; + if (isDownloadableArchiveValue && !canEditAsCreatorValue) { + canEditAsArchiverValue = await canEditAsArchiver(hack, userId, supabase); + } + + return { + canEdit: canEditAsCreatorValue || canEditAsArchiverValue, + canEditAsCreator: canEditAsCreatorValue, + canEditAsArchiver: canEditAsArchiverValue, + isInformationalArchive: isInformationalArchiveValue, + isDownloadableArchive: isDownloadableArchiveValue, + }; +} + diff --git a/src/utils/user.ts b/src/utils/user.ts new file mode 100644 index 0000000..56ba92b --- /dev/null +++ b/src/utils/user.ts @@ -0,0 +1,17 @@ +import { SupabaseClient } from "@supabase/supabase-js"; + +/** + * Check if a user is admin or archiver + */ +export async function checkUserRoles( + supabase: SupabaseClient +): Promise<{ + isAdmin: boolean; + isArchiver: boolean; +}> { + const { data: claims } = await supabase.rpc("get_my_claims"); + return { + isAdmin: claims?.claims_admin ?? false, + isArchiver: claims?.archiver ?? false, + }; +} diff --git a/supabase/migrations/20251206100008_archiver_role_more_rls_policies.sql b/supabase/migrations/20251206100008_archiver_role_more_rls_policies.sql new file mode 100644 index 0000000..44f9a86 --- /dev/null +++ b/supabase/migrations/20251206100008_archiver_role_more_rls_policies.sql @@ -0,0 +1,6 @@ +-- Archivers can view covers for archive hacks (including unapproved ones) +CREATE POLICY "Archivers can view covers for archive hacks." ON "public"."hack_covers" FOR SELECT USING (("public"."is_archiver"() AND "public"."is_archive_hack_for_archiver"("hack_slug"))); + +-- Archivers can view tags for archive hacks (including unapproved ones) +CREATE POLICY "Archivers can view tags for archive hacks." ON "public"."hack_tags" FOR SELECT USING (("public"."is_archiver"() AND "public"."is_archive_hack_for_archiver"("hack_slug"))); +