From 3609f7c19bca9835292ffbe944900f73c8e387dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Thu, 20 Nov 2025 16:19:28 +0200 Subject: [PATCH 1/4] Add Dapper method implementation for current user's team permissions Currently these are used to check if the authenticated user can leave the team (team needs to retain at least one owner) and if the team can be disanded (team with packages can't be disbanded). --- packages/dapper-fake/src/fakers/user.ts | 9 +++++++ packages/dapper-fake/src/index.ts | 6 ++++- packages/dapper-ts/src/index.ts | 8 +++++- packages/dapper-ts/src/methods/currentUser.ts | 18 ++++++++++++- packages/dapper/src/dapper.ts | 1 + packages/dapper/src/types/methods.ts | 6 ++++- packages/dapper/src/types/user.ts | 5 ++++ .../thunderstore-api/src/get/currentUser.ts | 25 +++++++++++++++++++ .../src/schemas/objectSchemas.ts | 9 +++++++ .../src/schemas/requestSchemas.ts | 9 +++++++ .../src/schemas/responseSchemas.ts | 9 +++++++ 11 files changed, 101 insertions(+), 4 deletions(-) diff --git a/packages/dapper-fake/src/fakers/user.ts b/packages/dapper-fake/src/fakers/user.ts index 96ab1e2c9..c530593e8 100644 --- a/packages/dapper-fake/src/fakers/user.ts +++ b/packages/dapper-fake/src/fakers/user.ts @@ -42,3 +42,12 @@ const getFakeOAuthConnection = (provider: string) => ({ username: faker.internet.userName(), avatar: faker.helpers.maybe(getFakeImg) ?? null, }); + +export const getFakeCurrentUserTeamPermissions = async (teamName: string) => { + setSeed(teamName); + + return { + can_disband_team: faker.datatype.boolean(), + can_leave_team: faker.datatype.boolean(), + }; +}; diff --git a/packages/dapper-fake/src/index.ts b/packages/dapper-fake/src/index.ts index 9d127ee39..d90df5d92 100644 --- a/packages/dapper-fake/src/index.ts +++ b/packages/dapper-fake/src/index.ts @@ -21,7 +21,10 @@ import { getFakeTeamMembers, postFakeTeamCreate, } from "./fakers/team"; -import { getFakeCurrentUser } from "./fakers/user"; +import { + getFakeCurrentUser, + getFakeCurrentUserTeamPermissions, +} from "./fakers/user"; import { postFakePackageSubmissionMetadata } from "./fakers/submission"; import { getFakePackageSubmissionStatus } from "./fakers/submission"; @@ -30,6 +33,7 @@ export class DapperFake implements DapperInterface { public getCommunity = getFakeCommunity; public getCommunityFilters = getFakeCommunityFilters; public getCurrentUser = getFakeCurrentUser; + public getCurrentUserTeamPermissions = getFakeCurrentUserTeamPermissions; public getPackageChangelog = getFakeChangelog; public getPackagePermissions = getFakePackagePermissions; public getPackageListingDetails = getFakePackageListingDetails; diff --git a/packages/dapper-ts/src/index.ts b/packages/dapper-ts/src/index.ts index 53edf2260..7a7960eab 100644 --- a/packages/dapper-ts/src/index.ts +++ b/packages/dapper-ts/src/index.ts @@ -5,7 +5,10 @@ import { getDynamicHTML } from "./methods/dynamicHTML"; import { getCommunities, getCommunity } from "./methods/communities"; import { getCommunityFilters } from "./methods/communityFilters"; import { getRatedPackages } from "./methods/ratedPackages"; -import { getCurrentUser } from "./methods/currentUser"; +import { + getCurrentUser, + getCurrentUserTeamPermissions, +} from "./methods/currentUser"; import { getPackageChangelog, getPackageReadme, @@ -48,6 +51,8 @@ export class DapperTs implements DapperTsInterface { this.getCommunityFilters = this.getCommunityFilters.bind(this); this.getRatedPackages = this.getRatedPackages.bind(this); this.getCurrentUser = this.getCurrentUser.bind(this); + this.getCurrentUserTeamPermissions = + this.getCurrentUserTeamPermissions.bind(this); this.getPackageChangelog = this.getPackageChangelog.bind(this); this.getPackageListings = this.getPackageListings.bind(this); this.getPackageListingDetails = this.getPackageListingDetails.bind(this); @@ -76,6 +81,7 @@ export class DapperTs implements DapperTsInterface { public getCommunityFilters = getCommunityFilters; public getRatedPackages = getRatedPackages; public getCurrentUser = getCurrentUser; + public getCurrentUserTeamPermissions = getCurrentUserTeamPermissions; public getPackageChangelog = getPackageChangelog; public getPackageListings = getPackageListings; public getPackageListingDetails = getPackageListingDetails; diff --git a/packages/dapper-ts/src/methods/currentUser.ts b/packages/dapper-ts/src/methods/currentUser.ts index f7109bc7f..ee80db2f5 100644 --- a/packages/dapper-ts/src/methods/currentUser.ts +++ b/packages/dapper-ts/src/methods/currentUser.ts @@ -1,4 +1,8 @@ -import { ApiError, fetchCurrentUser } from "@thunderstore/thunderstore-api"; +import { + ApiError, + fetchCurrentUser, + fetchCurrentUserTeamPermissions, +} from "@thunderstore/thunderstore-api"; import { DapperTsInterface } from "../index"; @@ -22,3 +26,15 @@ export async function getCurrentUser(this: DapperTsInterface) { } } } + +export async function getCurrentUserTeamPermissions( + this: DapperTsInterface, + teamName: string +) { + return await fetchCurrentUserTeamPermissions({ + config: this.config, + params: { team_name: teamName }, + data: {}, + queryParams: {}, + }); +} diff --git a/packages/dapper/src/dapper.ts b/packages/dapper/src/dapper.ts index 5a3c71d37..ffaa2f82d 100644 --- a/packages/dapper/src/dapper.ts +++ b/packages/dapper/src/dapper.ts @@ -5,6 +5,7 @@ export interface DapperInterface { getCommunity: methods.GetCommunity; getCommunityFilters: methods.GetCommunityFilters; getCurrentUser: methods.GetCurrentUser; + getCurrentUserTeamPermissions: methods.GetCurrentUserTeamPermissions; getPackageChangelog: methods.GetPackageChangelog; getPackageListingDetails: methods.GetPackageListingDetails; getPackageListings: methods.GetPackageListings; diff --git a/packages/dapper/src/types/methods.ts b/packages/dapper/src/types/methods.ts index bc3162194..d27a4ce84 100644 --- a/packages/dapper/src/types/methods.ts +++ b/packages/dapper/src/types/methods.ts @@ -15,7 +15,7 @@ import { import { type PackageListingType } from "./props"; import { type HTMLContentResponse, type MarkdownResponse } from "./shared"; import { type TeamDetails, type ServiceAccount, type TeamMember } from "./team"; -import { type CurrentUser } from "./user"; +import { type CurrentUser, type CurrentUserTeamPermissions } from "./user"; export type GetCommunities = ( page?: number, @@ -31,6 +31,10 @@ export type GetCommunityFilters = ( export type GetCurrentUser = () => Promise; +export type GetCurrentUserTeamPermissions = ( + teamName: string +) => Promise; + export type GetPackageChangelog = ( namespace: string, name: string, diff --git a/packages/dapper/src/types/user.ts b/packages/dapper/src/types/user.ts index cc22bcc0b..e2a8e6d07 100644 --- a/packages/dapper/src/types/user.ts +++ b/packages/dapper/src/types/user.ts @@ -46,3 +46,8 @@ interface Badge { description: string; imageSource: string; } + +export interface CurrentUserTeamPermissions { + can_disband_team: boolean; + can_leave_team: boolean; +} diff --git a/packages/thunderstore-api/src/get/currentUser.ts b/packages/thunderstore-api/src/get/currentUser.ts index 56d534bb1..95e043fa6 100644 --- a/packages/thunderstore-api/src/get/currentUser.ts +++ b/packages/thunderstore-api/src/get/currentUser.ts @@ -1,8 +1,14 @@ import { ApiEndpointProps } from "../index"; import { apiFetch } from "../apiFetch"; +import { + type CurrentUserTeamPermissionsRequestParams, + currentUserTeamPermissionsRequestParamsSchema, +} from "../schemas/requestSchemas"; import { CurrentUserResponseData, currentUserResponseDataSchema, + CurrentUserTeamPermissionsResponseData, + currentUserTeamPermissionsResponseDataSchema, } from "../schemas/responseSchemas"; export async function fetchCurrentUser( @@ -19,3 +25,22 @@ export async function fetchCurrentUser( responseSchema: currentUserResponseDataSchema, }); } + +export async function fetchCurrentUserTeamPermissions( + props: ApiEndpointProps< + CurrentUserTeamPermissionsRequestParams, + object, + object + > +): Promise { + const { config, params } = props; + const path = `api/experimental/current-user/permissions/team/${params.team_name}/`; + const request = { cache: "no-store" as RequestCache }; + + return await apiFetch({ + args: { config, path, request, useSession: true }, + requestSchema: currentUserTeamPermissionsRequestParamsSchema, + queryParamsSchema: undefined, + responseSchema: currentUserTeamPermissionsResponseDataSchema, + }); +} diff --git a/packages/thunderstore-api/src/schemas/objectSchemas.ts b/packages/thunderstore-api/src/schemas/objectSchemas.ts index 362b4567f..caeaecc68 100644 --- a/packages/thunderstore-api/src/schemas/objectSchemas.ts +++ b/packages/thunderstore-api/src/schemas/objectSchemas.ts @@ -359,6 +359,15 @@ export const userSchema = z.object({ export type User = z.infer; +export const currentUserTeamPermissionsSchema = z.object({ + can_disband_team: z.boolean(), + can_leave_team: z.boolean(), +}); + +export type CurrentUserTeamPermissions = z.infer< + typeof currentUserTeamPermissionsSchema +>; + export const ratedPackagesSchema = z.object({ rated_packages: z.string().array(), }); diff --git a/packages/thunderstore-api/src/schemas/requestSchemas.ts b/packages/thunderstore-api/src/schemas/requestSchemas.ts index 449339647..48ad36eac 100644 --- a/packages/thunderstore-api/src/schemas/requestSchemas.ts +++ b/packages/thunderstore-api/src/schemas/requestSchemas.ts @@ -115,6 +115,15 @@ export type PackageListingsRequestQueryParams = z.infer< typeof packageListingsRequestQueryParamsSchema >; +// CurrentUserTeamPermissionsRequest +export const currentUserTeamPermissionsRequestParamsSchema = z.object({ + team_name: z.string(), +}); + +export type CurrentUserTeamPermissionsRequestParams = z.infer< + typeof currentUserTeamPermissionsRequestParamsSchema +>; + // DynamicHTMLRequest export const dynamicHTMLRequestParamsSchema = z.object({ placement: z.string(), diff --git a/packages/thunderstore-api/src/schemas/responseSchemas.ts b/packages/thunderstore-api/src/schemas/responseSchemas.ts index 435c94483..903ca644e 100644 --- a/packages/thunderstore-api/src/schemas/responseSchemas.ts +++ b/packages/thunderstore-api/src/schemas/responseSchemas.ts @@ -20,6 +20,7 @@ import { packageSourceSchema, packageVersionDependencySchema, packageTeamSchema, + currentUserTeamPermissionsSchema, } from "../schemas/objectSchemas"; import { paginatedResults } from "../schemas/objectSchemas"; @@ -85,6 +86,14 @@ export type CurrentUserResponseData = z.infer< typeof currentUserResponseDataSchema >; +// CurrentUserTeamPermissionsResponse +export const currentUserTeamPermissionsResponseDataSchema = + currentUserTeamPermissionsSchema; + +export type CurrentUserTeamPermissionsResponseData = z.infer< + typeof currentUserTeamPermissionsResponseDataSchema +>; + // DynamicHTMLResponse export const dynamicHTMLResponseDataSchema = z.object({ dynamic_htmls: z.array(z.string().min(1)), From 414bd242c816c7de006fc64a6b2f9859057df5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Thu, 20 Nov 2025 16:33:40 +0200 Subject: [PATCH 2/4] Use API to check team leaving/disbanding permissions There's no longer need to check if currentUser is set in the session context as the API call will return 401 that gets handled automatically (currently by showing 500 error but better once the global error handling PR is merged) if the user isn't authenticated. There's no need to wait for the teamName to resolve as it's resolved synchronously (based on the team name in the route URL), i.e. no Promise is returned. --- .../teams/team/tabs/Settings/Settings.tsx | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx index 588b0e25b..3b17333fc 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx @@ -26,25 +26,22 @@ import { } from "@thunderstore/thunderstore-api"; import { ApiAction } from "@thunderstore/ts-api-react-actions"; -import { NotLoggedIn } from "app/commonComponents/NotLoggedIn/NotLoggedIn"; import { type OutletContextShape } from "app/root"; import { makeTeamSettingsTabLoader } from "cyberstorm/utils/dapperClientLoaders"; import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; export const clientLoader = makeTeamSettingsTabLoader( - // TODO: add end point for checking can leave/disband status. - async (dapper, teamName) => ({ teamName }) + async (dapper, teamName) => ({ + permissions: dapper.getCurrentUserTeamPermissions(teamName), + }) ); export default function Settings() { - const { teamName } = useLoaderData(); + const { permissions, teamName } = useLoaderData(); const outletContext = useOutletContext() as OutletContextShape; const toast = useToast(); const navigate = useNavigate(); - const currentUser = outletContext.currentUser?.username; - if (!currentUser) return ; - async function moveToTeams() { toast.addToast({ csVariant: "info", @@ -56,8 +53,8 @@ export default function Settings() { return ( Loading...}> - - {(resolvedTeamName) => ( + + {(resolvedPermissions) => (
@@ -74,8 +71,8 @@ export default function Settings() { team has another owner assigned.

You cannot currently disband this team as it has packages. -

You are about to disband the team {resolvedTeamName}.

+

You are about to disband the team {teamName}.

Be aware you can currently only disband teams with no packages. If you need to archive a team with existing pages, contact Mythic#0001 on the Thunderstore Discord.

Date: Fri, 21 Nov 2025 12:56:33 +0200 Subject: [PATCH 3/4] Add isTeamOwner helper While a simple thing to check, this gets repeated constantly so having a dedicated helper with basic unit tests in place makes sense. --- .../utils/__tests__/permissions.test.ts | 46 +++++++++++++++++++ .../cyberstorm/utils/permissions.ts | 7 +++ 2 files changed, 53 insertions(+) create mode 100644 apps/cyberstorm-remix/cyberstorm/utils/__tests__/permissions.test.ts create mode 100644 apps/cyberstorm-remix/cyberstorm/utils/permissions.ts diff --git a/apps/cyberstorm-remix/cyberstorm/utils/__tests__/permissions.test.ts b/apps/cyberstorm-remix/cyberstorm/utils/__tests__/permissions.test.ts new file mode 100644 index 000000000..03fbb7062 --- /dev/null +++ b/apps/cyberstorm-remix/cyberstorm/utils/__tests__/permissions.test.ts @@ -0,0 +1,46 @@ +import { assert, describe, it } from "vitest"; + +import type { CurrentUser } from "@thunderstore/dapper/types"; + +import { isTeamOwner } from "../permissions"; + +describe("utils.permissions.isTeamOwner", () => { + it("returns false if user is unauthenticated", () => { + const actual = isTeamOwner("test-team", undefined); + + assert.isFalse(actual); + }); + + it("returns false if user does not belong to team", () => { + const user = { + teams_full: [{ name: "other-team", role: "owner", member_count: 1 }], + }; + + const actual = isTeamOwner("test-team", user as CurrentUser); + + assert.isFalse(actual); + }); + + it("returns false if user is non-owner member", () => { + const user = { + teams_full: [{ name: "test-team", role: "member", member_count: 2 }], + }; + + const actual = isTeamOwner("test-team", user as CurrentUser); + + assert.isFalse(actual); + }); + + it("returns true if user is owner of team", () => { + const user = { + teams_full: [ + { name: "other-team", role: "member", member_count: 1 }, + { name: "test-team", role: "owner", member_count: 3 }, + ], + }; + + const actual = isTeamOwner("test-team", user as CurrentUser); + + assert.isTrue(actual); + }); +}); diff --git a/apps/cyberstorm-remix/cyberstorm/utils/permissions.ts b/apps/cyberstorm-remix/cyberstorm/utils/permissions.ts new file mode 100644 index 000000000..f4671f859 --- /dev/null +++ b/apps/cyberstorm-remix/cyberstorm/utils/permissions.ts @@ -0,0 +1,7 @@ +import type { CurrentUser } from "@thunderstore/dapper/types"; + +export const isTeamOwner = ( + teamName: string, + currentUser: CurrentUser | undefined +) => + currentUser?.teams_full?.find((t) => t.name === teamName)?.role === "owner"; From 53b482d5083ec9be2f745772a521e2497234fd69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 21 Nov 2025 13:26:54 +0200 Subject: [PATCH 4/4] Adjust contents on Settings tab of team settings page Use actual permission checks to show content instead of hardcoding it. Try to remove repetitive text on the page. If user can perform the action, only show the button to do so, and show clarification texts in the modal. Show why user can't perform an operation only if they actually can't do so. Change alert boxes from "danger" to "info", since it looked like there was some sort of error preventing user from performing the operations, while the system is working just as intended. Update the "how to disband team if team has packages" text to match (roughly) what's on the legacy website. Contacting Mythic does nothing. --- .../teams/team/tabs/Settings/Settings.tsx | 90 +++++++++++-------- 1 file changed, 54 insertions(+), 36 deletions(-) diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx index 3b17333fc..950efb302 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Settings/Settings.tsx @@ -28,6 +28,7 @@ import { ApiAction } from "@thunderstore/ts-api-react-actions"; import { type OutletContextShape } from "app/root"; import { makeTeamSettingsTabLoader } from "cyberstorm/utils/dapperClientLoaders"; +import { isTeamOwner } from "cyberstorm/utils/permissions"; import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; export const clientLoader = makeTeamSettingsTabLoader( @@ -59,24 +60,22 @@ export default function Settings() {

Leave team

-

Leave your team

+

+ Resign from the team +

- - You cannot currently leave this team as you are it's last - owner. - -

- If you are the owner of the team, you can only leave if the - team has another owner assigned. -

- + {resolvedPermissions.can_leave_team ? ( + + ) : ( + + )}
@@ -84,25 +83,22 @@ export default function Settings() {

Disband team

- Disband your team completely + Remove the team completely

- - You cannot currently disband this team as it has packages. - -

You are about to disband the team {teamName}.

-

- Be aware you can currently only disband teams with no - packages. If you need to archive a team with existing pages, - contact Mythic#0001 on the Thunderstore Discord. -

- + {resolvedPermissions.can_disband_team ? ( + + ) : isTeamOwner(teamName, outletContext.currentUser) ? ( + + ) : ( + + )}
@@ -112,6 +108,28 @@ export default function Settings() { ); } +const LastOwnerAlert = () => ( + + You cannot currently leave this team as you are its last owner. +
+ To leave the team, you need to assign another owner to it. Alternatively, + you can disband the whole team. +
+); + +const TeamHasPackagesAlert = () => ( + + You cannot currently disband this team as it has packages. +
+ If you need to archive this team, contact #support in the{" "} + Thunderstore Discord. +
+); + +const NotTeamOwnerAlert = () => ( + Only team owners can disband teams. +); + function LeaveTeamForm(props: { userName: string; teamName: string; @@ -144,7 +162,7 @@ function LeaveTeamForm(props: { - {teamName} + {teamName}. @@ -266,7 +284,7 @@ function DisbandTeamForm(props: { - {teamName} + {teamName}.