From 2e6346172efec0a1e37d8370d1ff062a98dd8921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 12 Dec 2025 10:44:35 +0200 Subject: [PATCH 1/7] Allow moderators to manage packages - Show "Manage Package" button on package page - Allow access to package "edit" page --- .../app/p/components/PackageListing/ManagementTools.tsx | 2 +- apps/cyberstorm-remix/app/p/packageEdit.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/cyberstorm-remix/app/p/components/PackageListing/ManagementTools.tsx b/apps/cyberstorm-remix/app/p/components/PackageListing/ManagementTools.tsx index ce0fa153f..25868a0d6 100644 --- a/apps/cyberstorm-remix/app/p/components/PackageListing/ManagementTools.tsx +++ b/apps/cyberstorm-remix/app/p/components/PackageListing/ManagementTools.tsx @@ -81,7 +81,7 @@ export function ManagementTools({ )} {/* Manage package */} - {perms.can_manage && ( + {(perms.can_manage || perms.can_moderate) && (
Date: Fri, 12 Dec 2025 10:46:56 +0200 Subject: [PATCH 2/7] packageEdit: show 401 for unauthenticated users --- apps/cyberstorm-remix/app/p/packageEdit.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/cyberstorm-remix/app/p/packageEdit.tsx b/apps/cyberstorm-remix/app/p/packageEdit.tsx index 998237a51..78d98bd5f 100644 --- a/apps/cyberstorm-remix/app/p/packageEdit.tsx +++ b/apps/cyberstorm-remix/app/p/packageEdit.tsx @@ -92,9 +92,11 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { params.packageId ); - if ( - !permissions?.permissions.can_manage && - !permissions?.permissions.can_moderate + if (!permissions) { + throw new Response("Unauthenticated", { status: 401 }); + } else if ( + !permissions.permissions.can_manage && + !permissions.permissions.can_moderate ) { throw new Response("Unauthorized", { status: 403 }); } From d67cc480538886c377b980f692c08e7fdc046177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 19 Dec 2025 09:19:45 +0200 Subject: [PATCH 3/7] packageEdit: clean up fetched data - team was unused - communityFilters was a duplicate for filters and unused - community was used only for identifier, which is available via URL params and listing object --- apps/cyberstorm-remix/app/p/packageEdit.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/cyberstorm-remix/app/p/packageEdit.tsx b/apps/cyberstorm-remix/app/p/packageEdit.tsx index 78d98bd5f..80ac5e569 100644 --- a/apps/cyberstorm-remix/app/p/packageEdit.tsx +++ b/apps/cyberstorm-remix/app/p/packageEdit.tsx @@ -51,14 +51,11 @@ export async function loader({ params }: LoaderFunctionArgs) { }; }); return { - community: await dapper.getCommunity(params.communityId), - communityFilters: await dapper.getCommunityFilters(params.communityId), listing: await dapper.getPackageListingDetails( params.communityId, params.namespaceId, params.packageId ), - team: await dapper.getTeamDetails(params.namespaceId), filters: await dapper.getCommunityFilters(params.communityId), permissions: undefined, }; @@ -102,14 +99,11 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { } return { - community: await dapper.getCommunity(params.communityId), - communityFilters: await dapper.getCommunityFilters(params.communityId), listing: await dapper.getPackageListingDetails( params.communityId, params.namespaceId, params.packageId ), - team: await dapper.getTeamDetails(params.namespaceId), filters: await dapper.getCommunityFilters(params.communityId), permissions: permissions, }; @@ -127,7 +121,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { clientLoader.hydrate = true; export default function PackageListing() { - const { community, listing, filters, permissions } = useLoaderData< + const { listing, filters, permissions } = useLoaderData< typeof loader | typeof clientLoader >(); @@ -199,7 +193,7 @@ export default function PackageListing() { return await packageListingUpdate({ config: config, params: { - community: community.identifier, + community: listing.community_identifier, namespace: listing.namespace, package: listing.name, }, @@ -267,7 +261,7 @@ export default function PackageListing() { unlistAction({ config: config, params: { - community: community.identifier, + community: listing.community_identifier, namespace: listing.namespace, package: listing.name, }, From 6191e0b86eda59152448e70474ab56c3b0cbab65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 19 Dec 2025 09:32:45 +0200 Subject: [PATCH 4/7] packageEdit: reduce nesting by exiting early --- apps/cyberstorm-remix/app/p/packageEdit.tsx | 128 ++++++++++---------- 1 file changed, 67 insertions(+), 61 deletions(-) diff --git a/apps/cyberstorm-remix/app/p/packageEdit.tsx b/apps/cyberstorm-remix/app/p/packageEdit.tsx index 80ac5e569..cfa1625ec 100644 --- a/apps/cyberstorm-remix/app/p/packageEdit.tsx +++ b/apps/cyberstorm-remix/app/p/packageEdit.tsx @@ -41,81 +41,87 @@ export const meta: MetaFunction = ({ data }) => { }; export async function loader({ params }: LoaderFunctionArgs) { - if (params.communityId && params.namespaceId && params.packageId) { - try { - const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); - const dapper = new DapperTs(() => { - return { - apiHost: publicEnvVariables.VITE_API_URL, - sessionId: undefined, - }; - }); + const { communityId, namespaceId, packageId } = params; + + if (!communityId || !namespaceId || !packageId) { + throw new Response("Package not found", { status: 404 }); + } + + try { + const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); + const dapper = new DapperTs(() => { return { - listing: await dapper.getPackageListingDetails( - params.communityId, - params.namespaceId, - params.packageId - ), - filters: await dapper.getCommunityFilters(params.communityId), - permissions: undefined, + apiHost: publicEnvVariables.VITE_API_URL, + sessionId: undefined, }; - } catch (error) { - if (error instanceof ApiError) { - throw new Response("Package not found", { status: 404 }); - } else { - // REMIX TODO: Add sentry - throw error; - } + }); + return { + listing: await dapper.getPackageListingDetails( + communityId, + namespaceId, + packageId + ), + filters: await dapper.getCommunityFilters(communityId), + permissions: undefined, + }; + } catch (error) { + if (error instanceof ApiError) { + throw new Response("Package not found", { status: 404 }); + } else { + // REMIX TODO: Add sentry + throw error; } } - throw new Response("Package not found", { status: 404 }); } // TODO: Needs to check if package is available for the logged in user export async function clientLoader({ params }: LoaderFunctionArgs) { - if (params.communityId && params.namespaceId && params.packageId) { - try { - const tools = getSessionTools(); - const dapper = new DapperTs(() => { - return { - apiHost: tools?.getConfig().apiHost, - sessionId: tools?.getConfig().sessionId, - }; - }); - - const permissions = await dapper.getPackagePermissions( - params.communityId, - params.namespaceId, - params.packageId - ); + const { communityId, namespaceId, packageId } = params; - if (!permissions) { - throw new Response("Unauthenticated", { status: 401 }); - } else if ( - !permissions.permissions.can_manage && - !permissions.permissions.can_moderate - ) { - throw new Response("Unauthorized", { status: 403 }); - } + if (!communityId || !namespaceId || !packageId) { + throw new Response("Package not found", { status: 404 }); + } + try { + const tools = getSessionTools(); + const dapper = new DapperTs(() => { return { - listing: await dapper.getPackageListingDetails( - params.communityId, - params.namespaceId, - params.packageId - ), - filters: await dapper.getCommunityFilters(params.communityId), - permissions: permissions, + apiHost: tools?.getConfig().apiHost, + sessionId: tools?.getConfig().sessionId, }; - } catch (error) { - if (error instanceof ApiError) { - throw new Response("Package not found", { status: 404 }); - } else { - throw error; - } + }); + + const permissions = await dapper.getPackagePermissions( + communityId, + namespaceId, + packageId + ); + + if (!permissions) { + throw new Response("Unauthenticated", { status: 401 }); + } else if ( + !permissions.permissions.can_manage && + !permissions.permissions.can_moderate + ) { + throw new Response("Unauthorized", { status: 403 }); + } + + return { + listing: await dapper.getPackageListingDetails( + communityId, + namespaceId, + packageId + ), + filters: await dapper.getCommunityFilters(communityId), + permissions: permissions, + }; + } catch (error) { + if (error instanceof ApiError) { + throw new Response("Package not found", { status: 404 }); + } else { + throw error; } } - throw new Response("Package not found", { status: 404 }); } clientLoader.hydrate = true; From 972a9aeca8082d3a585e5609db59263a3143cd5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 19 Dec 2025 09:35:04 +0200 Subject: [PATCH 5/7] packageEdit: DRY error handling code Sentry logging isn't needed in UI component, it's handled by entry.server.tsx on server and RouteErrorBoundary on client. --- apps/cyberstorm-remix/app/p/packageEdit.tsx | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/apps/cyberstorm-remix/app/p/packageEdit.tsx b/apps/cyberstorm-remix/app/p/packageEdit.tsx index cfa1625ec..6e04307ba 100644 --- a/apps/cyberstorm-remix/app/p/packageEdit.tsx +++ b/apps/cyberstorm-remix/app/p/packageEdit.tsx @@ -40,11 +40,13 @@ export const meta: MetaFunction = ({ data }) => { ]; }; +const package404 = new Response("Package not found", { status: 404 }); + export async function loader({ params }: LoaderFunctionArgs) { const { communityId, namespaceId, packageId } = params; if (!communityId || !namespaceId || !packageId) { - throw new Response("Package not found", { status: 404 }); + throw package404; } try { @@ -65,12 +67,7 @@ export async function loader({ params }: LoaderFunctionArgs) { permissions: undefined, }; } catch (error) { - if (error instanceof ApiError) { - throw new Response("Package not found", { status: 404 }); - } else { - // REMIX TODO: Add sentry - throw error; - } + throw error instanceof ApiError ? package404 : error; } } @@ -79,7 +76,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const { communityId, namespaceId, packageId } = params; if (!communityId || !namespaceId || !packageId) { - throw new Response("Package not found", { status: 404 }); + throw package404; } try { @@ -116,11 +113,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { permissions: permissions, }; } catch (error) { - if (error instanceof ApiError) { - throw new Response("Package not found", { status: 404 }); - } else { - throw error; - } + throw error instanceof ApiError ? package404 : error; } } From 72903a9fcbbc5883a833f80af89c0de1fb6bf1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 19 Dec 2025 13:59:19 +0200 Subject: [PATCH 6/7] packageEdit: don't load data on SSR loader The page requires authentication and authorization, and the SSR rendering half a form that's shown to user while the client side checks for permissions is just bad UX. Instead, return undefined so the component renders immediately and show a skeleton component to user. --- apps/cyberstorm-remix/app/p/packageEdit.css | 4 ++ apps/cyberstorm-remix/app/p/packageEdit.tsx | 56 ++++++--------------- 2 files changed, 20 insertions(+), 40 deletions(-) diff --git a/apps/cyberstorm-remix/app/p/packageEdit.css b/apps/cyberstorm-remix/app/p/packageEdit.css index 0ba9d6f4f..47c2a1b74 100644 --- a/apps/cyberstorm-remix/app/p/packageEdit.css +++ b/apps/cyberstorm-remix/app/p/packageEdit.css @@ -85,4 +85,8 @@ .package-edit__save-button { flex: 1 0 0; } + + .package-edit__skeleton { + min-height: 20rem; + } } diff --git a/apps/cyberstorm-remix/app/p/packageEdit.tsx b/apps/cyberstorm-remix/app/p/packageEdit.tsx index 6e04307ba..ce6ca162b 100644 --- a/apps/cyberstorm-remix/app/p/packageEdit.tsx +++ b/apps/cyberstorm-remix/app/p/packageEdit.tsx @@ -6,6 +6,7 @@ import { NewIcon, NewSelectSearch, NewTag, + SkeletonBox, formatToDisplayName, useToast, } from "@thunderstore/cyberstorm"; @@ -19,10 +20,7 @@ import { } from "@thunderstore/thunderstore-api"; import { DapperTs } from "@thunderstore/dapper-ts"; import { type OutletContextShape } from "~/root"; -import { - getPublicEnvVariables, - getSessionTools, -} from "cyberstorm/security/publicEnvVariables"; +import { getSessionTools } from "cyberstorm/security/publicEnvVariables"; import { PageHeader } from "~/commonComponents/PageHeader/PageHeader"; import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; import { useReducer } from "react"; @@ -30,7 +28,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBan, faCheck } from "@fortawesome/pro-solid-svg-icons"; import { ApiAction } from "@thunderstore/ts-api-react-actions"; -export const meta: MetaFunction = ({ data }) => { +export const meta: MetaFunction = ({ data }) => { return [ { title: data @@ -43,32 +41,7 @@ export const meta: MetaFunction = ({ data }) => { const package404 = new Response("Package not found", { status: 404 }); export async function loader({ params }: LoaderFunctionArgs) { - const { communityId, namespaceId, packageId } = params; - - if (!communityId || !namespaceId || !packageId) { - throw package404; - } - - try { - const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); - const dapper = new DapperTs(() => { - return { - apiHost: publicEnvVariables.VITE_API_URL, - sessionId: undefined, - }; - }); - return { - listing: await dapper.getPackageListingDetails( - communityId, - namespaceId, - packageId - ), - filters: await dapper.getCommunityFilters(communityId), - permissions: undefined, - }; - } catch (error) { - throw error instanceof ApiError ? package404 : error; - } + return undefined; } // TODO: Needs to check if package is available for the logged in user @@ -120,10 +93,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { clientLoader.hydrate = true; export default function PackageListing() { - const { listing, filters, permissions } = useLoaderData< - typeof loader | typeof clientLoader - >(); - + const loaderData = useLoaderData(); const outletContext = useOutletContext() as OutletContextShape; const config = outletContext.requestConfig; const toast = useToast(); @@ -183,7 +153,7 @@ export default function PackageListing() { } const [formInputs, updateFormFieldState] = useReducer(formFieldUpdateAction, { - categories: listing.categories.map((c) => c.slug), + categories: loaderData?.listing.categories.map((c) => c.slug) ?? [], }); type SubmitorOutput = Awaited>; @@ -231,6 +201,12 @@ export default function PackageListing() { }, }); + if (!loaderData) { + return ; + } + + const { listing, filters, permissions } = loaderData; + return ( <> @@ -238,7 +214,7 @@ export default function PackageListing() {
- {permissions?.permissions.can_unlist ? ( + {permissions.permissions.can_unlist && ( <>
@@ -278,8 +254,8 @@ export default function PackageListing() {
- ) : null} - {permissions?.permissions.can_manage_deprecation ? ( + )} + {permissions.permissions.can_manage_deprecation && ( <>
@@ -329,7 +305,7 @@ export default function PackageListing() {
- ) : null} + )}
Categories
From 6e5f19eab433bff7c43d1f5395fbadf2b3598ad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 19 Dec 2025 14:17:15 +0200 Subject: [PATCH 7/7] packageEdit: use two-phase package listing fetch Attempt to fetch package listing data first as unauthenticated user in hopes of hitting cache. If this results in 404, retry as authenticated user in hopes of having permissions to view a rejected package listing. Since we block rendering be awaiting permissions and listing, we might as well trigger filter loading before we know whether user can access the page or not. A little bit on overfetch is preferred to awaiting in phases. --- apps/cyberstorm-remix/app/p/packageEdit.tsx | 37 +++++++-------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/apps/cyberstorm-remix/app/p/packageEdit.tsx b/apps/cyberstorm-remix/app/p/packageEdit.tsx index ce6ca162b..11f98b870 100644 --- a/apps/cyberstorm-remix/app/p/packageEdit.tsx +++ b/apps/cyberstorm-remix/app/p/packageEdit.tsx @@ -18,15 +18,15 @@ import { type PackageListingUpdateRequestData, packageUnlist, } from "@thunderstore/thunderstore-api"; -import { DapperTs } from "@thunderstore/dapper-ts"; import { type OutletContextShape } from "~/root"; -import { getSessionTools } from "cyberstorm/security/publicEnvVariables"; import { PageHeader } from "~/commonComponents/PageHeader/PageHeader"; import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; import { useReducer } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faBan, faCheck } from "@fortawesome/pro-solid-svg-icons"; import { ApiAction } from "@thunderstore/ts-api-react-actions"; +import { getDapperForRequest } from "cyberstorm/utils/dapperSingleton"; +import { getPrivateListing } from "app/p/listingUtils"; export const meta: MetaFunction = ({ data }) => { return [ @@ -44,8 +44,7 @@ export async function loader({ params }: LoaderFunctionArgs) { return undefined; } -// TODO: Needs to check if package is available for the logged in user -export async function clientLoader({ params }: LoaderFunctionArgs) { +export async function clientLoader({ params, request }: LoaderFunctionArgs) { const { communityId, namespaceId, packageId } = params; if (!communityId || !namespaceId || !packageId) { @@ -53,19 +52,15 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { } try { - const tools = getSessionTools(); - const dapper = new DapperTs(() => { - return { - apiHost: tools?.getConfig().apiHost, - sessionId: tools?.getConfig().sessionId, - }; - }); + const dapper = getDapperForRequest(request); - const permissions = await dapper.getPackagePermissions( - communityId, - namespaceId, - packageId - ); + // Fetch everything at once for faster page load, even if we may + // overfetch in case we lack authentication/authorization. + const [listing, permissions, filters] = await Promise.all([ + getPrivateListing(dapper, { communityId, namespaceId, packageId }), + dapper.getPackagePermissions(communityId, namespaceId, packageId), + dapper.getCommunityFilters(communityId), + ]); if (!permissions) { throw new Response("Unauthenticated", { status: 401 }); @@ -76,15 +71,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { throw new Response("Unauthorized", { status: 403 }); } - return { - listing: await dapper.getPackageListingDetails( - communityId, - namespaceId, - packageId - ), - filters: await dapper.getCommunityFilters(communityId), - permissions: permissions, - }; + return { listing, permissions, filters }; } catch (error) { throw error instanceof ApiError ? package404 : error; }