Implement patch download permissions

This commit is contained in:
Jared Schoeny 2026-05-01 17:08:20 -06:00
parent bb4fda1579
commit d773e29f0b
5 changed files with 504 additions and 107 deletions

View File

@ -10,7 +10,11 @@ import { validateEmail } from "@/utils/auth";
import { revalidatePath, revalidateTag } from "next/cache";
import { unstable_cache as cache } from "next/cache";
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
import { Database } from "@/types/db";
import { Database, Constants } from "@/types/db";
const PATCHES_DOWNLOAD_PERMISSION_VALUES = Constants.public.Enums[
"Patches Download Permission"
] as readonly Database["public"]["Enums"]["Patches Download Permission"][];
export interface HackMetadata {
hack: {
@ -415,25 +419,44 @@ export async function getPatchDownloadUrl(patchId: number): Promise<{ ok: true;
return { ok: false, error: "Patch not found" };
}
// Only allow downloading published, non-archived patches (or if user is creator)
const { data: { user } } = await supabase.auth.getUser();
if (!patch.published || patch.archived) {
if (!user) {
return { ok: false, error: "Unauthorized" };
}
// Check if user is creator
if (!patch.parent_hack) {
return { ok: false, error: "Unauthorized" };
}
const { data: hack } = await supabase
.from("hacks")
.select("created_by")
.eq("slug", patch.parent_hack)
.maybeSingle();
if (!patch.parent_hack) {
return { ok: false, error: "Patch not found" };
}
if (!hack || hack.created_by !== user.id) {
const { data: hack, error: hackError } = await supabase
.from("hacks")
.select("slug, created_by, original_author, is_archive, current_patch, patches_download_permission")
.eq("slug", patch.parent_hack)
.maybeSingle();
if (hackError || !hack) {
return { ok: false, error: "Hack not found" };
}
const {
data: { user },
} = await supabase.auth.getUser();
const isEditor =
!!user &&
(canEditAsCreator(hack, user.id) || (await canEditAsAdmin(hack, user.id, supabase)));
// Only allow downloading published, non-archived patches (unless user can edit)
if (!patch.published || patch.archived) {
if (!isEditor) {
return { ok: false, error: "Unauthorized" };
}
} else if (!isEditor) {
const permission = hack.patches_download_permission;
if (permission === "None") {
return { ok: false, error: "Unauthorized" };
}
if (permission === "Current") {
if (hack.current_patch == null || patch.id !== hack.current_patch) {
return { ok: false, error: "Unauthorized" };
}
}
// "All": published + non-archived already satisfied
}
try {
@ -451,6 +474,46 @@ export async function getPatchDownloadUrl(patchId: number): Promise<{ ok: true;
}
}
export async function updatePatchesDownloadPermission(
slug: string,
permission: Database["public"]["Enums"]["Patches Download Permission"],
): Promise<{ ok: true } | { ok: false; error: string }> {
if (!PATCHES_DOWNLOAD_PERMISSION_VALUES.includes(permission)) {
return { ok: false, error: "Invalid permission" };
}
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { ok: false, error: "Unauthorized" };
const { data: hack, error: hErr } = await supabase
.from("hacks")
.select("slug, created_by, current_patch, original_author, is_archive")
.eq("slug", slug)
.maybeSingle();
if (hErr || !hack) return { ok: false, error: "Hack not found" };
if (!canEditAsCreator(hack, user.id)) {
const editableAsAdmin = await canEditAsAdmin(hack, user.id, supabase);
if (!editableAsAdmin) {
return { ok: false, error: "Forbidden" };
}
}
const serviceClient = await createServiceClient();
const { error: updateErr } = await serviceClient
.from("hacks")
.update({ patches_download_permission: permission })
.eq("slug", slug);
if (updateErr) return { ok: false, error: updateErr.message };
revalidatePath(`/hack/${slug}/versions`);
return { ok: true };
}
export async function archivePatchVersion(slug: string, patchId: number): Promise<{ ok: true } | { ok: false; error: string }> {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();

View File

@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import { canEditAsCreator, canEditAsAdmin } from "@/utils/hack";
import VersionList from "@/components/Hack/VersionList";
import DownloadPermissionSettings from "@/components/Hack/DownloadPermissionSettings";
import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
import Link from "next/link";
import { FaChevronLeft, FaPlus, FaStar } from "react-icons/fa6";
@ -18,7 +19,7 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
// Fetch hack
const { data: hack } = await supabase
.from("hacks")
.select("slug, title, created_by, current_patch, original_author, permission_from, base_rom, is_archive")
.select("slug, title, created_by, current_patch, original_author, permission_from, base_rom, is_archive, patches_download_permission")
.eq("slug", slug)
.maybeSingle();
@ -88,6 +89,13 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
</div>
</div>
{canEdit && (
<DownloadPermissionSettings
hackSlug={slug}
initialPermission={hack.patches_download_permission}
/>
)}
<CollapsibleCard
title="Version Status Guide"
className="mb-6 bg-[var(--surface-1)] border border-[var(--border)]/50 rounded-lg"
@ -132,6 +140,7 @@ export default async function VersionsPage({ params }: VersionsPageProps) {
canEdit={canEdit}
hackSlug={slug}
baseRom={hack.base_rom}
patchesDownloadPermission={hack.patches_download_permission}
/>
</div>
);

View File

@ -0,0 +1,222 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { updatePatchesDownloadPermission } from "@/app/hack/[slug]/actions";
import type { Database } from "@/types/db";
import CollapsibleCard from "@/components/Primitives/CollapsibleCard";
import { FaUserGear } from "react-icons/fa6";
export type PatchesDownloadPermission = Database["public"]["Enums"]["Patches Download Permission"];
export const PATCH_DOWNLOAD_OPTIONS: {
value: PatchesDownloadPermission;
label: string;
/** Short helper shown next to or below the option */
description: string;
}[] = [
{
value: "None",
label: "None",
description: "Users can only download your hack through the built-in patcher.",
},
{
value: "Current",
label: "Current only",
description: "Only the patch version marked Current can be downloaded directly.",
},
{
value: "All",
label: "All published",
description: "Every published patch version can be downloaded directly.",
},
];
function optionLabel(value: PatchesDownloadPermission): string {
return PATCH_DOWNLOAD_OPTIONS.find((o) => o.value === value)?.label ?? value;
}
interface DownloadPermissionSettingsProps {
hackSlug: string;
initialPermission: PatchesDownloadPermission;
}
export default function DownloadPermissionSettings({
hackSlug,
initialPermission,
}: DownloadPermissionSettingsProps) {
const [savedPermission, setSavedPermission] = useState<PatchesDownloadPermission>(initialPermission);
const [selectedPermission, setSelectedPermission] = useState<PatchesDownloadPermission>(initialPermission);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showSaved, setShowSaved] = useState(false);
const [savedFadeOut, setSavedFadeOut] = useState(false);
const savedTimersRef = useRef<{
hold?: ReturnType<typeof setTimeout>;
fade?: ReturnType<typeof setTimeout>;
}>({});
function clearSavedFeedbackTimers() {
const t = savedTimersRef.current;
if (t.hold) clearTimeout(t.hold);
if (t.fade) clearTimeout(t.fade);
t.hold = undefined;
t.fade = undefined;
}
useEffect(() => {
setSavedPermission(initialPermission);
setSelectedPermission(initialPermission);
}, [initialPermission]);
useEffect(() => {
return () => {
clearSavedFeedbackTimers();
};
}, []);
const hasUnsavedChanges = selectedPermission !== savedPermission;
const handleSave = async () => {
setSaving(true);
setError(null);
try {
const result = await updatePatchesDownloadPermission(hackSlug, selectedPermission);
if (result.ok) {
setSavedPermission(selectedPermission);
clearSavedFeedbackTimers();
setSavedFadeOut(false);
setShowSaved(true);
const HOLD_MS = 4000;
const FADE_MS = 450;
savedTimersRef.current.hold = setTimeout(() => {
setSavedFadeOut(true);
savedTimersRef.current.fade = setTimeout(() => {
setShowSaved(false);
setSavedFadeOut(false);
}, FADE_MS);
}, HOLD_MS);
} else {
setError(result.error || "Failed to save");
}
} catch {
setError("Failed to save");
} finally {
setSaving(false);
}
};
const summary = (
<>
<span className="text-foreground/45">Live: </span>
<span className="text-foreground/80 font-medium">{optionLabel(savedPermission)}</span>
{hasUnsavedChanges && (
<>
<span className="text-foreground/40"> · Draft: </span>
<span className="text-foreground/70 font-medium">{optionLabel(selectedPermission)}</span>
</>
)}
</>
);
return (
<CollapsibleCard
title="Patch Download Settings"
titleId="patch-download-permissions-heading"
leading={<FaUserGear size={20} />}
summary={summary}
className="mb-6 rounded-lg border border-[var(--border)]/70 border-l-[3px] border-l-[var(--accent)]/40 bg-[var(--surface-2)]"
>
<div>
<p className="text-xs sm:text-sm text-foreground/60 leading-snug md:-mt-4 mb-6">
Changing this setting will allow users to download the patch file directly from this page as an alternative to using the built-in patcher.
</p>
<RadioCardsBody
selectedPermission={selectedPermission}
savedPermission={savedPermission}
onSelect={setSelectedPermission}
/>
<div className="mt-3 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={saving || !hasUnsavedChanges}
className="inline-flex items-center justify-center min-w-20 h-8 px-3 text-xs font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] hover:bg-[var(--accent-700)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-[var(--accent)] shrink-0"
>
{saving ? "Saving…" : "Save"}
</button>
<div
className="text-xs min-h-[1.25rem] flex items-center flex-1 min-w-0"
aria-live="polite"
>
{showSaved ? (
<span
className={`text-emerald-600 dark:text-emerald-400 font-medium transition-opacity duration-[450ms] ease-out ${
savedFadeOut ? "opacity-0" : "opacity-100"
}`}
>
Setting updated.
</span>
) : error ? (
<span className="text-red-400">{error}</span>
) : null}
</div>
</div>
</div>
</CollapsibleCard>
);
}
function RadioCardsBody({
selectedPermission,
savedPermission,
onSelect,
}: {
selectedPermission: PatchesDownloadPermission;
savedPermission: PatchesDownloadPermission;
onSelect: (v: PatchesDownloadPermission) => void;
}) {
const dirty = selectedPermission !== savedPermission;
return (
<div className="flex flex-col gap-1.5 sm:gap-1" role="radiogroup" aria-label="Who can download patch files">
{PATCH_DOWNLOAD_OPTIONS.map((opt) => {
const isUiSelected = selectedPermission === opt.value;
const showSavedBadge = savedPermission === opt.value && dirty;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={isUiSelected}
onClick={() => onSelect(opt.value)}
className={`w-full text-left rounded-md border-2 px-3 py-2.5 sm:px-2 sm:py-1.5 transition-colors flex gap-3 sm:gap-2 items-center touch-manipulation min-h-[2.75rem] sm:min-h-[2.25rem] ${
isUiSelected
? "border-[var(--accent)] bg-[var(--surface-2)]"
: "border-[var(--border)] bg-[var(--surface-2)]/50 hover:bg-[var(--surface-2)]"
}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded-full border-2 flex items-center justify-center ${
isUiSelected ? "border-[var(--accent)]" : "border-[var(--border)]"
}`}
aria-hidden
>
{isUiSelected && <span className="h-1.5 w-1.5 rounded-full bg-[var(--accent)]" />}
</span>
<span className="min-w-0 flex-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 sm:gap-x-1.5 sm:gap-y-0 leading-tight">
<span className="text-sm font-semibold sm:text-xs">{opt.label}</span>
<span className="text-xs text-foreground/55 sm:text-[11px]">{opt.description}</span>
{showSavedBadge && (
<span className="inline-flex items-center rounded px-1 py-px text-[10px] font-medium uppercase tracking-wide text-foreground/60 bg-foreground/5 ring-1 ring-[var(--border)]">
Saved
</span>
)}
</span>
</button>
);
})}
</div>
);
}

View File

@ -5,7 +5,8 @@ import Markdown from "@/components/Markdown/Markdown";
import { FaChevronDown, FaChevronUp, FaStar, FaDownload, FaTrash, FaRotateLeft, FaUpload, FaCheck, FaPlus } from "react-icons/fa6";
import { FiEdit2, FiEdit, FiX } from "react-icons/fi";
import VersionActions from "@/components/Hack/VersionActions";
import { updatePatchChangelog, updatePatchVersion } from "@/app/hack/[slug]/actions";
import type { PatchesDownloadPermission } from "@/components/Hack/DownloadPermissionSettings";
import { updatePatchChangelog, updatePatchVersion, getPatchDownloadUrl } from "@/app/hack/[slug]/actions";
import { useRouter } from "next/navigation";
import { createClient } from "@/utils/supabase/client";
@ -19,15 +20,68 @@ interface Patch {
archived: boolean;
}
function shouldShowPublicPatchDownload(
permission: PatchesDownloadPermission,
patch: Patch,
isCurrent: boolean,
): boolean {
if (permission === "None") return false;
if (!patch.published || patch.archived) return false;
if (permission === "All") return true;
if (permission === "Current") return isCurrent;
return false;
}
function PublicPatchDownloadButton({ patchId }: { patchId: number }) {
const [loading, setLoading] = useState(false);
const handleClick = async () => {
setLoading(true);
try {
const result = await getPatchDownloadUrl(patchId);
if (result.ok) {
window.open(result.url, "_blank");
} else {
alert(result.error || "Failed to generate download URL");
}
} catch {
alert("Failed to download patch");
} finally {
setLoading(false);
}
};
return (
<button
type="button"
onClick={handleClick}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 text-xs font-medium hover:bg-[var(--surface-3)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed touch-manipulation"
title="Download patch file"
>
<FaDownload size={12} aria-hidden />
{loading ? "Opening…" : "Download Patch"}
</button>
);
}
interface VersionListProps {
patches: Patch[];
currentPatchId: number | null;
canEdit: boolean;
hackSlug: string;
baseRom: string;
patchesDownloadPermission: PatchesDownloadPermission;
}
export default function VersionList({ patches, currentPatchId, canEdit, hackSlug, baseRom }: VersionListProps) {
export default function VersionList({
patches,
currentPatchId,
canEdit,
hackSlug,
baseRom,
patchesDownloadPermission,
}: VersionListProps) {
// Initialize with first patch's changelog expanded if it exists
const getInitialExpanded = () => {
if (patches.length > 0) {
@ -115,6 +169,83 @@ export default function VersionList({ patches, currentPatchId, canEdit, hackSlug
const isEditing = editingChangelog === patch.id;
const currentPatch = allPatches.find(p => p.id === currentPatchId);
const currentPatchCreatedAt = currentPatch?.created_at || null;
const showPublicPatchDownload =
!canEdit &&
shouldShowPublicPatchDownload(patchesDownloadPermission, patch, isCurrent);
const titleBar = (
<div
className={`flex flex-wrap items-center gap-2 ${showPublicPatchDownload ? "" : "mb-1.5 sm:mb-2"}`}
>
{editingVersion === patch.id ? (
<VersionEditor
patchId={patch.id}
initialVersion={patch.version}
hackSlug={hackSlug}
onSave={() => {
setEditingVersion(null);
router.refresh();
}}
onCancel={() => setEditingVersion(null)}
/>
) : (
<>
<h3 className="text-base sm:text-lg font-semibold">{patch.version}</h3>
{canEdit && (
<button
onClick={() => setEditingVersion(patch.id)}
className="inline-flex items-center justify-center rounded-md p-1.5 text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)] touch-manipulation"
title="Edit version"
aria-label="Edit version"
>
<FiEdit size={14} />
</button>
)}
</>
)}
{isCurrent && editingVersion !== patch.id && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<FaStar size={10} />
Current
</span>
)}
{!patch.published && (
<span className="inline-flex items-center rounded-full bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
Unpublished
</span>
)}
{patch.archived && (
<span className="inline-flex items-center rounded-full bg-gray-500/20 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400">
Archived
</span>
)}
</div>
);
const datesBlock = (
<div className="text-xs sm:text-sm text-foreground/60 mt-4 sm:mt-0">
<p>
Created: {new Date(patch.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
{patch.updated_at && patch.updated_at !== patch.created_at && (
<p>
Updated: {new Date(patch.updated_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
)}
</div>
);
return (
<div
@ -122,96 +253,42 @@ export default function VersionList({ patches, currentPatchId, canEdit, hackSlug
className={`card p-4 sm:p-5 ${isCurrent ? "ring-2 ring-emerald-500/50" : ""}`}
>
<div className="space-y-3 sm:space-y-4">
<div className="flex items-start justify-between gap-3 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1.5 sm:mb-2">
{editingVersion === patch.id ? (
<VersionEditor
patchId={patch.id}
initialVersion={patch.version}
{showPublicPatchDownload ? (
<div className="space-y-2">
<div className="flex items-start justify-between gap-3 sm:gap-4">
<div className="flex-1 min-w-0">{titleBar}</div>
<div className="shrink-0">
<PublicPatchDownloadButton patchId={patch.id} />
</div>
</div>
{datesBlock}
</div>
) : (
<div className="flex items-start justify-between gap-3 sm:gap-4">
<div className="flex-1 min-w-0">
{titleBar}
{datesBlock}
</div>
<div className="shrink-0">
{canEdit && (
<VersionActions
patch={patch}
isCurrent={isCurrent}
hackSlug={hackSlug}
onSave={() => {
setEditingVersion(null);
baseRom={baseRom}
currentPatchCreatedAt={currentPatchCreatedAt}
onActionComplete={() => {
router.refresh();
setEditingChangelog(null);
setEditingVersion(null);
// Clear archived patches to force refetch if checkbox is toggled
setArchivedPatches([]);
}}
onCancel={() => setEditingVersion(null)}
/>
) : (
<>
<h3 className="text-base sm:text-lg font-semibold">{patch.version}</h3>
{canEdit && (
<button
onClick={() => setEditingVersion(patch.id)}
className="inline-flex items-center justify-center rounded-md p-1.5 text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)] touch-manipulation"
title="Edit version"
aria-label="Edit version"
>
<FiEdit size={14} />
</button>
)}
</>
)}
{isCurrent && editingVersion !== patch.id && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<FaStar size={10} />
Current
</span>
)}
{!patch.published && (
<span className="inline-flex items-center rounded-full bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
Unpublished
</span>
)}
{patch.archived && (
<span className="inline-flex items-center rounded-full bg-gray-500/20 px-2 py-0.5 text-xs font-medium text-gray-600 dark:text-gray-400">
Archived
</span>
)}
</div>
<div className="text-xs sm:text-sm text-foreground/60">
<p>
Created: {new Date(patch.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
{patch.updated_at && patch.updated_at !== patch.created_at && (
<p>
Updated: {new Date(patch.updated_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
)}
</div>
</div>
<div className="shrink-0">
{canEdit && (
<VersionActions
patch={patch}
isCurrent={isCurrent}
hackSlug={hackSlug}
baseRom={baseRom}
currentPatchCreatedAt={currentPatchCreatedAt}
onActionComplete={() => {
router.refresh();
setEditingChangelog(null);
setEditingVersion(null);
// Clear archived patches to force refetch if checkbox is toggled
// This ensures restored/archived patches don't show duplicates
setArchivedPatches([]);
}}
/>
)}
</div>
</div>
)}
{hasChangelog ? (
<div className="border-t border-[var(--border)] pt-2">

View File

@ -5,24 +5,50 @@ import { FaChevronDown } from "react-icons/fa6";
interface CollapsibleCardProps {
title: string;
/** Icon or badge shown before the title (e.g. to signal editable settings) */
leading?: ReactNode;
/** Always visible under the title (e.g. current summary when collapsed) */
summary?: ReactNode;
titleId?: string;
children: ReactNode;
defaultExpanded?: boolean;
className?: string;
}
export default function CollapsibleCard({ title, children, defaultExpanded = false, className }: CollapsibleCardProps) {
export default function CollapsibleCard({
title,
leading,
summary,
titleId,
children,
defaultExpanded = false,
className,
}: CollapsibleCardProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<div className={`px-4 sm:px-5 ${className}`}>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="py-4 sm:py-5 hover:cursor-pointer w-full flex items-center justify-between gap-2 text-left group-hover:opacity-80 transition-opacity"
className="py-4 sm:py-5 hover:cursor-pointer w-full flex items-start justify-between gap-3 text-left group-hover:opacity-80 transition-opacity"
aria-expanded={isExpanded}
>
<h2 className="text-sm font-semibold text-foreground/90">{title}</h2>
<div className="min-w-0 flex gap-2.5 sm:gap-3 items-start">
{leading != null && (
<span className="shrink-0 mt-0.5 text-[var(--accent)] opacity-90" aria-hidden>
{leading}
</span>
)}
<div className="min-w-0 flex flex-col gap-0.5 flex-1">
<h2 id={titleId} className="text-sm font-semibold text-foreground/90">
{title}
</h2>
{summary != null && <div className="text-xs text-foreground/55 font-normal">{summary}</div>}
</div>
</div>
<span
className={`text-foreground/60 shrink-0 transition-transform duration-300 ease-in-out ${
className={`text-foreground/60 shrink-0 mt-0.5 transition-transform duration-300 ease-in-out ${
isExpanded ? "rotate-180" : ""
}`}
>